diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 53075c5d49bb..a9d585441ff1 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -192,7 +192,12 @@ Notes: semantics when unifying appearance. - Respect `AppShell` overlay ownership. Persistent terminal/content layers, route overlays, dialogs, and boot surfaces must not compete through ad-hoc - z-index literals. + z-index literals. Pick a rung of the ladder in `styles.css` instead — + `--z-modal-backdrop` / `--z-modal` / `--z-modal-popover`, `--z-over-modal` + (toasts, tooltips, command surfaces) and `--z-over-modal-content`, + `--z-switcher-backdrop` / `--z-switcher`, then the boot chain + `--z-connecting` → `--z-onboarding` → `--z-setup` → `--z-crash`. Plain + `z-10`/`z-20` are still right for stacking *within* one component. ## Iconography & brand diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 1dabc674d043..3427d42c9bf3 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -230,6 +230,10 @@ export function buildAppEnv(sandbox: Sandbox, extra: Record = {} HERMES_DESKTOP_IGNORE_EXISTING: '1', HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT, HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`, + // `app.close()` in teardown must exit even when a spec leaves a turn + // mid-flight — otherwise the quit confirmation waits on a click that no + // one is there to make, and the worker dies on a teardown timeout. + HERMES_DESKTOP_SKIP_QUIT_CONFIRM: '1', // Clear dev-server override — we want the built dist/, not a vite server. // The dev-server check in main.ts looks for this env var; if it's set, // it loads from the vite URL instead of the local file. diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d88e1f787601..7ac24c57f8ed 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -146,6 +146,7 @@ import { rehomePrimaryConnection } from './primary-connection-rehome' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import { fetchPrimaryProfileSessions } from './profile-session-routing' import { createQuickEntryShortcut, quickEntryWindowBounds, sanitizeQuickEntrySettings } from './quick-entry' +import { type ActiveWork, mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard' import * as remoteLifecycle from './remote-lifecycle' import { RemoteLivenessTracker, @@ -607,6 +608,10 @@ const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4 const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}` const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1' const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || '' +// Automated teardown (Playwright's app.close(), harness scripts) quits with +// nobody to answer a modal, so the active-work confirmation would hang the +// caller instead of letting the process exit. Force quits set this. +const SKIP_QUIT_CONFIRM = process.env.HERMES_DESKTOP_SKIP_QUIT_CONFIRM === '1' const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) @@ -2510,6 +2515,12 @@ let updateInFlight = false // actually dies and the hand-off script can proceed immediately. let isQuittingForHandoff = false +// Quit-guard latches: one while the confirmation is on screen (a second +// Cmd-Q must not stack dialogs), one after the user has said "quit anyway" +// (the app.quit() that follows re-enters before-quit and must pass through). +let quitPromptOpen = false +let quitConfirmedWithActiveWork = false + // Resolve the staged updater binary. The Tauri installer copies itself to // HERMES_HOME/hermes-setup.exe on a successful install (see // apps/bootstrap-installer paths::copy_self_to_hermes_home). That binary owns @@ -10134,6 +10145,20 @@ ipcMain.handle('hermes:watchPreviewFile', (_event, url) => watchPreviewFile(Stri ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWatch(String(id || ''))) +// Each renderer reports the turns it has in flight; the quit guard reads the +// merged picture. Keyed by webContents id so a closed window stops counting. +const activeWorkByWebContents = new Map() + +ipcMain.on('hermes:active-work', (event, payload) => { + const id = event.sender.id + + if (!activeWorkByWebContents.has(id)) { + event.sender.once('destroyed', () => activeWorkByWebContents.delete(id)) + } + + activeWorkByWebContents.set(id, normalizeActiveWork(payload)) +}) + ipcMain.on('hermes:titlebar-theme', (_event, payload) => { if (!payload || !isHexColor(payload.background) || !isHexColor(payload.foreground)) { return @@ -11399,7 +11424,58 @@ function configureSpellChecker() { } } +// Ask before a quit kills a turn in flight. True when the quit was intercepted +// and the confirmation is on screen; "Quit Anyway" re-enters before-quit with +// the latch set and falls straight through to the teardown below. +function heldQuitForActiveWork(event: Electron.Event): boolean { + if (SKIP_QUIT_CONFIRM || quitConfirmedWithActiveWork || quitPromptOpen) { + return false + } + + const prompt = quitPromptFor(mergeActiveWork(activeWorkByWebContents.values()), isQuittingForHandoff) + const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] + + if (!prompt || !parent || parent.isDestroyed()) { + return false + } + + event.preventDefault() + quitPromptOpen = true + + void dialog + .showMessageBox(parent, { + buttons: ['Keep Running', 'Quit Anyway'], + cancelId: 0, + defaultId: 0, + detail: prompt.detail, + message: prompt.message, + type: 'question' + }) + .then(({ response }) => { + quitPromptOpen = false + + if (response === 1) { + quitConfirmedWithActiveWork = true + app.quit() + } + }) + .catch(() => { + // A dialog we can't show must not become a quit we can't perform. + quitPromptOpen = false + quitConfirmedWithActiveWork = true + app.quit() + }) + + return true +} + app.on('before-quit', event => { + // Runs ahead of every teardown below, so "Keep Running" leaves the app + // exactly as it was. + if (heldQuitForActiveWork(event)) { + return + } + if ((sshConnections.size > 0 || sshBootstrapCoordinator.promises().length > 0) && !sshQuitTeardownDone) { event.preventDefault() sshBootstrapCoordinator.cancelAll() diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 99df85abd4c1..059a03986afe 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -114,6 +114,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir), watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url), stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id), + setActiveWork: payload => ipcRenderer.send('hermes:active-work', payload), setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload), setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode), setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload), diff --git a/apps/desktop/electron/quit-guard.test.ts b/apps/desktop/electron/quit-guard.test.ts new file mode 100644 index 000000000000..8888f99b40f4 --- /dev/null +++ b/apps/desktop/electron/quit-guard.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard' + +test('normalizeActiveWork drops junk and keeps the count at least the title count', () => { + assert.deepEqual(normalizeActiveWork(null), { count: 0, titles: [] }) + assert.deepEqual(normalizeActiveWork({ count: 'many', titles: 'nope' }), { count: 0, titles: [] }) + assert.deepEqual(normalizeActiveWork({ count: -3, titles: [' Fix login ', '', 7] }), { + count: 1, + titles: ['Fix login'] + }) +}) + +test('normalizeActiveWork keeps untitled sessions in the count', () => { + assert.deepEqual(normalizeActiveWork({ count: 3, titles: ['Fix login'] }), { count: 3, titles: ['Fix login'] }) +}) + +test('mergeActiveWork de-dupes a session two windows both report', () => { + const merged = mergeActiveWork([ + { count: 2, titles: ['Fix login', 'Ship docs'] }, + { count: 1, titles: ['Fix login'] } + ]) + + assert.deepEqual(merged, { count: 2, titles: ['Fix login', 'Ship docs'] }) +}) + +test('quitPromptFor stays out of the way when nothing is running', () => { + assert.equal(quitPromptFor({ count: 0, titles: [] }, false), null) +}) + +test('quitPromptFor stays out of the way during an update handoff', () => { + assert.equal(quitPromptFor({ count: 2, titles: ['Fix login'] }, true), null) +}) + +test('quitPromptFor names the running chats', () => { + const prompt = quitPromptFor({ count: 2, titles: ['Fix login', 'Ship docs'] }, false) + + assert.ok(prompt) + assert.equal(prompt.message, 'Hermes is still working on 2 chats.') + assert.ok(prompt.detail.includes('• Fix login')) + assert.ok(prompt.detail.includes('• Ship docs')) +}) + +test('quitPromptFor summarizes past the list cap and counts untitled work', () => { + const prompt = quitPromptFor({ count: 9, titles: ['a', 'b', 'c', 'd', 'e', 'f'] }, false) + + assert.ok(prompt) + assert.equal(prompt.message, 'Hermes is still working on 9 chats.') + assert.ok(prompt.detail.includes('• d')) + assert.ok(!prompt.detail.includes('• e')) + assert.ok(prompt.detail.includes('• 5 more')) +}) + +test('quitPromptFor speaks singular for one chat', () => { + const prompt = quitPromptFor({ count: 1, titles: [] }, false) + + assert.ok(prompt) + assert.equal(prompt.message, 'Hermes is still working on 1 chat.') + assert.ok(prompt.detail.includes('mid-turn')) +}) diff --git a/apps/desktop/electron/quit-guard.ts b/apps/desktop/electron/quit-guard.ts new file mode 100644 index 000000000000..e2bb40e59da5 --- /dev/null +++ b/apps/desktop/electron/quit-guard.ts @@ -0,0 +1,92 @@ +// Quitting with a turn in flight kills the backend mid-tool-call: the work is +// lost, and anything the agent had half-written to disk stays half-written. +// Renderers publish what they're running; the main process asks before it lets +// that go. The decision + copy live here (pure, testable) so main.ts only owns +// the IPC and the dialog call. + +const MAX_LISTED = 4 + +export interface ActiveWork { + /** Titles of sessions running a turn. Untitled sessions contribute a count only. */ + titles: string[] + /** Running turns, including untitled ones — always >= titles.length. */ + count: number +} + +export const NO_ACTIVE_WORK: ActiveWork = { count: 0, titles: [] } + +/** Coerce an IPC payload from an untrusted renderer into an ActiveWork. */ +export function normalizeActiveWork(payload: unknown): ActiveWork { + if (!payload || typeof payload !== 'object') { + return NO_ACTIVE_WORK + } + + const raw = payload as { count?: unknown; titles?: unknown } + + const titles = Array.isArray(raw.titles) + ? raw.titles + .filter((title): title is string => typeof title === 'string') + .map(title => title.trim()) + .filter(Boolean) + : [] + + const count = typeof raw.count === 'number' && Number.isFinite(raw.count) ? Math.max(0, Math.floor(raw.count)) : 0 + + return { count: Math.max(count, titles.length), titles } +} + +/** Merge every window's report into one. Windows can show the same session. */ +export function mergeActiveWork(reports: Iterable): ActiveWork { + const titles: string[] = [] + let count = 0 + + for (const report of reports) { + count = Math.max(count, report.count) + + for (const title of report.titles) { + if (!titles.includes(title)) { + titles.push(title) + } + } + } + + return { count: Math.max(count, titles.length), titles } +} + +export interface QuitPrompt { + detail: string + message: string +} + +/** + * The confirmation to show, or null when quitting should just proceed. + * + * `quittingForHandoff` covers the update / swap / uninstall relaunches: those + * are the app replacing itself, not the user walking away, and a modal there + * would strand the detached script waiting on a PID that never exits. + */ +export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean): null | QuitPrompt { + if (quittingForHandoff || work.count < 1) { + return null + } + + const listed = work.titles.slice(0, MAX_LISTED) + const remaining = work.count - listed.length + const lines = listed.map(title => `• ${title}`) + + if (remaining > 0) { + lines.push(remaining === 1 ? '• 1 more' : `• ${remaining} more`) + } + + return { + detail: [ + lines.join('\n'), + lines.length > 0 ? '' : null, + 'Quitting stops the agent mid-turn. Any work it has not finished writing is lost.' + ] + .filter(line => line !== null) + .join('\n') + .trim(), + message: work.count === 1 ? 'Hermes is still working on 1 chat.' : `Hermes is still working on ${work.count} chats.` + } +} diff --git a/apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx b/apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx index c3870be01357..f07a3c04310b 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx @@ -119,7 +119,7 @@ export function BaseBranchPicker({ {parts.after} - + (searchValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}> diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index e86bd2415e39..55e19fc9d665 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -875,13 +875,13 @@ export function CommandPalette() { {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */} - + {t.commandCenter.paletteTitle} diff --git a/apps/desktop/src/app/pet-generate/components/provider-picker.tsx b/apps/desktop/src/app/pet-generate/components/provider-picker.tsx index 3279d7758aa6..dad76c78d57e 100644 --- a/apps/desktop/src/app/pet-generate/components/provider-picker.tsx +++ b/apps/desktop/src/app/pet-generate/components/provider-picker.tsx @@ -31,9 +31,9 @@ export function ProviderPicker() { - {/* The picker lives inside the pet-gen Dialog (z-130) and portals to body, - so lift its menu above the dialog or it opens behind it. */} - + {/* The picker lives inside the pet-gen Dialog and portals to body, so its + menu needs the rung above the modal or it opens behind the dialog. */} + {providers.map(provider => ( {/* Transparent click-catcher: click-away closes, but no dim/blur. */}
{ e.preventDefault() closeSwitcher() @@ -56,7 +56,7 @@ export function SessionSwitcher() { className={cn( HUD_POSITION, HUD_SURFACE, - 'dt-portal-scrollbar z-[220] max-h-[min(22rem,64vh)] w-[min(19rem,calc(100vw-2rem))] select-none overflow-y-auto p-1' + 'dt-portal-scrollbar z-(--z-switcher) max-h-[min(22rem,64vh)] w-[min(19rem,calc(100vw-2rem))] select-none overflow-y-auto p-1' )} > {sessions.map((session, i) => { diff --git a/apps/desktop/src/components/assistant-ui/thread/status.tsx b/apps/desktop/src/components/assistant-ui/thread/status.tsx index b239f0fb8a8f..5b9a5837c265 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.tsx @@ -142,7 +142,11 @@ export const StreamStallIndicator: FC = () => { return `${s.message.content.length}:${textLength}` }) - const [stalled, setStalled] = useState(false) + // Timestamp of the activity that preceded the current quiet spell, set once + // the spell qualifies as a stall. Holding the timestamp (not a boolean) is + // what lets the timer read "quiet for 12s" rather than the age of this + // component, which is the whole turn so far. + const [quietSince, setQuietSince] = useState(undefined) const compacting = useStore($compactionActive) const turnTimerKey = useActiveTurnTimerKey() // A pending clarify / approval / sudo / secret means the turn is paused on the @@ -151,14 +155,18 @@ export const StreamStallIndicator: FC = () => { const awaitingInput = useStore($activeSessionAwaitingInput) useEffect(() => { - setStalled(false) - const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000) + setQuietSince(undefined) + const seenAt = Date.now() + const id = window.setTimeout(() => setQuietSince(seenAt), STREAM_STALL_S * 1000) return () => window.clearTimeout(id) }, [activity]) - const active = (stalled || compacting) && !awaitingInput - const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined) + const active = (quietSince !== undefined || compacting) && !awaitingInput + + // Compaction owns the whole turn, so it keeps counting from the turn's start; + // a plain stall counts from the last thing the stream produced. + const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined, compacting ? undefined : quietSince) if (!active) { return null diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index ea5ca27a1066..f07366f2898a 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -284,7 +284,7 @@ export function BootFailureOverlay() { if (view === 'connect') { return ( -
+
{/* Subtle back affordance (projects/overlay idiom): muted → foreground on hover, no divider. */} @@ -307,7 +307,7 @@ export function BootFailureOverlay() { } return ( -
+
diff --git a/apps/desktop/src/components/chat/activity-timer.test.tsx b/apps/desktop/src/components/chat/activity-timer.test.tsx index acc70a99ed06..4768f60c56ef 100644 --- a/apps/desktop/src/components/chat/activity-timer.test.tsx +++ b/apps/desktop/src/components/chat/activity-timer.test.tsx @@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { __resetElapsedTimerRegistryForTests, useElapsedSeconds } from './activity-timer' -function Probe({ active, timerKey }: { active: boolean; timerKey?: string }) { - const elapsed = useElapsedSeconds(active, timerKey) +function Probe({ active, since, timerKey }: { active: boolean; since?: number; timerKey?: string }) { + const elapsed = useElapsedSeconds(active, timerKey, since) return {elapsed} } @@ -40,4 +40,30 @@ describe('useElapsedSeconds', () => { expect(screen.getByTestId('elapsed').textContent).toBe('8') }) + + it('counts from an explicit epoch rather than mount time', () => { + const mountedAt = Date.now() + + act(() => { + vi.advanceTimersByTime(30_000) + }) + + render() + + expect(screen.getByTestId('elapsed').textContent).toBe('2') + }) + + it('re-anchors when the epoch moves', () => { + const { rerender } = render() + + act(() => { + vi.advanceTimersByTime(10_000) + }) + + expect(screen.getByTestId('elapsed').textContent).toBe('10') + + rerender() + + expect(screen.getByTestId('elapsed').textContent).toBe('0') + }) }) diff --git a/apps/desktop/src/components/chat/activity-timer.ts b/apps/desktop/src/components/chat/activity-timer.ts index afb27fb02f3b..9fe67642239d 100644 --- a/apps/desktop/src/components/chat/activity-timer.ts +++ b/apps/desktop/src/components/chat/activity-timer.ts @@ -30,13 +30,22 @@ export function formatElapsed(seconds: number): string { return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` } -export function useElapsedSeconds(active = true, timerKey?: string): number { - const start = useRef(startedAt(timerKey)) +/** + * Seconds since the timer's origin, reported once a second while `active`. + * + * Origin, in order: an explicit `since` timestamp, else the `timerKey`'s + * registry entry (survives unmount/remount), else mount time. Pass `since` when + * the thing being measured started at a moment the caller knows and that moment + * isn't the mount — otherwise an anonymous timer reports the component's age, + * which is only the same number by accident. + */ +export function useElapsedSeconds(active = true, timerKey?: string, since?: number): number { + const start = useRef(since ?? startedAt(timerKey)) const lastKey = useRef(timerKey) const [elapsed, setElapsed] = useState(() => Math.max(0, Math.floor((Date.now() - start.current) / 1000))) if (lastKey.current !== timerKey) { - start.current = startedAt(timerKey) + start.current = since ?? startedAt(timerKey) lastKey.current = timerKey } @@ -46,7 +55,9 @@ export function useElapsedSeconds(active = true, timerKey?: string): number { return } - if (timerKey) { + if (since !== undefined) { + start.current = since + } else if (timerKey) { start.current = startedAt(timerKey) } @@ -55,7 +66,7 @@ export function useElapsedSeconds(active = true, timerKey?: string): number { const id = window.setInterval(tick, 1000) return () => window.clearInterval(id) - }, [active, timerKey]) + }, [active, since, timerKey]) return elapsed } diff --git a/apps/desktop/src/components/chat/diff-lines.tsx b/apps/desktop/src/components/chat/diff-lines.tsx index edcf08e38f70..d7155bef2884 100644 --- a/apps/desktop/src/components/chat/diff-lines.tsx +++ b/apps/desktop/src/components/chat/diff-lines.tsx @@ -41,15 +41,15 @@ interface ParsedHunk { // plain renderer; the Shiki path omits it so syntax colors win, layering only // the background + border. const DIFF_KIND_TINT: Record = { - add: 'border-emerald-500 bg-emerald-500/12', + add: 'border-(--ui-diff-add-border) bg-(--ui-diff-add-background)', context: 'border-transparent', - remove: 'border-rose-500 bg-rose-500/12' + remove: 'border-(--ui-diff-remove-border) bg-(--ui-diff-remove-background)' } const DIFF_KIND_TEXT: Record = { - add: 'text-emerald-800 dark:text-emerald-200', + add: 'text-(--ui-diff-add-foreground)', context: '', - remove: 'text-rose-800 dark:text-rose-200' + remove: 'text-(--ui-diff-remove-foreground)' } const DIFF_LINE_BASE = 'block min-w-max whitespace-pre border-l-2 px-2.5 py-px' @@ -537,7 +537,10 @@ function DiffOverviewRuler({ lines }: { lines: DiffLine[] }) {
{runs.map((run, index) => (
diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index 4941f6dd6bc4..4f814832395a 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -398,7 +398,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP if (state.setupChoice) { return ( -
+
@@ -478,7 +478,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP const platformLabel = ups.platform === 'darwin' ? 'macOS' : ups.platform === 'linux' ? 'Linux' : ups.platform return ( -
+

{copy.oneTimeTitle}

{copy.unsupportedDesc(platformLabel)}

@@ -547,7 +547,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP const currentElapsed = typeof currentStartedAt === 'number' ? formatElapsed(now - currentStartedAt) : '' return ( -
+
{/* Header -- always visible, never scrolls */}
diff --git a/apps/desktop/src/components/error-boundary.tsx b/apps/desktop/src/components/error-boundary.tsx index 87b6b7743c51..f89f1d50673b 100644 --- a/apps/desktop/src/components/error-boundary.tsx +++ b/apps/desktop/src/components/error-boundary.tsx @@ -56,7 +56,7 @@ function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) { const { t } = useI18n() return ( -
+
+
diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx index 508dfb27f335..a63fd1c72b3c 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx @@ -10,7 +10,7 @@ import { BootFailureOverlay } from './boot-failure-overlay' import { GatewayConnectingOverlay } from './gateway-connecting-overlay' // Repro for the "remote gateway → stuck on CONNECTING, no way to settings" -// report. The connecting overlay (z-1200, full-screen, pointer-events on) used +// report. The connecting overlay (full-screen, pointer-events on) used // to be shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape // hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" / // "Retry" — only renders when `boot.error` is set. diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index 0268755d5632..34303bb52cfc 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -141,7 +141,7 @@ export function GatewayConnectingOverlay() { return (
diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index 65cc79bedf89..b38630d7e9b0 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -28,10 +28,10 @@ interface ModelPickerDialogProps { onSelect: (selection: { provider: string; model: string }) => void profile?: string /** - * Optional class to apply to DialogContent. Use to override z-index when - * stacking the picker on top of another fixed overlay (e.g. the desktop - * onboarding overlay, which sits at z-1300; the default Dialog z-130 ends - * up rendering underneath and blocks pointer events). + * Optional class for DialogContent. Use it to lift the picker onto a higher + * rung of the overlay ladder when it opens over another fixed overlay (the + * desktop onboarding overlay, say) — on the default modal rung it renders + * underneath and blocks pointer events. */ contentClassName?: string } @@ -85,7 +85,7 @@ export function ModelPickerDialog({ // Open the full onboarding provider selector to add/switch a provider. // Reuses the entire onboarding flow (OAuth rows, API-key form, device-code, // model-confirm) instead of duplicating provider UI here. Closes the picker - // so the onboarding overlay (z-1300) isn't rendered underneath it. + // so the onboarding overlay isn't rendered underneath it. const addProvider = () => { startManualOnboarding() onOpenChange(false) diff --git a/apps/desktop/src/components/notifications.tsx b/apps/desktop/src/components/notifications.tsx index 4d4a3c1058fd..640603236494 100644 --- a/apps/desktop/src/components/notifications.tsx +++ b/apps/desktop/src/components/notifications.tsx @@ -92,9 +92,9 @@ export function NotificationStack() { ) } -// Portaled to with a z above the Radix dialog layer (overlay z-[120], -// content z-[130]) — see the top-center variant below for why. -const REGION_BASE = 'pointer-events-none fixed z-[200] flex gap-2' +// Portaled to on the over-modal rung so a toast clears an open dialog — +// see the top-center variant below for why. +const REGION_BASE = 'pointer-events-none fixed z-(--z-over-modal) flex gap-2' // Primary stack: top-center, collapsed to the latest toast with a "+N more" // expander + clear-all — the noisy/important surface (errors, warnings, diff --git a/apps/desktop/src/components/onboarding/flow.tsx b/apps/desktop/src/components/onboarding/flow.tsx index a6499010c1a6..c81419fc847d 100644 --- a/apps/desktop/src/components/onboarding/flow.tsx +++ b/apps/desktop/src/components/onboarding/flow.tsx @@ -308,15 +308,14 @@ function ConfirmingModelPanel({
{/* - ModelPickerDialog defaults to z-130 on its content, which renders - UNDER the onboarding overlay (z-1300) and breaks pointer events. - Bump it above with z-[1310] so the picker sits on top of the - onboarding panel. The dialog's own dim-backdrop layer stays at - its default z-120 — the onboarding overlay is already dimming - the rest of the screen, so we don't want a second backdrop. + ModelPickerDialog's content sits on the modal rung, which is below the + onboarding overlay — it would render underneath and swallow pointer + events. Lift it to the rung above onboarding. Its own dim-backdrop + layer stays on the modal-backdrop rung: onboarding already dims the + rest of the screen, so a second backdrop would double up. */} - + {t.commandCenter.sections.sessions} diff --git a/apps/desktop/src/components/ui/dialog.tsx b/apps/desktop/src/components/ui/dialog.tsx index d84bb30c3453..7c69bdad8c4a 100644 --- a/apps/desktop/src/components/ui/dialog.tsx +++ b/apps/desktop/src/components/ui/dialog.tsx @@ -28,7 +28,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps Promise watchPreviewFile: (url: string) => Promise stopPreviewFileWatch: (id: string) => Promise + setActiveWork?: (payload: HermesActiveWork) => void setTitleBarTheme?: (payload: HermesTitleBarTheme) => void setNativeTheme?: (mode: 'dark' | 'light' | 'system') => void setTranslucency?: (payload: { intensity: number }) => void @@ -458,6 +459,12 @@ export interface HermesTitleBarTheme { foreground: string } +/** Turns in flight, so the main process can confirm before a quit kills them. */ +export interface HermesActiveWork { + count: number + titles: string[] +} + export interface HermesWindowState { isFullscreen: boolean isMinimized?: boolean diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 9aec2131073c..9a14e26e457e 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -1,4 +1,6 @@ import './styles.css' +// Side-effect: reports in-flight turns to the main process for the quit guard. +import './store/active-work' // Side-effect: applies the persisted window translucency on load. import './store/translucency' // Dev-only render/state churn counters. MUST precede the `react-dom` import diff --git a/apps/desktop/src/store/active-work.test.ts b/apps/desktop/src/store/active-work.test.ts new file mode 100644 index 000000000000..fbff83737399 --- /dev/null +++ b/apps/desktop/src/store/active-work.test.ts @@ -0,0 +1,60 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ClientSessionState } from '@/app/types' + +import { $sessions } from './session' +import { clearAllSessionStates, publishSessionState } from './session-states' + +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } +const setActiveWork = vi.fn() + +const busy = (storedSessionId: string, isBusy: boolean) => + ({ busy: isBusy, needsInput: false, storedSessionId }) as ClientSessionState + +const session = (id: string, title: null | string) => ({ id, title }) as (typeof $sessions.value)[number] + +beforeAll(async () => { + desktopWindow.hermesDesktop = { setActiveWork } as unknown as Window['hermesDesktop'] + // Subscribes at import time, so the bridge has to exist first. + await import('./active-work') +}) + +beforeEach(() => { + clearAllSessionStates() + $sessions.set([]) + setActiveWork.mockClear() +}) + +describe('active work bridge', () => { + it('reports a busy session by title', () => { + $sessions.set([session('s1', 'Fix login'), session('s2', 'Idle chat')]) + publishSessionState('runtime-1', busy('s1', true)) + + expect(setActiveWork).toHaveBeenLastCalledWith({ count: 1, titles: ['Fix login'] }) + }) + + it('counts an untitled busy session without inventing a title', () => { + $sessions.set([session('s1', null)]) + publishSessionState('runtime-1', busy('s1', true)) + + expect(setActiveWork).toHaveBeenLastCalledWith({ count: 1, titles: [] }) + }) + + it('drops back to nothing when the turn ends', () => { + $sessions.set([session('s1', 'Fix login')]) + publishSessionState('runtime-1', busy('s1', true)) + publishSessionState('runtime-1', busy('s1', false)) + + expect(setActiveWork).toHaveBeenLastCalledWith({ count: 0, titles: [] }) + }) + + it('does not re-send an unchanged summary', () => { + $sessions.set([session('s1', 'Fix login')]) + publishSessionState('runtime-1', busy('s1', true)) + setActiveWork.mockClear() + + $sessions.set([session('s1', 'Fix login'), session('s2', 'Something else')]) + + expect(setActiveWork).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/store/active-work.ts b/apps/desktop/src/store/active-work.ts new file mode 100644 index 000000000000..4d22916ff487 --- /dev/null +++ b/apps/desktop/src/store/active-work.ts @@ -0,0 +1,42 @@ +/** + * Mirror of "which chats are mid-turn" to the main process. + * + * The renderer is the only side that knows a turn is in flight, and the main + * process is the only side that can intercept a quit. This module bridges the + * two: it publishes a small summary on every membership change, and + * `electron/quit-guard.ts` turns that into the confirmation dialog. + * + * Imported for its side effect from `main.tsx`, alongside `store/translucency`. + */ + +import { computed } from 'nanostores' + +import type { HermesActiveWork } from '@/global' +import { $sessions } from '@/store/session' +import { $workingSessionIds } from '@/store/session-states' + +const $activeWork = computed([$workingSessionIds, $sessions], (workingIds, sessions): HermesActiveWork => { + const titleById = new Map(sessions.map(session => [session.id, session.title?.trim() ?? ''])) + + return { + count: workingIds.length, + titles: workingIds.map(id => titleById.get(id) ?? '').filter(Boolean) + } +}) + +if (typeof window !== 'undefined') { + // `$sessions` republishes on unrelated churn (previews, heartbeats), so only + // send when the summary itself moved — this crosses a process boundary. + let lastSent = '' + + $activeWork.subscribe(work => { + const next = JSON.stringify(work) + + if (next === lastSent) { + return + } + + lastSent = next + window.hermesDesktop?.setActiveWork?.(work) + }) +} diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 61b6f46abcc7..f9e89ca434d9 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -2,6 +2,7 @@ import { type ConnectionState, type GatewayEvent, resolveGatewayWsUrl } from '@h import { atom } from 'nanostores' import { HermesGateway } from '@/hermes' +import { markNativeNotifyBaseline } from '@/store/notify-baseline' import { setGatewayState } from '@/store/session' // ── Multi-profile gateway routing ────────────────────────────────────────── @@ -136,6 +137,12 @@ export function activeGateway(): HermesGateway | null { // composer reflect the active profile's socket without a background reconnect // flipping the foreground enabled/disabled state. function reportGatewayState(profile: string, state: ConnectionState): void { + // Any socket opening replays parked prompts; hold OS notifications so a + // launch/reconnect doesn't alert about state that already existed. + if (state === 'open') { + markNativeNotifyBaseline() + } + if (normKey(profile) === g.activeKey) { setGatewayState(state) } diff --git a/apps/desktop/src/store/native-notifications.test.ts b/apps/desktop/src/store/native-notifications.test.ts index de0bf8765426..4ebfd95a88e9 100644 --- a/apps/desktop/src/store/native-notifications.test.ts +++ b/apps/desktop/src/store/native-notifications.test.ts @@ -9,6 +9,7 @@ import { setNativeNotifyEnabled, setNativeNotifyKind } from './native-notifications' +import { __resetNativeNotifyBaselineForTests, markNativeNotifyBaseline } from './notify-baseline' import { $approvalRequest, setApprovalRequest } from './prompts' import { $activeSessionId, setActiveSessionId } from './session' @@ -43,6 +44,7 @@ beforeEach(() => { setActiveSessionId(null) setWindowState({ focused: false, hidden: true }) + __resetNativeNotifyBaselineForTests() }) afterEach(() => { @@ -139,6 +141,35 @@ describe('dispatchNativeNotification preferences', () => { }) }) +describe('dispatchNativeNotification post-connect baseline', () => { + it('suppresses a prompt replayed right after a socket opens', () => { + markNativeNotifyBaseline() + dispatchNativeNotification({ kind: 'approval', sessionId: freshSession(), title: 'approve' }) + expect(notify).not.toHaveBeenCalled() + }) + + it('suppresses a completion replayed right after a socket opens', () => { + const sessionId = freshSession() + setActiveSessionId(sessionId) + markNativeNotifyBaseline() + dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' }) + expect(notify).not.toHaveBeenCalled() + }) + + it('fires again once the window has passed', () => { + vi.useFakeTimers() + + try { + markNativeNotifyBaseline() + vi.advanceTimersByTime(5000) + dispatchNativeNotification({ kind: 'approval', sessionId: freshSession(), title: 'approve' }) + expect(notify).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) + describe('dispatchNativeNotification throttle', () => { it('collapses duplicate kind+session within the throttle window', () => { const sessionId = freshSession() diff --git a/apps/desktop/src/store/native-notifications.ts b/apps/desktop/src/store/native-notifications.ts index db56d94a3fa4..450360b7666c 100644 --- a/apps/desktop/src/store/native-notifications.ts +++ b/apps/desktop/src/store/native-notifications.ts @@ -3,6 +3,7 @@ import { atom } from 'nanostores' import { persistString, storedString } from '@/lib/storage' import { $gateway } from './gateway' +import { withinNativeNotifyBaseline } from './notify-baseline' import { clearApprovalRequest } from './prompts' import { $activeSessionId } from './session' @@ -160,6 +161,10 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void return } + if (withinNativeNotifyBaseline()) { + return + } + if (!shouldFire(input.kind, input.sessionId, input.global)) { return } diff --git a/apps/desktop/src/store/notify-baseline.ts b/apps/desktop/src/store/notify-baseline.ts new file mode 100644 index 000000000000..e48fde8ba138 --- /dev/null +++ b/apps/desktop/src/store/notify-baseline.ts @@ -0,0 +1,30 @@ +// Post-connect quiet window for native (OS) notifications. +// +// A socket opening replays state that already existed: a session parked on an +// approval re-emits its request so the UI can render the prompt. Those are not +// things that just happened, so launching Hermes — or any reconnect, profile +// switch, or gateway-mode apply — would otherwise fire an OS notification for a +// prompt the user has known about for an hour. The in-app surfaces (sidebar +// row, inline approval bar) still show the prompt immediately; only the OS +// notification is held. +// +// Lives in its own leaf module so `store/gateway` (which marks the baseline) +// and `store/native-notifications` (which reads it) don't import each other. + +const SEED_QUIET_MS = 4000 + +let quietUntil = 0 + +/** Called on every gateway `open`. Opens the quiet window. */ +export function markNativeNotifyBaseline(): void { + quietUntil = Date.now() + SEED_QUIET_MS +} + +/** True while replayed post-connect state should not raise an OS notification. */ +export function withinNativeNotifyBaseline(): boolean { + return Date.now() < quietUntil +} + +export function __resetNativeNotifyBaselineForTests(): void { + quietUntil = 0 +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 0f7fdbdd3b47..9fb935bbe088 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -188,6 +188,38 @@ --context-usage-subagents: color-mix(in srgb, var(--ui-blue) 70%, var(--ui-cyan)); --context-usage-memory: color-mix(in srgb, var(--ui-orange) 80%, var(--ui-yellow)); --context-usage-conversation: var(--ui-cyan); + /* Diff add/remove, derived from the semantic palette so every diff surface + (tool cards, preview, review pane) tracks the theme's green/red. Only the + foregrounds need a dark override — they mix toward the page instead of + away from it. */ + --ui-diff-add-border: var(--ui-green); + --ui-diff-add-background: color-mix(in srgb, var(--ui-green) 12%, transparent); + --ui-diff-add-foreground: color-mix(in srgb, var(--ui-green) 70%, #000); + --ui-diff-remove-border: var(--ui-red); + --ui-diff-remove-background: color-mix(in srgb, var(--ui-red) 12%, transparent); + --ui-diff-remove-foreground: color-mix(in srgb, var(--ui-red) 70%, #000); + + /* Overlay ladder. DESIGN.md: app-wide surfaces must not compete through + ad-hoc z-index literals — pick the rung that describes the surface. + Values are deliberately sparse so a one-off can slot between two rungs + without a renumber. Local stacking inside a component (a sticky header + over its own scroll area) stays on plain `z-10`/`z-20`; these rungs are + only for surfaces that float over the app. */ + --z-modal-backdrop: 120; + --z-modal: 130; + /* A select/dropdown/popover opened from inside a modal, portaled to body. */ + --z-modal-popover: 140; + /* Must clear any open modal: toasts, tooltips, command-surface backdrops. */ + --z-over-modal: 200; + --z-over-modal-content: 210; + --z-switcher-backdrop: 219; + --z-switcher: 220; + /* Boot and blocking states, in the order they can stack. */ + --z-connecting: 1200; + --z-onboarding: 1300; + --z-onboarding-popover: 1310; + --z-setup: 1400; + --z-crash: 1500; --ui-bg-chrome: color-mix( in srgb, var(--theme-background-seed) var(--theme-mix-chrome), @@ -444,6 +476,8 @@ --ui-red: #e75e78; --ui-green: #55a583; --ui-cyan: #6f9ba6; + --ui-diff-add-foreground: color-mix(in srgb, var(--ui-green) 62%, #fff); + --ui-diff-remove-foreground: color-mix(in srgb, var(--ui-red) 62%, #fff); --sidebar-edge-border: color-mix(in srgb, var(--ui-base) 12%, transparent); --composer-ring-strength: 1.3;