/** * App auto-update prompt. * * Globally-mounted banner that surfaces the Tauri shell updater to the user. * The state machine, listeners, and auto-download orchestration all live in * `useAppUpdate`; this component is a thin presentational layer on top. * * UX contract: the banner is **silent during background download**. The user * only sees a prompt once bytes are staged (`ready_to_install`) — at which * point they can choose "Restart now" or "Later". Errors and the active * install/restart flow also surface visually. * * Visual conventions mirror `LocalAIDownloadSnackbar` — bottom-right portal, * stone-900 panel, primary gradient progress bar. */ import { useCallback, useState } from 'react'; import { createPortal } from 'react-dom'; import { useAppUpdate } from '../hooks/useAppUpdate'; import { formatBytes } from '../utils/localAiHelpers'; interface AppUpdatePromptProps { /** Override auto-check defaults (mostly for tests). */ autoCheck?: boolean; initialCheckDelayMs?: number; recheckIntervalMs?: number; autoDownload?: boolean; } /** * Phases that should surface a visible banner. Background-only phases * (`checking`, `available`, `downloading`) stay silent so the user isn't * pestered while we're working — the prompt only appears once the user * has a meaningful decision to make. */ function shouldShow(phase: ReturnType['phase']): boolean { return ( phase === 'ready_to_install' || phase === 'installing' || phase === 'restarting' || phase === 'error' ); } const AppUpdatePrompt = (props: AppUpdatePromptProps) => { const { phase, info, bytesDownloaded, totalBytes, error, install, download, reset } = useAppUpdate(props); const [dismissed, setDismissed] = useState(false); const [prevPhase, setPrevPhase] = useState(phase); // Re-show on every transition INTO a visible phase, even if the user had // dismissed a previous error/prompt earlier in the session. if (phase !== prevPhase) { setPrevPhase(phase); if (shouldShow(phase) && !shouldShow(prevPhase)) { setDismissed(false); } } const handleInstall = useCallback(() => { void install(); }, [install]); const handleLater = useCallback(() => { setDismissed(true); }, []); const handleRetryDownload = useCallback(() => { setDismissed(false); reset(); void download(); }, [reset, download]); const handleDismissError = useCallback(() => { reset(); setDismissed(true); }, [reset]); if (!shouldShow(phase) || dismissed) return null; const newVersion = info?.available_version ?? null; const currentVersion = info?.current_version ?? null; const percent = totalBytes != null && totalBytes > 0 ? Math.min(100, Math.round((bytesDownloaded / totalBytes) * 100)) : null; return createPortal(
{/* Header */}
{headerLabel(phase)}
{(phase === 'ready_to_install' || phase === 'error') && ( )}
{/* Body */}
{phase === 'ready_to_install' && ( <>

{newVersion ? `Version ${newVersion} is ready to install.` : 'A new version is ready to install.'} {currentVersion && ( Currently on {currentVersion}. )}

{info?.body && }

Restarting will close any open conversations briefly. The new build launches automatically.

)} {(phase === 'installing' || phase === 'restarting') && ( <>
{progressDetail(phase, bytesDownloaded, totalBytes, percent)} {newVersion && v{newVersion}}
)} {phase === 'error' && ( <>

{error ?? 'Something went wrong while updating.'}

)}
, document.body ); }; function headerLabel(phase: ReturnType['phase']): string { switch (phase) { case 'ready_to_install': return 'Update ready to install'; case 'installing': return 'Installing update'; case 'restarting': return 'Restarting…'; case 'error': return 'Update failed'; default: return 'Update'; } } function progressDetail( phase: ReturnType['phase'], downloaded: number, total: number | null, percent: number | null ): string { if (phase === 'installing') return 'Installing the new version…'; if (phase === 'restarting') return 'Relaunching the app…'; if (total != null && total > 0) { return `${formatBytes(downloaded)} / ${formatBytes(total)}`; } if (downloaded > 0) return `${formatBytes(downloaded)} downloaded`; return percent != null ? `${percent}%` : 'Working…'; } const ProgressBar = ({ indeterminate, percent, }: { indeterminate?: boolean; percent?: number | null; }) => { const indet = indeterminate || percent == null; return (
); }; const ReleaseNotes = ({ body }: { body: string }) => { const [expanded, setExpanded] = useState(false); const trimmed = body.trim(); if (!trimmed) return null; const isLong = trimmed.length > 160; const display = expanded || !isLong ? trimmed : `${trimmed.slice(0, 160).trimEnd()}…`; return (

{display}

{isLong && ( )}
); }; const UpdateIcon = ({ className }: { className?: string }) => ( ); const CloseIcon = ({ className }: { className?: string }) => ( ); export default AppUpdatePrompt;