From 9ae3bd73c984caa371dc10b01750397833dd3cec Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:47:57 -0500 Subject: [PATCH] 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) + }) +}