mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
feat(desktop): composer status stack, live subagent windows, editable prompts (#44630)
* 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.
This commit is contained in:
parent
9c50521704
commit
d62979a6f3
84 changed files with 3749 additions and 917 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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\<u>\.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\<u>\.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())
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,28 @@
|
|||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="shortcut icon" href="/apple-touch-icon.png" />
|
||||
<title>Hermes</title>
|
||||
<script>
|
||||
// Pre-paint the themed background before the app bundle loads. Without
|
||||
// this, the first frame (which is what `ready-to-show` waits for) is the
|
||||
// UA-default white page, and the real theme only lands once the whole
|
||||
// module graph has executed — i.e. the "white flash" on every new
|
||||
// window. applyTheme() in src/themes/context.tsx keeps these keys fresh.
|
||||
try {
|
||||
let bg = localStorage.getItem('hermes-boot-background')
|
||||
let scheme = localStorage.getItem('hermes-boot-color-scheme')
|
||||
if (!bg) {
|
||||
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
bg = dark ? '#111111' : '#f7f7f7'
|
||||
scheme = dark ? 'dark' : 'light'
|
||||
}
|
||||
document.documentElement.style.backgroundColor = bg
|
||||
if (scheme === 'dark' || scheme === 'light') {
|
||||
document.documentElement.style.colorScheme = scheme
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable — keep UA defaults.
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" class="scrollbar-dt"></div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<BrailleSpinner
|
||||
<GlyphSpinner
|
||||
ariaLabel={a.running}
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
|
||||
spinner="breathe"
|
||||
|
|
@ -290,7 +290,7 @@ function StreamLine({
|
|||
<span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}>
|
||||
{entry.text}
|
||||
{active ? (
|
||||
<BrailleSpinner
|
||||
<GlyphSpinner
|
||||
ariaLabel={t.agents.streaming}
|
||||
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
|
||||
spinner="breathe"
|
||||
|
|
@ -372,7 +372,9 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
|||
|
||||
{open && fileLines.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-0.5 pl-6">
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">{t.agents.files}</p>
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">
|
||||
{t.agents.files}
|
||||
</p>
|
||||
{fileLines.slice(0, 8).map(line => (
|
||||
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
|
||||
{line}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
{c.tipPre}
|
||||
<kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
|
||||
<Kbd size="sm">@</Kbd>
|
||||
{c.tipPost}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{c.steer}
|
||||
<KbdCombo combo="mod+enter" size="sm" variant="inverted" />
|
||||
</span>
|
||||
)
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
|
|
@ -75,7 +83,7 @@ export function ComposerControls({
|
|||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{canSteer && (
|
||||
<Tip label={steerLabel}>
|
||||
<Tip label={steerTip}>
|
||||
<Button
|
||||
aria-label={steerLabel}
|
||||
className={GHOST_ICON_BTN}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
* steal focus from the composer effect.
|
||||
*/
|
||||
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
import type { InlineRefInput } from './inline-refs'
|
||||
|
||||
export type ComposerTarget = 'edit' | 'main'
|
||||
|
|
@ -123,3 +124,12 @@ export const focusComposerInput = (el: HTMLElement | null) => {
|
|||
window.requestAnimationFrame(focus)
|
||||
window.setTimeout(focus, 0)
|
||||
}
|
||||
|
||||
/** Drop focus from the main composer input (status-stack chrome, sidebar, etc.). */
|
||||
export const blurComposerInput = () => {
|
||||
const el = document.querySelector(`[data-slot="${RICH_INPUT_SLOT}"]`) as HTMLElement | null
|
||||
|
||||
if (el && document.activeElement === el) {
|
||||
el.blur()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
|
||||
|
||||
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
|
||||
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+Shift+K', 'Cmd/Ctrl+/', 'Esc', '↑ / ↓']
|
||||
|
||||
/** Stable ids → i18n `hotkeyDescs` keys. Combos resolve mod labels per OS. */
|
||||
const COMPOSER_HOTKEY_ROWS = [
|
||||
{ id: 'composer.mention', combos: ['@'] },
|
||||
{ id: 'composer.slash', combos: ['/'] },
|
||||
{ id: 'composer.help', combos: ['?'] },
|
||||
{ id: 'composer.sendNewline', combos: ['enter', 'shift+enter'] },
|
||||
{ id: 'composer.sendQueued', combos: ['mod+shift+k'] },
|
||||
{ id: 'keybinds.openPanel', combos: ['mod+/'] },
|
||||
{ id: 'composer.cancel', combos: ['escape'] },
|
||||
{ id: 'composer.history', combos: ['up', 'down'] }
|
||||
] as const
|
||||
|
||||
export function HelpHint() {
|
||||
const { t } = useI18n()
|
||||
|
|
@ -20,8 +32,8 @@ export function HelpHint() {
|
|||
</Section>
|
||||
|
||||
<Section title={c.hotkeys}>
|
||||
{HOTKEY_KEYS.map(key => (
|
||||
<Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
|
||||
{COMPOSER_HOTKEY_ROWS.map(row => (
|
||||
<HotkeyRow description={c.hotkeyDescs[row.id] ?? ''} combos={[...row.combos]} key={row.id} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
|
|
@ -57,3 +69,16 @@ function Row({ description, keyLabel, mono = false }: { description: string; key
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HotkeyRow({ combos, description }: { combos: string[]; description: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1 text-xs">
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{combos.map(combo => (
|
||||
<KbdCombo combo={combo} key={combo} size="sm" />
|
||||
))}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground/80">{description}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLFormElement | null>(null)
|
||||
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
|
||||
const editorRef = useRef<HTMLDivElement | null>(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.
|
||||
<div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
|
||||
<QueuePanel
|
||||
busy={busy}
|
||||
editingId={queueEdit?.entryId ?? null}
|
||||
entries={queuedPrompts}
|
||||
onDelete={id => {
|
||||
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
|
||||
exitQueuedEdit('cancel')
|
||||
}
|
||||
}}
|
||||
onEdit={beginQueuedEdit}
|
||||
onSendNow={id => void sendQueuedNow(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<ComposerStatusStack
|
||||
queue={
|
||||
activeQueueSessionKey && queuedPrompts.length > 0 ? (
|
||||
<QueuePanel
|
||||
busy={busy}
|
||||
editingId={queueEdit?.entryId ?? null}
|
||||
entries={queuedPrompts}
|
||||
onDelete={id => {
|
||||
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
|
||||
exitQueuedEdit('cancel')
|
||||
}
|
||||
}}
|
||||
onEdit={beginQueuedEdit}
|
||||
onSendNow={id => void sendQueuedNow(id)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
sessionId={statusSessionId}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
style={{ background: COMPOSER_FADE_BACKGROUND }}
|
||||
|
|
@ -1723,10 +1738,10 @@ export function ChatBar({
|
|||
<div className="relative w-full rounded-[inherit]">
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out',
|
||||
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
'group-has-data-[state=open]/composer:border-t-transparent',
|
||||
'group-data-[status-stack]/composer:border-t-transparent',
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
data-slot="composer-surface"
|
||||
|
|
@ -1736,20 +1751,14 @@ export function ChatBar({
|
|||
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
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
||||
scrolledUp
|
||||
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
data-slot="composer-fade"
|
||||
>
|
||||
|
|
@ -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
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<typeof ref> => 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1 mx-1">
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 px-2 text-left text-[0.6rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
onClick={() => setCollapsed(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret className="shrink-0" open={!collapsed} size="1em" />
|
||||
<span className="truncate">{c.queued(entries.length)}</span>
|
||||
</button>
|
||||
<StatusSection label={c.queued(entries.length)}>
|
||||
{entries.map(entry => {
|
||||
const isEditing = editingId === entry.id
|
||||
const attachmentsCount = entry.attachments.length
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-0.5 px-1 pb-0.5">
|
||||
{entries.map(entry => {
|
||||
const isEditing = editingId === entry.id
|
||||
const attachmentsCount = entry.attachments.length
|
||||
const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-0.5',
|
||||
'transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
|
||||
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
)}
|
||||
key={entry.id}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
|
||||
{(attachmentsCount > 0 || isEditing) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
{c.editingInComposer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-0 transition-opacity',
|
||||
isEditing
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
|
||||
)}
|
||||
return (
|
||||
<StatusRow
|
||||
className={cn(
|
||||
'border border-transparent',
|
||||
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
)}
|
||||
key={entry.id}
|
||||
leading={
|
||||
<span aria-hidden className="size-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent" />
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
<Button
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
<Tip label={c.editQueued}>
|
||||
<Button
|
||||
aria-label={c.editQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={sendLabel}>
|
||||
<Button
|
||||
aria-label={sendLabel}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.deleteQueued}>
|
||||
<Button
|
||||
aria-label={c.deleteQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
{c.queueEdit}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{busy ? c.queueSendNext : c.queueSend}
|
||||
</Button>
|
||||
<Button onClick={() => onDelete(entry.id)} size="micro" type="button" variant="text">
|
||||
{c.queueDelete}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
trailingVisible={isEditing}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
|
||||
{(attachmentsCount > 0 || isEditing) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
{c.editingInComposer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StatusRow>
|
||||
)
|
||||
})}
|
||||
</StatusSection>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:`<img src=x onerror=alert(1)>` <b>raw</b>')
|
||||
})
|
||||
})
|
||||
|
||||
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 = '<div><span data-ref-text="@file:`src/foo.ts`" contenteditable="false">foo.ts</span> </div>'
|
||||
|
||||
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` ')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
194
apps/desktop/src/app/chat/composer/status-stack/index.tsx
Normal file
194
apps/desktop/src/app/chat/composer/status-stack/index.tsx
Normal file
|
|
@ -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: (
|
||||
<StatusSection
|
||||
accessory={
|
||||
group.type === 'subagent' ? (
|
||||
<Button
|
||||
className="text-muted-foreground/75 hover:text-foreground/90"
|
||||
onClick={openAgents}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{t.statusStack.agents}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
defaultCollapsed={group.type !== 'todo'}
|
||||
icon={
|
||||
group.type === 'todo' ? (
|
||||
<Codicon className="text-muted-foreground/70" name="checklist" size="0.8rem" />
|
||||
) : undefined
|
||||
}
|
||||
label={groupLabel(group, t.statusStack)}
|
||||
>
|
||||
{group.items.map(item => (
|
||||
<StatusItemRow
|
||||
item={item}
|
||||
key={item.id}
|
||||
onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined}
|
||||
onOpen={() => openSubagent(item)}
|
||||
onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</StatusSection>
|
||||
)
|
||||
}))
|
||||
|
||||
if (queue) {
|
||||
sections.push({ key: 'queue', node: queue })
|
||||
}
|
||||
|
||||
const visible = sections.length > 0
|
||||
const stackRef = useRef<HTMLDivElement | null>(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 (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-full z-6 -mb-[9px] max-h-[40vh] overflow-y-auto"
|
||||
onPointerDownCapture={() => 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. */}
|
||||
<div className={cn(composerDockCard('top'), 'mx-1 pt-0.5 pb-1')}>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
apps/desktop/src/app/chat/composer/status-stack/status-row.tsx
Normal file
155
apps/desktop/src/app/chat/composer/status-stack/status-row.tsx
Normal file
|
|
@ -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<Exclude<TodoStatus, 'in_progress' | 'pending'>, { 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 (
|
||||
<span
|
||||
aria-hidden
|
||||
className="box-border size-[0.7rem] rounded-full border border-dashed border-muted-foreground/60"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.todoStatus && item.todoStatus !== 'in_progress') {
|
||||
const glyph = TODO_GLYPHS[item.todoStatus]
|
||||
|
||||
return <Codicon className={glyph.tone} name={glyph.icon} size="0.8rem" />
|
||||
}
|
||||
|
||||
if (item.state === 'running') {
|
||||
return (
|
||||
<GlyphSpinner
|
||||
ariaLabel={s.running}
|
||||
className="text-[0.9rem] leading-none text-muted-foreground/80"
|
||||
spinner="braille"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('size-1.5 rounded-full', item.state === 'failed' ? 'bg-destructive/80' : 'bg-emerald-500/70')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Fragment>
|
||||
<StatusRow
|
||||
leading={leadingGlyph(item, s)}
|
||||
onActivate={onActivate}
|
||||
trailing={
|
||||
action ? (
|
||||
<Tip label={action.label}>
|
||||
<Button
|
||||
aria-label={action.label}
|
||||
className="-my-1 size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
action.onClick()
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : canOpen ? (
|
||||
<ArrowUpRight aria-hidden className="size-3.5 text-muted-foreground/55" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4',
|
||||
failed
|
||||
? 'text-destructive/90'
|
||||
: item.todoStatus && item.todoStatus !== 'in_progress'
|
||||
? 'text-muted-foreground/75'
|
||||
: 'text-foreground/92'
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
{item.type === 'subagent' && item.currentTool && (
|
||||
<span className="shrink-0 truncate text-[0.62rem] leading-4 text-muted-foreground/70">
|
||||
{toolLabel(item.currentTool)}
|
||||
</span>
|
||||
)}
|
||||
{failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && (
|
||||
<span className="shrink-0 rounded bg-destructive/15 px-1 text-[0.58rem] font-semibold text-destructive tabular-nums">
|
||||
{s.exit(item.exitCode)}
|
||||
</span>
|
||||
)}
|
||||
{hasOutput && <DisclosureCaret className="shrink-0 text-muted-foreground/45" open={outputOpen} size="0.8em" />}
|
||||
</StatusRow>
|
||||
{hasOutput && outputOpen && <TerminalOutput className="mx-auto mb-1 max-w-[90%]" text={item.output!} />}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
|
|
@ -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<string, string> = {
|
||||
diff: 'diff',
|
||||
|
|
@ -87,7 +83,7 @@ export function ComposerTriggerPopover({
|
|||
{items.length === 0 ? (
|
||||
loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
|
||||
<BrailleSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
|
||||
<GlyphSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
|
||||
<span>{copy.lookupLoading}</span>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<React.ComponentProps<'div'>, 'onSubmit'> {
|
|||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void>
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
}
|
||||
|
||||
|
|
@ -124,13 +125,7 @@ function ChatHeader({
|
|||
|
||||
return (
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
style={{
|
||||
maxWidth:
|
||||
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
|
||||
}}
|
||||
>
|
||||
<div className={titlebarHeaderTitleClass}>
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
|
|
@ -141,7 +136,7 @@ function ChatHeader({
|
|||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
className="pointer-events-auto flex h-6 w-full min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
|
|
@ -176,6 +171,7 @@ export function ChatView({
|
|||
onThreadMessagesChange,
|
||||
onEdit,
|
||||
onReload,
|
||||
onRestoreToMessage,
|
||||
onTranscribeAudio
|
||||
}: ChatViewProps) {
|
||||
const location = useLocation()
|
||||
|
|
@ -362,6 +358,7 @@ export function ChatView({
|
|||
loading={threadLoading}
|
||||
onBranchInNewChat={onBranchInNewChat}
|
||||
onCancel={onCancel}
|
||||
onRestoreToMessage={onRestoreToMessage}
|
||||
sessionId={activeSessionId}
|
||||
sessionKey={threadKey}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -10,12 +10,16 @@ import { useEffect, useMemo, useState } from 'react'
|
|||
import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/composer/focus'
|
||||
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||
|
|
@ -357,6 +361,38 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
|||
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
|
||||
}
|
||||
|
||||
// ⌘/Ctrl+L with a line selection drops the same `@line:path:start-end` ref the
|
||||
// gutter drag produces — so the keyboard path mirrors dragging the lines into
|
||||
// the composer. Capture-phase + stopPropagation so it beats the terminal's
|
||||
// global ⌘L handler (which would otherwise grab the native text selection).
|
||||
useEffect(() => {
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isAddSelectionShortcut(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
const lineEnd = selection.end > selection.start ? selection.end : undefined
|
||||
const ref = droppedFileInlineRef({ line: selection.start, lineEnd, path: filePath }, $currentCwd.get())
|
||||
|
||||
if (!ref) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
requestComposerInsertRefs([ref])
|
||||
requestComposerFocus('main')
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [filePath, selection])
|
||||
|
||||
return (
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
|
||||
<div className="select-none py-3 text-right text-muted-foreground/55">
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export function SidebarCronJobsSection({
|
|||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
|
||||
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-x-hidden overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
|
||||
{shown.map(job => (
|
||||
<CronJobSidebarRow
|
||||
expanded={peekJobId === job.id}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { Tip } from '@/components/ui/tooltip'
|
|||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { profileColor } from '@/lib/profile-color'
|
||||
import { comboTokens } from '@/lib/keybinds/combo'
|
||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -108,11 +109,7 @@ const VIRTUALIZE_THRESHOLD = 25
|
|||
const NON_SESSION_INITIAL_ROWS = 3
|
||||
const NON_SESSION_LOAD_STEP = 10
|
||||
|
||||
// Render the modifier key the user actually presses on this platform. The
|
||||
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
|
||||
// else) in desktop-controller.tsx, but the hint should match muscle memory.
|
||||
const NEW_SESSION_KBD: readonly string[] =
|
||||
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
|
||||
const NEW_SESSION_KBD = comboTokens('mod+n')
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
|
|
@ -144,8 +141,11 @@ const GROUP_DND_ID_PREFIX = 'group:'
|
|||
// the next — the flexbox `min-height: auto` overlap trap that caused the bug.
|
||||
const COMPACT_FLAT = 'compact:max-h-none compact:overflow-visible'
|
||||
|
||||
// Vertical scroll only — never a horizontal bar from glow bleed, long titles, etc.
|
||||
const SCROLL_Y = 'overflow-y-auto overflow-x-hidden overscroll-contain'
|
||||
|
||||
// A non-session group's scroll body: own scroller when tall, flattened when compact.
|
||||
const GROUP_BODY = cn('overflow-y-auto overscroll-contain', COMPACT_FLAT)
|
||||
const GROUP_BODY = cn(SCROLL_Y, COMPACT_FLAT)
|
||||
|
||||
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
|
||||
|
||||
|
|
@ -830,8 +830,9 @@ export function ChatSidebar({
|
|||
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
|
||||
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -857,11 +858,11 @@ export function ChatSidebar({
|
|||
)}
|
||||
|
||||
{contentVisible && showSessionSections && (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75">
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col pb-1.75', SCROLL_Y)}>
|
||||
{trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
contentClassName={cn('flex min-h-0 flex-1 flex-col gap-px pb-1.75', SCROLL_Y)}
|
||||
emptyState={
|
||||
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
|
||||
{s.noMatch(trimmedQuery)}
|
||||
|
|
@ -908,7 +909,8 @@ export function ChatSidebar({
|
|||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
|
||||
'flex min-h-0 flex-1 flex-col pb-1.75',
|
||||
SCROLL_Y,
|
||||
// Separate profile sections clearly in the ALL view; rows inside
|
||||
// each group keep their own tight gap-px rhythm.
|
||||
showAllProfiles ? 'gap-3' : 'gap-px',
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
|||
})
|
||||
|
||||
const list = (
|
||||
<div className={cn('relative min-h-0 flex-1 overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
|
||||
<div className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
|
||||
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
|
||||
{rows}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<CommandItem
|
||||
|
|
@ -632,10 +630,10 @@ export function CommandPalette() {
|
|||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{keys && <KbdGroup className="ml-auto" keys={keys} />}
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && (
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<RightSidebarPane
|
||||
onActivateFile={composer.attachContextFilePath}
|
||||
onActivateFolder={composer.attachContextFolderPath}
|
||||
onActivateFile={path => composer.insertContextPathInlineRef(path)}
|
||||
onActivateFolder={path => composer.insertContextPathInlineRef(path, true)}
|
||||
onChangeCwd={changeSessionCwd}
|
||||
/>
|
||||
</Pane>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<span aria-hidden className="flex w-3 items-center justify-center">
|
||||
<Codicon
|
||||
className="text-(--ui-text-tertiary)"
|
||||
name={node.isOpen ? 'chevron-down' : 'chevron-right'}
|
||||
size="0.75rem"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
|
||||
{/* No chevron column — the folder icon (open/closed) already carries the
|
||||
expand state, so the extra glyph was pure noise. */}
|
||||
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
|
||||
{isPlaceholder && !isErrorPlaceholder ? (
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, boolean>
|
||||
rootError: string | null
|
||||
rootLoading: boolean
|
||||
|
|
@ -80,6 +84,8 @@ interface ProjectTreeState {
|
|||
loaded: boolean
|
||||
openState: Record<string, boolean>
|
||||
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<ProjectTreeState>(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<string | null> {
|
||||
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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwd={effectiveCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
error={rootError}
|
||||
|
|
@ -126,13 +126,12 @@ interface FilesystemTabProps extends FileTreeBodyProps {
|
|||
onRefresh: () => void
|
||||
}
|
||||
|
||||
// Sidebar-specific color/hover treatment only — size, radius, cursor and the
|
||||
// base focus ring come from <Button size="icon-xs">. This constant exists
|
||||
// purely to share the sidebar palette + the hover-reveal behavior below.
|
||||
// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all
|
||||
// stays visible while any folder is expanded.
|
||||
const HEADER_ACTION_CLASS =
|
||||
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
|
||||
|
||||
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
|
||||
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 peer-focus-visible/project-label:pointer-events-auto peer-focus-visible/project-label:opacity-100 peer-hover/project-label:pointer-events-auto peer-hover/project-label:opacity-100`
|
||||
|
||||
function FilesystemTab({
|
||||
canCollapse,
|
||||
|
|
@ -157,20 +156,20 @@ function FilesystemTab({
|
|||
const r = t.rightSidebar
|
||||
|
||||
return (
|
||||
<div className="group/project-header flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<RightSidebarSectionHeader>
|
||||
<Tip label={hasCwd ? r.folderTip(cwd) : r.openFolder}>
|
||||
<div className="peer/project-label flex min-w-0 flex-1">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={r.refreshTree}
|
||||
className={HEADER_ACTION_CLASS}
|
||||
className={HEADER_ACTION_LABEL_REVEAL}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
|
|
@ -189,7 +188,7 @@ function FilesystemTab({
|
|||
</Button>
|
||||
<Button
|
||||
aria-label={r.collapseAll}
|
||||
className={HEADER_ACTION_REVEAL_CLASS}
|
||||
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon-xs"
|
||||
|
|
@ -209,6 +208,7 @@ function FilesystemTab({
|
|||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
onRetry={onRefresh}
|
||||
openState={openState}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -230,6 +230,9 @@ interface FileTreeBodyProps {
|
|||
onLoadChildren: (id: string) => void | Promise<void>
|
||||
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<typeof useProjectTree>['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 <EmptyState body={r.unreadableBody(error)} title={r.unreadableTitle} />
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<EmptyState body={r.unreadableBody(error)} title={r.unreadableTitle} />
|
||||
{onRetry && (
|
||||
<button
|
||||
className="text-[0.68rem] font-medium text-muted-foreground transition hover:text-foreground"
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
>
|
||||
{r.tryAgain}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading && data.length === 0) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
|
||||
<KbdCombo className="ml-1 opacity-70" combo="mod+l" size="sm" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
refreshSessions: () => Promise<void>
|
||||
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
|
||||
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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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> = {}): SessionInfo {
|
|||
|
||||
interface HarnessHandle {
|
||||
cancelRun: () => Promise<void>
|
||||
restoreToMessage: (messageId: string) => Promise<void>
|
||||
steerPrompt: (text: string) => Promise<boolean>
|
||||
submitText: (
|
||||
text: string,
|
||||
|
|
@ -57,6 +59,7 @@ function Harness({
|
|||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
seedMessages,
|
||||
storedSessionId
|
||||
}: {
|
||||
busyRef?: MutableRefObject<boolean>
|
||||
|
|
@ -65,6 +68,7 @@ function Harness({
|
|||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
|
||||
seedMessages?: unknown[]
|
||||
storedSessionId?: null | string
|
||||
}) {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { 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<string, unknown> = {}
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (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<string, unknown> = {}
|
||||
let handle: HarnessHandle | null = null
|
||||
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (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(
|
||||
<Harness
|
||||
onReady={h => (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(<Harness onReady={h => (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()
|
||||
|
|
|
|||
|
|
@ -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<void>(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
|
||||
|
|
|
|||
|
|
@ -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<SessionResumeResponse>('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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<span className="kbd-cap kbd-capturing">{k.pressKey}</span>
|
||||
<Kbd variant="capturing">{k.pressKey}</Kbd>
|
||||
) : combos.length > 0 ? (
|
||||
combos.map(combo => (
|
||||
<span className="kbd-cap" key={combo}>
|
||||
{formatCombo(combo)}
|
||||
</span>
|
||||
))
|
||||
combos.map(combo => <KbdCombo combo={combo} key={combo} />)
|
||||
) : (
|
||||
<span className="kbd-cap kbd-cap--ghost">{k.set}</span>
|
||||
<Kbd variant="ghost">{k.set}</Kbd>
|
||||
)}
|
||||
</button>
|
||||
|
||||
|
|
@ -209,9 +206,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
|
|||
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{shortcut.keys.map(key => (
|
||||
<span className="kbd-cap" key={key}>
|
||||
{formatCombo(key)}
|
||||
</span>
|
||||
<KbdCombo combo={key} key={key} />
|
||||
))}
|
||||
</div>
|
||||
<span aria-hidden className="size-6 shrink-0" />
|
||||
|
|
|
|||
|
|
@ -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-['']"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[0.6875rem] text-muted-foreground/85">{copy.shortcut}</span>
|
||||
<span className="inline-flex items-center gap-1 text-[0.6875rem] text-muted-foreground/85">
|
||||
<KbdCombo combo="mod+enter" size="sm" />
|
||||
{copy.shortcutSuffix}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasChoices && (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -216,36 +216,6 @@ function assistantTodoMessage(
|
|||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantReasoningTodoMessage(
|
||||
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>
|
||||
): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-reasoning-todo-1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'Let me make a quick todo list.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'todo-1',
|
||||
toolName: 'todo',
|
||||
args: { todos },
|
||||
argsText: JSON.stringify({ todos }),
|
||||
result: { todos }
|
||||
},
|
||||
{ type: 'text', text: 'Done — fake list created.' }
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function StreamingHarness() {
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
|
||||
const [isRunning, setIsRunning] = useState(true)
|
||||
|
|
@ -718,7 +688,7 @@ describe('assistant-ui streaming renderer', () => {
|
|||
expect(container.textContent).toContain('Interim answer.')
|
||||
})
|
||||
|
||||
it('renders live todo rows during a running turn', () => {
|
||||
it('does not render an inline todo panel — todos live in the composer status stack', () => {
|
||||
const { container } = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([
|
||||
|
|
@ -728,52 +698,6 @@ describe('assistant-ui streaming renderer', () => {
|
|||
/>
|
||||
)
|
||||
|
||||
const ui = within(container)
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeTruthy()
|
||||
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
|
||||
expect(ui.getByText('Gather ingredients')).toBeTruthy()
|
||||
expect(ui.queryByText(/pending/i)).toBeNull()
|
||||
expect(ui.queryByRole('button', { name: /todo/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders archived todos after turn completion regardless of pending state', () => {
|
||||
const first = render(
|
||||
<TodoHarness message={assistantTodoMessage([{ content: 'Boil water', id: 'boil', status: 'pending' }], false)} />
|
||||
)
|
||||
|
||||
const ui = within(first.container)
|
||||
|
||||
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
|
||||
|
||||
first.unmount()
|
||||
|
||||
const second = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([{ content: 'Serve latte', id: 'serve', status: 'completed' }], false)}
|
||||
/>
|
||||
)
|
||||
|
||||
const archivedUi = within(second.container)
|
||||
|
||||
expect(archivedUi.getAllByText('Serve latte').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('hoists todo outside the thinking disclosure when reasoning is present', () => {
|
||||
const { container } = render(
|
||||
<TodoHarness
|
||||
message={assistantReasoningTodoMessage([
|
||||
{ content: 'Buy oats', id: 'oats', status: 'completed' },
|
||||
{ content: "Reply to Sam's email", id: 'email', status: 'in_progress' }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
const todoPanel = container.querySelector('[data-slot="aui_todo-hoisted"]')
|
||||
const thinkingDisclosure = container.querySelector('[data-slot="aui_thinking-disclosure"]')
|
||||
|
||||
expect(todoPanel).toBeTruthy()
|
||||
expect(thinkingDisclosure).toBeTruthy()
|
||||
expect(Boolean(thinkingDisclosure?.contains(todoPanel as Node))).toBe(false)
|
||||
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
|||
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
|
||||
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
|
||||
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
|
||||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
|
||||
|
|
@ -70,6 +69,7 @@ import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-p
|
|||
import { Intro, type IntroProps } from '@/components/chat/intro'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -136,6 +136,7 @@ export const Thread: FC<{
|
|||
loading?: ThreadLoadingState
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
onCancel?: () => Promise<void> | void
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
||||
sessionId?: string | null
|
||||
sessionKey?: string | null
|
||||
}> = ({
|
||||
|
|
@ -146,6 +147,7 @@ export const Thread: FC<{
|
|||
loading,
|
||||
onBranchInNewChat,
|
||||
onCancel,
|
||||
onRestoreToMessage,
|
||||
sessionId = null,
|
||||
sessionKey
|
||||
}) => {
|
||||
|
|
@ -154,9 +156,9 @@ export const Thread: FC<{
|
|||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
|
||||
SystemMessage,
|
||||
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
||||
UserMessage: () => <UserMessage onCancel={onCancel} />
|
||||
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
|
||||
}),
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, sessionId]
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId]
|
||||
)
|
||||
|
||||
const emptyPlaceholder = intro ? (
|
||||
|
|
@ -216,7 +218,6 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
|||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content])
|
||||
|
||||
const previewTargets = useMemo(() => {
|
||||
if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
|
||||
|
|
@ -246,7 +247,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
|||
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
|
||||
data-slot="aui_assistant-message-content"
|
||||
>
|
||||
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
|
||||
{/* Todos render in the composer status stack now, not inline. */}
|
||||
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
|
||||
{messageStatus === 'running' && <StreamStallIndicator activity={`${content.length}:${messageText.length}`} />}
|
||||
{previewTargets.length > 0 && (
|
||||
|
|
@ -737,11 +738,46 @@ const USER_ACTION_ICON_BUTTON_CLASS =
|
|||
const USER_ACTION_ICON_SIZE = '0.6875rem'
|
||||
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
|
||||
|
||||
// Background-process notifications are injected into the conversation as user
|
||||
// messages (the agent must react to them, and message-role alternation forbids
|
||||
// a synthetic system row mid-loop). They are NOT something the human typed, so
|
||||
// render them as a compact system-style notice instead of a user bubble.
|
||||
// Shape: see tools/process_registry.py format_process_notification().
|
||||
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
|
||||
|
||||
const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
|
||||
const body = text.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '')
|
||||
const newline = body.indexOf('\n')
|
||||
const headline = (newline === -1 ? body : body.slice(0, newline)).trim()
|
||||
const detail = newline === -1 ? '' : body.slice(newline + 1).trim()
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[min(86%,44rem)] flex-col gap-0.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Codicon className="shrink-0 text-muted-foreground/55" name="terminal" size="0.75rem" />
|
||||
<span className="wrap-anywhere">{headline}</span>
|
||||
</span>
|
||||
{detail && (
|
||||
<details className="pl-[1.3125rem]">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground/45 hover:text-muted-foreground/70">
|
||||
output
|
||||
</summary>
|
||||
<pre className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55">
|
||||
{detail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UserMessage: FC<{
|
||||
onCancel?: () => Promise<void> | void
|
||||
}> = ({ onCancel }) => {
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
||||
}> = ({ onCancel, onRestoreToMessage }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
|
|
@ -791,15 +827,32 @@ const UserMessage: FC<{
|
|||
|
||||
useResizeObserver(measureClamp, clampInnerRef)
|
||||
|
||||
// Injected background-process notification, not a human prompt — render the
|
||||
// compact system-style notice (after all hooks above have run).
|
||||
if (PROCESS_NOTIFICATION_RE.test(messageText.trim())) {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="flex w-full min-w-0 flex-col items-stretch"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<ProcessNotificationNote text={messageText.trim()} />
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const hasBody = messageText.trim().length > 0
|
||||
const isLatestUser = messageId === latestUserId
|
||||
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
|
||||
const showRestore = !isLatestUser && !threadRunning
|
||||
// Restore (re-run this exact prompt) is available everywhere the Stop button
|
||||
// isn't — including mid-stream on older prompts, since the action interrupts
|
||||
// the live turn before rewinding.
|
||||
const showRestore = !showStop && Boolean(onRestoreToMessage) && hasBody
|
||||
|
||||
const bubbleClassName = cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
'border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
|
||||
!threadRunning && 'cursor-pointer hover:border-(--ui-stroke-secondary)'
|
||||
'cursor-pointer pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
|
||||
'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)'
|
||||
)
|
||||
|
||||
const bubbleContent = (
|
||||
|
|
@ -828,21 +881,19 @@ const UserMessage: FC<{
|
|||
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
|
||||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
<div className="relative w-full">
|
||||
{threadRunning ? (
|
||||
<div className={bubbleClassName}>{bubbleContent}</div>
|
||||
) : (
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
)}
|
||||
{/* Always editable — clicking opens the edit composer even while a
|
||||
turn streams; sending the edit reverts (interrupt + rewind). */}
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
{(showStop || showRestore) && (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||
{showStop ? (
|
||||
|
|
@ -860,13 +911,20 @@ const UserMessage: FC<{
|
|||
{StopGlyph}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
|
||||
title={copy.editableCheckpoint}
|
||||
<button
|
||||
aria-label={copy.restoreCheckpoint}
|
||||
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
setRestoreConfirmOpen(true)
|
||||
}}
|
||||
title={copy.restoreFromHere}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -894,6 +952,17 @@ const UserMessage: FC<{
|
|||
</BranchPickerPrimitive.Root>
|
||||
</div>
|
||||
</ActionBarPrimitive.Root>
|
||||
{showRestore && (
|
||||
<ConfirmDialog
|
||||
confirmLabel={copy.restoreConfirm}
|
||||
description={copy.restoreBody}
|
||||
destructive
|
||||
onClose={() => setRestoreConfirmOpen(false)}
|
||||
onConfirm={() => onRestoreToMessage?.(messageId)}
|
||||
open={restoreConfirmOpen}
|
||||
title={copy.restoreTitle}
|
||||
/>
|
||||
)}
|
||||
</StickyHumanMessageContainer>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
|
||||
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 (
|
||||
<span
|
||||
aria-label={`In progress: ${label}`}
|
||||
className="grid size-[1.1rem] shrink-0 place-items-center rounded-full border border-ring/65 bg-[color-mix(in_srgb,var(--dt-ring)_14%,transparent)]"
|
||||
>
|
||||
<Loader2Icon className="size-3 animate-spin text-ring" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const checked = status === 'completed'
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
className={cn(
|
||||
'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100',
|
||||
checked &&
|
||||
'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
|
||||
status === 'cancelled' && 'border-muted-foreground/40'
|
||||
)}
|
||||
disabled
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
|
||||
if (!todos.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = headerLabel(todos)
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mt-1 mb-3 inline-block w-fit max-w-full overflow-hidden rounded-2xl border border-border/70 bg-card align-top shadow-[0_1px_2px_0_hsl(var(--foreground)/0.04),0_1px_4px_-1px_hsl(var(--foreground)/0.06)]"
|
||||
data-slot="aui_todo-hoisted"
|
||||
>
|
||||
<header className="px-3 pt-3 pb-2">
|
||||
<span
|
||||
className="block max-w-full truncate text-[0.85rem] font-semibold leading-tight tracking-tight text-foreground"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</header>
|
||||
<ul className="grid min-w-0 gap-0.5 px-3 pb-3">
|
||||
{todos.map(todo => (
|
||||
<li
|
||||
// Active row at full presence; everything else fades. Opacity on
|
||||
// the row so the checkbox glyph dims with the text.
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-3 py-1.5 transition-opacity',
|
||||
todo.status === 'in_progress' ? 'opacity-100' : 'opacity-45'
|
||||
)}
|
||||
key={todo.id}
|
||||
>
|
||||
<Checkmark label={todo.content} status={todo.status} />
|
||||
<span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">{todo.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<BrailleSpinner
|
||||
<GlyphSpinner
|
||||
ariaLabel={copy.statusRunning}
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-(--ui-text-tertiary)"
|
||||
spinner="breathe"
|
||||
|
|
@ -114,10 +114,7 @@ function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
|
|||
|
||||
if (status === 'warning') {
|
||||
return (
|
||||
<AlertCircle
|
||||
aria-label={copy.statusRecovered}
|
||||
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<AlertCircle aria-label={copy.statusRecovered} className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
31
apps/desktop/src/components/chat/composer-dock.ts
Normal file
31
apps/desktop/src/components/chat/composer-dock.ts
Normal file
|
|
@ -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)
|
||||
68
apps/desktop/src/components/chat/status-row.tsx
Normal file
68
apps/desktop/src/components/chat/status-row.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'group/status-row flex min-h-6 items-center gap-2 rounded-md px-1.5 py-1 hover:bg-(--ui-row-hover-background)',
|
||||
onActivate && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
onClick={onActivate}
|
||||
onKeyDown={
|
||||
onActivate
|
||||
? event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onActivate()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
role={onActivate ? 'button' : undefined}
|
||||
tabIndex={onActivate ? 0 : undefined}
|
||||
>
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center">{leading}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">{children}</div>
|
||||
{trailing && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-0.5',
|
||||
!trailingVisible && 'opacity-0 group-hover/status-row:opacity-100 group-focus-within/status-row:opacity-100'
|
||||
)}
|
||||
>
|
||||
{trailing}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
apps/desktop/src/components/chat/status-section.tsx
Normal file
42
apps/desktop/src/components/chat/status-section.tsx
Normal file
|
|
@ -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 (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 pr-1">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left text-xs font-normal text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
onClick={() => setCollapsed(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret className="shrink-0" open={!collapsed} size="1em" />
|
||||
{icon && <span className="flex shrink-0 items-center">{icon}</span>}
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
{accessory && <div className="flex shrink-0 items-center gap-1">{accessory}</div>}
|
||||
</div>
|
||||
{!collapsed && <div className="px-1 pb-0.5">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
apps/desktop/src/components/chat/terminal-output.tsx
Normal file
50
apps/desktop/src/components/chat/terminal-output.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div className={cn('max-h-16 overflow-auto overscroll-contain', className)} ref={ref}>
|
||||
<pre className="w-max min-w-full font-mono text-[0.5625rem] leading-[0.85rem] whitespace-pre text-muted-foreground/70">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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({
|
|||
<div className="max-h-[55vh] overflow-y-auto pb-1">
|
||||
{providers.length === 0 ? (
|
||||
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
|
||||
{modelOptions.isPending ? <BrailleSpinner className="mx-auto text-sm" /> : copy.noAuthenticatedProviders}
|
||||
{modelOptions.isPending ? <GlyphSpinner className="mx-auto text-sm" /> : copy.noAuthenticatedProviders}
|
||||
</div>
|
||||
) : (
|
||||
providers.map(provider => {
|
||||
|
|
|
|||
|
|
@ -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]',
|
||||
|
|
|
|||
|
|
@ -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<BrailleSpinnerName, NormalisedSpinner> = (() => {
|
||||
const out = {} as Record<BrailleSpinnerName, NormalisedSpinner>
|
||||
const FRAMES_BY_NAME: Record<SpinnerName, NormalisedSpinner> = (() => {
|
||||
const out = {} as Record<SpinnerName, NormalisedSpinner>
|
||||
|
||||
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<BrailleSpinnerName, NormalisedSpinner> = (() => {
|
|||
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(() => {
|
||||
|
|
@ -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<typeof kbdVariants> {}
|
||||
|
||||
function Kbd({ children, className, size, variant, ...props }: KbdProps) {
|
||||
const label = typeof children === 'string' ? children : ''
|
||||
|
||||
return (
|
||||
<kbd
|
||||
className={cn(
|
||||
'inline-grid h-4 min-w-4 place-items-center rounded-sm border border-border/70 bg-muted/45 px-1 font-mono text-[0.5625rem] font-medium leading-none text-muted-foreground shadow-xs',
|
||||
className
|
||||
)}
|
||||
className={cn(kbdVariants({ size, variant }), kbdShapeClass(label, size), className)}
|
||||
data-slot="kbd"
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
interface KbdGroupProps extends Omit<React.ComponentProps<'span'>, 'children'> {
|
||||
interface KbdGroupProps extends Omit<React.ComponentProps<'span'>, 'children'>, VariantProps<typeof kbdVariants> {
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
function KbdGroup({ className, keys, ...props }: KbdGroupProps) {
|
||||
function KbdGroup({ className, keys, size, variant, ...props }: KbdGroupProps) {
|
||||
return (
|
||||
<span
|
||||
aria-label={keys.join(' ')}
|
||||
className={cn('inline-flex shrink-0 items-center gap-0.5 opacity-55', className)}
|
||||
className={cn('inline-flex shrink-0 items-center gap-1', className)}
|
||||
data-slot="kbd-group"
|
||||
{...props}
|
||||
>
|
||||
{keys.map(key => (
|
||||
<Kbd key={key}>{key}</Kbd>
|
||||
{keys.map((key, index) => (
|
||||
<Kbd key={`${key}-${index}`} size={size} variant={variant}>
|
||||
{key}
|
||||
</Kbd>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
interface KbdComboProps extends Omit<KbdGroupProps, 'keys'> {
|
||||
combo: string
|
||||
}
|
||||
|
||||
function KbdCombo({ combo, ...props }: KbdComboProps) {
|
||||
return <KbdGroup keys={comboTokens(combo)} {...props} />
|
||||
}
|
||||
|
||||
export { Kbd, KbdCombo, KbdGroup, kbdVariants }
|
||||
|
|
|
|||
|
|
@ -339,7 +339,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
className
|
||||
)}
|
||||
data-sidebar="content"
|
||||
|
|
|
|||
9
apps/desktop/src/global.d.ts
vendored
9
apps/desktop/src/global.d.ts
vendored
|
|
@ -20,8 +20,10 @@ declare global {
|
|||
getGatewayWsUrl: (profile?: null | string) => Promise<string>
|
||||
// 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<DesktopBootProgress>
|
||||
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
|
||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
|
|
@ -52,6 +54,7 @@ declare global {
|
|||
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
|
||||
stopPreviewFileWatch: (id: string) => Promise<boolean>
|
||||
setTitleBarTheme?: (payload: HermesTitleBarTheme) => void
|
||||
setNativeTheme?: (mode: 'dark' | 'light' | 'system') => void
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
fetchLinkTitle: (url: string) => Promise<string>
|
||||
|
|
@ -76,7 +79,7 @@ declare global {
|
|||
onClosePreviewRequested?: (callback: () => void) => () => void
|
||||
onOpenUpdatesRequested?: (callback: () => void) => () => void
|
||||
onDeepLink?: (
|
||||
callback: (payload: { kind: string; name: string; params: Record<string, string> }) => void,
|
||||
callback: (payload: { kind: string; name: string; params: Record<string, string> }) => void
|
||||
) => () => void
|
||||
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
|
||||
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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: '送信'
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 <name> 或「新工作階段」選擇器在其他設定檔中開始聊天。`,
|
||||
profileStatus: current => `設定檔:${current}。使用 /profile <name> 或「新工作階段」選擇器在其他設定檔中開始聊天。`,
|
||||
unknownProfile: '未知設定檔',
|
||||
noProfileNamed: (target, available) => `沒有名為「${target}」的設定檔。可用的:${available}`,
|
||||
newChatsProfile: name => `新聊天將使用設定檔 ${name}。`,
|
||||
|
|
|
|||
|
|
@ -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: '发送'
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {}) => ({
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
99
apps/desktop/src/store/composer-status.test.ts
Normal file
99
apps/desktop/src/store/composer-status.test.ts
Normal file
|
|
@ -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({})
|
||||
})
|
||||
})
|
||||
257
apps/desktop/src/store/composer-status.ts
Normal file
257
apps/desktop/src/store/composer-status.ts
Normal file
|
|
@ -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<Record<string, ComposerStatusItem[]>>({})
|
||||
|
||||
// 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<string, Set<string>>()
|
||||
|
||||
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<string, ComposerStatusItem[]> = {}
|
||||
|
||||
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<StatusItemType, ComposerStatusItem[]>()
|
||||
|
||||
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<void> {
|
||||
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<string>()
|
||||
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<string>()
|
||||
|
||||
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, [])
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
47
apps/desktop/src/store/todos.test.ts
Normal file
47
apps/desktop/src/store/todos.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
64
apps/desktop/src/store/todos.ts
Normal file
64
apps/desktop/src/store/todos.ts
Normal file
|
|
@ -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<Record<string, TodoItem[]>>({})
|
||||
|
||||
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<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
// `watch: true` opens a spectator window (lazy resume, live-mirror stream).
|
||||
export async function openSessionInNewWindow(sessionId: string, opts?: { watch?: boolean }): Promise<void> {
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <ThemeProvider> 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())
|
||||
|
|
|
|||
151
hermes_state.py
151
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
215
tests/tui_gateway/test_subagent_child_mirror.py
Normal file
215
tests/tui_gateway/test_subagent_child_mirror.py
Normal file
|
|
@ -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"}),
|
||||
]
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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", ""))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue