From 4fd25b01d33c138f8528ef7a358da26fecb3b262 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Thu, 15 Jan 2026 21:03:16 +0100 Subject: [PATCH] 117-sidebar-update-banner (#1078) * auto-claude: subtask-1-1 - Create UpdateBanner component with 5-minute polling - Add UpdateBanner component that polls for updates every 5 minutes - Listen to onAppUpdateAvailable for push notifications - Show compact inline banner when update is available - Provide Update and Restart / Install and Restart buttons - Add dismiss functionality (session-scoped) - Add i18n translation keys for EN and FR - Integrate component into Sidebar above ClaudeCodeStatusBadge Co-Authored-By: Claude Opus 4.5 * fix(frontend): address PR review issues in UpdateBanner component - Use ref pattern for stable callbacks to prevent unnecessary re-renders - Remove updateInfo from useEffect/useCallback deps to avoid listener churn - Add null checks for installAppUpdate and downloadAppUpdate API calls - Fix race condition by resetting isDownloaded when new version found - Add type="button" to dismiss button for defensive coding Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Test User Co-authored-by: Claude Opus 4.5 --- .../src/renderer/components/Sidebar.tsx | 4 + .../src/renderer/components/UpdateBanner.tsx | 261 ++++++++++++++++++ .../shared/i18n/locales/en/navigation.json | 9 + .../shared/i18n/locales/fr/navigation.json | 9 + 4 files changed, 283 insertions(+) create mode 100644 apps/frontend/src/renderer/components/UpdateBanner.tsx diff --git a/apps/frontend/src/renderer/components/Sidebar.tsx b/apps/frontend/src/renderer/components/Sidebar.tsx index 5114b549..b7a55588 100644 --- a/apps/frontend/src/renderer/components/Sidebar.tsx +++ b/apps/frontend/src/renderer/components/Sidebar.tsx @@ -50,6 +50,7 @@ import { AddProjectModal } from './AddProjectModal'; import { GitSetupModal } from './GitSetupModal'; import { RateLimitIndicator } from './RateLimitIndicator'; import { ClaudeCodeStatusBadge } from './ClaudeCodeStatusBadge'; +import { UpdateBanner } from './UpdateBanner'; import type { Project, AutoBuildVersionInfo, GitStatus, ProjectEnvConfig } from '../../shared/types'; export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'gitlab-issues' | 'github-prs' | 'gitlab-merge-requests' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools'; @@ -328,6 +329,9 @@ export function Sidebar({ {/* Rate Limit Indicator - shows when Claude is rate limited */} + {/* Update Banner - shows when app update is available */} + + {/* Bottom section with Settings, Help, and New Task */}
{/* Claude Code Status Badge */} diff --git a/apps/frontend/src/renderer/components/UpdateBanner.tsx b/apps/frontend/src/renderer/components/UpdateBanner.tsx new file mode 100644 index 00000000..c7b2ad23 --- /dev/null +++ b/apps/frontend/src/renderer/components/UpdateBanner.tsx @@ -0,0 +1,261 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Download, X, RefreshCw } from "lucide-react"; +import { Button } from "./ui/button"; +import { cn } from "../lib/utils"; +import type { AppUpdateAvailableEvent, AppUpdateProgress } from "../../shared/types"; + +// Poll for updates every 5 minutes +const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000; + +interface UpdateBannerProps { + className?: string; +} + +/** + * Inline update notification banner for the sidebar. + * Shows when a new application update is available and provides + * quick access to download/install or dismiss. + */ +export function UpdateBanner({ className }: UpdateBannerProps) { + const { t } = useTranslation(["navigation", "common"]); + const [updateInfo, setUpdateInfo] = useState(null); + const [isDismissed, setIsDismissed] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const [downloadProgress, setDownloadProgress] = useState(null); + const [isDownloaded, setIsDownloaded] = useState(false); + const [downloadError, setDownloadError] = useState(null); + + // Ref to track current version for stable callbacks + const currentVersionRef = useRef(null); + + // Check for updates + const checkForUpdate = useCallback(async () => { + try { + if (!window.electronAPI?.checkAppUpdate) { + return; + } + + const result = await window.electronAPI.checkAppUpdate(); + if (result.success && result.data) { + const newVersion = result.data.version; + // New update available - show banner (unless same version already dismissed) + if (currentVersionRef.current !== newVersion) { + setIsDismissed(false); + // Reset downloaded state when a newer version is found + setIsDownloaded(false); + currentVersionRef.current = newVersion; + } + setUpdateInfo({ + version: newVersion, + releaseNotes: result.data.releaseNotes, + releaseDate: result.data.releaseDate, + }); + } + } catch (err) { + // Silent failure - update check is non-critical + } + }, []); + + // Check if there's already a downloaded update on mount + useEffect(() => { + const checkDownloaded = async () => { + try { + if (!window.electronAPI?.getDownloadedAppUpdate) { + return; + } + const result = await window.electronAPI.getDownloadedAppUpdate(); + if (result.success && result.data) { + currentVersionRef.current = result.data.version; + setUpdateInfo({ + version: result.data.version, + releaseNotes: result.data.releaseNotes, + releaseDate: result.data.releaseDate, + }); + setIsDownloaded(true); + } + } catch { + // Silent failure + } + }; + checkDownloaded(); + }, []); + + // Initial check and periodic polling + useEffect(() => { + checkForUpdate(); + + const interval = setInterval(() => { + checkForUpdate(); + }, UPDATE_CHECK_INTERVAL_MS); + + return () => clearInterval(interval); + }, [checkForUpdate]); + + // Listen for push notifications about updates + useEffect(() => { + if (!window.electronAPI?.onAppUpdateAvailable) { + return; + } + + const cleanup = window.electronAPI.onAppUpdateAvailable((info) => { + // New update notification - reset dismiss state if new version + if (currentVersionRef.current !== info.version) { + setIsDismissed(false); + currentVersionRef.current = info.version; + } + setUpdateInfo(info); + setIsDownloading(false); + setIsDownloaded(false); + setDownloadProgress(null); + setDownloadError(null); + }); + + return cleanup; + }, []); + + // Listen for download progress + useEffect(() => { + if (!window.electronAPI?.onAppUpdateProgress) { + return; + } + + const cleanup = window.electronAPI.onAppUpdateProgress((progress) => { + setDownloadProgress(progress); + }); + + return cleanup; + }, []); + + // Listen for download completed + useEffect(() => { + if (!window.electronAPI?.onAppUpdateDownloaded) { + return; + } + + const cleanup = window.electronAPI.onAppUpdateDownloaded(() => { + setIsDownloading(false); + setIsDownloaded(true); + setDownloadProgress(null); + }); + + return cleanup; + }, []); + + // Handle update and restart + const handleUpdate = async () => { + if (isDownloaded) { + // Already downloaded - just install + window.electronAPI?.installAppUpdate?.(); + return; + } + + // Start download + setIsDownloading(true); + setDownloadError(null); + + try { + if (!window.electronAPI?.downloadAppUpdate) { + setDownloadError(t("navigation:updateBanner.downloadError")); + setIsDownloading(false); + return; + } + const result = await window.electronAPI.downloadAppUpdate(); + if (!result.success) { + setDownloadError(result.error || t("navigation:updateBanner.downloadError")); + setIsDownloading(false); + } + } catch (error) { + setDownloadError(t("navigation:updateBanner.downloadError")); + setIsDownloading(false); + } + }; + + // Handle dismiss + const handleDismiss = () => { + setIsDismissed(true); + }; + + // Don't render if no update or dismissed + if (!updateInfo || isDismissed) { + return null; + } + + return ( +
+ {/* Header with version and dismiss */} +
+
+ + + {t("navigation:updateBanner.title")} + +
+ +
+ + {/* Version info */} +

+ {t("navigation:updateBanner.version", { version: updateInfo.version })} +

+ + {/* Download progress */} + {isDownloading && downloadProgress && ( +
+
+ {t("navigation:updateBanner.downloading")} + {Math.round(downloadProgress.percent)}% +
+
+
+
+
+ )} + + {/* Error message */} + {downloadError && ( +

{downloadError}

+ )} + + {/* Action button */} + +
+ ); +} diff --git a/apps/frontend/src/shared/i18n/locales/en/navigation.json b/apps/frontend/src/shared/i18n/locales/en/navigation.json index 37323518..c5409543 100644 --- a/apps/frontend/src/shared/i18n/locales/en/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/en/navigation.json @@ -30,6 +30,15 @@ "messages": { "initializeToCreateTasks": "Initialize Auto Claude to create tasks" }, + "updateBanner": { + "title": "Update Available", + "version": "Version {{version}} is ready", + "updateAndRestart": "Update and Restart", + "installAndRestart": "Install and Restart", + "downloading": "Downloading...", + "dismiss": "Dismiss", + "downloadError": "Failed to download update" + }, "claudeCode": { "checking": "Checking Claude Code...", "upToDate": "Claude Code is up to date", diff --git a/apps/frontend/src/shared/i18n/locales/fr/navigation.json b/apps/frontend/src/shared/i18n/locales/fr/navigation.json index 1a9327f8..19646a2f 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/fr/navigation.json @@ -30,6 +30,15 @@ "messages": { "initializeToCreateTasks": "Initialisez Auto Claude pour créer des tâches" }, + "updateBanner": { + "title": "Mise à jour disponible", + "version": "Version {{version}} est prête", + "updateAndRestart": "Mettre à jour et redémarrer", + "installAndRestart": "Installer et redémarrer", + "downloading": "Téléchargement...", + "dismiss": "Ignorer", + "downloadError": "Échec du téléchargement de la mise à jour" + }, "claudeCode": { "checking": "Vérification de Claude Code...", "upToDate": "Claude Code est à jour",