From a18ac1958df97c1b21301e87c8152064981ee304 Mon Sep 17 00:00:00 2001 From: Matt OD Date: Mon, 27 Apr 2026 11:00:03 -0700 Subject: [PATCH] fix(ui): surface update errors in AppShell with retry (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, when an update download or install failed, useUpdateCheck caught the error into local state but AppShell never read it — the "Update Available" button silently flipped back to idle. This is how the v0.2.0 sandbox bug stayed invisible for weeks. Changes: - useUpdateCheck.ts: add `phase` state ('idle' | 'checking' | 'downloading' | 'relaunching') wired to the Tauri updater plugin's onEvent callback. Add `retry()` helper. Reset error on retry so stale text doesn't shadow a fresh attempt. Keep `checking` and `installing` as derived booleans for backward compat. - AppShell.tsx: destructure `error`, `phase`, `retry`. When an error is present, render an inline alert chip in place of the update button — friendly summary (sandbox / signature / network / generic), verbatim error in `title` for support diagnosis, and a "Try again" action. Phase-driven button labels: 'Update Available' / 'Downloading…' / 'Relaunching…'. No src-tauri/ changes. No new components or toast system — the existing app has no toast infra and one error type doesn't justify adding it. Verification: - `npm run type-check` clean - `npm run build` clean (vite + tsc) - Failure-path simulation (network, signature mismatch) requires running the app — best done at PR review or in next dev session. Closes #54 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/layout/AppShell.tsx | 62 ++++++++++++++++++++++++++++-- src/hooks/useUpdateCheck.ts | 46 +++++++++++++++++----- 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 8a6314c..7e23349 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,12 +1,28 @@ import { type ReactNode } from 'react'; import { useLayout } from '../../contexts/LayoutContext'; import { useNetwork } from '../../hooks'; -import { useUpdateCheck } from '../../hooks/useUpdateCheck'; +import { useUpdateCheck, type UpdatePhase } from '../../hooks/useUpdateCheck'; import { OfflineIndicator } from '../shared'; import { TabSwitcher, ConversationSidebar } from '../conversations'; import { EmployeePanel } from '../employees'; import { TrialBanner } from '../trial/TrialBanner'; +function updateButtonLabel(phase: UpdatePhase): string { + switch (phase) { + case 'downloading': return 'Downloading…'; + case 'relaunching': return 'Relaunching…'; + default: return 'Update Available'; + } +} + +function friendlyUpdateError(raw: string): string { + const lower = raw.toLowerCase(); + if (lower.includes('sandbox')) return 'Update blocked by sandbox'; + if (lower.includes('signature') || lower.includes('sig ')) return 'Update signature invalid'; + if (lower.includes('network') || lower.includes('fetch') || lower.includes('request')) return 'Network error during update'; + return 'Update failed'; +} + interface AppShellProps { children: ReactNode; contextPanel?: ReactNode; @@ -79,7 +95,7 @@ function IconButton({ export function AppShell({ children, contextPanel, onSettingsClick }: AppShellProps) { const { sidebarOpen, contextPanelOpen, sidebarTab, toggleSidebar, toggleContextPanel, setSidebarTab } = useLayout(); const { isOnline, errorMessage, checkNow, isChecking } = useNetwork(); - const { updateAvailable, installing, installUpdate } = useUpdateCheck(); + const { updateAvailable, phase, installing, error: updateError, installUpdate, retry: retryUpdate } = useUpdateCheck(); return (
@@ -115,7 +131,45 @@ export function AppShell({ children, contextPanel, onSettingsClick }: AppShellPr />
- {updateAvailable && ( + {updateError ? ( +
+ + {friendlyUpdateError(updateError)} + +
+ ) : updateAvailable && ( )} diff --git a/src/hooks/useUpdateCheck.ts b/src/hooks/useUpdateCheck.ts index 1d1b4a4..52aec90 100644 --- a/src/hooks/useUpdateCheck.ts +++ b/src/hooks/useUpdateCheck.ts @@ -2,18 +2,20 @@ import { useEffect, useState } from 'react'; import { check, Update } from '@tauri-apps/plugin-updater'; import { relaunch } from '@tauri-apps/plugin-process'; +export type UpdatePhase = 'idle' | 'checking' | 'downloading' | 'relaunching'; + export function useUpdateCheck() { const [updateAvailable, setUpdateAvailable] = useState(null); - const [checking, setChecking] = useState(false); - const [installing, setInstalling] = useState(false); + const [phase, setPhase] = useState('idle'); const [error, setError] = useState(null); useEffect(() => { - checkForUpdate(); + void checkForUpdate(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function checkForUpdate() { - setChecking(true); + setPhase('checking'); setError(null); try { const update = await check(); @@ -23,21 +25,47 @@ export function useUpdateCheck() { } catch (e) { setError(e instanceof Error ? e.message : 'Failed to check for updates'); } finally { - setChecking(false); + setPhase('idle'); } } async function installUpdate() { if (!updateAvailable) return; - setInstalling(true); + setError(null); + setPhase('downloading'); try { - await updateAvailable.downloadAndInstall(); + await updateAvailable.downloadAndInstall((progress) => { + if (progress.event === 'Finished') { + setPhase('relaunching'); + } + }); await relaunch(); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to install update'); - setInstalling(false); + setPhase('idle'); } } - return { updateAvailable, checking, installing, error, checkForUpdate, installUpdate }; + async function retry() { + setError(null); + if (updateAvailable) { + await installUpdate(); + } else { + await checkForUpdate(); + } + } + + const checking = phase === 'checking'; + const installing = phase === 'downloading' || phase === 'relaunching'; + + return { + updateAvailable, + phase, + checking, + installing, + error, + checkForUpdate, + installUpdate, + retry, + }; }