From b378cc0a72228fa6ec2201a7d7d8dd9b7a5fa77b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:24:35 -0500 Subject: [PATCH 01/10] fix(gateway): let `@` completion find folders by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fuzzy branch of `complete.path` ranked basenames from `_list_repo_files`, which lists files only, so a directory was only ever reachable by typing a `/` — `@Desktop` returned nothing at all. Rank each ancestor directory alongside the files, and break same-tier ties toward the folder so `@docs` leads with `docs/` rather than `docs.md`. Outside a git repo the fallback `os.walk` compounded this: it can spend the whole `_FUZZY_CACHE_MAX_FILES` budget inside one deep subtree before reaching a sibling, hiding top-level folders entirely. Seed the scan with a `listdir` of the root so immediate children are always candidates. --- tests/gateway/test_complete_path_at_filter.py | 76 +++++++++++++++++++ tui_gateway/server.py | 58 ++++++++++---- 2 files changed, 120 insertions(+), 14 deletions(-) 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 863601d68eb..5ab0dd44676 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -16184,24 +16184,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), } ) From de0b376cc9dbafe72f3b7c43dfaacb5b089bd447 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:24:44 -0500 Subject: [PATCH 02/10] fix(desktop): keep the `@` popover open while typing a path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AT_TRIGGER_RE` excluded `/` from the query, so the trigger died on the first separator: `@/desk`, `@./www`, `@~/Desktop` and even `@file:src/foo` all stopped matching the moment a path appeared. The gateway already answered those queries correctly — the composer just never asked. A `/` inside an `@` token is navigation, not a delimiter. The token stays whitespace-bounded, which is what actually ends it. --- .../src/app/chat/composer/text-utils.test.ts | 33 +++++++++++++++++++ .../src/app/chat/composer/text-utils.ts | 8 +++-- 2 files changed, 39 insertions(+), 2 deletions(-) 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-]*)?$/ From ecd5c796364a61a77e51a3f5388c06e83958057f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:24:56 -0500 Subject: [PATCH 03/10] fix(desktop): sit composer chips on the text baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `align-middle` centers a pill on the x-height midpoint, which sits above the center of the surrounding text box, so chips rode visibly low against the words they're nestled between. Measured against the rendered surface, `-0.12em` lands the chip's own baseline within 0.08px of the line's (vs 0.79px off before) without growing the line box. Applies to both the directive and slash chip classes — they share a line, so fixing one and not the other just moves the mismatch. --- .../src/components/assistant-ui/directive-text.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 From 93477b2a0c266c3b1a28611d98cdf22ab5e1d571 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:47:42 -0500 Subject: [PATCH 04/10] 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 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/styles.css b/apps/desktop/src/styles.css index 0f7fdbdd3b4..5d34dbfb749 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 05/10] 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 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 +} From 2f5926ed0591e46de3b5835d12b77ce5cbf7c529 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:47:53 -0500 Subject: [PATCH 06/10] 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 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/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 } From 9ae3bd73c984caa371dc10b01750397833dd3cec Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:47:57 -0500 Subject: [PATCH 07/10] 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 d88e1f78760..78157a3af79 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 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/global.d.ts b/apps/desktop/src/global.d.ts index 1e6225bb209..03af8c62adf 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 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) + }) +} From c7ef4c192d321407d06bb33e826bc8209cc271e1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:48:09 -0500 Subject: [PATCH 08/10] 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 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/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/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/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 Date: Mon, 27 Jul 2026 16:19:27 -0500 Subject: [PATCH 09/10] 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 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 78157a3af79..7ac24c57f8e 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 } From e00901259c6383744dc6ac13d0939f88f3154971 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 16:20:58 -0500 Subject: [PATCH 10/10] feat(desktop): Tab into a folder from the `@` popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab and Enter shared one branch, so picking a folder always committed a chip and closed the menu — the list could show `apps/` but never open it. Reaching a nested path meant typing every segment by hand. Split the two intents. Tab re-types the token as a bare path so the next completion lists that folder's children; Enter still commits the folder itself as a chip. Files ignore the distinction — there's nowhere deeper to go. Backspace mirrors the descent, dropping one path segment per press instead of one character, so climbing out costs the same as going in. --- .../composer/at-folder-navigation.test.tsx | 180 ++++++++++++++++++ .../composer/hooks/use-composer-trigger.ts | 56 +++++- apps/desktop/src/app/chat/composer/index.tsx | 15 +- 3 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx 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