From d62979a6f34f64f2ed840f159aac66e24d7cad78 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 12 Jun 2026 08:30:06 -0500 Subject: [PATCH] feat(desktop): composer status stack, live subagent windows, editable prompts (#44630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): session-scoped status stack + kill new-window theme flash Stack subagents, background tasks, and the queue into one collapsible "sink" above the composer, reusing the queue's chrome so every status reads as one piece. Extracts shared StatusSection / StatusRow / TerminalOutput primitives and a unified $statusItemsBySession store (subagents mirrored, background owned here, merged + grouped for render). Renames BrailleSpinner → GlyphSpinner now that it drives more than braille. Separately, fix the white flash on every new/cmd-clicked window: macOS `vibrancy` paints an NSVisualEffectView that follows the OS appearance and ignores `backgroundColor`, so a dark app on a light-mode Mac flashed white until the renderer painted over it. Pin `nativeTheme.themeSource` to the app theme (persisted to userData so cold launches paint right before the renderer loads), hold windows with `show:false` until `ready-to-show`, and pre-paint the themed background via an inline script before the bundle runs. * feat(desktop): dock the slash popover to the composer via one shared fill var The slash·@ popover (and ? help) now docks onto the composer's edge with the same chrome as the queue/status stack — rounded outer corners, fused borderless edge, no shadow — but keeps its own narrow width. Surface + drawer paint a single --composer-fill var; the state ladder (rest / scrolled / focused / drawer-open) lives once in styles.css on [data-slot='composer-root']. The :has() drawer-open rule is last and forces an opaque fill, since translucent glass sampling different backdrops (thread vs fade gradient) can never match. This replaces the focus-within !important override that repainted the surface behind every previous matching attempt. Also drop the chevron column from the project file tree — the folder open/closed icon already carries the expand state. * feat(desktop): base inset for file tree rows (post-chevron alignment) * feat(desktop): wire the status stack's background tasks to the real process registry The background group was UI-only (dev-mock seeded). Now it's live e2e: - tui_gateway: new session-scoped `process.list` (registry snapshot filtered by the session's session_key, plus a 4KB output tail for the inline terminal viewer) and `process.kill` (single process, ownership-checked — unlike process.stop's kill_all). - Renderer: `reconcileBackgroundProcesses` syncs snapshots into the store layout-stably — rows keep their position when state flips (never re-sort), new processes append, unchanged rows keep object identity so memoised rows skip re-rendering, and a dismissed-set stops the registry's retained finished procs from resurrecting X-ed rows. - Refresh triggers: session open, terminal/process tool.complete, status.update(kind=process) from the gateway's notification poller, and a 5s poll armed only while a running row is visible (catches silent exits). - Stop = real `process.kill` + optimistic dismiss; Dismiss = client-side with resurrection guard. - Re-keyed the stack to the RUNTIME session id: it was keyed by the stored session id, where neither subagent events nor process.list would ever land. - Deleted dev-status-mocks.ts (__hermesStatusMocks) — no more seed shit. Reconcile invariants covered in store/composer-status.test.ts. * feat(desktop): todos + openable subagents in the status stack, self-healing file tree - todo lists move out of the inline chat panel into the composer status stack (checklist icon, dashed ring = pending, spinner = in progress, check = done), fed live from todo tool events and seeded from history on session open - subagent rows carry the child's real session id end-to-end (delegate_tool → gateway → renderer) so clicking one opens ITS session window - status stack publishes its measured height so the thread's bottom clearance grows with it; card paints the shared --composer-fill so focused/scrolled states match the composer exactly - file tree self-heals: ENOENT roots retry on a 3s cadence + Try again button, and the main process expands ~ in IPC paths (gateway cwds arrive as ~/...) - composer drag-drop of tree entries inserts inline refs instead of attachments * fix(desktop): file tree falls back to the workspace dir when a session's cwd is gone Sessions record their launch cwd; deleted worktrees leave that path dead, so opening such a session swapped the tree from the default workspace to a directory that ENOENTs forever — the 3s retry just spun on it. On a root read error the tree now asks main to sanitize the cwd (prefers the configured default project dir), displays that fallback, and quietly re-probes the original path so it switches back if the dir reappears. * feat(desktop): working restore-checkpoint button on past user prompts The discard icon on hover of a past user bubble was decorative — clicking did nothing. It's now a real control: a confirmation dialog explains that everything after the prompt is removed, then the session rewinds to that turn and reruns the same prompt (prompt.submit with truncate_before_user_ordinal, the same mechanism the edit composer uses). Failures rethrow into the dialog's inline error instead of toasting. * fix(desktop): show the restore-checkpoint button on the latest user prompt too Restoring the most recent prompt is just 'retry this turn' — no reason to exclude it. Stop still takes the slot while the turn is running. * fix(desktop): finished todo lists clear themselves out of the status stack A list whose every item is completed/cancelled lingers ~4s so the final checkmark is visible, then the todo group drops out of the stack. A fresh active list arriving within the linger cancels the scheduled clear. * chore(desktop): drop dead editableCheckpoint copy, terser restore confirm * fix(desktop): rewind clears the abandoned timeline's todos + background Restoring to (or editing) an earlier prompt rewinds the conversation, but the todos and background processes spawned by the now-discarded turns kept showing in the status stack — and the real background processes kept running. Both rewind paths now clear the session's todo rows and kill + drop its background processes before the fresh run repopulates them. Also drops the click-to-edit clamp transition, which flashed a half-expanded bubble on the way into the edit composer. * feat(desktop): user messages are always editable; edit/restore revert mid-stream The bubble is now always click-to-edit — even while a turn streams — instead of going inert during a run. Sending an edit acts like restore: it rewinds to that prompt and re-runs with the new text. Both edit and restore can fire mid-stream now; the gateway refuses prompt.submit while a turn runs (4009 "session busy"), so they interrupt the live turn first and retry the submit until the cooperative interrupt winds it down. Restore (re-run as-is) shows on every prompt except the latest running one, which keeps the Stop button. * fix(desktop): label preview-pane ⌘L selections with the filename, not "zsh" The terminal owns a global ⌘/Ctrl+L "send selection to composer" shortcut, so selecting text in the file preview pane and hitting it fell through to the terminal handler — which imported the right text but labelled the composer ref "zsh:N lines" off the shell name. When the selection isn't an xterm selection, label it with the previewed file instead. * fix(desktop): ⌘L on a preview line selection inserts the @line ref, like dragging The source preview lets you select lines in the gutter and drag them into the composer as an @line:path:start-end ref. ⌘/Ctrl+L now does the same when a line selection is active — it drops the identical ref instead of falling through to the terminal's global handler (which grabbed the native text selection and sent a bogus terminal block). Capture-phase + stopPropagation so it wins; with a line selection there's no native selection, so the terminal handler stays out of it. * chore: gitignore apps/desktop/demo/ scratch output The desktop demo prompt writes demo/*.txt during recorded walkthroughs; it's throwaway, never part of the app. Ignore it so it stops cluttering git status. * feat(desktop): subagent watch windows, hard stop, sidebar hygiene Child-session mirror for live subagent windows, delegate sessions tagged and excluded from the sidebar, composer focus/stop polish, and WS stall resilience on the gateway transport. * refactor: DRY delegate SQL + trim status-stack noise Extract shared listable-child and delegate-delete helpers in hermes_state, collapse cancelRun busy release, and cut comment bloat in resume/status paths. * fix(desktop): hide orphaned subagent sessions in sidebar Cascade-delete all ephemeral children on parent delete (not just tagged rows), run v16 backfill to tag legacy orphans, and record new delegates as source=subagent. * fix: restore orphan contract for untagged children + lazy session eviction Cascade-delete only _delegate_from-tagged rows (v16 backfill covers legacy), walk marker chains recursively with FK-safe orphaning, gate lazy watch sessions out of the still-starting eviction exemption via an explicit flag, pass session_id to _make_agent only when resuming, and hide source=subagent from session search. * fix(gateway): gate child mirror off upgraded sessions + age out stale run entries Review findings: the mirror could interleave synthetic events with a real native stream once a watch window upgrades (prompt.submit builds an agent), and a lost subagent.complete left _active_child_runs pinning running=true forever. Mirror now stops when the live session owns an agent; liveness reads ignore entries older than an hour. * fix(gateway): reject prompt.submit into a watch session while its child runs A lazy watch session's running flag is False (the run lives in the parent turn), so typing mid-run sailed past the busy guard and built a second agent racing the in-flight child on the same stored session. Busy error until the run completes; afterwards the submit upgrades into a normal conversation. * refactor(gateway): DRY watch-resume payload + compose listable-child SQL Fold the duplicated child-run busy overlay into one _reuse_live_payload helper across both resume reuse paths, collapse the twin mirror early-returns, and build _LISTABLE_CHILD_SQL from _BRANCH_CHILD_SQL instead of restating it. * fix(desktop): clip horizontal overflow on sidebar scroll areas Add overflow-x-hidden alongside overflow-y-auto on session list scrollers and the shared SidebarContent primitive — vertical scroll unchanged. --- .gitignore | 4 + apps/desktop/electron/hardening.cjs | 10 +- apps/desktop/electron/hardening.test.cjs | 13 + apps/desktop/electron/main.cjs | 271 ++++++++++------ apps/desktop/electron/preload.cjs | 3 +- apps/desktop/electron/session-windows.cjs | 23 +- .../desktop/electron/session-windows.test.cjs | 6 + apps/desktop/index.html | 22 ++ apps/desktop/src/app/agents/index.tsx | 10 +- .../app/chat/composer/completion-drawer.tsx | 32 +- .../src/app/chat/composer/context-menu.tsx | 3 +- .../src/app/chat/composer/controls.tsx | 12 +- apps/desktop/src/app/chat/composer/focus.ts | 10 + .../src/app/chat/composer/help-hint.tsx | 31 +- apps/desktop/src/app/chat/composer/index.tsx | 99 +++--- .../src/app/chat/composer/inline-refs.ts | 119 +++++--- .../src/app/chat/composer/queue-panel.tsx | 157 ++++------ .../src/app/chat/composer/rich-editor.test.ts | 45 ++- .../src/app/chat/composer/rich-editor.ts | 33 ++ .../app/chat/composer/status-stack/index.tsx | 194 ++++++++++++ .../chat/composer/status-stack/status-row.tsx | 155 ++++++++++ .../src/app/chat/composer/trigger-popover.tsx | 10 +- .../app/chat/hooks/use-composer-actions.ts | 24 +- apps/desktop/src/app/chat/index.tsx | 15 +- .../src/app/chat/right-rail/preview-file.tsx | 36 +++ .../app/chat/sidebar/cron-jobs-section.tsx | 2 +- apps/desktop/src/app/chat/sidebar/index.tsx | 22 +- .../app/chat/sidebar/virtual-session-list.tsx | 2 +- .../desktop/src/app/command-palette/index.tsx | 8 +- apps/desktop/src/app/desktop-controller.tsx | 34 ++- .../src/app/right-sidebar/files/tree.tsx | 22 +- .../files/use-project-tree.test.ts | 30 ++ .../right-sidebar/files/use-project-tree.ts | 103 ++++++- apps/desktop/src/app/right-sidebar/index.tsx | 61 ++-- .../src/app/right-sidebar/terminal/index.tsx | 4 +- .../app/right-sidebar/terminal/selection.ts | 2 - .../terminal/use-terminal-session.ts | 26 +- .../app/session/hooks/use-message-stream.ts | 56 +++- .../session/hooks/use-prompt-actions.test.tsx | 130 +++++++- .../app/session/hooks/use-prompt-actions.ts | 173 +++++++++-- .../app/session/hooks/use-session-actions.ts | 53 ++-- apps/desktop/src/app/shell/keybind-panel.tsx | 15 +- apps/desktop/src/app/shell/titlebar.ts | 5 +- .../components/assistant-ui/clarify-tool.tsx | 6 +- .../assistant-ui/streaming.test.tsx | 80 +---- .../src/components/assistant-ui/thread.tsx | 127 ++++++-- .../src/components/assistant-ui/todo-tool.tsx | 109 ------- .../components/assistant-ui/tool-fallback.tsx | 9 +- .../src/components/chat/composer-dock.ts | 31 ++ .../src/components/chat/status-row.tsx | 68 +++++ .../src/components/chat/status-section.tsx | 42 +++ .../src/components/chat/terminal-output.tsx | 50 +++ .../components/model-visibility-dialog.tsx | 8 +- apps/desktop/src/components/ui/button.tsx | 12 +- ...{braille-spinner.tsx => glyph-spinner.tsx} | 28 +- apps/desktop/src/components/ui/kbd.tsx | 95 +++++- apps/desktop/src/components/ui/sidebar.tsx | 2 +- apps/desktop/src/global.d.ts | 9 +- apps/desktop/src/i18n/en.ts | 45 ++- apps/desktop/src/i18n/ja.ts | 77 +++-- apps/desktop/src/i18n/types.ts | 26 +- apps/desktop/src/i18n/zh-hant.ts | 71 +++-- apps/desktop/src/i18n/zh.ts | 48 ++- apps/desktop/src/lib/chat-messages.ts | 2 + apps/desktop/src/lib/todos.test.ts | 47 ++- apps/desktop/src/lib/todos.ts | 37 +++ .../desktop/src/store/composer-status.test.ts | 99 ++++++ apps/desktop/src/store/composer-status.ts | 257 ++++++++++++++++ apps/desktop/src/store/subagents.ts | 3 + apps/desktop/src/store/todos.test.ts | 47 +++ apps/desktop/src/store/todos.ts | 64 ++++ apps/desktop/src/store/windows.test.ts | 12 +- apps/desktop/src/store/windows.ts | 29 +- apps/desktop/src/styles.css | 81 +++-- apps/desktop/src/themes/context.tsx | 34 ++- hermes_state.py | 151 +++++++-- tests/test_hermes_state.py | 76 +++++ tests/test_tui_gateway_ws.py | 39 +++ tests/tui_gateway/test_protocol.py | 115 +++++++ .../tui_gateway/test_subagent_child_mirror.py | 215 +++++++++++++ tools/delegate_tool.py | 20 +- tools/session_search_tool.py | 7 +- tui_gateway/server.py | 289 +++++++++++++++++- tui_gateway/ws.py | 14 + 84 files changed, 3749 insertions(+), 917 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/status-stack/index.tsx create mode 100644 apps/desktop/src/app/chat/composer/status-stack/status-row.tsx delete mode 100644 apps/desktop/src/components/assistant-ui/todo-tool.tsx create mode 100644 apps/desktop/src/components/chat/composer-dock.ts create mode 100644 apps/desktop/src/components/chat/status-row.tsx create mode 100644 apps/desktop/src/components/chat/status-section.tsx create mode 100644 apps/desktop/src/components/chat/terminal-output.tsx rename apps/desktop/src/components/ui/{braille-spinner.tsx => glyph-spinner.tsx} (52%) create mode 100644 apps/desktop/src/store/composer-status.test.ts create mode 100644 apps/desktop/src/store/composer-status.ts create mode 100644 apps/desktop/src/store/todos.test.ts create mode 100644 apps/desktop/src/store/todos.ts create mode 100644 tests/tui_gateway/test_subagent_child_mirror.py diff --git a/.gitignore b/.gitignore index 2935832db3b..6d87318e35e 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,7 @@ scripts/out/ # stores the published notes. They are not a build artifact and must never be # committed to the repo root. See the hermes-release skill. RELEASE_v*.md + +# Desktop demo-run scratch output (hermes writes demo/*.txt during recorded +# walkthroughs). Throwaway artifacts, never part of the app. +apps/desktop/demo/ diff --git a/apps/desktop/electron/hardening.cjs b/apps/desktop/electron/hardening.cjs index 812dc3f77c7..7b568ec3d11 100644 --- a/apps/desktop/electron/hardening.cjs +++ b/apps/desktop/electron/hardening.cjs @@ -1,4 +1,5 @@ const fs = require('node:fs') +const os = require('node:os') const path = require('node:path') const { fileURLToPath } = require('node:url') @@ -142,7 +143,14 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') { function resolveRequestedPathForIpc(filePath, options = {}) { const purpose = String(options.purpose || 'File read') - const raw = rejectUnsafePathSyntax(filePath, purpose) + let raw = rejectUnsafePathSyntax(filePath, purpose) + + // Gateway-reported cwds (config `terminal.cwd`, remote sessions) routinely + // arrive as `~/...`. Node's fs has no shell — without expansion the path + // resolves under process.cwd() and every read "ENOENT"s forever. + if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) { + raw = path.join(os.homedir(), raw.slice(1)) + } if (/^file:/i.test(raw)) { let resolvedPath diff --git a/apps/desktop/electron/hardening.test.cjs b/apps/desktop/electron/hardening.test.cjs index a52ee27c830..b38a03b0082 100644 --- a/apps/desktop/electron/hardening.test.cjs +++ b/apps/desktop/electron/hardening.test.cjs @@ -106,6 +106,19 @@ test('resolveRequestedPathForIpc resolves relative paths from the trimmed base d ) }) +test('resolveRequestedPathForIpc expands ~ to the home directory', () => { + assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir())) + assert.equal( + resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }), + path.resolve(os.homedir(), 'www/project') + ) + // `~user` shorthand is NOT expanded — only the caller's own home. + assert.equal( + resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }), + path.resolve(os.tmpdir(), '~other/secret') + ) +}) + test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-')) t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 2dd0a68d0d2..336e105c7d8 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -26,7 +26,12 @@ const { pathToFileURL } = require('node:url') const { execFileSync, spawn } = require('node:child_process') const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') const { runBootstrap } = require('./bootstrap-runner.cjs') -const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs') +const { + buildSessionWindowUrl, + createSessionWindowRegistry, + SESSION_WINDOW_MIN_HEIGHT, + SESSION_WINDOW_MIN_WIDTH +} = require('./session-windows.cjs') const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { adoptServedDashboardToken } = require('./dashboard-token.cjs') @@ -36,10 +41,7 @@ const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-ma const { buildDesktopBackendEnv } = require('./backend-env.cjs') const { readDirForIpc } = require('./fs-read-dir.cjs') const { gitRootForIpc } = require('./git-root.cjs') -const { - OFFICIAL_REPO_HTTPS_URL, - isOfficialSshRemote -} = require('./update-remote.cjs') +const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') const { buildPosixCleanupScript, buildWindowsCleanupScript, @@ -348,10 +350,58 @@ const APP_ICON_PATHS = [ let rendererTitleBarTheme = null const terminalSessions = new Map() +// Force the NATIVE window appearance (vibrancy material, titlebar, the +// pre-first-paint window background) to follow the APP theme instead of the +// OS appearance. With `vibrancy` set, macOS paints an NSVisualEffectView that +// tracks the window's effective appearance and ignores `backgroundColor` — +// so a dark-themed app on a light-mode Mac flashes a white material on every +// new window until the renderer covers it. The renderer reports its mode via +// 'hermes:native-theme' ('dark' | 'light' | 'system'); we pin +// nativeTheme.themeSource to it and persist the value so cold launches paint +// correctly before the renderer has even loaded. +const NATIVE_THEME_CONFIG_PATH = path.join(app.getPath('userData'), 'native-theme.json') +const THEME_SOURCES = new Set(['dark', 'light', 'system']) + +function readPersistedThemeSource() { + try { + const parsed = JSON.parse(fs.readFileSync(NATIVE_THEME_CONFIG_PATH, 'utf8')) + + if (parsed && THEME_SOURCES.has(parsed.themeSource)) { + return parsed.themeSource + } + } catch { + // Missing / malformed → follow the OS like a fresh install. + } + + return 'system' +} + +function writePersistedThemeSource(mode) { + try { + fs.mkdirSync(path.dirname(NATIVE_THEME_CONFIG_PATH), { recursive: true }) + fs.writeFileSync(NATIVE_THEME_CONFIG_PATH, JSON.stringify({ themeSource: mode }, null, 2), 'utf8') + } catch (error) { + rememberLog(`[theme] write native theme failed: ${error.message}`) + } +} + +nativeTheme.themeSource = readPersistedThemeSource() + function isHexColor(value) { return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) } +// Background color to paint a window with BEFORE its renderer loads, so a new +// (or reopened) window doesn't flash white/light in dark mode. Prefer the theme +// the renderer last reported; fall back to the OS preference on first launch. +function getWindowBackgroundColor() { + if (rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.background)) { + return rendererTitleBarTheme.background + } + + return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7' +} + function getTitleBarOverlayOptions() { if (IS_MAC) { return { height: TITLEBAR_HEIGHT } @@ -1164,10 +1214,14 @@ function findSystemPython() { if (pyExe) { for (const version of SUPPORTED_VERSIONS) { try { - const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({ - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'] - })) + const out = execFileSync( + pyExe, + [`-${version}`, '-c', 'import sys; print(sys.executable)'], + hiddenWindowsChildOptions({ + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }) + ) const candidate = out.trim() if (candidate && fileExists(candidate)) return candidate } catch { @@ -1302,11 +1356,15 @@ function resolveUpdateRoot() { function runGit(args, options = {}) { return new Promise((resolve, reject) => { - const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({ - cwd: options.cwd, - env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' }, - stdio: ['ignore', 'pipe', 'pipe'] - })) + const child = spawn( + resolveGitBinary(), + IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, + hiddenWindowsChildOptions({ + cwd: options.cwd, + env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + ) let stdout = '' let stderr = '' @@ -1743,11 +1801,15 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { return new Promise(resolve => { let child try { - child = spawn(command, args, hiddenWindowsChildOptions({ - cwd, - env: { ...process.env, ...(env || {}) }, - stdio: ['ignore', 'pipe', 'pipe'] - })) + child = spawn( + command, + args, + hiddenWindowsChildOptions({ + cwd, + env: { ...process.env, ...(env || {}) }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + ) } catch (err) { resolve({ code: 1, error: err.message }) return @@ -4569,25 +4631,29 @@ async function spawnPoolBackend(profile, entry) { rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`) - const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({ - cwd: hermesCwd, - env: { - ...process.env, - HERMES_HOME, - ...backend.env, - // Pin the gateway's tool/terminal cwd to the same directory we chose for - // the child process. Inherited TERMINAL_CWD (or a stale config bridge) - // can still point at the install dir even when spawn cwd is home. - TERMINAL_CWD: hermesCwd, - HERMES_DASHBOARD_SESSION_TOKEN: token, - // Marks this dashboard backend as desktop-spawned so it runs the cron - // scheduler tick loop (the gateway isn't running under the app). - HERMES_DESKTOP: '1', - HERMES_WEB_DIST: webDist - }, - shell: backend.shell, - stdio: ['ignore', 'pipe', 'pipe'] - })) + const child = spawn( + backend.command, + backend.args, + hiddenWindowsChildOptions({ + cwd: hermesCwd, + env: { + ...process.env, + HERMES_HOME, + ...backend.env, + // Pin the gateway's tool/terminal cwd to the same directory we chose for + // the child process. Inherited TERMINAL_CWD (or a stale config bridge) + // can still point at the install dir even when spawn cwd is home. + TERMINAL_CWD: hermesCwd, + HERMES_DASHBOARD_SESSION_TOKEN: token, + // Marks this dashboard backend as desktop-spawned so it runs the cron + // scheduler tick loop (the gateway isn't running under the app). + HERMES_DESKTOP: '1', + HERMES_WEB_DIST: webDist + }, + shell: backend.shell, + stdio: ['ignore', 'pipe', 'pipe'] + }) + ) entry.process = child entry.port = port entry.token = token @@ -4784,30 +4850,34 @@ async function startHermes() { await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84) rememberLog(`Starting Hermes backend via ${backend.label}`) - hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({ - cwd: hermesCwd, - env: { - ...process.env, - // Explicitly pin HERMES_HOME for the child so Python's get_hermes_home() - // resolves to the SAME location our resolveHermesHome() picked. Without - // this pin, Python falls back to ~/.hermes on every platform — fine on - // mac/linux (where our default matches), but on Windows our default is - // %LOCALAPPDATA%\hermes, which differs from C:\Users\\.hermes. - // Mismatch would split config / sessions / .env / logs across two - // directories. install.ps1 sets HERMES_HOME via setx; the desktop - // can't reliably do that, so we set it inline for every spawn. - HERMES_HOME, - ...backend.env, - TERMINAL_CWD: hermesCwd, - HERMES_DASHBOARD_SESSION_TOKEN: token, - // Marks this dashboard backend as desktop-spawned so it runs the cron - // scheduler tick loop (the gateway isn't running under the app). - HERMES_DESKTOP: '1', - HERMES_WEB_DIST: webDist - }, - shell: backend.shell, - stdio: ['ignore', 'pipe', 'pipe'] - })) + hermesProcess = spawn( + backend.command, + backend.args, + hiddenWindowsChildOptions({ + cwd: hermesCwd, + env: { + ...process.env, + // Explicitly pin HERMES_HOME for the child so Python's get_hermes_home() + // resolves to the SAME location our resolveHermesHome() picked. Without + // this pin, Python falls back to ~/.hermes on every platform — fine on + // mac/linux (where our default matches), but on Windows our default is + // %LOCALAPPDATA%\hermes, which differs from C:\Users\\.hermes. + // Mismatch would split config / sessions / .env / logs across two + // directories. install.ps1 sets HERMES_HOME via setx; the desktop + // can't reliably do that, so we set it inline for every spawn. + HERMES_HOME, + ...backend.env, + TERMINAL_CWD: hermesCwd, + HERMES_DASHBOARD_SESSION_TOKEN: token, + // Marks this dashboard backend as desktop-spawned so it runs the cron + // scheduler tick loop (the gateway isn't running under the app). + HERMES_DESKTOP: '1', + HERMES_WEB_DIST: webDist + }, + shell: backend.shell, + stdio: ['ignore', 'pipe', 'pipe'] + }) + ) hermesProcess.stdout.on('data', rememberLog) hermesProcess.stderr.on('data', rememberLog) @@ -4945,21 +5015,28 @@ function focusWindow(win) { } // Open (or focus) a standalone window for a single chat session. -function createSessionWindow(sessionId) { +function createSessionWindow(sessionId, { watch = false } = {}) { return sessionWindows.openOrFocus(sessionId, () => { const icon = getAppIconPath() const win = new BrowserWindow({ - width: 480, - height: 800, - minWidth: 420, - minHeight: 620, + width: SESSION_WINDOW_MIN_WIDTH, + height: SESSION_WINDOW_MIN_HEIGHT, + minWidth: SESSION_WINDOW_MIN_WIDTH, + minHeight: SESSION_WINDOW_MIN_HEIGHT, title: 'Hermes', titleBarStyle: 'hidden', titleBarOverlay: getTitleBarOverlayOptions(), trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, vibrancy: IS_MAC ? 'sidebar' : undefined, icon, - backgroundColor: '#f7f7f7', + // Don't show until the renderer's first themed paint is ready. macOS + // `vibrancy` ignores `backgroundColor` and paints a translucent OS + // material (which follows the OS appearance, not the app theme), so a + // dark-themed app on a light-mode Mac flashes white until the renderer + // covers it. ready-to-show fires after the boot-time paint in + // themes/context.tsx, so the window appears already themed. + show: false, + backgroundColor: getWindowBackgroundColor(), webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, @@ -4974,6 +5051,10 @@ function createSessionWindow(sessionId) { win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) } + win.once('ready-to-show', () => { + if (!win.isDestroyed()) win.show() + }) + win.on('will-enter-full-screen', () => sendWindowStateChanged(true)) win.on('enter-full-screen', () => sendWindowStateChanged(true)) win.on('will-leave-full-screen', () => sendWindowStateChanged(false)) @@ -4984,7 +5065,8 @@ function createSessionWindow(sessionId) { win.loadURL( buildSessionWindowUrl(sessionId, { devServer: DEV_SERVER, - rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex() + rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(), + watch }) ) @@ -5011,7 +5093,11 @@ function createWindow() { trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, vibrancy: IS_MAC ? 'sidebar' : undefined, icon, - backgroundColor: '#f7f7f7', + // Hidden until the first themed paint so macOS `vibrancy` (which ignores + // `backgroundColor` and follows the OS appearance) can't flash a light + // material before the renderer paints the app theme. See createSessionWindow. + show: false, + backgroundColor: getWindowBackgroundColor(), webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, @@ -5047,6 +5133,10 @@ function createWindow() { } } + mainWindow.once('ready-to-show', () => { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show() + }) + mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true)) mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true)) mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) @@ -5158,12 +5248,12 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => { return { ok: true } }) ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile)) -ipcMain.handle('hermes:window:openSession', async (_event, sessionId) => { +ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => { if (typeof sessionId !== 'string' || !sessionId.trim()) { return { ok: false, error: 'invalid-session-id' } } - createSessionWindow(sessionId.trim()) + createSessionWindow(sessionId.trim(), { watch: opts?.watch === true }) return { ok: true } }) @@ -5571,6 +5661,18 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => { mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions()) }) +// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH). +ipcMain.on('hermes:native-theme', (_event, mode) => { + if (!THEME_SOURCES.has(mode)) { + return + } + + if (nativeTheme.themeSource !== mode) { + nativeTheme.themeSource = mode + writePersistedThemeSource(mode) + } +}) + ipcMain.handle('hermes:openExternal', (_event, url) => { if (!openExternalUrl(url)) { throw new Error('Invalid external URL') @@ -6008,11 +6110,15 @@ async function getUninstallSummary() { resolve(value) } try { - const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({ - cwd: agentRoot, - env: { ...process.env, HERMES_HOME, NO_COLOR: '1' }, - stdio: ['ignore', 'pipe', 'ignore'] - })) + const child = spawn( + py, + ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], + hiddenWindowsChildOptions({ + cwd: agentRoot, + env: { ...process.env, HERMES_HOME, NO_COLOR: '1' }, + stdio: ['ignore', 'pipe', 'ignore'] + }) + ) child.stdout.on('data', chunk => { stdout += chunk.toString() }) @@ -6170,7 +6276,7 @@ let _rendererReadyForDeepLink = false function _extractDeepLink(argv) { if (!Array.isArray(argv)) return null - return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null + return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null } function handleDeepLink(url) { @@ -6214,9 +6320,7 @@ ipcMain.handle('hermes:deep-link-ready', () => { _pendingDeepLink = null handleDeepLink( `${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` + - (Object.keys(queued.params).length - ? '?' + new URLSearchParams(queued.params).toString() - : ''), + (Object.keys(queued.params).length ? '?' + new URLSearchParams(queued.params).toString() : '') ) } return { ok: true } @@ -6227,9 +6331,7 @@ function registerDeepLinkProtocol() { if (process.defaultApp && process.argv.length >= 2) { // Dev: register with the electron exec path + entry script so the OS can // relaunch us with the URL. - app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [ - path.resolve(process.argv[1]), - ]) + app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [path.resolve(process.argv[1])]) } else { app.setAsDefaultProtocolClient(HERMES_PROTOCOL) } @@ -6262,7 +6364,6 @@ app.on('open-url', (event, url) => { handleDeepLink(url) }) - app.whenReady().then(() => { if (IS_MAC) { Menu.setApplicationMenu(buildApplicationMenu()) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 9880d4bcf58..06302527d29 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'), touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), - openSessionWindow: sessionId => ipcRenderer.invoke('hermes:window:openSession', sessionId), + openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile), saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), @@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url), stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id), setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload), + setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode), setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url), diff --git a/apps/desktop/electron/session-windows.cjs b/apps/desktop/electron/session-windows.cjs index 8775feb1bce..172ca16c757 100644 --- a/apps/desktop/electron/session-windows.cjs +++ b/apps/desktop/electron/session-windows.cjs @@ -5,22 +5,30 @@ const { pathToFileURL } = require('node:url') +// Secondary windows open at the minimum usable size — a compact side panel for +// subagent watch / cmd-click session pop-out, not a second full desktop. +const SESSION_WINDOW_MIN_WIDTH = 420 +const SESSION_WINDOW_MIN_HEIGHT = 620 + // Build the renderer URL for a secondary window. The renderer uses a // HashRouter, so the session route lives after the '#'. The `?win=secondary` // flag MUST sit in the query string BEFORE the '#': anything after the '#' is // treated as the route by HashRouter and would break routeSessionId(). The // renderer reads the flag from window.location.search to suppress the install / -// onboarding overlays and the global session sidebar. -function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath } = {}) { +// onboarding overlays and the global session sidebar. `watch=1` marks a +// spectator window (e.g. a running subagent's session): the renderer resumes +// it lazily so the gateway never builds an agent just to stream into it. +function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) { + const query = `?win=secondary${watch ? '&watch=1' : ''}` const route = `#/${encodeURIComponent(sessionId)}` if (devServer) { const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer - return `${base}/?win=secondary${route}` + return `${base}/${query}${route}` } - return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}` + return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}` } // A small registry keyed by sessionId that guarantees one window per chat: @@ -83,4 +91,9 @@ function createSessionWindowRegistry() { } } -module.exports = { buildSessionWindowUrl, createSessionWindowRegistry } +module.exports = { + buildSessionWindowUrl, + createSessionWindowRegistry, + SESSION_WINDOW_MIN_HEIGHT, + SESSION_WINDOW_MIN_WIDTH +} diff --git a/apps/desktop/electron/session-windows.test.cjs b/apps/desktop/electron/session-windows.test.cjs index 3453971eb51..a668b0ac082 100644 --- a/apps/desktop/electron/session-windows.test.cjs +++ b/apps/desktop/electron/session-windows.test.cjs @@ -76,6 +76,12 @@ test('buildSessionWindowUrl builds a packaged file URL with the flag before the assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/) }) +test('buildSessionWindowUrl adds the watch flag for spectator windows, before the hash', () => { + const url = buildSessionWindowUrl('abc', { devServer: 'http://localhost:5173', watch: true }) + + assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc') +}) + test('registry opens one window per session and focuses on re-open', () => { const registry = createSessionWindowRegistry() let built = 0 diff --git a/apps/desktop/index.html b/apps/desktop/index.html index 0ef2dcb59ab..831478f6e93 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -9,6 +9,28 @@ Hermes +
diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index ff0aa8fb654..ec8f186dd1b 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -3,8 +3,8 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' -import { BrailleSpinner } from '@/components/ui/braille-spinner' import { FadeText } from '@/components/ui/fade-text' +import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { type Translations, useI18n } from '@/i18n' import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' @@ -25,7 +25,7 @@ import { OverlayView } from '../overlays/overlay-view' function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode { if (status === 'running' || status === 'queued') { return ( - {entry.text} {active ? ( - 0 ? (
-

{t.agents.files}

+

+ {t.agents.files} +

{fileLines.slice(0, 8).map(line => (

{line} diff --git a/apps/desktop/src/app/chat/composer/completion-drawer.tsx b/apps/desktop/src/app/chat/composer/completion-drawer.tsx index d7738cb82a7..021af0bda56 100644 --- a/apps/desktop/src/app/chat/composer/completion-drawer.tsx +++ b/apps/desktop/src/app/chat/composer/completion-drawer.tsx @@ -2,25 +2,21 @@ import type { Unstable_TriggerAdapter } from '@assistant-ui/core' import { ComposerPrimitive } from '@assistant-ui/react' import type { ReactNode } from 'react' -export const COMPLETION_DRAWER_CLASS = [ - 'absolute bottom-[calc(100%+0.375rem)] left-0 z-50', - 'w-80 max-w-[calc(100vw-2rem)]', - 'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain', - 'rounded-xl border border-(--ui-stroke-secondary)', - 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]', - 'p-1 text-xs text-popover-foreground shadow-lg', - 'backdrop-blur-md' -].join(' ') +import { composerFusedDockCard } from '@/components/chat/composer-dock' +import { cn } from '@/lib/utils' -export const COMPLETION_DRAWER_BELOW_CLASS = [ - 'absolute left-0 top-[calc(100%+0.375rem)] z-50', - 'w-80 max-w-[calc(100vw-2rem)]', - 'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain', - 'rounded-xl border border-(--ui-stroke-secondary)', - 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]', - 'p-1 text-xs text-popover-foreground shadow-lg', - 'backdrop-blur-md' -].join(' ') +// Same docked chrome as the queue/status stack, but its own thing: a narrow, +// left-aligned card (not full width) that fuses to the composer's edge instead +// of floating above it. `left-1` matches the stack's `mx-1` inset; the negative +// margin overlaps the seam so the composer's (now-transparent) edge border reads +// as shared. Fused (opaque) fill — the composer surface swaps to the same fill +// while a drawer is open, so the two paint as one panel. +const DRAWER_SHELL = + 'absolute left-1 z-50 w-80 max-w-[calc(100%-0.5rem)] max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain p-1 text-xs text-popover-foreground' + +export const COMPLETION_DRAWER_CLASS = cn(DRAWER_SHELL, 'bottom-full -mb-[9px]', composerFusedDockCard('top')) + +export const COMPLETION_DRAWER_BELOW_CLASS = cn(DRAWER_SHELL, 'top-full -mt-[9px]', composerFusedDockCard('bottom')) export function ComposerCompletionDrawer({ adapter, diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx index 3f09ec2fccb..22c10985f82 100644 --- a/apps/desktop/src/app/chat/composer/context-menu.tsx +++ b/apps/desktop/src/app/chat/composer/context-menu.tsx @@ -11,6 +11,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Kbd } from '@/components/ui/kbd' import { useI18n } from '@/i18n' import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -86,7 +87,7 @@ export function ContextMenu({

{c.tipPre} - @ + @ {c.tipPost}
diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index ed65795d1c4..8bc1a2b7cf9 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { KbdCombo } from '@/components/ui/kbd' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' @@ -63,7 +64,14 @@ export function ComposerControls({ }) { const { t } = useI18n() const c = t.composer - const steerLabel = `${c.steer} (${formatCombo('mod+enter')})` + const steerCombo = formatCombo('mod+enter') + const steerLabel = `${c.steer} (${steerCombo})` + const steerTip = ( + + {c.steer} + + + ) if (conversation.active) { return @@ -75,7 +83,7 @@ export function ComposerControls({
{canSteer && ( - +
) } + +function HotkeyRow({ combos, description }: { combos: string[]; description: string }) { + return ( +
+ + {combos.map(combo => ( + + ))} + + {description} +
+ ) +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 94e80d6bec3..b44a7ec976c 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -14,6 +14,7 @@ import { } from 'react' import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text' +import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' @@ -48,6 +49,7 @@ import { shouldAutoDrainOnSettle, updateQueuedPrompt } from '@/store/composer-queue' +import { $statusItemsBySession } from '@/store/composer-status' import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' import { useTheme } from '@/themes' @@ -80,12 +82,14 @@ import { import { QueuePanel } from './queue-panel' import { composerPlainText, + normalizeComposerEditorDom, placeCaretEnd, refChipElement, renderComposerContents, RICH_INPUT_SLOT, slashChipElement } from './rich-editor' +import { ComposerStatusStack } from './status-stack' import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' import type { ChatBarProps } from './types' @@ -168,6 +172,7 @@ export function ChatBar({ const draft = useAuiState(s => s.composer.text) const attachments = useStore($composerAttachments) const queuedPromptsBySession = useStore($queuedPromptsBySession) + const statusItemsBySession = useStore($statusItemsBySession) const scrolledUp = useStore($threadScrolledUp) const sessionMessages = useStore($messages) const activeQueueSessionKey = queueSessionKey || sessionId || null @@ -177,6 +182,17 @@ export function ChatBar({ [activeQueueSessionKey, queuedPromptsBySession] ) + // Status items (subagents, background processes) are keyed by the RUNTIME + // session id — gateway events and process.list both speak that id. Only the + // queue uses the stored-session fallback key (prompts can queue pre-resume). + const statusSessionId = sessionId ?? null + + const statusStackVisible = useMemo( + () => + queuedPrompts.length > 0 || (statusSessionId ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 : false), + [queuedPrompts.length, statusItemsBySession, statusSessionId] + ) + const composerRef = useRef(null) const composerSurfaceRef = useRef(null) const editorRef = useRef(null) @@ -602,9 +618,7 @@ export function ChatBar({ // (which drives `hasComposerPayload` → the send button). Shared by the input // and compositionend paths so committed IME text reaches state through either. const flushEditorToDraft = (editor: HTMLDivElement) => { - if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') { - editor.replaceChildren() - } + normalizeComposerEditorDom(editor) const nextDraft = composerPlainText(editor) @@ -688,8 +702,7 @@ export function ChatBar({ // already an arg pick (`/personality alice`), so it commits normally. const command = (item.metadata as { command?: string } | undefined)?.command ?? '' - const expandsToArgs = - trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command) + const expandsToArgs = trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command) const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} ` const directive = !starter && serialized.match(/^@([^:]+):(.+)$/) @@ -1113,11 +1126,8 @@ export function ChatBar({ } } - const stashAt = ( - scope: string | null, - text = draftRef.current, - attachments = $composerAttachments.get() - ) => stashSessionDraft(scope, text, attachments) + const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) => + stashSessionDraft(scope, text, attachments) // Per-thread draft swap — the composer's only session coupling. Lifecycle // never clears composer state; this effect alone stashes on leave, restores @@ -1669,6 +1679,7 @@ export function ChatBar({ className="group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]" data-drag-active={dragActive ? '' : undefined} data-slot="composer-root" + data-status-stack={statusStackVisible ? '' : undefined} data-thread-scrolled-up={scrolledUp ? '' : undefined} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} @@ -1696,26 +1707,30 @@ export function ChatBar({ onPick={replaceTriggerWithChip} /> )} - {activeQueueSessionKey && queuedPrompts.length > 0 && ( - // Out of flow so the queue never inflates the composer's measured - // height (that drives thread bottom padding → chat resizes on - // queue). Overlaps -mb-2 onto the surface's top border for a shared - // edge; capped + scrollable. Overlays the chat instead of pushing it. -
- { - if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) { - exitQueuedEdit('cancel') - } - }} - onEdit={beginQueuedEdit} - onSendNow={id => void sendQueuedNow(id)} - /> -
- )} + {/* Session-scoped status stack (todos, subagents, background tasks, + queue). Out of flow so it never inflates the composer's measured + height; it overlays the chat instead of pushing it, and publishes + its own --status-stack-measured-height so the thread's clearance + accounts for it. Collapses to nothing when every status is empty. */} + 0 ? ( + { + if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) { + exitQueuedEdit('cancel') + } + }} + onEdit={beginQueuedEdit} + onSendNow={id => void sendQueuedNow(id)} + /> + ) : null + } + sessionId={statusSessionId} + />
@@ -1824,12 +1833,8 @@ export function ChatBarFallback() { aria-hidden className={cn( 'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]', - 'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]', - 'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]', - '[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]', - 'transition-[background-color] duration-150 ease-out', - 'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]', - 'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]' + composerFill, + composerSurfaceGlass )} />
diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts index 9aae24db4c5..6e580266212 100644 --- a/apps/desktop/src/app/chat/composer/inline-refs.ts +++ b/apps/desktop/src/app/chat/composer/inline-refs.ts @@ -3,7 +3,12 @@ import { contextPath } from '@/lib/chat-runtime' import type { DroppedFile } from '../hooks/use-composer-actions' -import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor' +import { + composerPlainText, + normalizeComposerEditorDom, + placeCaretEnd, + refChipElement +} from './rich-editor' /** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */ export type InlineRefInput = string | { kind: string; label?: string; value: string } @@ -89,56 +94,102 @@ export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | n return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref)) } -export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) { - if (!refs.length) { +function parseInlineRef(ref: InlineRefInput): { kind: string; label?: string; rawValue: string } | null { + if (typeof ref !== 'string') { + return { kind: ref.kind, label: ref.label, rawValue: ref.value } + } + + const match = ref.match(/^@([^:]+):(.+)$/) + + if (!match) { return null } - const refsHtml = refs - .map(ref => { - if (typeof ref !== 'string') { - return refChipHtml(ref.kind, ref.value, ref.label) - } + return { kind: match[1] || 'file', rawValue: match[2] || '' } +} - const match = ref.match(/^@([^:]+):(.+)$/) +function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' | 'before') { + const slice = range.cloneRange() + slice.selectNodeContents(editor) - return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref) - }) - .join(' ') + if (edge === 'before') { + slice.setEnd(range.startContainer, range.startOffset) + } else { + slice.setStart(range.endContainer, range.endOffset) + } + + const container = document.createElement('div') + container.appendChild(slice.cloneContents()) + + return composerPlainText(container) +} + +function buildRefFragment( + refs: readonly { kind: string; label?: string; rawValue: string }[], + { needsBeforeSpace, needsAfterSpace }: { needsAfterSpace: boolean; needsBeforeSpace: boolean } +) { + const fragment = document.createDocumentFragment() + + if (needsBeforeSpace) { + fragment.append(document.createTextNode(' ')) + } + + refs.forEach((ref, index) => { + if (index > 0) { + fragment.append(document.createTextNode(' ')) + } + + fragment.append(refChipElement(ref.kind, ref.rawValue, ref.label)) + }) + + if (needsAfterSpace) { + fragment.append(document.createTextNode(' ')) + } + + return fragment +} + +export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) { + const parsed = refs.map(parseInlineRef).filter((ref): ref is NonNullable => ref !== null) + + if (!parsed.length) { + return null + } + + editor.focus({ preventScroll: true }) const selection = window.getSelection() - const range = selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer) ? selection.getRangeAt(0) : null - editor.focus({ preventScroll: true }) + if (range && selection) { + const beforeText = plainTextInRange(editor, range, 'before') + const afterText = plainTextInRange(editor, range, 'after') - if (range) { - const beforeRange = range.cloneRange() - beforeRange.selectNodeContents(editor) - beforeRange.setEnd(range.startContainer, range.startOffset) - const beforeContainer = document.createElement('div') - beforeContainer.appendChild(beforeRange.cloneContents()) - - const afterRange = range.cloneRange() - afterRange.selectNodeContents(editor) - afterRange.setStart(range.endContainer, range.endOffset) - const afterContainer = document.createElement('div') - afterContainer.appendChild(afterRange.cloneContents()) - - const beforeText = composerPlainText(beforeContainer) - const afterText = composerPlainText(afterContainer) - const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText) - const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText) - - document.execCommand('insertHTML', false, `${needsBeforeSpace ? ' ' : ''}${refsHtml}${needsAfterSpace ? ' ' : ''}`) + range.insertNode( + buildRefFragment(parsed, { + needsAfterSpace: afterText.length === 0 || !/^\s/.test(afterText), + needsBeforeSpace: beforeText.length > 0 && !/\s$/.test(beforeText) + }) + ) + range.collapse(false) + selection.removeAllRanges() + selection.addRange(range) } else { const current = composerPlainText(editor) + + editor.append( + buildRefFragment(parsed, { + needsAfterSpace: true, + needsBeforeSpace: current.length > 0 && !/\s$/.test(current) + }) + ) placeCaretEnd(editor) - document.execCommand('insertHTML', false, `${current && !/\s$/.test(current) ? ' ' : ''}${refsHtml} `) } + normalizeComposerEditorDom(editor) + return composerPlainText(editor) } diff --git a/apps/desktop/src/app/chat/composer/queue-panel.tsx b/apps/desktop/src/app/chat/composer/queue-panel.tsx index 33906452026..9ed2bfb4fa1 100644 --- a/apps/desktop/src/app/chat/composer/queue-panel.tsx +++ b/apps/desktop/src/app/chat/composer/queue-panel.tsx @@ -1,10 +1,7 @@ -import { useState } from 'react' - +import { StatusRow } from '@/components/chat/status-row' +import { StatusSection } from '@/components/chat/status-section' import { Button } from '@/components/ui/button' -import { DisclosureCaret } from '@/components/ui/disclosure-caret' -import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' -import { ArrowUp, Pencil, Trash2 } from '@/lib/icons' import { cn } from '@/lib/utils' import type { QueuedPromptEntry } from '@/store/composer-queue' @@ -23,108 +20,70 @@ const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) => export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) { const { t } = useI18n() const c = t.composer - const [collapsed, setCollapsed] = useState(true) if (entries.length === 0) { return null } return ( -
- + + {entries.map(entry => { + const isEditing = editingId === entry.id + const attachmentsCount = entry.attachments.length - {!collapsed && ( -
- {entries.map(entry => { - const isEditing = editingId === entry.id - const attachmentsCount = entry.attachments.length - const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow - - return ( -
- -
-

{entryPreview(entry, c)}

- {(attachmentsCount > 0 || isEditing) && ( -
- {attachmentsCount > 0 && {c.attachments(attachmentsCount)}} - {isEditing && ( - - {c.editingInComposer} - - )} -
- )} -
-
+ } + trailing={ + <> + - - - - - - - + {c.queueEdit} + + + + + } + trailingVisible={isEditing} + > +
+

{entryPreview(entry, c)}

+ {(attachmentsCount > 0 || isEditing) && ( +
+ {attachmentsCount > 0 && {c.attachments(attachmentsCount)}} + {isEditing && ( + + {c.editingInComposer} + + )}
-
- ) - })} -
- )} -
+ )} +
+ + ) + })} +
) } diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts index c04e19a048b..45204fb34a5 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' -import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor' +import { insertInlineRefsIntoEditor } from './inline-refs' +import { + composerPlainText, + normalizeComposerEditorDom, + refChipElement, + renderComposerContents, + RICH_INPUT_SLOT +} from './rich-editor' describe('renderComposerContents', () => { it('renders refs and raw text without interpreting user text as HTML', () => { @@ -16,3 +23,39 @@ describe('renderComposerContents', () => { expect(composerPlainText(editor)).toBe('@file:`` raw') }) }) + +describe('normalizeComposerEditorDom', () => { + it('unwraps a single insertHTML wrapper div so plain text stays one line', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.innerHTML = '
foo.ts
' + + normalizeComposerEditorDom(editor) + + expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ') + expect(editor.querySelector(':scope > div')).toBeNull() + }) + + it('removes a trailing br after a ref chip', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.append(refChipElement('file', '`src/foo.ts`'), document.createElement('br')) + + normalizeComposerEditorDom(editor) + + expect(composerPlainText(editor)).toBe('@file:`src/foo.ts`') + expect(editor.querySelector('br')).toBeNull() + }) +}) + +describe('insertInlineRefsIntoEditor', () => { + it('inserts chips without wrapper divs or spurious newlines', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + + insertInlineRefsIntoEditor(editor, ['@file:`src/foo.ts`']) + + expect(editor.querySelector(':scope > div')).toBeNull() + expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index ea6382f9abd..89a54b69925 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -184,3 +184,36 @@ export function placeCaretEnd(element: HTMLElement) { selection?.removeAllRanges() selection?.addRange(range) } + +/** Drop contenteditable junk that serializes as `\n` and falsely expands the composer. */ +export function normalizeComposerEditorDom(editor: HTMLElement) { + if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') { + editor.replaceChildren() + + return + } + + if (editor.childNodes.length === 1 && editor.firstChild?.nodeType === Node.ELEMENT_NODE) { + const wrapper = editor.firstChild as HTMLElement + + if (wrapper.tagName === 'DIV' && wrapper.dataset.slot !== RICH_INPUT_SLOT) { + editor.replaceChildren(...Array.from(wrapper.childNodes)) + } + } + + const last = editor.lastChild + + if (last?.nodeName !== 'BR') { + return + } + + let prev: ChildNode | null = last.previousSibling + + while (prev?.nodeType === Node.TEXT_NODE && !(prev.textContent || '').trim()) { + prev = prev.previousSibling + } + + if ((prev as HTMLElement | null)?.dataset.refText) { + editor.removeChild(last) + } +} diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx new file mode 100644 index 00000000000..cc744e0aae8 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -0,0 +1,194 @@ +import { useStore } from '@nanostores/react' +import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { useNavigate } from 'react-router-dom' + +import { blurComposerInput } from '@/app/chat/composer/focus' +import { AGENTS_ROUTE } from '@/app/routes' +import { composerDockCard } from '@/components/chat/composer-dock' +import { StatusSection } from '@/components/chat/status-section' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { type Translations, useI18n } from '@/i18n' +import { cn } from '@/lib/utils' +import { + $statusItemsBySession, + type ComposerStatusItem, + dismissBackgroundProcess, + groupStatusItems, + refreshBackgroundProcesses, + type StatusGroup, + stopBackgroundProcess +} from '@/store/composer-status' +import { $threadScrolledUp } from '@/store/thread-scroll' +import { openSessionInNewWindow } from '@/store/windows' + +import { StatusItemRow } from './status-row' + +// Slow safety-net poll for silent exits (processes without notify_on_complete +// emit no event when they die). Only armed while a running row is on screen. +const BACKGROUND_POLL_MS = 5_000 + +const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => { + if (group.type === 'todo') { + return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length) + } + + return group.type === 'subagent' ? s.subagents(group.items.length) : s.background(group.items.length) +} + +interface ComposerStatusStackProps { + /** The queue, built by the composer (it owns the queue's callbacks). Rendered + * as the last group so it stays fused to the composer like before. */ + queue: ReactNode + sessionId: null | string +} + +/** + * The status "sink" above the composer: one card (the queue's chrome) holding + * every session-scoped status — subagents, background tasks, queue — grouped by + * type and separated by light dividers. Collapses to nothing when empty. + */ +export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) { + const { t } = useI18n() + const navigate = useNavigate() + const itemsBySession = useStore($statusItemsBySession) + const scrolledUp = useStore($threadScrolledUp) + + const groups = useMemo( + () => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []), + [itemsBySession, sessionId] + ) + + // Seed from the registry on session open; event-driven refreshes (terminal / + // process tool completions) live in use-message-stream. + useEffect(() => { + if (sessionId) { + void refreshBackgroundProcesses(sessionId) + } + }, [sessionId]) + + const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running')) + + useEffect(() => { + if (!sessionId || !hasRunningBackground) { + return + } + + const timer = setInterval(() => void refreshBackgroundProcesses(sessionId), BACKGROUND_POLL_MS) + + return () => clearInterval(timer) + }, [hasRunningBackground, sessionId]) + + const openAgents = () => navigate(AGENTS_ROUTE) + + const openSubagent = (item: ComposerStatusItem) => + item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents() + + const sections: { key: string; node: ReactNode }[] = groups.map(group => ({ + key: group.type, + node: ( + + {t.statusStack.agents} + + ) : undefined + } + defaultCollapsed={group.type !== 'todo'} + icon={ + group.type === 'todo' ? ( + + ) : undefined + } + label={groupLabel(group, t.statusStack)} + > + {group.items.map(item => ( + dismissBackgroundProcess(sessionId, id) : undefined} + onOpen={() => openSubagent(item)} + onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined} + /> + ))} + + ) + })) + + if (queue) { + sections.push({ key: 'queue', node: queue }) + } + + const visible = sections.length > 0 + const stackRef = useRef(null) + + // The stack is out of flow (overlays the thread), so the composer's measured + // height never sees it. Publish our own measured height — bucketed like the + // composer's, to avoid style invalidation churn — so the thread's + // last-message clearance can add it and the stack never hides messages. + useLayoutEffect(() => { + const root = document.documentElement + const el = stackRef.current + + if (!visible || !el) { + root.style.removeProperty('--status-stack-measured-height') + + return + } + + let last = -1 + + const sync = () => { + const bucket = Math.round(el.getBoundingClientRect().height / 8) * 8 + + if (bucket !== last) { + last = bucket + root.style.setProperty('--status-stack-measured-height', `${bucket}px`) + } + } + + const observer = new ResizeObserver(sync) + observer.observe(el) + sync() + + return () => { + observer.disconnect() + root.style.removeProperty('--status-stack-measured-height') + } + }, [visible]) + + if (!visible) { + return null + } + + return ( +
blurComposerInput()} + ref={stackRef} + > + {/* The card paints the shared --composer-fill (rest / scrolled / focused + all match the composer surface by construction); on scroll we only + ghost the CONTENT — element opacity on the card would kill the blur. */} +
+
+ {sections.map(section => ( +
{section.node}
+ ))} +
+
+
+ ) +} diff --git a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx new file mode 100644 index 00000000000..27a9ef0262c --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx @@ -0,0 +1,155 @@ +import { Fragment, memo, type ReactNode, useState } from 'react' + +import { StatusRow } from '@/components/chat/status-row' +import { TerminalOutput } from '@/components/chat/terminal-output' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { GlyphSpinner } from '@/components/ui/glyph-spinner' +import { Tip } from '@/components/ui/tooltip' +import { type Translations, useI18n } from '@/i18n' +import { ArrowUpRight, X } from '@/lib/icons' +import type { TodoStatus } from '@/lib/todos' +import { cn } from '@/lib/utils' +import type { ComposerStatusItem } from '@/store/composer-status' + +const toolLabel = (name: string) => + name + .split('_') + .filter(Boolean) + .map(part => part[0]!.toUpperCase() + part.slice(1)) + .join(' ') || name + +// Todo rows speak checkbox, not spinner-and-dot: a dashed ring while the item +// is still open (pending), codicons once it resolves, a live spinner only on +// the in-progress item. +const TODO_GLYPHS: Record, { icon: string; tone: string }> = { + cancelled: { icon: 'circle-slash', tone: 'text-muted-foreground/45' }, + completed: { icon: 'pass-filled', tone: 'text-emerald-500/80' } +} + +// Left slot: braille spinner while running, otherwise a small status dot +// (green = done, red = failed) so the slot is always filled and rows align. +function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): ReactNode { + if (item.todoStatus === 'pending') { + return ( + + ) + } + + if (item.todoStatus && item.todoStatus !== 'in_progress') { + const glyph = TODO_GLYPHS[item.todoStatus] + + return + } + + if (item.state === 'running') { + return ( + + ) + } + + return ( + + ) +} + +interface StatusItemRowProps { + item: ComposerStatusItem + /** Clear a finished background task from the stack. */ + onDismiss?: (id: string) => void + /** Open the subagent's own session window, livestreamed by the gateway's + * child-session mirror (Agents view fallback for older gateways). */ + onOpen?: () => void + /** Cancel a running background task. */ + onStop?: (id: string) => void +} + +/** + * Renders one {@link ComposerStatusItem} into the shared {@link StatusRow}. + * Memoised + keyed by id so parent re-renders never remount it (the spinner + * keeps ticking instead of resetting). + */ +export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOpen, onStop }: StatusItemRowProps) { + const { t } = useI18n() + const s = t.statusStack + const [outputOpen, setOutputOpen] = useState(false) + const failed = item.state === 'failed' + const running = item.state === 'running' + + const action = + item.type === 'background' + ? running + ? onStop && { label: s.stop, onClick: () => onStop(item.id) } + : onDismiss && { label: s.dismiss, onClick: () => onDismiss(item.id) } + : null + + const canOpen = item.type === 'subagent' && !!onOpen + const hasOutput = item.type === 'background' && !!item.output + const onActivate = canOpen ? onOpen : hasOutput ? () => setOutputOpen(open => !open) : undefined + + return ( + + + + + ) : canOpen ? ( + + ) : undefined + } + > + + {item.title} + + {item.type === 'subagent' && item.currentTool && ( + + {toolLabel(item.currentTool)} + + )} + {failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && ( + + {s.exit(item.exitCode)} + + )} + {hasOutput && } + + {hasOutput && outputOpen && } + + ) +}) diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx index dffa1ae7745..6f08a7e0347 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -1,16 +1,12 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core' import { Fragment } from 'react' -import { BrailleSpinner } from '@/components/ui/braille-spinner' import { Codicon } from '@/components/ui/codicon' +import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' -import { - COMPLETION_DRAWER_BELOW_CLASS, - COMPLETION_DRAWER_CLASS, - CompletionDrawerEmpty -} from './completion-drawer' +import { COMPLETION_DRAWER_BELOW_CLASS, COMPLETION_DRAWER_CLASS, CompletionDrawerEmpty } from './completion-drawer' const AT_ICON_BY_TYPE: Record = { diff: 'diff', @@ -87,7 +83,7 @@ export function ComposerTriggerPopover({ {items.length === 0 ? ( loading ? (
- + {copy.lookupLoading}
) : ( diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index 7b479bf4f6c..ddf38340235 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' -import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus' +import { requestComposerFocus, requestComposerInsert, requestComposerInsertRefs } from '@/app/chat/composer/focus' +import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { useI18n } from '@/i18n' import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime' @@ -286,6 +287,26 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway [currentCwd] ) + const insertContextPathInlineRef = useCallback( + (path: string, isDirectory = false) => { + if (!path) { + return false + } + + const ref = droppedFileInlineRef({ isDirectory, path }, currentCwd) + + if (!ref) { + return false + } + + requestComposerInsertRefs([ref]) + requestComposerFocus('main') + + return true + }, + [currentCwd] + ) + const attachContextFilePath = useCallback( (filePath: string) => { if (!filePath) { @@ -546,6 +567,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway attachDroppedItems, attachImageBlob, attachImagePath, + insertContextPathInlineRef, pasteClipboardImage, pickContextPaths, pickImages, diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 77d92248e3a..b296072d131 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -43,7 +43,7 @@ import { import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' -import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar' +import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar' import { ChatDropOverlay } from './chat-drop-overlay' import { ChatSwapOverlay } from './chat-swap-overlay' @@ -80,6 +80,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void onEdit: (message: AppendMessage) => Promise onReload: (parentId: string | null) => Promise + onRestoreToMessage?: (messageId: string) => Promise onTranscribeAudio?: (audio: Blob) => Promise } @@ -124,13 +125,7 @@ function ChatHeader({ return (
-
+
{open && ( - + {shown.map(job => ( `${GROUP_DND_ID_PREFIX}${id}` @@ -830,8 +830,9 @@ export function ChatSidebar({ {s.nav[item.id] ?? item.label} {isNewSession && ( )} @@ -857,11 +858,11 @@ export function ChatSidebar({ )} {contentVisible && showSessionSections && ( -
+
{trimmedQuery && ( {s.noMatch(trimmedQuery)} @@ -908,7 +909,8 @@ export function ChatSidebar({ = ({ }) const list = ( -
+
{rows}
diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 2e3a45d771e..1424639bc8a 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -7,7 +7,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 { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' -import { KbdGroup } from '@/components/ui/kbd' +import { KbdCombo } from '@/components/ui/kbd' import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' @@ -38,7 +38,6 @@ import { Wrench, Zap } from '@/lib/icons' -import { comboTokens } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette' import { $bindings } from '@/store/keybinds' @@ -620,7 +619,6 @@ export function CommandPalette() { {group.items.map(item => { const Icon = item.icon const combo = item.action ? bindings[item.action]?.[0] : undefined - const keys = combo ? comboTokens(combo) : null return ( {item.label} - {keys && } + {combo && } {item.to && ( )} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 0130eb7c613..1a97583c444 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -11,7 +11,6 @@ import { Pane, PaneMain } from '@/components/pane-shell' import { useMediaQuery } from '@/hooks/use-media-query' import { useSkinCommand } from '@/themes/use-skin-command' -import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus' import { formatRefValue } from '../components/assistant-ui/directive-text' import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes' import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' @@ -21,6 +20,7 @@ import { MESSAGING_SESSION_SOURCE_IDS, normalizeSessionSource } from '../lib/session-source' +import { latestSessionTodos } from '../lib/todos' import { setCronFocusJobId, setCronJobs } from '../store/cron' import { $panesFlipped, @@ -76,10 +76,12 @@ import { setSessionsLoading, setSessionsTotal } from '../store/session' +import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos' import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates' import { isSecondaryWindow } from '../store/windows' import { ChatView } from './chat' +import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus' import { useComposerActions } from './chat/hooks/use-composer-actions' import { ChatPreviewRail, @@ -141,7 +143,7 @@ const CRON_POLL_INTERVAL_MS = 30_000 // self-managed sidebar section (refreshMessagingSessions). Excluding both here // keeps "Load more" paging through interactive local chats instead of // interleaving gateway threads that bury them. -const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS] +const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS] // The messaging slice is the inverse: drop cron + every local source so only // external-platform conversations remain, then split per platform in the UI. const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS] @@ -273,22 +275,27 @@ export function DesktopController() { // the shared command handler) creates the job. Signal readiness so a link // that arrived during boot is flushed exactly once. useEffect(() => { - const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => { + const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => { if (!payload || payload.kind !== 'blueprint' || !payload.name) { return } + const slots = Object.entries(payload.params || {}) .map(([k, v]) => { const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v + return `${k}=${sval}` }) .join(' ') + const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}` requestComposerInsert(command, { mode: 'block', target: 'main' }) requestComposerFocus('main') }) + // Tell the main process the renderer is ready to receive deep links. void window.hermesDesktop?.signalDeepLinkReady?.() + return () => unsubscribe?.() }, []) @@ -554,15 +561,27 @@ export function DesktopController() { for (let index = 0; index < Math.max(1, attempts); index += 1) { try { const latest = await getSessionMessages(storedSessionId, storedProfile) + const messages = toChatMessages(latest.messages) updateSessionState( runtimeSessionId, state => ({ ...state, - messages: preserveLocalAssistantErrors(toChatMessages(latest.messages), state.messages) + messages: preserveLocalAssistantErrors(messages, state.messages) }), storedSessionId ) + // Seed the status stack's todo group from history — but only while + // the plan is still in flight, so reopening an old chat doesn't pin + // its finished todo list above the composer forever. + const todos = latestSessionTodos(messages) + + if (todos && todoListActive(todos)) { + setSessionTodos(runtimeSessionId, todos) + } else { + clearSessionTodos(runtimeSessionId) + } + return } catch { // Best-effort fallback when live stream payloads are empty. @@ -582,6 +601,7 @@ export function DesktopController() { queryClient, refreshHermesConfig, refreshSessions, + sessionStateByRuntimeIdRef, updateSessionState }) @@ -711,6 +731,7 @@ export function DesktopController() { editMessage, handleThreadMessagesChange, reloadFromMessage, + restoreToMessage, steerPrompt, submitText, transcribeVoiceAudio @@ -945,6 +966,7 @@ export function DesktopController() { onPickImages={() => void composer.pickImages()} onReload={reloadFromMessage} onRemoveAttachment={id => void composer.removeAttachment(id)} + onRestoreToMessage={restoreToMessage} onSteer={steerPrompt} onSubmit={submitText} onThreadMessagesChange={handleThreadMessagesChange} @@ -990,8 +1012,8 @@ export function DesktopController() { width={FILE_BROWSER_DEFAULT_WIDTH} > composer.insertContextPathInlineRef(path)} + onActivateFolder={path => composer.insertContextPathInlineRef(path, true)} onChangeCwd={changeSessionCwd} /> diff --git a/apps/desktop/src/app/right-sidebar/files/tree.tsx b/apps/desktop/src/app/right-sidebar/files/tree.tsx index 80ad1697cd5..e7399d2611a 100644 --- a/apps/desktop/src/app/right-sidebar/files/tree.tsx +++ b/apps/desktop/src/app/right-sidebar/files/tree.tsx @@ -12,6 +12,8 @@ import type { TreeNode } from './use-project-tree' const ROW_HEIGHT = 22 const INDENT = 10 +/** Base inset for every row; react-arborist owns paddingLeft for depth indent. */ +const TREE_ROW_INSET = 12 interface ProjectTreeProps { collapseNonce: number @@ -200,18 +202,16 @@ function ProjectTreeRow({ event.dataTransfer.setData('text/plain', node.data.id) }} ref={dragHandle} - style={style} + style={{ + ...style, + paddingLeft: + (typeof style.paddingLeft === 'number' + ? style.paddingLeft + : Number.parseFloat(String(style.paddingLeft ?? 0)) || 0) + TREE_ROW_INSET + }} > - {isFolder && !isPlaceholder && ( - - - - )} - {!isFolder && } + {/* No chevron column — the folder icon (open/closed) already carries the + expand state, so the extra glyph was pure noise. */} {isPlaceholder && !isErrorPlaceholder ? ( diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts index 03027883781..566ce2c3fed 100644 --- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts @@ -221,6 +221,36 @@ describe('useProjectTree', () => { expect(readDir).toHaveBeenLastCalledWith('/b') }) + it('falls back to the sanitized workspace dir when the session cwd is gone', async () => { + const sanitizeWorkspaceCwd = vi.fn(async () => ({ cwd: '/home/me/projects', sanitized: true })) + readDir.mockImplementation(async path => { + if (path === '/deleted/worktree') return { entries: [], error: 'ENOENT' } + if (path === '/home/me/projects') return ok([{ name: 'repo', path: '/home/me/projects/repo', isDirectory: true }]) + throw new Error(`unexpected path ${path}`) + }) + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { readDir, sanitizeWorkspaceCwd } + + const { result } = renderHook(() => useProjectTree('/deleted/worktree')) + + await waitFor(() => expect(result.current.data.length).toBe(1)) + + expect(sanitizeWorkspaceCwd).toHaveBeenCalledWith('/deleted/worktree') + expect(result.current.rootError).toBeNull() + expect(result.current.effectiveCwd).toBe('/home/me/projects') + expect(result.current.data[0]?.name).toBe('repo') + }) + + it('keeps the root error when sanitize offers no usable fallback', async () => { + const sanitizeWorkspaceCwd = vi.fn(async () => ({ cwd: '/deleted/worktree', sanitized: false })) + readDir.mockResolvedValue({ entries: [], error: 'ENOENT' }) + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { readDir, sanitizeWorkspaceCwd } + + const { result } = renderHook(() => useProjectTree('/deleted/worktree')) + + await waitFor(() => expect(result.current.rootError).toBe('ENOENT')) + expect(result.current.effectiveCwd).toBe('/deleted/worktree') + }) + it('returns no-bridge gracefully when window.hermesDesktop is missing', async () => { delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts index ab637b07c9e..0f454e73a3d 100644 --- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts @@ -64,6 +64,10 @@ export interface UseProjectTreeResult { /** Bumped by collapseAll so callers can remount the tree fully collapsed. */ collapseNonce: number data: TreeNode[] + /** Directory actually displayed — differs from the requested cwd when the + * session's recorded cwd no longer exists and we fell back to the default + * workspace dir. */ + effectiveCwd: string openState: Record rootError: string | null rootLoading: boolean @@ -80,6 +84,8 @@ interface ProjectTreeState { loaded: boolean openState: Record requestId: number + /** Directory the displayed entries were read from ('' until first load). */ + resolvedCwd: string rootError: string | null rootLoading: boolean } @@ -91,6 +97,7 @@ const initialState: ProjectTreeState = { loaded: false, openState: {}, requestId: 0, + resolvedCwd: '', rootError: null, rootLoading: false } @@ -100,6 +107,11 @@ const $projectTree = atom(initialState) let nextRootRequestId = 0 let lastConnectionKey = '' +// While the root is errored (ENOENT during a session's cwd race, a folder that +// reappears after a checkout, a remote that wasn't ready), keep retrying on a +// slow cadence so the tree self-heals instead of staying "UNREADABLE" forever. +const ROOT_ERROR_RETRY_MS = 3_000 + function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) { $projectTree.set(updater($projectTree.get())) } @@ -110,6 +122,31 @@ function clearProjectTree() { $projectTree.set({ ...initialState, requestId: nextRootRequestId }) } +/** Sessions record their launch cwd; deleted worktrees and remote-backend + * paths arrive here as directories that don't exist on this machine. Rather + * than bricking the tree, display the sanitized workspace fallback (main + * prefers the configured default project dir). Local connections only — + * remote trees are read through the remote bridge. */ +async function fallbackRootFor(cwd: string): Promise { + if ($connection.get()?.mode === 'remote') { + return null + } + + const sanitize = window.hermesDesktop?.sanitizeWorkspaceCwd + + if (!sanitize) { + return null + } + + try { + const { cwd: fallback, sanitized } = await sanitize(cwd) + + return sanitized && fallback && fallback !== cwd ? fallback : null + } catch { + return null + } +} + async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}) { if (!cwd) { clearProjectTree() @@ -138,11 +175,27 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {} loaded: false, openState: current.cwd === cwd ? current.openState : {}, requestId, + resolvedCwd: '', rootError: null, rootLoading: true }) - const { entries, error } = await readProjectDir(cwd, cwd) + let resolvedCwd = cwd + let { entries, error } = await readProjectDir(cwd, cwd) + + if (error) { + const fallback = await fallbackRootFor(cwd) + + if (fallback) { + const retry = await readProjectDir(fallback, fallback) + + if (!retry.error) { + resolvedCwd = fallback + entries = retry.entries + error = undefined + } + } + } setProjectTree(latest => { if (latest.cwd !== cwd || latest.requestId !== requestId) { @@ -153,6 +206,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {} ...latest, data: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)), loaded: true, + resolvedCwd, rootError: error || null, rootLoading: false } @@ -230,7 +284,8 @@ export function useProjectTree(cwd: string): UseProjectTreeResult { } }) - const { entries, error } = await readProjectDir(id, cwd) + const rootPath = $projectTree.get().resolvedCwd || cwd + const { entries, error } = await readProjectDir(id, rootPath) inflight.delete(id) @@ -256,19 +311,62 @@ export function useProjectTree(cwd: string): UseProjectTreeResult { useEffect(() => { const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey lastConnectionKey = connectionKey + if (connectionChanged) { clearProjectDirCache() void loadRoot(cwd, { force: true }) + return } + void loadRoot(cwd) }, [connectionKey, cwd]) + // Self-heal: an errored root re-probes every few seconds while the tree is + // mounted. Each attempt bumps requestId, so a persistent error re-arms the + // timer; a success clears rootError and stops it. + useEffect(() => { + if (!cwd || state.cwd !== cwd || !state.rootError) { + return + } + + const timer = window.setTimeout(() => void loadRoot(cwd, { force: true }), ROOT_ERROR_RETRY_MS) + + return () => window.clearTimeout(timer) + }, [cwd, state.cwd, state.requestId, state.rootError]) + + // While showing the fallback root, quietly re-probe the session's real cwd + // (a worktree re-created, a checkout restored) and switch back when it + // reappears. The probe never touches state, so there's no flicker. + const usingFallback = state.cwd === cwd && Boolean(state.resolvedCwd) && state.resolvedCwd !== cwd + + useEffect(() => { + if (!cwd || !usingFallback) { + return + } + + let cancelled = false + + const timer = window.setInterval(() => { + void readProjectDir(cwd, cwd).then(({ error }) => { + if (!cancelled && !error) { + void loadRoot(cwd, { force: true }) + } + }) + }, ROOT_ERROR_RETRY_MS) + + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [cwd, usingFallback]) + return useMemo( () => ({ collapseAll, collapseNonce: state.cwd === cwd ? state.collapseNonce : 0, data: state.cwd === cwd ? state.data : [], + effectiveCwd: state.cwd === cwd && state.resolvedCwd ? state.resolvedCwd : cwd, loadChildren, openState: state.cwd === cwd ? state.openState : {}, refreshRoot, @@ -286,6 +384,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult { state.cwd, state.data, state.openState, + state.resolvedCwd, state.rootError, state.rootLoading ] diff --git a/apps/desktop/src/app/right-sidebar/index.tsx b/apps/desktop/src/app/right-sidebar/index.tsx index 30c45d40a25..8a77dbc9844 100644 --- a/apps/desktop/src/app/right-sidebar/index.tsx +++ b/apps/desktop/src/app/right-sidebar/index.tsx @@ -5,7 +5,6 @@ import { ErrorBoundary } from '@/components/error-boundary' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Loader } from '@/components/ui/loader' -import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { selectDesktopPaths } from '@/lib/desktop-fs' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' @@ -34,17 +33,11 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd const currentCwd = useStore($currentCwd).trim() const hasCwd = currentCwd.length > 0 - const cwdName = hasCwd - ? (currentCwd - .split(/[\\/]+/) - .filter(Boolean) - .pop() ?? currentCwd) - : r.noFolderSelected - const { collapseAll, collapseNonce, data, + effectiveCwd, loadChildren, openState, refreshRoot, @@ -53,11 +46,18 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd setNodeOpen } = useProjectTree(currentCwd) + const cwdName = hasCwd + ? (effectiveCwd + .split(/[\\/]+/) + .filter(Boolean) + .pop() ?? effectiveCwd) + : r.noFolderSelected + const canCollapse = Object.values(openState).some(Boolean) const chooseFolder = async () => { const selected = await selectDesktopPaths({ - defaultPath: hasCwd ? currentCwd : undefined, + defaultPath: hasCwd ? effectiveCwd : undefined, directories: true, multiple: false, title: r.changeCwdTitle @@ -70,7 +70,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd const previewFile = async (path: string) => { try { - const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined) + const preview = await normalizeOrLocalPreviewTarget(path, effectiveCwd || undefined) if (!preview) { throw new Error(r.couldNotPreview(path)) @@ -97,7 +97,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd void } -// Sidebar-specific color/hover treatment only — size, radius, cursor and the -// base focus ring come from - +
@@ -230,6 +230,9 @@ interface FileTreeBodyProps { onLoadChildren: (id: string) => void | Promise onNodeOpenChange: (id: string, open: boolean) => void onPreviewFile?: (path: string) => void + /** Force-reload the root. The hook also auto-retries while errored, so this + * is the impatient-user path. */ + onRetry?: () => void openState: ReturnType['openState'] } @@ -244,6 +247,7 @@ function FileTreeBody({ onLoadChildren, onNodeOpenChange, onPreviewFile, + onRetry, openState }: FileTreeBodyProps) { const { t } = useI18n() @@ -254,7 +258,20 @@ function FileTreeBody({ } if (error) { - return + return ( +
+ + {onRetry && ( + + )} +
+ ) } if (loading && data.length === 0) { diff --git a/apps/desktop/src/app/right-sidebar/terminal/index.tsx b/apps/desktop/src/app/right-sidebar/terminal/index.tsx index c3842366254..5dc8f62ad4f 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/index.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/index.tsx @@ -9,7 +9,7 @@ import { useI18n } from '@/i18n' import { SidebarPanelLabel } from '../../shell/sidebar-label' import { setTerminalTakeover } from '../store' -import { addSelectionShortcutLabel } from './selection' +import { KbdCombo } from '@/components/ui/kbd' import { useTerminalSession } from './use-terminal-session' interface TerminalTabProps { @@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) { variant="secondary" > {t.rightSidebar.addToChat} - {addSelectionShortcutLabel()} +
)} diff --git a/apps/desktop/src/app/right-sidebar/terminal/selection.ts b/apps/desktop/src/app/right-sidebar/terminal/selection.ts index 955a9ea1f18..2e6f0184e7c 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/selection.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/selection.ts @@ -99,8 +99,6 @@ export function resolveSurfaceColor(fallback: string): string { export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac') -export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L') - export function isAddSelectionShortcut(event: KeyboardEvent) { const mod = isMacPlatform() ? event.metaKey : event.ctrlKey diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 7c0f13da5c0..199457e29af 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties } from 'react' import { triggerHaptic } from '@/lib/haptics' +import { $filePreviewTarget, $previewTarget } from '@/store/preview' import { useTheme } from '@/themes/context' import { makeTerminalReader, setActiveTerminalReader } from './buffer' @@ -20,6 +21,17 @@ import { type TerminalStatus = 'closed' | 'open' | 'starting' +// ⌘/Ctrl+L is a global shortcut, so a text selection in the file preview pane +// lands in this handler with no xterm selection. Label those with the previewed +// file's name instead of the shell, so the composer ref reads as a file quote +// rather than a bogus "zsh:N lines". +function previewSelectionLabel(): string { + const target = $filePreviewTarget.get() ?? $previewTarget.get() + const source = target?.path || target?.url || '' + + return source.split(/[\\/]/).filter(Boolean).pop() || target?.label?.trim() || '' +} + const HERMES_PATHS_MIME = 'application/x-hermes-paths' function readEscapeSequence(data: string, index: number) { @@ -257,16 +269,20 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes ) const addSelectionToChat = useCallback(() => { - const selectedText = readSelection() || selectionRef.current + const termSelection = (termRef.current?.getSelection() || selectionRef.current).trim() + const selectedText = termSelection || window.getSelection()?.toString() || '' const trimmed = selectedText.trim() if (!trimmed) { return } - const label = - selectionLabelRef.current || - (termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection') + // Terminal selection → shell-anchored label; anything else came from the + // preview pane sharing this global shortcut → label it with the file. + const label = termSelection + ? selectionLabelRef.current || + (termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection') + : previewSelectionLabel() || 'selection' onAddSelectionToChatRef.current(trimmed, label) termRef.current?.clearSelection() @@ -275,7 +291,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes setSelection('') setSelectionStyle(null) triggerHaptic('selection') - }, [readSelection]) + }, []) // Always listen — gating on the React selection state misses selections the // TUI redraw races. Only swallow ⌘/Ctrl+L when there's text to send, else it diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index 442435956ac..99da03f08bf 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -18,7 +18,9 @@ import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from import { gatewayEventRequiresSessionId } from '@/lib/gateway-events' import { triggerHaptic } from '@/lib/haptics' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' +import { parseTodos } from '@/lib/todos' import { setClarifyRequest } from '@/store/clarify' +import { refreshBackgroundProcesses } from '@/store/composer-status' import { $gateway } from '@/store/gateway' import { notify } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' @@ -37,6 +39,7 @@ import { setYoloActive } from '@/store/session' import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' +import { setSessionTodos } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' import type { RpcEvent } from '@/types/hermes' @@ -52,6 +55,7 @@ interface MessageStreamOptions { queryClient: QueryClient refreshHermesConfig: () => Promise refreshSessions: () => Promise + sessionStateByRuntimeIdRef: MutableRefObject> updateSessionState: ( sessionId: string, updater: (state: ClientSessionState) => ClientSessionState, @@ -67,15 +71,7 @@ interface QueuedStreamDeltas { type SessionRuntimeStatePatch = Partial< Pick< ClientSessionState, - | 'branch' - | 'cwd' - | 'fast' - | 'model' - | 'personality' - | 'provider' - | 'reasoningEffort' - | 'serviceTier' - | 'yolo' + 'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo' > > @@ -253,8 +249,14 @@ export function useMessageStream({ queryClient, refreshHermesConfig, refreshSessions, + sessionStateByRuntimeIdRef, updateSessionState }: MessageStreamOptions) { + const sessionInterrupted = useCallback( + (sessionId: string) => sessionStateByRuntimeIdRef.current.get(sessionId)?.interrupted ?? false, + [sessionStateByRuntimeIdRef] + ) + // Patch the in-flight assistant message (or seed it). Centralises the // streamId/groupId bookkeeping every event callback would otherwise repeat. const mutateStream = useCallback( @@ -478,6 +480,20 @@ export function useMessageStream({ // a tool part can't jump ahead of the text that preceded it. flushQueuedDeltas(sessionId) + if (sessionInterrupted(sessionId)) { + return + } + + // The composer status stack owns todo display now (no inline panel) — + // mirror every todo state the tool reports into its session store. + if (payload?.name === 'todo') { + const todos = parseTodos(payload.todos) ?? parseTodos(payload.result) ?? parseTodos(payload.args) + + if (todos) { + setSessionTodos(sessionId, todos) + } + } + if (!nativeSubagentSessionsRef.current.has(sessionId)) { for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) { upsertSubagent( @@ -496,7 +512,7 @@ export function useMessageStream({ { pending: m => phase !== 'complete' || (m.pending ?? false) } ) }, - [flushQueuedDeltas, mutateStream] + [flushQueuedDeltas, mutateStream, sessionInterrupted] ) const completeAssistantMessage = useCallback( @@ -677,9 +693,11 @@ export function useMessageStream({ (event: RpcEvent) => { const payload = event.payload as GatewayEventPayload | undefined const explicitSid = event.session_id || '' + if (!explicitSid && gatewayEventRequiresSessionId(event.type)) { return } + const sessionId = explicitSid || activeSessionIdRef.current const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current @@ -875,13 +893,22 @@ export function useMessageStream({ // the sidebar indicator clears as soon as it's answered, not only at // message.complete. updateSessionState(sessionId, state => (state.needsInput ? { ...state, needsInput: false } : state)) + + // terminal/process tool calls are the only things that spawn or reap + // background processes — sync the composer status stack right after. + if ( + !sessionInterrupted(sessionId) && + (payload?.name === 'terminal' || payload?.name === 'process') + ) { + void refreshBackgroundProcesses(sessionId) + } } if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) { recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff) } } else if (SUBAGENT_EVENT_TYPES.has(event.type)) { - if (sessionId && payload) { + if (sessionId && payload && !sessionInterrupted(sessionId)) { if (!nativeSubagentSessionsRef.current.has(sessionId)) { pruneDelegateFallbackSubagents(sessionId) } @@ -987,6 +1014,12 @@ export function useMessageStream({ text: result ? JSON.stringify(result) : '' }) } + } else if (event.type === 'status.update') { + // The gateway's notification poller announces background process + // completions / watch matches here — re-sync the status stack. + if (sessionId && payload?.kind === 'process') { + void refreshBackgroundProcesses(sessionId) + } } else if (event.type === 'error') { const errorMessage = payload?.message || 'Hermes reported an error' const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage) @@ -1027,6 +1060,7 @@ export function useMessageStream({ flushQueuedDeltas, queryClient, refreshHermesConfig, + sessionInterrupted, updateSessionState, upsertToolCall ] diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx index e7dfe9d7da5..abc4fae3163 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx @@ -3,8 +3,9 @@ import type { MutableRefObject } from 'react' import { useEffect, useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { textPart } from '@/lib/chat-messages' import { $composerAttachments, type ComposerAttachment } from '@/store/composer' -import { $connection, $sessions, setSessions } from '@/store/session' +import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session' import type { SessionInfo } from '@/types/hermes' import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions' @@ -43,6 +44,7 @@ function sessionInfo(overrides: Partial = {}): SessionInfo { interface HarnessHandle { cancelRun: () => Promise + restoreToMessage: (messageId: string) => Promise steerPrompt: (text: string) => Promise submitText: ( text: string, @@ -57,6 +59,7 @@ function Harness({ refreshSessions, requestGateway, resumeStoredSession, + seedMessages, storedSessionId }: { busyRef?: MutableRefObject @@ -65,6 +68,7 @@ function Harness({ refreshSessions: () => Promise requestGateway: (method: string, params?: Record) => Promise resumeStoredSession?: (storedSessionId: string) => Promise | void + seedMessages?: unknown[] storedSessionId?: null | string }) { const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } @@ -73,7 +77,7 @@ function Harness({ } const localBusyRef = busyRef ?? { current: false } const stateRef = useRef({ - messages: [], + messages: seedMessages ?? [], busy: false, awaitingResponse: false, interrupted: true @@ -105,10 +109,11 @@ function Harness({ useEffect(() => { onReady({ cancelRun: actions.cancelRun, + restoreToMessage: actions.restoreToMessage, steerPrompt: actions.steerPrompt, submitText: actions.submitText }) - }, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady]) + }, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady]) return null } @@ -395,6 +400,125 @@ describe('usePromptActions steerPrompt', () => { }) }) +describe('usePromptActions restoreToMessage', () => { + beforeEach(() => { + $busy.set(false) + $messages.set([ + { id: 'u1', role: 'user', parts: [textPart('first prompt')] }, + { id: 'a1', role: 'assistant', parts: [textPart('first answer')] }, + { id: 'u2', role: 'user', parts: [textPart('second prompt')] }, + { id: 'a2', role: 'assistant', parts: [textPart('second answer')] } + ]) + }) + + afterEach(() => { + cleanup() + $busy.set(false) + $messages.set([]) + vi.restoreAllMocks() + }) + + it('rewinds to the target user turn and resubmits its text', async () => { + const requestGateway = vi.fn(async () => ({}) as never) + let lastState: Record = {} + + let handle: HarnessHandle | null = null + render( + (handle = h)} + onSeedState={state => (lastState = state)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + seedMessages={$messages.get()} + /> + ) + + await handle!.restoreToMessage('u1') + + // Ordinal 0 = "truncate before the first visible user message": the gateway + // drops that turn and everything after, then runs the same text again. + expect(requestGateway).toHaveBeenCalledWith('prompt.submit', { + session_id: RUNTIME_SESSION_ID, + text: 'first prompt', + truncate_before_user_ordinal: 0 + }) + expect((lastState.messages as { id: string }[]).map(m => m.id)).toEqual(['u1']) + expect(lastState.busy).toBe(true) + }) + + it('rethrows gateway failures and clears the busy flags for the dialog to surface', async () => { + const requestGateway = vi.fn(async () => { + throw new Error('gateway exploded') + }) + + let lastState: Record = {} + let handle: HarnessHandle | null = null + + render( + (handle = h)} + onSeedState={state => (lastState = state)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + await expect(handle!.restoreToMessage('u2')).rejects.toThrow('gateway exploded') + expect(lastState.busy).toBe(false) + }) + + it('interrupts the live turn and retries past "session busy" when reverting mid-stream', async () => { + $busy.set(true) + + let submitAttempts = 0 + const requestGateway = vi.fn(async (method: string) => { + if (method === 'prompt.submit') { + submitAttempts += 1 + + // The cooperative interrupt hasn't wound the turn down yet on the first + // try; the second attempt lands once the gateway reports idle. + if (submitAttempts === 1) { + throw new Error('session busy') + } + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + seedMessages={$messages.get()} + /> + ) + + await handle!.restoreToMessage('u1') + + expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: RUNTIME_SESSION_ID }) + expect(submitAttempts).toBe(2) + expect(requestGateway).toHaveBeenCalledWith('prompt.submit', { + session_id: RUNTIME_SESSION_ID, + text: 'first prompt', + truncate_before_user_ordinal: 0 + }) + }) + + it('ignores non-user targets and unknown ids without touching the gateway', async () => { + const requestGateway = vi.fn(async () => ({}) as never) + + let handle: HarnessHandle | null = null + render( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />) + + await handle!.restoreToMessage('a1') + await handle!.restoreToMessage('missing') + + expect(requestGateway).not.toHaveBeenCalled() + }) +}) + describe('usePromptActions file attachment sync', () => { afterEach(() => { cleanup() diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index b09d86ffd10..a481728362d 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -35,6 +35,7 @@ import { terminalContextBlocksFromDraft, updateComposerAttachment } from '@/store/composer' +import { resetSessionBackground } from '@/store/composer-status' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' @@ -52,6 +53,8 @@ import { setSessions, setYoloActive } from '@/store/session' +import { clearSessionSubagents } from '@/store/subagents' +import { clearSessionTodos } from '@/store/todos' import type { ClientSessionState, @@ -114,6 +117,18 @@ function isSessionNotFoundError(error: unknown): boolean { return /session not found/i.test(message) } +// The gateway refuses prompt.submit while a turn is running (4009 "session +// busy"). Edit/restore (revert) can fire mid-turn, so they interrupt first then +// retry the submit until the cooperative interrupt has wound the turn down. +const REWIND_INTERRUPT_TIMEOUT_MS = 6_000 +const REWIND_RETRY_INTERVAL_MS = 150 + +function isSessionBusyError(error: unknown): boolean { + return /session busy/i.test(error instanceof Error ? error.message : String(error)) +} + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + function base64FromDataUrl(dataUrl: string): string { const comma = dataUrl.indexOf(',') @@ -523,6 +538,7 @@ export function usePromptActions({ // Images use their base64 preview so the thumbnail renders inline without // a (remote-mode 403-prone) /api/media fetch — see optimisticAttachmentRef. let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r)) + const buildContextText = (atts: ComposerAttachment[]): string => { const contextRefs = atts .map(a => a.refText) @@ -540,6 +556,7 @@ export function usePromptActions({ // bounce the drained send. The drain lock serializes them; the user path // keeps the guard so a stray Enter mid-turn can't double-submit. const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage) + if (!hasSendable || (!options?.fromQueue && busyRef.current)) { return false } @@ -652,6 +669,7 @@ export function usePromptActions({ const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, { updateComposerAttachments: usingComposerAttachments }) + // Rewrite the optimistic message + prompt text with the synced refs so // the gateway receives @file: paths that resolve in its workspace. // (Images keep their inline base64 preview — see optimisticAttachmentRef.) @@ -672,6 +690,7 @@ export function usePromptActions({ const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: selectedStoredSessionIdRef.current }) + const recoveredId = resumed?.session_id if (recoveredId) { @@ -1234,12 +1253,13 @@ export function usePromptActions({ const cancelRun = useCallback(async () => { const sessionId = activeSessionId || activeSessionIdRef.current + const releaseBusy = () => { + setMutableRef(busyRef, false) + setBusy(false) + } setAwaitingResponse(false) - // Interrupting keeps whatever was already generated and just - // stops — no "[interrupted]" marker. A pending/streaming message with no - // body text is dropped entirely so we never leave an empty bubble behind. const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) => messages .filter( @@ -1251,8 +1271,7 @@ export function usePromptActions({ ) if (!sessionId) { - setMutableRef(busyRef, false) - setBusy(false) + releaseBusy() setMessages(finalizeMessages($messages.get())) return @@ -1260,13 +1279,12 @@ export function usePromptActions({ updateSessionState(sessionId, state => { const streamId = state.streamId - const messages = finalizeMessages(state.messages, streamId) return { ...state, messages, - busy: true, + busy: false, awaitingResponse: false, streamId: null, pendingBranchGroup: null, @@ -1274,8 +1292,13 @@ export function usePromptActions({ } }) + clearSessionTodos(sessionId) + clearSessionSubagents(sessionId) + resetSessionBackground(sessionId) + try { await requestGateway('session.interrupt', { session_id: sessionId }) + releaseBusy() } catch (err) { let stopError = err @@ -1284,11 +1307,13 @@ export function usePromptActions({ const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: selectedStoredSessionIdRef.current }) + const recoveredId = resumed?.session_id if (recoveredId) { activeSessionIdRef.current = recoveredId await requestGateway('session.interrupt', { session_id: recoveredId }) + releaseBusy() return } @@ -1297,8 +1322,7 @@ export function usePromptActions({ } } - setMutableRef(busyRef, false) - setBusy(false) + releaseBusy() notifyError(stopError, copy.stopFailed) } }, [ @@ -1421,13 +1445,116 @@ export function usePromptActions({ [activeSessionId, copy.regenerateFailed, requestGateway, updateSessionState] ) + // Cursor-style "restore checkpoint": rewind the conversation to a past user + // prompt and run it again from there. Reuses the edit composer's rewind + // mechanism — `prompt.submit` with `truncate_before_user_ordinal` drops that + // user turn and everything after it from the session history, then the same + // text is submitted as a fresh turn. Callers confirm before invoking; errors + // are rethrown so the confirmation dialog can surface them inline. + // Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore + // can fire while a turn is streaming, interrupt the live turn first, then + // retry the submit until the gateway stops reporting "session busy" — the + // interrupt is cooperative, so the running turn takes a beat to wind down. + const submitRewindPrompt = useCallback( + async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => { + if (wasRunning) { + try { + await requestGateway('session.interrupt', { session_id: sessionId }) + } catch { + // Best-effort — the busy-retry below still gates the submit. + } + } + + const deadline = Date.now() + REWIND_INTERRUPT_TIMEOUT_MS + + for (;;) { + try { + await requestGateway('prompt.submit', { + session_id: sessionId, + text, + ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) + }) + + return + } catch (err) { + if (isSessionBusyError(err) && Date.now() < deadline) { + await sleep(REWIND_RETRY_INTERVAL_MS) + + continue + } + + throw err + } + } + }, + [requestGateway] + ) + + const restoreToMessage = useCallback( + async (messageId: string) => { + const sessionId = activeSessionId || activeSessionIdRef.current + + if (!sessionId) { + return + } + + const messages = $messages.get() + const sourceIndex = messages.findIndex(m => m.id === messageId) + const source = messages[sourceIndex] + + if (!source || source.role !== 'user') { + return + } + + const text = chatMessageText(source).trim() + + if (!text) { + return + } + + const wasRunning = $busy.get() + const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, sourceIndex) + + // The turns we're discarding may have spawned todos and background + // processes; they belong to the abandoned timeline, so wipe their status + // rows (and kill the live processes) before the fresh run repopulates. + clearSessionTodos(sessionId) + resetSessionBackground(sessionId) + + clearNotifications() + setMutableRef(busyRef, true) + setBusy(true) + setAwaitingResponse(true) + updateSessionState(sessionId, state => ({ + ...state, + busy: true, + awaitingResponse: true, + pendingBranchGroup: null, + sawAssistantPayload: false, + interrupted: false, + messages: state.messages.slice(0, sourceIndex + 1) + })) + + try { + await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, wasRunning) + } catch (err) { + setMutableRef(busyRef, false) + setBusy(false) + setAwaitingResponse(false) + updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false })) + throw err + } + }, + [activeSessionId, activeSessionIdRef, busyRef, submitRewindPrompt, updateSessionState] + ) + const editMessage = useCallback( async (edited: AppendMessage) => { const sessionId = activeSessionId || activeSessionIdRef.current const sourceId = edited.sourceId || edited.parentId const text = appendText(edited) - if (!sessionId || !sourceId || !text || edited.role !== 'user' || $busy.get()) { + if (!sessionId || !sourceId || !text || edited.role !== 'user') { return } @@ -1439,12 +1566,23 @@ export function usePromptActions({ return } + // Sending an edit is a revert: rewind to this prompt and re-run with the + // new text. It can fire mid-turn, so capture the live state — the submit + // helper interrupts first when a turn is running. + const wasRunning = $busy.get() + // Failed turn: optimistic user msg never reached the gateway, so truncating // by ordinal would 422. Submit as a plain resend instead. const nextMessage = messages[sourceIndex + 1] const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error) const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] } + // Editing rewinds the conversation to this prompt — same as restore — so + // drop the abandoned timeline's todos/background rows (and kill the live + // processes) before the re-run repopulates them. + clearSessionTodos(sessionId) + resetSessionBackground(sessionId) + clearNotifications() setMutableRef(busyRef, true) setBusy(true) @@ -1459,24 +1597,18 @@ export function usePromptActions({ messages: [...state.messages.slice(0, sourceIndex), editedMessage] })) - const submit = (truncateOrdinal?: number) => - requestGateway('prompt.submit', { - session_id: sessionId, - text, - ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) - }) - const isStaleTargetError = (err: unknown) => /no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err)) try { - await submit(isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex)) + await submitRewindPrompt(sessionId, text, isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), wasRunning) } catch (err) { let surfaced = err if (!isFailedTurn && isStaleTargetError(err)) { try { - await submit() + // Already interrupted on the first attempt — submit as a plain resend. + await submitRewindPrompt(sessionId, text, undefined, false) return } catch (retryErr) { @@ -1491,7 +1623,7 @@ export function usePromptActions({ notifyError(surfaced, copy.editFailed) } }, - [activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, requestGateway, updateSessionState] + [activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, submitRewindPrompt, updateSessionState] ) const handleThreadMessagesChange = useCallback( @@ -1534,6 +1666,7 @@ export function usePromptActions({ handleThreadMessagesChange, handoffSession, reloadFromMessage, + restoreToMessage, steerPrompt, submitText, transcribeVoiceAudio diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 8a419488740..00350538711 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -43,6 +43,7 @@ import { workspaceCwdForNewSession } from '@/store/session' import { reportBackendContract } from '@/store/updates' +import { isWatchWindow } from '@/store/windows' import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes' import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes' @@ -534,6 +535,7 @@ export function useSessionActions({ if (cachedRuntimeId && cachedState) { const stored = $sessions.get().find(session => session.id === storedSessionId) + const cachedViewState = !cachedState.model && stored?.model != null ? { @@ -606,26 +608,23 @@ export function useSessionActions({ })) } + let resumedRunning = false + try { - // Load the local snapshot first, then ask the gateway to resume. - // Previously these raced: - // 1. clear messages to [] - // 2. local getSessionMessages -> 45 msgs - // 3. a second resume path cleared [] again - // 4. gateway resume -> 43 msgs - // That is the ctrl+R flash chain. Avoid showing an empty thread - // while we already have a route-scoped session id, and don't race the - // local snapshot against gateway resume. + const watchWindow = isWatchWindow() let localSnapshot = $messages.get() try { - const storedMessages = await getSessionMessages(storedSessionId, sessionProfile) + // Watch windows skip REST prefetch — lazy resume attaches the live mirror. + if (!watchWindow) { + const storedMessages = await getSessionMessages(storedSessionId, sessionProfile) - if (isCurrentResume()) { - localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get()) + if (isCurrentResume()) { + localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get()) - if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) { - setMessages(localSnapshot) + if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) { + setMessages(localSnapshot) + } } } } catch { @@ -635,9 +634,7 @@ export function useSessionActions({ const resumed = await requestGateway('session.resume', { session_id: storedSessionId, cols: 96, - // Owning profile: in app-global remote mode one backend serves every - // profile, so the gateway opens this profile's state.db + home to - // resume + persist the right session (no-op for single/launch profile). + ...(watchWindow ? { lazy: true } : {}), ...(sessionProfile ? { profile: sessionProfile } : {}) }) @@ -651,15 +648,7 @@ export function useSessionActions({ reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages), currentMessages ) - // Avoid a second visible transcript rebuild on resume/switch. - // `getSessionMessages()` is the stable stored transcript snapshot and - // paints first; `session.resume` can return a slightly different - // runtime-shaped projection (e.g. tool/system coalescing), which was - // causing a second full message-list replacement a second later. - // Keep the already-painted local snapshot for the view/cache when it - // exists; use gateway messages only as a fallback when no local - // snapshot was available. - + // Keep the local snapshot when resume would only reshuffle runtime projection. const preferredMessages = localSnapshot.length > 0 ? localSnapshot @@ -675,14 +664,16 @@ export function useSessionActions({ patchSessionWorkspace(storedSessionId, runtimeInfo?.cwd) + resumedRunning = Boolean((resumed as { running?: boolean }).running) + updateSessionState( resumed.session_id, state => ({ ...state, ...(runtimeInfo ?? {}), messages: messagesForView, - busy: false, - awaitingResponse: false + busy: resumedRunning, + awaitingResponse: resumedRunning }), storedSessionId ) @@ -701,9 +692,9 @@ export function useSessionActions({ notifyError(err, copy.resumeFailed) } finally { if (isCurrentResume()) { - busyRef.current = false - setBusy(false) - setAwaitingResponse(false) + busyRef.current = resumedRunning + setBusy(resumedRunning) + setAwaitingResponse(resumedRunning) } } }, diff --git a/apps/desktop/src/app/shell/keybind-panel.tsx b/apps/desktop/src/app/shell/keybind-panel.tsx index 81d292862ac..ff0b7b27ff1 100644 --- a/apps/desktop/src/app/shell/keybind-panel.tsx +++ b/apps/desktop/src/app/shell/keybind-panel.tsx @@ -5,6 +5,7 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { Kbd, KbdCombo } from '@/components/ui/kbd' import { useI18n } from '@/i18n' import { KEYBIND_ACTIONS, @@ -166,15 +167,11 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) { type="button" > {capturing ? ( - {k.pressKey} + {k.pressKey} ) : combos.length > 0 ? ( - combos.map(combo => ( - - {formatCombo(combo)} - - )) + combos.map(combo => ) ) : ( - {k.set} + {k.set} )} @@ -209,9 +206,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) { {label}
{shortcut.keys.map(key => ( - - {formatCombo(key)} - + ))}
diff --git a/apps/desktop/src/app/shell/titlebar.ts b/apps/desktop/src/app/shell/titlebar.ts index 1e56a5f9c48..20fbba17367 100644 --- a/apps/desktop/src/app/shell/titlebar.ts +++ b/apps/desktop/src/app/shell/titlebar.ts @@ -19,7 +19,10 @@ export const titlebarButtonClass = 'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground' export const titlebarHeaderBaseClass = - 'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]' + 'pointer-events-none relative z-3 flex h-(--titlebar-height) w-full min-w-0 shrink-0 items-center justify-start gap-3 overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))] pr-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,0px)+0.75rem)]' + +// Title row inside the header — must stay in the flex truncate chain. +export const titlebarHeaderTitleClass = 'min-w-0 flex-1 overflow-hidden' export const titlebarHeaderShadowClass = "after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-4 after:bg-linear-to-b after:from-(--ui-chat-surface-background) after:to-transparent after:content-['']" diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index 7b8dd8d6a41..ce72a8179fb 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -6,6 +6,7 @@ import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useSt import { ToolFallback } from '@/components/assistant-ui/tool-fallback' import { Button } from '@/components/ui/button' +import { KbdCombo } from '@/components/ui/kbd' import { Textarea } from '@/components/ui/textarea' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' @@ -229,7 +230,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { value={draft} />
- {copy.shortcut} + + + {copy.shortcutSuffix} +
{hasChoices && ( - - )} + {/* Always editable — clicking opens the edit composer even while a + turn streams; sending the edit reverts (interrupt + rewind). */} + + + {(showStop || showRestore) && (
{showStop ? ( @@ -860,13 +911,20 @@ const UserMessage: FC<{ {StopGlyph} ) : ( - + )}
)} @@ -894,6 +952,17 @@ const UserMessage: FC<{
+ {showRestore && ( + setRestoreConfirmOpen(false)} + onConfirm={() => onRestoreToMessage?.(messageId)} + open={restoreConfirmOpen} + title={copy.restoreTitle} + /> + )} ) diff --git a/apps/desktop/src/components/assistant-ui/todo-tool.tsx b/apps/desktop/src/components/assistant-ui/todo-tool.tsx deleted file mode 100644 index 549c8c3bd9d..00000000000 --- a/apps/desktop/src/components/assistant-ui/todo-tool.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { type FC } from 'react' - -import { Checkbox } from '@/components/ui/checkbox' -import { Loader2Icon } from '@/lib/icons' -import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos' -import { cn } from '@/lib/utils' - -export function todosFromMessageContent(content: unknown): TodoItem[] { - if (!Array.isArray(content)) { - return [] - } - - let latest: null | TodoItem[] = null - - for (const part of content) { - if (!part || typeof part !== 'object') { - continue - } - - const row = part as Record - - if (row.type !== 'tool-call' || row.toolName !== 'todo') { - continue - } - - const parsed = parseTodos(row.result) ?? parseTodos(row.args) - - if (parsed !== null) { - latest = parsed - } - } - - return latest ?? [] -} - -const headerLabel = (todos: readonly TodoItem[]): string => - todos.find(t => t.status === 'in_progress')?.content ?? - todos.find(t => t.status === 'pending')?.content ?? - todos.at(-1)?.content ?? - 'Tasks' - -const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => { - if (status === 'in_progress') { - return ( - - - - ) - } - - const checked = status === 'completed' - - return ( - - ) -} - -export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => { - if (!todos.length) { - return null - } - - const label = headerLabel(todos) - - return ( -
-
- - {label} - -
-
    - {todos.map(todo => ( -
  • - - {todo.content} -
  • - ))} -
-
- ) -} diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index b5d65b5571e..8478afc118c 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -12,9 +12,9 @@ import { DiffLines } from '@/components/chat/diff-lines' import { DisclosureRow } from '@/components/chat/disclosure-row' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { ZoomableImage } from '@/components/chat/zoomable-image' -import { BrailleSpinner } from '@/components/ui/braille-spinner' import { CopyButton } from '@/components/ui/copy-button' import { FadeText } from '@/components/ui/fade-text' +import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { ToolIcon } from '@/components/ui/tool-icon' import { useI18n } from '@/i18n' import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link' @@ -100,7 +100,7 @@ function rawTechnicalTrace(args: unknown, result: unknown): string { function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode { if (status === 'running') { return ( - + ) } diff --git a/apps/desktop/src/components/chat/composer-dock.ts b/apps/desktop/src/components/chat/composer-dock.ts new file mode 100644 index 00000000000..8eb2b24e7ee --- /dev/null +++ b/apps/desktop/src/components/chat/composer-dock.ts @@ -0,0 +1,31 @@ +import { cn } from '@/lib/utils' + +/** + * The composer surface and everything docked to it (slash·@ popover, `?` help) + * paint ONE shared `--composer-fill` var. The state ladder (rest / scrolled / + * focused / drawer-open) lives in styles.css on `[data-slot='composer-root']`, + * so the two layers can never disagree — drawer-open forces an opaque fill via + * `:has()`, because translucent glass sampling different backdrops (thread vs + * fade gradient) renders as different colors even with identical tints. + */ +export const composerFill = 'bg-(--composer-fill)' + +/** Backdrop treatment for the composer input surface. Harmless when the fill + * goes opaque (drawer open) — nothing shows through to blur. */ +export const composerSurfaceGlass = cn( + 'backdrop-blur-[0.75rem] backdrop-saturate-[1.12] [-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]', + 'transition-[background-color] duration-150 ease-out' +) + +const composerDockEdge = (edge: 'bottom' | 'top') => + cn('border border-border/65', edge === 'top' ? 'rounded-t-2xl border-b-0' : 'rounded-b-2xl border-t-0') + +/** Glassy docked card — the status stack / queue. Paints the SAME + * `--composer-fill` as the surface, so rest / scrolled / focused / drawer-open + * all match the composer by construction. */ +export const composerDockCard = (edge: 'bottom' | 'top' = 'top') => + cn(composerDockEdge(edge), composerFill, composerSurfaceGlass) + +/** Fused docked card — completion drawers. Shares `--composer-fill` with the + * composer surface, which goes opaque while a drawer is open. */ +export const composerFusedDockCard = (edge: 'bottom' | 'top' = 'top') => cn(composerDockEdge(edge), composerFill) diff --git a/apps/desktop/src/components/chat/status-row.tsx b/apps/desktop/src/components/chat/status-row.tsx new file mode 100644 index 00000000000..8d66bde51eb --- /dev/null +++ b/apps/desktop/src/components/chat/status-row.tsx @@ -0,0 +1,68 @@ +import { type ReactNode } from 'react' + +import { cn } from '@/lib/utils' + +interface StatusRowProps { + children: ReactNode + className?: string + /** Leading glyph slot (spinner / status dot / selection circle). */ + leading?: ReactNode + /** Makes the whole row activatable (adds `cursor-pointer` + keyboard a11y). + * Trailing-slot buttons should `stopPropagation` so they don't also fire it. */ + onActivate?: () => void + /** Right-aligned actions. Revealed on row hover/focus unless `trailingVisible`. */ + trailing?: ReactNode + trailingVisible?: boolean +} + +/** + * Shared row chrome for everything in the composer status stack — status items + * (subagents, background) AND queued prompts. Fixed height, a leading glyph + * slot, flexible content, and a trailing actions slot that reveals on hover. + * Hover background matches the session sidebar. Consumers fill the three slots; + * they never re-implement the row container. + */ +export function StatusRow({ + children, + className, + leading, + onActivate, + trailing, + trailingVisible = false +}: StatusRowProps) { + return ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onActivate() + } + } + : undefined + } + role={onActivate ? 'button' : undefined} + tabIndex={onActivate ? 0 : undefined} + > + {leading} +
{children}
+ {trailing && ( +
+ {trailing} +
+ )} +
+ ) +} diff --git a/apps/desktop/src/components/chat/status-section.tsx b/apps/desktop/src/components/chat/status-section.tsx new file mode 100644 index 00000000000..161cc6f6a69 --- /dev/null +++ b/apps/desktop/src/components/chat/status-section.tsx @@ -0,0 +1,42 @@ +import { type ReactNode, useState } from 'react' + +import { DisclosureCaret } from '@/components/ui/disclosure-caret' + +interface StatusSectionProps { + /** Optional right-aligned actions (text links / micro buttons). Pass + * `Button` with `size="micro"` + `variant="text"` or `"link"`. */ + accessory?: ReactNode + children: ReactNode + defaultCollapsed?: boolean + /** Optional glyph between the caret and the label (e.g. a `Codicon`). */ + icon?: ReactNode + label: ReactNode +} + +/** + * One collapsible group inside the composer status stack. Pure chrome — header + * (caret + label) + body — styled to match the queue exactly so every status + * (queue, subagents, background) reads as one piece. The stack supplies the + * outer card and the dividers between groups; this owns only its own collapse. + */ +export function StatusSection({ accessory, children, defaultCollapsed = true, icon, label }: StatusSectionProps) { + const [collapsed, setCollapsed] = useState(defaultCollapsed) + + return ( +
+
+ + {accessory &&
{accessory}
} +
+ {!collapsed &&
{children}
} +
+ ) +} diff --git a/apps/desktop/src/components/chat/terminal-output.tsx b/apps/desktop/src/components/chat/terminal-output.tsx new file mode 100644 index 00000000000..946ec2386be --- /dev/null +++ b/apps/desktop/src/components/chat/terminal-output.tsx @@ -0,0 +1,50 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' + +import { cn } from '@/lib/utils' + +interface TerminalOutputProps { + className?: string + text: string +} + +const NEAR_BOTTOM_PX = 24 + +/** + * Tiny read-only terminal viewer: monospace, non-wrapping (long lines scroll + * horizontally), vertical scroll past `max-h`. Jumps to the bottom on mount, + * then tails — sticking to the bottom as `text` grows, but only when the user + * is already near the bottom so scrolling up to read earlier output isn't + * interrupted. + * + * Self-contained so any surface (status rows, tool calls, inspectors) can drop + * in a stdout/stderr box without re-implementing the scroll logic. + */ +export function TerminalOutput({ className, text }: TerminalOutputProps) { + const ref = useRef(null) + + // On open: jump straight to the latest output (no animation, before paint). + useLayoutEffect(() => { + const el = ref.current + + if (el) { + el.scrollTop = el.scrollHeight + } + }, []) + + // On growth: tail only when already pinned near the bottom. + useEffect(() => { + const el = ref.current + + if (el && el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX) { + el.scrollTop = el.scrollHeight + } + }, [text]) + + return ( +
+
+        {text}
+      
+
+ ) +} diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx index d7147cc5c49..0b92dba36fb 100644 --- a/apps/desktop/src/components/model-visibility-dialog.tsx +++ b/apps/desktop/src/components/model-visibility-dialog.tsx @@ -2,9 +2,9 @@ import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' -import { BrailleSpinner } from '@/components/ui/braille-spinner' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { Switch } from '@/components/ui/switch' import type { HermesGateway } from '@/hermes' import { getGlobalModelOptions } from '@/hermes' @@ -69,9 +69,7 @@ export function ModelVisibilityDialog({ next.delete(key) // Check if this was the last real model for this provider. - const remainingForProvider = [...next].some( - k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k) - ) + const remainingForProvider = [...next].some(k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k)) if (!remainingForProvider) { next.add(sentinel) @@ -110,7 +108,7 @@ export function ModelVisibilityDialog({
{providers.length === 0 ? (
- {modelOptions.isPending ? : copy.noAuthenticatedProviders} + {modelOptions.isPending ? : copy.noAuthenticatedProviders}
) : ( providers.map(provider => { diff --git a/apps/desktop/src/components/ui/button.tsx b/apps/desktop/src/components/ui/button.tsx index ad1d6c20f06..06abd4b7945 100644 --- a/apps/desktop/src/components/ui/button.tsx +++ b/apps/desktop/src/components/ui/button.tsx @@ -4,6 +4,9 @@ import * as React from 'react' import { cn } from '@/lib/utils' +// Text+icon actions underline the label on hover, not the glyph. +const TEXT_ACTION_ICON = '[&_.codicon]:no-underline [&_svg]:no-underline' + // Text buttons are square (no radius) and sized by padding + line-height — no // fixed heights — so they stay snug and scale with content. Only icon buttons // (inherently square) carry the shared 4px radius. @@ -22,13 +25,13 @@ const buttonVariants = cva( secondary: 'bg-(--ui-bg-quaternary) text-(--ui-text-primary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)', ghost: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)', - link: 'text-primary underline-offset-4 decoration-current/20 hover:underline', + link: `text-primary underline-offset-4 decoration-current/20 hover:underline ${TEXT_ACTION_ICON}`, // Boxless inline-text action (no bg/border). Quiet by default — reads as // muted label text, underlines on hover (e.g. "Cancel", "Clear"). - text: 'text-muted-foreground underline-offset-4 hover:text-foreground hover:underline', + text: `text-muted-foreground underline-offset-4 hover:text-foreground hover:underline ${TEXT_ACTION_ICON}`, // Emphasized inline-text action: bold + always-underlined link. Use for // the actionable affordance in a row ("Change", "Set", "Open logs", …). - textStrong: 'font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground' + textStrong: `font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground ${TEXT_ACTION_ICON}` }, size: { default: 'px-3 py-1.5 has-[>svg]:px-2.5', @@ -39,6 +42,9 @@ const buttonVariants = cva( // variants when the button must sit inline in a heading or sentence // (replaces ad-hoc `h-auto px-0 py-0` overrides). inline: 'h-auto gap-1 p-0 has-[>svg]:px-0', + // Status-stack headers, table footers — 12px text actions beside a label. + micro: + "h-auto gap-0.5 px-1 py-0 text-xs leading-4 font-normal has-[>svg]:px-0.5 [&_svg:not([class*='size-'])]:size-3", icon: 'size-9 rounded-[4px]', 'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8 rounded-[4px]', diff --git a/apps/desktop/src/components/ui/braille-spinner.tsx b/apps/desktop/src/components/ui/glyph-spinner.tsx similarity index 52% rename from apps/desktop/src/components/ui/braille-spinner.tsx rename to apps/desktop/src/components/ui/glyph-spinner.tsx index 3b6b8985c67..bf42e587640 100644 --- a/apps/desktop/src/components/ui/braille-spinner.tsx +++ b/apps/desktop/src/components/ui/glyph-spinner.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react' -import spinners, { type BrailleSpinnerName } from 'unicode-animations' +import spinners, { type BrailleSpinnerName as SpinnerName } from 'unicode-animations' import { cn } from '@/lib/utils' +export type { SpinnerName } + interface NormalisedSpinner { frames: readonly string[] interval: number @@ -10,10 +12,10 @@ interface NormalisedSpinner { // Some spinners ship multi-character frames. Pull the first cell so each // frame fits in one monospace box — matches how the TUI uses them. -const FRAMES_BY_NAME: Record = (() => { - const out = {} as Record +const FRAMES_BY_NAME: Record = (() => { + const out = {} as Record - for (const name of Object.keys(spinners) as BrailleSpinnerName[]) { + for (const name of Object.keys(spinners) as SpinnerName[]) { const raw = spinners[name] out[name] = { @@ -25,21 +27,21 @@ const FRAMES_BY_NAME: Record = (() => { return out })() -interface BrailleSpinnerProps { +interface GlyphSpinnerProps { ariaLabel?: string className?: string - spinner?: BrailleSpinnerName + spinner?: SpinnerName } /** - * One-char braille spinner driven by `unicode-animations`. Mirrors the - * spinner used by the Ink TUI so the desktop and terminal experiences - * read the same visually. Renders inside an `inline-flex` cell with - * `leading-none` and `items-center` so it sits vertically centred inside - * its parent's line-box (e.g. the 1.1rem disclosure row). + * One-char glyph spinner driven by `unicode-animations` (braille, orbit, scan, + * etc. — pick any `spinner` name). Mirrors the spinner used by the Ink TUI so + * the desktop and terminal experiences read the same visually. Renders inside + * an `inline-flex` cell with `leading-none` and `items-center` so it sits + * vertically centred inside its parent's line-box. */ -export function BrailleSpinner({ ariaLabel = 'Loading', className, spinner = 'breathe' }: BrailleSpinnerProps) { - const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.breathe! +export function GlyphSpinner({ ariaLabel = 'Loading', className, spinner = 'braille' }: GlyphSpinnerProps) { + const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.braille! const [frame, setFrame] = useState(0) useEffect(() => { diff --git a/apps/desktop/src/components/ui/kbd.tsx b/apps/desktop/src/components/ui/kbd.tsx index 7f5ecf28d65..0d4b5df310b 100644 --- a/apps/desktop/src/components/ui/kbd.tsx +++ b/apps/desktop/src/components/ui/kbd.tsx @@ -1,37 +1,108 @@ +import { cva, type VariantProps } from 'class-variance-authority' import * as React from 'react' +import { comboTokens } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' -function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { +const COMPACT_KEY = /^[\p{L}\p{N}⌘⌥⇧⌃↵⇥⌫↑↓←→@/?]$/u + +const kbdSurface = [ + 'border-[color-mix(in_srgb,var(--ui-stroke-secondary)_75%,transparent)]', + 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_94%,var(--dt-foreground)_6%)]', + 'text-[color-mix(in_srgb,var(--dt-foreground)_58%,transparent)]', + 'shadow-[0_1px_0_0_color-mix(in_srgb,var(--ui-stroke-tertiary)_85%,transparent),0_1px_2px_0_color-mix(in_srgb,var(--dt-foreground)_7%,transparent)]' +] + +const kbdVariants = cva( + 'inline-flex shrink-0 items-center justify-center border [font-family:var(--dt-font-kbd)] font-normal leading-none select-none', + { + variants: { + variant: { + default: kbdSurface, + ghost: [ + ...kbdSurface, + 'text-[color-mix(in_srgb,var(--dt-foreground)_38%,transparent)]', + 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_72%,var(--dt-foreground)_3%)]', + 'border-[color-mix(in_srgb,var(--ui-stroke-tertiary)_80%,transparent)]' + ], + capturing: [ + 'border-[color-mix(in_srgb,var(--theme-primary)_50%,var(--ui-stroke-secondary))]', + 'bg-[color-mix(in_srgb,var(--theme-primary)_10%,var(--ui-bg-elevated))]', + 'text-[color-mix(in_srgb,var(--theme-primary)_88%,transparent)]', + 'shadow-none' + ], + inverted: [ + 'border-[color-mix(in_srgb,currentColor_22%,transparent)]', + 'bg-[color-mix(in_srgb,currentColor_12%,transparent)]', + 'text-[color-mix(in_srgb,currentColor_88%,transparent)]', + 'shadow-[0_1px_0_0_color-mix(in_srgb,currentColor_18%,transparent)]' + ] + }, + size: { + sm: 'rounded-[0.2rem] text-[0.625rem]', + md: 'rounded-[0.25rem] text-[0.6875rem]' + } + }, + defaultVariants: { + variant: 'default', + size: 'md' + } + } +) + +function kbdShapeClass(label: string, size: 'sm' | 'md' | null | undefined): string { + const compact = COMPACT_KEY.test(label) + + if (size === 'sm') { + return compact ? 'size-[1.125rem] px-0' : 'h-[1.125rem] min-w-[1.125rem] px-1' + } + + return compact ? 'size-[1.375rem] px-0' : 'h-[1.375rem] min-w-[1.375rem] px-1.5' +} + +interface KbdProps extends React.ComponentProps<'kbd'>, VariantProps {} + +function Kbd({ children, className, size, variant, ...props }: KbdProps) { + const label = typeof children === 'string' ? children : '' + return ( + > + {children} + ) } -interface KbdGroupProps extends Omit, 'children'> { +interface KbdGroupProps extends Omit, 'children'>, VariantProps { keys: string[] } -function KbdGroup({ className, keys, ...props }: KbdGroupProps) { +function KbdGroup({ className, keys, size, variant, ...props }: KbdGroupProps) { return ( - {keys.map(key => ( - {key} + {keys.map((key, index) => ( + + {key} + ))} ) } -export { Kbd, KbdGroup } +interface KbdComboProps extends Omit { + combo: string +} + +function KbdCombo({ combo, ...props }: KbdComboProps) { + return +} + +export { Kbd, KbdCombo, KbdGroup, kbdVariants } diff --git a/apps/desktop/src/components/ui/sidebar.tsx b/apps/desktop/src/components/ui/sidebar.tsx index 539b0215440..96d00e8b7ef 100644 --- a/apps/desktop/src/components/ui/sidebar.tsx +++ b/apps/desktop/src/components/ui/sidebar.tsx @@ -339,7 +339,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) { return (
Promise // Open (or focus) a standalone OS window for a single chat session so // the user can work with multiple chats side by side. Returns ok:false - // with an error code when the sessionId is empty/invalid. - openSessionWindow: (sessionId: string) => Promise<{ ok: boolean; error?: string }> + // with an error code when the sessionId is empty/invalid. `watch` opens + // a spectator window (lazy resume — no agent build) for live-streaming + // a running subagent's session. + openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }> getBootProgress: () => Promise getConnectionConfig: (profile?: null | string) => Promise saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise @@ -52,6 +54,7 @@ declare global { watchPreviewFile: (url: string) => Promise stopPreviewFileWatch: (id: string) => Promise setTitleBarTheme?: (payload: HermesTitleBarTheme) => void + setNativeTheme?: (mode: 'dark' | 'light' | 'system') => void setPreviewShortcutActive?: (active: boolean) => void openExternal: (url: string) => Promise fetchLinkTitle: (url: string) => Promise @@ -76,7 +79,7 @@ declare global { onClosePreviewRequested?: (callback: () => void) => () => void onOpenUpdatesRequested?: (callback: () => void) => () => void onDeepLink?: ( - callback: (payload: { kind: string; name: string; params: Record }) => void, + callback: (payload: { kind: string; name: string; params: Record }) => void ) => () => void signalDeepLinkReady?: () => Promise<{ ok: boolean }> onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a0cfdbb08b7..0b2e40b6d5e 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1184,14 +1184,14 @@ export const en: Translations = { '/quit': 'exit hermes' }, hotkeyDescs: { - '@': 'reference files, folders, urls, git', - '/': 'slash command palette', - '?': 'this quick help (delete to dismiss)', - Enter: 'send · Shift+Enter for newline', - 'Cmd/Ctrl+Shift+K': 'send next queued turn', - 'Cmd/Ctrl+/': 'all keyboard shortcuts', - Esc: 'close popover · cancel run', - '↑ / ↓': 'cycle popover / history' + 'composer.mention': 'reference files, folders, urls, git', + 'composer.slash': 'slash command palette', + 'composer.help': 'this quick help (delete to dismiss)', + 'composer.sendNewline': 'send · Shift+Enter for newline', + 'composer.sendQueued': 'send next queued turn', + 'keybinds.openPanel': 'all keyboard shortcuts', + 'composer.cancel': 'close popover · cancel run', + 'composer.history': 'cycle popover / history' }, attachUrlTitle: 'Attach a URL', attachUrlDesc: 'Hermes will fetch the page and include it as context for this turn.', @@ -1204,10 +1204,10 @@ export const en: Translations = { attachments: count => `${count} attachment${count === 1 ? '' : 's'}`, editingInComposer: 'Editing in composer', editingQueuedInComposer: 'Editing queued turn in composer', - editQueued: 'Edit queued turn', - sendQueuedNext: 'Send queued turn next', - sendQueuedNow: 'Send queued turn now', - deleteQueued: 'Delete queued turn', + queueEdit: 'Edit', + queueSendNext: 'Next', + queueSend: 'Send', + queueDelete: 'Delete', previewUnavailable: 'Preview unavailable', previewLabel: label => `Preview ${label}`, couldNotPreview: label => `Could not preview ${label}`, @@ -1252,6 +1252,17 @@ export const en: Translations = { } }, + statusStack: { + agents: 'Agents', + background: count => `${count} Background`, + subagents: count => `${count} Subagent${count === 1 ? '' : 's'}`, + todos: (done, total) => `Tasks ${done}/${total}`, + running: 'Running', + stop: 'Stop', + dismiss: 'Dismiss', + exit: code => `exit ${code}` + }, + updates: { stages: { idle: 'Getting ready…', @@ -1287,7 +1298,8 @@ export const en: Translations = { copied: 'Copied', done: 'Done', applyingBody: 'The Hermes updater will take over in its own window and reopen Hermes when it’s done.', - applyingBodyBackend: 'The remote backend is applying the update and will restart. Hermes reconnects automatically when it’s back.', + applyingBodyBackend: + 'The remote backend is applying the update and will restart. Hermes reconnects automatically when it’s back.', applyingClose: 'Hermes will close to apply the update.', errorTitle: 'Update didn’t finish', errorBody: 'No worries — nothing was lost. You can try again now.', @@ -1653,9 +1665,12 @@ export const en: Translations = { readAloud: 'Read aloud', editMessage: 'Edit message', stop: 'Stop', - editableCheckpoint: 'Editable checkpoint', restorePrevious: 'Restore previous checkpoint', restoreCheckpoint: 'Restore checkpoint', + restoreFromHere: 'Restore checkpoint — rerun from this prompt', + restoreTitle: 'Restore to this checkpoint?', + restoreBody: 'Everything after this prompt is removed from the conversation, and the prompt runs again from here.', + restoreConfirm: 'Restore & rerun', restoreNext: 'Restore next checkpoint', goForward: 'Go forward', sendEdited: 'Send edited message', @@ -1681,7 +1696,7 @@ export const en: Translations = { loadingQuestion: 'Loading question…', other: 'Other (type your answer)', placeholder: 'Type your answer…', - shortcut: '⌘/Ctrl + Enter to send', + shortcutSuffix: ' to send', back: 'Back', skip: 'Skip', send: 'Send' diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 0ae343586fd..bdc5c6b9dab 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -216,9 +216,11 @@ export const ja = defineLocale({ technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。', themeTitle: 'テーマ', themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。', - themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`, + themeProfileNote: profile => + `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`, installTitle: 'VS Code から導入', - installDesc: 'Marketplace の拡張機能 ID(例: dracula-theme.theme-dracula)を貼り付けると、その配色テーマをデスクトップ用パレットに変換します。', + installDesc: + 'Marketplace の拡張機能 ID(例: dracula-theme.theme-dracula)を貼り付けると、その配色テーマをデスクトップ用パレットに変換します。', installPlaceholder: 'publisher.extension', installButton: 'インストール', installing: 'インストール中…', @@ -387,7 +389,8 @@ export const ja = defineLocale({ personality: '新しいセッションのデフォルトのアシスタントスタイルです。', showReasoning: 'バックエンドが推論内容を提供したときに表示します。' }, - timezone: 'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。', + timezone: + 'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。', agent: { imageInputMode: '画像添付をモデルへ送る方法を制御します。', maxTurns: 'Hermes が 1 回の実行を停止するまでのツール呼び出しターン上限です。' @@ -513,15 +516,16 @@ export const ja = defineLocale({ envOverrideDesc: '保存された設定を使用するには HERMES_DESKTOP_REMOTE_URL と HERMES_DESKTOP_REMOTE_TOKEN の設定を解除してください。', localTitle: 'ローカルゲートウェイ', - localDesc: 'ローカルホストでプライベートな Hermes バックエンドを起動します。これがデフォルトで、オフラインでも動作します。', + localDesc: + 'ローカルホストでプライベートな Hermes バックエンドを起動します。これがデフォルトで、オフラインでも動作します。', remoteTitle: 'リモートゲートウェイ', remoteDesc: 'このデスクトップシェルをリモートの Hermes バックエンドに接続します。ホスト型ゲートウェイは OAuth またはユーザー名とパスワードを使用します。自己ホスト型はセッショントークンを使用する場合があります。', remoteUrlTitle: 'リモート URL', - remoteUrlDesc: 'リモートダッシュボードバックエンドのベース URL。/hermes などのパスプレフィックスもサポートしています。', + remoteUrlDesc: + 'リモートダッシュボードバックエンドのベース URL。/hermes などのパスプレフィックスもサポートしています。', probing: 'このゲートウェイの認証方法を確認中…', - probeError: - 'このゲートウェイにまだ到達できません。URL を確認してください。応答後に認証方法が表示されます。', + probeError: 'このゲートウェイにまだ到達できません。URL を確認してください。応答後に認証方法が表示されます。', signedIn: 'サインイン済み', signIn: 'サインイン', signOut: 'サインアウト', @@ -529,7 +533,8 @@ export const ja = defineLocale({ authTitle: '認証', authSignedInPassword: 'このゲートウェイはユーザー名とパスワードを使用します。サインイン済みです。セッションは自動的に更新されます。', - authSignedInOauth: 'このゲートウェイは OAuth を使用します。サインイン済みです。セッションは自動的に更新されます。', + authSignedInOauth: + 'このゲートウェイは OAuth を使用します。サインイン済みです。セッションは自動的に更新されます。', authNeedsPassword: 'このゲートウェイはユーザー名とパスワードを使用します。このデスクトップアプリを承認するにはサインインしてください。', authNeedsOauth: provider => @@ -544,8 +549,7 @@ export const ja = defineLocale({ saveForRestart: '次回起動時のために保存', saveAndReconnect: '保存して再接続', diagnostics: '診断', - diagnosticsDesc: - 'ファイルマネージャーで desktop.log を表示します。ゲートウェイの起動に失敗した際に役立ちます。', + diagnosticsDesc: 'ファイルマネージャーで desktop.log を表示します。ゲートウェイの起動に失敗した際に役立ちます。', openLogs: 'ログを開く', incompleteTitle: 'リモートゲートウェイの設定が不完全です', incompleteSignIn: 'リモートに切り替える前にリモート URL を入力してサインインしてください。', @@ -603,7 +607,8 @@ export const ja = defineLocale({ }, model: { loading: 'モデル設定を読み込み中...', - appliesDesc: '新しいセッションに適用されます。コンポーザーのモデルピッカーを使ってアクティブなチャットをホットスワップできます。', + appliesDesc: + '新しいセッションに適用されます。コンポーザーのモデルピッカーを使ってアクティブなチャットをホットスワップできます。', provider: 'プロバイダー', model: 'モデル', applying: '適用中...', @@ -1017,7 +1022,8 @@ export const ja = defineLocale({ notSet: '未設定', soulDesc: 'このプロファイルに組み込まれたシステムプロンプトとペルソナの指示。', soulOptional: '省略可能', - soulPlaceholder: mode => `このプロファイルのシステムプロンプト / ペルソナ。\n空欄のままにすると ${mode} のデフォルトを使用します。`, + soulPlaceholder: mode => + `このプロファイルのシステムプロンプト / ペルソナ。\n空欄のままにすると ${mode} のデフォルトを使用します。`, soulPlaceholderCloned: 'クローン済み', soulPlaceholderEmpty: '空', unsavedChanges: '未保存の変更', @@ -1316,14 +1322,14 @@ export const ja = defineLocale({ '/quit': 'hermes を終了' }, hotkeyDescs: { - '@': 'ファイル、フォルダー、URL、Git を参照', - '/': 'スラッシュコマンドパレット', - '?': 'クイックヘルプ(削除で閉じる)', - Enter: '送信 · 改行は Shift+Enter', - 'Cmd/Ctrl+K': '次のキュー済みターンを送信', - 'Cmd/Ctrl+L': '再描画', - Esc: 'ポップオーバーを閉じる · 実行をキャンセル', - '↑ / ↓': 'ポップオーバー / 履歴を切り替え' + 'composer.mention': 'ファイル、フォルダー、URL、Git を参照', + 'composer.slash': 'スラッシュコマンドパレット', + 'composer.help': 'クイックヘルプ(削除で閉じる)', + 'composer.sendNewline': '送信 · 改行は Shift+Enter', + 'composer.sendQueued': '次のキュー済みターンを送信', + 'keybinds.openPanel': 'すべてのキーボードショートカット', + 'composer.cancel': 'ポップオーバーを閉じる · 実行をキャンセル', + 'composer.history': 'ポップオーバー / 履歴を切り替え' }, attachUrlTitle: 'URL を添付', attachUrlDesc: 'Hermes がページを取得し、このターンのコンテキストとして含めます。', @@ -1336,9 +1342,10 @@ export const ja = defineLocale({ attachments: count => `${count} 件の添付`, editingInComposer: 'コンポーザーで編集中', editingQueuedInComposer: 'コンポーザーでキュー済みターンを編集中', - editQueued: 'キュー済みターンを編集', - sendQueuedNow: 'キュー済みターンを今すぐ送信', - deleteQueued: 'キュー済みターンを削除', + queueEdit: '編集', + queueSendNext: '次に送信', + queueSend: '送信', + queueDelete: '削除', previewUnavailable: 'プレビューは利用できません', previewLabel: label => `${label} のプレビュー`, couldNotPreview: label => `${label} をプレビューできませんでした`, @@ -1383,6 +1390,17 @@ export const ja = defineLocale({ } }, + statusStack: { + agents: 'エージェント', + background: count => `バックグラウンド ${count} 件`, + subagents: count => `サブエージェント ${count} 件`, + todos: (done, total) => `タスク ${done}/${total}`, + running: '実行中', + stop: '停止', + dismiss: '閉じる', + exit: code => `終了コード ${code}` + }, + updates: { stages: { idle: '準備中…', @@ -1407,7 +1425,8 @@ export const ja = defineLocale({ availableBody: '新しいバージョンの Hermes をインストールする準備ができています。', availableTitleBackend: 'バックエンドの更新があります', availableBodyBackend: '接続中の Hermes バックエンドの新しいバージョンをインストールできます。', - availableBodyNoChangelog: '新しいバージョンを利用できます。このインストール形式ではリリースノートは表示できません。', + availableBodyNoChangelog: + '新しいバージョンを利用できます。このインストール形式ではリリースノートは表示できません。', updateNow: '今すぐ更新', maybeLater: '後で', moreChanges: count => `さらに ${count} 件の変更が含まれています。`, @@ -1430,7 +1449,8 @@ export const ja = defineLocale({ restarting: 'バックエンドが更新を読み込むため再起動しています…', notAvailable: 'このバックエンドでは更新を利用できません。', failed: 'バックエンドの更新に失敗しました。', - noReturn: 'バックエンドがオンラインに戻りませんでした。更新が完了していない可能性があります。バックエンドホストを確認してください。' + noReturn: + 'バックエンドがオンラインに戻りませんでした。更新が完了していない可能性があります。バックエンドホストを確認してください。' } }, @@ -1786,9 +1806,12 @@ export const ja = defineLocale({ readAloud: '読み上げ', editMessage: 'メッセージを編集', stop: '停止', - editableCheckpoint: '編集可能なチェックポイント', restorePrevious: '前のチェックポイントに戻す', restoreCheckpoint: 'チェックポイントを復元', + restoreFromHere: 'チェックポイントを復元 — このプロンプトから再実行', + restoreTitle: 'このチェックポイントに復元しますか?', + restoreBody: 'このプロンプト以降のメッセージは会話から削除され、ここからプロンプトが再実行されます。', + restoreConfirm: '復元して再実行', restoreNext: '次のチェックポイントに戻す', goForward: '進む', sendEdited: '編集済みメッセージを送信', @@ -1814,7 +1837,7 @@ export const ja = defineLocale({ loadingQuestion: '質問を読み込み中…', other: 'その他(回答を入力)', placeholder: '回答を入力…', - shortcut: '⌘/Ctrl + Enter で送信', + shortcutSuffix: ' で送信', back: '戻る', skip: 'スキップ', send: '送信' diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 592fe2bfa2c..65f8788f760 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -919,10 +919,10 @@ export interface Translations { attachments: (count: number) => string editingInComposer: string editingQueuedInComposer: string - editQueued: string - sendQueuedNext: string - sendQueuedNow: string - deleteQueued: string + queueEdit: string + queueSendNext: string + queueSend: string + queueDelete: string previewUnavailable: string previewLabel: (label: string) => string couldNotPreview: (label: string) => string @@ -951,6 +951,17 @@ export interface Translations { dropSession: string } + statusStack: { + agents: string + background: (count: number) => string + subagents: (count: number) => string + todos: (done: number, total: number) => string + running: string + stop: string + dismiss: string + exit: (code: number) => string + } + updates: { stages: Record checking: string @@ -1313,9 +1324,12 @@ export interface Translations { readAloud: string editMessage: string stop: string - editableCheckpoint: string restorePrevious: string restoreCheckpoint: string + restoreFromHere: string + restoreTitle: string + restoreBody: string + restoreConfirm: string restoreNext: string goForward: string sendEdited: string @@ -1340,7 +1354,7 @@ export interface Translations { loadingQuestion: string other: string placeholder: string - shortcut: string + shortcutSuffix: string back: string skip: string send: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 058ad3fb3c2..020c01b5236 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -503,8 +503,7 @@ export const zhHant = defineLocale({ defaultConnection: '預設連線適用於所有沒有自訂覆寫的設定檔。', profileConnection: profile => `僅當「${profile}」為作用中設定檔時使用此連線。設為本機可繼承預設連線。`, envOverrideTitle: '環境變數正在控制此桌面工作階段。', - envOverrideDesc: - '取消設定 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 後才會使用下方儲存的設定。', + envOverrideDesc: '取消設定 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 後才會使用下方儲存的設定。', localTitle: '本機閘道', localDesc: '在 localhost 啟動私有 Hermes 後端。這是預設方式,可離線使用。', remoteTitle: '遠端閘道', @@ -626,8 +625,7 @@ export const zhHant = defineLocale({ sessions: { loading: '正在載入已封存工作階段…', archivedTitle: '已封存工作階段', - archivedIntro: - '已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。', + archivedIntro: '已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。', emptyArchivedTitle: '暫無封存', emptyArchivedDesc: '封存一個聊天後會顯示在這裡。', unarchive: '取消封存', @@ -636,8 +634,7 @@ export const zhHant = defineLocale({ restored: '已還原', deleteConfirm: title => `永久刪除「${title}」?此操作無法復原。`, defaultDirTitle: '預設專案目錄', - defaultDirDesc: - '新工作階段預設從此資料夾開始,除非您選擇其他目錄。留空則使用您的家目錄。', + defaultDirDesc: '新工作階段預設從此資料夾開始,除非您選擇其他目錄。留空則使用您的家目錄。', defaultDirUpdated: '預設專案目錄已更新', defaultsTo: label => `預設使用 ${label}。`, change: '變更', @@ -1080,8 +1077,7 @@ export const zhHant = defineLocale({ topOfHour: '每個整點', everyHourAt: minute => `每小時的 :${minute}`, newCron: '新排程工作', - emptyDescNew: - '按 cron 表達式排程一個提示詞。Hermes 會執行它,並將結果傳送至您選擇的目的地。', + emptyDescNew: '按 cron 表達式排程一個提示詞。Hermes 會執行它,並將結果傳送至您選擇的目的地。', emptyDescSearch: '請嘗試更廣泛的搜尋詞。', emptyTitleNew: '暫無排程工作', emptyTitleSearch: '無相符項目', @@ -1282,14 +1278,14 @@ export const zhHant = defineLocale({ '/quit': '結束 hermes' }, hotkeyDescs: { - '@': '參照檔案、資料夾、URL、git', - '/': '斜線指令面板', - '?': '此快速說明(刪除以關閉)', - Enter: '傳送 · Shift+Enter 換行', - 'Cmd/Ctrl+K': '傳送下一個排隊的回合', - 'Cmd/Ctrl+L': '重繪', - Esc: '關閉彈出視窗 · 取消執行', - '↑ / ↓': '循環彈出視窗 / 歷史記錄' + 'composer.mention': '參照檔案、資料夾、URL、git', + 'composer.slash': '斜線指令面板', + 'composer.help': '此快速說明(刪除以關閉)', + 'composer.sendNewline': '傳送 · Shift+Enter 換行', + 'composer.sendQueued': '傳送下一個排隊的回合', + 'keybinds.openPanel': '所有鍵盤快捷鍵', + 'composer.cancel': '關閉彈出視窗 · 取消執行', + 'composer.history': '循環彈出視窗 / 歷史記錄' }, attachUrlTitle: '附加 URL', attachUrlDesc: 'Hermes 將擷取該頁面並作為此回合的脈絡。', @@ -1302,9 +1298,10 @@ export const zhHant = defineLocale({ attachments: count => `${count} 個附件`, editingInComposer: '在輸入框中編輯', editingQueuedInComposer: '在輸入框中編輯排隊回合', - editQueued: '編輯排隊回合', - sendQueuedNow: '立即傳送排隊回合', - deleteQueued: '刪除排隊回合', + queueEdit: '編輯', + queueSendNext: '下一個', + queueSend: '傳送', + queueDelete: '刪除', previewUnavailable: '預覽不可用', previewLabel: label => `預覽 ${label}`, couldNotPreview: label => `無法預覽 ${label}`, @@ -1349,6 +1346,17 @@ export const zhHant = defineLocale({ } }, + statusStack: { + agents: '代理', + background: count => `${count} 個背景任務`, + subagents: count => `${count} 個子代理`, + todos: (done, total) => `任務 ${done}/${total}`, + running: '執行中', + stop: '停止', + dismiss: '關閉', + exit: code => `結束碼 ${code}` + }, + updates: { stages: { idle: '準備中…', @@ -1420,8 +1428,7 @@ export const zhHant = defineLocale({ finishingTitle: '正在收尾', failedDesc: '某個安裝步驟失敗。在 Windows 上,如果另一個 Hermes CLI 或桌面執行個體正在執行,可能會出現這種情況。請停止正在執行的 Hermes 執行個體後重試。可查看下方的詳細資訊或 desktop 記錄中的完整記錄。', - activeDesc: - '這是一次性設定。Hermes 安裝程式正在下載相依套件並設定您的電腦。之後啟動會略過此步驟。', + activeDesc: '這是一次性設定。Hermes 安裝程式正在下載相依套件並設定您的電腦。之後啟動會略過此步驟。', progress: (completed, total) => `${completed}/${total} 個步驟已完成`, currentStage: stage => ` -- 目前:${stage}`, fetchingManifest: '正在取得安裝程式 manifest...', @@ -1487,12 +1494,10 @@ export const zhHant = defineLocale({ copyAuthCode: '複製授權碼並貼到下方。', pasteAuthCode: '貼上授權碼', reopenAuthPage: '重新開啟授權頁面', - autoBrowser: provider => - `已在瀏覽器中開啟 ${provider}。請在那裡授權 Hermes,連線會自動完成,無需複製或貼上。`, + autoBrowser: provider => `已在瀏覽器中開啟 ${provider}。請在那裡授權 Hermes,連線會自動完成,無需複製或貼上。`, reopenSignInPage: '重新開啟登入頁面', waitingAuthorize: '等待您授權...', - externalPending: provider => - `${provider} 透過自己的 CLI 登入。請在終端機執行此指令,然後回來選擇「我已登入」:`, + externalPending: provider => `${provider} 透過自己的 CLI 登入。請在終端機執行此指令,然後回來選擇「我已登入」:`, signedIn: '我已登入', deviceCodeOpened: provider => `已在瀏覽器中開啟 ${provider}。請在那裡輸入此代碼:`, reopenVerification: '重新開啟驗證頁面', @@ -1707,16 +1712,14 @@ export const zhHant = defineLocale({ showConsole: '顯示預覽主控台', hideDevTools: '隱藏預覽 DevTools', openDevTools: '開啟預覽 DevTools', - finishedRestarting: message => - `Hermes 已完成預覽伺服器重新啟動${message ? `:${message}` : ''}`, + finishedRestarting: message => `Hermes 已完成預覽伺服器重新啟動${message ? `:${message}` : ''}`, failedRestarting: message => `伺服器重新啟動失敗:${message}`, unknownError: '未知錯誤', restartedTitle: '預覽伺服器已重新啟動', reloadingNow: '正在重新載入預覽。', restartFailedTitle: '預覽重新啟動失敗', restartFailedMessage: 'Hermes 無法重新啟動伺服器。', - stillWorking: - 'Hermes 仍在執行,但尚未收到重新啟動結果。伺服器指令可能正在前台執行。', + stillWorking: 'Hermes 仍在執行,但尚未收到重新啟動結果。伺服器指令可能正在前台執行。', workspaceReloading: '工作區已變更,正在重新載入預覽', fileChanged: url => `檔案已變更,正在重新載入預覽:${url}`, filesChanged: (count, url) => `${count} 個檔案變更,正在重新載入預覽:${url}`, @@ -1747,9 +1750,12 @@ export const zhHant = defineLocale({ readAloud: '朗讀', editMessage: '編輯訊息', stop: '停止', - editableCheckpoint: '可編輯的檢查點', restorePrevious: '還原至上一個檢查點', restoreCheckpoint: '還原檢查點', + restoreFromHere: '還原檢查點 — 從此提示重新執行', + restoreTitle: '還原至此檢查點?', + restoreBody: '此提示之後的所有訊息將從對話中移除,並從此處重新執行該提示。', + restoreConfirm: '還原並重新執行', restoreNext: '還原至下一個檢查點', goForward: '前進', sendEdited: '傳送編輯後的訊息', @@ -1775,7 +1781,7 @@ export const zhHant = defineLocale({ loadingQuestion: '正在載入問題…', other: '其他(輸入您的答案)', placeholder: '輸入您的答案…', - shortcut: '⌘/Ctrl + Enter 傳送', + shortcutSuffix: ' 傳送', back: '返回', skip: '略過', send: '傳送' @@ -1833,8 +1839,7 @@ export const zhHant = defineLocale({ yoloSystem: active => `此工作階段 YOLO ${active ? '已開啟' : '已關閉'}`, yoloTitle: 'YOLO', yoloToggleFailed: '無法切換 YOLO', - profileStatus: current => - `設定檔:${current}。使用 /profile 或「新工作階段」選擇器在其他設定檔中開始聊天。`, + profileStatus: current => `設定檔:${current}。使用 /profile 或「新工作階段」選擇器在其他設定檔中開始聊天。`, unknownProfile: '未知設定檔', noProfileNamed: (target, available) => `沒有名為「${target}」的設定檔。可用的:${available}`, newChatsProfile: name => `新聊天將使用設定檔 ${name}。`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index de6f467ab61..bd438ea1842 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1018,13 +1018,15 @@ export const zh: Translations = { platformIntro: { telegram: '在 Telegram 中,与 @BotFather 对话,运行 /newbot,复制它给你的令牌。然后从 @userinfobot 获取你的数字用户 ID。', - discord: '打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。', + discord: + '打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。', slack: '创建 Slack 应用,启用 Socket Mode,安装到你的工作区,然后复制 bot 令牌和 app 级令牌。', mattermost: '在你的 Mattermost 服务器上,创建机器人账户或个人访问令牌,然后在此粘贴服务器 URL 和令牌。', matrix: '用机器人账户登录你的 homeserver,然后复制访问令牌、用户 ID 和 homeserver URL。', signal: '在可访问的位置运行 signal-cli REST 桥接,然后把 Hermes 指向该 URL 和已注册的电话号码。', whatsapp: '启动 Hermes 自带的 WhatsApp 桥接,首次运行时扫描二维码,然后启用该平台。', - bluebubbles: '在装有 iMessage 的 Mac 上运行 BlueBubbles Server,暴露其 API,然后用服务器密码把 Hermes 指向该 URL。', + bluebubbles: + '在装有 iMessage 的 Mac 上运行 BlueBubbles Server,暴露其 API,然后用服务器密码把 Hermes 指向该 URL。', homeassistant: '在 Home Assistant 中打开你的个人资料并创建长期访问令牌。把它连同你的 HA URL 一起粘贴到这里。', email: '使用专用邮箱。对于 Gmail/Workspace,创建应用专用密码并使用 imap.gmail.com / smtp.gmail.com。', sms: '从 Twilio 控制台获取你的 Account SID 和 Auth Token,以及一个可发送短信的电话号码。', @@ -1370,14 +1372,14 @@ export const zh: Translations = { '/quit': '退出 hermes' }, hotkeyDescs: { - '@': '引用文件、文件夹、URL、git', - '/': '斜杠命令面板', - '?': '此快速帮助 (删除以关闭)', - Enter: '发送 · Shift+Enter 换行', - 'Cmd/Ctrl+K': '发送下一条排队的回合', - 'Cmd/Ctrl+L': '重绘', - Esc: '关闭弹窗 · 取消运行', - '↑ / ↓': '循环弹窗 / 历史' + 'composer.mention': '引用文件、文件夹、URL、git', + 'composer.slash': '斜杠命令面板', + 'composer.help': '此快速帮助 (删除以关闭)', + 'composer.sendNewline': '发送 · Shift+Enter 换行', + 'composer.sendQueued': '发送下一条排队的回合', + 'keybinds.openPanel': '所有键盘快捷键', + 'composer.cancel': '关闭弹窗 · 取消运行', + 'composer.history': '循环弹窗 / 历史' }, attachUrlTitle: '附加 URL', attachUrlDesc: 'Hermes 将抓取该页面并作为本回合的上下文。', @@ -1390,10 +1392,10 @@ export const zh: Translations = { attachments: count => `${count} 个附件`, editingInComposer: '正在输入框中编辑', editingQueuedInComposer: '正在输入框中编辑排队回合', - editQueued: '编辑排队回合', - sendQueuedNext: '下一个发送排队回合', - sendQueuedNow: '立即发送排队回合', - deleteQueued: '删除排队回合', + queueEdit: '编辑', + queueSendNext: '下一个', + queueSend: '发送', + queueDelete: '删除', previewUnavailable: '预览不可用', previewLabel: label => `预览 ${label}`, couldNotPreview: label => `无法预览 ${label}`, @@ -1438,6 +1440,17 @@ export const zh: Translations = { } }, + statusStack: { + agents: '代理', + background: count => `${count} 个后台任务`, + subagents: count => `${count} 个子代理`, + todos: (done, total) => `任务 ${done}/${total}`, + running: '运行中', + stop: '停止', + dismiss: '关闭', + exit: code => `退出码 ${code}` + }, + updates: { stages: { idle: '准备中…', @@ -1832,9 +1845,12 @@ export const zh: Translations = { readAloud: '朗读', editMessage: '编辑消息', stop: '停止', - editableCheckpoint: '可编辑检查点', restorePrevious: '恢复上一个检查点', restoreCheckpoint: '恢复检查点', + restoreFromHere: '恢复检查点 — 从此提示重新运行', + restoreTitle: '恢复到此检查点?', + restoreBody: '此提示之后的所有消息将从对话中移除,并从此处重新运行该提示。', + restoreConfirm: '恢复并重新运行', restoreNext: '恢复下一个检查点', goForward: '前进', sendEdited: '发送编辑后的消息', @@ -1860,7 +1876,7 @@ export const zh: Translations = { loadingQuestion: '正在加载问题…', other: '其他 (输入你的答案)', placeholder: '输入你的答案…', - shortcut: '⌘/Ctrl + Enter 发送', + shortcutSuffix: ' 发送', back: '返回', skip: '跳过', send: '发送' diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index e569f2582f2..09f8f6f6f4e 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -66,6 +66,8 @@ export type GatewayEventPayload = { // terminal.read.request (GUI agent reading the in-app terminal pane) start?: number count?: number + // status.update (kind=process → background process completion/watch-match) + kind?: string } export function textPart(text: string): ChatMessagePart { diff --git a/apps/desktop/src/lib/todos.test.ts b/apps/desktop/src/lib/todos.test.ts index ebd296ab7a4..a19752c7372 100644 --- a/apps/desktop/src/lib/todos.test.ts +++ b/apps/desktop/src/lib/todos.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { parseTodos } from './todos' +import { latestSessionTodos, parseTodos } from './todos' describe('parseTodos', () => { it('parses todo arrays with valid ids, content, and statuses', () => { @@ -33,3 +33,48 @@ describe('parseTodos', () => { expect(parseTodos({ message: 'no todos here' })).toBeNull() }) }) + +describe('latestSessionTodos', () => { + const todoPart = (todos: unknown, extra: Record = {}) => ({ + type: 'tool-call', + toolCallId: 't1', + toolName: 'todo', + args: { todos }, + ...extra + }) + + it('returns the last todo list across the transcript (result beats args)', () => { + const messages = [ + { parts: [todoPart([{ content: 'Old', id: 'a', status: 'pending' }])] }, + { parts: [{ type: 'text', text: 'hi' }] }, + { + parts: [ + todoPart([{ content: 'Stale', id: 'a', status: 'pending' }], { + result: { todos: [{ content: 'Fresh', id: 'a', status: 'completed' }] } + }) + ] + } + ] + + expect(latestSessionTodos(messages)).toEqual([{ content: 'Fresh', id: 'a', status: 'completed' }]) + }) + + it('prefers the live carried `todos` field over args', () => { + const messages = [ + { + parts: [ + todoPart([{ content: 'Args', id: 'a', status: 'pending' }], { + todos: [{ content: 'Live', id: 'a', status: 'in_progress' }] + }) + ] + } + ] + + expect(latestSessionTodos(messages)).toEqual([{ content: 'Live', id: 'a', status: 'in_progress' }]) + }) + + it('returns null when no todo tool calls exist', () => { + expect(latestSessionTodos([{ parts: [{ type: 'text', text: 'hi' }] }])).toBeNull() + expect(latestSessionTodos([])).toBeNull() + }) +}) diff --git a/apps/desktop/src/lib/todos.ts b/apps/desktop/src/lib/todos.ts index 56f36b45c27..6a5d8eea06d 100644 --- a/apps/desktop/src/lib/todos.ts +++ b/apps/desktop/src/lib/todos.ts @@ -49,3 +49,40 @@ function parse(value: unknown, depth: number): null | TodoItem[] { } export const parseTodos = (value: unknown): null | TodoItem[] => parse(value, 0) + +/** Latest parseable todo list from one message's aui content parts (tool-call + * parts named `todo`; live parts carry `todos`, hydrated ones args/result). */ +export function todosFromMessageContent(content: unknown): null | TodoItem[] { + if (!Array.isArray(content)) { + return null + } + + let latest: null | TodoItem[] = null + + for (const part of content) { + if (!isRecord(part) || part.type !== 'tool-call' || part.toolName !== 'todo') { + continue + } + + const parsed = parseTodos(part.todos) ?? parseTodos(part.result) ?? parseTodos(part.args) + + if (parsed !== null) { + latest = parsed + } + } + + return latest +} + +/** Current todo state for a whole transcript — the last list wins. */ +export function latestSessionTodos(messages: readonly { parts?: unknown }[]): null | TodoItem[] { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const todos = todosFromMessageContent(messages[i]?.parts) + + if (todos !== null) { + return todos + } + } + + return null +} diff --git a/apps/desktop/src/store/composer-status.test.ts b/apps/desktop/src/store/composer-status.test.ts new file mode 100644 index 00000000000..e677dc0bb8c --- /dev/null +++ b/apps/desktop/src/store/composer-status.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { $backgroundStatusBySession, dismissBackgroundProcess, reconcileBackgroundProcesses } from './composer-status' + +const SID = 'sess-1' + +const running = (id: string, command = `cmd ${id}`) => ({ command, session_id: id, status: 'running' }) + +const exited = (id: string, exit_code = 0, command = `cmd ${id}`) => ({ + command, + exit_code, + session_id: id, + status: 'exited' +}) + +const items = () => $backgroundStatusBySession.get()[SID] ?? [] + +describe('reconcileBackgroundProcesses', () => { + beforeEach(() => { + $backgroundStatusBySession.set({}) + }) + + it('maps registry entries to status items', () => { + reconcileBackgroundProcesses(SID, [running('a'), exited('b', 0), exited('c', 1)]) + + expect(items().map(i => [i.id, i.state])).toEqual([ + ['a', 'running'], + ['b', 'done'], + ['c', 'failed'] + ]) + expect(items()[2]!.exitCode).toBe(1) + }) + + it('keeps row order stable when a process flips state or the snapshot reorders', () => { + reconcileBackgroundProcesses(SID, [running('a'), running('b')]) + // Snapshot arrives reordered AND `a` has exited — rows must not move. + reconcileBackgroundProcesses(SID, [running('b'), exited('a', 0)]) + + expect(items().map(i => [i.id, i.state])).toEqual([ + ['a', 'done'], + ['b', 'running'] + ]) + }) + + it('appends new processes after existing rows', () => { + reconcileBackgroundProcesses(SID, [running('a')]) + reconcileBackgroundProcesses(SID, [running('b'), running('a')]) + + expect(items().map(i => i.id)).toEqual(['a', 'b']) + }) + + it('preserves object identity for unchanged rows (memo stability)', () => { + reconcileBackgroundProcesses(SID, [running('a'), running('b')]) + const [a1] = items() + + reconcileBackgroundProcesses(SID, [running('a'), exited('b', 0)]) + const [a2, b2] = items() + + expect(a2).toBe(a1) + expect(b2!.state).toBe('done') + }) + + it('is a no-op store write when nothing changed', () => { + reconcileBackgroundProcesses(SID, [running('a')]) + const before = $backgroundStatusBySession.get() + + reconcileBackgroundProcesses(SID, [running('a')]) + + expect($backgroundStatusBySession.get()).toBe(before) + }) + + it('never resurrects a dismissed process while the registry still reports it', () => { + reconcileBackgroundProcesses(SID, [exited('a', 0), running('b')]) + dismissBackgroundProcess(SID, 'a') + + reconcileBackgroundProcesses(SID, [exited('a', 0), running('b')]) + + expect(items().map(i => i.id)).toEqual(['b']) + }) + + it('forgets a dismissal once the registry prunes the process', () => { + reconcileBackgroundProcesses(SID, [exited('a', 0)]) + dismissBackgroundProcess(SID, 'a') + + // Registry pruned it… + reconcileBackgroundProcesses(SID, []) + // …so a future process reusing the id (new spawn) shows again. + reconcileBackgroundProcesses(SID, [running('a')]) + + expect(items().map(i => i.id)).toEqual(['a']) + }) + + it('drops the session key entirely when the last row goes away', () => { + reconcileBackgroundProcesses(SID, [running('a')]) + reconcileBackgroundProcesses(SID, []) + + expect($backgroundStatusBySession.get()).toEqual({}) + }) +}) diff --git a/apps/desktop/src/store/composer-status.ts b/apps/desktop/src/store/composer-status.ts new file mode 100644 index 00000000000..9991ca57adc --- /dev/null +++ b/apps/desktop/src/store/composer-status.ts @@ -0,0 +1,257 @@ +import { atom, computed } from 'nanostores' + +import type { TodoItem, TodoStatus } from '@/lib/todos' + +import { $gateway } from './gateway' +import { $subagentsBySession, type SubagentProgress } from './subagents' +import { $todosBySession } from './todos' + +/** Composer status stack feed — merged todos, subagents, background per session. */ +export type StatusItemState = 'done' | 'failed' | 'running' +export type StatusItemType = 'background' | 'subagent' | 'todo' + +export interface ComposerStatusItem { + /** background: non-zero exit shown inline when failed. */ + exitCode?: number + /** subagent: active tool label shown on the right. */ + currentTool?: string + id: string + /** background process: captured stdout/stderr tail for the inline viewer. */ + output?: string + /** subagent: its own stored session id — row click opens that session window + * (livestreamed by the gateway's child-session mirror). */ + sessionId?: string + state: StatusItemState + title: string + /** todo: the full four-state status driving the row's checkmark glyph. */ + todoStatus?: TodoStatus + type: StatusItemType +} + +// Writable source for background work, synced from the gateway's process +// registry (`terminal(background=true)` spawns) via `process.list`. +export const $backgroundStatusBySession = atom>({}) + +// Rows the user X-ed away. The registry keeps finished processes around for a +// while, so without this every refresh would resurrect a dismissed row. +const dismissedBySession = new Map>() + +const subToItem = (s: SubagentProgress): ComposerStatusItem => ({ + currentTool: s.currentTool, + id: s.id, + sessionId: s.sessionId, + state: 'running', + title: s.goal, + type: 'subagent' +}) + +const todoToItem = (t: TodoItem): ComposerStatusItem => ({ + id: `todo:${t.id}`, + state: t.status === 'in_progress' ? 'running' : 'done', + title: t.content, + todoStatus: t.status, + type: 'todo' +}) + +// The single thing the stack reads: a typed, merged item list per session. +export const $statusItemsBySession = computed( + [$subagentsBySession, $backgroundStatusBySession, $todosBySession], + (subs, background, todos) => { + const out: Record = {} + + const push = (sid: string, items: ComposerStatusItem[]) => { + if (items.length > 0) { + out[sid] = out[sid] ? [...out[sid], ...items] : items + } + } + + for (const [sid, list] of Object.entries(todos)) { + push(sid, list.map(todoToItem)) + } + + for (const [sid, list] of Object.entries(subs)) { + push(sid, list.filter(s => s.status === 'running' || s.status === 'queued').map(subToItem)) + } + + for (const [sid, list] of Object.entries(background)) { + push(sid, list) + } + + return out + } +) + +// Fixed render order for the groups in the stack (top → bottom, above queue). +const TYPE_ORDER: readonly StatusItemType[] = ['todo', 'subagent', 'background'] + +export interface StatusGroup { + items: ComposerStatusItem[] + type: StatusItemType +} + +export function groupStatusItems(items: readonly ComposerStatusItem[]): StatusGroup[] { + const byType = new Map() + + for (const item of items) { + const list = byType.get(item.type) + + if (list) { + list.push(item) + } else { + byType.set(item.type, [item]) + } + } + + return TYPE_ORDER.filter(type => byType.has(type)).map(type => ({ items: byType.get(type)!, type })) +} + +const writeBackground = (sid: string, items: ComposerStatusItem[]) => { + const current = $backgroundStatusBySession.get() + const next = { ...current } + + if (items.length > 0) { + next[sid] = items + } else { + delete next[sid] + } + + $backgroundStatusBySession.set(next) +} + +// `tui_gateway` process.list entry (tools/process_registry.list_sessions + output_tail). +interface GatewayProcessEntry { + command?: string + exit_code?: number + output_tail?: string + session_id?: string + status?: string +} + +const toBackgroundItem = (proc: GatewayProcessEntry): ComposerStatusItem => { + const exited = proc.status === 'exited' + const exitCode = typeof proc.exit_code === 'number' ? proc.exit_code : undefined + + return { + exitCode, + id: proc.session_id ?? '', + output: proc.output_tail || undefined, + state: exited ? (exitCode ? 'failed' : 'done') : 'running', + title: (proc.command ?? '').split('\n')[0]!.trim() || 'background process', + type: 'background' + } +} + +const sameItem = (a: ComposerStatusItem, b: ComposerStatusItem) => + a.state === b.state && a.title === b.title && a.output === b.output && a.exitCode === b.exitCode + +/** + * Layout-stable sync of the registry snapshot into the store: existing rows + * keep their position (status flips happen in place, never reorder), new + * processes append, dismissed ids stay gone, and unchanged rows keep their + * object identity so memoised rows skip re-rendering. + */ +export function reconcileBackgroundProcesses(sid: string, procs: GatewayProcessEntry[]) { + const dismissed = dismissedBySession.get(sid) + + const fresh = new Map( + procs + .filter(proc => proc.session_id && !dismissed?.has(proc.session_id)) + .map(proc => [proc.session_id!, toBackgroundItem(proc)]) + ) + + const prev = $backgroundStatusBySession.get()[sid] ?? [] + + const kept = prev.flatMap(old => { + const next = fresh.get(old.id) + fresh.delete(old.id) + + return next ? [sameItem(old, next) ? old : next] : [] + }) + + const next = [...kept, ...fresh.values()] + + // Dismissals only need remembering while the registry still reports the id. + if (dismissed) { + const reported = new Set(procs.map(proc => proc.session_id)) + + for (const id of dismissed) { + if (!reported.has(id)) { + dismissed.delete(id) + } + } + } + + if (next.length === prev.length && next.every((item, i) => item === prev[i])) { + return + } + + writeBackground(sid, next) +} + +/** Pull the session's live process snapshot from the gateway. */ +export async function refreshBackgroundProcesses(sid: string): Promise { + const gateway = $gateway.get() + + if (!sid || !gateway) { + return + } + + try { + const result = await gateway.request<{ processes?: GatewayProcessEntry[] }>('process.list', { session_id: sid }) + + reconcileBackgroundProcesses(sid, result?.processes ?? []) + } catch { + // Transient socket loss — the next trigger (event or poll) retries. + } +} + +/** X on a finished row: drop it now and keep it dropped across refreshes. */ +export function dismissBackgroundProcess(sid: string, id: string) { + const dismissed = dismissedBySession.get(sid) ?? new Set() + dismissed.add(id) + dismissedBySession.set(sid, dismissed) + + const list = $backgroundStatusBySession.get()[sid] ?? [] + + writeBackground( + sid, + list.filter(item => item.id !== id) + ) +} + +/** X on a running row: kill the process for real, then drop the row. */ +export function stopBackgroundProcess(sid: string, id: string) { + void $gateway + .get() + ?.request('process.kill', { process_id: id, session_id: sid }) + .catch(() => undefined) + dismissBackgroundProcess(sid, id) +} + +/** + * Rewind cleanup: a restore/edit discards the turns that spawned these + * processes, so they belong to an abandoned timeline. Kill the live ones and + * drop every row. Ids are marked dismissed so an in-flight `process.list` poll + * (kill is async) can't resurrect them; reconcile garbage-collects those once + * the registry stops reporting them. + */ +export function resetSessionBackground(sid: string) { + if (!sid) { + return + } + + const gateway = $gateway.get() + const list = $backgroundStatusBySession.get()[sid] ?? [] + const dismissed = dismissedBySession.get(sid) ?? new Set() + + for (const item of list) { + dismissed.add(item.id) + + if (item.state === 'running') { + void gateway?.request('process.kill', { process_id: item.id, session_id: sid }).catch(() => undefined) + } + } + + dismissedBySession.set(sid, dismissed) + writeBackground(sid, []) +} diff --git a/apps/desktop/src/store/subagents.ts b/apps/desktop/src/store/subagents.ts index bc94794c0e0..2b406e3f539 100644 --- a/apps/desktop/src/store/subagents.ts +++ b/apps/desktop/src/store/subagents.ts @@ -14,6 +14,8 @@ export interface SubagentProgress { id: string parentId: null | string goal: string + /** The child's own stored session id — lets UIs open its session window. */ + sessionId?: string model?: string status: SubagentStatus taskCount: number @@ -159,6 +161,7 @@ function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined id: prev?.id ?? idOf(payload), parentId: str(payload.parent_id) || prev?.parentId || null, goal: str(payload.goal) || prev?.goal || 'Subagent', + sessionId: str(payload.child_session_id) || prev?.sessionId, model: str(payload.model) || prev?.model, status, taskCount: num(payload.task_count) ?? prev?.taskCount ?? 1, diff --git a/apps/desktop/src/store/todos.test.ts b/apps/desktop/src/store/todos.test.ts new file mode 100644 index 00000000000..544706df9f4 --- /dev/null +++ b/apps/desktop/src/store/todos.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { TodoItem } from '@/lib/todos' + +import { $todosBySession, clearSessionTodos, setSessionTodos } from './todos' + +const todo = (id: string, status: TodoItem['status']): TodoItem => ({ content: `task ${id}`, id, status }) + +describe('setSessionTodos finished-list auto-clear', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + clearSessionTodos('s1') + vi.useRealTimers() + }) + + it('keeps an in-flight list indefinitely', () => { + setSessionTodos('s1', [todo('a', 'completed'), todo('b', 'in_progress')]) + + vi.advanceTimersByTime(60_000) + + expect($todosBySession.get().s1).toHaveLength(2) + }) + + it('drops the list shortly after every item completes', () => { + setSessionTodos('s1', [todo('a', 'completed'), todo('b', 'cancelled')]) + + expect($todosBySession.get().s1).toHaveLength(2) + + vi.advanceTimersByTime(5_000) + + expect($todosBySession.get().s1).toBeUndefined() + }) + + it('cancels the pending clear when a new active list arrives', () => { + setSessionTodos('s1', [todo('a', 'completed')]) + vi.advanceTimersByTime(2_000) + + // The next turn starts a fresh plan before the linger expires. + setSessionTodos('s1', [todo('a', 'completed'), todo('b', 'pending')]) + vi.advanceTimersByTime(60_000) + + expect($todosBySession.get().s1).toHaveLength(2) + }) +}) diff --git a/apps/desktop/src/store/todos.ts b/apps/desktop/src/store/todos.ts new file mode 100644 index 00000000000..20228bb9117 --- /dev/null +++ b/apps/desktop/src/store/todos.ts @@ -0,0 +1,64 @@ +import { atom } from 'nanostores' + +import type { TodoItem } from '@/lib/todos' + +/** + * Live todo list per runtime session, rendered by the composer status stack + * (the inline transcript panel is gone). Fed from two places: + * + * - live `todo` tool events (use-message-stream) + * - stored-session hydration (desktop-controller) — but only when the list is + * still in flight, so reopening an old chat doesn't pin its finished plan + * above the composer forever. + */ +export const $todosBySession = atom>({}) + +export const todoListActive = (todos: readonly TodoItem[]) => + todos.some(t => t.status === 'pending' || t.status === 'in_progress') + +// Once a list finishes (every item completed/cancelled), the final state +// lingers just long enough to see the last checkmark land, then the group +// drops out of the stack on its own. +const FINISHED_LINGER_MS = 4_000 +const clearTimers = new Map>() + +function cancelScheduledClear(sid: string) { + const timer = clearTimers.get(sid) + + if (timer !== undefined) { + clearTimeout(timer) + clearTimers.delete(sid) + } +} + +export function setSessionTodos(sid: string, todos: TodoItem[]) { + if (!sid) { + return + } + + cancelScheduledClear(sid) + $todosBySession.set({ ...$todosBySession.get(), [sid]: todos }) + + if (!todoListActive(todos)) { + clearTimers.set( + sid, + setTimeout(() => { + clearTimers.delete(sid) + clearSessionTodos(sid) + }, FINISHED_LINGER_MS) + ) + } +} + +export function clearSessionTodos(sid: string) { + cancelScheduledClear(sid) + + const map = $todosBySession.get() + + if (!(sid in map)) { + return + } + + const { [sid]: _drop, ...rest } = map + $todosBySession.set(rest) +} diff --git a/apps/desktop/src/store/windows.test.ts b/apps/desktop/src/store/windows.test.ts index 18487480fcd..50c42dbf3af 100644 --- a/apps/desktop/src/store/windows.test.ts +++ b/apps/desktop/src/store/windows.test.ts @@ -71,7 +71,17 @@ describe('openSessionInNewWindow', () => { await openSessionInNewWindow('s1') - expect(open).toHaveBeenCalledWith('s1') + expect(open).toHaveBeenCalledWith('s1', undefined) + expect(notifyError).not.toHaveBeenCalled() + }) + + it('forwards the watch flag for spectator (subagent) windows', async () => { + const open = vi.fn().mockResolvedValue({ ok: true }) + installBridge(open) + + await openSessionInNewWindow('s1', { watch: true }) + + expect(open).toHaveBeenCalledWith('s1', { watch: true }) expect(notifyError).not.toHaveBeenCalled() }) diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index 57a47bf0bca..461c6343823 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -27,6 +27,30 @@ export function isSecondaryWindow(): boolean { return result } +let watchWindowCache: boolean | null = null + +// A "watch" window spectates a session that is being driven elsewhere (a +// running subagent). It resumes lazily — the gateway registers history + a +// transport for the live mirror without building an agent, so opening it is +// cheap even while the backend is busy running the delegation. +export function isWatchWindow(): boolean { + if (watchWindowCache !== null) { + return watchWindowCache + } + + let result = false + + try { + result = new URLSearchParams(window.location.search).get('watch') === '1' + } catch { + result = false + } + + watchWindowCache = result + + return result +} + // True when running inside the Electron desktop shell (the preload bridge is // present). The "open in new window" affordance is desktop-only. export function canOpenSessionWindow(): boolean { @@ -35,13 +59,14 @@ export function canOpenSessionWindow(): boolean { // Open (or focus) a standalone OS window for a single chat session. No-ops // gracefully outside Electron so callers can wire it unconditionally. -export async function openSessionInNewWindow(sessionId: string): Promise { +// `watch: true` opens a spectator window (lazy resume, live-mirror stream). +export async function openSessionInNewWindow(sessionId: string, opts?: { watch?: boolean }): Promise { if (!sessionId || !canOpenSessionWindow()) { return } try { - const result = await window.hermesDesktop.openSessionWindow(sessionId) + const result = await window.hermesDesktop.openSessionWindow(sessionId, opts) if (!result?.ok) { notifyError(new Error(result?.error || 'unknown error'), 'Could not open chat in a new window') diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index a73631d3609..42b781eb3cf 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -297,6 +297,8 @@ --dt-font-sans: 'Segoe WPC', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji; + /* Key caps always use the native UI face — never theme typography overrides. */ + --dt-font-kbd: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif; --dt-font-mono: 'Cascadia Code', 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, Consolas, monospace, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji; @@ -308,8 +310,10 @@ --radius: 0.75rem; --radius-scalar: 0.6; - /* Space under last message vs overlay composer — driven by the measured composer height (see composer/index.tsx). */ - --thread-last-message-clearance: calc(var(--composer-measured-height) + 2rem); + /* Space under last message vs overlay composer — driven by the measured composer height (see composer/index.tsx) + plus the out-of-flow status stack's measured height (see status-stack/index.tsx) when one is showing. */ + --status-stack-measured-height: 0px; + --thread-last-message-clearance: calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 2rem); --composer-shell-pad-block-end: 0.625rem; --message-text-indent: 0.75rem; @@ -890,14 +894,13 @@ canvas { /* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long prompt doesn't dominate the viewport. The clamp lifts on focus only (clicking opens the edit composer, which shows the full text) — not on hover, so the - bubble doesn't jump as the pointer passes over it. --human-msg-full is the - measured content height (set in UserMessage) so it animates to the real - height instead of overshooting the cap. */ + bubble doesn't jump as the pointer passes over it. No transition: the lift + happens in the same click that swaps in the edit composer, so animating it + just flashes a half-expanded bubble on the way in. */ .sticky-human-clamp { cursor: pointer; max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem); overflow: hidden; - transition: max-height 0.08s cubic-bezier(0.4, 0, 0.2, 1); } .sticky-human-clamp[data-clamped='true'] { @@ -1024,8 +1027,32 @@ canvas { color: var(--ui-text-tertiary) !important; } -[data-slot='composer-root']:focus-within [data-slot='composer-surface'] > [aria-hidden='true'] { - background: var(--ui-chat-bubble-background) !important; +/* ── Composer fill — ONE var painted by the surface AND anything docked to it + (slash·@ popover, `?` help). State ladder sets the var; consumers just paint + `background: var(--composer-fill)`, so every state matches by construction. + The :has() rule is last on purpose: while a completion drawer is open it + beats focus/scroll and forces an OPAQUE fill (both mix endpoints solid) — + translucent glass can never match across the two layers because they sample + different backdrops. */ +:root { + /* Fallback for drawers outside the main composer (e.g. edit-message). */ + --composer-fill: color-mix(in srgb, var(--dt-card) 90%, var(--dt-background)); +} + +[data-slot='composer-root'] { + --composer-fill: color-mix(in srgb, var(--dt-card) 72%, transparent); +} + +[data-slot='composer-root'][data-thread-scrolled-up] { + --composer-fill: color-mix(in srgb, var(--dt-card) 48%, transparent); +} + +[data-slot='composer-root']:has([data-slot='composer-surface']:focus-within) { + --composer-fill: var(--ui-chat-bubble-background); +} + +[data-slot='composer-root']:has([data-slot='composer-completion-drawer']) { + --composer-fill: color-mix(in srgb, var(--dt-card) 90%, var(--dt-background)); } /* Tool/thinking blocks now live at message-text alignment (no leading @@ -1250,41 +1277,3 @@ canvas { } } -/* ── Keybind panel / edit overlay: small key chips ────────────────────────── - A quiet `kbd`-style chip shared by the shortcuts panel and the on-screen - editor so both read as the same control. No animation, no glow. */ -.kbd-cap { - display: inline-grid; - place-items: center; - min-width: 1.5rem; - height: 1.4rem; - padding: 0 0.4rem; - border-radius: 0.375rem; - font-family: var(--dt-font-mono, ui-monospace, monospace); - font-size: 0.72rem; - font-weight: 500; - line-height: 1; - color: color-mix(in srgb, var(--dt-foreground) 82%, transparent); - background: color-mix(in srgb, var(--ui-bg-elevated) 70%, transparent); - border: 1px solid var(--ui-stroke-secondary); - box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--ui-stroke-tertiary) 50%, transparent); -} - -/* Unbound slot: a hollow dashed chip inviting a binding. */ -.kbd-cap--ghost { - color: color-mix(in srgb, var(--dt-foreground) 42%, transparent); - background: none; - border-style: dashed; - border-color: var(--ui-stroke-tertiary); - box-shadow: none; - font-style: italic; -} - -/* Waiting for a keypress: solid accent, no motion. */ -.kbd-capturing { - color: var(--theme-primary); - border-color: color-mix(in srgb, var(--theme-primary) 55%, var(--ui-stroke-secondary)) !important; - border-style: solid; - background: color-mix(in srgb, var(--theme-primary) 9%, var(--ui-bg-elevated)); - box-shadow: none; -} diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index f7bc07c3b7e..8dec1c9e0a8 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -227,6 +227,17 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') { foreground: c.foreground }) + // Raw (non-JSON) keys read by the inline pre-paint script in index.html — + // they let a brand-new window paint the themed background on its very first + // frame, before this module has even loaded. + try { + window.localStorage.setItem('hermes-boot-background', c.background) + window.localStorage.setItem('hermes-boot-color-scheme', rendered) + } catch { + // Storage may be unavailable (private mode / quota); the inline script + // falls back to prefers-color-scheme. + } + if (typo.fontUrl && !INJECTED_FONT_URLS.has(typo.fontUrl)) { const link = document.createElement('link') link.rel = 'stylesheet' @@ -237,13 +248,23 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') { } } +// Pin Electron's nativeTheme to the app's mode so the NATIVE window chrome +// (macOS vibrancy material, titlebar, pre-paint background) matches the app +// theme instead of the OS appearance. An explicit light/dark pick is forced; +// 'system' stays 'system' so prefers-color-scheme keeps tracking the OS. +const syncNativeTheme = (pref: ThemeMode, rendered: 'light' | 'dark') => + window.hermesDesktop?.setNativeTheme?.(pref === 'system' ? 'system' : rendered) + // Boot-time paint to avoid a flash before mounts. Use the last // active profile's appearance so a non-default profile relaunch paints its own // skin + light/dark mode. if (typeof window !== 'undefined') { const profile = readBootProfileKey() - const resolved = resolveMode(modePref.resolve(profile)) - applyTheme(deriveTheme(skinPref.resolve(profile), resolved), resolved) + const pref = modePref.resolve(profile) + const resolved = resolveMode(pref) + const theme = deriveTheme(skinPref.resolve(profile), resolved) + applyTheme(theme, resolved) + syncNativeTheme(pref, renderedModeFor(theme.colors, resolved)) } // ─── Context ──────────────────────────────────────────────────────────────── @@ -320,13 +341,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) { const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode]) // What actually gets painted (matches the `.dark` class applyTheme toggles). - const renderedMode = useMemo( - () => renderedModeFor(activeTheme.colors, resolvedMode), - [activeTheme, resolvedMode] - ) + const renderedMode = useMemo(() => renderedModeFor(activeTheme.colors, resolvedMode), [activeTheme, resolvedMode]) useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode]) + // Keep the native window appearance pinned to the app theme (vibrancy + // material, titlebar, new-window pre-paint background). + useEffect(() => syncNativeTheme(mode, renderedMode), [mode, renderedMode]) + // Assign to whichever profile is live right now (read fresh so the callbacks // stay stable across profile switches). const liveProfile = () => normalizeProfileKey($activeGatewayProfile.get()) diff --git a/hermes_state.py b/hermes_state.py index 0f97ebdf098..7ca3db06a79 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -29,11 +29,85 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar logger = logging.getLogger(__name__) +def _delegate_from_json(col: str = "model_config") -> str: + return f"json_extract(COALESCE({col}, '{{}}'), '$._delegate_from')" + + +# A child session counts as a /branch (kept visible, never cascade-deleted) if +# it carries the stable marker OR the legacy end_reason heuristic holds. +_BRANCH_CHILD_SQL = ( + "json_extract(COALESCE({a}.model_config, '{{}}'), '$._branched_from') IS NOT NULL" + " OR EXISTS (SELECT 1 FROM sessions p" + " WHERE p.id = {a}.parent_session_id" + " AND p.end_reason = 'branched'" + " AND {a}.started_at >= p.ended_at)" +) + +_COMPRESSION_CHILD_SQL = ( + "EXISTS (SELECT 1 FROM sessions p" + " WHERE p.id = {a}.parent_session_id" + " AND p.end_reason = 'compression'" + " AND {a}.started_at >= p.ended_at)" +) + +# Rows that surface in pickers: roots + branch children (subagent runs and +# compression continuations stay hidden). +_LISTABLE_CHILD_SQL = f"(s.parent_session_id IS NULL OR {_BRANCH_CHILD_SQL.format(a='s')})" + + +def _ephemeral_child_sql(alias: str = "s") -> str: + """Subagent runs (cascade-delete targets), not branches or compression tips.""" + branch = _BRANCH_CHILD_SQL.format(a=alias) + compression = _COMPRESSION_CHILD_SQL.format(a=alias) + return ( + f"({alias}.parent_session_id IS NOT NULL" + f" AND NOT ({branch})" + f" AND NOT ({compression}))" + ) + + +def _collect_delegate_child_ids(conn, parent_ids: List[str]) -> List[str]: + """Delegate-subagent ids to cascade-delete with *parent_ids*. + + Only rows carrying the ``_delegate_from`` marker (set at creation, and + backfilled by the v16 migration) — generic untagged children keep the + orphan-don't-delete contract. Walks marker chains recursively so an + orchestrator subagent's own delegate children go too (FK safety). + """ + df = _delegate_from_json() + found: set[str] = set() + frontier = [sid for sid in parent_ids if sid] + while frontier: + ph = ",".join("?" * len(frontier)) + cursor = conn.execute( + f"SELECT id FROM sessions WHERE {df} IN ({ph}) " + f"OR (parent_session_id IN ({ph}) AND {df} IS NOT NULL)", + frontier + frontier, + ) + frontier = [row["id"] for row in cursor.fetchall() if row["id"] not in found] + found.update(frontier) + return list(found) + + +def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]: + ids = _collect_delegate_child_ids(conn, parent_ids) + if ids: + ph = ",".join("?" * len(ids)) + conn.execute(f"DELETE FROM messages WHERE session_id IN ({ph})", ids) + # FK safety: orphan any untagged stragglers pointing at a doomed row. + conn.execute( + f"UPDATE sessions SET parent_session_id = NULL " + f"WHERE parent_session_id IN ({ph})", + ids, + ) + conn.execute(f"DELETE FROM sessions WHERE id IN ({ph})", ids) + return ids + T = TypeVar("T") DEFAULT_DB_PATH = get_hermes_home() / "state.db" -SCHEMA_VERSION = 15 +SCHEMA_VERSION = 16 # --------------------------------------------------------------------------- # WAL-compatibility fallback @@ -1134,6 +1208,32 @@ class SessionDB: ) except sqlite3.OperationalError: pass + if current_version < 16: + # v16: tag delegate subagent rows so pickers stay clean after + # parent deletes that used to orphan them (parent_session_id → NULL). + try: + cursor.execute( + "UPDATE sessions SET model_config = json_set(" + "COALESCE(model_config, '{}'), '$._delegate_from', parent_session_id) " + f"WHERE parent_session_id IS NOT NULL " + "AND json_extract(COALESCE(model_config, '{}'), '$._delegate_from') IS NULL " + f"AND {_ephemeral_child_sql('sessions')}" + ) + cursor.execute( + "UPDATE sessions SET model_config = json_set(" + "COALESCE(model_config, '{}'), '$._delegate_from', '__orphaned__') " + "WHERE parent_session_id IS NULL " + "AND json_extract(COALESCE(model_config, '{}'), '$._delegate_from') IS NULL " + "AND json_extract(COALESCE(model_config, '{}'), '$._branched_from') IS NULL " + "AND title IS NULL " + "AND message_count <= 25 " + "AND EXISTS (SELECT 1 FROM messages m " + " WHERE m.session_id = sessions.id AND m.role = 'tool') " + "AND NOT EXISTS (SELECT 1 FROM sessions ch " + " WHERE ch.parent_session_id = sessions.id)" + ) + except sqlite3.OperationalError: + pass if current_version < SCHEMA_VERSION and fts_migrations_complete: cursor.execute( "UPDATE schema_version SET version = ?", @@ -1931,14 +2031,8 @@ class SessionDB: # 2. The legacy heuristic (parent ended with 'branched' before the # child started), covering branch sessions created before the # marker existed. - where_clauses.append( - "(s.parent_session_id IS NULL" - " OR json_extract(s.model_config, '$._branched_from') IS NOT NULL" - " OR EXISTS (SELECT 1 FROM sessions p" - " WHERE p.id = s.parent_session_id" - " AND p.end_reason = 'branched'" - " AND s.started_at >= p.ended_at))" - ) + where_clauses.append(_LISTABLE_CHILD_SQL) + where_clauses.append(f"{_delegate_from_json('s.model_config')} IS NULL") if source: where_clauses.append("s.source = ?") @@ -3558,13 +3652,8 @@ class SessionDB: # Mirror list_sessions_rich's child-exclusion clause exactly so the # count lines up with the rows: roots (no parent) plus branch # children (parent ended with end_reason='branched'). - where_clauses.append( - "(s.parent_session_id IS NULL" - " OR EXISTS (SELECT 1 FROM sessions p" - " WHERE p.id = s.parent_session_id" - " AND p.end_reason = 'branched'" - " AND s.started_at >= p.ended_at))" - ) + where_clauses.append(_LISTABLE_CHILD_SQL) + where_clauses.append(f"{_delegate_from_json('s.model_config')} IS NULL") if source: where_clauses.append("s.source = ?") params.append(source) @@ -3667,19 +3756,24 @@ class SessionDB: ) -> bool: """Delete a session and all its messages. - Child sessions are orphaned (parent_session_id set to NULL) rather - than cascade-deleted, so they remain accessible independently. + Delegate subagent children (``model_config._delegate_from``) are + cascade-deleted with the parent so they never resurface in session + pickers as orphaned rows. Branch / compression children are orphaned + (``parent_session_id → NULL``) so they remain accessible independently. When *sessions_dir* is provided, also removes on-disk transcript - files (``.json`` / ``.jsonl`` / ``request_dump_*``) for the deleted + files (``.json`` / ``.jsonl`` / ``request_dump_*``) for every deleted session. Returns True if the session was found and deleted. """ + removed_delegate_ids: List[str] = [] + def _do(conn): cursor = conn.execute( "SELECT COUNT(*) FROM sessions WHERE id = ?", (session_id,) ) if cursor.fetchone()[0] == 0: return False - # Orphan child sessions so FK constraint is satisfied + removed_delegate_ids.extend(_delete_delegate_children(conn, [session_id])) + # Orphan remaining child sessions (branches, etc.) so FK is satisfied. conn.execute( "UPDATE sessions SET parent_session_id = NULL " "WHERE parent_session_id = ?", @@ -3691,8 +3785,10 @@ class SessionDB: deleted = self._execute_write(_do) if deleted: + for delegate_id in removed_delegate_ids: + self._remove_session_files(sessions_dir, delegate_id) self._remove_session_files(sessions_dir, session_id) - return deleted + return bool(deleted) def delete_session_if_empty( self, @@ -3750,10 +3846,9 @@ class SessionDB: * Unknown IDs are silently skipped (no 404) — selection state in the UI can race against another tab's delete, and we'd rather succeed-on-the-rest than fail-the-whole-batch. - * Children of every deleted ID are orphaned - (``parent_session_id → NULL``), never cascade-deleted, so a - branch / subagent transcript survives an inadvertent parent - delete. + * Delegate subagent children (``model_config._delegate_from``) are + cascade-deleted with their parent; branch children are orphaned + (``parent_session_id → NULL``) so they stay accessible. * Messages and the session row both go in one ``_execute_write`` call so a partial failure can't leave the DB in a "messages gone but session row still there" state. @@ -3776,6 +3871,7 @@ class SessionDB: return 0 removed_ids: list[str] = [] + removed_delegate_ids: list[str] = [] def _do(conn): placeholders = ",".join("?" * len(unique_ids)) @@ -3790,7 +3886,8 @@ class SessionDB: return 0 existing_placeholders = ",".join("?" * len(existing)) - # Orphan children whose parent is in the kill list so the + removed_delegate_ids.extend(_delete_delegate_children(conn, existing)) + # Orphan remaining children whose parent is in the kill list so the # FK constraint stays satisfied. Pin children whose parent # is itself in the kill list rather than NULL-ing parents # of survivors — the IN list on ``parent_session_id`` does @@ -3812,6 +3909,8 @@ class SessionDB: return len(existing) count = self._execute_write(_do) + for sid in removed_delegate_ids: + self._remove_session_files(sessions_dir, sid) for sid in removed_ids: self._remove_session_files(sessions_dir, sid) return count diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 04334317705..a1932b650fc 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -2741,6 +2741,82 @@ class TestListSessionsRich: ids = [s["id"] for s in sessions] assert "branch" in ids, "Branch session should be visible in default list" + def test_delegate_subagent_marker_hides_orphaned_row(self, db): + """``_delegate_from`` keeps delegate rows out of pickers after orphaning.""" + db.create_session("parent", "cli") + db.create_session( + "delegate", + "cli", + parent_session_id="parent", + model_config={"_delegate_from": "parent"}, + ) + db.append_message("delegate", "user", "scan the repo") + + assert "delegate" not in [s["id"] for s in db.list_sessions_rich()] + + db._conn.execute( + "UPDATE sessions SET parent_session_id = NULL WHERE id = ?", ("delegate",) + ) + db._conn.commit() + + assert "delegate" not in [s["id"] for s in db.list_sessions_rich()] + + def test_delete_parent_cascades_delegate_children(self, db): + db.create_session("parent", "cli") + db.create_session( + "delegate", + "cli", + parent_session_id="parent", + model_config={"_delegate_from": "parent"}, + ) + db.create_session( + "branch", + "cli", + parent_session_id="parent", + model_config={"_branched_from": "parent"}, + ) + + assert db.delete_session("parent") is True + assert db.get_session("delegate") is None + assert db.get_session("branch") is not None + + def test_v16_migration_tags_linked_delegate_rows(self, tmp_path): + """Pre-marker linked subagent rows get tagged, then cascade with parent.""" + import json + + db_path = tmp_path / "state.db" + db = SessionDB(db_path=db_path) + db.create_session("parent", "cli") + db.create_session("delegate", "cli", parent_session_id="parent") + db._conn.execute("UPDATE schema_version SET version = 15") + db._conn.commit() + db.close() + + db = SessionDB(db_path=db_path) + row = db.get_session("delegate") + assert json.loads(row["model_config"])["_delegate_from"] == "parent" + assert db.delete_session("parent") is True + assert db.get_session("delegate") is None + db.close() + + def test_v16_migration_tags_orphaned_delegate_rows(self, tmp_path): + import json + + db_path = tmp_path / "state.db" + db = SessionDB(db_path=db_path) + db.create_session("orphan", "cli") + db.append_message("orphan", "user", "Echo progress") + db.append_message("orphan", "tool", "step 1", tool_name="terminal") + db._conn.execute("UPDATE schema_version SET version = 15") + db._conn.commit() + db.close() + + db = SessionDB(db_path=db_path) + assert "orphan" not in [s["id"] for s in db.list_sessions_rich()] + row = db.get_session("orphan") + assert json.loads(row["model_config"])["_delegate_from"] == "__orphaned__" + db.close() + def test_branch_session_visible_after_parent_reopen_and_reend(self, db): """Branch sessions stay visible after the parent is reopened and re-ended. diff --git a/tests/test_tui_gateway_ws.py b/tests/test_tui_gateway_ws.py index 3fd8b404cf6..39a9d61a9f6 100644 --- a/tests/test_tui_gateway_ws.py +++ b/tests/test_tui_gateway_ws.py @@ -1,4 +1,6 @@ import asyncio +import threading +import time from tui_gateway import server from tui_gateway import ws as ws_mod @@ -87,3 +89,40 @@ def test_ws_disconnect_preserves_and_repoints_reconnectable_session(monkeypatch) assert server._sessions["plain"]["transport"] is server._detached_ws_transport finally: server._sessions.clear() + + +def test_ws_write_loop_stall_does_not_latch_transport(monkeypatch): + """A write that times out because the event loop is stalled (GIL-heavy + agent turn) must NOT latch the transport closed — the frame is already + scheduled and flushes when the loop recovers. Latching here permanently + silenced live watch windows after one slow write.""" + monkeypatch.setattr(ws_mod, "_WS_WRITE_TIMEOUT_S", 0.05) + sent = [] + + class FakeWS: + async def send_text(self, line): + sent.append(line) + + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + try: + transport = ws_mod.WSTransport(FakeWS(), loop, peer="stall-test") + # Stall the loop well past the write timeout, then write from this + # (non-loop) thread: the wait times out but the send stays in flight. + loop.call_soon_threadsafe(time.sleep, 0.3) + assert transport.write({"a": 1}) is True + assert transport._closed is False + + # Once the loop breathes again, both the stalled frame and new writes + # must reach the socket. + assert transport.write({"b": 2}) is True + deadline = time.time() + 2 + while len(sent) < 2 and time.time() < deadline: + time.sleep(0.01) + assert len(sent) == 2 + assert transport._closed is False + finally: + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=2) + loop.close() diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index cb8458395a2..bc10ffdf82e 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -394,6 +394,121 @@ def test_session_resume_handles_multimodal_list_content(server, monkeypatch): ] +def test_session_resume_lazy_registers_watch_session_without_agent(server, monkeypatch): + """``lazy: true`` (subagent watch windows) must register the live session + — keyed for the child mirror, on this transport — WITHOUT building an + agent. The eager build is what made opening a subagent window contend + with the already-running parent turn.""" + + target = "20260612_000000_child99" + + class _DB: + def get_session(self, _sid): + return {"id": target} + + def get_session_by_title(self, _title): + return None + + def reopen_session(self, _sid): + return None + + def get_messages_as_conversation(self, _sid, include_ancestors=False): + return [ + {"role": "user", "content": "delegated goal"}, + ] + + def _boom(*_args, **_kwargs): + raise AssertionError("lazy resume must not build an agent") + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setattr(server, "_make_agent", _boom) + + resp = server.handle_request( + { + "id": "r1", + "method": "session.resume", + "params": {"session_id": target, "cols": 100, "lazy": True}, + } + ) + + assert "error" not in resp + result = resp["result"] + assert result["resumed"] == target + assert result["session_key"] == target + assert result["info"]["lazy"] is True + assert result["info"]["desktop_contract"] == server.DESKTOP_BACKEND_CONTRACT + assert result["messages"] == [{"role": "user", "text": "delegated goal"}] + + sid = result["session_id"] + session = server._sessions[sid] + assert session["agent"] is None + # The child mirror finds the watch window by stored key. + assert server._find_live_session_by_key(target) == (sid, session) + # A later prompt.submit upgrade must continue THIS stored conversation. + assert session["resume_session_id"] == target + # No build started: the idle reaper must still be able to evict it, and + # the live status must not report a never-ending "starting". + assert not session["agent_ready"].is_set() + assert server._session_live_status(sid, session) != "starting" + session["transport"] = server._detached_ws_transport + far_future = time.time() + 999999 + assert server._session_is_evictable(sid, session, far_future) + + # Resuming again (window refresh) reuses the same live session. + resp2 = server.handle_request( + { + "id": "r2", + "method": "session.resume", + "params": {"session_id": target, "cols": 100, "lazy": True}, + } + ) + assert "error" not in resp2 + assert resp2["result"]["session_id"] == sid + assert len(server._sessions) == 1 + + +def test_session_resume_lazy_reports_running_for_inflight_child(server, monkeypatch): + """A watch window attaching to a child mid-delegation must learn the run is + live from the resume response itself — the child can sit silent inside a + long tool call, so waiting for the next stream event leaves the window + looking dead.""" + + target = "20260612_000000_child42" + + class _DB: + def get_session(self, _sid): + return {"id": target} + + def get_session_by_title(self, _title): + return None + + def reopen_session(self, _sid): + return None + + def get_messages_as_conversation(self, _sid, include_ancestors=False): + return [{"role": "user", "content": "delegated goal"}] + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setattr( + server, "_make_agent", lambda *a, **k: (_ for _ in ()).throw(AssertionError("no build")) + ) + server._active_child_runs[target] = time.time() + try: + resp = server.handle_request( + { + "id": "r1", + "method": "session.resume", + "params": {"session_id": target, "cols": 100, "lazy": True}, + } + ) + finally: + server._active_child_runs.pop(target, None) + + assert "error" not in resp + assert resp["result"]["running"] is True + assert resp["result"]["status"] == "streaming" + + def test_session_resume_reuses_existing_live_session(server, monkeypatch): """Repeated resume must not allocate duplicate live agents.""" diff --git a/tests/tui_gateway/test_subagent_child_mirror.py b/tests/tui_gateway/test_subagent_child_mirror.py new file mode 100644 index 00000000000..bb828482bb7 --- /dev/null +++ b/tests/tui_gateway/test_subagent_child_mirror.py @@ -0,0 +1,215 @@ +"""Tests for the gateway's child-session live mirror. + +A delegated child runs synchronously inside the parent's turn; its activity +reaches the gateway only as relayed ``subagent.*`` events on the PARENT sid +(tagged with ``child_session_id``). When a UI resumes the child's own session +(desktop open-in-new-window), ``_mirror_subagent_to_child`` translates those +relayed events into native stream events on the CHILD's live sid so the window +shows a real midstream turn instead of sitting silent until persistence. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture() +def server(): + with patch.dict( + "sys.modules", + { + "hermes_constants": MagicMock( + get_hermes_home=MagicMock(return_value="/tmp/hermes_test_child_mirror") + ), + "hermes_cli.env_loader": MagicMock(), + "hermes_cli.banner": MagicMock(), + "hermes_state": MagicMock(), + }, + ): + import importlib + + mod = importlib.import_module("tui_gateway.server") + yield mod + mod._sessions.clear() + mod._pending.clear() + mod._answers.clear() + mod._child_mirrors.clear() + mod._active_child_runs.clear() + + +@pytest.fixture() +def emits(server, monkeypatch): + captured: list = [] + monkeypatch.setattr( + server, + "_emit", + lambda event, sid, payload=None: captured.append((event, sid, payload)), + ) + monkeypatch.setattr(server, "_tool_progress_enabled", lambda sid: True) + return captured + + +def _relay(server, event_type, **payload): + """Drive _on_tool_progress the way the delegate relay does.""" + server._on_tool_progress( + "parent-sid", + event_type, + payload.pop("tool_name", None), + payload.pop("preview", None), + None, + goal="research X", + task_count=1, + task_index=0, + **payload, + ) + + +def test_no_live_child_session_no_mirror(server, emits): + _relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1") + + # Only the parent-sid relay event — nothing mirrored, no state retained. + assert [(e, s) for e, s, _ in emits] == [("subagent.tool", "parent-sid")] + assert server._child_mirrors == {} + + +def test_live_child_session_gets_native_stream(server, emits): + # A window resumed the child session: live sid differs from the stored key. + server._sessions["live-1"] = {"session_key": "child-1", "agent": None} + + _relay(server, "subagent.tool", tool_name="terminal", preview="ls", child_session_id="child-1") + _relay(server, "subagent.thinking", preview="hmm", child_session_id="child-1") + _relay(server, "subagent.tool", tool_name="read_file", child_session_id="child-1") + _relay( + server, + "subagent.complete", + child_session_id="child-1", + status="completed", + summary="done deal", + ) + + child = [(e, p) for e, s, p in emits if s == "live-1"] + + # Synthetic turn: start → tool → reasoning → tool rotation → close + summary. + assert [e for e, _ in child] == [ + "message.start", + "tool.start", + "reasoning.delta", + "tool.complete", + "tool.start", + "tool.complete", + "message.complete", + ] + first_tool = child[1][1] + assert first_tool["name"] == "terminal" + assert first_tool["tool_id"].startswith("submirror:child-1:") + assert child[2][1] == {"text": "hmm"} + # The rotated-out tool closes with the same id it opened with. + assert child[3][1]["tool_id"] == first_tool["tool_id"] + assert child[6][1] == {"text": "done deal"} + + # Parent relay is untouched alongside the mirror. + assert [e for e, s, _ in emits if s == "parent-sid"] == [ + "subagent.tool", + "subagent.thinking", + "subagent.tool", + "subagent.complete", + ] + # Completion clears mirror state. + assert server._child_mirrors == {} + + +def test_window_closed_midrun_drops_state_then_fresh_turn_on_reopen(server, emits): + server._sessions["live-1"] = {"session_key": "child-1", "agent": None} + _relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1") + assert "child-1" in server._child_mirrors + + # Window closes → live session gone → state dropped on the next event. + server._sessions.clear() + _relay(server, "subagent.tool", tool_name="read_file", child_session_id="child-1") + assert server._child_mirrors == {} + + # Reopen under a new live sid → a fresh synthetic turn starts. + emits.clear() + server._sessions["live-2"] = {"session_key": "child-1", "agent": None} + _relay(server, "subagent.tool", tool_name="web_search", child_session_id="child-1") + assert [(e, s) for e, s, _ in emits if s == "live-2"] == [ + ("message.start", "live-2"), + ("tool.start", "live-2"), + ] + + +def test_upgraded_child_session_not_mirrored(server, emits): + """A watch window upgraded to a full session (agent built) owns a real + native stream — mirroring on top would interleave two turns on one sid.""" + server._sessions["live-1"] = {"session_key": "child-1", "agent": object()} + + _relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1") + + assert [(e, s) for e, s, _ in emits] == [("subagent.tool", "parent-sid")] + assert server._child_mirrors == {} + # Liveness registry still updates — it serves resume, not the mirror. + assert "child-1" in server._active_child_runs + + +def test_stale_child_run_not_reported_active(server, emits): + """A leaked registry entry (lost completion event) must age out instead of + pinning running=true on every future lazy resume of that child.""" + server._active_child_runs["child-1"] = 0.0 # epoch — ancient + + assert server._child_run_active("child-1") is False + + _relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1") + assert server._child_run_active("child-1") is True + + +def test_prompt_submit_rejected_while_child_run_active(server, emits): + """Typing into a watch window mid-run must not build a second agent racing + the in-flight child on the same stored session — busy error instead.""" + import threading + + server._sessions["live-1"] = { + "agent": None, + "history_lock": threading.Lock(), + "lazy": True, + "running": False, + "session_key": "child-1", + } + _relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1") + + result = server._methods["prompt.submit"]("rid-1", {"session_id": "live-1", "text": "hi"}) + assert result["error"]["code"] == 4009 + + # Run completes → the same submit upgrades into a real conversation + # (passes the guard; fails later only because this test stubs no agent). + _relay(server, "subagent.complete", child_session_id="child-1", status="completed", summary="ok") + assert server._child_run_active("child-1") is False + + +def test_active_child_runs_registry_tracks_liveness(server, emits): + """Every relayed event marks the child as in flight (even with no window + open), and completion clears it — lazy watch resumes read this registry to + report running=true while the child is silent inside a long tool call.""" + _relay(server, "subagent.start", preview="go", child_session_id="child-1") + assert "child-1" in server._active_child_runs + + _relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1") + assert "child-1" in server._active_child_runs + + _relay(server, "subagent.complete", child_session_id="child-1", status="completed", summary="ok") + assert "child-1" not in server._active_child_runs + + +def test_start_and_progress_mirror_as_immediate_text_activity(server, emits): + server._sessions["live-1"] = {"session_key": "child-1", "agent": None} + + _relay(server, "subagent.start", preview="starting child branch", child_session_id="child-1") + _relay(server, "subagent.progress", preview="step 1/3", child_session_id="child-1") + + child = [(e, p) for e, s, p in emits if s == "live-1"] + assert child == [ + ("message.start", None), + ("message.delta", {"text": "starting child branch\n"}), + ("message.delta", {"text": "step 1/3\n"}), + ] diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 6e195dfe59f..18dd176a130 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -725,6 +725,7 @@ def _build_child_progress_callback( depth: Optional[int] = None, model: Optional[str] = None, toolsets: Optional[List[str]] = None, + session_ref: Optional[Dict[str, Any]] = None, ) -> Optional[callable]: """Build a callback that relays child agent tool calls to the parent display. @@ -772,6 +773,11 @@ def _build_child_progress_callback( kw["model"] = model if toolsets is not None: kw["toolsets"] = list(toolsets) + # The child's own session id — filled into the shared ref once the + # child agent exists (the callback is built first), so every relayed + # event lets UIs open/inspect the subagent's session directly. + if session_ref and session_ref.get("session_id"): + kw["child_session_id"] = str(session_ref["session_id"]) kw["tool_count"] = _tool_count[0] return kw @@ -1021,6 +1027,7 @@ def _build_child_agent( # Build progress callback to relay tool calls to parent display. # Identity kwargs thread the subagent_id through every emitted event so the # TUI can reconstruct the spawn tree and route per-branch controls. + child_session_ref: Dict[str, Any] = {} child_progress_cb = _build_child_progress_callback( task_index, goal, @@ -1031,6 +1038,7 @@ def _build_child_agent( depth=tui_depth, model=effective_model_for_cb, toolsets=child_toolsets, + session_ref=child_session_ref, ) # Each subagent gets its own iteration budget capped at max_iterations @@ -1154,7 +1162,7 @@ def _build_child_agent( quiet_mode=True, ephemeral_system_prompt=child_prompt, log_prefix=f"[subagent-{task_index}]", - platform=parent_agent.platform, + platform="subagent", skip_context_files=True, skip_memory=True, clarify_callback=None, @@ -1170,6 +1178,9 @@ def _build_child_agent( iteration_budget=None, # fresh budget per subagent ) child._print_fn = getattr(parent_agent, "_print_fn", None) + # Now the child exists, its session id can ride on every relayed event + # (including the spawn_requested below — first emit happens after this). + child_session_ref["session_id"] = getattr(child, "session_id", "") or "" # Set delegation depth so children can't spawn grandchildren child._delegate_depth = child_depth # Stash the post-degrade role for introspection (leaf if the @@ -1181,6 +1192,13 @@ def _build_child_agent( child._parent_subagent_id = parent_subagent_id child._subagent_goal = goal child._parent_turn_id = getattr(parent_agent, "_current_turn_id", "") or "" + # Stable sidebar marker: delegate subagent sessions must stay out of + # session pickers even when a parent delete orphans them (parent_session_id + # → NULL). Mirrors /branch's ``_branched_from`` pattern — see + # ``list_sessions_rich`` child-exclusion clause. + parent_sid = getattr(parent_agent, "session_id", None) + if parent_sid and getattr(child, "_session_init_model_config", None) is not None: + child._session_init_model_config["_delegate_from"] = parent_sid # Share a credential pool with the child when possible so subagents can # rotate credentials on rate limits instead of getting pinned to one key. diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index 7bbb26a2ece..d96c9faec0f 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -34,9 +34,10 @@ import logging from typing import Any, Dict, List, Optional, Union # Sources that are excluded from session browsing/searching by default. -# Third-party integrations tag their sessions with HERMES_SESSION_SOURCE=tool -# so they don't clutter the user's session history. -_HIDDEN_SESSION_SOURCES = ("tool",) +# Third-party integrations tag their sessions with HERMES_SESSION_SOURCE=tool; +# delegate subagent runs are tagged "subagent" — neither belongs in the +# user's session history. +_HIDDEN_SESSION_SOURCES = ("subagent", "tool") def _format_timestamp(ts: Union[int, float, str, None]) -> str: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d3563034648..4eaa356cd2e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -605,7 +605,9 @@ def _session_is_evictable(sid: str, session: dict, now: float) -> bool: if session.get("running") or _session_pending_kind(sid): return False ready = session.get("agent_ready") - if ready is not None and not ready.is_set(): # still starting + # Lazy watch sessions (subagent spectator windows) never start a build, + # so their forever-unset agent_ready must not make them immortal. + if ready is not None and not ready.is_set() and not session.get("lazy"): return False if not _transport_is_dead(session.get("transport")): return False @@ -902,6 +904,9 @@ def _start_agent_build(sid: str, session: dict) -> None: if ready.is_set() or session.get("agent_build_started"): return session["agent_build_started"] = True + # An upgrading lazy session is now genuinely mid-construction — restore + # its "still starting" eviction exemption. + session.pop("lazy", None) key = session["session_key"] def _build() -> None: @@ -930,7 +935,13 @@ def _start_agent_build(sid: str, session: dict) -> None: except Exception: session_db = None try: - agent = _make_agent(sid, key, session_db=session_db) + # Lazy-resumed (watch) sessions carry the stored conversation + # id — pass it through so the upgrade continues that session + # instead of starting a fresh one under the same key. + kw = {"session_db": session_db} + if resume_sid := current.get("resume_session_id"): + kw["session_id"] = resume_sid + agent = _make_agent(sid, key, **kw) finally: _clear_session_context(tokens) @@ -2580,6 +2591,8 @@ def _on_tool_progress( payload["subagent_id"] = str(_kwargs["subagent_id"]) if _kwargs.get("parent_id"): payload["parent_id"] = str(_kwargs["parent_id"]) + if _kwargs.get("child_session_id"): + payload["child_session_id"] = str(_kwargs["child_session_id"]) if _kwargs.get("depth") is not None: payload["depth"] = int(_kwargs["depth"]) if _kwargs.get("model"): @@ -2626,6 +2639,91 @@ def _on_tool_progress( payload["tool_preview"] = str(preview) payload["text"] = str(preview) _emit(event_type, sid, payload) + _mirror_subagent_to_child(event_type, payload) + + +# ── Child-session live mirror ──────────────────────────────────────── +# A delegated child is not a live gateway session — it runs synchronously +# inside the parent's turn, and its activity reaches the gateway only as +# relayed ``subagent.*`` events on the PARENT sid. When a UI opens the child's +# own session (session.resume on ``child_session_id``, e.g. the desktop's +# open-in-new-window), that window would otherwise sit silent until the run +# persists. Translate the relayed events into the native stream events the +# window already renders — emitted on the CHILD sid, routed to its transport +# by write_json — so the window shows a real midstream turn. +_child_mirrors: dict[str, dict] = {} +_child_mirrors_lock = threading.Lock() +# Stored child session ids with a delegation run currently in flight (refreshed +# on every relayed subagent.* event, popped on subagent.complete). Lets a lazy +# watch resume report running=true so the window shows a busy indicator even +# while the child is silent inside a long tool call (no events for 25s+). +_active_child_runs: dict[str, float] = {} +# Staleness bound for the registry: entries refresh on every relayed event, so +# anything this quiet means the completion event was lost (callback raised, +# parent crashed) — don't let a leaked entry pin "running" forever. +_CHILD_RUN_STALE_S = 3600.0 + + +def _child_run_active(child_key: str) -> bool: + ts = _active_child_runs.get(child_key) + return ts is not None and (time.time() - ts) < _CHILD_RUN_STALE_S + + +def _mirror_subagent_to_child(event_type: str, payload: dict) -> None: + child_key = str(payload.get("child_session_id") or "") + if not child_key: + return + # Liveness registry first — it must be accurate even when no window is + # open, so a window opened mid-run can immediately know the child is busy. + if event_type == "subagent.complete": + _active_child_runs.pop(child_key, None) + else: + _active_child_runs[child_key] = time.time() + # Mirror only into a live watch session (keyed by session_key; its live sid + # differs from the stored id) that has NOT been upgraded to a full agent. + # No window / closed → nothing to mirror; an upgraded session owns a real + # native stream and mirroring on top would interleave two turns on one sid. + # Either way drop state so a reopened window starts a fresh synthetic turn. + live = _find_live_session_by_key(child_key) + if live is None or live[1].get("agent") is not None: + with _child_mirrors_lock: + _child_mirrors.pop(child_key, None) + return + csid = live[0] + with _child_mirrors_lock: + st = _child_mirrors.setdefault(child_key, {"seq": 0, "open_tool": None, "started": False}) + if not st["started"]: + st["started"] = True + _emit("message.start", csid) + if event_type == "subagent.thinking": + if text := str(payload.get("text") or ""): + _emit("reasoning.delta", csid, {"text": text}) + elif event_type in {"subagent.start", "subagent.progress"}: + # Mirror branch-level progress lines so a just-opened child window + # shows immediate activity instead of waiting for the next tool or + # completion event. This matches the TUI /agents "live branch log" + # feel that users expect. + if text := str(payload.get("text") or ""): + _emit("message.delta", csid, {"text": f"{text}\n"}) + elif event_type == "subagent.tool": + if st["open_tool"]: + _emit("tool.complete", csid, st["open_tool"]) + st["seq"] += 1 + tool = { + "name": str(payload.get("tool_name") or "tool"), + "tool_id": f"submirror:{child_key}:{st['seq']}", + "args": {}, + } + if preview := str(payload.get("tool_preview") or payload.get("text") or ""): + tool["preview"] = preview + st["open_tool"] = tool + _emit("tool.start", csid, tool) + elif event_type == "subagent.complete": + if st["open_tool"]: + _emit("tool.complete", csid, st["open_tool"]) + summary = str(payload.get("summary") or payload.get("text") or "") + _emit("message.complete", csid, {"text": summary}) + _child_mirrors.pop(child_key, None) def _agent_cbs(sid: str) -> dict: @@ -3811,20 +3909,124 @@ def _(rid, params: dict) -> dict: target = found["id"] else: return _err(rid, 4007, "session not found") + def _reuse_live_payload(sid: str, session: dict) -> dict: + payload = _live_session_payload( + sid, + session, + cols=cols, + touch=True, + transport=current_transport() or _stdio_transport, + ) + payload["resumed"] = target + # A lazy watch session never owns a run loop, so its payload's running + # flag is always False — overlay the child-run registry so a reconnecting + # watch window keeps its busy indicator while the child is still mid-run. + if session.get("agent") is None and _child_run_active(target): + payload["running"] = True + payload["status"] = "streaming" + return payload + # Fast path: if the session is already live, reuse it under the lock. with _session_resume_lock: live = _find_live_session_by_key(target) if live is not None: - sid, session = live - payload = _live_session_payload( - sid, - session, - cols=cols, - touch=True, - transport=current_transport() or _stdio_transport, - ) - payload["resumed"] = target - return _ok(rid, payload) + return _ok(rid, _reuse_live_payload(*live)) + + # Lazy/watch resume: register the live session WITHOUT building an agent. + # Used by the desktop's subagent windows — the child runs inside the + # parent's turn, so its window only needs the stored history plus a + # transport for the child-mirror's live events. Skipping _make_agent here + # is what keeps the window cheap while the backend is busy running the + # delegation. A later prompt.submit upgrades it via _start_agent_build + # (resume_session_id keeps the upgrade on the stored conversation). + if is_truthy_value(params.get("lazy", False)): + sid = uuid.uuid4().hex[:8] + lease, limit_message = _claim_active_session_slot(target, live_session_id=sid) + if limit_message is not None: + return _err(rid, 4090, limit_message) + try: + db.reopen_session(target) + # The child's OWN conversation only. Delegation children are + # parent-linked rows, so include_ancestors would prepend the + # parent's entire transcript — a watch window opened on a subagent + # must show the subagent's branch, not the parent's prompt. + history = db.get_messages_as_conversation(target) + except Exception as e: + if lease is not None: + lease.release() + return _err(rid, 5000, f"resume failed: {e}") + messages = _history_to_messages(history) + cwd = os.getenv("TERMINAL_CWD", os.getcwd()) + now = time.time() + # A delegated child mid-run emits no native session events of its own — + # report its liveness from the relay registry so the window paints a + # busy indicator instead of a dead idle transcript. + child_running = _child_run_active(target) + with _session_resume_lock: + live = _find_live_session_by_key(target) + if live is not None: + if lease is not None: + lease.release() + return _ok(rid, _reuse_live_payload(*live)) + with _sessions_lock: + _sessions[sid] = { + "agent": None, + "agent_error": None, + "agent_ready": threading.Event(), + "attached_images": [], + "close_on_disconnect": is_truthy_value( + params.get("close_on_disconnect", False) + ), + "active_session_lease": lease, + "cols": cols, + "created_at": now, + "display_history_prefix": [], + "edit_snapshots": {}, + "explicit_cwd": False, + "history": history, + "history_lock": threading.Lock(), + "history_version": 0, + "image_counter": 0, + "cwd": cwd, + "inflight_turn": None, + "last_active": now, + "lazy": True, + "pending_title": None, + "profile_home": str(profile_home) if profile_home is not None else None, + "resume_session_id": target, + "running": False, + "session_key": target, + "show_reasoning": _load_show_reasoning(), + "slash_worker": None, + "tool_progress_mode": _load_tool_progress_mode(), + "tool_started_at": {}, + "transport": current_transport() or _stdio_transport, + } + _register_session_cwd(_sessions[sid]) + return _ok( + rid, + { + "session_id": sid, + "resumed": target, + "message_count": len(messages), + "messages": messages, + "info": { + "cwd": cwd, + "branch": _git_branch_for_cwd(cwd), + "model": _resolve_model(), + "tools": {}, + "skills": {}, + "lazy": True, + "desktop_contract": DESKTOP_BACKEND_CONTRACT, + "profile_name": _current_profile_name(), + }, + "inflight": None, + "running": child_running, + "session_key": target, + "started_at": now, + "status": "streaming" if child_running else "idle", + }, + ) # Build the agent OUTSIDE the lock — _make_agent can block for seconds # (MCP discovery, prompt/skill build, AIAgent construction). Holding @@ -3969,7 +4171,9 @@ def _session_live_status(sid: str, session: dict) -> str: if _session_pending_kind(sid): return "waiting" ready = session.get("agent_ready") - if ready is not None and not ready.is_set(): + # Unset + build never started = a lazy watch session sitting idle, not a + # session stuck mid-construction. + if ready is not None and not ready.is_set() and session.get("agent_build_started"): return "starting" if session.get("running"): return "working" @@ -5080,6 +5284,13 @@ def _(rid, params: dict) -> dict: with session["history_lock"]: if session.get("running"): return _err(rid, 4009, "session busy") + # A watch session's run lives in the PARENT turn, so its own running + # flag is False — without this, typing mid-run builds a second agent + # racing the in-flight child on the same stored session (interleaved + # transcript, stale fork). After the run completes, submitting is fine: + # the upgrade resumes the child's transcript as a normal conversation. + if session.get("lazy") and _child_run_active(str(session.get("session_key") or "")): + return _err(rid, 4009, "subagent still running — wait for it to finish") if truncate_user_ordinal is not None: try: ordinal = int(truncate_user_ordinal) @@ -7271,6 +7482,58 @@ def _(rid, params: dict) -> dict: return _err(rid, 5010, str(e)) +def _session_processes(session: dict) -> list: + """Background processes owned by this session (registry session_key match).""" + from tools.process_registry import process_registry + + key = str(session.get("session_key") or "") + owned = [] + for entry in process_registry.list_sessions(): + proc = process_registry.get(entry["session_id"]) + if proc is None or str(getattr(proc, "session_key", "") or "") != key: + continue + # The 200-char list preview is too thin for the desktop's inline + # terminal viewer — ship a real tail alongside it. + entry["output_tail"] = (proc.output_buffer or "")[-4000:] + owned.append(entry) + return owned + + +@method("process.list") +def _(rid, params: dict) -> dict: + """Session-scoped view of the background process registry (desktop status stack).""" + session, err = _sess(params, rid) + if err: + return err + try: + return _ok(rid, {"processes": _session_processes(session)}) + except Exception as e: + return _err(rid, 5010, str(e)) + + +@method("process.kill") +def _(rid, params: dict) -> dict: + """Kill ONE background process — scoped to the caller's session so one + window can't reap another session's work (unlike process.stop's kill_all).""" + session, err = _sess(params, rid) + if err: + return err + proc_id = str(params.get("process_id") or "") + if not proc_id: + return _err(rid, 4012, "process_id required") + try: + from tools.process_registry import process_registry + + proc = process_registry.get(proc_id) + if proc is None or str(getattr(proc, "session_key", "") or "") != str( + session.get("session_key") or "" + ): + return _err(rid, 4044, f"no such process: {proc_id}") + return _ok(rid, process_registry.kill_process(proc_id)) + except Exception as e: + return _err(rid, 5010, str(e)) + + @method("reload.mcp") def _(rid, params: dict) -> dict: session = _sessions.get(params.get("session_id", "")) diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py index 738ed9b1b80..b487e934842 100644 --- a/tui_gateway/ws.py +++ b/tui_gateway/ws.py @@ -24,6 +24,7 @@ Mounting from __future__ import annotations import asyncio +import concurrent.futures import json import logging import socket @@ -99,6 +100,19 @@ class WSTransport: return False fut.result(timeout=_WS_WRITE_TIMEOUT_S) return not self._closed + except concurrent.futures.TimeoutError: # builtin TimeoutError on 3.11+ + # The event loop is stalled (GIL-heavy agent turn, delegation + # running N children), NOT the socket dead. The send coroutine is + # already scheduled and will flush once the loop breathes — latching + # _closed here permanently silenced live windows after one slow + # write (the "subagent window shows zero streaming" bug). Unblock + # the worker thread and keep the transport alive; _safe_send latches + # on a real socket error when the frame actually fails. + _log.warning( + "ws write slow (loop stalled >%ss) peer=%s — frame left in flight", + _WS_WRITE_TIMEOUT_S, self._peer, + ) + return not self._closed except Exception as exc: self._closed = True _log.warning(