feat(updater): add auto-update UI with periodic checks and state guards

- Add green "Update app" / "Restart to update" button in header (TabBarActions)
- Enhance UpdateDialog: larger, dynamic buttons, scrollable release notes,
  version badge, "View on GitHub" link
- Add DashboardUpdateBanner: compact dismissible banner keyed by version
- Add periodic hourly update checks in UpdaterService with unref() timer
- Add dismissed version tracking in updateSlice (localStorage persistence)
- Add state transition guards in store listener: prevent periodic re-checks
  from resetting downloading/downloaded status back to available/checking
- Guard error events from overriding downloaded state (transient check failures)
This commit is contained in:
iliya 2026-03-22 14:00:44 +02:00
parent be38447c97
commit a9cdf20ca8
7 changed files with 269 additions and 32 deletions

View file

@ -18,6 +18,7 @@ const logger = createLogger('UpdaterService');
export class UpdaterService {
private mainWindow: BrowserWindow | null = null;
private periodicTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
autoUpdater.autoDownload = false;
@ -66,6 +67,27 @@ export class UpdaterService {
autoUpdater.quitAndInstall(true, true);
}
/**
* Start periodic update checks at the given interval (default: 1 hour).
* Uses unref() so the timer does not prevent process exit.
*/
startPeriodicCheck(intervalMs: number = 3_600_000): void {
this.stopPeriodicCheck();
this.periodicTimer = setInterval(() => void this.checkForUpdates(), intervalMs);
this.periodicTimer.unref();
logger.info(`Periodic update check started (interval: ${Math.round(intervalMs / 60_000)}min)`);
}
/**
* Stop periodic update checks.
*/
stopPeriodicCheck(): void {
if (this.periodicTimer !== null) {
clearInterval(this.periodicTimer);
this.periodicTimer = null;
}
}
private sendStatus(status: UpdaterStatus): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('updater:status', status);

View file

@ -3,22 +3,26 @@
*
* Prompts the user to download the update or dismiss it.
* Release notes (markdown from GitHub) are rendered with ReactMarkdown.
* Shows "Restart now" when the update has already been downloaded.
*/
import { useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import { isElectronMode } from '@renderer/api';
import { markdownComponents } from '@renderer/components/chat/markdownComponents';
import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { X } from 'lucide-react';
import { ExternalLink, X } from 'lucide-react';
import remarkGfm from 'remark-gfm';
export const UpdateDialog = (): React.JSX.Element | null => {
const showUpdateDialog = useStore((s) => s.showUpdateDialog);
const updateStatus = useStore((s) => s.updateStatus);
const availableVersion = useStore((s) => s.availableVersion);
const releaseNotes = useStore((s) => s.releaseNotes);
const downloadUpdate = useStore((s) => s.downloadUpdate);
const installUpdate = useStore((s) => s.installUpdate);
const dismissUpdateDialog = useStore((s) => s.dismissUpdateDialog);
const dialogRef = useRef<HTMLDivElement>(null);
@ -75,6 +79,21 @@ export const UpdateDialog = (): React.JSX.Element | null => {
if (!showUpdateDialog) return null;
const isDownloaded = updateStatus === 'downloaded';
const releaseUrl = availableVersion
? `https://github.com/777genius/claude_agent_teams_ui/releases/tag/v${availableVersion}`
: null;
const openReleaseOnGitHub = (): void => {
if (!releaseUrl) return;
if (isElectronMode()) {
void window.electronAPI.openExternal(releaseUrl);
} else {
window.open(releaseUrl, '_blank');
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
@ -87,7 +106,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
/>
<div
ref={dialogRef}
className="relative mx-4 w-full max-w-sm rounded-md border p-4 shadow-lg"
className="relative mx-4 w-full max-w-lg rounded-md border p-5 shadow-lg"
role="dialog"
aria-modal="true"
aria-label="Update available"
@ -107,25 +126,33 @@ export const UpdateDialog = (): React.JSX.Element | null => {
<div className="mb-3 pr-8">
<h2 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
Update Available
{isDownloaded ? 'Update Ready' : 'Update Available'}
</h2>
{availableVersion && (
<div className="mt-1 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
<div
className="mt-1.5 inline-block rounded-full px-2.5 py-0.5 text-xs font-medium"
style={{
backgroundColor: isDownloaded
? 'rgba(34, 197, 94, 0.15)'
: 'rgba(59, 130, 246, 0.15)',
color: isDownloaded ? '#4ade80' : '#60a5fa',
}}
>
v{availableVersion}
</div>
)}
</div>
{/* Release notes */}
{releaseNotes && (
<div
className="prose prose-sm prose-invert mb-4 max-h-60 overflow-y-auto rounded border p-3 text-xs"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<div
className="prose prose-sm prose-invert mb-4 max-h-[60vh] overflow-y-auto rounded border p-3 text-xs"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
{releaseNotes ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={REHYPE_PLUGINS}
@ -133,11 +160,26 @@ export const UpdateDialog = (): React.JSX.Element | null => {
>
{releaseNotes}
</ReactMarkdown>
</div>
)}
) : (
<p className="italic" style={{ color: 'var(--color-text-muted)' }}>
No release notes available.
</p>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-2">
<div className="flex items-center gap-2">
{releaseUrl && (
<button
onClick={openReleaseOnGitHub}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs transition-colors hover:bg-white/5"
style={{ color: 'var(--color-text-muted)' }}
>
<ExternalLink className="size-3" />
View on GitHub
</button>
)}
<div className="flex-1" />
<button
onClick={dismissUpdateDialog}
className="rounded-md border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-white/5"
@ -148,12 +190,21 @@ export const UpdateDialog = (): React.JSX.Element | null => {
>
Later
</button>
<button
onClick={downloadUpdate}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-500"
>
Download
</button>
{isDownloaded ? (
<button
onClick={installUpdate}
className="rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-500"
>
Restart now
</button>
) : (
<button
onClick={downloadUpdate}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-500"
>
Download
</button>
)}
</div>
</div>
</div>

View file

@ -0,0 +1,84 @@
/**
* DashboardUpdateBanner - Compact banner on the dashboard when a new app version is available.
*
* Single-line banner: icon + "New version available vX.Y.Z" + action button + dismiss X.
* Dismissible with localStorage persistence keyed by version
* dismissed for v1.3.0 won't suppress the banner when v1.4.0 arrives.
*/
import { useEffect, useState } from 'react';
import { useStore } from '@renderer/store';
import { ArrowUpCircle, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
const DISMISSED_KEY = 'update:dashboard-dismissed-version';
export const DashboardUpdateBanner = (): React.JSX.Element | null => {
const { updateStatus, availableVersion, openUpdateDialog, installUpdate } = useStore(
useShallow((s) => ({
updateStatus: s.updateStatus,
availableVersion: s.availableVersion,
openUpdateDialog: s.openUpdateDialog,
installUpdate: s.installUpdate,
}))
);
const [dismissed, setDismissed] = useState(() => {
const saved = localStorage.getItem(DISMISSED_KEY);
return saved === availableVersion;
});
// Reset dismissed state when a new version becomes available
useEffect(() => {
const saved = localStorage.getItem(DISMISSED_KEY);
setDismissed(saved === availableVersion);
}, [availableVersion]);
if (dismissed) return null;
if (updateStatus !== 'available' && updateStatus !== 'downloaded') return null;
const handleDismiss = (): void => {
if (availableVersion) {
localStorage.setItem(DISMISSED_KEY, availableVersion);
}
setDismissed(true);
};
const isDownloaded = updateStatus === 'downloaded';
return (
<div
className="mb-6 flex items-center gap-3 rounded-lg border px-4 py-3"
style={{
borderColor: 'rgba(34, 197, 94, 0.3)',
backgroundColor: 'rgba(34, 197, 94, 0.04)',
}}
>
<ArrowUpCircle className="size-4 shrink-0 text-green-400" />
<span className="flex-1 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
New version available{' '}
{availableVersion && (
<span className="font-medium text-green-400">v{availableVersion}</span>
)}
</span>
<button
onClick={isDownloaded ? installUpdate : openUpdateDialog}
className="shrink-0 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'rgba(34, 197, 94, 0.3)',
color: '#4ade80',
}}
>
{isDownloaded ? 'Restart now' : 'View details'}
</button>
<button
onClick={handleDismiss}
className="shrink-0 rounded p-0.5 transition-colors hover:bg-white/10"
style={{ color: 'var(--color-text-muted)' }}
>
<X className="size-3.5" />
</button>
</div>
);
};

View file

@ -30,6 +30,7 @@ import { formatDistanceToNow } from 'date-fns';
import { Command, FolderGit2, FolderOpen, GitBranch, GitFork, Search, Users } from 'lucide-react';
import { CliStatusBanner } from './CliStatusBanner';
import { DashboardUpdateBanner } from './DashboardUpdateBanner';
import type { RepositoryGroup } from '@renderer/types/data';
@ -747,6 +748,9 @@ export const DashboardView = (): React.JSX.Element => {
{/* Content */}
<div className="relative mx-auto max-w-5xl px-8 py-12">
{/* App update banner */}
<DashboardUpdateBanner />
{/* CLI Status Banner */}
<CliStatusBanner />

View file

@ -7,6 +7,7 @@
import { useMemo, useState } from 'react';
import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { Bell, PanelRight, Puzzle, Settings, Users } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -25,6 +26,8 @@ export const TabBarActions = (): React.JSX.Element => {
tabSessionData,
sidebarCollapsed,
toggleSidebar,
updateStatus,
openUpdateDialog,
} = useStore(
useShallow((s) => ({
unreadCount: s.unreadCount,
@ -37,6 +40,8 @@ export const TabBarActions = (): React.JSX.Element => {
tabSessionData: s.tabSessionData,
sidebarCollapsed: s.sidebarCollapsed,
toggleSidebar: s.toggleSidebar,
updateStatus: s.updateStatus,
openUpdateDialog: s.openUpdateDialog,
}))
);
@ -47,6 +52,7 @@ export const TabBarActions = (): React.JSX.Element => {
const [githubHover, setGithubHover] = useState(false);
const [settingsHover, setSettingsHover] = useState(false);
const [expandHover, setExpandHover] = useState(false);
const [updateHover, setUpdateHover] = useState(false);
// Derive active tab and session detail for MoreMenu
const activeTab = useMemo(
@ -62,6 +68,31 @@ export const TabBarActions = (): React.JSX.Element => {
className="ml-2 flex shrink-0 items-center gap-1"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{/* Update app button — only visible when update available or downloaded */}
{(updateStatus === 'available' || updateStatus === 'downloaded') && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={openUpdateDialog}
onMouseEnter={() => setUpdateHover(true)}
onMouseLeave={() => setUpdateHover(false)}
className="rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors"
style={{
color: updateHover ? '#4ade80' : '#22c55e',
backgroundColor: updateHover ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
}}
>
{updateStatus === 'downloaded' ? 'Restart to update' : 'Update app'}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{updateStatus === 'downloaded'
? 'Update downloaded, restart to apply'
: 'New version available'}
</TooltipContent>
</Tooltip>
)}
{/* Notifications bell icon */}
<button
onClick={openNotificationsTab}

View file

@ -668,20 +668,40 @@ export function initializeNotificationListeners(): () => void {
const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => {
const s = status as UpdaterStatus;
switch (s.type) {
case 'checking':
useStore.setState({ updateStatus: 'checking' });
case 'checking': {
// Don't downgrade status if we already know about an available/downloaded update
// or if a download is in progress (prevents UI flash during periodic re-checks)
const current = useStore.getState().updateStatus;
if (current !== 'available' && current !== 'downloaded' && current !== 'downloading') {
useStore.setState({ updateStatus: 'checking' });
}
break;
case 'available':
}
case 'available': {
// Don't downgrade from downloading/downloaded — the update is already
// in progress or ready to install (prevents periodic re-check from
// resetting the state after download completes)
const currentStatus = useStore.getState().updateStatus;
if (currentStatus === 'downloading' || currentStatus === 'downloaded') {
break;
}
const dismissed = useStore.getState().dismissedUpdateVersion;
useStore.setState({
updateStatus: 'available',
availableVersion: s.version ?? null,
releaseNotes: s.releaseNotes ?? null,
showUpdateDialog: true,
showUpdateDialog: s.version !== dismissed,
});
break;
case 'not-available':
useStore.setState({ updateStatus: 'not-available' });
}
case 'not-available': {
// Don't reset status if update is already downloading or downloaded
const notAvailCurrent = useStore.getState().updateStatus;
if (notAvailCurrent !== 'downloading' && notAvailCurrent !== 'downloaded') {
useStore.setState({ updateStatus: 'not-available' });
}
break;
}
case 'downloading':
useStore.setState({
updateStatus: 'downloading',
@ -695,12 +715,19 @@ export function initializeNotificationListeners(): () => void {
availableVersion: s.version ?? useStore.getState().availableVersion,
});
break;
case 'error':
case 'error': {
// Don't lose downloaded state due to a transient check error —
// the update is already on disk and ready to install
const errCurrent = useStore.getState().updateStatus;
if (errCurrent === 'downloaded') {
break;
}
useStore.setState({
updateStatus: 'error',
updateError: s.error ?? 'Unknown error',
});
break;
}
}
});
if (typeof cleanup === 'function') {

View file

@ -10,6 +10,8 @@ import type { StateCreator } from 'zustand';
const logger = createLogger('Store:update');
const DISMISSED_VERSION_KEY = 'update:dismissed-version';
// =============================================================================
// Slice Interface
// =============================================================================
@ -30,11 +32,13 @@ export interface UpdateSlice {
updateError: string | null;
showUpdateDialog: boolean;
showUpdateBanner: boolean;
dismissedUpdateVersion: string | null;
// Actions
checkForUpdates: () => void;
downloadUpdate: () => void;
installUpdate: () => void;
openUpdateDialog: () => void;
dismissUpdateDialog: () => void;
dismissUpdateBanner: () => void;
}
@ -43,7 +47,7 @@ export interface UpdateSlice {
// Slice Creator
// =============================================================================
export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (set) => ({
export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (set, get) => ({
// Initial state
updateStatus: 'idle',
availableVersion: null,
@ -52,12 +56,16 @@ export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (s
updateError: null,
showUpdateDialog: false,
showUpdateBanner: false,
dismissedUpdateVersion: localStorage.getItem(DISMISSED_VERSION_KEY),
checkForUpdates: () => {
set({ updateStatus: 'checking', updateError: null });
api.updater.check().catch((error) => {
logger.error('Failed to check for updates:', error);
set({ updateStatus: 'error', updateError: error instanceof Error ? error.message : 'Check failed' });
set({
updateStatus: 'error',
updateError: error instanceof Error ? error.message : 'Check failed',
});
});
},
@ -74,8 +82,18 @@ export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (s
});
},
openUpdateDialog: () => {
set({ showUpdateDialog: true });
},
dismissUpdateDialog: () => {
set({ showUpdateDialog: false });
const version = get().availableVersion;
if (version) {
localStorage.setItem(DISMISSED_VERSION_KEY, version);
set({ showUpdateDialog: false, dismissedUpdateVersion: version });
} else {
set({ showUpdateDialog: false });
}
},
dismissUpdateBanner: () => {