diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 53075c5d49b..a9d585441ff 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 1dabc674d04..3427d42c9bf 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 d88e1f78760..7ac24c57f8e 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 99df85abd4c..059a03986af 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 00000000000..8888f99b40f --- /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 00000000000..e2bb40e59da --- /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/composer/at-folder-navigation.test.tsx b/apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx new file mode 100644 index 00000000000..25df1140c58 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx @@ -0,0 +1,180 @@ +import type { Unstable_TriggerItem } from '@assistant-ui/core' +import { act, renderHook } from '@testing-library/react' +import { createRef } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { useComposerTrigger } from './hooks/use-composer-trigger' +import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor' + +/** + * Folder navigation in the `@` popover, driven through the REAL hook against a + * real contentEditable. + * + * Tab and Enter used to be the same branch, so picking a folder always + * committed a chip and closed the menu — there was no way to walk into a + * subdirectory from the list. Tab now descends, Enter still commits, and + * Backspace climbs back out one segment. + */ +function folderItem(path: string): Unstable_TriggerItem { + return { + id: `@folder:${path}|0`, + type: 'folder', + label: path.split('/').filter(Boolean).pop() ?? path, + metadata: { + icon: 'folder', + display: path, + meta: 'dir', + rawText: `@folder:${path}`, + insertId: path + } + } +} + +function setup(initialText: string) { + const editor = document.createElement('div') + editor.contentEditable = 'true' + // The real composer marks its editor with this slot; `composerPlainText` + // keys off it to decide whether a DIV contributes a trailing newline. + // Without it the harness would silently diverge from production text. + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + renderComposerContents(editor, initialText) + + // Caret at the end, which is where a typed trigger always leaves it. + const range = document.createRange() + range.selectNodeContents(editor) + range.collapse(false) + const sel = window.getSelection() + sel?.removeAllRanges() + sel?.addRange(range) + + const editorRef = createRef() as { current: HTMLDivElement | null } + editorRef.current = editor + + const draftRef = { current: initialText } + const setComposerText = vi.fn() + + const { result } = renderHook(() => + useComposerTrigger({ + at: { adapter: null, loading: false }, + draftRef, + editorRef, + requestMainFocus: vi.fn(), + setComposerText, + slash: { adapter: null, loading: false } + }) + ) + + act(() => { + result.current.refreshTrigger() + }) + + return { editor, result, setComposerText } +} + +describe('@ folder navigation', () => { + it('Tab on a folder walks into it and keeps the popover open', () => { + const { editor, result } = setup('@app') + + expect(result.current.trigger).toMatchObject({ kind: '@', query: 'app' }) + + act(() => { + result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true }) + }) + + // Plain text, not a chip — the token is still being typed. + expect(composerPlainText(editor)).toBe('@apps/') + expect(editor.querySelector('[data-ref-text]')).toBeNull() + }) + + it('descends repeatedly, one level per Tab', () => { + const { editor, result } = setup('@apps/desk') + + act(() => { + result.current.replaceTriggerWithChip(folderItem('apps/desktop'), { descend: true }) + }) + + expect(composerPlainText(editor)).toBe('@apps/desktop/') + }) + + it('Enter on a folder commits it as a chip instead of descending', () => { + const { editor, result } = setup('@app') + + act(() => { + result.current.replaceTriggerWithChip(folderItem('apps')) + }) + + const chip = editor.querySelector('[data-ref-text]') + + expect(chip).not.toBeNull() + expect(chip?.getAttribute('data-ref-kind')).toBe('folder') + expect(composerPlainText(editor)).toContain('@folder:') + }) + + it('only descends for `@` folders — a file pick still commits', () => { + const { editor, result } = setup('@main') + + const file: Unstable_TriggerItem = { + id: '@file:src/main.tsx|0', + type: 'file', + label: 'main.tsx', + metadata: { + icon: 'file', + display: 'main.tsx', + meta: 'src', + rawText: '@file:src/main.tsx', + insertId: 'src/main.tsx' + } + } + + act(() => { + result.current.replaceTriggerWithChip(file, { descend: true }) + }) + + expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull() + }) + + it('Backspace climbs out one segment at a time', () => { + const { editor, result } = setup('@apps/desktop/') + + let handled = false + act(() => { + handled = result.current.ascendTriggerPath() + }) + + expect(handled).toBe(true) + expect(composerPlainText(editor)).toBe('@apps/') + }) + + it('Backspace drops a partially typed segment before its parent', () => { + const { editor, result } = setup('@apps/desk') + + act(() => { + result.current.ascendTriggerPath() + }) + + expect(composerPlainText(editor)).toBe('@apps/') + }) + + it('leaves Backspace alone when there is no path to climb', () => { + const { result } = setup('@apps') + + let handled = true + act(() => { + handled = result.current.ascendTriggerPath() + }) + + // No `/` in the query — normal character deletion must still happen. + expect(handled).toBe(false) + }) + + it('preserves text typed before the mention', () => { + const { editor, result } = setup('look at @app') + + act(() => { + result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true }) + }) + + expect(composerPlainText(editor)).toBe('look at @apps/') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index 2df218e78f3..5f0be41c80c 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -189,7 +189,7 @@ export function useComposerTrigger({ return true } - const replaceTriggerWithChip = (item: Unstable_TriggerItem) => { + const replaceTriggerWithChip = (item: Unstable_TriggerItem, options?: { descend?: boolean }) => { const editor = editorRef.current if (!editor || !trigger) { @@ -219,6 +219,31 @@ export function useComposerTrigger({ const serialized = hermesDirectiveFormatter.serialize(item) const starter = serialized.endsWith(':') + // Tab on a folder walks INTO it instead of committing it: re-type the + // token as the bare path so the next `complete.path` lists that folder's + // children, exactly as typing the path by hand would. Enter still commits + // the folder itself — the two intents are distinct, so the keys are too. + // Only `@` folders descend; a slash command's arg list has no hierarchy. + const descendInto = + options?.descend && trigger.kind === '@' && item.type === 'folder' + ? String((item.metadata as { insertId?: unknown } | undefined)?.insertId ?? '') + : '' + + if (descendInto) { + const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/` + const current = composerPlainText(editor) + const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength)) + + renderComposerContents(editor, `${prefix}@${path}`) + placeCaretEnd(editor) + draftRef.current = composerPlainText(editor) + setComposerText(draftRef.current) + requestMainFocus() + window.setTimeout(refreshTrigger, 0) + + return + } + // Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit // it — expand to its options step so the popover shows the inline list, just // as typing `/personality ` by hand would. A serialized value with a space is @@ -300,8 +325,37 @@ export function useComposerTrigger({ finish() } + /** Backspace inside an `@` path drops the last segment (`a/b/` → `a/`) + * instead of one character. Descending is one Tab per level, so climbing + * back out should cost one key too rather than a held delete. Returns + * false when the caret isn't in a path, so keydown falls through. */ + const ascendTriggerPath = () => { + const editor = editorRef.current + + if (!editor || trigger?.kind !== '@' || !trigger.query.includes('/')) { + return false + } + + // Trailing slash means we're listing a folder's children: drop that + // folder. Otherwise a partial segment is typed — drop just that. + const trimmed = trigger.query.replace(/\/$/, '') + const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1) + + const current = composerPlainText(editor) + const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength)) + + renderComposerContents(editor, `${prefix}@${parent}`) + placeCaretEnd(editor) + draftRef.current = composerPlainText(editor) + setComposerText(draftRef.current) + window.setTimeout(refreshTrigger, 0) + + return true + } + return { argStageEmpty, + ascendTriggerPath, closeTrigger, commitTypedSlashDirective, refreshTrigger, diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index b5a76f34904..6ce8289fa0a 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -299,6 +299,7 @@ export function ChatBar({ // this API; keyup uses triggerKeyConsumedRef to skip its refresh. const { argStageEmpty, + ascendTriggerPath, closeTrigger, commitTypedSlashDirective, refreshTrigger, @@ -556,12 +557,24 @@ export function ChatBar({ const item = triggerItems[triggerActive] if (item) { - replaceTriggerWithChip(item) + // Tab means "go deeper" on a folder; Enter means "I want this one". + // Everything else treats them alike. + replaceTriggerWithChip(item, { descend: event.key === 'Tab' }) } return } + // Backspace climbs out of an `@` path one segment at a time, mirroring + // Tab's one-key descent. Only when the caret sits at the end of the + // token — mid-token editing keeps normal character deletion. + if (event.key === 'Backspace' && !event.metaKey && !event.altKey && ascendTriggerPath()) { + event.preventDefault() + triggerKeyConsumedRef.current = true + + return + } + if (event.key === 'Escape') { event.preventDefault() triggerKeyConsumedRef.current = true diff --git a/apps/desktop/src/app/chat/composer/text-utils.test.ts b/apps/desktop/src/app/chat/composer/text-utils.test.ts index ca2ac803576..2a350593f45 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts @@ -51,6 +51,39 @@ describe('detectTrigger', () => { expect(detectTrigger('and/or')).toBeNull() }) + it('keeps the at-mention live while walking into subfolders', () => { + // A `/` inside the query is path navigation, not the end of the token — + // the popover has to stay open so the next directory level can load. + expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3 }) + expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6 }) + expect(detectTrigger('@~/Desktop/')).toEqual({ kind: '@', query: '~/Desktop/', tokenLength: 11 }) + expect(detectTrigger('@/usr/local')).toEqual({ kind: '@', query: '/usr/local', tokenLength: 11 }) + expect(detectTrigger('@apps/desktop/src')).toEqual({ + kind: '@', + query: 'apps/desktop/src', + tokenLength: 17 + }) + }) + + it('keeps the at-mention live for a typed ref kind with a path', () => { + expect(detectTrigger('@file:src/main.tsx')).toEqual({ + kind: '@', + query: 'file:src/main.tsx', + tokenLength: 18 + }) + expect(detectTrigger('@folder:apps/')).toEqual({ kind: '@', query: 'folder:apps/', tokenLength: 13 }) + }) + + it('still ends the at-mention token at whitespace', () => { + // The token is whitespace-delimited; a path doesn't change that. + expect(detectTrigger('@./src and more')).toBeNull() + expect(detectTrigger('look at @apps/desktop')).toEqual({ + kind: '@', + query: 'apps/desktop', + tokenLength: 13 + }) + }) + it('treats a mid-message slash as an inline reference', () => { // Skills have to be reachable anywhere in a prompt, not just at position 0. expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1 }) diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index 8bc6663210e..3029c7bc601 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -10,7 +10,11 @@ export interface TriggerState { } // `@` triggers stop at the first whitespace — `@file:path` and `@diff` are -// single tokens. Restricting the slash command name to `[a-zA-Z][\w-]*` avoids +// single tokens, and a path is part of that token: `@./src/`, `@~/Desktop/`, +// and `@file:src/foo` all have to keep the popover live while the user walks +// into subdirectories. Excluding `/` from the query class would end the token +// at the first separator, which is exactly the "can't browse into a folder" +// bug. Restricting the slash command name to `[a-zA-Z][\w-]*` avoids // matching file paths like `src/foo/bar`. // // `/` triggers fire in two shapes, because a slash means two different things @@ -27,7 +31,7 @@ export interface TriggerState { // The inline shape is what makes skills reachable anywhere in a prompt. Both // shapes need the trailing `$`: detection runs against the text BEFORE the // caret, so the match must end where the user is typing. -const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/ +const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@]*)$/ const SLASH_COMMAND_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ const SLASH_INLINE_TRIGGER_RE = /[\s](\/)([a-zA-Z][\w-]*)?$/ 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 c3870be0135..f07a3c04310 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 e86bd2415e3..55e19fc9d66 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 3279d7758aa..dad76c78d57 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/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 312c7d9414d..988562c0494 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -113,7 +113,7 @@ const SLASH_CHIP_VARIANT: Record = { } export const SLASH_CHIP_BASE_CLASS = - 'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-medium leading-none' + 'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-[-0.12em] text-[0.86em] font-medium leading-none' export function slashChipClass(kind: SlashChipKind): string { return `${SLASH_CHIP_BASE_CLASS} ${SLASH_CHIP_VARIANT[kind]}` @@ -145,9 +145,15 @@ const DirectiveIcon: FC<{ type: string; className?: string }> = ({ /** Shared chip styling — used by both the rendered and the * raw HTML composer chips in `rich-editor.ts`. Neutral subtle wash + plain - * muted-foreground text so chips read as quiet tags on any bubble color. */ + * muted-foreground text so chips read as quiet tags on any bubble color. + * + * `align-[-0.12em]` rather than `align-middle`: `middle` centers the pill on + * the x-height midpoint, which sits above the center of the surrounding text + * box, so the chip visibly rides low next to the words it's nestled in. The + * em nudge lands the chip's own text baseline on the line's baseline (measured + * to within 0.08px) without growing the line box. */ export const DIRECTIVE_CHIP_CLASS = - 'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground' + 'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-[-0.12em] text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground' /** * Parses our composer's `@type:value` references into directive segments diff --git a/apps/desktop/src/components/assistant-ui/thread/status.tsx b/apps/desktop/src/components/assistant-ui/thread/status.tsx index b239f0fb8a8..5b9a5837c26 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 ea5ca27a106..f07366f2898 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 acc70a99ed0..4768f60c56e 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 afb27fb02f3..9fe67642239 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 edcf08e38f7..d7155bef288 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 4941f6dd6bc..4f814832395 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 87b6b7743c5..f89f1d50673 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 508dfb27f33..a63fd1c72b3 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 0268755d563..34303bb52cf 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 65cc79bedf8..b38630d7e9b 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 4d4a3c1058f..64060323649 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 a6499010c1a..c81419fc847 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 d84bb30c345..7c69bdad8c4 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 9aec2131073..9a14e26e457 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 00000000000..fbff8373739 --- /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 00000000000..4d22916ff48 --- /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 61b6f46abcc..f9e89ca434d 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 de0bf876542..4ebfd95a88e 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 db56d94a3fa..450360b7666 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 00000000000..e48fde8ba13 --- /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 0f7fdbdd3b4..9fb935bbe08 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; diff --git a/tests/gateway/test_complete_path_at_filter.py b/tests/gateway/test_complete_path_at_filter.py index 4a3e292b01f..f46631dea27 100644 --- a/tests/gateway/test_complete_path_at_filter.py +++ b/tests/gateway/test_complete_path_at_filter.py @@ -277,3 +277,79 @@ def test_fuzzy_paths_relative_to_cwd_inside_subdir(tmp_path, monkeypatch): readme_texts = [t for t, _, _ in _items("@README")] assert not any("README.md" in t for t in readme_texts), readme_texts + + +# ── Fuzzy DIRECTORY matching ───────────────────────────────────────────── +# `@Desktop` used to return nothing: the fuzzy scanner ranks basenames from +# `_list_repo_files`, which lists FILES only, so a directory whose name no +# file inside it happens to match was unreachable without typing a `/`. + + +def test_fuzzy_finds_directory_by_name(tmp_path, monkeypatch): + """A folder is reachable by bare name, with no trailing slash typed.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "Desktop" / "nested").mkdir(parents=True) + # Deliberately named so NO file basename fuzzy-matches "Desktop". + (tmp_path / "Desktop" / "nested" / "zzz.txt").write_text("x") + + entries = _items("@Desktop") + texts = [t for t, _, _ in entries] + + assert "@folder:Desktop/" in texts, texts + + row = next(r for r in entries if r[0] == "@folder:Desktop/") + assert row[1] == "Desktop/" + assert row[2] == "dir" + + +def test_fuzzy_directory_prefix_match(tmp_path, monkeypatch): + """Partial folder names match too — `@Desk` finds `Desktop/`.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "Desktop").mkdir() + (tmp_path / "Desktop" / "zzz.txt").write_text("x") + + assert "@folder:Desktop/" in [t for t, _, _ in _items("@Desk")] + + +def test_fuzzy_ranks_folder_above_file_at_same_tier(tmp_path, monkeypatch): + """At an equal match tier the folder leads: `@docs` means the directory.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "intro.md").write_text("x") + (tmp_path / "docs.md").write_text("x") + + texts = [t for t, _, _ in _items("@docs")] + + assert texts[0] == "@folder:docs/", texts + assert "@file:docs.md" in texts + + +def test_fuzzy_hides_dot_directories_unless_asked(tmp_path, monkeypatch): + """Dot-folders follow the same rule as dotfiles.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".config").mkdir() + (tmp_path / ".config" / "zzz.txt").write_text("x") + + assert not any(".config" in t for t, _, _ in _items("@config")) + assert any(t.startswith("@folder:.config") for t, _, _ in _items("@.config")) + + +def test_fuzzy_finds_top_level_entries_outside_a_git_repo(tmp_path, monkeypatch): + """Outside a repo the fallback walk can exhaust its file budget on one + deep subtree before reaching a sibling, hiding top-level folders. The + root listdir seed guarantees immediate children are always candidates. + """ + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(server, "_FUZZY_CACHE_MAX_FILES", 5) + + # A deep subtree that soaks up the entire (patched) file budget... + deep = tmp_path / "aaa_hog" + deep.mkdir() + for i in range(40): + (deep / f"f{i:03d}.txt").write_text("x") + + # ...and the folder the user actually wants, sorted after it. + (tmp_path / "Desktop").mkdir() + (tmp_path / "Desktop" / "note.txt").write_text("x") + + assert "@folder:Desktop/" in [t for t, _, _ in _items("@Desktop")] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4d3ebe03f16..d533b713dd5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -16190,24 +16190,54 @@ def _(rid, params: dict) -> dict: and "/" not in path_part and prefix_tag != "folder" ): - ranked: list[tuple[tuple[int, int], str, str]] = [] - for rel in _list_repo_files(root): - basename = os.path.basename(rel) - if basename.startswith(".") and not path_part.startswith("."): - continue - rank = _fuzzy_basename_rank(basename, path_part) - if rank is None: - continue - ranked.append((rank, rel, basename)) + ranked: list[tuple[tuple[int, int], str, str, bool]] = [] + walked_dirs: set[str] = set() + seen: set[str] = set() + want_hidden = path_part.startswith(".") - ranked.sort(key=lambda r: (r[0], len(r[1]), r[1])) + def _consider(rel: str, name: str, is_dir: bool) -> None: + if rel in seen or (name.startswith(".") and not want_hidden): + return + rank = _fuzzy_basename_rank(name, path_part) + if rank is not None: + seen.add(rel) + ranked.append((rank, rel, name, is_dir)) + + # Seed with root's immediate children. `_list_repo_files` is capped + # at _FUZZY_CACHE_MAX_FILES, and outside a git repo the fallback + # walk can burn that whole budget on one deep subtree before ever + # reaching a sibling — which is why `@Desk` in a non-repo $HOME + # found nothing. One listdir keeps the top level always reachable. + try: + for entry in os.listdir(root): + if entry not in _FUZZY_FALLBACK_EXCLUDES: + _consider(entry, entry, os.path.isdir(os.path.join(root, entry))) + except OSError: + pass + + for rel in _list_repo_files(root): + _consider(rel, os.path.basename(rel), False) + + # Directories are only implied by the file listing, so rank each + # ancestor too. Without this a bare `@Desktop` finds nothing — + # a folder with no name-matching file inside it is invisible to + # a file-only scan, which is the "can't @ a folder by name" bug. + parent = os.path.dirname(rel) + while parent and parent not in walked_dirs: + walked_dirs.add(parent) + _consider(parent, os.path.basename(parent), True) + parent = os.path.dirname(parent) + + # Same rank tier: folders first, so `@Desktop` leads with the folder + # rather than a file that merely fuzzy-matches the same letters. + ranked.sort(key=lambda r: (r[0], not r[3], len(r[1]), r[1])) tag = prefix_tag or "file" - for _, rel, basename in ranked[:30]: + for _, rel, basename, is_dir in ranked[:30]: items.append( { - "text": f"@{tag}:{rel}", - "display": basename, - "meta": os.path.dirname(rel), + "text": f"@{'folder' if is_dir else tag}:{rel}{'/' if is_dir else ''}", + "display": basename + ("/" if is_dir else ""), + "meta": "dir" if is_dir else os.path.dirname(rel), } )