From 8c76fe19f80c96c1a462147c447a7666b826dd2d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 00:51:33 -0500 Subject: [PATCH 01/17] fix(update): let the GUI updater's hermes update child pass the lock it already holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-process update lock (fe8e4d93d) made the in-progress marker mutually exclusive across every update entrypoint — but the Tauri updater holds that marker for its WHOLE run and then spawns hermes update as a child stage. The child read the marker, found its own parent's live pid, refused with exit 2, and the GUI mapped that to "Hermes is still running. Close all Hermes windows and try the update again." Retry spawns a fresh updater that deadlocks against itself the same way, so every GUI-driven update dead-ends on the failure screen with no winnable retry (observed: three consecutive self-refusals in bootstrap-installer.log within 90 seconds). Hand the claim off explicitly: update_child_env exports HERMES_UPDATE_HANDOFF_PID naming the updater's own pid, and UpdateLock.acquire treats a live holder matching that pid as the lock we are already running under — run without claiming, and release leaves the parent's marker untouched. The env var alone grants nothing: the pid must also be the live marker owner, so a stale or forged value cannot bypass the lock, and a dashboard-spawned hermes update (no handoff env) is still refused exactly as before. --- .../src-tauri/src/update.rs | 22 +++++++++ hermes_cli/update_lock.py | 42 +++++++++++++++- tests/hermes_cli/test_update_lock.py | 49 +++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/apps/bootstrap-installer/src-tauri/src/update.rs b/apps/bootstrap-installer/src-tauri/src/update.rs index 63a5bfe8d71..3fada182d02 100644 --- a/apps/bootstrap-installer/src-tauri/src/update.rs +++ b/apps/bootstrap-installer/src-tauri/src/update.rs @@ -895,6 +895,17 @@ fn update_child_env(install_root: &Path) -> Vec<(String, OsString)> { // a frozen stage, and users cancel a healthy update. Force line-by-line // output instead. envs.push(("PYTHONUNBUFFERED".to_string(), OsString::from("1"))); + // We hold the update-in-progress marker for this whole run, and the + // `hermes update` child claims that SAME lock (hermes_cli/update_lock.py). + // Name our pid so the child recognizes the live holder as its own + // orchestrator and runs under our claim — without this every GUI update + // refuses its parent's marker with exit 2 ("Hermes is still running") + // and no number of retries can ever succeed. Keep the variable name in + // sync with HANDOFF_PID_ENV in hermes_cli/update_lock.py. + envs.push(( + "HERMES_UPDATE_HANDOFF_PID".to_string(), + OsString::from(std::process::id().to_string()), + )); if let Some(path) = path_with_prepended_entries(&[ hermes_home.join("node").join("bin"), venv_bin_dir(install_root), @@ -1218,6 +1229,17 @@ mod tests { ); } + #[test] + fn update_child_env_names_our_pid_for_the_lock_handoff() { + let envs = update_child_env(Path::new("/x/hermes-agent")); + assert!( + envs.iter().any(|(k, v)| k == "HERMES_UPDATE_HANDOFF_PID" + && v.to_str() == Some(std::process::id().to_string().as_str())), + "the hermes update child claims the same marker we hold; without our pid \ + it refuses its own parent's lock and every GUI update dead-ends on exit 2" + ); + } + #[test] fn lock_probe_paths_include_desktop_app_payload() { let root = Path::new("/x/hermes-agent"); diff --git a/hermes_cli/update_lock.py b/hermes_cli/update_lock.py index cfd6a4ea697..2d8b2aaf8ac 100644 --- a/hermes_cli/update_lock.py +++ b/hermes_cli/update_lock.py @@ -27,6 +27,15 @@ A marker only counts as a live update when its pid is alive AND it is younger than :data:`UPDATE_MARKER_MAX_AGE_MS` — mirroring ``readLiveUpdateMarker`` so a crashed updater self-heals instead of wedging every future update. A stale marker is removed on read by whoever notices it first. + +One layering wrinkle: the Tauri updater holds this marker for its WHOLE run and +then spawns ``hermes update`` as a child stage. Without a handoff the child +sees its own parent's live marker and refuses — the GUI update deadlocks +against itself on every attempt ("Hermes is still running", retry forever). +The updater therefore exports :data:`HANDOFF_PID_ENV` naming its own pid, and +``acquire`` treats a live holder matching that pid as the lock we are already +running under. The env var alone grants nothing: the pid must also be the +live marker owner, so a stale or forged value cannot bypass the lock. """ from __future__ import annotations @@ -47,6 +56,13 @@ UPDATE_MARKER_MAX_AGE_SECONDS = 20 * 60 MARKER_NAME = ".hermes-update-in-progress" +# Set by an orchestrating updater (the Tauri `hermes-setup --update` flow) to +# its own pid before spawning `hermes update` as a child stage. The parent +# holds the marker for its whole run, so without this the child refuses its +# own parent's lock and the GUI update can never complete. See update_child_env +# in apps/bootstrap-installer/src-tauri/src/update.rs — keep the name in sync. +HANDOFF_PID_ENV = "HERMES_UPDATE_HANDOFF_PID" + # Exit code meaning "another updater/instance owns this install right now". # Already the de-facto contract: the Windows shim + venv-holder guards in # _cmd_update_impl exit 2, and the Tauri updater matches on it @@ -95,6 +111,22 @@ def _pid_alive(pid: int) -> bool: return False +def _handoff_pid() -> int | None: + """Pid of the orchestrating updater that spawned us, if any. + + Read from :data:`HANDOFF_PID_ENV`. Malformed values count as absent — + a broken handoff must fall back to the normal refusal, never crash. + """ + raw = os.environ.get(HANDOFF_PID_ENV, "").strip() + if not raw: + return None + try: + pid = int(raw) + except ValueError: + return None + return pid if pid > 0 else None + + @dataclass(frozen=True) class UpdateHolder: """A confirmed-live update currently holding the lock.""" @@ -168,9 +200,17 @@ class UpdateLock: self.holder: UpdateHolder | None = None def acquire(self) -> bool: - """Claim the lock. Returns False (and sets ``holder``) if it's taken.""" + """Claim the lock. Returns False (and sets ``holder``) if it's taken. + + A live holder whose pid matches :data:`HANDOFF_PID_ENV` is our own + orchestrating parent (the Tauri updater spawning `hermes update` as a + stage): we run under ITS claim rather than refusing or re-writing the + marker, and ``release`` leaves the parent's marker untouched. + """ existing = read_live_update(path=self.path) if existing is not None: + if existing.pid == _handoff_pid(): + return True self.holder = existing return False try: diff --git a/tests/hermes_cli/test_update_lock.py b/tests/hermes_cli/test_update_lock.py index 93dfef71016..290d94c7c04 100644 --- a/tests/hermes_cli/test_update_lock.py +++ b/tests/hermes_cli/test_update_lock.py @@ -22,6 +22,7 @@ import time import pytest from hermes_cli.update_lock import ( + HANDOFF_PID_ENV, UPDATE_MARKER_MAX_AGE_SECONDS, UpdateLock, describe_holder, @@ -175,3 +176,51 @@ def test_unwritable_marker_location_does_not_block_the_update(tmp_path): assert lock.acquire() is True assert lock.acquired is False, "nothing was written, so there is nothing to release" + + +class TestHandoffFromOrchestratingUpdater: + """The Tauri updater holds the marker, then spawns ``hermes update``. + + The regression: the child saw its own parent's live marker and exited 2, + so every GUI update failed with "Hermes is still running" and retrying + just re-ran the same self-deadlock. The parent names its pid in + HANDOFF_PID_ENV; a live holder matching it is our own orchestrator. + """ + + def test_child_runs_under_the_parents_live_claim(self, marker, monkeypatch): + # Stand in for the parent updater with our own (live) pid. + marker.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8") + monkeypatch.setenv(HANDOFF_PID_ENV, str(os.getpid())) + + lock = UpdateLock(path=marker) + assert lock.acquire() is True + assert lock.acquired is False, "the parent's claim is not ours to own" + + lock.release() + assert marker.exists(), "the parent still needs its marker after our stage ends" + assert int(marker.read_text(encoding="utf-8").splitlines()[0]) == os.getpid() + + def test_handoff_pid_that_is_not_the_live_holder_grants_nothing(self, marker, monkeypatch): + """The env var alone must not bypass the lock.""" + marker.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8") + monkeypatch.setenv(HANDOFF_PID_ENV, str(os.getpid() + 1)) + + lock = UpdateLock(path=marker) + assert lock.acquire() is False + assert lock.holder is not None + + @pytest.mark.parametrize("value", ["", "not-a-pid", "-1", "0"], ids=["empty", "garbage", "negative", "zero"]) + def test_malformed_handoff_values_fall_back_to_refusal(self, marker, monkeypatch, value): + marker.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8") + monkeypatch.setenv(HANDOFF_PID_ENV, value) + + assert UpdateLock(path=marker).acquire() is False + + def test_handoff_env_with_no_marker_claims_normally(self, marker, monkeypatch): + """A handoff pid must not stop us writing our own claim when unlocked.""" + monkeypatch.setenv(HANDOFF_PID_ENV, str(os.getpid())) + + lock = UpdateLock(path=marker) + assert lock.acquire() is True + assert lock.acquired is True + assert int(marker.read_text(encoding="utf-8").splitlines()[0]) == os.getpid() From 7556c51234d607fc79e7fe37c83cb31de8461730 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 01:14:11 -0500 Subject: [PATCH 02/17] =?UTF-8?q?fix(desktop):=20focus=20the=20tab=20resto?= =?UTF-8?q?red=20by=20undo-close=20(=E2=8C=98=E2=87=A7T)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adoption alone is silent, so reopenLastClosedTile only restored placement and left the tab behind the still-fronted workspace. Focus it after open. --- apps/desktop/src/store/session-states.test.ts | 93 ++++++++++++++++++- apps/desktop/src/store/session-states.ts | 7 +- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts index 4133babb927..3bff4ba5208 100644 --- a/apps/desktop/src/store/session-states.test.ts +++ b/apps/desktop/src/store/session-states.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClientSessionState } from '@/app/types' -import { group, split } from '@/components/pane-shell/tree/model' +import { findGroupOfPane, group, split } from '@/components/pane-shell/tree/model' import { $layoutTree } from '@/components/pane-shell/tree/store' import { $selectedStoredSessionId } from '@/store/session' import type { SessionTile } from '@/store/session-states' @@ -138,3 +138,92 @@ describe('blankDraftTile', () => { expect(blankDraftTile([], {})).toBeNull() }) }) + +// ⌘⇧T used to only restore `$sessionTiles`. Adoption inserts silently +// (activate:false), so the tab came back behind the still-fronted workspace. +// Real path: register, adopt, focus — same as paneMirror + reopen. +describe('reopenLastClosedTile focuses the restored tab', () => { + beforeEach(() => { + window.localStorage.clear() + vi.resetModules() + }) + + afterEach(() => { + vi.resetModules() + }) + + async function setup() { + const tree = await import('@/components/pane-shell/tree/store') + const model = await import('@/components/pane-shell/tree/model') + const { registry } = await import('@/contrib/registry') + const session = await import('@/store/session') + const states = await import('@/store/session-states') + + registry.register({ + area: 'panes', + data: { placement: 'main', uncloseable: true }, + id: 'workspace', + render: () => null, + title: 'chat' + }) + + // panes ← $sessionTiles (paneMirror stub). Adoption is synchronous on + // register, so openSessionTile + focusOpenSession works the same tick. + const registered = new Map void>() + + const syncTiles = () => { + const wanted = new Set(states.$sessionTiles.get().map(t => t.storedSessionId)) + + for (const id of wanted) { + if (registered.has(id)) { + continue + } + + registered.set( + id, + registry.register({ + area: 'panes', + data: { dock: { pane: 'workspace', pos: 'center' }, placement: 'main' }, + id: tilePane(id), + render: () => null, + title: id + }) + ) + } + + for (const [id, dispose] of registered) { + if (!wanted.has(id)) { + dispose() + registered.delete(id) + tree.removeTreePane(tilePane(id)) + } + } + } + + states.$sessionTiles.listen(syncTiles) + tree.watchContributedPanes() + session.$selectedStoredSessionId.set('primary') + tree.declareDefaultTree(model.group(['workspace'], { active: 'workspace', id: 'grp-main' })) + + states.openSessionTile('closed', 'center', 'workspace') + states.focusOpenSession('closed') + tree.noteActiveTreeGroup('grp-main') + expect(findGroupOfPane(tree.$layoutTree.get()!, tilePane('closed'))?.active).toBe(tilePane('closed')) + + return { states, tree } + } + + it('fronts the restored tab after ⌘⇧T', async () => { + const { states, tree } = await setup() + + states.closeSessionTile('closed') + expect(states.$sessionTiles.get().some(t => t.storedSessionId === 'closed')).toBe(false) + expect(findGroupOfPane(tree.$layoutTree.get()!, 'workspace')?.active).toBe('workspace') + + states.reopenLastClosedTile() + + expect(states.$sessionTiles.get().some(t => t.storedSessionId === 'closed')).toBe(true) + expect(findGroupOfPane(tree.$layoutTree.get()!, tilePane('closed'))?.active).toBe(tilePane('closed')) + expect(tree.$activeTreeGroup.get()).toBe('grp-main') + }) +}) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index 5d3f41c4fc6..ebf58f9819c 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -702,8 +702,10 @@ export function discardSessionTile(storedSessionId: string) { saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId)) } -/** ⌘⇧T — reopen the most recently closed tab where it was. Skips ids that are - * live again (reopened, or now the primary). */ +/** ⌘⇧T — reopen the most recently closed tab where it was, then focus it. + * Adoption alone is silent (won't steal the active tab), so restore has to + * front the pane explicitly. Skips ids that are live again (reopened / now + * the primary). */ export function reopenLastClosedTile(): void { const stack = closedStack() @@ -716,6 +718,7 @@ export function reopenLastClosedTile(): void { if (!$sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) { openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before) + focusOpenSession(storedSessionId) return } From 5affcd6bf451545e60c9f9fbf731f4e2a05dc5b2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 01:15:01 -0500 Subject: [PATCH 03/17] =?UTF-8?q?feat(desktop):=20open=20a=20folder=20as?= =?UTF-8?q?=20a=20project=20with=20=E2=8C=98O?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workspace.openFolder (default mod+o, the editor-standard open-folder chord) runs openFolderAsProject: pick a folder, enter the project that already owns it or create one named after the folder, scope the sidebar, and land on a fresh session draft anchored there. A stale backend without the projects.* RPC still gets the workspace session, with a warning. StartWorkSessionRequest grows an openTab flag so these opens-from-nowhere stack a tab instead of spending an occupied main, and goToProject/ resolveNewSessionCwd share one projectRootCwd resolver. --- apps/desktop/src/app/contrib/wiring.tsx | 2 +- apps/desktop/src/app/hooks/use-keybinds.ts | 5 +- apps/desktop/src/i18n/ar.ts | 1 + apps/desktop/src/lib/keybinds/actions.ts | 5 ++ apps/desktop/src/store/projects.ts | 85 +++++++++++++++++++++- 5 files changed, 92 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 559ccc90e0b..44d35607e10 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -528,7 +528,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { } lastStartWorkTokenRef.current = startWorkSessionRequest.token - startSessionInWorkspace(startWorkSessionRequest.path) + startSessionInWorkspace(startWorkSessionRequest.path, { openTab: startWorkSessionRequest.openTab }) if (startWorkSessionRequest.draft) { requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' }) diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 07ac0384e63..3f7b83080c8 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -33,7 +33,7 @@ import { switchToDefaultProfile, toggleShowAllProfiles } from '@/store/profile' -import { requestNewWorktree } from '@/store/projects' +import { openFolderAsProject, requestNewWorktree } from '@/store/projects' import { toggleReview } from '@/store/review' import { setModelPickerOpen } from '@/store/session' import { reopenLastClosedTile } from '@/store/session-states' @@ -173,6 +173,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { // Only meaningful inside a git repo — a no-op otherwise (the key falls // through instead of silently doing nothing). 'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(), + // ⌘O: native folder picker → open the folder as a project (upsert) with a + // fresh session anchored there. + 'workspace.openFolder': () => void openFolderAsProject(), // Narrow-viewport reveal is handled inside the store toggles now. 'view.toggleSidebar': toggleSidebarOpen, diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 48866637b0d..03abd7364dc 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -220,6 +220,7 @@ export const ar = defineLocale({ 'session.focusSearch': 'البحث في الجلسات', 'session.togglePin': 'تثبيت / إلغاء تثبيت الجلسة الحالية', 'workspace.newWorktree': 'worktree جديد', + 'workspace.openFolder': 'فتح مجلد كمشروع', 'composer.focus': 'التركيز على المحرّر', 'composer.modelPicker': 'فتح منتقي النموذج', 'composer.voice': 'بدء / إيقاف المحادثة الصوتية', diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index f4a609c202f..392292bd7c2 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -88,6 +88,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ { id: 'session.togglePin', category: 'session', defaults: [] }, // ⌘⇧B — "b" for branch: spin up a new git worktree from the active repo. { id: 'workspace.newWorktree', category: 'session', defaults: ['mod+shift+b'] }, + // ⌘O — the editor-standard "open folder" chord (VS Code ⌘O, Zed's + // workspace::Open). Picks a folder and opens it as a project (upsert: + // enters the owning project when one exists, else creates one), landing on + // a fresh session anchored there. + { id: 'workspace.openFolder', category: 'session', defaults: ['mod+o'] }, // ── Navigation ─────────────────────────────────────────────────────────── { id: 'nav.commandPalette', category: 'navigation', defaults: ['mod+k', 'mod+p'] }, diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 8b0f9cbc99a..22e9ce2af81 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -161,6 +161,36 @@ export function exitProjectScope(): void { $projectScope.set(ALL_PROJECTS) } +// A project's working root: its primary folder, else the first repo that has +// one. Empty for the path-less Home bucket. (The sidebar's `projectTreeCwd` is +// the same rule over the same tree — this is the store-side copy so the store +// doesn't reach into the sidebar's React module.) +const projectRootCwd = (project: SidebarProjectTree | undefined): string => + (project?.path || project?.repos.find(repo => repo.path)?.path || '').trim() + +// ⌘K "go to project": flip the sidebar into grouped mode and enter the project +// — a pure scope switch, same as clicking the overview row (never spends main). +// With `newSession` (⌘-select / ⌘-Enter) it also lands on a fresh session draft +// anchored at the project root — stacked as a tab when main already holds a +// chat (palette opens are opens-from-nowhere). A path-less project (the Home +// bucket) gets a plain detached draft. +export function goToProject(id: string, options?: { newSession?: boolean }): void { + setSidebarAgentsGrouped(true) + enterProject(id) + + if (!options?.newSession) { + return + } + + const cwd = projectRootCwd($projectTree.get().find(node => node.id === id)) + + if (cwd) { + requestStartWorkSession(cwd, undefined, { openTab: true }) + } else { + requestFreshSession() + } +} + // The cwd a NEW chat should start in. The "active project" is just an atom // ($projectScope) — so when you're inside a project, a new session (cmd-n, the // trunk "+") starts at that project's root (its primary repo = the default-branch @@ -177,8 +207,7 @@ export function resolveNewSessionCwd(): string { } if (scope !== ALL_PROJECTS) { - const project = $projectTree.get().find(node => node.id === scope) - const cwd = (project?.path || project?.repos.find(repo => repo.path)?.path || '').trim() + const cwd = projectRootCwd($projectTree.get().find(node => node.id === scope)) if (cwd) { return cwd @@ -997,6 +1026,8 @@ export async function switchBranchInRepo(repoPath: string, branch: string): Prom // effect even if the path repeats. export interface StartWorkSessionRequest { draft?: string + /** Stack the fresh session as a tab when main already holds a chat (palette/⌘O opens-from-nowhere). */ + openTab?: boolean path: string token: number } @@ -1016,7 +1047,7 @@ export function requestNewWorktree(): void { let startWorkToken = 0 -export function requestStartWorkSession(path: string, draft?: string): void { +export function requestStartWorkSession(path: string, draft?: string, options?: { openTab?: boolean }): void { const target = path.trim() if (!target) { @@ -1024,7 +1055,12 @@ export function requestStartWorkSession(path: string, draft?: string): void { } startWorkToken += 1 - $startWorkSessionRequest.set({ draft: draft?.trim() || undefined, path: target, token: startWorkToken }) + $startWorkSessionRequest.set({ + draft: draft?.trim() || undefined, + openTab: options?.openTab || undefined, + path: target, + token: startWorkToken + }) } export async function removeWorktreePath( @@ -1068,3 +1104,44 @@ export async function pickProjectFolder(): Promise { return dir || null } + +// ⌘O / palette "Open folder…": open a folder AS a project, upserting. A folder +// already covered by a project (explicit or auto) just enters it; anything else +// becomes a new project named after the folder. Either way the sidebar scopes +// to the project and a fresh session draft lands anchored at the folder — the +// one-keystroke version of new project → enter → new session. Like goToProject, +// this is an open-from-nowhere: an occupied main gets a stacked tab, not stolen. +export async function openFolderAsProject(dir?: string): Promise { + const target = (dir ?? (await pickProjectFolder()) ?? '').trim() + + if (!target) { + return + } + + // Refresh first so the membership check runs against live truth — a repo + // cloned since the last scan should enter its auto project, not double-create. + await refreshProjectTree() + + const existing = projectIdForCwd(target) + + if (existing) { + setSidebarAgentsGrouped(true) + enterProject(existing) + } else { + const name = target.replace(/[/\\]+$/, '').split(/[/\\]/).pop() || target + + try { + const created = await createProject({ name, folders: [target], primaryPath: target, use: true }) + + if (created) { + enterProject(created.id) + } + } catch (err) { + // Stale backend (no projects.* RPC) or a failed write: still open the + // folder as a plain workspace session below — the project row can wait. + notify({ kind: 'warning', message: err instanceof Error ? err.message : String(err) }) + } + } + + requestStartWorkSession(target, undefined, { openTab: true }) +} From a11611af39d8514ec483a2856701b079b0a1bb58 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 01:15:01 -0500 Subject: [PATCH 04/17] feat(desktop): projects in the command palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘K gains a Projects group carrying each project's own sidebar codicon. Selecting one is a pure scope switch; holding ⌘/⌃ previews the variant — the label swaps to 'New session in ' beside a ⌘↵ chip — and ⌘-Enter runs it. A pinned row opens the native picker, and typing an absolute path offers the same upsert inline. modLabel/comboHint live on PaletteItem, so the next modifier-variant row gets both for free. --- .../desktop/src/app/command-palette/index.tsx | 113 ++++++++++++++++-- apps/desktop/src/i18n/en.ts | 5 + apps/desktop/src/i18n/types.ts | 4 + apps/desktop/src/i18n/zh.ts | 5 + 4 files changed, 120 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index a0279c495ee..0a444d00c37 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom' import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud' import { setTerminalTakeover } from '@/app/right-sidebar/store' +import { codiconIcon } from '@/components/ui/codicon' import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { HighlightMatches } from '@/components/ui/highlight-matches' import { KbdCombo } from '@/components/ui/kbd' @@ -60,7 +61,7 @@ import { } from '@/store/command-palette' import { $bindings } from '@/store/keybinds' import { openPetGenerate } from '@/store/pet-generate' -import { requestStartWorkSession } from '@/store/projects' +import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects' import { $connection } from '@/store/session' import { runGatewayRestart } from '@/store/system-actions' import { @@ -103,6 +104,8 @@ interface PaletteItem { action?: string /** Renders a trailing check: this row IS the current setting (theme, mode). */ active?: boolean + /** Static trailing combo hint for a modifier-variant select (e.g. `mod+enter`). */ + comboHint?: string /** Muted text beside the label — state the row acts on (a version, a count). */ detail?: string icon: IconComponent @@ -111,6 +114,8 @@ interface PaletteItem { keepOpen?: boolean keywords?: string[] label: string + /** Label shown while ⌘/⌃ is held — previews the modifier-variant action. */ + modLabel?: string /** * When set, ⌘/⌃-select (or ⌘-Enter) opens a new tab and ⇧⌘-select pops a * window — matching sidebar session rows. Plain select stays in-place. @@ -233,18 +238,25 @@ const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.i const PaletteRow = memo(function PaletteRow({ bindings, item, + modHeld, onSelectMods, onSelectItem, search }: { bindings: Record item: PaletteItem + modHeld: boolean onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void onSelectItem: (item: PaletteItem) => void search: string }) { const Icon = item.icon - const combo = item.action ? bindings[item.action]?.[0] : undefined + // The row's live keybind, else a static modifier-variant hint (⌘↵). One slot, + // so every downstream `ml-auto` fallback below keeps working unchanged. + const combo = (item.action ? bindings[item.action]?.[0] : undefined) ?? item.comboHint + // While ⌘/⌃ is held, a row with a modifier variant previews it: the label + // swaps to the variant's copy so Enter reads as what it will actually do. + const modPreview = modHeld && Boolean(item.modLabel) return ( - {/* Same per-term split as scoreItem's AND matcher, so the emphasis - shows exactly which words earned the row its rank. */} - + {modPreview ? ( + item.modLabel + ) : ( + /* Same per-term split as scoreItem's AND matcher, so the emphasis + shows exactly which words earned the row its rank. */ + + )} {item.detail && {item.detail}} - {combo && } + {combo && } {item.to && } {item.active && } @@ -272,6 +288,12 @@ const PaletteRow = memo(function PaletteRow({ // "Go to session ‹id›" jump for ids that aren't in the recent-200 list. const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/ +// A typed/pasted folder path: absolute (`/…`) or a Windows drive (`C:\…`). +// Deliberately NOT `~/…`: the upsert's membership check (projectIdForCwd) +// compares literal strings against the tree's absolute paths, so an unexpanded +// home path would always miss and double-create. +const FOLDER_PATH_RE = /^(\/|[A-Za-z]:[/\\]).+/ + type SessionRow = Awaited>['sessions'][number] const toSessionEntry = (session: SessionRow): SessionEntry => ({ @@ -359,6 +381,7 @@ export function CommandPalette() { const pendingPage = useStore($commandPalettePage) const bindings = useStore($bindings) const worktrees = useStore($repoWorktrees) + const projectTree = useStore($projectTree) const navigate = useNavigate() const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme() const [search, setSearch] = useState('') @@ -409,6 +432,33 @@ export function CommandPalette() { } } + // Live ⌘/⌃-held state while the palette is open: rows with a modifier + // variant (projects) preview it by swapping their label. Window-level + // listeners because focus sits in the search input; blur clears so a + // ⌘-Tab away doesn't strand the preview on. + const [modHeld, setModHeld] = useState(false) + + useEffect(() => { + if (!open) { + setModHeld(false) + + return + } + + const sync = (event: KeyboardEvent) => setModHeld(event.metaKey || event.ctrlKey) + const clear = () => setModHeld(false) + + window.addEventListener('keydown', sync, { capture: true }) + window.addEventListener('keyup', sync, { capture: true }) + window.addEventListener('blur', clear) + + return () => { + window.removeEventListener('keydown', sync, { capture: true }) + window.removeEventListener('keyup', sync, { capture: true }) + window.removeEventListener('blur', clear) + } + }, [open]) + // Server-backed sources for the type-to-search groups, fetched lazily while // the palette is open. react-query handles caching/dedup/staleness. const configQuery = useQuery({ @@ -495,6 +545,36 @@ export function CommandPalette() { const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}` const cc = t.commandCenter + // Projects are the primary way the desktop scopes work, so they're jumpable + // from the palette. Plain select is a pure scope switch (sidebar enters the + // project — never spends main); ⌘-Enter / ⌘-click also starts a new session + // at the project root (stacked as a tab when main holds a chat), previewed + // by the label swap while ⌘ is held. Rows carry the project's own codicon, + // matching the sidebar. The pinned "Open folder…" row is the ⌘O upsert. + const projectGroup: PaletteGroup = { + heading: cc.projects, + items: [ + { + action: 'workspace.openFolder', + icon: codiconIcon('folder-opened'), + id: 'project-open-folder', + keywords: ['open', 'folder', 'directory', 'project', 'add', 'import', 'workspace'], + label: cc.openFolder, + run: () => void openFolderAsProject() + }, + ...projectTree.map(project => ({ + comboHint: 'mod+enter', + icon: codiconIcon(project.icon || (project.isNoProject ? 'home' : 'folder-library')), + id: `project-${project.id}`, + keywords: ['project', 'workspace', 'go to', project.label, ...(project.path ? [project.path] : [])], + label: project.label, + modLabel: cc.newSessionInProject(project.label), + runWithEvent: (event?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) => + goToProject(project.id, { newSession: Boolean(event?.metaKey || event?.ctrlKey) }) + })) + ] + } + // The active repo's worktrees → "new conversation in ". This is the // ⌘K-typed "I want to work on " reflex: each entry seeds a fresh // session anchored to that worktree's checkout (requestStartWorkSession), @@ -599,6 +679,7 @@ export function CommandPalette() { } ] }, + projectGroup, ...branchGroup, { heading: cc.commandCenter, @@ -714,7 +795,7 @@ export function CommandPalette() { ] : []) ] - }, [contributedItems, go, settingsSectionLabel, t, updateVersionLabel, worktrees]) + }, [contributedItems, go, projectTree, settingsSectionLabel, t, updateVersionLabel, worktrees]) // The long, granular lists (settings fields, API keys, MCP servers, archived // chats) only surface once the user types — otherwise they'd bury the @@ -744,6 +825,23 @@ export function CommandPalette() { }) } + // Paste/type an absolute folder path → open it as a project directly (the + // ⌘O upsert without the native picker). Same reflex as the raw-session-id + // row above. + if (FOLDER_PATH_RE.test(directId)) { + result.push({ + items: [ + { + icon: codiconIcon('folder-opened'), + id: `open-folder-${directId}`, + keywords: ['open', 'folder', 'project', directId], + label: t.commandCenter.openFolderAt(directId), + run: () => void openFolderAsProject(directId) + } + ] + }) + } + // Deep-link straight to a Capabilities sub-tab. The root "Go to" entry only // lands on the top-level Skills view; typing "mcp"/"tools"/"skills" should // jump to the exact tab (matches the "not just the top lvl" ask). @@ -1083,6 +1181,7 @@ export function CommandPalette() { bindings={bindings} item={item} key={item.id} + modHeld={modHeld} onSelectItem={handleSelect} onSelectMods={noteSelectMods} search={search} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6426b61ac5d..49f52386a81 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -251,6 +251,7 @@ export const en: Translations = { 'session.focusSearch': 'Search sessions', 'session.togglePin': 'Pin / unpin current session', 'workspace.newWorktree': 'New worktree', + 'workspace.openFolder': 'Open folder as project', 'composer.focus': 'Focus composer', 'composer.modelPicker': 'Open model picker', 'composer.voice': 'Start / stop voice conversation', @@ -1143,6 +1144,10 @@ export const en: Translations = { goTo: 'Go to', goToSession: 'Go to session', branches: 'Branches', + projects: 'Projects', + openFolder: 'Open folder as project…', + openFolderAt: path => `Open folder as project — ${path}`, + newSessionInProject: project => `New session in ${project}`, commands: 'Commands', startInBranch: branch => `New conversation in ${branch}`, commandCenter: 'Command Center', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 7bd9c8cb1fc..c41cbc343a4 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1006,6 +1006,10 @@ export interface Translations { goTo: string goToSession: string branches: string + projects: string + openFolder: string + openFolderAt: (path: string) => string + newSessionInProject: (project: string) => string commands: string startInBranch: (branch: string) => string commandCenter: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 171da340086..bd8c7630016 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -246,6 +246,7 @@ export const zh: Translations = { 'session.focusSearch': '搜索会话', 'session.togglePin': '固定/取消固定当前会话', 'workspace.newWorktree': '新建工作树', + 'workspace.openFolder': '打开文件夹为项目', 'composer.focus': '聚焦输入框', 'composer.modelPicker': '打开模型选择器', 'composer.voice': '开始 / 停止语音对话', @@ -1340,6 +1341,10 @@ export const zh: Translations = { goTo: '前往', goToSession: '前往会话', branches: '分支', + projects: '项目', + openFolder: '打开文件夹为项目…', + openFolderAt: path => `打开文件夹为项目 — ${path}`, + newSessionInProject: project => `在 ${project} 中新建会话`, commands: '命令', startInBranch: branch => `在 ${branch} 中开始新对话`, commandCenter: '命令中心', From ba194c1d19999f1208cb9afee6688e74f21035f1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 01:15:01 -0500 Subject: [PATCH 05/17] =?UTF-8?q?feat(desktop):=20File=20>=20Open=20Folder?= =?UTF-8?q?=E2=80=A6=20menu=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No accelerator (⌘O stays a rebindable renderer keybind, matching New Window's rationale); clicking routes hermes:open-folder-requested through the preload bridge to the same openFolderAsProject flow. --- apps/desktop/electron/main.ts | 18 ++++++++++++++++++ apps/desktop/electron/preload.ts | 6 ++++++ .../contrib/hooks/use-desktop-integrations.ts | 8 ++++++++ apps/desktop/src/global.d.ts | 1 + 4 files changed, 33 insertions(+) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 344b149d0d1..121de24f698 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -5155,6 +5155,20 @@ function sendClosePreviewRequested() { webContents.send('hermes:close-preview-requested') } +function sendOpenFolderRequested() { + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + + const webContents = mainWindow.webContents + + if (!webContents || webContents.isDestroyed()) { + return + } + + webContents.send('hermes:open-folder-requested') +} + // Tell the renderer the machine just woke. Sleep silently drops the // renderer's WebSocket to the local backend; the renderer reconnects on this // signal so the chat composer doesn't stay stuck on "Starting Hermes...". @@ -5272,6 +5286,10 @@ function buildApplicationMenu() { // a menu accelerator would fight the rebind panel and (on macOS) be // swallowed before the renderer sees it. Here purely for discoverability. { click: () => createInstanceWindow(), label: 'New Window' }, + // Same no-accelerator rationale: ⌘O is the rebindable renderer keybind + // (workspace.openFolder). Clicking runs the same open-folder-as-project + // flow through the renderer. + { click: () => sendOpenFolderRequested(), label: 'Open Folder…' }, { type: 'separator' }, IS_MAC ? { diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index b82a6e1484b..8822efd2a99 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -212,6 +212,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', { return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener) }, + onOpenFolderRequested: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:open-folder-requested', listener) + + return () => ipcRenderer.removeListener('hermes:open-folder-requested', listener) + }, onOpenUpdatesRequested: callback => { const listener = () => callback() ipcRenderer.on('hermes:open-updates', listener) diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts index d64a3a6fe79..cff5d86f4d5 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -5,6 +5,7 @@ import { openSession } from '@/app/open-session' import { storedSessionIdForNotification } from '@/lib/session-ids' import { respondToApprovalAction } from '@/store/native-notifications' import { $activeGatewayProfile } from '@/store/profile' +import { openFolderAsProject } from '@/store/projects' import { $sessions, getRememberedRoute, @@ -187,6 +188,13 @@ export function useDesktopIntegrations({ return () => unsubscribe?.() }, [navigate]) + // File > Open Folder… — same open-folder-as-project upsert as the ⌘O keybind. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onOpenFolderRequested?.(() => void openFolderAsProject()) + + return () => unsubscribe?.() + }, []) + // Another window mutated the shared session list -> re-pull the sidebar. useEffect(() => { if (isSecondaryWindow()) { diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 2c1904c3215..d2804298fa2 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -241,6 +241,7 @@ declare global { write: (id: string, data: string) => Promise } onClosePreviewRequested?: (callback: () => void) => () => void + onOpenFolderRequested?: (callback: () => void) => () => void onOpenUpdatesRequested?: (callback: () => void) => () => void onDeepLink?: ( callback: (payload: { kind: string; name: string; params: Record }) => void From 958ab818b8855fd1fda8cc23d7efe5a80b7a7551 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 01:19:31 -0500 Subject: [PATCH 06/17] fix(desktop): sidebar labels truncate instead of pushing header icons out Flex containers around section/lane labels kept their default min-width:auto, so a long project title refused to shrink at narrow sidebar widths and shoved the trailing action icons (caret, +, kebab, branch) past the edge. Give every header label min-w-0 so its truncate can engage, pin shrink-0 on the caret at the primitive level and on SidebarSectionMeta, and clip LaneLabel's pinned tail inside the label. Icons now stay visible at any width. --- apps/desktop/src/app/chat/sidebar/chrome.tsx | 2 +- apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx | 2 +- .../src/app/chat/sidebar/projects/workspace-header.tsx | 4 +++- apps/desktop/src/app/chat/sidebar/sessions-section.tsx | 6 ++++-- apps/desktop/src/app/settings/keybind-settings.tsx | 6 ++++-- apps/desktop/src/components/ui/disclosure-caret.tsx | 2 +- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx index 196c71768a9..8e2da487829 100644 --- a/apps/desktop/src/app/chat/sidebar/chrome.tsx +++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx @@ -10,7 +10,7 @@ import { cn } from '@/lib/utils' /** The muted slot beside a section label (loading glyph, status hint). */ export function SidebarSectionMeta({ children }: { children: React.ReactNode }) { - return {children} + return {children} } // ── Row geometry (session row is canonical — everything composes these) ───── diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx index 21c43cd6c7c..dd6988ce916 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -135,7 +135,7 @@ export function SidebarCronJobsSection({
) : ( -
{labelBody}
+
{labelBody}
)} {action}
diff --git a/apps/desktop/src/app/settings/keybind-settings.tsx b/apps/desktop/src/app/settings/keybind-settings.tsx index 0e6b69ee36a..a974de48882 100644 --- a/apps/desktop/src/app/settings/keybind-settings.tsx +++ b/apps/desktop/src/app/settings/keybind-settings.tsx @@ -173,11 +173,13 @@ export function KeybindSettings() { function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) { return ( - - - - )} - {attachments.length > 0 && } +
+
-
- {contextMenu} - -
-
{input}
-
- - {controls} + {/* Contribution seams: banners above, a row below, inline + additions beside the "+" menu and before the controls. + All four render nothing until something contributes. */} + + + + {queueEdit && editingQueuedPrompt && ( +
+
+ {t.composer.editingQueuedInComposer} +
+
+ + +
+
+ )} + {attachments.length > 0 && } +
+
+ {contextMenu} + +
+
{input}
+
+ + {controls} +
+
-
- {/* Underside: chrome-free strip BELOW the composer. Outside the root for the same reason as the micro actions — it must not fall inside diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index ff7af6b5c91..ce82571d7d2 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -277,7 +277,9 @@ const PaletteRow = memo(function PaletteRow({ )} {item.detail && {item.detail}} - {combo && } + {combo && ( + + )} {item.to && } {item.active && } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 019328d21c5..2c0913fb6dc 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -100,16 +100,8 @@ const _chatMessageFieldsExhaustive: { [K in Exclude]: never } = {} -const COMPARED_FIELDS = [ - 'id', - 'role', - 'pending', - 'error', - 'hidden', - 'branchGroupId', - 'interim', - 'reactions' -] as const +const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId', 'interim', 'reactions'] as const + const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts', 'rowId'] as const // Compile-time check: every ChatMessagePart discriminant must be handled by @@ -193,10 +185,7 @@ export function chatReactionsEquivalent(a: ChatMessage['reactions'], b: ChatMess return ( aList.length === bList.length && - aList.every( - (reaction, index) => - reaction.emoji === bList[index].emoji && reaction.author === bList[index].author - ) + aList.every((reaction, index) => reaction.emoji === bList[index].emoji && reaction.author === bList[index].author) ) } diff --git a/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx b/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx index 749335b1c38..05d07a5b12f 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx @@ -117,10 +117,7 @@ export const ReactionPicker: FC<{ // Opt this one surface out of the shared popover glass: emoji hover // tints at 15% alpha are unreadable over blurred transcript text. // Overriding the local surface var keeps the arrow matched for free. - className={cn( - 'w-auto p-1 [--popover-surface:var(--ui-bg-elevated)]', - !expanded && 'flex gap-0.5' - )} + className={cn('w-auto p-1 [--popover-surface:var(--ui-bg-elevated)]', !expanded && 'flex gap-0.5')} onCloseAutoFocus={event => event.preventDefault()} side="top" > diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 22e9ce2af81..ca4511e6186 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -1128,7 +1128,11 @@ export async function openFolderAsProject(dir?: string): Promise { setSidebarAgentsGrouped(true) enterProject(existing) } else { - const name = target.replace(/[/\\]+$/, '').split(/[/\\]/).pop() || target + const name = + target + .replace(/[/\\]+$/, '') + .split(/[/\\]/) + .pop() || target try { const created = await createProject({ name, folders: [target], primaryPath: target, use: true }) diff --git a/apps/desktop/src/store/reactions.ts b/apps/desktop/src/store/reactions.ts index c8c2b414a8a..c75af2b81f1 100644 --- a/apps/desktop/src/store/reactions.ts +++ b/apps/desktop/src/store/reactions.ts @@ -62,10 +62,7 @@ export async function toggleMessageReaction( const gateway = activeGateway() if (!sessionId || !gateway) { - notifyError( - new Error(!sessionId ? 'No active session' : 'Gateway not connected'), - 'Could not react' - ) + notifyError(new Error(!sessionId ? 'No active session' : 'Gateway not connected'), 'Could not react') return } From 975f4ef38d2ff671172edd2078b75f05bf3b87d0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 02:14:22 -0500 Subject: [PATCH 14/17] perf(desktop): budget the transcript live tail in parts, not turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The content-visibility virtualization from #66470 stopped engaging on agent sessions. Its live tail — the newest turns kept always-rendered so a turn is only virtualized once its height has settled — was sized as a raw count of 6 turns, while everything else in this file budgets in rendered PARTS (RENDER_BUDGET=300, FIRST_PAINT_BUDGET=20). Those units diverge badly on agent transcripts. A chat turn is 2-6 parts, but a turn with tool calls is 50-200, so "6 turns" can exempt the entire visible transcript. Measured on a 5-tile window (7/3/5/3/2 groups per tile): zero content-visibility containers were active anywhere, and every Radix overlay open paid the full whole-document style recalc that #66470 exists to avoid (~610ms of a ~700ms open, in a handful of enormous recalcs rather than any long task). Size the tail by parts instead, clamped to [2, 6] turns. The floor keeps the streaming turn rendered when turns are huge, preserving the anti-drift guarantee; the ceiling stops a tail of tiny turns from reaching further back than the old turn-count policy did, so no transcript shape renders more than before. `liveTailStart` replaces the per-row `isVirtualizedGroup` predicate and is computed once per render off the weighted groups. Parts left always-rendered, real transcript shapes: | shape | before | after | |------------------------------|--------|-------| | agent tile (7 tool-heavy) | 690 | 270 | | agent tile (5 turns) | 535 | 225 | | long agent session (40) | 720 | 240 | | long chat (40 short turns) | 24 | 24 | --- .../assistant-ui/thread/list.test.ts | 92 +++++++++++++++---- .../components/assistant-ui/thread/list.tsx | 75 ++++++++++++--- 2 files changed, 137 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.test.ts b/apps/desktop/src/components/assistant-ui/thread/list.test.ts index e053af6f5ba..a09d57deba4 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.test.ts +++ b/apps/desktop/src/components/assistant-ui/thread/list.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' -import { buildGroups, firstVisibleGroupIndex, isVirtualizedGroup, LIVE_TAIL_GROUPS, type MessageGroup } from './list' +import { + buildGroups, + firstVisibleGroupIndex, + LIVE_TAIL_MIN_GROUPS, + LIVE_TAIL_PARTS, + liveTailStart, + type MessageGroup +} from './list' // Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState // selector in list.tsx). @@ -81,32 +88,79 @@ describe('firstVisibleGroupIndex', () => { }) }) -describe('isVirtualizedGroup', () => { - it('never virtualizes the newest turns (the live tail)', () => { - const count = 20 +describe('liveTailStart', () => { + const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight }) - for (let i = count - LIVE_TAIL_GROUPS; i < count; i++) { - expect(isVirtualizedGroup(i, count)).toBe(false) - } + it('keeps the newest turns rendered until the parts budget is spent', () => { + // 10 turns x 10 parts. A 40-part tail covers the newest 4-5 turns. + const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 10)) + const start = liveTailStart(groups) + + expect(start).toBeGreaterThan(0) + expect(start).toBeLessThan(groups.length) + + // Everything from `start` onward is the live tail... + const tailParts = groups.slice(start).reduce((sum, g) => sum + g.weight, 0) + expect(tailParts).toBeGreaterThan(LIVE_TAIL_PARTS) + + // ...and dropping its oldest member puts it back under budget, i.e. the + // tail is minimal rather than sprawling. + const withoutOldest = groups.slice(start + 1).reduce((sum, g) => sum + g.weight, 0) + expect(withoutOldest).toBeLessThanOrEqual(LIVE_TAIL_PARTS) }) - it('virtualizes older turns that sit before the live tail', () => { - const count = 20 + it('virtualizes the old bulk of a long agent transcript', () => { + // The regression this guards: heavy tool turns. A turn-count tail (6) left + // NOTHING virtualized on transcripts like this, so every Radix overlay open + // paid a whole-document style recalc. + const groups = Array.from({ length: 40 }, (_, i) => group(`g${i}`, 120)) - expect(isVirtualizedGroup(0, count)).toBe(true) - expect(isVirtualizedGroup(count - LIVE_TAIL_GROUPS - 1, count)).toBe(true) + // Only the min-group floor stays rendered; the other 38 turns skip. + expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS) + }) + + it('never virtualizes below the min-group floor, however heavy the turns', () => { + const groups = Array.from({ length: 5 }, (_, i) => group(`g${i}`, 10_000)) + + expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS) }) it('keeps every turn rendered when the whole transcript fits in the tail', () => { - const count = LIVE_TAIL_GROUPS + const groups = [group('a', 5), group('b', 5), group('c', 5)] - for (let i = 0; i < count; i++) { - expect(isVirtualizedGroup(i, count)).toBe(false) + expect(liveTailStart(groups)).toBe(0) + }) + + it('handles an empty transcript', () => { + expect(liveTailStart([])).toBe(0) + }) + + it('honors a custom budget', () => { + const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 1)) + + // A 3-part budget would keep 4 turns, but the max-groups ceiling is not hit + // here, so the parts budget wins. + expect(liveTailStart(groups, 3)).toBe(6) + }) + + it('never renders more than the old turn-count tail did, on any shape', () => { + // Guards the one way a parts budget can regress: a long transcript of tiny + // turns, where walking back 40 parts reaches further than 6 turns would. + const shapes = [ + Array.from({ length: 40 }, () => 4), // long chat, tiny turns + Array.from({ length: 40 }, () => 1), // pathological: 1-part turns + Array.from({ length: 12 }, () => 6), + [80, 120, 60, 150, 90, 200, 70], // real agent tile + [30, 45] + ] + + for (const weights of shapes) { + const groups = weights.map((weight, i) => group(`g${i}`, weight)) + const rendered = (start: number) => weights.slice(start).reduce((a, b) => a + b, 0) + + const oldStart = Math.max(0, groups.length - 6) + + expect(rendered(liveTailStart(groups))).toBeLessThanOrEqual(rendered(oldStart)) } }) - - it('honors a custom tail size', () => { - expect(isVirtualizedGroup(5, 10, 3)).toBe(true) - expect(isVirtualizedGroup(7, 10, 3)).toBe(false) - }) }) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index 3afbc324c4f..ba4b7964152 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -130,16 +130,63 @@ export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget: // stick-to-bottom lock drifts and the view creeps up over older turns — the // "long session eventually shows old responses" glitch. // -// Keep the newest N turns always-rendered so a turn is only ever virtualized +// Keep the newest turns always-rendered so a turn is only ever virtualized // once its layout has settled at its final size (remembered == real → skipping // it changes no height). Off-screen OLDER turns still skip, so the dialog/popover -// recalc win on long transcripts is preserved (that scales with the hundreds of -// old turns, not this small live tail). -export const LIVE_TAIL_GROUPS = 6 +// recalc win on long transcripts is preserved. +// +// The tail is budgeted in PARTS, not turns, because that is what the cost +// actually scales with — the same currency as RENDER_BUDGET / FIRST_PAINT_BUDGET. +// A turn-count tail silently defeats itself on agent transcripts: one tool-heavy +// turn is 50-200 parts, so a 6-TURN tail exempted the entire visible transcript +// and nothing virtualized at all. Measured on a 5-tile window (7/3/5/3/2 groups +// per tile): zero content-visibility containers were active, and every Radix +// overlay open paid the full ~610ms whole-document recalc that #66470 fixed. +// +// 40 parts ≈ the 1-2 turns a viewport shows after scroll-to-bottom (the same +// reasoning as FIRST_PAINT_BUDGET=20, doubled so a turn that grows mid-stream +// doesn't fall out of the tail as it settles). +export const LIVE_TAIL_PARTS = 40 +// Floor: always exempt at least this many turns regardless of weight, so a +// transcript of very heavy turns still keeps the streaming one unvirtualized. +export const LIVE_TAIL_MIN_GROUPS = 2 +// Ceiling: never exempt more than this many turns, however light they are. On a +// long transcript of tiny turns a parts-only budget would walk back further +// than the old turn-count tail did and virtualize LESS — this keeps the new +// policy a strict improvement on every shape. +export const LIVE_TAIL_MAX_GROUPS = 6 -/** True when a visible group is old enough to virtualize (outside the live tail). */ -export function isVirtualizedGroup(indexInVisible: number, visibleCount: number, liveTail = LIVE_TAIL_GROUPS): boolean { - return indexInVisible < visibleCount - liveTail +/** + * Index of the newest group that still virtualizes — everything at or after it + * is the live tail and stays rendered. Walks newest-first accumulating parts, + * so the tail covers a viewport's worth of content rather than a fixed number + * of turns, clamped to [MIN, MAX] turns. Computed once per render, not per row. + */ +export function liveTailStart( + groups: readonly MessageGroup[], + tailParts = LIVE_TAIL_PARTS, + minGroups = LIVE_TAIL_MIN_GROUPS, + maxGroups = LIVE_TAIL_MAX_GROUPS +): number { + let parts = 0 + let start = groups.length + + for (let i = groups.length - 1; i >= 0; i--) { + parts += groups[i]?.weight ?? 1 + start = i + + if (parts > tailParts) { + break + } + } + + // Clamp the tail to [minGroups, maxGroups] turns: the floor keeps the live + // turn rendered when turns are huge, the ceiling stops a tail of tiny turns + // from sprawling past what the old turn-count policy rendered. + const floor = Math.max(0, groups.length - minGroups) + const ceiling = Math.max(0, groups.length - maxGroups) + + return Math.min(floor, Math.max(ceiling, start)) } const ThreadMessageListInner: FC = ({ @@ -278,6 +325,13 @@ const ThreadMessageListInner: FC = ({ const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget) const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups + // Where the always-rendered live tail begins. Derived from the WEIGHTED + // groups (parts, not turns) so the tail is a viewport's worth of content — + // see liveTailStart. Computed once here rather than per row. + const tailStart = useMemo( + () => liveTailStart(hiddenCount > 0 ? weightedGroups.slice(hiddenCount) : weightedGroups), + [weightedGroups, hiddenCount] + ) // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out) // hide the titlebar tool cluster + session header, but the OS traffic lights // still sit in the top-left, so reserve the titlebar gap above the transcript. @@ -436,12 +490,11 @@ const ThreadMessageListInner: FC = ({ // The live tail (newest turns) is exempt: virtualizing a turn // whose final size hasn't been remembered yet snaps it to a stale // height when it scrolls off, drifting stick-to-bottom up over old - // turns. See isVirtualizedGroup. + // turns. See liveTailStart.
@@ -461,7 +514,7 @@ const ThreadMessageListInner: FC = ({
)), - [visibleGroups, components, structuralSignature] + [visibleGroups, components, structuralSignature, tailStart] ) return ( From 04817665619864e96ac40d78dca74d084f9e2746 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 02:32:44 -0500 Subject: [PATCH 15/17] =?UTF-8?q?perf(desktop):=20make=20=E2=8C=98K=20cost?= =?UTF-8?q?=20nothing=20until=20it=20opens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘K is an overlay that is stateful to itself — pressing it owes the user a frame immediately, whatever else the shell is doing. It was not built that way. `CommandPalette` is mounted for the life of the app, and its body ran unconditionally: a dozen store subscriptions (connection, desktop version, client + backend update status/apply, keybinds, worktrees, theme, i18n), three `useQuery`s, and the group builders that assemble a few hundred rows. `` renders nothing while closed, so none of it was ever visible — but all of it still ran. An in-flight update rewrites `$updateApply` on every progress line, and each of those rebuilt the entire row set for a surface nobody could see. Split the body into `CommandPaletteBody`, mounted only while the palette is on screen. A closed palette is now one store subscription. The body is keyed by open count, so per-open state (search, sub-page) resets by remount and the explicit close-reset effect goes away, and `mounted` lags `open` by the 150ms exit animation so Radix can still play `data-[state=closed]` instead of the overlay vanishing. Rows additionally move behind `useDeferredValue` in their own memo component. Because that component mounts with the portal, the deferred initial value applies per open: the first commit is the frame + input, and the several-hundred-row list arrives in an interruptible follow-up render rather than blocking the frame the keypress asked for. The empty state is suppressed while rows are still pending so opening doesn't flash "no results". The `enabled: open` gates on the three queries are dropped — the component only exists when open, so they are inherently lazy, and react-query still serves a reopen from cache while revalidating. --- .../desktop/scripts/probe-command-palette.mjs | 146 ++++++++ .../desktop/src/app/command-palette/index.tsx | 347 ++++++++++++------ 2 files changed, 371 insertions(+), 122 deletions(-) create mode 100644 apps/desktop/scripts/probe-command-palette.mjs diff --git a/apps/desktop/scripts/probe-command-palette.mjs b/apps/desktop/scripts/probe-command-palette.mjs new file mode 100644 index 00000000000..1b48d81df8a --- /dev/null +++ b/apps/desktop/scripts/probe-command-palette.mjs @@ -0,0 +1,146 @@ +// ⌘K open latency, measured in-page (no CDP round-trip in the number). +// +// node scripts/probe-command-palette.mjs [--port 9222] [--rounds 8] +// +// Reports, per round, the time from the keydown the app actually receives to: +// frame_ms — the dialog frame + input in the DOM and painted (what "instant" +// means: the overlay owes you a frame immediately) +// rows_ms — the row list painted (may lag frame_ms; rows are deferred) +// plus any long tasks in the window, so a slow open is attributable. +import { CDP, sleep } from './perf/lib/cdp.mjs' + +const args = process.argv.slice(2) +const flag = name => { + const i = args.indexOf(`--${name}`) + + return i >= 0 ? args[i + 1] : undefined +} + +const port = Number(flag('port') ?? 9222) +const rounds = Number(flag('rounds') ?? 8) + +const cdp = await CDP.connect({ port }) + +await cdp.send('Runtime.enable') + +const INSTALL = ` + (() => { + if (window.__CMDK__) window.__CMDK__.stop() + + const state = { t0: null, frame: null, rows: 0, rowsAt: null, tasks: [], armed: false } + + // Time from the keydown the APP receives — excludes CDP transport, so the + // number is what a user's finger actually experiences. + const onKey = e => { + if (state.armed && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + state.t0 = performance.now() + state.armed = false + } + } + + window.addEventListener('keydown', onKey, true) + + const obs = new MutationObserver(() => { + if (state.t0 === null) return + if (state.frame === null && document.querySelector('[cmdk-input]')) { + state.frame = performance.now() - state.t0 + } + const n = document.querySelectorAll('[cmdk-item]').length + if (n > state.rows) { state.rows = n; state.rowsAt = performance.now() - state.t0 } + }) + + obs.observe(document.body, { childList: true, subtree: true }) + + const po = new PerformanceObserver(list => { + for (const e of list.getEntries()) state.tasks.push({ start: e.startTime, dur: Math.round(e.duration) }) + }) + + try { po.observe({ entryTypes: ['longtask'] }) } catch {} + + window.__CMDK__ = { + arm: () => { state.t0 = null; state.frame = null; state.rows = 0; state.rowsAt = null; state.tasks = []; state.armed = true }, + read: () => ({ + frame_ms: state.frame === null ? -1 : Math.round(state.frame), + rows_ms: state.rowsAt === null ? -1 : Math.round(state.rowsAt), + rows: state.rows, + longtask_ms: state.t0 === null ? 0 : state.tasks.filter(t => t.start >= state.t0).reduce((s, t) => s + t.dur, 0) + }), + stop: () => { window.removeEventListener('keydown', onKey, true); obs.disconnect(); po.disconnect() } + } + + return true + })() +` + +// Settle: frame painted AND rows stopped growing for two frames. +const WAIT = ` + new Promise(resolve => { + let stable = 0 + let last = -1 + const started = performance.now() + const tick = () => { + const r = window.__CMDK__.read() + if (r.frame_ms >= 0 && r.rows === last && r.rows > 0) { + if (++stable >= 2) { resolve(r); return } + } else { stable = 0 } + last = r.rows + if (performance.now() - started > 8000) { resolve(window.__CMDK__.read()); return } + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + }) +` + +const key = async type => + cdp.send('Input.dispatchKeyEvent', { + type, + key: 'k', + code: 'KeyK', + windowsVirtualKeyCode: 75, + nativeVirtualKeyCode: 75, + modifiers: 4 + }) + +const esc = async () => { + for (const type of ['keyDown', 'keyUp']) { + await cdp.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }) + } + + await sleep(400) +} + +await cdp.eval(INSTALL) +await esc() + +const samples = [] + +for (let i = 0; i < rounds; i++) { + await sleep(250) + await cdp.eval('window.__CMDK__.arm()') + await key('rawKeyDown') + await key('keyUp') + const r = await cdp.eval(WAIT) + samples.push(r) + console.log(`round ${i}:`, r) + await esc() +} + +await cdp.eval('window.__CMDK__.stop()') + +const stat = k => { + const v = samples.map(s => s[k]).filter(n => n >= 0).sort((a, b) => a - b) + + if (!v.length) return null + + return { + min: v[0], + median: v[Math.floor(v.length / 2)], + max: v[v.length - 1] + } +} + +console.log('\nkeydown → dialog frame painted (ms):', stat('frame_ms')) +console.log('keydown → rows painted (ms):', stat('rows_ms')) +console.log('long-task time in window (ms):', stat('longtask_ms')) + +cdp.close() diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index ff7af6b5c91..260e22db955 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -1,7 +1,7 @@ import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { Dialog as DialogPrimitive } from 'radix-ui' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud' @@ -235,6 +235,70 @@ const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => { // theme lists under both Light and Dark). The id suffix disambiguates. const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}` +const EMPTY_GROUPS: PaletteGroup[] = [] + +// Backstop only. The palette normally retires on the content's real +// `animationend`, so the CSS owns the close duration; this just guarantees the +// body can't stay mounted forever somewhere animations never run (jsdom, +// `animation: none`). Deliberately longer than any plausible exit so it never +// races the real signal and truncates the fade. +const EXIT_FALLBACK_MS = 1000 + +/** + * The palette's row list, split out so an OPENING palette paints before it + * renders rows. This component mounts with the portal, so `useDeferredValue`'s + * initial value applies per open: the first commit is the frame + input + * (instant), and the several-hundred-row list arrives in an interruptible + * follow-up render. Opening ⌘K must never wait on building the list. + */ +const PaletteGroups = memo(function PaletteGroups({ + bindings, + groups, + modHeld, + noResultsLabel, + onSelectItem, + onSelectMods, + search +}: { + bindings: Record + groups: PaletteGroup[] + modHeld: boolean + noResultsLabel: string + onSelectItem: (item: PaletteItem) => void + onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void + search: string +}) { + const deferred = useDeferredValue(groups, EMPTY_GROUPS) + // While the rows are still catching up, an empty list means "not rendered + // yet", not "nothing matched" — don't flash the empty state on open. + const pending = deferred !== groups + + return ( + <> + {/* Filtering happens in rankGroups, so cmdk's own CommandEmpty + (keyed to its internal filter count) would never fire. */} + {deferred.length === 0 && !pending && ( +
{noResultsLabel}
+ )} + {deferred.map((group, index) => ( + + {group.items.map(item => ( + + ))} + + ))} + + ) +}) + const PaletteRow = memo(function PaletteRow({ bindings, item, @@ -277,7 +341,9 @@ const PaletteRow = memo(function PaletteRow({ )} {item.detail && {item.detail}} - {combo && } + {combo && ( + + )} {item.to && } {item.active && } @@ -375,9 +441,68 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean { return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5 } +/** + * ⌘K is an overlay that is stateful to itself: pressing it must open a frame + * immediately, and must not be held up by whatever else the shell is doing. So + * the mounted cost of a CLOSED palette is one store subscription and nothing + * else. + * + * Everything expensive — a dozen store subscriptions (connection, update + * status/apply, keybinds, worktrees, projects, theme, i18n), three server + * queries, and the group builders that assemble a few hundred rows — lives in + * `CommandPaletteBody`, which only exists while the palette is on screen. + * Before this split those hooks ran on every render of the always-mounted + * component: an in-flight update rewrote `$updateApply` per progress line and + * rebuilt the entire row set each time, for a surface nobody could see. + * + * `mounted` lags `open` by the close animation rather than tracking it exactly. + * Unmounting the body the instant `open` flips false would rip the content out + * of the tree before Radix could play `data-[state=closed]`, so the overlay + * would vanish instead of closing. The body reports its own exit via + * `onExited` (the content's real `animationend`), so nothing here has to know + * how long that animation is — the CSS owns the duration. + * + * The `openCount` key remounts the body per open, which is what lets local + * search/sub-page state reset without a close effect. + */ export function CommandPalette() { - const { t } = useI18n() const open = useStore($commandPaletteOpen) + const [mounted, setMounted] = useState(open) + const [openCount, setOpenCount] = useState(0) + + const retire = useCallback(() => { + // Only retire the body if the palette is still closed — a reopen mid-fade + // must not unmount the fresh instance. + if (!$commandPaletteOpen.get()) { + setMounted(false) + } + }, []) + + useEffect(() => { + if (open) { + setOpenCount(count => count + 1) + setMounted(true) + + return + } + + // Safety net for environments where the exit animation never runs (jsdom, + // `animation: none`), so the body can't be stranded mounted. The real + // unmount is `onExited` below; whichever fires first wins. + const timer = setTimeout(retire, EXIT_FALLBACK_MS) + + return () => clearTimeout(timer) + }, [open, retire]) + + return ( + + {mounted && } + + ) +} + +function CommandPaletteBody({ onExited }: { onExited: () => void }) { + const { t } = useI18n() const pendingPage = useStore($commandPalettePage) const bindings = useStore($bindings) const worktrees = useStore($repoWorktrees) @@ -439,12 +564,6 @@ export function CommandPalette() { const [modHeld, setModHeld] = useState(false) useEffect(() => { - if (!open) { - setModHeld(false) - - return - } - const sync = (event: KeyboardEvent) => setModHeld(event.metaKey || event.ctrlKey) const clear = () => setModHeld(false) @@ -457,26 +576,25 @@ export function CommandPalette() { window.removeEventListener('keyup', sync, { capture: true }) window.removeEventListener('blur', clear) } - }, [open]) + }, []) - // Server-backed sources for the type-to-search groups, fetched lazily while - // the palette is open. react-query handles caching/dedup/staleness. + // Server-backed sources for the type-to-search groups. This component only + // exists while the palette is open, so the queries are inherently lazy — no + // `enabled` gate needed. react-query handles caching/dedup/staleness, so a + // reopen paints from cache and revalidates in the background. const configQuery = useQuery({ queryKey: ['command-palette', 'config'], - queryFn: getHermesConfigRecord, - enabled: open + queryFn: getHermesConfigRecord }) const sessionsQuery = useQuery({ queryKey: ['command-palette', 'sessions'], - queryFn: () => listAllProfileSessions(200, 1, 'exclude'), - enabled: open + queryFn: () => listAllProfileSessions(200, 1, 'exclude') }) const archivedQuery = useQuery({ queryKey: ['command-palette', 'archived'], - queryFn: () => listAllProfileSessions(200, 0, 'only'), - enabled: open + queryFn: () => listAllProfileSessions(200, 0, 'only') }) const mcpServers = useMemo(() => { @@ -490,21 +608,16 @@ export function CommandPalette() { const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data]) const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data]) - // Reset the query/sub-page on close so it reopens clean. - useEffect(() => { - if (!open) { - setSearch('') - setPage(null) - } - }, [open]) + // Search/sub-page are local to a mount, and this component remounts per open + // (keyed by open count), so each open starts clean without a reset effect. // Deep-link into a nested page (e.g. `/pet list` → pets picker). useEffect(() => { - if (open && pendingPage) { + if (pendingPage) { setPage(pendingPage) $commandPalettePage.set(null) } - }, [open, pendingPage]) + }, [pendingPage]) const go = useCallback((path: string) => () => navigateToWorkspacePage(navigate, path), [navigate]) @@ -1099,102 +1212,92 @@ export function CommandPalette() { } return ( - - - {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */} - - + {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */} + + { + if (event.target === event.currentTarget && event.currentTarget.dataset.state === 'closed') { + onExited() + } + }} + > + {t.commandCenter.paletteTitle} + + {activePage && ( + )} - > - {t.commandCenter.paletteTitle} - - {activePage && ( - + { + // Capture modifiers before cmdk's Enter fires onSelect (which + // swipes the inviting MouseEvent and hands us nothing). + noteSelectMods(event) + + if (!activePage) { + return + } + + // In a submenu: Esc and empty-input Backspace step back out + // instead of closing the whole palette. + if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) { + event.preventDefault() + event.stopPropagation() + goBack() + + return + } + }} + onValueChange={setSearch} + placeholder={placeholder} + right={page === 'pets' ? : undefined} + value={search} + /> + + {/* Server-driven pages render their own list; the rest show groups. */} + {page === 'pets' ? ( + { + closeCommandPalette() + openPetGenerate() + }} + search={search} + /> + ) : page === 'install-theme' ? ( + + ) : ( + )} - { - // Capture modifiers before cmdk's Enter fires onSelect (which - // swipes the inviting MouseEvent and hands us nothing). - noteSelectMods(event) - - if (!activePage) { - return - } - - // In a submenu: Esc and empty-input Backspace step back out - // instead of closing the whole palette. - if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) { - event.preventDefault() - event.stopPropagation() - goBack() - - return - } - }} - onValueChange={setSearch} - placeholder={placeholder} - right={page === 'pets' ? : undefined} - value={search} - /> - - {/* Server-driven pages render their own list; the rest show groups. */} - {page === 'pets' ? ( - { - closeCommandPalette() - openPetGenerate() - }} - search={search} - /> - ) : page === 'install-theme' ? ( - - ) : ( - <> - {/* Filtering happens in rankGroups, so cmdk's own CommandEmpty - (keyed to its internal filter count) would never fire. */} - {visibleGroups.length === 0 && ( -
{t.commandCenter.noResults}
- )} - {visibleGroups.map((group, index) => ( - - {group.items.map(item => ( - - ))} - - ))} - - )} -
-
-
-
-
+ + + + ) } From b5ca90050886b171da2802cf27b80955c3d18cb5 Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:08:51 +0000 Subject: [PATCH 16/17] fmt(js): `npm run fix` on merge (#74694) Co-authored-by: github-actions[bot] --- .../packages/hermes-ink/src/ink/hooks/use-terminal-title.ts | 5 ++++- ui-tui/src/app/useMainApp.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts index 08508bb770f..b3ec7a0fa54 100644 --- a/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts @@ -29,13 +29,15 @@ export function useTerminalTitle(title: string | TerminalTitlePair | null): void } if (process.platform === 'win32') { - const clean = stripAnsi(typeof title === 'string' ? title : title.window ?? title.tab ?? '') + const clean = stripAnsi(typeof title === 'string' ? title : (title.window ?? title.tab ?? '')) process.title = clean + return } if (typeof title === 'string') { writeRaw(osc(OSC.SET_TITLE_AND_ICON, stripAnsi(title))) + return } @@ -43,6 +45,7 @@ export function useTerminalTitle(title: string | TerminalTitlePair | null): void // show the short session name instead of a truncated tail. const tab = stripAnsi(title.tab ?? '') const window = stripAnsi(title.window ?? '') + if (tab && window) { writeRaw(osc(OSC.SET_ICON, tab) + osc(OSC.SET_TITLE, window)) } else if (window) { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 19729f080e0..5766ebb6a7e 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -629,7 +629,7 @@ export function useMainApp(gw: GatewayClient) { model ? { tab: composeTabTitle(marker, ui.sessionTitle, '', ''), - window: composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : ''), + window: composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : '') } : 'Hermes' ) From 9accf79d833f4aeaca2061422bbbc0d920ca70e7 Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:19:58 +0000 Subject: [PATCH 17/17] fmt(js): `npm run fix` on merge (#74702) Co-authored-by: github-actions[bot] --- apps/desktop/src/components/assistant-ui/thread/list.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index ba4b7964152..a9aff632e81 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -325,6 +325,7 @@ const ThreadMessageListInner: FC = ({ const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget) const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups + // Where the always-rendered live tail begins. Derived from the WEIGHTED // groups (parts, not turns) so the tail is a viewport's worth of content — // see liveTailStart. Computed once here rather than per row. @@ -332,6 +333,7 @@ const ThreadMessageListInner: FC = ({ () => liveTailStart(hiddenCount > 0 ? weightedGroups.slice(hiddenCount) : weightedGroups), [weightedGroups, hiddenCount] ) + // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out) // hide the titlebar tool cluster + session header, but the OS traffic lights // still sit in the top-left, so reserve the titlebar gap above the transcript.