Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateButtonLabel doesn’t handle the 'checking' phase even though it’s part of UpdatePhase. If the button can ever be rendered while phase === 'checking' (e.g., a re-check while an update is already known), the label will incorrectly show “Update Available”. Consider adding an explicit 'checking' case (or ensure the update button cannot render while checking).

Suggested change
switch (phase) {
switch (phase) {
case 'checking': return 'Checking for updates…';

Copilot uses AI. Check for mistakes.
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;
Expand Down Expand Up @@ -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 (
<div className="h-screen flex flex-col bg-stone-50 overflow-hidden">
Expand Down Expand Up @@ -115,7 +131,45 @@ export function AppShell({ children, contextPanel, onSettingsClick }: AppShellPr
/>

<div className="flex items-center gap-1">
{updateAvailable && (
{updateError ? (
<div
role="alert"
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The update error chip uses role="alert" but lacks aria-live. OfflineIndicator uses role="alert" plus aria-live="polite" to ensure screen readers announce changes reliably. Consider adding aria-live="polite" here for consistency and accessibility.

Suggested change
role="alert"
role="alert"
aria-live="polite"

Copilot uses AI. Check for mistakes.
className="
flex items-center gap-2
px-2 py-1 mr-1
text-xs
bg-red-50
border border-red-200/60
rounded-md
"
title={updateError}
>
<svg
className="w-3.5 h-3.5 text-red-600 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<span className="text-red-700 font-medium">{friendlyUpdateError(updateError)}</span>
<button
type="button"
onClick={() => { void retryUpdate(); }}
className="
ml-1 px-1.5 py-0.5
text-red-700 hover:text-red-900
hover:bg-red-100
rounded
transition-colors duration-150
"
>
Try again
</button>
</div>
) : updateAvailable && (
<button
type="button"
onClick={() => { void installUpdate(); }}
Expand All @@ -130,7 +184,7 @@ export function AppShell({ children, contextPanel, onSettingsClick }: AppShellPr
disabled:opacity-70 disabled:cursor-wait
"
>
{installing ? 'Installing Update...' : 'Update Available'}
{updateButtonLabel(phase)}
</button>
)}

Expand Down
46 changes: 37 additions & 9 deletions src/hooks/useUpdateCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Update | null>(null);
const [checking, setChecking] = useState(false);
const [installing, setInstalling] = useState(false);
const [phase, setPhase] = useState<UpdatePhase>('idle');
const [error, setError] = useState<string | null>(null);

useEffect(() => {
checkForUpdate();
void checkForUpdate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
Comment on lines 12 to 15
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initial update check effect disables react-hooks/exhaustive-deps, which is the only occurrence of an ESLint disable in src/ right now. To avoid masking real dependency issues, consider restructuring this to not need the disable (e.g., inline an async IIFE inside the effect, or wrap checkForUpdate in useCallback and include it in the deps array).

Copilot uses AI. Check for mistakes.

async function checkForUpdate() {
setChecking(true);
setPhase('checking');
setError(null);
try {
const update = await check();
Expand All @@ -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,
};
}
Loading