mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 05:39:06 +08:00
Compare commits
5 Commits
feat/web-g
...
ethie/node
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e24bfcb0b | ||
|
|
46d758bb3e | ||
|
|
7d4e60e44a | ||
|
|
79c3ed3cc9 | ||
|
|
d62979a6f3 |
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 26
|
||||
cache: npm
|
||||
|
||||
- name: Install npm dependencies
|
||||
|
||||
2
.github/workflows/deploy-site.yml
vendored
2
.github/workflows/deploy-site.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 26
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
|
||||
2
.github/workflows/docs-site-checks.yml
vendored
2
.github/workflows/docs-site-checks.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 26
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
|
||||
13
.github/workflows/typecheck.yml
vendored
13
.github/workflows/typecheck.yml
vendored
@@ -10,16 +10,15 @@ on:
|
||||
jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
package:
|
||||
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
|
||||
fail-fast: false # report all failures, not just the first one
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 26
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run --prefix ${{ matrix.package }} typecheck
|
||||
- run: npm run --prefix ui-tui typecheck
|
||||
- run: npm run --prefix web typecheck
|
||||
- run: npm run --prefix apps/bootstrap-installer typecheck
|
||||
- run: npm run --prefix apps/desktop typecheck
|
||||
- run: npm run --prefix apps/shared typecheck
|
||||
|
||||
2
.github/workflows/upload_to_pypi.yml
vendored
2
.github/workflows/upload_to_pypi.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '26'
|
||||
|
||||
- name: Build web dashboard
|
||||
run: cd web && npm ci && npm run build
|
||||
|
||||
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/
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -1,12 +1,12 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
|
||||
# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
||||
# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
||||
# which reached EOL in April 2026 — we copy node + npm + corepack from the
|
||||
# upstream node:22 image instead so we can stay on a supported LTS without
|
||||
# waiting for Debian 14 (forky, ~mid-2027). Bookworm-based slim image used
|
||||
# upstream node:26 image instead so we can stay on the supported node without
|
||||
# waiting for Debian 15+. Bookworm-based slim image used
|
||||
# so the produced binary links against glibc 2.36, which runs cleanly on
|
||||
# our Debian 13 (trixie, glibc 2.41) runtime. Bumping to a new Node major
|
||||
# is a one-line ARG change; see #4977.
|
||||
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
|
||||
FROM node:26-bookworm-slim@sha256:3fe807a03a4436e7bc76b7e84e6861899cd75c9028ae99bc00581940141ae150 AS node_source
|
||||
FROM debian:13.4
|
||||
|
||||
# Disable Python stdout buffering to ensure logs are printed immediately
|
||||
@@ -90,17 +90,15 @@ RUN useradd -u 10000 -m -d /opt/data hermes
|
||||
|
||||
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
|
||||
|
||||
# Node 22 LTS: copy the node binary plus the bundled npm + corepack JS
|
||||
# installs from the upstream image. npm and npx are recreated as symlinks
|
||||
# Node 26: copy the node binary plus the bundled npm JS
|
||||
# installs from the upstream image. npm and npx are recreated as symlinks
|
||||
# because they're symlinks in the source image (and need to live on PATH).
|
||||
# See node_source stage at the top of the file for the version-bump
|
||||
# rationale (#4977).
|
||||
COPY --chmod=0755 --from=node_source /usr/local/bin/node /usr/local/bin/
|
||||
COPY --from=node_source /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
|
||||
COPY --from=node_source /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/corepack
|
||||
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \
|
||||
ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack
|
||||
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
WORKDIR /opt/hermes
|
||||
|
||||
@@ -119,7 +117,7 @@ COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||
|
||||
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
||||
# symlinks instead of copies. This is the default since npm 10+, which is
|
||||
# what the image ships now (via the node:22 source stage). We set it
|
||||
# what the image ships now (via the node:26 source stage). We set it
|
||||
# explicitly anyway as defense-in-depth: the previous Debian-bundled npm
|
||||
# 9.x defaulted to install-as-copy, which produced a hidden
|
||||
# node_modules/.package-lock.json that permanently disagreed with the root
|
||||
|
||||
@@ -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,110 @@ 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()
|
||||
|
||||
// Window translucency (see-through window). One lever, 0–100; 0 = off (the
|
||||
// default). Mapped to the native window opacity so the desktop shows through
|
||||
// the whole window. Persisted so a cold launch applies it at window creation,
|
||||
// before the renderer reports its value. macOS + Windows only; `setOpacity` is
|
||||
// a no-op on Linux. See store/translucency.
|
||||
const TRANSLUCENCY_CONFIG_PATH = path.join(app.getPath('userData'), 'translucency.json')
|
||||
|
||||
function clampIntensity(value) {
|
||||
const n = Math.round(Number(value))
|
||||
|
||||
return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0
|
||||
}
|
||||
|
||||
function readPersistedTranslucency() {
|
||||
try {
|
||||
return clampIntensity(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')).intensity)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function writePersistedTranslucency(intensity) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(TRANSLUCENCY_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(TRANSLUCENCY_CONFIG_PATH, JSON.stringify({ intensity }, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[translucency] write failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
let translucencyIntensity = readPersistedTranslucency()
|
||||
|
||||
// Map the 0–100 lever to a window opacity. Floor at 0.3 so the most see-through
|
||||
// setting is still usable rather than nearly invisible. 0 → fully opaque.
|
||||
function windowOpacity() {
|
||||
return 1 - (translucencyIntensity / 100) * 0.7
|
||||
}
|
||||
|
||||
// Re-apply translucency to a live window (runtime toggle, no recreation).
|
||||
// `setOpacity` is a no-op on Linux, which is fine — it just stays opaque there.
|
||||
function applyWindowTranslucency(win) {
|
||||
if (!win || win.isDestroyed() || typeof win.setOpacity !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
win.setOpacity(windowOpacity())
|
||||
} catch (error) {
|
||||
rememberLog(`[translucency] apply failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
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 +1266,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 +1408,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 +1853,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 +4683,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 +4902,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 +5067,29 @@ 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,
|
||||
opacity: windowOpacity(),
|
||||
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 +5104,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 +5118,8 @@ function createSessionWindow(sessionId) {
|
||||
win.loadURL(
|
||||
buildSessionWindowUrl(sessionId, {
|
||||
devServer: DEV_SERVER,
|
||||
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex()
|
||||
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
|
||||
watch
|
||||
})
|
||||
)
|
||||
|
||||
@@ -5010,8 +5145,13 @@ function createWindow() {
|
||||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
opacity: windowOpacity(),
|
||||
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 +5187,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 +5302,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 +5715,35 @@ 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)
|
||||
}
|
||||
})
|
||||
|
||||
// See-through window translucency. Persist + re-apply opacity to every open
|
||||
// window at runtime (no recreation, so caching/sessions are untouched).
|
||||
ipcMain.on('hermes:translucency', (_event, payload) => {
|
||||
const next = clampIntensity(payload && payload.intensity)
|
||||
|
||||
if (next === translucencyIntensity) {
|
||||
return
|
||||
}
|
||||
|
||||
translucencyIntensity = next
|
||||
writePersistedTranslucency(next)
|
||||
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
applyWindowTranslucency(win)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
if (!openExternalUrl(url)) {
|
||||
throw new Error('Invalid external URL')
|
||||
@@ -6008,11 +6181,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 +6347,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 +6391,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 +6402,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 +6435,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,8 @@ 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),
|
||||
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
|
||||
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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "module",
|
||||
"main": "electron/main.cjs",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
"node": ">=26.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||
|
||||
@@ -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
|
||||
|
||||
119
apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Normal file
119
apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
import { useSessionActions } from './use-session-actions'
|
||||
|
||||
vi.mock('@/hermes', async importOriginal => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
deleteSession: vi.fn(),
|
||||
getSessionMessages: vi.fn(),
|
||||
listAllProfileSessions: vi.fn(),
|
||||
setApiRequestProfile: vi.fn(),
|
||||
setSessionArchived: vi.fn()
|
||||
}))
|
||||
|
||||
const RUNTIME_SESSION_ID = 'rt-new-001'
|
||||
|
||||
function Harness({
|
||||
onReady,
|
||||
requestGateway
|
||||
}: {
|
||||
onReady: (create: (preview?: string | null) => Promise<string | null>) => void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}) {
|
||||
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
|
||||
|
||||
const actions = useSessionActions({
|
||||
activeSessionId: null,
|
||||
activeSessionIdRef: ref<string | null>(null),
|
||||
busyRef: ref(false),
|
||||
creatingSessionRef: ref(false),
|
||||
ensureSessionState: () => ({}) as ClientSessionState,
|
||||
getRouteToken: () => 'token',
|
||||
navigate: vi.fn() as never,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
|
||||
selectedStoredSessionId: null,
|
||||
selectedStoredSessionIdRef: ref<string | null>(null),
|
||||
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
|
||||
syncSessionStateToView: vi.fn(),
|
||||
updateSessionState: () => ({}) as ClientSessionState
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onReady(actions.createBackendSessionForSend)
|
||||
}, [actions.createBackendSessionForSend, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function createWith(profileSetup: () => void): Promise<Record<string, unknown> | undefined> {
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: RUNTIME_SESSION_ID, stored_session_id: null } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
$currentCwd.set('')
|
||||
profileSetup()
|
||||
|
||||
let create: ((preview?: string | null) => Promise<string | null>) | null = null
|
||||
render(<Harness onReady={c => (create = c)} requestGateway={requestGateway} />)
|
||||
await waitFor(() => expect(create).not.toBeNull())
|
||||
await create!()
|
||||
|
||||
return createParams
|
||||
}
|
||||
|
||||
describe('createBackendSessionForSend profile routing', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$newChatProfile.set(null)
|
||||
$activeGatewayProfile.set('default')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('routes a plain new chat (no explicit profile) to the live gateway profile', async () => {
|
||||
// The "rubberband to default" bug: the top New Session button clears
|
||||
// $newChatProfile to null. In global-remote mode one backend serves every
|
||||
// profile, so an omitted `profile` lands the chat on the launch (default)
|
||||
// profile. The session must instead carry the active gateway profile.
|
||||
const params = await createWith(() => {
|
||||
$activeGatewayProfile.set('coder')
|
||||
$newChatProfile.set(null)
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({ profile: 'coder' })
|
||||
})
|
||||
|
||||
it('honours an explicit per-profile "+" selection', async () => {
|
||||
const params = await createWith(() => {
|
||||
$activeGatewayProfile.set('coder')
|
||||
$newChatProfile.set('analyst')
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({ profile: 'analyst' })
|
||||
})
|
||||
|
||||
it('passes the default profile for single-profile users (backend resolves it to launch)', async () => {
|
||||
const params = await createWith(() => {
|
||||
$activeGatewayProfile.set('default')
|
||||
$newChatProfile.set(null)
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({ profile: 'default' })
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -406,13 +407,17 @@ export function useSessionActions({
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
// Route the new chat to the chosen profile's backend (null = primary,
|
||||
// so single-profile users are unaffected).
|
||||
await ensureGatewayProfile($newChatProfile.get())
|
||||
// A plain new session (top "New Session", /new, keybind) leaves
|
||||
// $newChatProfile null to mean "use the live context"; the per-profile
|
||||
// "+" sets it explicitly. Resolve null to the active gateway profile so
|
||||
// session.create always carries it: in global-remote mode one backend
|
||||
// serves every profile, so an omitted profile param silently lands the
|
||||
// chat on the launch (default) profile — the "rubberbands back to
|
||||
// default" bug. This is a no-op for single-profile/local-pooled users:
|
||||
// a backend resolves its own launch profile to None (_profile_home).
|
||||
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
|
||||
await ensureGatewayProfile(newChatProfile)
|
||||
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
|
||||
// Pass the owning profile so a new chat under a non-launch profile (global
|
||||
// remote mode) builds its agent + persists against THAT profile's home/db.
|
||||
const newChatProfile = $newChatProfile.get()
|
||||
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
@@ -534,6 +539,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 +612,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 +638,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 +652,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 +668,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 +696,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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { $translucency, setTranslucency } from '@/store/translucency'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||
@@ -135,6 +136,7 @@ export function AppearanceSettings() {
|
||||
const { t, isSavingLocale } = useI18n()
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const translucency = useStore($translucency)
|
||||
const profiles = useStore($profiles)
|
||||
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
const a = t.settings.appearance
|
||||
@@ -183,6 +185,32 @@ export function AppearanceSettings() {
|
||||
title={a.colorMode}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
aria-label={a.translucencyTitle}
|
||||
className="h-1 w-40 cursor-pointer appearance-none rounded-full bg-(--ui-stroke-tertiary)"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={event => {
|
||||
triggerHaptic('selection')
|
||||
setTranslucency(Number(event.target.value))
|
||||
}}
|
||||
step={5}
|
||||
style={{ accentColor: 'var(--dt-primary)' }}
|
||||
type="range"
|
||||
value={translucency}
|
||||
/>
|
||||
<span className="w-9 text-right text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)">
|
||||
{translucency}%
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={a.translucencyDesc}
|
||||
title={a.translucencyTitle}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
below={
|
||||
<>
|
||||
|
||||
@@ -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"
|
||||
|
||||
10
apps/desktop/src/global.d.ts
vendored
10
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,8 @@ declare global {
|
||||
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
|
||||
stopPreviewFileWatch: (id: string) => Promise<boolean>
|
||||
setTitleBarTheme?: (payload: HermesTitleBarTheme) => void
|
||||
setNativeTheme?: (mode: 'dark' | 'light' | 'system') => void
|
||||
setTranslucency?: (payload: { intensity: number }) => void
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
fetchLinkTitle: (url: string) => Promise<string>
|
||||
@@ -76,7 +80,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
|
||||
|
||||
@@ -296,6 +296,8 @@ export const en: Translations = {
|
||||
colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.',
|
||||
toolViewTitle: 'Tool Call Display',
|
||||
toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',
|
||||
translucencyTitle: 'Window Translucency',
|
||||
translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
|
||||
product: 'Product',
|
||||
productDesc: 'Human-friendly tool activity with concise summaries.',
|
||||
technical: 'Technical',
|
||||
@@ -1184,14 +1186,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 +1206,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 +1254,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 +1300,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 +1667,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 +1698,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'
|
||||
|
||||
@@ -210,15 +210,19 @@ export const ja = defineLocale({
|
||||
colorModeDesc: '固定モードを選ぶか、Hermes をシステム設定に合わせます。',
|
||||
toolViewTitle: 'ツール呼び出しの表示',
|
||||
toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。',
|
||||
translucencyTitle: 'ウィンドウの透過',
|
||||
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
|
||||
product: 'プロダクト',
|
||||
productDesc: '読みやすいツール活動と簡潔な要約を表示します。',
|
||||
technical: 'テクニカル',
|
||||
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 +391,8 @@ export const ja = defineLocale({
|
||||
personality: '新しいセッションのデフォルトのアシスタントスタイルです。',
|
||||
showReasoning: 'バックエンドが推論内容を提供したときに表示します。'
|
||||
},
|
||||
timezone: 'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。',
|
||||
timezone:
|
||||
'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。',
|
||||
agent: {
|
||||
imageInputMode: '画像添付をモデルへ送る方法を制御します。',
|
||||
maxTurns: 'Hermes が 1 回の実行を停止するまでのツール呼び出しターン上限です。'
|
||||
@@ -513,15 +518,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 +535,8 @@ export const ja = defineLocale({
|
||||
authTitle: '認証',
|
||||
authSignedInPassword:
|
||||
'このゲートウェイはユーザー名とパスワードを使用します。サインイン済みです。セッションは自動的に更新されます。',
|
||||
authSignedInOauth: 'このゲートウェイは OAuth を使用します。サインイン済みです。セッションは自動的に更新されます。',
|
||||
authSignedInOauth:
|
||||
'このゲートウェイは OAuth を使用します。サインイン済みです。セッションは自動的に更新されます。',
|
||||
authNeedsPassword:
|
||||
'このゲートウェイはユーザー名とパスワードを使用します。このデスクトップアプリを承認するにはサインインしてください。',
|
||||
authNeedsOauth: provider =>
|
||||
@@ -544,8 +551,7 @@ export const ja = defineLocale({
|
||||
saveForRestart: '次回起動時のために保存',
|
||||
saveAndReconnect: '保存して再接続',
|
||||
diagnostics: '診断',
|
||||
diagnosticsDesc:
|
||||
'ファイルマネージャーで desktop.log を表示します。ゲートウェイの起動に失敗した際に役立ちます。',
|
||||
diagnosticsDesc: 'ファイルマネージャーで desktop.log を表示します。ゲートウェイの起動に失敗した際に役立ちます。',
|
||||
openLogs: 'ログを開く',
|
||||
incompleteTitle: 'リモートゲートウェイの設定が不完全です',
|
||||
incompleteSignIn: 'リモートに切り替える前にリモート URL を入力してサインインしてください。',
|
||||
@@ -603,7 +609,8 @@ export const ja = defineLocale({
|
||||
},
|
||||
model: {
|
||||
loading: 'モデル設定を読み込み中...',
|
||||
appliesDesc: '新しいセッションに適用されます。コンポーザーのモデルピッカーを使ってアクティブなチャットをホットスワップできます。',
|
||||
appliesDesc:
|
||||
'新しいセッションに適用されます。コンポーザーのモデルピッカーを使ってアクティブなチャットをホットスワップできます。',
|
||||
provider: 'プロバイダー',
|
||||
model: 'モデル',
|
||||
applying: '適用中...',
|
||||
@@ -1017,7 +1024,8 @@ export const ja = defineLocale({
|
||||
notSet: '未設定',
|
||||
soulDesc: 'このプロファイルに組み込まれたシステムプロンプトとペルソナの指示。',
|
||||
soulOptional: '省略可能',
|
||||
soulPlaceholder: mode => `このプロファイルのシステムプロンプト / ペルソナ。\n空欄のままにすると ${mode} のデフォルトを使用します。`,
|
||||
soulPlaceholder: mode =>
|
||||
`このプロファイルのシステムプロンプト / ペルソナ。\n空欄のままにすると ${mode} のデフォルトを使用します。`,
|
||||
soulPlaceholderCloned: 'クローン済み',
|
||||
soulPlaceholderEmpty: '空',
|
||||
unsavedChanges: '未保存の変更',
|
||||
@@ -1316,14 +1324,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 +1344,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 +1392,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 +1427,8 @@ export const ja = defineLocale({
|
||||
availableBody: '新しいバージョンの Hermes をインストールする準備ができています。',
|
||||
availableTitleBackend: 'バックエンドの更新があります',
|
||||
availableBodyBackend: '接続中の Hermes バックエンドの新しいバージョンをインストールできます。',
|
||||
availableBodyNoChangelog: '新しいバージョンを利用できます。このインストール形式ではリリースノートは表示できません。',
|
||||
availableBodyNoChangelog:
|
||||
'新しいバージョンを利用できます。このインストール形式ではリリースノートは表示できません。',
|
||||
updateNow: '今すぐ更新',
|
||||
maybeLater: '後で',
|
||||
moreChanges: count => `さらに ${count} 件の変更が含まれています。`,
|
||||
@@ -1430,7 +1451,8 @@ export const ja = defineLocale({
|
||||
restarting: 'バックエンドが更新を読み込むため再起動しています…',
|
||||
notAvailable: 'このバックエンドでは更新を利用できません。',
|
||||
failed: 'バックエンドの更新に失敗しました。',
|
||||
noReturn: 'バックエンドがオンラインに戻りませんでした。更新が完了していない可能性があります。バックエンドホストを確認してください。'
|
||||
noReturn:
|
||||
'バックエンドがオンラインに戻りませんでした。更新が完了していない可能性があります。バックエンドホストを確認してください。'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1786,9 +1808,12 @@ export const ja = defineLocale({
|
||||
readAloud: '読み上げ',
|
||||
editMessage: 'メッセージを編集',
|
||||
stop: '停止',
|
||||
editableCheckpoint: '編集可能なチェックポイント',
|
||||
restorePrevious: '前のチェックポイントに戻す',
|
||||
restoreCheckpoint: 'チェックポイントを復元',
|
||||
restoreFromHere: 'チェックポイントを復元 — このプロンプトから再実行',
|
||||
restoreTitle: 'このチェックポイントに復元しますか?',
|
||||
restoreBody: 'このプロンプト以降のメッセージは会話から削除され、ここからプロンプトが再実行されます。',
|
||||
restoreConfirm: '復元して再実行',
|
||||
restoreNext: '次のチェックポイントに戻す',
|
||||
goForward: '進む',
|
||||
sendEdited: '編集済みメッセージを送信',
|
||||
@@ -1814,7 +1839,7 @@ export const ja = defineLocale({
|
||||
loadingQuestion: '質問を読み込み中…',
|
||||
other: 'その他(回答を入力)',
|
||||
placeholder: '回答を入力…',
|
||||
shortcut: '⌘/Ctrl + Enter で送信',
|
||||
shortcutSuffix: ' で送信',
|
||||
back: '戻る',
|
||||
skip: 'スキップ',
|
||||
send: '送信'
|
||||
|
||||
@@ -213,6 +213,8 @@ export interface Translations {
|
||||
colorModeDesc: string
|
||||
toolViewTitle: string
|
||||
toolViewDesc: string
|
||||
translucencyTitle: string
|
||||
translucencyDesc: string
|
||||
product: string
|
||||
productDesc: string
|
||||
technical: string
|
||||
@@ -919,10 +921,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 +953,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 +1326,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 +1356,7 @@ export interface Translations {
|
||||
loadingQuestion: string
|
||||
other: string
|
||||
placeholder: string
|
||||
shortcut: string
|
||||
shortcutSuffix: string
|
||||
back: string
|
||||
skip: string
|
||||
send: string
|
||||
|
||||
@@ -204,6 +204,8 @@ export const zhHant = defineLocale({
|
||||
colorModeDesc: '選擇固定模式,或讓 Hermes 跟隨系統設定。',
|
||||
toolViewTitle: '工具呼叫顯示',
|
||||
toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。',
|
||||
translucencyTitle: '視窗透明',
|
||||
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
|
||||
product: '產品',
|
||||
productDesc: '易讀的工具活動與精簡摘要。',
|
||||
technical: '技術',
|
||||
@@ -503,8 +505,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 +627,7 @@ export const zhHant = defineLocale({
|
||||
sessions: {
|
||||
loading: '正在載入已封存工作階段…',
|
||||
archivedTitle: '已封存工作階段',
|
||||
archivedIntro:
|
||||
'已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。',
|
||||
archivedIntro: '已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。',
|
||||
emptyArchivedTitle: '暫無封存',
|
||||
emptyArchivedDesc: '封存一個聊天後會顯示在這裡。',
|
||||
unarchive: '取消封存',
|
||||
@@ -636,8 +636,7 @@ export const zhHant = defineLocale({
|
||||
restored: '已還原',
|
||||
deleteConfirm: title => `永久刪除「${title}」?此操作無法復原。`,
|
||||
defaultDirTitle: '預設專案目錄',
|
||||
defaultDirDesc:
|
||||
'新工作階段預設從此資料夾開始,除非您選擇其他目錄。留空則使用您的家目錄。',
|
||||
defaultDirDesc: '新工作階段預設從此資料夾開始,除非您選擇其他目錄。留空則使用您的家目錄。',
|
||||
defaultDirUpdated: '預設專案目錄已更新',
|
||||
defaultsTo: label => `預設使用 ${label}。`,
|
||||
change: '變更',
|
||||
@@ -1080,8 +1079,7 @@ export const zhHant = defineLocale({
|
||||
topOfHour: '每個整點',
|
||||
everyHourAt: minute => `每小時的 :${minute}`,
|
||||
newCron: '新排程工作',
|
||||
emptyDescNew:
|
||||
'按 cron 表達式排程一個提示詞。Hermes 會執行它,並將結果傳送至您選擇的目的地。',
|
||||
emptyDescNew: '按 cron 表達式排程一個提示詞。Hermes 會執行它,並將結果傳送至您選擇的目的地。',
|
||||
emptyDescSearch: '請嘗試更廣泛的搜尋詞。',
|
||||
emptyTitleNew: '暫無排程工作',
|
||||
emptyTitleSearch: '無相符項目',
|
||||
@@ -1282,14 +1280,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 +1300,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 +1348,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 +1430,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 +1496,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 +1714,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 +1752,12 @@ export const zhHant = defineLocale({
|
||||
readAloud: '朗讀',
|
||||
editMessage: '編輯訊息',
|
||||
stop: '停止',
|
||||
editableCheckpoint: '可編輯的檢查點',
|
||||
restorePrevious: '還原至上一個檢查點',
|
||||
restoreCheckpoint: '還原檢查點',
|
||||
restoreFromHere: '還原檢查點 — 從此提示重新執行',
|
||||
restoreTitle: '還原至此檢查點?',
|
||||
restoreBody: '此提示之後的所有訊息將從對話中移除,並從此處重新執行該提示。',
|
||||
restoreConfirm: '還原並重新執行',
|
||||
restoreNext: '還原至下一個檢查點',
|
||||
goForward: '前進',
|
||||
sendEdited: '傳送編輯後的訊息',
|
||||
@@ -1775,7 +1783,7 @@ export const zhHant = defineLocale({
|
||||
loadingQuestion: '正在載入問題…',
|
||||
other: '其他(輸入您的答案)',
|
||||
placeholder: '輸入您的答案…',
|
||||
shortcut: '⌘/Ctrl + Enter 傳送',
|
||||
shortcutSuffix: ' 傳送',
|
||||
back: '返回',
|
||||
skip: '略過',
|
||||
send: '傳送'
|
||||
@@ -1833,8 +1841,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}。`,
|
||||
|
||||
@@ -291,6 +291,8 @@ export const zh: Translations = {
|
||||
colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。',
|
||||
toolViewTitle: '工具调用显示',
|
||||
toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。',
|
||||
translucencyTitle: '窗口透明',
|
||||
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
|
||||
product: '产品',
|
||||
productDesc: '易读的工具活动与简洁摘要。',
|
||||
technical: '技术',
|
||||
@@ -1018,13 +1020,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 +1374,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 +1394,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 +1442,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 +1847,12 @@ export const zh: Translations = {
|
||||
readAloud: '朗读',
|
||||
editMessage: '编辑消息',
|
||||
stop: '停止',
|
||||
editableCheckpoint: '可编辑检查点',
|
||||
restorePrevious: '恢复上一个检查点',
|
||||
restoreCheckpoint: '恢复检查点',
|
||||
restoreFromHere: '恢复检查点 — 从此提示重新运行',
|
||||
restoreTitle: '恢复到此检查点?',
|
||||
restoreBody: '此提示之后的所有消息将从对话中移除,并从此处重新运行该提示。',
|
||||
restoreConfirm: '恢复并重新运行',
|
||||
restoreNext: '恢复下一个检查点',
|
||||
goForward: '前进',
|
||||
sendEdited: '发送编辑后的消息',
|
||||
@@ -1860,7 +1878,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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import './styles.css'
|
||||
// Side-effect: applies the persisted window translucency on load.
|
||||
import './store/translucency'
|
||||
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { StrictMode } from 'react'
|
||||
|
||||
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)
|
||||
}
|
||||
38
apps/desktop/src/store/translucency.ts
Normal file
38
apps/desktop/src/store/translucency.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Window translucency (see-through window).
|
||||
*
|
||||
* One lever, 0–100. 0 = off (fully opaque, the default). Higher = more of the
|
||||
* desktop shows through the whole window — the main process maps it to the
|
||||
* native window opacity (`setOpacity`), the same effect as the Windows
|
||||
* shift-scroll trick. macOS + Windows only; Linux has no runtime window
|
||||
* opacity, so it's a no-op there.
|
||||
*
|
||||
* The renderer owns the value and mirrors it to the main process over IPC.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
|
||||
const KEY = 'hermes.desktop.translucency.v1'
|
||||
|
||||
const clamp = (n: number): number => Math.min(100, Math.max(0, Math.round(n)))
|
||||
|
||||
const read = (): number => {
|
||||
const n = Number(storedString(KEY))
|
||||
|
||||
return Number.isFinite(n) ? clamp(n) : 0
|
||||
}
|
||||
|
||||
export const $translucency = atom<number>(typeof window === 'undefined' ? 0 : read())
|
||||
|
||||
export function setTranslucency(intensity: number): void {
|
||||
$translucency.set(clamp(intensity))
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
$translucency.subscribe(intensity => {
|
||||
persistString(KEY, String(intensity))
|
||||
window.hermesDesktop?.setTranslucency?.({ intensity })
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -212,7 +212,7 @@ terminal:
|
||||
# cwd: "/workspace" # Path INSIDE the container (default: /)
|
||||
# timeout: 180
|
||||
# lifetime_seconds: 300
|
||||
# docker_image: "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
# docker_image: "nikolaik/python-nodejs:python3.11-nodejs26"
|
||||
# docker_mount_cwd_to_workspace: true # Explicit opt-in: mount your launch cwd into /workspace
|
||||
# # Optional: run the container as your host user's uid:gid so files written
|
||||
# # into bind-mounted dirs are owned by you, not root. Drops SETUID/SETGID
|
||||
@@ -242,7 +242,7 @@ terminal:
|
||||
# cwd: "/workspace" # Path INSIDE the container (default: /root)
|
||||
# timeout: 180
|
||||
# lifetime_seconds: 300
|
||||
# singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
# singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs26"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTION 5: Modal cloud execution
|
||||
@@ -254,7 +254,7 @@ terminal:
|
||||
# cwd: "/workspace" # Path INSIDE the sandbox (default: /root)
|
||||
# timeout: 180
|
||||
# lifetime_seconds: 300
|
||||
# modal_image: "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
# modal_image: "nikolaik/python-nodejs:python3.11-nodejs26"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTION 6: Daytona cloud execution
|
||||
@@ -267,7 +267,7 @@ terminal:
|
||||
# cwd: "~"
|
||||
# timeout: 180
|
||||
# lifetime_seconds: 300
|
||||
# daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
# daytona_image: "nikolaik/python-nodejs:python3.11-nodejs26"
|
||||
# container_disk: 10240 # Daytona max is 10GB per sandbox
|
||||
|
||||
#
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -22,11 +22,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1775036866,
|
||||
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
|
||||
"lastModified": 1780749050,
|
||||
"narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
|
||||
"rev": "a799d3e3886da994fa307f817a6bc705ae538eeb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -7459,7 +7459,55 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
|
||||
return resolve_uv() or shutil.which("uv")
|
||||
|
||||
|
||||
def _ensure_managed_node() -> None:
|
||||
"""Ensure Hermes-managed Node.js matches .nvmrc, updating if necessary."""
|
||||
nvmrc_path = PROJECT_ROOT / ".nvmrc"
|
||||
if not nvmrc_path.exists():
|
||||
return # Let system node handle it
|
||||
|
||||
target_version = nvmrc_path.read_text().strip()
|
||||
if not target_version:
|
||||
return
|
||||
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
managed_node = hermes_home / "node" / "bin" / "node"
|
||||
|
||||
if managed_node.exists():
|
||||
try:
|
||||
current_version = subprocess.check_output(
|
||||
[str(managed_node), "--version"], text=True
|
||||
).strip().lstrip("v")
|
||||
if current_version == target_version:
|
||||
return # Already up to date
|
||||
except Exception:
|
||||
pass # Fall through to reinstall
|
||||
|
||||
bootstrap_script = PROJECT_ROOT / "scripts" / "lib" / "node-bootstrap.sh"
|
||||
if not bootstrap_script.exists():
|
||||
return
|
||||
|
||||
print(f"→ Updating Hermes-managed Node.js to {target_version}...")
|
||||
env = {**os.environ, "HERMES_HOME": str(hermes_home)}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{bootstrap_script}" && ensure_node'],
|
||||
env=env,
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(f" ✓ Node.js updated to {target_version}")
|
||||
else:
|
||||
print(f" ⚠ Node.js update failed (non-fatal): {result.stderr.strip()}")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Node.js update failed (non-fatal): {e}")
|
||||
|
||||
|
||||
def _update_node_dependencies() -> None:
|
||||
# Ensure managed Node.js matches .nvmrc before running npm
|
||||
_ensure_managed_node()
|
||||
|
||||
npm = shutil.which("npm")
|
||||
if not npm:
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
inputsFrom = packages;
|
||||
packages = with pkgs; [
|
||||
uv
|
||||
nodejs_26
|
||||
];
|
||||
shellHook = ''
|
||||
echo "Hermes Agent dev shell"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
makeWrapper,
|
||||
callPackage,
|
||||
python312,
|
||||
nodejs_22,
|
||||
nodejs_26,
|
||||
electron,
|
||||
ripgrep,
|
||||
git,
|
||||
@@ -36,7 +36,7 @@
|
||||
extraDependencyGroups ? [ ],
|
||||
}:
|
||||
let
|
||||
nodejs = nodejs_22;
|
||||
nodejs = nodejs_26;
|
||||
hermesVenv = callPackage ./python.nix {
|
||||
inherit uv2nix pyproject-nix pyproject-build-systems;
|
||||
dependency-groups = [ "all" ] ++ extraDependencyGroups;
|
||||
|
||||
@@ -21,7 +21,7 @@ let
|
||||
|
||||
# Single npm deps fetch from the workspace root lockfile.
|
||||
# All workspace packages share this derivation.
|
||||
npmDepsHash = "sha256-BfTSh6J2VZ/07tq2DYnKgUViZCgRhW1sC2uj18H65SE=";
|
||||
npmDepsHash = "sha256-+MqqcCsnUSMbsAooGKRx+qD/r/kPZG0Y8rfHuFGlHSU=";
|
||||
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
|
||||
@@ -130,7 +130,7 @@ docker build --no-cache -t my-app . # clean rebuild
|
||||
DOCKER_BUILDKIT=1 docker build -t my-app . # faster with BuildKit
|
||||
|
||||
# Pull and push
|
||||
docker pull node:20-alpine
|
||||
docker pull node:26-alpine
|
||||
docker login ghcr.io
|
||||
docker tag my-app:latest registry/my-app:v1.0
|
||||
docker push registry/my-app:v1.0
|
||||
@@ -276,6 +276,6 @@ When reviewing or creating a Dockerfile, suggest these improvements:
|
||||
2. **Layer ordering** — put dependencies before source code so changes don't invalidate cached layers
|
||||
3. **Combine RUN commands** — fewer layers, smaller image
|
||||
4. **Use .dockerignore** — exclude `node_modules`, `.git`, `__pycache__`, etc.
|
||||
5. **Pin base image versions** — `node:20-alpine` not `node:latest`
|
||||
5. **Pin base image versions** — `node:26-alpine` not `node:latest`
|
||||
6. **Run as non-root** — add `USER` instruction for security
|
||||
7. **Use slim/alpine bases** — `python:3.12-slim` not `python:3.12`
|
||||
|
||||
273
package-lock.json
generated
273
package-lock.json
generated
@@ -20,7 +20,8 @@
|
||||
"agent-browser": "^0.26.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=26.0.0 <27.0.0",
|
||||
"npm": ">=11.0.0 <12.0.0"
|
||||
}
|
||||
},
|
||||
"apps/bootstrap-installer": {
|
||||
@@ -160,7 +161,7 @@
|
||||
"wait-on": "^9.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
"node": ">=26.0.0"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/@nous-research/ui": {
|
||||
@@ -409,17 +410,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"apps/desktop/node_modules/vite": {
|
||||
"version": "8.0.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
||||
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.10",
|
||||
"rolldown": "1.0.0-rc.17",
|
||||
"tinyglobby": "^0.2.16"
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.3",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
@@ -435,7 +436,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"@vitejs/devtools": "^0.1.18",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -1716,6 +1717,40 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
@@ -2728,14 +2763,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
"@tybys/wasm-util": "^0.10.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -2805,9 +2840,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.127.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
|
||||
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -6449,9 +6484,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -6466,9 +6501,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -6483,9 +6518,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -6500,9 +6535,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -6517,9 +6552,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -6534,13 +6569,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6551,13 +6589,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6568,13 +6609,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6585,13 +6629,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6602,13 +6649,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6619,13 +6669,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6636,9 +6689,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -6653,9 +6706,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -6672,9 +6725,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -6689,9 +6742,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -7996,9 +8049,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -17140,9 +17193,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.13",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -17159,7 +17212,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -18416,14 +18469,14 @@
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.127.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.17"
|
||||
"@oxc-project/types": "=0.133.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -18432,27 +18485,27 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
|
||||
"@rolldown/binding-android-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -19515,9 +19568,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@@ -19705,7 +19758,6 @@
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19722,7 +19774,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19739,7 +19790,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19756,7 +19806,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19773,7 +19822,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19790,7 +19838,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19807,7 +19854,6 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19824,7 +19870,6 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19841,7 +19886,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19858,7 +19902,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19875,7 +19918,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19892,7 +19934,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19909,7 +19950,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19926,7 +19966,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19943,7 +19982,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19960,7 +19998,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19977,7 +20014,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19994,7 +20030,6 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20011,7 +20046,6 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20028,7 +20062,6 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20045,7 +20078,6 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20062,7 +20094,6 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20079,7 +20110,6 @@
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20096,7 +20126,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20113,7 +20142,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20130,7 +20158,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20772,9 +20799,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"version": "7.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
|
||||
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
|
||||
12
package.json
12
package.json
@@ -39,6 +39,16 @@
|
||||
"lodash": "4.18.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=26.0.0 <27.0.0",
|
||||
"npm": ">=11.0.0 <12.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"agent-browser@0.26.0": true,
|
||||
"electron@40.9.3": true,
|
||||
"electron-winstaller@5.4.0": true,
|
||||
"esbuild@0.27.7": true,
|
||||
"esbuild@0.28.0": true,
|
||||
"node-pty@1.1.0": true,
|
||||
"unicode-animations": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ try {
|
||||
$RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git"
|
||||
$RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git"
|
||||
$PythonVersion = "3.11"
|
||||
$NodeVersion = "22"
|
||||
$NodeVersion = "26"
|
||||
|
||||
# Stage-protocol version. Bumped only for genuinely breaking changes to the
|
||||
# manifest schema, stage-name set semantics, or stdout JSON shape. Adding a
|
||||
|
||||
@@ -57,7 +57,7 @@ else
|
||||
INSTALL_DIR_EXPLICIT=false
|
||||
fi
|
||||
PYTHON_VERSION="3.11"
|
||||
NODE_VERSION="22"
|
||||
NODE_VERSION="26"
|
||||
|
||||
# FHS-style root install layout (set by resolve_install_layout when applicable):
|
||||
# code at /usr/local/lib/hermes-agent, command at /usr/local/bin/hermes,
|
||||
@@ -703,7 +703,7 @@ check_git() {
|
||||
}
|
||||
|
||||
# The desktop build runs Vite ^8, which refuses to start on Node outside
|
||||
# `^20.19 || >=22.12` — older Node lacks `node:util.styleText`, so `vite build`
|
||||
# `>=26.0.0` — older Node lacks the required features, so `vite build`
|
||||
# crashes with a SyntaxError that surfaces only as the opaque "Build desktop
|
||||
# app … exit code 1" install failure. Returns 0 when the given `node --version`
|
||||
# string clears that floor; anything below it is replaced with the Hermes-
|
||||
@@ -737,7 +737,7 @@ check_node() {
|
||||
fi
|
||||
|
||||
if command -v node &> /dev/null; then
|
||||
log_warn "Node.js $(node --version) is too old for the desktop build (need ^20.19 or >=22.12) — installing Hermes-managed Node $NODE_VERSION LTS..."
|
||||
log_warn "Node.js $(node --version) is too old for the desktop build (need >=26.0.0) — installing Hermes-managed Node $NODE_VERSION LTS..."
|
||||
elif [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Node.js not found — installing Node.js via pkg..."
|
||||
else
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
# ============================================================================
|
||||
# scripts/lib/node-bootstrap.sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Sourceable helper: ensure Node.js >= MIN_VERSION is available for the TUI
|
||||
# (React + Ink), browser tools, and the WhatsApp bridge.
|
||||
# Sourceable helper: ensure Node.js matching .nvmrc (or default 26.3.0) is
|
||||
# available for the TUI, browser tools, and the WhatsApp bridge.
|
||||
#
|
||||
# Strategy (first hit wins — respects the user's existing tooling):
|
||||
# 1. modern `node` already on PATH
|
||||
# 2. ~/.hermes/node/ from a prior Hermes-managed install
|
||||
# 3. fnm, proto, nvm (in that order) if the user already uses a version manager
|
||||
# 4. Termux `pkg`, macOS Homebrew
|
||||
# 5. pinned nodejs.org tarball into ~/.hermes/node/ (always works, zero shell rc edits)
|
||||
# Strategy (strict order):
|
||||
# 1. ~/.hermes/node/ (Hermes-managed, auto-upgrades if .nvmrc changes)
|
||||
# 2. `node` already on PATH (fallback, validated against package.json engines)
|
||||
# 3. Termux `pkg install nodejs` (fallback for Termux)
|
||||
#
|
||||
# Usage:
|
||||
# source scripts/lib/node-bootstrap.sh
|
||||
@@ -18,20 +16,24 @@
|
||||
# if [ "$HERMES_NODE_AVAILABLE" = true ]; then ...; fi
|
||||
#
|
||||
# Env inputs (set before sourcing to override defaults):
|
||||
# HERMES_NODE_MIN_VERSION (default: 20) — accepted on PATH
|
||||
# HERMES_NODE_TARGET_MAJOR (default: 22) — installed when we install
|
||||
# HERMES_HOME (default: $HOME/.hermes)
|
||||
# HERMES_NODE_TARGET_VERSION (default: read from .nvmrc, fallback 26.3.0)
|
||||
# HERMES_HOME (default: $HOME/.hermes)
|
||||
# ============================================================================
|
||||
|
||||
HERMES_NODE_MIN_VERSION="${HERMES_NODE_MIN_VERSION:-20}"
|
||||
HERMES_NODE_TARGET_MAJOR="${HERMES_NODE_TARGET_MAJOR:-22}"
|
||||
# Read target version from .nvmrc if present, otherwise default to 26.3.0
|
||||
if [ -f ".nvmrc" ]; then
|
||||
HERMES_NODE_TARGET_VERSION=$(cat .nvmrc | tr -d '[:space:]')
|
||||
else
|
||||
HERMES_NODE_TARGET_VERSION="${HERMES_NODE_TARGET_VERSION:-26.3.0}"
|
||||
fi
|
||||
# Extract major version for fallback compatibility checks
|
||||
HERMES_NODE_TARGET_MAJOR="${HERMES_NODE_TARGET_VERSION%%.*}"
|
||||
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
|
||||
HERMES_NODE_AVAILABLE=false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging — prefer the host script's log_* helpers when present
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_log() { declare -F log_info >/dev/null 2>&1 && log_info "$*" || printf '→ %s\n' "$*" >&2; }
|
||||
_nb_ok() { declare -F log_success >/dev/null 2>&1 && log_success "$*" || printf '✓ %s\n' "$*" >&2; }
|
||||
_nb_warn() { declare -F log_warn >/dev/null 2>&1 && log_warn "$*" || printf '⚠ %s\n' "$*" >&2; }
|
||||
@@ -39,103 +41,53 @@ _nb_warn() { declare -F log_warn >/dev/null 2>&1 && log_warn "$*" || print
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform + version helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_is_termux() {
|
||||
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
|
||||
}
|
||||
|
||||
# Where to symlink node/npm/npx so they land on PATH.
|
||||
# Mirrors get_command_link_dir() from install.sh: root FHS → /usr/local/bin,
|
||||
# Termux → $PREFIX/bin, otherwise ~/.local/bin.
|
||||
_nb_get_link_dir() {
|
||||
if _nb_is_termux && [ -n "${PREFIX:-}" ]; then
|
||||
echo "$PREFIX/bin"
|
||||
elif [ "$(id -u)" = 0 ] && [ "$(uname -s)" = "Linux" ]; then
|
||||
echo "/usr/local/bin"
|
||||
else
|
||||
echo "$HOME/.local/bin"
|
||||
fi
|
||||
}
|
||||
|
||||
_nb_node_major() {
|
||||
local v
|
||||
v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)
|
||||
[[ "$v" =~ ^[0-9]+$ ]] && echo "$v" || echo 0
|
||||
}
|
||||
|
||||
# Dynamically read the minimum required Node major version from package.json's engines field.
|
||||
# Falls back to HERMES_NODE_TARGET_MAJOR if package.json is missing or unreadable.
|
||||
_nb_get_engines_min_major() {
|
||||
local pkg_json="package.json"
|
||||
if [ ! -f "$pkg_json" ] && [ -f "../package.json" ]; then
|
||||
pkg_json="../package.json"
|
||||
fi
|
||||
|
||||
if [ -f "$pkg_json" ]; then
|
||||
# Extract the first number from the "node" engine string (e.g., ">=26.0.0" -> 26)
|
||||
# This avoids matching "node_modules" or other fields by requiring the specific JSON structure.
|
||||
local min_major
|
||||
min_major=$(grep -E '"node"[[:space:]]*:[[:space:]]*"[^"]*"' "$pkg_json" 2>/dev/null | grep -o '[0-9]\+' | head -1)
|
||||
if [[ "$min_major" =~ ^[0-9]+$ ]]; then
|
||||
echo "$min_major"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback to target major version if parsing fails
|
||||
echo "${HERMES_NODE_TARGET_MAJOR}"
|
||||
}
|
||||
|
||||
_nb_have_modern_node() {
|
||||
command -v node >/dev/null 2>&1 || return 1
|
||||
[ "$(_nb_node_major)" -ge "$HERMES_NODE_MIN_VERSION" ]
|
||||
local current_major
|
||||
current_major=$(_nb_node_major)
|
||||
local required_major
|
||||
required_major=$(_nb_get_engines_min_major)
|
||||
|
||||
[ "$current_major" -ge "$required_major" ]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version-manager paths — respect what the user already uses
|
||||
# Hermes-managed Node.js installation (fallback to nodejs.org tarball)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_try_fnm() {
|
||||
command -v fnm >/dev/null 2>&1 || return 1
|
||||
_nb_log "fnm detected — installing Node $HERMES_NODE_TARGET_MAJOR..."
|
||||
eval "$(fnm env 2>/dev/null)" || true
|
||||
fnm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
fnm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) activated via fnm"
|
||||
return 0
|
||||
}
|
||||
|
||||
_nb_try_proto() {
|
||||
command -v proto >/dev/null 2>&1 || return 1
|
||||
_nb_log "proto detected — installing Node $HERMES_NODE_TARGET_MAJOR..."
|
||||
proto install node "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) activated via proto"
|
||||
return 0
|
||||
}
|
||||
|
||||
_nb_try_nvm() {
|
||||
local nvm_sh="${NVM_DIR:-$HOME/.nvm}/nvm.sh"
|
||||
[ -s "$nvm_sh" ] || return 1
|
||||
# shellcheck source=/dev/null
|
||||
\. "$nvm_sh" >/dev/null 2>&1 || return 1
|
||||
_nb_log "nvm detected — installing Node $HERMES_NODE_TARGET_MAJOR..."
|
||||
nvm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
nvm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) activated via nvm"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform package managers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_try_termux_pkg() {
|
||||
_nb_is_termux || return 1
|
||||
_nb_log "Installing Node.js via pkg..."
|
||||
pkg install -y nodejs >/dev/null 2>&1 || return 1
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) installed via pkg"
|
||||
return 0
|
||||
}
|
||||
|
||||
_nb_try_brew() {
|
||||
[ "$(uname -s)" = "Darwin" ] || return 1
|
||||
command -v brew >/dev/null 2>&1 || return 1
|
||||
_nb_log "Installing Node via Homebrew..."
|
||||
brew install "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 \
|
||||
|| brew install node >/dev/null 2>&1 \
|
||||
|| return 1
|
||||
brew link --overwrite --force "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 || true
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) installed via Homebrew"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bundled binary fallback — always works, no shell rc edits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_install_bundled_node() {
|
||||
_nb_install_hermes_node() {
|
||||
local arch node_arch os_name node_os
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
@@ -143,7 +95,7 @@ _nb_install_bundled_node() {
|
||||
aarch64|arm64) node_arch="arm64" ;;
|
||||
armv7l) node_arch="armv7l" ;;
|
||||
*)
|
||||
_nb_warn "Unsupported arch ($arch) — install Node.js manually: https://nodejs.org/"
|
||||
_nb_warn "Unsupported arch ($arch) for Hermes-managed Node.js"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
@@ -153,7 +105,7 @@ _nb_install_bundled_node() {
|
||||
Linux*) node_os="linux" ;;
|
||||
Darwin*) node_os="darwin" ;;
|
||||
*)
|
||||
_nb_warn "Unsupported OS ($os_name) — install Node.js manually: https://nodejs.org/"
|
||||
_nb_warn "Unsupported OS ($os_name) for Hermes-managed Node.js"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
@@ -169,7 +121,7 @@ _nb_install_bundled_node() {
|
||||
| head -1)
|
||||
fi
|
||||
if [ -z "$tarball" ]; then
|
||||
_nb_warn "Could not resolve Node $HERMES_NODE_TARGET_MAJOR binary for $node_os-$node_arch"
|
||||
_nb_warn "Could not resolve Node $HERMES_NODE_TARGET_VERSION binary for $node_os-$node_arch"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -200,54 +152,88 @@ _nb_install_bundled_node() {
|
||||
mv "$extracted" "$HERMES_HOME/node"
|
||||
rm -rf "$tmp"
|
||||
|
||||
local _link_dir
|
||||
_link_dir="$(_nb_get_link_dir)"
|
||||
# Symlink to standard bin dir so it's on PATH for other tools
|
||||
local _link_dir="$HOME/.local/bin"
|
||||
if _nb_is_termux && [ -n "${PREFIX:-}" ]; then
|
||||
_link_dir="$PREFIX/bin"
|
||||
elif [ "$(id -u)" = 0 ] && [ "$os_name" = "Linux" ]; then
|
||||
_link_dir="/usr/local/bin"
|
||||
fi
|
||||
|
||||
mkdir -p "$_link_dir"
|
||||
ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node"
|
||||
ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm"
|
||||
ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx"
|
||||
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
|
||||
_nb_have_modern_node || return 1
|
||||
if ! _nb_have_modern_node; then
|
||||
_nb_warn "Installed Node.js version check failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_nb_ok "Node $(node --version) installed to $HERMES_HOME/node/"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Termux pkg fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
_nb_try_termux_pkg() {
|
||||
_nb_is_termux || return 1
|
||||
_nb_log "Installing Node.js via pkg..."
|
||||
pkg install -y nodejs >/dev/null 2>&1 || return 1
|
||||
if _nb_have_modern_node; then
|
||||
_nb_ok "Node $(node --version) installed via pkg"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ensure_node() {
|
||||
HERMES_NODE_AVAILABLE=false
|
||||
|
||||
if _nb_have_modern_node; then
|
||||
_nb_ok "Node $(node --version) found"
|
||||
HERMES_NODE_AVAILABLE=true
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 1. Hermes-managed Node.js (~/.hermes/node/)
|
||||
if [ -x "$HERMES_HOME/node/bin/node" ]; then
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
if _nb_have_modern_node; then
|
||||
local managed_ver
|
||||
managed_ver=$("$HERMES_HOME/node/bin/node" --version 2>/dev/null | sed 's/^v//')
|
||||
if [ "$managed_ver" = "$HERMES_NODE_TARGET_VERSION" ]; then
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
_nb_ok "Node $(node --version) found (Hermes-managed)"
|
||||
HERMES_NODE_AVAILABLE=true
|
||||
return 0
|
||||
else
|
||||
_nb_log "Managed Node.js is $managed_ver, but .nvmrc requires $HERMES_NODE_TARGET_VERSION. Updating..."
|
||||
if _nb_install_hermes_node; then
|
||||
HERMES_NODE_AVAILABLE=true
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# Not installed yet, try to install it first
|
||||
if _nb_install_hermes_node; then
|
||||
HERMES_NODE_AVAILABLE=true
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Version managers first — respect the user's existing setup.
|
||||
_nb_try_fnm && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
_nb_try_proto && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
_nb_try_nvm && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
# 2. Node already on PATH (validated against package.json engines)
|
||||
if _nb_have_modern_node; then
|
||||
_nb_ok "Node $(node --version) found on PATH (meets package.json engines requirement)"
|
||||
HERMES_NODE_AVAILABLE=true
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Platform package managers.
|
||||
_nb_try_termux_pkg && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
_nb_try_brew && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
|
||||
# Last resort: pinned nodejs.org tarball.
|
||||
_nb_install_bundled_node && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
# 3. Termux pkg (fallback)
|
||||
if _nb_try_termux_pkg; then
|
||||
HERMES_NODE_AVAILABLE=true
|
||||
return 0
|
||||
fi
|
||||
|
||||
_nb_warn "Node.js install failed — TUI and browser tools will be unavailable."
|
||||
_nb_warn "Install manually: https://nodejs.org/en/download/ (or: \`brew install node\`, \`fnm install $HERMES_NODE_TARGET_MAJOR\`, etc.)"
|
||||
_nb_warn "Install manually: https://nodejs.org/en/download/"
|
||||
return 1
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user