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:
parent
be38447c97
commit
a9cdf20ca8
7 changed files with 269 additions and 32 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
84
src/renderer/components/dashboard/DashboardUpdateBanner.tsx
Normal file
84
src/renderer/components/dashboard/DashboardUpdateBanner.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue