From 93477b2a0c266c3b1a28611d98cdf22ab5e1d571 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:47:42 -0500 Subject: [PATCH 1/6] fix(desktop): paint diffs from the theme palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diff add/remove lines were hardcoded to Tailwind's emerald/rose while the overview ruler beside them — and the rest of the app — used --ui-green / --ui-red, so every diff sat slightly off-brand and stayed put when the semantic palette moved. Derive the tint, gutter, and text from those two colors instead. One renderer feeds the tool card, the file preview, and the review pane, so all three follow. --- apps/desktop/src/components/chat/diff-lines.tsx | 13 ++++++++----- apps/desktop/src/styles.css | 12 ++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) 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/styles.css b/apps/desktop/src/styles.css index 0f7fdbdd3b47..5d34dbfb7499 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -188,6 +188,16 @@ --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); --ui-bg-chrome: color-mix( in srgb, var(--theme-background-seed) var(--theme-mix-chrome), @@ -444,6 +454,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; From e9bb4c39511f7c60442514d73803c76d63945a2b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:47:45 -0500 Subject: [PATCH 2/6] fix(desktop): don't alert for prompts a reconnect replayed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A socket opening replays state that already existed — a session parked on an approval re-emits its request so the UI can draw the prompt. Those arrive as ordinary events, so launching Hermes, switching profiles, or riding out a reconnect fired an OS notification for a prompt the user had known about for an hour. Hold native notifications for a beat after any gateway opens. The sidebar row and the inline approval bar still appear immediately; only the OS notification waits for something that actually just happened. --- apps/desktop/src/store/gateway.ts | 7 +++++ .../src/store/native-notifications.test.ts | 31 +++++++++++++++++++ .../desktop/src/store/native-notifications.ts | 5 +++ apps/desktop/src/store/notify-baseline.ts | 30 ++++++++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 apps/desktop/src/store/notify-baseline.ts 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 +} From 2f5926ed0591e46de3b5835d12b77ce5cbf7c529 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:47:53 -0500 Subject: [PATCH 3/6] fix(desktop): time a stream stall from the last activity The tail "Hermes is thinking" indicator resets on every flush, but its timer never did: with no timer key, useElapsedSeconds anchors to mount, and the indicator mounts with the assistant message. A stall two minutes into a turn therefore claimed two minutes of silence instead of the two seconds that had actually passed. Give the hook an explicit epoch and hand it the timestamp of the activity the quiet spell followed. Compaction still counts from the turn's start, which is the span it owns. --- .../components/assistant-ui/thread/status.tsx | 18 +++++++---- .../components/chat/activity-timer.test.tsx | 30 +++++++++++++++++-- .../src/components/chat/activity-timer.ts | 21 +++++++++---- 3 files changed, 57 insertions(+), 12 deletions(-) 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/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 } From 9ae3bd73c984caa371dc10b01750397833dd3cec Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:47:57 -0500 Subject: [PATCH 4/6] feat(desktop): confirm before quitting with a turn in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd-Q went straight through to teardown, killing the backend mid-tool-call — the turn is gone and whatever the agent was part-way through writing stays part-way written, with nothing on screen to warn about it. Renderers now report which chats are mid-turn; before-quit merges the reports and asks, naming them, defaulting to Keep Running. Update, swap, and uninstall relaunches skip the prompt: those are the app replacing itself, and a modal there would strand the detached script waiting on a PID that never exits. --- apps/desktop/electron/main.ts | 72 +++++++++++++++++ apps/desktop/electron/preload.ts | 1 + apps/desktop/electron/quit-guard.test.ts | 62 +++++++++++++++ apps/desktop/electron/quit-guard.ts | 92 ++++++++++++++++++++++ apps/desktop/src/global.d.ts | 7 ++ apps/desktop/src/main.tsx | 2 + apps/desktop/src/store/active-work.test.ts | 60 ++++++++++++++ apps/desktop/src/store/active-work.ts | 42 ++++++++++ 8 files changed, 338 insertions(+) create mode 100644 apps/desktop/electron/quit-guard.test.ts create mode 100644 apps/desktop/electron/quit-guard.ts create mode 100644 apps/desktop/src/store/active-work.test.ts create mode 100644 apps/desktop/src/store/active-work.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d88e1f787601..78157a3af79f 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, @@ -2510,6 +2511,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 +10141,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 +11420,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 (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/global.d.ts b/apps/desktop/src/global.d.ts index 1e6225bb2094..03af8c62adf0 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -124,6 +124,7 @@ declare global { normalizePreviewTarget: (target: string, baseDir?: string) => 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) + }) +} From c7ef4c192d321407d06bb33e826bc8209cc271e1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:48:09 -0500 Subject: [PATCH 5/6] refactor(desktop): name the overlay z-index ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN.md already said app-wide surfaces must not compete through ad-hoc z-index literals, and the code disagreed: three overlapping numbering schemes, and comments narrating the fight ("defaults to z-130, renders UNDER the onboarding overlay (z-1300) ... bump it above with z-[1310]"). Picking a number meant reading someone else's near miss. Name the rungs — modal, over-modal, switcher, and the boot chain — and point the call sites at them. Every rung keeps the exact value it had, so nothing moves; what changes is that the next overlay has a name to reach for instead of a number to guess. Local stacking within a component stays on plain z-10/z-20. --- apps/desktop/DESIGN.md | 7 +++++- .../sidebar/projects/base-branch-picker.tsx | 2 +- .../desktop/src/app/command-palette/index.tsx | 4 ++-- .../components/provider-picker.tsx | 6 ++--- apps/desktop/src/app/session-switcher.tsx | 4 ++-- .../src/components/boot-failure-overlay.tsx | 4 ++-- .../components/desktop-install-overlay.tsx | 6 ++--- .../desktop/src/components/error-boundary.tsx | 2 +- .../src/components/first-run-remote-form.tsx | 2 +- .../gateway-connecting-overlay.test.tsx | 2 +- .../components/gateway-connecting-overlay.tsx | 2 +- apps/desktop/src/components/model-picker.tsx | 10 ++++----- apps/desktop/src/components/notifications.tsx | 6 ++--- .../src/components/onboarding/flow.tsx | 13 +++++------ .../src/components/onboarding/index.tsx | 2 +- .../desktop/src/components/session-picker.tsx | 4 ++-- apps/desktop/src/components/ui/dialog.tsx | 6 ++--- apps/desktop/src/components/ui/select.tsx | 2 +- apps/desktop/src/components/ui/tooltip.tsx | 2 +- apps/desktop/src/styles.css | 22 +++++++++++++++++++ 20 files changed, 67 insertions(+), 41 deletions(-) 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/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/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/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 Date: Mon, 27 Jul 2026 16:19:27 -0500 Subject: [PATCH 6/6] fix(desktop): let automated teardown quit past the active-work prompt Playwright closes the app with a turn still in flight, so the new quit confirmation waited on a click nobody was there to make and the E2E worker died on a 90s teardown timeout. --- apps/desktop/e2e/fixtures.ts | 4 ++++ apps/desktop/electron/main.ts | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) 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 78157a3af79f..7ac24c57f8ed 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -608,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) @@ -11424,7 +11428,7 @@ function configureSpellChecker() { // 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 (quitConfirmedWithActiveWork || quitPromptOpen) { + if (SKIP_QUIT_CONFIRM || quitConfirmedWithActiveWork || quitPromptOpen) { return false }