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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Test User <test@example.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 */}
|
||||
<RateLimitIndicator />
|
||||
|
||||
{/* Update Banner - shows when app update is available */}
|
||||
<UpdateBanner />
|
||||
|
||||
{/* Bottom section with Settings, Help, and New Task */}
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Claude Code Status Badge */}
|
||||
|
||||
@@ -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<AppUpdateAvailableEvent | null>(null);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState<AppUpdateProgress | null>(null);
|
||||
const [isDownloaded, setIsDownloaded] = useState(false);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
|
||||
// Ref to track current version for stable callbacks
|
||||
const currentVersionRef = useRef<string | null>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-3 mb-3 rounded-lg border border-info/30 bg-info/10 p-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header with version and dismiss */}
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-info shrink-0" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{t("navigation:updateBanner.title")}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t("navigation:updateBanner.dismiss")}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version info */}
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
{t("navigation:updateBanner.version", { version: updateInfo.version })}
|
||||
</p>
|
||||
|
||||
{/* Download progress */}
|
||||
{isDownloading && downloadProgress && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground mb-1">
|
||||
<span>{t("navigation:updateBanner.downloading")}</span>
|
||||
<span>{Math.round(downloadProgress.percent)}%</span>
|
||||
</div>
|
||||
<div className="h-1 w-full bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-info transition-all duration-300"
|
||||
style={{ width: `${downloadProgress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{downloadError && (
|
||||
<p className="text-[10px] text-destructive mb-2">{downloadError}</p>
|
||||
)}
|
||||
|
||||
{/* Action button */}
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs gap-1.5"
|
||||
onClick={handleUpdate}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
{t("navigation:updateBanner.downloading")}
|
||||
</>
|
||||
) : isDownloaded ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{t("navigation:updateBanner.installAndRestart")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3" />
|
||||
{t("navigation:updateBanner.updateAndRestart")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user