opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config); Ink stays the default and the shipping path is untouched. - _resolve_tui_engine() (env > config > ink); refuses opentui on Windows/Termux (no Bun) -> falls back to ink with a notice. - _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step). - _bun_bin() with HERMES_BUN override. - Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host must not bootstrap Node). - Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun is JSC; the V8 flag errors/ignores). Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI -> real Python gateway streamed a real reply. No-flag default still ink.
@@ -11,8 +11,7 @@
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:build:debug": "tauri build --debug",
|
||||
"typecheck": "tsc -p . --noEmit"
|
||||
"tauri:build:debug": "tauri build --debug"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.16.0",
|
||||
@@ -41,7 +40,7 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
|
||||
@@ -93,7 +93,7 @@ Run before opening a PR (lint may surface pre-existing warnings but must exit cl
|
||||
|
||||
```bash
|
||||
npm run fix
|
||||
npm run typecheck
|
||||
npm run type-check
|
||||
npm run lint
|
||||
npm run test:desktop:all
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 561 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 561 KiB After Width: | Height: | Size: 674 KiB |
@@ -40,15 +40,6 @@ const path = require('node:path')
|
||||
const https = require('node:https')
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
function hiddenWindowsChildOptions(options = {}) {
|
||||
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options
|
||||
}
|
||||
return { ...options, windowsHide: true }
|
||||
}
|
||||
|
||||
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
||||
|
||||
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
||||
@@ -293,7 +284,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
|
||||
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
||||
|
||||
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
|
||||
const child = spawn(ps, fullArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -301,7 +292,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
// choice rather than re-computing the default.
|
||||
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Helpers for local dashboard session-token discovery.
|
||||
*
|
||||
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
|
||||
* spawns the local dashboard, but the dashboard is the source of truth for the
|
||||
* token it actually serves to the renderer. If those drift, HTTP readiness
|
||||
* probes still pass while /api/ws rejects the renderer's token.
|
||||
*/
|
||||
|
||||
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
|
||||
|
||||
async function fetchPublicText(url, options = {}) {
|
||||
const { protocol } = new URL(url)
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
|
||||
}
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
|
||||
if (error.name === 'TimeoutError') {
|
||||
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
const text = await res.text()
|
||||
|
||||
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function extractInjectedDashboardToken(html) {
|
||||
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
|
||||
if (!match) return null
|
||||
try {
|
||||
return JSON.parse(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardIndexUrl(baseUrl) {
|
||||
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
|
||||
}
|
||||
|
||||
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
|
||||
const fetchText = options.fetchText || fetchPublicText
|
||||
const html = await fetchText(dashboardIndexUrl(baseUrl), {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
})
|
||||
const servedToken = extractInjectedDashboardToken(html)
|
||||
|
||||
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
|
||||
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
|
||||
}
|
||||
|
||||
return servedToken || fallbackToken
|
||||
}
|
||||
|
||||
/**
|
||||
* A served token that differs from our spawn token while our child is DEAD
|
||||
* came from a process we did not spawn (orphan/port squatter that satisfied
|
||||
* the public /api/status readiness probe). With a live child the mismatch is
|
||||
* benign: our own backend regenerated the token because the env pin did not
|
||||
* survive the spawn.
|
||||
*/
|
||||
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
|
||||
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the token the backend actually serves, adopting benign drift and
|
||||
* failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
|
||||
* sampled after the fetch, not before.
|
||||
*/
|
||||
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
|
||||
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
|
||||
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
|
||||
return spawnToken
|
||||
})
|
||||
|
||||
if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
|
||||
throw new Error(
|
||||
`${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
|
||||
)
|
||||
}
|
||||
|
||||
return servedToken
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/dashboard-token.cjs.
|
||||
*
|
||||
* Run with: node --test electron/dashboard-token.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
} = require('./dashboard-token.cjs')
|
||||
|
||||
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served-token')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken handles escaped token strings', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
|
||||
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
|
||||
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
|
||||
})
|
||||
|
||||
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
|
||||
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
|
||||
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
|
||||
const logs = []
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async url => {
|
||||
assert.equal(url, 'http://127.0.0.1:9120/')
|
||||
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /served a different session token/)
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => '<html></html>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when no served token is present')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when token already matches')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'same-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
}),
|
||||
/boom/
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchPublicText rejects unsupported protocols', async () => {
|
||||
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
|
||||
})
|
||||
|
||||
test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
|
||||
const cases = [
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
|
||||
// Live child + drift = our backend regenerated the token (env pin lost).
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
|
||||
]
|
||||
for (const [input, expected] of cases) {
|
||||
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
|
||||
}
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken adopts drift from a live child', async () => {
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => false,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
|
||||
label: 'Hermes backend for profile "work"'
|
||||
}),
|
||||
/profile "work".*process we did not spawn/
|
||||
)
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
|
||||
const logs = []
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveDirectoryForIpc } = require('./hardening.cjs')
|
||||
|
||||
const FS_READDIR_STAT_CONCURRENCY = 16
|
||||
|
||||
// Always-hidden noise (covers non-git projects too; gitignore catches many of
|
||||
// these, but the project tree should keep the same hygiene without one).
|
||||
const FS_READDIR_HIDDEN = new Set([
|
||||
'.git',
|
||||
'.hg',
|
||||
'.svn',
|
||||
'.cache',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'build',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'target',
|
||||
'venv'
|
||||
])
|
||||
|
||||
function direntIsDirectory(dirent) {
|
||||
return typeof dirent.isDirectory === 'function' && dirent.isDirectory()
|
||||
}
|
||||
|
||||
function direntIsFile(dirent) {
|
||||
return typeof dirent.isFile === 'function' && dirent.isFile()
|
||||
}
|
||||
|
||||
function direntIsSymbolicLink(dirent) {
|
||||
return typeof dirent.isSymbolicLink === 'function' && dirent.isSymbolicLink()
|
||||
}
|
||||
|
||||
function shouldStatDirent(dirent) {
|
||||
if (direntIsDirectory(dirent)) return false
|
||||
|
||||
return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
|
||||
}
|
||||
|
||||
async function entryForDirent(dirent, resolved, fsImpl) {
|
||||
const fullPath = path.join(resolved, dirent.name)
|
||||
let isDirectory = direntIsDirectory(dirent)
|
||||
|
||||
if (!isDirectory && shouldStatDirent(dirent)) {
|
||||
try {
|
||||
isDirectory = (await fsImpl.promises.stat(fullPath)).isDirectory()
|
||||
} catch {
|
||||
isDirectory = false
|
||||
}
|
||||
}
|
||||
|
||||
return { name: dirent.name, path: fullPath, isDirectory }
|
||||
}
|
||||
|
||||
async function mapWithStatConcurrency(items, mapper) {
|
||||
const results = new Array(items.length)
|
||||
let nextIndex = 0
|
||||
|
||||
async function runWorker() {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
results[index] = await mapper(items[index])
|
||||
}
|
||||
}
|
||||
|
||||
const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
|
||||
const workers = Array.from({ length: workerCount }, () => runWorker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function readDirForIpc(dirPath, options = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
let resolved
|
||||
|
||||
try {
|
||||
;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, {
|
||||
fs: fsImpl,
|
||||
purpose: 'Directory read'
|
||||
}))
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
|
||||
try {
|
||||
const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true })
|
||||
const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name))
|
||||
const entries = await mapWithStatConcurrency(visibleDirents, dirent =>
|
||||
entryForDirent(dirent, resolved, fsImpl)
|
||||
)
|
||||
|
||||
entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
|
||||
|
||||
return { entries }
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readDirForIpc
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
|
||||
}
|
||||
|
||||
function fakeDirent(name, flags = {}) {
|
||||
return {
|
||||
name,
|
||||
isDirectory: () => Boolean(flags.directory),
|
||||
isFile: () => Boolean(flags.file),
|
||||
isSymbolicLink: () => Boolean(flags.symlink)
|
||||
}
|
||||
}
|
||||
|
||||
test('readDirForIpc hides noisy directories and files from the project tree', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'node_modules'))
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'target'), 'hidden file')
|
||||
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['src', 'README.md']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc filters a hidden basename whether it is a file or directory', async () => {
|
||||
const dirRoot = mkTmpDir()
|
||||
const fileRoot = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(dirRoot, 'node_modules'))
|
||||
fs.writeFileSync(path.join(dirRoot, 'visible.txt'), 'visible')
|
||||
fs.writeFileSync(path.join(fileRoot, 'node_modules'), 'hidden file')
|
||||
fs.writeFileSync(path.join(fileRoot, 'visible.txt'), 'visible')
|
||||
|
||||
assert.deepEqual(
|
||||
(await readDirForIpc(dirRoot)).entries.map(entry => entry.name),
|
||||
['visible.txt']
|
||||
)
|
||||
assert.deepEqual(
|
||||
(await readDirForIpc(fileRoot)).entries.map(entry => entry.name),
|
||||
['visible.txt']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(dirRoot, { recursive: true, force: true })
|
||||
fs.rmSync(fileRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc returns directories before files and sorts by name within groups', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(root, 'z.txt'), 'z')
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'a.txt'), 'a')
|
||||
fs.mkdirSync(path.join(root, 'lib'))
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['lib', 'src', 'a.txt', 'z.txt']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc accepts file URLs for directories', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
|
||||
|
||||
const result = await readDirForIpc(pathToFileURL(root).toString())
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['src', 'README.md']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
|
||||
let readdirCalls = 0
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(await readDirForIpc('', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.deepEqual(await readDirForIpc(' ', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.deepEqual(await readDirForIpc(null, { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.equal(readdirCalls, 0)
|
||||
})
|
||||
|
||||
test('readDirForIpc rejects Windows device paths before readdir', async () => {
|
||||
let readdirCalls = 0
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(await readDirForIpc('\\\\?\\C:\\secret', { fs: fsImpl }), {
|
||||
entries: [],
|
||||
error: 'device-path'
|
||||
})
|
||||
assert.equal(readdirCalls, 0)
|
||||
})
|
||||
|
||||
test('readDirForIpc returns filesystem error codes instead of throwing', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
const result = await readDirForIpc(path.join(root, 'missing'))
|
||||
|
||||
assert.deepEqual(result, { entries: [], error: 'ENOENT' })
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc marks a symlink to a directory as a directory', async t => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'actual-dir'))
|
||||
|
||||
try {
|
||||
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'linked-dir'), 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
const linked = result.entries.find(entry => entry.name === 'linked-dir')
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(linked?.isDirectory, true)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc marks a Windows junction to a directory as a directory', async t => {
|
||||
if (process.platform !== 'win32') {
|
||||
t.skip('junctions are a Windows-specific symlink type')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'actual-dir'))
|
||||
|
||||
try {
|
||||
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'junction-dir'), 'junction')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`junction creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
const junction = result.entries.find(entry => entry.name === 'junction-dir')
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(junction?.isDirectory, true)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc allows expanding symlink or junction directories outside the project root', async t => {
|
||||
const root = mkTmpDir()
|
||||
const outside = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
|
||||
|
||||
const linkPath = path.join(root, 'outside-link')
|
||||
try {
|
||||
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(linkPath)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(result.entries, [
|
||||
{ name: 'outside.txt', path: path.join(linkPath, 'outside.txt'), isDirectory: false }
|
||||
])
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
fs.rmSync(outside, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc stats symbolic links and unknown entries without dropping the whole listing', async () => {
|
||||
const input = path.join('virtual-root')
|
||||
const resolved = path.resolve(input)
|
||||
const statCalls = []
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
fakeDirent('unknown-entry'),
|
||||
fakeDirent('linked-dir', { symlink: true }),
|
||||
fakeDirent('broken-link', { symlink: true }),
|
||||
fakeDirent('plain.txt', { file: true })
|
||||
],
|
||||
stat: async fullPath => {
|
||||
if (fullPath === resolved) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
|
||||
statCalls.push(fullPath)
|
||||
if (fullPath.endsWith(`${path.sep}linked-dir`)) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(input, { fs: fsImpl })
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
statCalls.sort(),
|
||||
[path.join(resolved, 'broken-link'), path.join(resolved, 'linked-dir'), path.join(resolved, 'unknown-entry')].sort()
|
||||
)
|
||||
assert.deepEqual(result.entries, [
|
||||
{ name: 'linked-dir', path: path.join(resolved, 'linked-dir'), isDirectory: true },
|
||||
{ name: 'broken-link', path: path.join(resolved, 'broken-link'), isDirectory: false },
|
||||
{ name: 'plain.txt', path: path.join(resolved, 'plain.txt'), isDirectory: false },
|
||||
{ name: 'unknown-entry', path: path.join(resolved, 'unknown-entry'), isDirectory: false }
|
||||
])
|
||||
})
|
||||
|
||||
test('readDirForIpc bounds concurrent stats while preserving complete sorted output', async () => {
|
||||
const input = path.join('virtual-root')
|
||||
const resolved = path.resolve(input)
|
||||
const names = Array.from({ length: 105 }, (_, index) => `entry-${String(104 - index).padStart(3, '0')}`)
|
||||
const failedName = 'entry-100'
|
||||
const directoryNames = new Set(names.filter((_, index) => index % 10 === 4))
|
||||
const successfulDirectoryNames = new Set([...directoryNames].filter(name => name !== failedName))
|
||||
const statCalls = []
|
||||
let active = 0
|
||||
let peak = 0
|
||||
let releaseStats
|
||||
let markFirstStatStarted
|
||||
const statsReleased = new Promise(resolve => {
|
||||
releaseStats = resolve
|
||||
})
|
||||
const firstStatStarted = new Promise(resolve => {
|
||||
markFirstStatStarted = resolve
|
||||
})
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
fakeDirent('node_modules', { symlink: true }),
|
||||
...names.map((name, index) => fakeDirent(name, { symlink: index % 2 === 0 }))
|
||||
],
|
||||
stat: async fullPath => {
|
||||
if (fullPath === resolved) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
|
||||
statCalls.push(fullPath)
|
||||
active += 1
|
||||
peak = Math.max(peak, active)
|
||||
markFirstStatStarted()
|
||||
await statsReleased
|
||||
active -= 1
|
||||
|
||||
const name = path.basename(fullPath)
|
||||
if (name === failedName) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
return { isDirectory: () => successfulDirectoryNames.has(name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resultPromise = readDirForIpc(input, { fs: fsImpl })
|
||||
await firstStatStarted
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
releaseStats()
|
||||
const result = await resultPromise
|
||||
|
||||
const expectedNames = [
|
||||
...names.filter(name => successfulDirectoryNames.has(name)).sort(),
|
||||
...names.filter(name => !successfulDirectoryNames.has(name)).sort()
|
||||
]
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(result.entries.length, names.length)
|
||||
assert.equal(statCalls.length, names.length)
|
||||
assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false)
|
||||
assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`)
|
||||
assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
expectedNames
|
||||
)
|
||||
assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false)
|
||||
assert.equal(
|
||||
result.entries.filter(entry => entry.isDirectory).length,
|
||||
successfulDirectoryNames.size
|
||||
)
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
function findGitRoot(start, fsImpl = fs) {
|
||||
let dir = start
|
||||
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
try {
|
||||
if (fsImpl.existsSync(path.join(dir, '.git'))) {
|
||||
return dir
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir)
|
||||
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function gitRootForIpc(startPath, options = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Git root' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fsImpl.promises.stat(resolved)
|
||||
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
|
||||
|
||||
return findGitRoot(start, fsImpl)
|
||||
} catch {
|
||||
return findGitRoot(resolved, fsImpl)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findGitRoot,
|
||||
gitRootForIpc
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))
|
||||
}
|
||||
|
||||
test('gitRootForIpc returns null for invalid and device paths', async () => {
|
||||
assert.equal(await gitRootForIpc(''), null)
|
||||
assert.equal(await gitRootForIpc(' '), null)
|
||||
assert.equal(await gitRootForIpc(null), null)
|
||||
assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null)
|
||||
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
|
||||
})
|
||||
|
||||
test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => {
|
||||
const root = mkTmpDir()
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
const gitDir = path.join(root, '.git')
|
||||
const srcDir = path.join(root, 'src')
|
||||
const filePath = path.join(srcDir, 'index.ts')
|
||||
fs.mkdirSync(gitDir)
|
||||
fs.mkdirSync(srcDir)
|
||||
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
|
||||
|
||||
assert.equal(await gitRootForIpc(root), root)
|
||||
assert.equal(await gitRootForIpc(srcDir), root)
|
||||
assert.equal(await gitRootForIpc(filePath), root)
|
||||
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
|
||||
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
|
||||
})
|
||||
@@ -106,155 +106,71 @@ function sensitiveFileBlockReason(filePath) {
|
||||
return null
|
||||
}
|
||||
|
||||
function ipcPathError(code, message) {
|
||||
const error = new Error(message)
|
||||
error.code = code
|
||||
return error
|
||||
}
|
||||
|
||||
function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
|
||||
if (typeof filePath !== 'string') {
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
|
||||
}
|
||||
|
||||
const raw = filePath.trim()
|
||||
function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') {
|
||||
const raw = String(filePath || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
|
||||
throw new Error(`${purpose} failed: file path is required.`)
|
||||
}
|
||||
|
||||
if (raw.includes('\0')) {
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file path is invalid.`)
|
||||
throw new Error(`${purpose} failed: file path is invalid.`)
|
||||
}
|
||||
|
||||
const normalized = raw.replace(/\\/g, '/').toLowerCase()
|
||||
if (
|
||||
normalized.startsWith('//?/') ||
|
||||
normalized.startsWith('//./') ||
|
||||
normalized.startsWith('globalroot/device/') ||
|
||||
normalized.includes('/globalroot/device/')
|
||||
) {
|
||||
throw ipcPathError('device-path', `${purpose} blocked: Windows device paths are not allowed.`)
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
function resolveRequestedPathForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const raw = rejectUnsafePathSyntax(filePath, purpose)
|
||||
|
||||
if (/^file:/i.test(raw)) {
|
||||
let resolvedPath
|
||||
try {
|
||||
const parsed = new URL(raw)
|
||||
if (parsed.protocol !== 'file:') {
|
||||
throw new Error('not a file URL')
|
||||
}
|
||||
resolvedPath = fileURLToPath(parsed)
|
||||
return fileURLToPath(raw)
|
||||
} catch {
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`)
|
||||
throw new Error(`${purpose} failed: file URL is invalid.`)
|
||||
}
|
||||
|
||||
rejectUnsafePathSyntax(resolvedPath, purpose)
|
||||
return path.resolve(resolvedPath)
|
||||
}
|
||||
|
||||
const baseInput = typeof options.baseDir === 'string' && options.baseDir.trim() ? options.baseDir : process.cwd()
|
||||
const safeBaseInput = rejectUnsafePathSyntax(baseInput, purpose)
|
||||
const resolvedBase = path.resolve(safeBaseInput)
|
||||
rejectUnsafePathSyntax(resolvedBase, purpose)
|
||||
const resolvedPath = path.resolve(resolvedBase, raw)
|
||||
rejectUnsafePathSyntax(resolvedPath, purpose)
|
||||
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
|
||||
try {
|
||||
return await fsImpl.promises.stat(resolvedPath)
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
|
||||
}
|
||||
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function realpathForIpc(fsImpl, resolvedPath, purpose) {
|
||||
if (typeof fsImpl.promises.realpath !== 'function') {
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
try {
|
||||
const realPath = await fsImpl.promises.realpath(resolvedPath)
|
||||
rejectUnsafePathSyntax(realPath, purpose)
|
||||
return realPath
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function rejectSensitiveFilePath(filePath, purpose) {
|
||||
const blockReason = sensitiveFileBlockReason(filePath)
|
||||
if (blockReason) {
|
||||
throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectoryForIpc(dirPath, options = {}) {
|
||||
const purpose = String(options.purpose || 'Directory read')
|
||||
const fsImpl = options.fs || fs
|
||||
const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose })
|
||||
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'directory')
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
throw ipcPathError('ENOTDIR', `${purpose} failed: path is not a directory.`)
|
||||
}
|
||||
|
||||
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
|
||||
|
||||
return { realPath, resolvedPath, stat }
|
||||
const resolvedBase = path.resolve(String(baseDir || process.cwd()))
|
||||
return path.resolve(resolvedBase, raw)
|
||||
}
|
||||
|
||||
async function resolveReadableFileForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const fsImpl = options.fs || fs
|
||||
const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose })
|
||||
const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose)
|
||||
|
||||
if (options.blockSensitive !== false) {
|
||||
rejectSensitiveFilePath(resolvedPath, purpose)
|
||||
const blockReason = sensitiveFileBlockReason(resolvedPath)
|
||||
if (blockReason) {
|
||||
throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`)
|
||||
}
|
||||
}
|
||||
|
||||
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'file')
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.promises.stat(resolvedPath)
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw new Error(`${purpose} failed: file does not exist.`)
|
||||
}
|
||||
throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
throw ipcPathError('EISDIR', `${purpose} failed: path points to a directory.`)
|
||||
throw new Error(`${purpose} failed: path points to a directory.`)
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw ipcPathError('EINVAL', `${purpose} failed: only regular files can be read.`)
|
||||
}
|
||||
|
||||
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
|
||||
if (options.blockSensitive !== false) {
|
||||
rejectSensitiveFilePath(realPath, purpose)
|
||||
throw new Error(`${purpose} failed: only regular files can be read.`)
|
||||
}
|
||||
|
||||
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
|
||||
if (maxBytes && stat.size > maxBytes) {
|
||||
throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
||||
throw new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
||||
}
|
||||
|
||||
try {
|
||||
await fsImpl.promises.access(resolvedPath, fs.constants.R_OK)
|
||||
await fs.promises.access(resolvedPath, fs.constants.R_OK)
|
||||
} catch {
|
||||
throw ipcPathError('EACCES', `${purpose} failed: file is not readable.`)
|
||||
throw new Error(`${purpose} failed: file is not readable.`)
|
||||
}
|
||||
|
||||
return { realPath, resolvedPath, stat }
|
||||
return { resolvedPath, stat }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -262,10 +178,7 @@ module.exports = {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret,
|
||||
rejectUnsafePathSyntax,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
}
|
||||
|
||||
@@ -8,20 +8,11 @@ const { pathToFileURL } = require('node:url')
|
||||
const {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
async function rejectsWithCode(promise, code) {
|
||||
await assert.rejects(promise, error => {
|
||||
assert.equal(error?.code, code)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
|
||||
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
@@ -60,52 +51,6 @@ test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
|
||||
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
|
||||
})
|
||||
|
||||
test('path helpers reject blank non-string NUL and Windows device syntax', async () => {
|
||||
await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path')
|
||||
|
||||
const devicePaths = [
|
||||
'\\\\?\\C:\\secret.txt',
|
||||
'\\\\.\\C:\\secret.txt',
|
||||
'\\\\?\\UNC\\server\\share\\secret.txt',
|
||||
'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt'
|
||||
]
|
||||
|
||||
for (const devicePath of devicePaths) {
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
|
||||
error => {
|
||||
assert.equal(error?.code, 'device-path')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path')
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
|
||||
error => {
|
||||
assert.equal(error?.code, 'invalid-path')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path')
|
||||
})
|
||||
|
||||
test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => {
|
||||
const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base')
|
||||
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('notes.txt', {
|
||||
baseDir: ` ${baseDir} `,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
path.resolve(baseDir, 'notes.txt')
|
||||
)
|
||||
})
|
||||
|
||||
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 }))
|
||||
@@ -126,13 +71,6 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
|
||||
})
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
|
||||
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
|
||||
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
|
||||
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc('missing.txt', {
|
||||
baseDir: tempDir,
|
||||
@@ -176,91 +114,3 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
|
||||
})
|
||||
assert.equal(envTemplate.resolvedPath, envTemplatePath)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks common sensitive files', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const sshDir = path.join(tempDir, '.ssh')
|
||||
fs.mkdirSync(sshDir)
|
||||
|
||||
const blockedFiles = [
|
||||
path.join(tempDir, '.env'),
|
||||
path.join(tempDir, '.npmrc'),
|
||||
path.join(sshDir, 'id_ed25519'),
|
||||
path.join(tempDir, 'cert.pem'),
|
||||
path.join(tempDir, 'cert.p12'),
|
||||
path.join(tempDir, 'cert.pfx')
|
||||
]
|
||||
|
||||
for (const filePath of blockedFiles) {
|
||||
fs.writeFileSync(filePath, 'secret', 'utf8')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
}
|
||||
|
||||
const allowed = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
const linkPath = path.join(tempDir, 'safe-name.txt')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
|
||||
try {
|
||||
fs.symlinkSync(envPath, linkPath, 'file')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'project')
|
||||
const filePath = path.join(tempDir, 'file.txt')
|
||||
fs.mkdirSync(directory)
|
||||
fs.writeFileSync(filePath, 'not a directory', 'utf8')
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(directory)
|
||||
assert.equal(resolved.resolvedPath, directory)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
|
||||
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
|
||||
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
|
||||
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'actual-project')
|
||||
const linkPath = path.join(tempDir, 'linked-project')
|
||||
fs.mkdirSync(directory)
|
||||
|
||||
try {
|
||||
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(linkPath)
|
||||
assert.equal(resolved.resolvedPath, linkPath)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
})
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* In-process port reservation pool for the desktop backend launcher.
|
||||
*
|
||||
* pickPort() probes a localhost port with a throwaway server and closes it
|
||||
* before the real bind happens in a separate Python child. Between that probe
|
||||
* and the child's bind there is a TOCTOU window: a second concurrent spawn
|
||||
* (the primary backend racing a pool backend) can be handed the SAME port, and
|
||||
* one then dies with EADDRINUSE ("address already in use" -> "Object has been
|
||||
* destroyed" boot loop). Reserving the chosen port in THIS process until the
|
||||
* child exits closes that window.
|
||||
*
|
||||
* The OS bind remains the source of truth; this only deconflicts racers inside
|
||||
* this process — it can't stop a foreign squatter, which the probe + the
|
||||
* EADDRINUSE self-heal still cover.
|
||||
*
|
||||
* The pool is dependency-injected (the availability probe is passed in) and
|
||||
* free of Electron/Node socket I/O, so it is unit-tested without real sockets
|
||||
* (see port-pool.test.cjs).
|
||||
*/
|
||||
class PortPool {
|
||||
/**
|
||||
* @param {number} floor inclusive lowest port to hand out
|
||||
* @param {number} ceiling inclusive highest port to hand out
|
||||
*/
|
||||
constructor(floor, ceiling) {
|
||||
this.floor = floor
|
||||
this.ceiling = ceiling
|
||||
this._reserved = new Set()
|
||||
}
|
||||
|
||||
/** @returns {boolean} whether `port` is currently reserved in-process. */
|
||||
has(port) {
|
||||
return this._reserved.has(port)
|
||||
}
|
||||
|
||||
/** Release a previously reserved port. No-op if it was not reserved. */
|
||||
release(port) {
|
||||
this._reserved.delete(port)
|
||||
}
|
||||
|
||||
/** Drop all reservations. */
|
||||
clear() {
|
||||
this._reserved.clear()
|
||||
}
|
||||
|
||||
/** @returns {number} count of currently reserved ports. */
|
||||
get size() {
|
||||
return this._reserved.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve and return the lowest port in [floor, ceiling] that is neither
|
||||
* already reserved in-process nor rejected by `isAvailable(port)`, or null
|
||||
* if every port is taken. `isAvailable` may be sync (boolean) or async
|
||||
* (Promise<boolean>); it is awaited either way.
|
||||
*
|
||||
* @param {(port: number) => boolean | Promise<boolean>} isAvailable
|
||||
* @returns {Promise<number|null>}
|
||||
*/
|
||||
async reserve(isAvailable) {
|
||||
for (let port = this.floor; port <= this.ceiling; port += 1) {
|
||||
if (this._reserved.has(port)) continue
|
||||
if (!(await isAvailable(port))) continue
|
||||
this._reserved.add(port)
|
||||
return port
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PortPool }
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/port-pool.cjs.
|
||||
*
|
||||
* Run with: node --test electron/port-pool.test.cjs
|
||||
*
|
||||
* PortPool is the in-process reservation that closes the pickPort() TOCTOU
|
||||
* window. These cover selection order, skipping reserved/unavailable ports,
|
||||
* release/reuse, exhaustion, and async probes — without real sockets.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { PortPool } = require('./port-pool.cjs')
|
||||
|
||||
const allFree = () => true
|
||||
|
||||
test('reserve returns the lowest free port and reserves it', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const port = await pool.reserve(allFree)
|
||||
assert.equal(port, 9120)
|
||||
assert.ok(pool.has(9120))
|
||||
assert.equal(pool.size, 1)
|
||||
})
|
||||
|
||||
test('reserve skips ports already reserved in-process', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const first = await pool.reserve(allFree)
|
||||
const second = await pool.reserve(allFree)
|
||||
assert.equal(first, 9120)
|
||||
assert.equal(second, 9121)
|
||||
})
|
||||
|
||||
test('reserve skips ports the probe rejects', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const busy = new Set([9120, 9121])
|
||||
const port = await pool.reserve(p => !busy.has(p))
|
||||
assert.equal(port, 9122)
|
||||
})
|
||||
|
||||
test('reserve returns null when every port is taken', async () => {
|
||||
const pool = new PortPool(9120, 9121)
|
||||
await pool.reserve(allFree)
|
||||
await pool.reserve(allFree)
|
||||
assert.equal(await pool.reserve(allFree), null)
|
||||
})
|
||||
|
||||
test('release frees a reserved port for reuse', async () => {
|
||||
const pool = new PortPool(9120, 9120)
|
||||
assert.equal(await pool.reserve(allFree), 9120)
|
||||
assert.equal(await pool.reserve(allFree), null) // exhausted
|
||||
pool.release(9120)
|
||||
assert.ok(!pool.has(9120))
|
||||
assert.equal(await pool.reserve(allFree), 9120) // reusable
|
||||
})
|
||||
|
||||
test('release is a no-op for an unreserved port', () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
pool.release(9120)
|
||||
assert.equal(pool.size, 0)
|
||||
})
|
||||
|
||||
test('reserve awaits an async probe', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const busy = new Set([9120])
|
||||
const port = await pool.reserve(p => Promise.resolve(!busy.has(p)))
|
||||
assert.equal(port, 9121)
|
||||
})
|
||||
|
||||
test('clear drops all reservations', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
await pool.reserve(allFree)
|
||||
await pool.reserve(allFree)
|
||||
assert.equal(pool.size, 2)
|
||||
pool.clear()
|
||||
assert.equal(pool.size, 0)
|
||||
})
|
||||
@@ -5,7 +5,6 @@ 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),
|
||||
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),
|
||||
@@ -42,7 +41,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
||||
sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd),
|
||||
settings: {
|
||||
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
|
||||
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
|
||||
@@ -80,12 +78,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
ipcRenderer.on('hermes:open-updates', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
|
||||
},
|
||||
onDeepLink: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:deep-link', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:deep-link', listener)
|
||||
},
|
||||
signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
|
||||
onWindowStateChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:window-state-changed', listener)
|
||||
@@ -140,9 +132,5 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
ipcRenderer.on('hermes:updates:progress', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
|
||||
}
|
||||
},
|
||||
themes: {
|
||||
fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id),
|
||||
searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// Secondary "session windows" — one extra OS window per chat so a user can
|
||||
// work with multiple chats side by side. The pure, Electron-free pieces live
|
||||
// here so they can be unit-tested with node --test (mirroring how the rest of
|
||||
// electron/*.cjs splits testable logic out of the main.cjs monolith).
|
||||
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
// 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 } = {}) {
|
||||
const route = `#/${encodeURIComponent(sessionId)}`
|
||||
|
||||
if (devServer) {
|
||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||
|
||||
return `${base}/?win=secondary${route}`
|
||||
}
|
||||
|
||||
return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}`
|
||||
}
|
||||
|
||||
// A small registry keyed by sessionId that guarantees one window per chat:
|
||||
// opening a session that already has a live window focuses it instead of
|
||||
// spawning a duplicate, and a window removes itself from the registry when it
|
||||
// closes. The actual BrowserWindow construction is injected (the `factory`) so
|
||||
// this module stays free of Electron and is unit-testable.
|
||||
function createSessionWindowRegistry() {
|
||||
const windows = new Map()
|
||||
|
||||
function openOrFocus(sessionId, factory) {
|
||||
const key = typeof sessionId === 'string' ? sessionId.trim() : ''
|
||||
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const existing = windows.get(key)
|
||||
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
// Focus-or-create: never duplicate a window for the same chat.
|
||||
if (typeof existing.isMinimized === 'function' && existing.isMinimized()) {
|
||||
existing.restore?.()
|
||||
}
|
||||
|
||||
if (typeof existing.isVisible === 'function' && !existing.isVisible()) {
|
||||
existing.show?.()
|
||||
}
|
||||
|
||||
existing.focus?.()
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
const win = factory(key)
|
||||
|
||||
if (!win) {
|
||||
return null
|
||||
}
|
||||
|
||||
windows.set(key, win)
|
||||
|
||||
// Self-cleanup on close so the registry never holds a destroyed window.
|
||||
win.on?.('closed', () => {
|
||||
if (windows.get(key) === win) {
|
||||
windows.delete(key)
|
||||
}
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
return {
|
||||
openOrFocus,
|
||||
get: key => windows.get(key),
|
||||
has: key => windows.has(key),
|
||||
get size() {
|
||||
return windows.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { buildSessionWindowUrl, createSessionWindowRegistry }
|
||||
@@ -1,165 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
||||
|
||||
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
|
||||
// test fire the 'closed' event, mirroring the slice of the Electron API the
|
||||
// registry actually touches.
|
||||
function makeFakeWindow() {
|
||||
const listeners = {}
|
||||
const calls = { focus: 0, show: 0, restore: 0 }
|
||||
let destroyed = false
|
||||
let minimized = false
|
||||
let visible = true
|
||||
|
||||
return {
|
||||
on(event, handler) {
|
||||
listeners[event] = handler
|
||||
|
||||
return this
|
||||
},
|
||||
emit(event) {
|
||||
listeners[event]?.()
|
||||
},
|
||||
isDestroyed: () => destroyed,
|
||||
destroy() {
|
||||
destroyed = true
|
||||
},
|
||||
isMinimized: () => minimized,
|
||||
setMinimized(value) {
|
||||
minimized = value
|
||||
},
|
||||
isVisible: () => visible,
|
||||
setVisible(value) {
|
||||
visible = value
|
||||
},
|
||||
restore() {
|
||||
calls.restore += 1
|
||||
minimized = false
|
||||
},
|
||||
show() {
|
||||
calls.show += 1
|
||||
visible = true
|
||||
},
|
||||
focus() {
|
||||
calls.focus += 1
|
||||
},
|
||||
calls
|
||||
}
|
||||
}
|
||||
|
||||
test('buildSessionWindowUrl puts the secondary flag before the hash route (dev server)', () => {
|
||||
const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173' })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123')
|
||||
})
|
||||
|
||||
test('buildSessionWindowUrl avoids a double slash when the dev server has a trailing slash', () => {
|
||||
const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173/' })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123')
|
||||
})
|
||||
|
||||
test('buildSessionWindowUrl encodes the session id in the hash route', () => {
|
||||
const url = buildSessionWindowUrl('a b/c', { devServer: 'http://localhost:5173' })
|
||||
|
||||
// The query flag must precede the '#' or HashRouter would swallow it as the
|
||||
// route; the id is URL-encoded so slashes/spaces survive routeSessionId().
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary#/a%20b%2Fc')
|
||||
assert.ok(url.indexOf('?win=secondary') < url.indexOf('#'))
|
||||
})
|
||||
|
||||
test('buildSessionWindowUrl builds a packaged file URL with the flag before the hash', () => {
|
||||
const url = buildSessionWindowUrl('abc', { rendererIndexPath: '/opt/app/index.html' })
|
||||
|
||||
assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/)
|
||||
})
|
||||
|
||||
test('registry opens one window per session and focuses on re-open', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
let built = 0
|
||||
const win = makeFakeWindow()
|
||||
const factory = () => {
|
||||
built += 1
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
const first = registry.openOrFocus('s1', factory)
|
||||
const second = registry.openOrFocus('s1', factory)
|
||||
|
||||
assert.equal(built, 1, 'factory runs once for the same session')
|
||||
assert.equal(first, second)
|
||||
assert.equal(registry.size, 1)
|
||||
assert.equal(win.calls.focus, 1, 'second open focuses the existing window')
|
||||
})
|
||||
|
||||
test('registry restores + shows a minimized/hidden window on re-open', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
const win = makeFakeWindow()
|
||||
registry.openOrFocus('s1', () => win)
|
||||
|
||||
win.setMinimized(true)
|
||||
win.setVisible(false)
|
||||
registry.openOrFocus('s1', () => win)
|
||||
|
||||
assert.equal(win.calls.restore, 1)
|
||||
assert.equal(win.calls.show, 1)
|
||||
assert.equal(win.calls.focus, 1)
|
||||
})
|
||||
|
||||
test('registry drops the entry when the window closes', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
const win = makeFakeWindow()
|
||||
registry.openOrFocus('s1', () => win)
|
||||
assert.equal(registry.size, 1)
|
||||
|
||||
win.emit('closed')
|
||||
|
||||
assert.equal(registry.size, 0)
|
||||
assert.equal(registry.has('s1'), false)
|
||||
})
|
||||
|
||||
test('registry rebuilds a fresh window after the previous one was destroyed', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
const first = makeFakeWindow()
|
||||
registry.openOrFocus('s1', () => first)
|
||||
first.destroy()
|
||||
|
||||
let built = 0
|
||||
const second = makeFakeWindow()
|
||||
const result = registry.openOrFocus('s1', () => {
|
||||
built += 1
|
||||
|
||||
return second
|
||||
})
|
||||
|
||||
assert.equal(built, 1, 'a destroyed window is replaced, not focused')
|
||||
assert.equal(result, second)
|
||||
})
|
||||
|
||||
test('registry ignores empty / non-string session ids', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
let built = 0
|
||||
const factory = () => {
|
||||
built += 1
|
||||
|
||||
return makeFakeWindow()
|
||||
}
|
||||
|
||||
assert.equal(registry.openOrFocus('', factory), null)
|
||||
assert.equal(registry.openOrFocus(' ', factory), null)
|
||||
assert.equal(registry.openOrFocus(null, factory), null)
|
||||
assert.equal(registry.openOrFocus(42, factory), null)
|
||||
assert.equal(built, 0)
|
||||
assert.equal(registry.size, 0)
|
||||
})
|
||||
|
||||
test('registry trims the session id before keying', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
const win = makeFakeWindow()
|
||||
registry.openOrFocus(' s1 ', () => win)
|
||||
|
||||
assert.equal(registry.has('s1'), true)
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Pure helpers for choosing a remote URL during passive update checks.
|
||||
*
|
||||
* A public install can end up with `origin=git@github.com:NousResearch/hermes-agent.git`.
|
||||
* If the user's GitHub SSH key is FIDO2/passkey-backed, a background `git fetch
|
||||
* origin` triggers an unexplained hardware-touch prompt. For passive checks
|
||||
* against the official repo we substitute the public HTTPS `ls-remote` path,
|
||||
* which needs no auth and cannot prompt. Active update/apply flows are left
|
||||
* unchanged.
|
||||
*
|
||||
* Extracted from main.cjs so the security-critical remote detection is unit
|
||||
* testable without booting Electron (main.cjs requires('electron') at load).
|
||||
*/
|
||||
|
||||
const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git'
|
||||
const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent'
|
||||
|
||||
// Normalize common GitHub remote URL forms to `host/owner/repo` (lowercased,
|
||||
// no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo
|
||||
// compare equal.
|
||||
function canonicalGitHubRemote(url) {
|
||||
if (!url) return ''
|
||||
let value = String(url).trim()
|
||||
if (value.startsWith('git@github.com:')) {
|
||||
value = `github.com/${value.slice('git@github.com:'.length)}`
|
||||
} else if (value.startsWith('ssh://git@github.com/')) {
|
||||
value = `github.com/${value.slice('ssh://git@github.com/'.length)}`
|
||||
} else {
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}`
|
||||
} catch {
|
||||
// Leave non-URL forms unchanged.
|
||||
}
|
||||
}
|
||||
value = value.trim().replace(/\/+$/, '')
|
||||
if (value.endsWith('.git')) value = value.slice(0, -4)
|
||||
return value.toLowerCase()
|
||||
}
|
||||
|
||||
function isSshRemote(url) {
|
||||
const value = String(url || '').trim().toLowerCase()
|
||||
return value.startsWith('git@') || value.startsWith('ssh://')
|
||||
}
|
||||
|
||||
function isOfficialSshRemote(url) {
|
||||
return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
OFFICIAL_REPO_CANONICAL,
|
||||
canonicalGitHubRemote,
|
||||
isSshRemote,
|
||||
isOfficialSshRemote
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/update-remote.cjs — the remote-detection helpers that
|
||||
* keep passive update checks off the SSH origin for official installs.
|
||||
*
|
||||
* Run with: node --test electron/update-remote.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Why this matters: a public install can carry
|
||||
* origin=git@github.com:NousResearch/hermes-agent.git. A background
|
||||
* `git fetch origin` then authenticates over SSH and, with a FIDO2/passkey
|
||||
* key, triggers an unexplained hardware-touch prompt. isOfficialSshRemote
|
||||
* must reliably recognize the official SSH remote (in every URL form,
|
||||
* case-insensitively) so the caller can swap in the anonymous HTTPS path —
|
||||
* while NOT misclassifying forks, other hosts, or the HTTPS remote (which
|
||||
* never prompts and should keep the normal fetch path).
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
OFFICIAL_REPO_CANONICAL,
|
||||
canonicalGitHubRemote,
|
||||
isSshRemote,
|
||||
isOfficialSshRemote
|
||||
} = require('./update-remote.cjs')
|
||||
|
||||
test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => {
|
||||
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent'), OFFICIAL_REPO_CANONICAL)
|
||||
assert.equal(canonicalGitHubRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||
assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||
// Case-insensitive: an uppercased owner still canonicalizes to the same repo.
|
||||
assert.equal(canonicalGitHubRemote('git@github.com:nousresearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||
// Trailing slashes are stripped.
|
||||
assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent/'), OFFICIAL_REPO_CANONICAL)
|
||||
})
|
||||
|
||||
test('canonicalGitHubRemote is empty for falsy input', () => {
|
||||
assert.equal(canonicalGitHubRemote(''), '')
|
||||
assert.equal(canonicalGitHubRemote(null), '')
|
||||
assert.equal(canonicalGitHubRemote(undefined), '')
|
||||
})
|
||||
|
||||
test('isSshRemote detects scp-like and ssh:// forms only', () => {
|
||||
assert.equal(isSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
|
||||
assert.equal(isSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
|
||||
assert.equal(isSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
|
||||
assert.equal(isSshRemote(''), false)
|
||||
assert.equal(isSshRemote(null), false)
|
||||
})
|
||||
|
||||
test('isOfficialSshRemote is true only for the official repo over SSH', () => {
|
||||
assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
|
||||
assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent'), true)
|
||||
assert.equal(isOfficialSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
|
||||
// Case-insensitive owner/repo match.
|
||||
assert.equal(isOfficialSshRemote('git@github.com:nousresearch/hermes-agent.git'), true)
|
||||
})
|
||||
|
||||
test('isOfficialSshRemote does NOT match forks, other hosts, or HTTPS', () => {
|
||||
// A fork over SSH belongs to the user — fetching it is their own remote,
|
||||
// not the official upstream, so the SSH-avoidance swap must not apply.
|
||||
assert.equal(isOfficialSshRemote('git@github.com:someuser/hermes-agent.git'), false)
|
||||
// Same repo name on a different host is not the official repo.
|
||||
assert.equal(isOfficialSshRemote('git@gitlab.com:NousResearch/hermes-agent.git'), false)
|
||||
// HTTPS to the official repo never prompts for SSH/FIDO2, so it keeps the
|
||||
// normal fetch path — must not be flagged as an official SSH remote.
|
||||
assert.equal(isOfficialSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
|
||||
assert.equal(isOfficialSshRemote(''), false)
|
||||
assert.equal(isOfficialSshRemote(null), false)
|
||||
})
|
||||
|
||||
test('OFFICIAL_REPO_HTTPS_URL canonicalizes to OFFICIAL_REPO_CANONICAL', () => {
|
||||
// Invariant: the URL we substitute in must be the same repo we detect.
|
||||
assert.equal(canonicalGitHubRemote(OFFICIAL_REPO_HTTPS_URL), OFFICIAL_REPO_CANONICAL)
|
||||
})
|
||||
@@ -1,331 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* VS Code Marketplace color-theme fetcher (main process).
|
||||
*
|
||||
* Resolves an extension's latest version via the (undocumented but stable)
|
||||
* gallery ExtensionQuery API, downloads the `.vsix` (a zip), and extracts the
|
||||
* color-theme JSON files it contributes. No theme code is ever executed — we
|
||||
* only read `package.json` + the referenced `*.json` theme files out of the
|
||||
* archive and hand their text back to the renderer to convert.
|
||||
*
|
||||
* Dependency-free on purpose: a `.vsix` is a plain zip, so we parse the central
|
||||
* directory and inflate just the entries we need with `zlib`. Avoids pulling a
|
||||
* zip library into the desktop bundle for a feature this small.
|
||||
*/
|
||||
|
||||
const https = require('node:https')
|
||||
const zlib = require('node:zlib')
|
||||
|
||||
const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery'
|
||||
const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage'
|
||||
const MAX_VSIX_BYTES = 40 * 1024 * 1024 // 40 MB — themes are tiny; this is paranoia.
|
||||
const MAX_REDIRECTS = 5
|
||||
const REQUEST_TIMEOUT_MS = 20_000
|
||||
|
||||
const ID_RE = /^[\w-]+\.[\w-]+$/
|
||||
|
||||
/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */
|
||||
function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(url, { method, headers }, res => {
|
||||
const status = res.statusCode ?? 0
|
||||
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
if (redirectsLeft <= 0) {
|
||||
res.resume()
|
||||
reject(new Error('Too many redirects.'))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const next = new URL(res.headers.location, url).toString()
|
||||
res.resume()
|
||||
// Redirects to the CDN are plain GETs (drop the POST body).
|
||||
resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (status < 200 || status >= 300) {
|
||||
res.resume()
|
||||
reject(new Error(`Request failed (${status}) for ${url}`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const chunks = []
|
||||
let total = 0
|
||||
|
||||
res.on('data', chunk => {
|
||||
total += chunk.length
|
||||
|
||||
if (total > maxBytes) {
|
||||
req.destroy()
|
||||
reject(new Error('Response exceeded the size limit.'))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
chunks.push(chunk)
|
||||
})
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
})
|
||||
|
||||
req.on('error', reject)
|
||||
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out.')))
|
||||
|
||||
if (body) {
|
||||
req.write(body)
|
||||
}
|
||||
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve `{ displayName, vsixUrl }` for the latest version of `id`. */
|
||||
async function resolveExtension(id) {
|
||||
const json = await queryGallery({
|
||||
// FilterType 7 = ExtensionName (the full publisher.extension id).
|
||||
filters: [{ criteria: [{ filterType: 7, value: id }], pageNumber: 1, pageSize: 1 }],
|
||||
// Flags: IncludeFiles | IncludeVersionProperties | IncludeAssetUri |
|
||||
// IncludeCategoryAndTags | IncludeLatestVersionOnly = 914.
|
||||
flags: 914
|
||||
})
|
||||
const extension = json?.results?.[0]?.extensions?.[0]
|
||||
|
||||
if (!extension) {
|
||||
throw new Error(`Extension "${id}" was not found on the Marketplace.`)
|
||||
}
|
||||
|
||||
const version = extension.versions?.[0]
|
||||
|
||||
if (!version) {
|
||||
throw new Error(`Extension "${id}" has no published versions.`)
|
||||
}
|
||||
|
||||
const asset = (version.files ?? []).find(file => file.assetType === VSIX_ASSET_TYPE)
|
||||
const vsixUrl = asset?.source
|
||||
|
||||
if (!vsixUrl) {
|
||||
throw new Error(`Could not find a downloadable package for "${id}".`)
|
||||
}
|
||||
|
||||
return { displayName: extension.displayName || id, vsixUrl }
|
||||
}
|
||||
|
||||
/** POST an ExtensionQuery payload and return the parsed gallery response. */
|
||||
async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) {
|
||||
const body = JSON.stringify(payload)
|
||||
const raw = await request(GALLERY_QUERY_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json;api-version=3.0-preview.1',
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'User-Agent': 'Hermes-Desktop'
|
||||
},
|
||||
body,
|
||||
maxBytes
|
||||
})
|
||||
|
||||
return JSON.parse(raw.toString('utf8'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the Marketplace for color-theme extensions. With an empty query this
|
||||
* returns the most-installed themes; with a query it's a full-text search
|
||||
* scoped to the Themes category. Returns lightweight cards (no download).
|
||||
*/
|
||||
/**
|
||||
* The "Themes" category also contains file-icon and product-icon themes (the
|
||||
* gallery has no color-only category). We can't see an extension's actual
|
||||
* contributions without downloading it, so filter the obvious icon packs out by
|
||||
* tag + name/description. Color themes that also ship icons are rare; worst case
|
||||
* a user installs them by exact id from settings.
|
||||
*/
|
||||
function looksLikeIconTheme(extension) {
|
||||
const tags = (extension.tags ?? []).map(tag => String(tag).toLowerCase())
|
||||
|
||||
if (tags.includes('icon-theme') || tags.includes('product-icon-theme')) {
|
||||
return true
|
||||
}
|
||||
|
||||
const text = `${extension.displayName ?? ''} ${extension.shortDescription ?? ''}`.toLowerCase()
|
||||
|
||||
return /\b(icon theme|file icons?|product icons?|icon pack|fileicons)\b/.test(text)
|
||||
}
|
||||
|
||||
async function searchMarketplaceThemes(query, limit = 20) {
|
||||
const text = String(query || '').trim()
|
||||
const pageSize = Math.min(Math.max(Number(limit) || 20, 1), 50)
|
||||
|
||||
// FilterType: 8=Target, 5=Category, 10=SearchText, 12=ExcludeWithFlags.
|
||||
const criteria = [
|
||||
{ filterType: 8, value: 'Microsoft.VisualStudio.Code' },
|
||||
{ filterType: 5, value: 'Themes' },
|
||||
{ filterType: 12, value: '4096' } // Exclude unpublished (Unpublished = 0x1000).
|
||||
]
|
||||
|
||||
if (text) {
|
||||
criteria.push({ filterType: 10, value: text })
|
||||
}
|
||||
|
||||
const json = await queryGallery({
|
||||
// Over-fetch so the icon-theme filter below still leaves a full page.
|
||||
filters: [{ criteria, pageNumber: 1, pageSize: Math.min(pageSize * 2, 50), sortBy: 4, sortOrder: 0 }],
|
||||
// IncludeStatistics (0x100) | IncludeLatestVersionOnly (0x200) | IncludeCategoryAndTags (0x4).
|
||||
flags: 772
|
||||
})
|
||||
|
||||
const extensions = json?.results?.[0]?.extensions ?? []
|
||||
|
||||
return extensions
|
||||
.filter(extension => !looksLikeIconTheme(extension))
|
||||
.slice(0, pageSize)
|
||||
.map(extension => {
|
||||
const publisherName = extension.publisher?.publisherName ?? ''
|
||||
const installStat = (extension.statistics ?? []).find(stat => stat.statisticName === 'install')
|
||||
|
||||
return {
|
||||
extensionId: `${publisherName}.${extension.extensionName}`,
|
||||
displayName: extension.displayName || extension.extensionName,
|
||||
publisher: extension.publisher?.displayName || publisherName,
|
||||
description: extension.shortDescription || '',
|
||||
installs: Math.round(installStat?.value ?? 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Minimal zip reader ─────────────────────────────────────────────────────
|
||||
|
||||
function findEndOfCentralDirectory(buf) {
|
||||
// EOCD signature 0x06054b50, scanning back from the end (comment is rare).
|
||||
for (let i = buf.length - 22; i >= 0; i--) {
|
||||
if (buf.readUInt32LE(i) === 0x06054b50) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Not a valid zip archive (no end-of-central-directory).')
|
||||
}
|
||||
|
||||
/** Parse the central directory into a name → record map. */
|
||||
function readCentralDirectory(buf) {
|
||||
const eocd = findEndOfCentralDirectory(buf)
|
||||
const count = buf.readUInt16LE(eocd + 10)
|
||||
let offset = buf.readUInt32LE(eocd + 16)
|
||||
const records = new Map()
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (buf.readUInt32LE(offset) !== 0x02014b50) {
|
||||
break
|
||||
}
|
||||
|
||||
const method = buf.readUInt16LE(offset + 10)
|
||||
const compressedSize = buf.readUInt32LE(offset + 20)
|
||||
const nameLen = buf.readUInt16LE(offset + 28)
|
||||
const extraLen = buf.readUInt16LE(offset + 30)
|
||||
const commentLen = buf.readUInt16LE(offset + 32)
|
||||
const localOffset = buf.readUInt32LE(offset + 42)
|
||||
const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen)
|
||||
|
||||
records.set(name, { method, compressedSize, localOffset })
|
||||
offset += 46 + nameLen + extraLen + commentLen
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
/** Inflate a single entry to a string. */
|
||||
function extractEntry(buf, record) {
|
||||
// The local header's name/extra lengths can differ from the central record,
|
||||
// so re-read them here to locate the compressed payload.
|
||||
if (buf.readUInt32LE(record.localOffset) !== 0x04034b50) {
|
||||
throw new Error('Corrupt zip: bad local file header.')
|
||||
}
|
||||
|
||||
const nameLen = buf.readUInt16LE(record.localOffset + 26)
|
||||
const extraLen = buf.readUInt16LE(record.localOffset + 28)
|
||||
const dataStart = record.localOffset + 30 + nameLen + extraLen
|
||||
const data = buf.subarray(dataStart, dataStart + record.compressedSize)
|
||||
|
||||
// 0 = stored, 8 = deflate. Theme files are one or the other.
|
||||
return record.method === 0 ? data.toString('utf8') : zlib.inflateRawSync(data).toString('utf8')
|
||||
}
|
||||
|
||||
/** Normalize a package.json theme path to its zip entry name. */
|
||||
function themeEntryName(themePath) {
|
||||
const clean = String(themePath).replace(/^\.\//, '').replace(/^\//, '')
|
||||
|
||||
return `extension/${clean}`
|
||||
}
|
||||
|
||||
/** Extract every contributed color theme from a `.vsix` buffer. */
|
||||
function extractThemes(vsixBuffer) {
|
||||
const records = readCentralDirectory(vsixBuffer)
|
||||
const pkgRecord = records.get('extension/package.json')
|
||||
|
||||
if (!pkgRecord) {
|
||||
throw new Error('Package manifest missing from the extension.')
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(extractEntry(vsixBuffer, pkgRecord))
|
||||
const contributed = pkg?.contributes?.themes
|
||||
|
||||
if (!Array.isArray(contributed) || contributed.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const themes = []
|
||||
|
||||
for (const entry of contributed) {
|
||||
if (!entry?.path) {
|
||||
continue
|
||||
}
|
||||
|
||||
const record = records.get(themeEntryName(entry.path))
|
||||
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
themes.push({
|
||||
label: entry.label || entry.id || pkg.displayName || pkg.name || 'VS Code Theme',
|
||||
uiTheme: entry.uiTheme,
|
||||
contents: extractEntry(vsixBuffer, record)
|
||||
})
|
||||
} catch {
|
||||
// Skip an entry we can't inflate rather than failing the whole install.
|
||||
}
|
||||
}
|
||||
|
||||
return themes
|
||||
}
|
||||
|
||||
/**
|
||||
* Public entry: resolve, download, and extract color themes for `id`
|
||||
* (`publisher.extension`). Returns `{ extensionId, displayName, themes }`.
|
||||
*/
|
||||
async function fetchMarketplaceThemes(id) {
|
||||
const trimmed = String(id || '').trim()
|
||||
|
||||
if (!ID_RE.test(trimmed)) {
|
||||
throw new Error('Expected a Marketplace id like "publisher.extension".')
|
||||
}
|
||||
|
||||
const { displayName, vsixUrl } = await resolveExtension(trimmed)
|
||||
const vsix = await request(vsixUrl, { headers: { 'User-Agent': 'Hermes-Desktop' } })
|
||||
const themes = extractThemes(vsix)
|
||||
|
||||
return { extensionId: trimmed, displayName, themes }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchMarketplaceThemes,
|
||||
searchMarketplaceThemes,
|
||||
extractThemes,
|
||||
readCentralDirectory,
|
||||
__testing: { themeEntryName, looksLikeIconTheme }
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert')
|
||||
const test = require('node:test')
|
||||
|
||||
const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs')
|
||||
|
||||
// Build a minimal zip with stored (uncompressed) entries so the test controls
|
||||
// the bytes exactly — exercises the central-directory reader + theme extraction
|
||||
// without a deflate dependency.
|
||||
function makeZip(entries) {
|
||||
const locals = []
|
||||
const centrals = []
|
||||
let offset = 0
|
||||
|
||||
for (const { name, data } of entries) {
|
||||
const nameBuf = Buffer.from(name, 'utf8')
|
||||
const body = Buffer.from(data, 'utf8')
|
||||
|
||||
const local = Buffer.alloc(30 + nameBuf.length)
|
||||
local.writeUInt32LE(0x04034b50, 0)
|
||||
local.writeUInt16LE(0, 8) // method: stored
|
||||
local.writeUInt32LE(body.length, 18) // compressed size
|
||||
local.writeUInt32LE(body.length, 22) // uncompressed size
|
||||
local.writeUInt16LE(nameBuf.length, 26)
|
||||
nameBuf.copy(local, 30)
|
||||
|
||||
locals.push(local, body)
|
||||
|
||||
const central = Buffer.alloc(46 + nameBuf.length)
|
||||
central.writeUInt32LE(0x02014b50, 0)
|
||||
central.writeUInt16LE(0, 10) // method: stored
|
||||
central.writeUInt32LE(body.length, 20)
|
||||
central.writeUInt32LE(body.length, 24)
|
||||
central.writeUInt16LE(nameBuf.length, 28)
|
||||
central.writeUInt32LE(offset, 42) // local header offset
|
||||
nameBuf.copy(central, 46)
|
||||
|
||||
centrals.push(central)
|
||||
offset += local.length + body.length
|
||||
}
|
||||
|
||||
const centralStart = offset
|
||||
const centralBuf = Buffer.concat(centrals)
|
||||
|
||||
const eocd = Buffer.alloc(22)
|
||||
eocd.writeUInt32LE(0x06054b50, 0)
|
||||
eocd.writeUInt16LE(entries.length, 8)
|
||||
eocd.writeUInt16LE(entries.length, 10)
|
||||
eocd.writeUInt32LE(centralBuf.length, 12)
|
||||
eocd.writeUInt32LE(centralStart, 16)
|
||||
|
||||
return Buffer.concat([...locals, centralBuf, eocd])
|
||||
}
|
||||
|
||||
test('readCentralDirectory finds every entry', () => {
|
||||
const zip = makeZip([
|
||||
{ name: 'extension/package.json', data: '{}' },
|
||||
{ name: 'extension/themes/x.json', data: '{}' }
|
||||
])
|
||||
|
||||
const records = readCentralDirectory(zip)
|
||||
assert.ok(records.has('extension/package.json'))
|
||||
assert.ok(records.has('extension/themes/x.json'))
|
||||
})
|
||||
|
||||
test('extractThemes reads contributed color themes (resolving ./ paths)', () => {
|
||||
const pkg = JSON.stringify({
|
||||
name: 'theme-dracula',
|
||||
displayName: 'Dracula',
|
||||
contributes: {
|
||||
themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }]
|
||||
}
|
||||
})
|
||||
const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } })
|
||||
|
||||
const zip = makeZip([
|
||||
{ name: 'extension/package.json', data: pkg },
|
||||
{ name: 'extension/themes/dracula.json', data: themeJson }
|
||||
])
|
||||
|
||||
const themes = extractThemes(zip)
|
||||
assert.strictEqual(themes.length, 1)
|
||||
assert.strictEqual(themes[0].label, 'Dracula')
|
||||
assert.strictEqual(themes[0].uiTheme, 'vs-dark')
|
||||
assert.match(themes[0].contents, /editor\.background/)
|
||||
})
|
||||
|
||||
test('extractThemes returns empty when the extension contributes no themes', () => {
|
||||
const zip = makeZip([{ name: 'extension/package.json', data: JSON.stringify({ name: 'x', contributes: {} }) }])
|
||||
assert.deepStrictEqual(extractThemes(zip), [])
|
||||
})
|
||||
|
||||
test('extractThemes throws when the manifest is missing', () => {
|
||||
const zip = makeZip([{ name: 'extension/other.txt', data: 'hi' }])
|
||||
assert.throws(() => extractThemes(zip), /manifest missing/i)
|
||||
})
|
||||
|
||||
test('looksLikeIconTheme filters icon/product-icon packs out of theme search', () => {
|
||||
const { looksLikeIconTheme } = __testing
|
||||
|
||||
// Tagged contribution points are the strongest signal.
|
||||
assert.strictEqual(looksLikeIconTheme({ tags: ['theme', 'icon-theme'] }), true)
|
||||
assert.strictEqual(looksLikeIconTheme({ tags: ['product-icon-theme'] }), true)
|
||||
|
||||
// Name/description fallback for packs that don't tag themselves.
|
||||
assert.strictEqual(looksLikeIconTheme({ displayName: 'Material Icon Theme' }), true)
|
||||
assert.strictEqual(looksLikeIconTheme({ shortDescription: 'A pack of file icons.' }), true)
|
||||
|
||||
// Real color themes survive.
|
||||
assert.strictEqual(looksLikeIconTheme({ displayName: 'Dracula Official', tags: ['theme', 'color-theme'] }), false)
|
||||
assert.strictEqual(looksLikeIconTheme({ displayName: 'One Dark Pro' }), false)
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const ELECTRON_DIR = __dirname
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
function requireHiddenChildOptions(source, needle) {
|
||||
const index = source.indexOf(needle)
|
||||
assert.notEqual(index, -1, `missing call site: ${needle}`)
|
||||
const snippet = source.slice(index, index + 700)
|
||||
assert.match(
|
||||
snippet,
|
||||
/hiddenWindowsChildOptions\(/,
|
||||
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
|
||||
)
|
||||
}
|
||||
|
||||
test('desktop background child processes opt into hidden Windows consoles', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||
|
||||
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
|
||||
requireHiddenChildOptions(source, 'execFileSync(pyExe')
|
||||
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
|
||||
requireHiddenChildOptions(source, "execFileSync('taskkill'")
|
||||
requireHiddenChildOptions(source, 'spawn(command, args')
|
||||
requireHiddenChildOptions(source, "spawn('curl'")
|
||||
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
|
||||
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
|
||||
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
|
||||
})
|
||||
|
||||
test('intentional or interactive desktop child processes stay documented', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
|
||||
assert.match(source, /windowsHide: false/)
|
||||
assert.match(source, /nodePty\.spawn\(command, args/)
|
||||
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
||||
})
|
||||
|
||||
test('bootstrap PowerShell runner hides Windows console children', () => {
|
||||
const source = readElectronFile('bootstrap-runner.cjs')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||
requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
const path = require('node:path')
|
||||
|
||||
/** True when `dir` lives inside a packaged app bundle / install tree. */
|
||||
function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
|
||||
if (!isPackaged || !dir) {
|
||||
return false
|
||||
}
|
||||
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = path.resolve(String(dir))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
const roots = new Set(
|
||||
(installRoots ?? [])
|
||||
.filter(Boolean)
|
||||
.map(candidate => path.resolve(String(candidate)))
|
||||
)
|
||||
|
||||
for (const root of roots) {
|
||||
if (resolved === root) {
|
||||
return true
|
||||
}
|
||||
|
||||
const rel = path.relative(root, resolved)
|
||||
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
module.exports = { isPackagedInstallPath }
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/workspace-cwd.cjs.
|
||||
*
|
||||
* Run with: node --test electron/workspace-cwd.test.cjs
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const path = require('node:path')
|
||||
|
||||
const { isPackagedInstallPath } = require('./workspace-cwd.cjs')
|
||||
|
||||
const installRoot = path.resolve('/opt/Hermes')
|
||||
|
||||
test('isPackagedInstallPath returns false when not packaged', () => {
|
||||
assert.equal(
|
||||
isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('isPackagedInstallPath flags the install root itself', () => {
|
||||
assert.equal(
|
||||
isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('isPackagedInstallPath flags paths nested under the install root', () => {
|
||||
const nested = path.join(installRoot, 'resources', 'app.asar')
|
||||
|
||||
assert.equal(
|
||||
isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('isPackagedInstallPath ignores paths outside the install root', () => {
|
||||
const homeProject = path.resolve('/home/user/projects/demo')
|
||||
|
||||
assert.equal(
|
||||
isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }),
|
||||
false
|
||||
)
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
||||
import typescriptParser from '@typescript-eslint/parser'
|
||||
import perfectionist from 'eslint-plugin-perfectionist'
|
||||
import reactPlugin from 'eslint-plugin-react'
|
||||
import reactCompiler from 'eslint-plugin-react-compiler'
|
||||
import hooksPlugin from 'eslint-plugin-react-hooks'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
import globals from 'globals'
|
||||
@@ -46,6 +47,7 @@ export default [
|
||||
'custom-rules': customRules,
|
||||
perfectionist,
|
||||
react: reactPlugin,
|
||||
'react-compiler': reactCompiler,
|
||||
'react-hooks': hooksPlugin,
|
||||
'unused-imports': unusedImports
|
||||
},
|
||||
@@ -96,6 +98,7 @@ export default [
|
||||
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'unused-imports/no-unused-imports': 'error'
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
|
||||
@@ -72,7 +72,6 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dnd-core": "^14.0.1",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hast-util-to-text": "^4.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
@@ -84,7 +83,6 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-arborist": "^3.5.0",
|
||||
"react-dnd-html5-backend": "^14.0.3",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react-shiki": "^0.9.3",
|
||||
@@ -105,19 +103,20 @@
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^10.0.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^40.9.3",
|
||||
"electron-builder": "^26.8.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-perfectionist": "^5.9.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"globals": "^16.5.0",
|
||||
@@ -134,14 +133,6 @@
|
||||
"appId": "com.nousresearch.hermes",
|
||||
"productName": "Hermes",
|
||||
"executableName": "Hermes",
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Hermes Protocol",
|
||||
"schemes": [
|
||||
"hermes"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "Hermes-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "assets/icon",
|
||||
"directories": {
|
||||
|
||||
|
Before Width: | Height: | Size: 528 KiB After Width: | Height: | Size: 1.1 MiB |
@@ -18,7 +18,7 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
|
||||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
@@ -388,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const sessions = (await listAllProfileSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile)))
|
||||
const sessions = (await listSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
|
||||
const nextArtifacts: ArtifactRecord[] = []
|
||||
|
||||
results.forEach((result, index) => {
|
||||
|
||||
@@ -3,9 +3,8 @@ import { useStore } from '@nanostores/react'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons'
|
||||
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
@@ -32,9 +31,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
const c = t.composer
|
||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
||||
const cwd = useStore($currentCwd)
|
||||
const isUploading = attachment.uploadState === 'uploading'
|
||||
const hasUploadError = attachment.uploadState === 'error'
|
||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
|
||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
|
||||
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
||||
|
||||
async function openPreview() {
|
||||
@@ -62,15 +59,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
throw new Error(c.couldNotPreview(attachment.label))
|
||||
}
|
||||
|
||||
// We already hold the image bytes (the card thumbnail) — render those
|
||||
// directly so a screenshot/clipboard image previews even when its only
|
||||
// on-disk copy is a transient path the renderer can't re-read.
|
||||
const withBytes =
|
||||
attachment.kind === 'image' && attachment.previewUrl
|
||||
? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
|
||||
: preview
|
||||
|
||||
setCurrentSessionPreviewTarget(withBytes, 'manual', target)
|
||||
setCurrentSessionPreviewTarget(preview, 'manual', target)
|
||||
} catch (error) {
|
||||
notifyError(error, c.previewUnavailable)
|
||||
}
|
||||
@@ -80,51 +69,30 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||
<div className="group/attachment relative min-w-0 shrink-0">
|
||||
<button
|
||||
aria-busy={isUploading || undefined}
|
||||
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
||||
className={cn(
|
||||
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
|
||||
hasUploadError
|
||||
? 'border-destructive/45 hover:border-destructive/60'
|
||||
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
|
||||
)}
|
||||
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openPreview()}
|
||||
type="button"
|
||||
>
|
||||
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-full object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-8 shrink-0 border border-border/70 object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
)}
|
||||
{isUploading && (
|
||||
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
|
||||
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
|
||||
</span>
|
||||
)}
|
||||
{hasUploadError && (
|
||||
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
|
||||
<AlertCircle className="size-3.5 text-destructive" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||
{attachment.label}
|
||||
</span>
|
||||
{detail && (
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-[0.62rem] leading-3.5',
|
||||
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
|
||||
)}
|
||||
>
|
||||
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -3,25 +3,32 @@ 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',
|
||||
'absolute bottom-[calc(100%+0.25rem)] left-0 z-50',
|
||||
'w-60 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-lg border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-md',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
|
||||
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',
|
||||
'absolute left-0 top-[calc(100%+0.25rem)] z-50',
|
||||
'w-60 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-lg border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-md',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
|
||||
export const COMPLETION_DRAWER_ROW_CLASS = [
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1',
|
||||
'w-full min-w-0 text-left text-xs outline-hidden transition-colors',
|
||||
'hover:bg-(--ui-bg-tertiary)',
|
||||
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
|
||||
].join(' ')
|
||||
|
||||
export function ComposerCompletionDrawer({
|
||||
adapter,
|
||||
ariaLabel,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
@@ -63,7 +62,6 @@ export function ComposerControls({
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
@@ -75,9 +73,9 @@ 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={c.steer}>
|
||||
<Button
|
||||
aria-label={steerLabel}
|
||||
aria-label={c.steer}
|
||||
className={GHOST_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={onSteer}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// No global setupFiles registers auto-cleanup, so unmount between tests —
|
||||
// otherwise a second render() leaks the first editor and getByTestId('editor')
|
||||
// matches multiple nodes.
|
||||
afterEach(cleanup)
|
||||
|
||||
// Faithful mirror of index.tsx's Enter wiring (handleEditorKeyDown's Enter
|
||||
// branch + submitDraft), driven through REAL DOM keydown events on a
|
||||
// contentEditable.
|
||||
//
|
||||
// Regression repro for #39630: pressing Enter right after typing (fast typing /
|
||||
// IME) did nothing. The composer state (`draft` from useAuiState) and its
|
||||
// derived `hasComposerPayload` lag the DOM by a render, so the keydown handler
|
||||
// read empty state and either dropped the message, drained a queued prompt
|
||||
// instead of sending, or (while busy) refused to queue. The fix reads the live
|
||||
// editor text — `hasLivePayload` in the handler and a DOM re-sync at the top of
|
||||
// submitDraft — so the just-typed text always wins.
|
||||
//
|
||||
// We model the race deterministically the way the IME repro does: mutate the
|
||||
// editor's textContent WITHOUT firing an input event, so the React `draft`
|
||||
// state stays stale while the DOM already holds the text.
|
||||
function Harness({
|
||||
busy = false,
|
||||
queued = [],
|
||||
onSubmit,
|
||||
onQueue,
|
||||
onCancel,
|
||||
onDrain
|
||||
}: {
|
||||
busy?: boolean
|
||||
queued?: readonly string[]
|
||||
onSubmit: (text: string) => void
|
||||
onQueue: (text: string) => void
|
||||
onCancel: () => void
|
||||
onDrain: () => void
|
||||
}) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const draftRef = useRef('')
|
||||
// Mirrors `useAuiState(s => s.composer.text)` — updated only via setText, so
|
||||
// it lags the DOM until React re-renders (the source of the bug).
|
||||
const [draft, setDraft] = useState('')
|
||||
const attachments: unknown[] = []
|
||||
|
||||
const composerPlainText = (el: HTMLElement) => el.textContent ?? ''
|
||||
|
||||
const setText = (next: string) => {
|
||||
draftRef.current = next
|
||||
setDraft(next)
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
const domText = composerPlainText(editor)
|
||||
if (domText !== draftRef.current) {
|
||||
draftRef.current = domText
|
||||
setDraft(domText)
|
||||
}
|
||||
}
|
||||
|
||||
const text = draftRef.current
|
||||
const payloadPresent = text.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (busy) {
|
||||
if (payloadPresent) {
|
||||
onQueue(text)
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
} else if (!payloadPresent && queued.length > 0) {
|
||||
onDrain()
|
||||
} else if (payloadPresent) {
|
||||
onSubmit(text)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (!busy && !hasLivePayload && queued.length > 0) {
|
||||
onDrain()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (busy && !hasLivePayload) {
|
||||
return
|
||||
}
|
||||
|
||||
submitDraft()
|
||||
}
|
||||
}
|
||||
|
||||
// `draft` is read so the lint/compiler treats the stale-state mirror as live;
|
||||
// the assertions prove the handler never relies on it.
|
||||
void draft
|
||||
|
||||
return (
|
||||
<div
|
||||
contentEditable
|
||||
data-testid="editor"
|
||||
onInput={event => setText(composerPlainText(event.currentTarget))}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={editorRef}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
|
||||
it('sends the just-typed text on Enter even when composer state has not synced', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
|
||||
)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
// Fast typing: the DOM has the text but NO input event fired, so `draft`
|
||||
// state is still empty (the exact stale-state race).
|
||||
await act(async () => {
|
||||
editor.textContent = 'hello world'
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('hello world')
|
||||
})
|
||||
|
||||
it('queues a fast-typed message while busy instead of draining the queue or cancelling', async () => {
|
||||
const onQueue = vi.fn()
|
||||
const onDrain = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />
|
||||
)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
editor.textContent = 'urgent follow-up'
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(onQueue).toHaveBeenCalledWith('urgent follow-up')
|
||||
expect(onDrain).not.toHaveBeenCalled()
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => {
|
||||
const onCancel = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
const onQueue = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
|
||||
)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
editor.textContent = ''
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(onQueue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
|
||||
const onDrain = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
|
||||
)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
editor.textContent = ''
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(onDrain).toHaveBeenCalledTimes(1)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,13 +5,6 @@ export interface CompletionEntry {
|
||||
text: string
|
||||
display?: unknown
|
||||
meta?: unknown
|
||||
/** Optional section label (e.g. "Commands", "Skills"). The popover renders a
|
||||
* header whenever this changes between consecutive items, so the fetcher must
|
||||
* emit entries already grouped contiguously. */
|
||||
group?: string
|
||||
/** Optional completion-action id. When set, picking the item runs that action
|
||||
* (e.g. opening an overlay) instead of inserting a chip + waiting for submit. */
|
||||
action?: string
|
||||
}
|
||||
|
||||
export interface CompletionPayload {
|
||||
|
||||
@@ -2,17 +2,12 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
type CommandsCatalogLike,
|
||||
desktopSkinSlashCompletions,
|
||||
desktopSlashDescription,
|
||||
type DesktopThemeCommandOption,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashExtensionCommand,
|
||||
isDesktopSlashSuggestion
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { $sessions } from '@/store/session'
|
||||
|
||||
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
|
||||
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
|
||||
@@ -21,10 +16,7 @@ interface SlashItemMetadata extends Record<string, string> {
|
||||
command: string
|
||||
display: string
|
||||
meta: string
|
||||
group: string
|
||||
rawText: string
|
||||
/** Completion-action id; empty for ordinary insert-a-chip completions. */
|
||||
action: string
|
||||
}
|
||||
|
||||
function textValue(value: unknown, fallback = ''): string {
|
||||
@@ -46,21 +38,12 @@ function commandText(value: string): string {
|
||||
return value.startsWith('/') ? value : `/${value}`
|
||||
}
|
||||
|
||||
/** How many recent sessions to surface inline before the "Browse all…" entry. */
|
||||
const SESSION_INLINE_LIMIT = 7
|
||||
|
||||
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
|
||||
export function useSlashCompletions(options: {
|
||||
gateway: HermesGateway | null
|
||||
/** Desktop theme list — `/skin` is owned client-side, so its arg completions
|
||||
* come from here, not the backend (whose skin list is CLI/TUI-only). */
|
||||
skinThemes?: DesktopThemeCommandOption[]
|
||||
activeSkin?: string
|
||||
}): {
|
||||
export function useSlashCompletions(options: { gateway: HermesGateway | null }): {
|
||||
adapter: Unstable_TriggerAdapter
|
||||
loading: boolean
|
||||
} {
|
||||
const { gateway, skinThemes, activeSkin } = options
|
||||
const { gateway } = options
|
||||
const enabled = Boolean(gateway)
|
||||
|
||||
const fetcher = useCallback(
|
||||
@@ -71,136 +54,34 @@ export function useSlashCompletions(options: {
|
||||
|
||||
const text = `/${query}`
|
||||
|
||||
// The desktop owns /skin entirely (client-side theme context). Surface its
|
||||
// theme list inside this single popover instead of a bespoke one, and skip
|
||||
// the backend skin completions (which describe CLI/TUI skins that don't
|
||||
// apply here). Matches once we're past `/skin ` into the arg stage.
|
||||
const skinArg = /^\/skin\s+(.*)$/is.exec(text)
|
||||
|
||||
if (skinArg && skinThemes) {
|
||||
const items = desktopSkinSlashCompletions(skinThemes, activeSkin ?? '', skinArg[1] ?? '').map(entry => ({
|
||||
text: entry.text,
|
||||
display: entry.display,
|
||||
meta: entry.meta,
|
||||
group: 'Themes'
|
||||
}))
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
// /resume (and its aliases) completes recent sessions inline — the same
|
||||
// client-side list the picker overlay shows — instead of the backend
|
||||
// (whose /resume opens an interactive TUI picker we can't render here).
|
||||
const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text)
|
||||
|
||||
if (sessionArg) {
|
||||
const needle = (sessionArg[1] ?? '').trim().toLowerCase()
|
||||
|
||||
const matches = (
|
||||
needle
|
||||
? $sessions.get().filter(
|
||||
session =>
|
||||
sessionTitle(session).toLowerCase().includes(needle) ||
|
||||
(session.preview ?? '').toLowerCase().includes(needle) ||
|
||||
session.id.toLowerCase().includes(needle)
|
||||
)
|
||||
: $sessions.get()
|
||||
).slice(0, SESSION_INLINE_LIMIT)
|
||||
|
||||
const items: CompletionEntry[] = matches.map(session => ({
|
||||
text: `/resume ${session.id}`,
|
||||
display: sessionTitle(session),
|
||||
meta: (session.preview ?? '').trim(),
|
||||
group: 'Sessions'
|
||||
}))
|
||||
|
||||
// Trailing "more" affordance (Cursor-style): picking it opens the full
|
||||
// session picker overlay directly. `text` stays a bare `/resume` so that
|
||||
// submitting it (Enter) still opens the overlay if the action is skipped.
|
||||
items.push({
|
||||
text: '/resume',
|
||||
display: 'Browse all sessions…',
|
||||
meta: '',
|
||||
group: 'Sessions',
|
||||
action: 'session-picker'
|
||||
})
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!query) {
|
||||
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
|
||||
|
||||
// Prefer the categorized layout so the popover renders section headers
|
||||
// (Session, Tools & Skills, ...). Fall back to the flat list when the
|
||||
// backend didn't categorize.
|
||||
const sections = catalog.categories?.length
|
||||
? catalog.categories
|
||||
: [{ name: '', pairs: catalog.pairs ?? [] }]
|
||||
|
||||
const items = sections.flatMap(section =>
|
||||
section.pairs.map(([command, meta]) => ({
|
||||
text: command,
|
||||
display: command,
|
||||
group: section.name || undefined,
|
||||
meta
|
||||
}))
|
||||
)
|
||||
const items = (catalog.pairs ?? []).map(([command, meta]) => ({
|
||||
text: command,
|
||||
display: command,
|
||||
meta
|
||||
}))
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
|
||||
'complete.slash',
|
||||
{ text }
|
||||
)
|
||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
|
||||
|
||||
// Arg-completion items (replace_from > 1) carry just the arg stub —
|
||||
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
|
||||
// with replace_from = 14. Rewrite those entries so the popover inserts
|
||||
// the full `/personality alice` token instead of stranding `/alice`.
|
||||
const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1
|
||||
const isArgCompletion = replaceFrom > 1
|
||||
const prefix = isArgCompletion ? text.slice(0, replaceFrom) : ''
|
||||
|
||||
const decorated = (result.items ?? [])
|
||||
.map(item => {
|
||||
if (!isArgCompletion) {
|
||||
return item
|
||||
}
|
||||
|
||||
const argText = typeof item.text === 'string' ? item.text : ''
|
||||
|
||||
return { ...item, text: `${prefix}${argText}` }
|
||||
})
|
||||
.filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text))
|
||||
const items = (result.items ?? [])
|
||||
.filter(item => isDesktopSlashSuggestion(item.text))
|
||||
.map(item => ({
|
||||
...item,
|
||||
// Arg suggestions (e.g. `/handoff <platform>`) live under one
|
||||
// header; otherwise split skills out from built-in commands.
|
||||
group: isArgCompletion ? 'Options' : isDesktopSlashExtensionCommand(item.text) ? 'Skills' : 'Commands',
|
||||
// Arg items carry their own meta (the personality/toolset/platform
|
||||
// blurb). Only command rows get the registry description — looking
|
||||
// one up for `/personality none` would clobber it with the parent
|
||||
// command's text.
|
||||
meta: isArgCompletion ? textValue(item.meta) : desktopSlashDescription(item.text, textValue(item.meta))
|
||||
meta: desktopSlashDescription(item.text, textValue(item.meta))
|
||||
}))
|
||||
|
||||
// Keep each group contiguous so headers render once: Commands before
|
||||
// Skills (stable within a group, preserving backend relevance order).
|
||||
const groupOrder = ['Commands', 'Skills', 'Options']
|
||||
|
||||
const items = isArgCompletion
|
||||
? decorated
|
||||
: [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
|
||||
|
||||
return { items, query }
|
||||
} catch {
|
||||
return { items: [], query }
|
||||
}
|
||||
},
|
||||
[gateway, skinThemes, activeSkin]
|
||||
[gateway]
|
||||
)
|
||||
|
||||
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
|
||||
@@ -212,8 +93,6 @@ export function useSlashCompletions(options: {
|
||||
command,
|
||||
display,
|
||||
meta,
|
||||
group: textValue(entry.group),
|
||||
action: textValue(entry.action),
|
||||
// Provide rawText so hermesDirectiveFormatter.serialize uses the
|
||||
// direct-insertion path instead of the legacy @type:id fallback.
|
||||
// Without this, the item.id (which includes a "|index" suffix for
|
||||
|
||||
@@ -13,25 +13,17 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$composerAttachments,
|
||||
clearComposerAttachments,
|
||||
clearSessionDraft,
|
||||
type ComposerAttachment,
|
||||
stashSessionDraft,
|
||||
takeSessionDraft
|
||||
} from '@/store/composer'
|
||||
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import {
|
||||
browseBackward,
|
||||
browseForward,
|
||||
@@ -48,11 +40,10 @@ import {
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $gatewayState, $messages } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { useTheme } from '@/themes'
|
||||
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions'
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
|
||||
|
||||
import { AttachmentList } from './attachments'
|
||||
import { ContextMenu } from './context-menu'
|
||||
@@ -73,7 +64,7 @@ import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
droppedFileInlineRefs,
|
||||
droppedFileInlineRef,
|
||||
type InlineRefInput,
|
||||
insertInlineRefsIntoEditor
|
||||
} from './inline-refs'
|
||||
@@ -83,9 +74,9 @@ import {
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT,
|
||||
slashChipElement
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { SkinSlashPopover } from './skin-slash-popover'
|
||||
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
@@ -104,30 +95,6 @@ const COMPOSER_FADE_BACKGROUND =
|
||||
|
||||
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
||||
|
||||
/** Completion items can carry an `action` (set in use-slash-completions) that
|
||||
* runs a side effect on pick instead of inserting a chip — e.g. the session
|
||||
* picker's "Browse all…" entry opens the overlay. Table-driven so new action
|
||||
* items are a registry row, not a composer branch. */
|
||||
const COMPLETION_ACTIONS: Record<string, () => void> = {
|
||||
'session-picker': () => setSessionPickerOpen(true)
|
||||
}
|
||||
|
||||
/** Map a picked `/` completion to its pill accent. Driven by the completion
|
||||
* group set in use-slash-completions (Skills / Themes / Commands|Options). */
|
||||
function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
|
||||
const group = (item.metadata as { group?: unknown } | undefined)?.group
|
||||
|
||||
if (group === 'Skills') {
|
||||
return 'skill'
|
||||
}
|
||||
|
||||
if (group === 'Themes') {
|
||||
return 'theme'
|
||||
}
|
||||
|
||||
return 'command'
|
||||
}
|
||||
|
||||
interface QueueEditState {
|
||||
attachments: ComposerAttachment[]
|
||||
draft: string
|
||||
@@ -137,10 +104,6 @@ interface QueueEditState {
|
||||
|
||||
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
|
||||
|
||||
// Quiet period after the last keystroke before persisting the draft;
|
||||
// unmount/pagehide flushes bypass it.
|
||||
const DRAFT_PERSIST_DEBOUNCE_MS = 400
|
||||
|
||||
export function ChatBar({
|
||||
busy,
|
||||
cwd,
|
||||
@@ -182,9 +145,6 @@ export function ChatBar({
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
|
||||
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
|
||||
activeQueueSessionKeyRef.current = activeQueueSessionKey
|
||||
const drainingQueueRef = useRef(false)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -196,17 +156,14 @@ export function ChatBar({
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const queueEditRef = useRef(queueEdit)
|
||||
queueEditRef.current = queueEdit
|
||||
const dragDepthRef = useRef(0)
|
||||
const composingRef = useRef(false) // true during IME composition (CJK input)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
|
||||
const { availableThemes, themeName } = useTheme()
|
||||
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
|
||||
const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
|
||||
const slash = useSlashCompletions({ gateway: gateway ?? null })
|
||||
|
||||
const stacked = expanded || narrow || tight
|
||||
const trimmedDraft = draft.trim()
|
||||
@@ -214,12 +171,10 @@ export function ChatBar({
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
|
||||
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
|
||||
// into a tool result) and never for a slash command (those execute inline).
|
||||
const canSteer =
|
||||
busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
|
||||
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -507,6 +462,12 @@ export function ChatBar({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectSkinSlashCommand = (command: string) => {
|
||||
draftRef.current = command
|
||||
aui.composer().setText(command)
|
||||
requestMainFocus()
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
@@ -659,50 +620,16 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Action items (e.g. "Browse all sessions…") run a side effect instead of
|
||||
// inserting a chip: strip the typed trigger token, then fire the action.
|
||||
const completionAction = (item.metadata as { action?: unknown } | undefined)?.action
|
||||
const runAction = typeof completionAction === 'string' ? COMPLETION_ACTIONS[completionAction] : undefined
|
||||
|
||||
if (runAction) {
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
renderComposerContents(editor, prefix)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
closeTrigger()
|
||||
runAction()
|
||||
requestMainFocus()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
const starter = serialized.endsWith(':')
|
||||
|
||||
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
|
||||
// it — expand to its options step so the popover shows the inline list, just
|
||||
// as typing `/personality ` by hand would. A serialized value with a space is
|
||||
// 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 text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
// No pill while expanding — the bare command stays plain text until an arg
|
||||
// is picked, at which point a single pill is emitted for the full command.
|
||||
const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
|
||||
const keepTriggerOpen = starter || expandsToArgs
|
||||
|
||||
const finish = () => {
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
requestMainFocus()
|
||||
keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
}
|
||||
|
||||
const sel = window.getSelection()
|
||||
@@ -712,20 +639,7 @@ export function ChatBar({
|
||||
|
||||
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
if (slashKind) {
|
||||
// Two-step arg picks (e.g. `/handoff` pill already inserted, now picking
|
||||
// the platform) land here because the caret sits past a contenteditable
|
||||
// chip. Rebuild the prefix and re-emit a single pill for the full command.
|
||||
renderComposerContents(editor, prefix)
|
||||
editor.append(slashChipElement(serialized, slashKind), document.createTextNode(' '))
|
||||
placeCaretEnd(editor)
|
||||
|
||||
return finish()
|
||||
}
|
||||
|
||||
renderComposerContents(editor, `${prefix}${text}`)
|
||||
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
|
||||
placeCaretEnd(editor)
|
||||
|
||||
return finish()
|
||||
@@ -736,13 +650,8 @@ export function ChatBar({
|
||||
replaceRange.setEnd(node, offset)
|
||||
replaceRange.deleteContents()
|
||||
|
||||
const chip = slashKind
|
||||
? slashChipElement(serialized, slashKind)
|
||||
: directive
|
||||
? refChipElement(directive[1], directive[2])
|
||||
: null
|
||||
|
||||
if (chip) {
|
||||
if (directive) {
|
||||
const chip = refChipElement(directive[1], directive[2])
|
||||
const space = document.createTextNode(' ')
|
||||
const fragment = document.createDocumentFragment()
|
||||
fragment.append(chip, space)
|
||||
@@ -905,16 +814,7 @@ export function ChatBar({
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
// Decide from the DOM, not React state. `hasComposerPayload` is derived
|
||||
// from the AUI composer state, which lags the latest keystroke by a
|
||||
// render, so on fast typing / IME the just-typed text isn't in state yet.
|
||||
// Without the live read, a real message typed while prompts are queued
|
||||
// would drain the queue instead of sending. submitDraft() re-syncs and
|
||||
// sends the live editor text.
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
|
||||
if (!busy && !hasComposerPayload && queuedPrompts.length > 0) {
|
||||
void drainNextQueued()
|
||||
|
||||
return
|
||||
@@ -922,10 +822,7 @@ export function ChatBar({
|
||||
|
||||
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
|
||||
// never a stray Enter after sending. With a payload, submitDraft queues it.
|
||||
// Gate on the live DOM payload (not the render-lagged composer state) so a
|
||||
// message typed fast / via IME while busy still reaches submitDraft() and
|
||||
// gets queued instead of being mistaken for an empty Enter.
|
||||
if (busy && !hasLivePayload) {
|
||||
if (busy && !hasComposerPayload) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1022,25 +919,24 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// In-app drags (project tree / gutter) are workspace-relative paths the
|
||||
// gateway resolves directly, so they stay inline @file:/@line: refs. OS
|
||||
// drops are absolute local paths a remote gateway can't read (and images
|
||||
// need byte upload for vision), so route them through the upload pipeline.
|
||||
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||
const refs = droppedFileInlineRefs(inAppRefs, cwd)
|
||||
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
|
||||
const refs = candidates
|
||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
||||
.filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
if (refs.length && insertInlineRefs(refs)) {
|
||||
triggerHaptic('selection')
|
||||
if (insertInlineRefs(refs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (osDrops.length) {
|
||||
void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => {
|
||||
if (attached) {
|
||||
triggerHaptic('selection')
|
||||
requestMainFocus()
|
||||
}
|
||||
})
|
||||
}
|
||||
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
|
||||
if (attached) {
|
||||
triggerHaptic('selection')
|
||||
requestMainFocus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||
@@ -1060,7 +956,11 @@ export function ChatBar({
|
||||
|
||||
const candidates = extractDroppedFiles(event.dataTransfer)
|
||||
|
||||
if (!candidates.length) {
|
||||
const refs = candidates
|
||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
||||
.filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
if (!refs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1068,27 +968,9 @@ export function ChatBar({
|
||||
event.stopPropagation()
|
||||
resetDragState()
|
||||
|
||||
// Dropping straight onto the text box used to inline-ref *every* file —
|
||||
// including OS/Finder drops, whose absolute local path a remote gateway
|
||||
// can't read and whose image bytes never reached vision. Split by origin:
|
||||
// in-app drags stay inline refs; OS drops go through the upload pipeline.
|
||||
// (When no upload handler is wired, fall back to inline refs for all.)
|
||||
const attach = onAttachDroppedItems
|
||||
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||
const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd)
|
||||
|
||||
if (refs.length && insertInlineRefs(refs)) {
|
||||
if (insertInlineRefs(refs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
|
||||
if (attach && osDrops.length) {
|
||||
void Promise.resolve(attach(osDrops)).then(attached => {
|
||||
if (attached) {
|
||||
triggerHaptic('selection')
|
||||
requestMainFocus()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const clearDraft = useCallback(() => {
|
||||
@@ -1113,69 +995,6 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// on enter. Keyed writes are idempotent, so no skip-sentinel.
|
||||
useEffect(() => {
|
||||
const { attachments, text } = takeSessionDraft(activeQueueSessionKey)
|
||||
loadIntoComposer(text, attachments)
|
||||
|
||||
return () => {
|
||||
const editing = queueEditRef.current
|
||||
|
||||
if (editing?.sessionKey === activeQueueSessionKey) {
|
||||
stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
|
||||
} else if (!isBrowsingHistory(sessionId)) {
|
||||
stashAt(activeQueueSessionKey)
|
||||
}
|
||||
}
|
||||
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Debounced stash into the active scope. Skipped while browsing history or
|
||||
// editing a queued prompt — recalled text must not clobber the real draft.
|
||||
useEffect(() => {
|
||||
if (isBrowsingHistory(sessionId) || queueEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft }
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
pendingDraftPersistRef.current = null
|
||||
stashAt(activeQueueSessionKey, draft)
|
||||
}, DRAFT_PERSIST_DEBOUNCE_MS)
|
||||
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [activeQueueSessionKey, draft, queueEdit, sessionId])
|
||||
|
||||
// pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
|
||||
// inside the debounce window would drop trailing keystrokes without this.
|
||||
useEffect(() => {
|
||||
const flushPendingDraftPersist = () => {
|
||||
const pending = pendingDraftPersistRef.current
|
||||
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDraftPersistRef.current = null
|
||||
stashAt(pending.scope, pending.text)
|
||||
}
|
||||
|
||||
window.addEventListener('pagehide', flushPendingDraftPersist)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', flushPendingDraftPersist)
|
||||
flushPendingDraftPersist()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
|
||||
if (!activeQueueSessionKey || queueEdit) {
|
||||
return
|
||||
@@ -1378,61 +1197,21 @@ export function ChatBar({
|
||||
}
|
||||
}, [busy, drainNextQueued, queuedPrompts.length])
|
||||
|
||||
// Queue-edit cleanup: on session swap the scope effect already stashed the
|
||||
// edit snapshot; only restore into the composer when still on the same scope.
|
||||
// Clean up queue edit when its target disappears (session swap or external delete).
|
||||
useEffect(() => {
|
||||
if (!queueEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
if (queueEdit.sessionKey === activeQueueSessionKey) {
|
||||
if (editingQueuedPrompt) {
|
||||
return
|
||||
}
|
||||
|
||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
|
||||
return
|
||||
}
|
||||
|
||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||
setQueueEdit(null)
|
||||
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
|
||||
const submittedScope = activeQueueSessionKeyRef.current
|
||||
const submittedAttachments = attachments ?? []
|
||||
|
||||
const restore = () => {
|
||||
loadIntoComposer(text, submittedAttachments)
|
||||
stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)
|
||||
}
|
||||
|
||||
void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))
|
||||
.then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
|
||||
.catch(restore)
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
// Source the text from the DOM editor, not React state. The AUI composer
|
||||
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
|
||||
// render, so on fast typing or IME composition the final keystroke(s) may
|
||||
// not have synced yet — reading state here drops the message (Enter looks
|
||||
// like it does nothing; typing a trailing space only "fixes" it because the
|
||||
// extra input event forces a state sync). draftRef is updated on every
|
||||
// input event; refresh it from the editor once more to also cover an
|
||||
// in-flight keystroke that hasn't fired its input event yet.
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
const domText = composerPlainText(editor)
|
||||
|
||||
if (domText !== draftRef.current) {
|
||||
draftRef.current = domText
|
||||
aui.composer().setText(domText)
|
||||
}
|
||||
}
|
||||
|
||||
const text = draftRef.current
|
||||
const payloadPresent = text.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (queueEdit) {
|
||||
exitQueuedEdit('save')
|
||||
} else if (busy) {
|
||||
@@ -1443,11 +1222,12 @@ export function ChatBar({
|
||||
// busy guard for commands that genuinely need an idle session (skill
|
||||
// /send directives). Queuing them would make every slash command wait
|
||||
// for the current turn to finish, which is how the TUI never behaves.
|
||||
if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
|
||||
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
|
||||
const submitted = draft
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
dispatchSubmit(text)
|
||||
} else if (payloadPresent) {
|
||||
void onSubmit(submitted)
|
||||
} else if (hasComposerPayload) {
|
||||
queueCurrentDraft()
|
||||
} else {
|
||||
// Stop button (the only way to reach here while busy with an empty
|
||||
@@ -1455,15 +1235,15 @@ export function ChatBar({
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
} else if (!payloadPresent && queuedPrompts.length > 0) {
|
||||
} else if (!hasComposerPayload && queuedPrompts.length > 0) {
|
||||
void drainNextQueued()
|
||||
} else if (payloadPresent) {
|
||||
const submittedAttachments = cloneAttachments(attachments)
|
||||
} else if (draft.trim() || attachments.length > 0) {
|
||||
const submitted = draft
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
dispatchSubmit(text, submittedAttachments)
|
||||
void onSubmit(submitted, { attachments })
|
||||
}
|
||||
|
||||
focusInput()
|
||||
@@ -1630,7 +1410,7 @@ export function ChatBar({
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
spellCheck="true"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
|
||||
@@ -1649,15 +1429,7 @@ export function ChatBar({
|
||||
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
|
||||
plain <textarea>, which carries the binding but skips autosize. */}
|
||||
<ComposerPrimitive.Input asChild submitMode="ctrlEnter" tabIndex={-1} unstable_focusOnScrollToBottom={false}>
|
||||
<textarea
|
||||
aria-hidden
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="sr-only"
|
||||
spellCheck={false}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<textarea aria-hidden className="sr-only" tabIndex={-1} />
|
||||
</ComposerPrimitive.Input>
|
||||
</div>
|
||||
)
|
||||
@@ -1696,6 +1468,7 @@ export function ChatBar({
|
||||
onPick={replaceTriggerWithChip}
|
||||
/>
|
||||
)}
|
||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
||||
{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
|
||||
|
||||
@@ -83,12 +83,6 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
|
||||
return `@${kind}:${formatRefValue(rel)}`
|
||||
}
|
||||
|
||||
/** Resolve a batch of drops to their inline `@file:`/`@line:`/`@folder:` refs,
|
||||
* dropping any that carry no path. */
|
||||
export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | null | undefined): string[] {
|
||||
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) {
|
||||
return null
|
||||
|
||||
@@ -10,10 +10,7 @@ import {
|
||||
DIRECTIVE_CHIP_CLASS,
|
||||
directiveIconElement,
|
||||
directiveIconSvg,
|
||||
formatRefValue,
|
||||
slashChipClass,
|
||||
type SlashChipKind,
|
||||
slashIconElement
|
||||
formatRefValue
|
||||
} from '@/components/assistant-ui/directive-text'
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
@@ -80,24 +77,6 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st
|
||||
return chip
|
||||
}
|
||||
|
||||
/** A non-editable pill for a picked slash command (`/skin nous`, `/tropes`).
|
||||
* `data-ref-text` carries the literal command so `composerPlainText` round-trips
|
||||
* it back to the exact text that gets submitted. */
|
||||
export function slashChipElement(command: string, kind: SlashChipKind, label?: string) {
|
||||
const chip = document.createElement('span')
|
||||
const text = document.createElement('span')
|
||||
|
||||
chip.contentEditable = 'false'
|
||||
chip.dataset.refText = command
|
||||
chip.dataset.slashKind = kind
|
||||
chip.className = slashChipClass(kind)
|
||||
text.className = 'truncate'
|
||||
text.textContent = label || command
|
||||
chip.append(slashIconElement(kind), text)
|
||||
|
||||
return chip
|
||||
}
|
||||
|
||||
function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
|
||||
const lines = text.split('\n')
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useI18n } from '@/i18n'
|
||||
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
|
||||
|
||||
interface SkinSlashPopoverProps {
|
||||
draft: string
|
||||
onSelect: (command: string) => void
|
||||
}
|
||||
|
||||
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const { availableThemes, themeName } = useTheme()
|
||||
const match = draft.match(/^\/skin\s+(\S*)$/i)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '')
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={c.themeSuggestions}
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
data-slot="composer-skin-completion-drawer"
|
||||
data-state="open"
|
||||
role="listbox"
|
||||
>
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={c.noMatchingThemes}>
|
||||
{c.themeTryPre}
|
||||
<span className="font-mono text-foreground/80">/skin list</span>
|
||||
{c.themeTryPost}
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map(item => (
|
||||
<button
|
||||
className={COMPLETION_DRAWER_ROW_CLASS}
|
||||
key={item.text}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onSelect(item.text)
|
||||
}}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{item.display}</span>
|
||||
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{item.meta}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,33 +22,6 @@ describe('detectTrigger', () => {
|
||||
it('returns null for plain text', () => {
|
||||
expect(detectTrigger('hello there')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the slash trigger live while typing args', () => {
|
||||
expect(detectTrigger('/personality ')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality ',
|
||||
tokenLength: 13
|
||||
})
|
||||
expect(detectTrigger('/personality alic')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality alic',
|
||||
tokenLength: 17
|
||||
})
|
||||
expect(detectTrigger('/tools enable foo')).toEqual({
|
||||
kind: '/',
|
||||
query: 'tools enable foo',
|
||||
tokenLength: 17
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat file-style paths as slash triggers', () => {
|
||||
expect(detectTrigger('src/foo/bar')).toBeNull()
|
||||
expect(detectTrigger('/path/to/file')).toBeNull()
|
||||
})
|
||||
|
||||
it('still anchors at-mention triggers strictly at the token edge', () => {
|
||||
expect(detectTrigger('@file:path with space')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractClipboardImageBlobs', () => {
|
||||
|
||||
@@ -6,13 +6,7 @@ export interface TriggerState {
|
||||
tokenLength: number
|
||||
}
|
||||
|
||||
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
|
||||
// single tokens. `/` triggers keep going so the popover stays live while the
|
||||
// user types args (`/personality alic` → arg completer suggests `alice`).
|
||||
// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file
|
||||
// paths like `src/foo/bar`.
|
||||
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
|
||||
const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
|
||||
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
|
||||
|
||||
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
|
||||
export function blobDedupeKey(blob: Blob): string {
|
||||
@@ -103,17 +97,11 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
|
||||
}
|
||||
|
||||
export function detectTrigger(textBefore: string): TriggerState | null {
|
||||
const slash = SLASH_TRIGGER_RE.exec(textBefore)
|
||||
const match = TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (slash) {
|
||||
return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length }
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const at = AT_TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (at) {
|
||||
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
|
||||
}
|
||||
|
||||
return null
|
||||
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
|
||||
}
|
||||
|
||||
@@ -34,17 +34,9 @@ describe('ComposerTriggerPopover i18n', () => {
|
||||
})
|
||||
|
||||
it('renders localized loading copy for slash commands', () => {
|
||||
renderPopover('/', true)
|
||||
const { container } = renderPopover('/', true)
|
||||
|
||||
// While loading the popover shows only the spinner + loading copy — the
|
||||
// `/help` empty-state hint is reserved for the resolved (not-loading) state.
|
||||
expect(screen.getByText('查找中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the slash empty-state hint when not loading', () => {
|
||||
const { container } = renderPopover('/')
|
||||
|
||||
expect(screen.getByText('没有匹配项。')).toBeTruthy()
|
||||
expect(container.textContent).toContain('/help')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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 { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -9,6 +7,7 @@ import { cn } from '@/lib/utils'
|
||||
import {
|
||||
COMPLETION_DRAWER_BELOW_CLASS,
|
||||
COMPLETION_DRAWER_CLASS,
|
||||
COMPLETION_DRAWER_ROW_CLASS,
|
||||
CompletionDrawerEmpty
|
||||
} from './completion-drawer'
|
||||
|
||||
@@ -24,7 +23,11 @@ const AT_ICON_BY_TYPE: Record<string, string> = {
|
||||
url: 'globe'
|
||||
}
|
||||
|
||||
function atIcon(item: Unstable_TriggerItem) {
|
||||
function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
|
||||
if (kind === '/') {
|
||||
return 'terminal'
|
||||
}
|
||||
|
||||
const meta = item.metadata as { rawText?: string } | undefined
|
||||
const raw = meta?.rawText || item.label
|
||||
|
||||
@@ -39,18 +42,6 @@ function atIcon(item: Unstable_TriggerItem) {
|
||||
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
|
||||
}
|
||||
|
||||
interface RowMeta {
|
||||
display?: string
|
||||
group?: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
const ROW_BASE_CLASS = [
|
||||
'relative flex w-full cursor-default select-none rounded-md px-2 py-1 text-left',
|
||||
'outline-hidden transition-colors hover:bg-(--ui-bg-tertiary)',
|
||||
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
|
||||
].join(' ')
|
||||
|
||||
interface ComposerTriggerPopoverProps {
|
||||
activeIndex: number
|
||||
items: readonly Unstable_TriggerItem[]
|
||||
@@ -72,9 +63,6 @@ export function ComposerTriggerPopover({
|
||||
}: ComposerTriggerPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.composer
|
||||
const isSlash = kind === '/'
|
||||
|
||||
let lastGroup: string | undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -85,94 +73,41 @@ export function ComposerTriggerPopover({
|
||||
role="listbox"
|
||||
>
|
||||
{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" />
|
||||
<span>{copy.lookupLoading}</span>
|
||||
</div>
|
||||
) : (
|
||||
<CompletionDrawerEmpty title={copy.lookupNoMatches}>
|
||||
{kind === '@' ? (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
|
||||
</>
|
||||
)}
|
||||
</CompletionDrawerEmpty>
|
||||
)
|
||||
<CompletionDrawerEmpty title={loading ? copy.lookupLoading : copy.lookupNoMatches}>
|
||||
{kind === '@' ? (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
|
||||
</>
|
||||
)}
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const meta = item.metadata as RowMeta | undefined
|
||||
const display = meta?.display ?? (isSlash ? `/${item.label}` : item.label)
|
||||
const meta = item.metadata as { display?: string; meta?: string } | undefined
|
||||
const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
|
||||
const description = meta?.meta || item.description
|
||||
const group = meta?.group?.trim()
|
||||
const showHeader = isSlash && Boolean(group) && group !== lastGroup
|
||||
const isFirstHeader = lastGroup === undefined
|
||||
lastGroup = group || lastGroup
|
||||
const active = index === activeIndex
|
||||
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
{showHeader && (
|
||||
<div
|
||||
className={cn(
|
||||
'select-none px-2 pb-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)',
|
||||
isFirstHeader ? 'pt-0.5' : 'pt-2'
|
||||
)}
|
||||
>
|
||||
{group}
|
||||
</div>
|
||||
<button
|
||||
className={cn(COMPLETION_DRAWER_ROW_CLASS, index === activeIndex && 'bg-(--ui-bg-tertiary)')}
|
||||
data-highlighted={index === activeIndex ? '' : undefined}
|
||||
key={item.id}
|
||||
onClick={() => onPick(item)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||
<Codicon name={completionIcon(kind, item)} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && (
|
||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||
)}
|
||||
<button
|
||||
className={cn(ROW_BASE_CLASS, isSlash ? 'flex-col gap-0' : 'items-center gap-2')}
|
||||
data-highlighted={active ? '' : undefined}
|
||||
onClick={() => onPick(item)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
{isSlash ? (
|
||||
<>
|
||||
{/* Active row (keyboard nav or hover) un-truncates inline so
|
||||
long command names / descriptions stay readable without a
|
||||
floating tooltip. */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.8125rem] font-medium leading-snug text-foreground',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{description && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.6875rem] leading-snug text-(--ui-text-tertiary)',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||
<Codicon name={atIcon(item)} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
|
||||
{display}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Fragment>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { type DroppedFile, partitionDroppedFiles } from './use-composer-actions'
|
||||
|
||||
// A Finder/Explorer drop carries a native File handle; an in-app drag (project
|
||||
// tree, gutter line ref) is path-only. The split decides whether a drop becomes
|
||||
// an inline @file: ref (in-app, workspace-relative, gateway-resolvable) or goes
|
||||
// through the upload pipeline (OS drop — absolute local path a remote gateway
|
||||
// can't read, plus image bytes for vision).
|
||||
const osDrop = (path: string): DroppedFile => ({ file: new File(['x'], path.split('/').pop() || 'f'), path })
|
||||
const inAppRef = (path: string, extra: Partial<DroppedFile> = {}): DroppedFile => ({ path, ...extra })
|
||||
|
||||
describe('partitionDroppedFiles', () => {
|
||||
it('routes File-bearing OS drops to osDrops and path-only in-app drags to inAppRefs', () => {
|
||||
const finderPdf = osDrop('/Users/mahmoud/Downloads/DEVIS_signed.pdf')
|
||||
const projectFile = inAppRef('src/index.ts')
|
||||
|
||||
const { inAppRefs, osDrops } = partitionDroppedFiles([finderPdf, projectFile])
|
||||
|
||||
expect(osDrops).toEqual([finderPdf])
|
||||
expect(inAppRefs).toEqual([projectFile])
|
||||
})
|
||||
|
||||
it('treats an OS screenshot drop as an upload target (so it gets byte upload + vision)', () => {
|
||||
const screenshot = osDrop('/var/folders/tmp/Screenshot 2026-06-09.png')
|
||||
|
||||
const { inAppRefs, osDrops } = partitionDroppedFiles([screenshot])
|
||||
|
||||
expect(osDrops).toEqual([screenshot])
|
||||
expect(inAppRefs).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps gutter line-range drags inline (no File handle)', () => {
|
||||
const lineRef = inAppRef('src/app.ts', { line: 10, lineEnd: 20 })
|
||||
|
||||
const { inAppRefs, osDrops } = partitionDroppedFiles([lineRef])
|
||||
|
||||
expect(osDrops).toEqual([])
|
||||
expect(inAppRefs).toEqual([lineRef])
|
||||
})
|
||||
|
||||
it('splits a mixed drop and preserves order within each group', () => {
|
||||
const a = inAppRef('a.ts')
|
||||
const b = osDrop('/abs/b.pdf')
|
||||
const c = inAppRef('c.ts')
|
||||
const d = osDrop('/abs/d.png')
|
||||
|
||||
const { inAppRefs, osDrops } = partitionDroppedFiles([a, b, c, d])
|
||||
|
||||
expect(inAppRefs).toEqual([a, c])
|
||||
expect(osDrops).toEqual([b, d])
|
||||
})
|
||||
|
||||
it('returns empty groups for an empty drop', () => {
|
||||
expect(partitionDroppedFiles([])).toEqual({ inAppRefs: [], osDrops: [] })
|
||||
})
|
||||
})
|
||||
@@ -33,7 +33,7 @@ function blobExtension(blob: Blob): string {
|
||||
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
|
||||
}
|
||||
|
||||
export function isImagePath(filePath: string): boolean {
|
||||
function isImagePath(filePath: string): boolean {
|
||||
return IMAGE_EXTENSION_PATTERN.test(filePath)
|
||||
}
|
||||
|
||||
@@ -181,35 +181,6 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Split dropped entries by origin. OS/Finder drops carry a native `File`
|
||||
* handle; in-app drags (project tree, gutter line refs) are path-only.
|
||||
*
|
||||
* The distinction is load-bearing: an in-app path is workspace-relative and
|
||||
* resolves on the gateway as-is, so it stays an inline `@file:`/`@line:` ref.
|
||||
* An OS drop is an absolute path on *this* machine — the gateway can't read it
|
||||
* in remote mode, and an image needs its bytes uploaded to get vision either
|
||||
* way. So OS drops must go through the attachment/upload pipeline rather than
|
||||
* leaking a local path into the prompt text.
|
||||
*/
|
||||
export function partitionDroppedFiles(candidates: DroppedFile[]): {
|
||||
osDrops: DroppedFile[]
|
||||
inAppRefs: DroppedFile[]
|
||||
} {
|
||||
const osDrops: DroppedFile[] = []
|
||||
const inAppRefs: DroppedFile[] = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.file) {
|
||||
osDrops.push(candidate)
|
||||
} else {
|
||||
inAppRefs.push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return { osDrops, inAppRefs }
|
||||
}
|
||||
|
||||
interface ComposerActionsOptions {
|
||||
activeSessionId: string | null
|
||||
currentCwd: string
|
||||
|
||||
@@ -49,9 +49,9 @@ import { ChatDropOverlay } from './chat-drop-overlay'
|
||||
import { ChatSwapOverlay } from './chat-swap-overlay'
|
||||
import { ChatBar, ChatBarFallback } from './composer'
|
||||
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
|
||||
import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
|
||||
import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
|
||||
import type { DroppedFile } from './hooks/use-composer-actions'
|
||||
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
|
||||
@@ -126,10 +126,7 @@ function ChatHeader({
|
||||
<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)'
|
||||
}}
|
||||
style={{ maxWidth: 'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)' }}
|
||||
>
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
@@ -302,25 +299,19 @@ export function ChatView({
|
||||
})
|
||||
|
||||
// Drop files anywhere in the conversation area, not just on the composer
|
||||
// input. In-app drags (project tree / gutter) carry workspace-relative paths
|
||||
// the gateway resolves directly, so they stay inline `@file:` refs. OS/Finder
|
||||
// drops carry absolute local paths that don't exist on a remote gateway (and
|
||||
// images need byte upload for vision), so route them through the attachment
|
||||
// pipeline — otherwise the local path leaks into the prompt verbatim.
|
||||
// input — appending the same inline `@file:` ref chips the composer drop
|
||||
// produces (vs. attachment cards) so both surfaces behave identically.
|
||||
const onDropFiles = useCallback(
|
||||
(candidates: DroppedFile[]) => {
|
||||
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||
const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
|
||||
const refs = candidates
|
||||
.map(candidate => droppedFileInlineRef(candidate, currentCwd))
|
||||
.filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
if (refs.length) {
|
||||
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
|
||||
}
|
||||
|
||||
if (osDrops.length) {
|
||||
void onAttachDroppedItems(osDrops)
|
||||
}
|
||||
},
|
||||
[currentCwd, onAttachDroppedItems]
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
// Dropping a sidebar session inserts an @session link the agent can resolve
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Streamdown } from 'streamdown'
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
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'
|
||||
|
||||
@@ -181,13 +180,15 @@ function looksBinaryBytes(bytes: Uint8Array) {
|
||||
}
|
||||
|
||||
async function readTextPreview(filePath: string) {
|
||||
try {
|
||||
return await readDesktopFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (window.hermesDesktop.readFileText) {
|
||||
try {
|
||||
return await window.hermesDesktop.readFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +288,7 @@ const MARKDOWN_COMPONENTS = {
|
||||
|
||||
function MarkdownPreview({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground" data-selectable-text="true">
|
||||
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
|
||||
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
|
||||
{text}
|
||||
</Streamdown>
|
||||
@@ -383,10 +384,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
|
||||
{selection && (
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -448,9 +446,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
|
||||
try {
|
||||
if (isImage) {
|
||||
// Prefer bytes the caller already handed us (a pasted/dropped
|
||||
// screenshot) over re-reading a path that may be transient/unreadable.
|
||||
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
|
||||
|
||||
if (active) {
|
||||
setState({ dataUrl, loading: false })
|
||||
@@ -488,7 +484,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.dataUrl, target.language])
|
||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
|
||||
|
||||
if (state.loading) {
|
||||
return <PageLoader label={t.preview.loading} />
|
||||
|
||||
@@ -1,50 +1,11 @@
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
describe('PreviewPane console state', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not watch backend-only remote filesystem previews locally', () => {
|
||||
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
|
||||
const onPreviewFileChanged = vi.fn(() => vi.fn())
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
hermesDesktop: {
|
||||
onPreviewFileChanged,
|
||||
watchPreviewFile
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<PreviewPane
|
||||
setTitlebarToolGroup={vi.fn()}
|
||||
target={{
|
||||
kind: 'file',
|
||||
label: 'file.txt',
|
||||
path: '/remote/file.txt',
|
||||
previewKind: 'text',
|
||||
source: '/remote/file.txt',
|
||||
url: 'file:///remote/file.txt'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(watchPreviewFile).not.toHaveBeenCalled()
|
||||
expect(onPreviewFileChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not rebuild the pane titlebar group for streamed console logs', () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
|
||||
import { Bug } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -407,7 +406,6 @@ export function PreviewPane({
|
||||
useEffect(() => {
|
||||
if (
|
||||
target.kind !== 'file' ||
|
||||
isDesktopFsRemoteMode() ||
|
||||
!window.hermesDesktop?.watchPreviewFile ||
|
||||
!window.hermesDesktop?.onPreviewFileChanged
|
||||
) {
|
||||
|
||||
@@ -14,8 +14,6 @@ import type { CronJob } from '@/types/hermes'
|
||||
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
|
||||
import { SidebarLoadMoreRow } from './load-more-row'
|
||||
|
||||
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
|
||||
|
||||
// Recent runs shown in the inline quick-peek — enough to glance at history
|
||||
@@ -26,11 +24,6 @@ const PEEK_RUN_LIMIT = 5
|
||||
// open peek so a freshly-fired run shows up within a few seconds.
|
||||
const PEEK_POLL_INTERVAL_MS = 8000
|
||||
|
||||
// Keep the section compact: show a few jobs up front, reveal more in larger
|
||||
// steps on demand (mirrors the messaging sections in the sidebar).
|
||||
const INITIAL_VISIBLE_JOBS = 3
|
||||
const LOAD_MORE_STEP = 10
|
||||
|
||||
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
|
||||
|
||||
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
|
||||
@@ -40,25 +33,17 @@ function relativeTime(targetMs: number, nowMs: number): string {
|
||||
const abs = Math.abs(diff)
|
||||
const sign = diff < 0 ? -1 : 1
|
||||
|
||||
if (abs < 60_000) {
|
||||
return relativeFmt.format(sign * Math.round(abs / 1000), 'second')
|
||||
}
|
||||
if (abs < 60_000) {return relativeFmt.format(sign * Math.round(abs / 1000), 'second')}
|
||||
|
||||
if (abs < 3_600_000) {
|
||||
return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')
|
||||
}
|
||||
if (abs < 3_600_000) {return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')}
|
||||
|
||||
if (abs < 86_400_000) {
|
||||
return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')
|
||||
}
|
||||
if (abs < 86_400_000) {return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')}
|
||||
|
||||
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
|
||||
}
|
||||
|
||||
function nextRunMs(job: CronJob): null | number {
|
||||
if (!job.next_run_at) {
|
||||
return null
|
||||
}
|
||||
if (!job.next_run_at) {return null}
|
||||
|
||||
const ms = Date.parse(job.next_run_at)
|
||||
|
||||
@@ -69,9 +54,7 @@ function nextRunMs(job: CronJob): null | number {
|
||||
// the timestamp is what tells them apart. Compact (no year, no seconds) for the
|
||||
// narrow sidebar.
|
||||
function formatRunTime(seconds?: null | number): string {
|
||||
if (!seconds) {
|
||||
return '—'
|
||||
}
|
||||
if (!seconds) {return '—'}
|
||||
|
||||
const date = new Date(seconds * 1000)
|
||||
|
||||
@@ -107,15 +90,11 @@ export function SidebarCronJobsSection({
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
// Single-open inline peek so the section stays scannable.
|
||||
const [peekJobId, setPeekJobId] = useState<null | string>(null)
|
||||
// Rows revealed so far; starts compact, grows in steps via "load more".
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_JOBS)
|
||||
|
||||
// One clock for the whole section (rows are pure) so the countdowns tick
|
||||
// without re-rendering the rest of the sidebar. Only runs while expanded.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
if (!open) {return}
|
||||
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
|
||||
|
||||
@@ -129,25 +108,17 @@ export function SidebarCronJobsSection({
|
||||
const an = nextRunMs(a)
|
||||
const bn = nextRunMs(b)
|
||||
|
||||
if (an !== null && bn !== null && an !== bn) {
|
||||
return an - bn
|
||||
}
|
||||
if (an !== null && bn !== null && an !== bn) {return an - bn}
|
||||
|
||||
if (an === null && bn !== null) {
|
||||
return 1
|
||||
}
|
||||
if (an === null && bn !== null) {return 1}
|
||||
|
||||
if (an !== null && bn === null) {
|
||||
return -1
|
||||
}
|
||||
if (an !== null && bn === null) {return -1}
|
||||
|
||||
return jobTitle(a).localeCompare(jobTitle(b))
|
||||
})
|
||||
}, [jobs])
|
||||
|
||||
const cap = Math.min(visibleCount, max)
|
||||
const shown = sorted.slice(0, cap)
|
||||
const hiddenCount = Math.min(sorted.length, max) - shown.length
|
||||
const shown = sorted.slice(0, max)
|
||||
// When capped, signal "50+" rather than implying the list is complete.
|
||||
const countLabel = jobs.length > max ? `${max}+` : String(jobs.length)
|
||||
|
||||
@@ -168,7 +139,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 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
|
||||
{shown.map(job => (
|
||||
<CronJobSidebarRow
|
||||
expanded={peekJobId === job.id}
|
||||
@@ -181,12 +152,6 @@ export function SidebarCronJobsSection({
|
||||
onTrigger={() => onTriggerJob(job.id)}
|
||||
/>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<SidebarLoadMoreRow
|
||||
onClick={() => setVisibleCount(count => count + LOAD_MORE_STEP)}
|
||||
step={Math.min(LOAD_MORE_STEP, hiddenCount)}
|
||||
/>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
@@ -216,7 +181,11 @@ function CronJobSidebarRow({
|
||||
const next = nextRunMs(job)
|
||||
const label = jobTitle(job)
|
||||
|
||||
const meta = INACTIVE_STATES.has(state) ? (c.states[state] ?? state) : next !== null ? relativeTime(next, nowMs) : '—'
|
||||
const meta = INACTIVE_STATES.has(state)
|
||||
? (c.states[state] ?? state)
|
||||
: next !== null
|
||||
? relativeTime(next, nowMs)
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -288,7 +257,13 @@ function CronJobSidebarRow({
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (sessionId: string) => void }) {
|
||||
function CronJobSidebarRuns({
|
||||
jobId,
|
||||
onOpenRun
|
||||
}: {
|
||||
jobId: string
|
||||
onOpenRun: (sessionId: string) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
@@ -300,22 +275,16 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
|
||||
const load = () =>
|
||||
getCronJobRuns(jobId, PEEK_RUN_LIMIT)
|
||||
.then(result => {
|
||||
if (!cancelled) {
|
||||
setRuns(result)
|
||||
}
|
||||
if (!cancelled) {setRuns(result)}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setRuns(prev => prev ?? [])
|
||||
}
|
||||
if (!cancelled) {setRuns(prev => prev ?? [])}
|
||||
})
|
||||
|
||||
void load()
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void load()
|
||||
}
|
||||
if (document.visibilityState === 'visible') {void load()}
|
||||
}, PEEK_POLL_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
$pinnedSessionIds,
|
||||
$sidebarAgentsGrouped,
|
||||
$sidebarCronOpen,
|
||||
$sidebarMessagingOpenIds,
|
||||
$sidebarOpen,
|
||||
$sidebarOverlayMounted,
|
||||
$sidebarPinsOpen,
|
||||
@@ -65,7 +64,6 @@ import {
|
||||
setSidebarSessionOrderIds,
|
||||
setSidebarWorkspaceOrderIds,
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
toggleSidebarMessagingOpen,
|
||||
unpinSession
|
||||
} from '@/store/layout'
|
||||
import {
|
||||
@@ -78,9 +76,6 @@ import {
|
||||
} from '@/store/profile'
|
||||
import {
|
||||
$cronSessions,
|
||||
$messagingPlatformTotals,
|
||||
$messagingSessions,
|
||||
$messagingTruncated,
|
||||
$selectedStoredSessionId,
|
||||
$sessionProfileTotals,
|
||||
$sessions,
|
||||
@@ -95,19 +90,12 @@ import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { SidebarCronJobsSection } from './cron-jobs-section'
|
||||
import { SidebarLoadMoreRow } from './load-more-row'
|
||||
import { ProfileRail } from './profile-switcher'
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
import { VirtualSessionList } from './virtual-session-list'
|
||||
|
||||
const VIRTUALIZE_THRESHOLD = 25
|
||||
|
||||
// Non-session groups (messaging platforms) stay compact: show a few rows up
|
||||
// front, reveal more in larger steps on demand. Keeps a busy platform from
|
||||
// dominating the sidebar before the user asks to see it.
|
||||
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.
|
||||
@@ -136,16 +124,7 @@ const WORKSPACE_PAGE = 5
|
||||
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
|
||||
const PROFILE_INITIAL_PAGE = 5
|
||||
const GROUP_DND_ID_PREFIX = 'group:'
|
||||
|
||||
// Two modes via the `compact` height variant (styles.css):
|
||||
// tall → each section is shrink-0, capped, its own scroller; Sessions is flex-1.
|
||||
// compact → COMPACT_FLAT drops the caps so the whole stack scrolls as one.
|
||||
// Sections stay shrink-0 so none can be squeezed below its content and bleed onto
|
||||
// the next — the flexbox `min-height: auto` overlap trap that caused the bug.
|
||||
const COMPACT_FLAT = 'compact:max-h-none compact:overflow-visible'
|
||||
|
||||
// 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 LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
|
||||
|
||||
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
|
||||
|
||||
@@ -162,25 +141,24 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[
|
||||
|
||||
const byId = new Map(items.map(item => [getId(item), item]))
|
||||
const seen = new Set<string>()
|
||||
const ordered: T[] = []
|
||||
const out: T[] = []
|
||||
|
||||
for (const id of orderIds) {
|
||||
const item = byId.get(id)
|
||||
|
||||
if (item) {
|
||||
ordered.push(item)
|
||||
out.push(item)
|
||||
seen.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Items missing from the persisted order are new since it was last
|
||||
// reconciled. Callers pass recency-sorted lists (newest first), so surface
|
||||
// these at the TOP instead of burying them beneath the saved order —
|
||||
// otherwise a brand-new session sinks to the bottom of the sidebar and reads
|
||||
// as "my latest session never showed up".
|
||||
const fresh = items.filter(item => !seen.has(getId(item)))
|
||||
for (const item of items) {
|
||||
if (!seen.has(getId(item))) {
|
||||
out.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return fresh.length ? [...fresh, ...ordered] : ordered
|
||||
return out
|
||||
}
|
||||
|
||||
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
|
||||
@@ -193,15 +171,17 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
|
||||
}
|
||||
|
||||
const current = new Set(currentIds)
|
||||
const retained = orderIds.filter(id => current.has(id))
|
||||
const retainedSet = new Set(retained)
|
||||
const next = orderIds.filter(id => current.has(id))
|
||||
const known = new Set(next)
|
||||
|
||||
// New ids (absent from the saved order) are the newest sessions/groups; keep
|
||||
// them ahead of the persisted order so fresh activity surfaces at the top of
|
||||
// the sidebar rather than being appended to the bottom.
|
||||
const fresh = currentIds.filter(id => !retainedSet.has(id))
|
||||
for (const id of currentIds) {
|
||||
if (!known.has(id)) {
|
||||
next.push(id)
|
||||
known.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
return [...fresh, ...retained]
|
||||
return next
|
||||
}
|
||||
|
||||
function sameIds(left: string[], right: string[]) {
|
||||
@@ -271,6 +251,43 @@ function workspaceGroupsFor(
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
function sourceSessionGroupsFor(sessions: SessionInfo[]): {
|
||||
localSessions: SessionInfo[]
|
||||
sourceGroups: SidebarSessionGroup[]
|
||||
} {
|
||||
const groups = new Map<string, SidebarSessionGroup>()
|
||||
const localSessions: SessionInfo[] = []
|
||||
|
||||
for (const session of sessions) {
|
||||
const sourceId = normalizeSessionSource(session.source)
|
||||
|
||||
if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
|
||||
localSessions.push(session)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const label = sessionSourceLabel(sourceId) ?? sourceId
|
||||
|
||||
const group = groups.get(sourceId) ?? {
|
||||
id: `source:${sourceId}`,
|
||||
label,
|
||||
mode: 'source',
|
||||
path: null,
|
||||
sessions: [],
|
||||
sourceId
|
||||
}
|
||||
|
||||
group.sessions.push(session)
|
||||
groups.set(sourceId, group)
|
||||
}
|
||||
|
||||
return {
|
||||
localSessions,
|
||||
sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
|
||||
}
|
||||
}
|
||||
|
||||
function useSortableBindings(id: string) {
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
|
||||
|
||||
@@ -292,7 +309,6 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
onNavigate: (item: SidebarNavItem) => void
|
||||
onLoadMoreSessions: () => void
|
||||
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
|
||||
onLoadMoreMessaging?: (platform: string) => Promise<void> | void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
@@ -306,7 +322,6 @@ export function ChatSidebar({
|
||||
onNavigate,
|
||||
onLoadMoreSessions,
|
||||
onLoadMoreProfileSessions,
|
||||
onLoadMoreMessaging,
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
@@ -330,9 +345,6 @@ export function ChatSidebar({
|
||||
const sessions = useStore($sessions)
|
||||
const cronSessions = useStore($cronSessions)
|
||||
const cronJobs = useStore($cronJobs)
|
||||
const messagingSessions = useStore($messagingSessions)
|
||||
const messagingPlatformTotals = useStore($messagingPlatformTotals)
|
||||
const messagingTruncated = useStore($messagingTruncated)
|
||||
const sessionsLoading = useStore($sessionsLoading)
|
||||
const sessionsTotal = useStore($sessionsTotal)
|
||||
const sessionProfileTotals = useStore($sessionProfileTotals)
|
||||
@@ -352,10 +364,6 @@ export function ChatSidebar({
|
||||
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
||||
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
|
||||
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
|
||||
const [messagingLoadMorePending, setMessagingLoadMorePending] = useState<Record<string, boolean>>({})
|
||||
const messagingOpenIds = useStore($sidebarMessagingOpenIds)
|
||||
// Per-platform count of rows currently revealed (starts at NON_SESSION_INITIAL_ROWS).
|
||||
const [messagingVisible, setMessagingVisible] = useState<Record<string, number>>({})
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const trimmedQuery = searchQuery.trim()
|
||||
|
||||
@@ -521,12 +529,24 @@ export function ChatSidebar({
|
||||
[unpinnedAgentSessions, agentOrderIds]
|
||||
)
|
||||
|
||||
// Recents are local-only: messaging-platform sessions are fetched as their
|
||||
// own slice ($messagingSessions) and rendered in self-managed per-platform
|
||||
// sections below, so there is no source-grouping magic to untangle here.
|
||||
const { localSessions: localAgentSessions, sourceGroups } = useMemo(
|
||||
() => sourceSessionGroupsFor(agentSessions),
|
||||
[agentSessions]
|
||||
)
|
||||
|
||||
const orderedSourceGroups = useMemo(
|
||||
() => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
|
||||
[sourceGroups, workspaceOrderIds]
|
||||
)
|
||||
|
||||
const agentGroups = useMemo(
|
||||
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
|
||||
[agentSessions, s.noWorkspace, workspaceOrderIds]
|
||||
() =>
|
||||
orderByIds(
|
||||
workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
|
||||
g => g.id,
|
||||
workspaceOrderIds
|
||||
),
|
||||
[localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
|
||||
)
|
||||
|
||||
const loadMoreForProfileGroup = useCallback(
|
||||
@@ -544,76 +564,6 @@ export function ChatSidebar({
|
||||
[onLoadMoreProfileSessions]
|
||||
)
|
||||
|
||||
const loadMoreForMessaging = useCallback(
|
||||
(platform: string) => {
|
||||
if (!onLoadMoreMessaging) {
|
||||
return
|
||||
}
|
||||
|
||||
setMessagingLoadMorePending(prev => ({ ...prev, [platform]: true }))
|
||||
|
||||
void Promise.resolve(onLoadMoreMessaging(platform))
|
||||
.catch(() => undefined)
|
||||
.finally(() => setMessagingLoadMorePending(({ [platform]: _done, ...rest }) => rest))
|
||||
},
|
||||
[onLoadMoreMessaging]
|
||||
)
|
||||
|
||||
// Reveal another batch of a platform's rows; fetch from the backend too if we
|
||||
// run past what's loaded and more remain on disk.
|
||||
const revealMoreMessaging = (platform: string, loaded: number, hasMore: boolean) => {
|
||||
const next = (messagingVisible[platform] ?? NON_SESSION_INITIAL_ROWS) + NON_SESSION_LOAD_STEP
|
||||
|
||||
setMessagingVisible(prev => ({ ...prev, [platform]: next }))
|
||||
|
||||
if (next > loaded && hasMore) {
|
||||
loadMoreForMessaging(platform)
|
||||
}
|
||||
}
|
||||
|
||||
// Each messaging platform is its own self-managed section: split the
|
||||
// separately-fetched messaging slice by source, newest platform first, rows
|
||||
// within a platform by recency. Per-platform totals (when a "load more" has
|
||||
// resolved them) drive the count + whether more remain on disk.
|
||||
const messagingGroups = useMemo<MessagingSection[]>(() => {
|
||||
if (!messagingSessions.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const bySource = new Map<string, SessionInfo[]>()
|
||||
|
||||
for (const session of messagingSessions) {
|
||||
const sourceId = normalizeSessionSource(session.source)
|
||||
|
||||
if (!sourceId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const list = bySource.get(sourceId) ?? []
|
||||
list.push(session)
|
||||
bySource.set(sourceId, list)
|
||||
}
|
||||
|
||||
return [...bySource.entries()]
|
||||
.map(([sourceId, list]) => {
|
||||
const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
|
||||
const known = messagingPlatformTotals[sourceId]
|
||||
const total = Math.max(ordered.length, known ?? 0)
|
||||
|
||||
return {
|
||||
// Known exact total → more exist iff total exceeds loaded; otherwise
|
||||
// the seed fetch was capped, so assume more until a per-platform load
|
||||
// resolves the count.
|
||||
hasMore: known != null ? known > ordered.length : messagingTruncated,
|
||||
label: sessionSourceLabel(sourceId) ?? sourceId,
|
||||
sessions: ordered,
|
||||
sourceId,
|
||||
total
|
||||
}
|
||||
})
|
||||
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
|
||||
}, [messagingSessions, messagingPlatformTotals, messagingTruncated])
|
||||
|
||||
// ALL-profiles view: one collapsible group per profile, color on the header
|
||||
// (not on every row). Default profile floats to the top, the rest alpha.
|
||||
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
|
||||
@@ -660,34 +610,37 @@ export function ChatSidebar({
|
||||
sessionProfileTotals
|
||||
])
|
||||
|
||||
const displayAgentSessions = agentSessions
|
||||
const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
|
||||
|
||||
// Pagination is scope-aware. In "All profiles" mode it tracks the global
|
||||
// unified set. When scoped to one profile it must compare that profile's own
|
||||
// loaded rows against that profile's total — otherwise a huge default profile
|
||||
// keeps "Load more" stuck on while you browse a small one (the aggregator's
|
||||
// total sums every profile). Per-profile totals come from the aggregator
|
||||
// (children excluded); fall back to the global total / loaded count.
|
||||
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
|
||||
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
|
||||
const displayAgentGroups = useMemo(() => {
|
||||
if (orderedSourceGroups.length) {
|
||||
const localGroups = agentsGrouped
|
||||
? agentGroups
|
||||
: localAgentSessions.length
|
||||
? [
|
||||
{
|
||||
id: 'local-sessions',
|
||||
label: 'Local',
|
||||
mode: 'workspace' as const,
|
||||
path: null,
|
||||
sessions: localAgentSessions
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
const knownSessionTotal = Math.max(
|
||||
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
|
||||
loadedSessionCount
|
||||
)
|
||||
return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
|
||||
}
|
||||
|
||||
const hasMoreSessions = knownSessionTotal > loadedSessionCount
|
||||
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
|
||||
|
||||
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
|
||||
|
||||
const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
|
||||
|
||||
// The recents list owns its own (virtualized) scroll container only when it's a
|
||||
// long flat list. In that case it must keep its scroller even in short mode, so
|
||||
// we don't flatten it (flattening would defeat virtualization). Short flat lists
|
||||
// and grouped views flatten into the single outer scroll instead.
|
||||
const recentsVirtualizes = !displayAgentGroups?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
|
||||
return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
|
||||
}, [
|
||||
agentGroups,
|
||||
agentsGrouped,
|
||||
localAgentSessions,
|
||||
orderedSourceGroups,
|
||||
profileGroups,
|
||||
showAllProfiles,
|
||||
workspaceOrderIds
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!displayAgentGroups?.length || showAllProfiles) {
|
||||
@@ -708,6 +661,25 @@ export function ChatSidebar({
|
||||
|
||||
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
|
||||
|
||||
// Pagination is scope-aware. In "All profiles" mode it tracks the global
|
||||
// unified set. When scoped to one profile it must compare that profile's own
|
||||
// loaded rows against that profile's total — otherwise a huge default profile
|
||||
// keeps "Load more" stuck on while you browse a small one (the aggregator's
|
||||
// total sums every profile). Per-profile totals come from the aggregator
|
||||
// (children excluded); fall back to the global total / loaded count.
|
||||
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
|
||||
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
|
||||
|
||||
const knownSessionTotal = Math.max(
|
||||
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
|
||||
loadedSessionCount
|
||||
)
|
||||
|
||||
const hasMoreSessions = knownSessionTotal > loadedSessionCount
|
||||
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
|
||||
|
||||
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
|
||||
|
||||
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id) {
|
||||
return
|
||||
@@ -797,14 +769,7 @@ export function ChatSidebar({
|
||||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
// no-drag: these rows sit directly under the titlebar's
|
||||
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
|
||||
// 6px of clearance. Drag regions win hit-testing over DOM
|
||||
// (pointer-events can't override), and on Linux/WSLg the
|
||||
// resolved region has been observed to swallow clicks on the
|
||||
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
|
||||
// thread.tsx.
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
active &&
|
||||
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
|
||||
!isInteractive &&
|
||||
@@ -827,7 +792,9 @@ export function ChatSidebar({
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
{contentVisible && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{s.nav[item.id] ?? item.label}
|
||||
</span>
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
|
||||
@@ -856,191 +823,135 @@ export function ChatSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentVisible && showSessionSections && (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75">
|
||||
{trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
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)}
|
||||
</div>
|
||||
}
|
||||
label={s.results}
|
||||
labelMeta={String(searchResults.length)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => undefined}
|
||||
onTogglePin={pinSession}
|
||||
open
|
||||
pinned={false}
|
||||
rootClassName="min-h-32 flex-1 overflow-hidden p-0"
|
||||
sessions={searchResults}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
{contentVisible && showSessionSections && trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
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)}
|
||||
</div>
|
||||
}
|
||||
label={s.results}
|
||||
labelMeta={String(searchResults.length)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => undefined}
|
||||
onTogglePin={pinSession}
|
||||
open
|
||||
pinned={false}
|
||||
rootClassName="min-h-0 flex-1 p-0"
|
||||
sessions={searchResults}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{contentVisible && showSessionSections && !trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
|
||||
dndSensors={dndSensors}
|
||||
emptyState={<SidebarPinnedEmptyState />}
|
||||
label={s.pinned}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handlePinnedDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
|
||||
onTogglePin={unpinSession}
|
||||
open={pinsOpen}
|
||||
pinned
|
||||
rootClassName="shrink-0 p-0 pb-1"
|
||||
sessions={pinnedSessions}
|
||||
sortable={pinnedSessions.length > 1}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{contentVisible && showSessionSections && !trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
|
||||
// Separate profile sections clearly in the ALL view; rows inside
|
||||
// each group keep their own tight gap-px rhythm.
|
||||
showAllProfiles ? 'gap-3' : 'gap-px'
|
||||
)}
|
||||
dndSensors={dndSensors}
|
||||
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
|
||||
footer={
|
||||
// Hide "load more" only when workspace-grouped (those groups page
|
||||
// themselves). ALL-profiles now pages per-profile from each profile
|
||||
// header; the global footer only applies to non-ALL views.
|
||||
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
|
||||
<SidebarLoadMoreRow
|
||||
loading={sessionsLoading}
|
||||
onClick={onLoadMoreSessions}
|
||||
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
forceEmptyState={showSessionSkeletons}
|
||||
groups={displayAgentGroups}
|
||||
headerAction={
|
||||
// Always reserve the icon-xs (size-6) slot so the header keeps the
|
||||
// same height whether or not the toggle renders — otherwise the
|
||||
// "Sessions" label jumps when switching to the ALL-profiles view.
|
||||
// Grouping operates on unpinned recents; if everything is pinned
|
||||
// the toggle does nothing, and it's irrelevant in the ALL-profiles
|
||||
// view (always grouped by profile), so hide the button (not the slot).
|
||||
<div className="grid size-6 shrink-0 place-items-center">
|
||||
{!showAllProfiles && localAgentSessions.length > 0 ? (
|
||||
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
|
||||
<Button
|
||||
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
label={s.sessions}
|
||||
labelMeta={recentsMeta}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
|
||||
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
|
||||
onTogglePin={pinSession}
|
||||
open={agentsOpen}
|
||||
pinned={false}
|
||||
rootClassName="min-h-0 flex-1 p-0"
|
||||
sessions={displayAgentSessions}
|
||||
sortable={!showAllProfiles && agentSessions.length > 1}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName={cn('flex max-h-44 flex-col gap-px rounded-lg pb-2 pt-1', GROUP_BODY)}
|
||||
dndSensors={dndSensors}
|
||||
emptyState={<SidebarPinnedEmptyState />}
|
||||
label={s.pinned}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handlePinnedDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
|
||||
onTogglePin={unpinSession}
|
||||
open={pinsOpen}
|
||||
pinned
|
||||
rootClassName="shrink-0 p-0 pb-1"
|
||||
sessions={pinnedSessions}
|
||||
sortable={pinnedSessions.length > 1}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
|
||||
// Separate profile sections clearly in the ALL view; rows inside
|
||||
// each group keep their own tight gap-px rhythm.
|
||||
showAllProfiles ? 'gap-3' : 'gap-px',
|
||||
// Flatten into the single scroll when compact — unless this is the
|
||||
// virtualized long list, which must keep its own scroller.
|
||||
!recentsVirtualizes && COMPACT_FLAT
|
||||
)}
|
||||
dndSensors={dndSensors}
|
||||
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
|
||||
footer={
|
||||
// Hide "load more" only when workspace-grouped (those groups page
|
||||
// themselves). ALL-profiles now pages per-profile from each profile
|
||||
// header; the global footer only applies to non-ALL views.
|
||||
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
|
||||
<SidebarLoadMoreRow
|
||||
loading={sessionsLoading}
|
||||
onClick={onLoadMoreSessions}
|
||||
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
forceEmptyState={showSessionSkeletons}
|
||||
groups={displayAgentGroups}
|
||||
headerAction={
|
||||
// Always reserve the icon-xs (size-6) slot so the header keeps the
|
||||
// same height whether or not the toggle renders — otherwise the
|
||||
// "Sessions" label jumps when switching to the ALL-profiles view.
|
||||
// Grouping operates on unpinned recents; if everything is pinned
|
||||
// the toggle does nothing, and it's irrelevant in the ALL-profiles
|
||||
// view (always grouped by profile), so hide the button (not the slot).
|
||||
<div className="grid size-6 shrink-0 place-items-center">
|
||||
{!showAllProfiles && agentSessions.length > 0 ? (
|
||||
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
|
||||
<Button
|
||||
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
label={s.sessions}
|
||||
labelMeta={recentsMeta}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
|
||||
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
|
||||
onTogglePin={pinSession}
|
||||
open={agentsOpen}
|
||||
pinned={false}
|
||||
rootClassName={cn(
|
||||
'min-h-32 flex-1 overflow-hidden p-0',
|
||||
!recentsVirtualizes && 'compact:min-h-0 compact:flex-none compact:overflow-visible'
|
||||
)}
|
||||
sessions={displayAgentSessions}
|
||||
sortable={!showAllProfiles && agentSessions.length > 1}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!trimmedQuery &&
|
||||
messagingGroups.map(group => {
|
||||
const visible = messagingVisible[group.sourceId] ?? NON_SESSION_INITIAL_ROWS
|
||||
const shownSessions = group.sessions.slice(0, visible)
|
||||
// More to show if rows are hidden behind the cap, or the backend
|
||||
// still has older threads on disk.
|
||||
const canRevealMore = visible < group.sessions.length || group.hasMore
|
||||
|
||||
return (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName={cn('flex max-h-56 flex-col gap-px pb-1.75', GROUP_BODY)}
|
||||
emptyState={null}
|
||||
footer={
|
||||
canRevealMore ? (
|
||||
<SidebarLoadMoreRow
|
||||
loading={Boolean(messagingLoadMorePending[group.sourceId])}
|
||||
onClick={() => revealMoreMessaging(group.sourceId, group.sessions.length, group.hasMore)}
|
||||
step={Math.min(NON_SESSION_LOAD_STEP, Math.max(0, group.total - shownSessions.length))}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
key={group.sourceId}
|
||||
label={group.label}
|
||||
labelIcon={
|
||||
<PlatformAvatar
|
||||
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
|
||||
platformId={group.sourceId}
|
||||
platformName={group.label}
|
||||
/>
|
||||
}
|
||||
labelMeta={countLabel(group.sessions.length, group.total)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => toggleSidebarMessagingOpen(group.sourceId)}
|
||||
onTogglePin={pinSession}
|
||||
open={messagingOpenIds.includes(group.sourceId)}
|
||||
pinned={false}
|
||||
rootClassName="shrink-0 p-0"
|
||||
sessions={shownSessions}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{!trimmedQuery && cronJobs.length > 0 && (
|
||||
<SidebarCronJobsSection
|
||||
jobs={cronJobs}
|
||||
label={s.cronJobs}
|
||||
onManageJob={onManageCronJob}
|
||||
onOpenRun={onResumeSession}
|
||||
onToggle={() => setSidebarCronOpen(!cronOpen)}
|
||||
onTriggerJob={onTriggerCronJob}
|
||||
open={cronOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{contentVisible && !trimmedQuery && cronJobs.length > 0 && (
|
||||
<SidebarCronJobsSection
|
||||
jobs={cronJobs}
|
||||
label={s.cronJobs}
|
||||
onManageJob={onManageCronJob}
|
||||
onOpenRun={onResumeSession}
|
||||
onToggle={() => setSidebarCronOpen(!cronOpen)}
|
||||
onTriggerJob={onTriggerCronJob}
|
||||
open={cronOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
|
||||
@@ -1061,10 +972,9 @@ interface SidebarSectionHeaderProps {
|
||||
onToggle: () => void
|
||||
action?: React.ReactNode
|
||||
meta?: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: SidebarSectionHeaderProps) {
|
||||
function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
|
||||
return (
|
||||
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
||||
<button
|
||||
@@ -1072,7 +982,6 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: Sid
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
<SidebarPanelLabel>{label}</SidebarPanelLabel>
|
||||
{meta && <SidebarCount>{meta}</SidebarCount>}
|
||||
<DisclosureCaret
|
||||
@@ -1135,14 +1044,6 @@ interface SidebarSessionGroup {
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
interface MessagingSection {
|
||||
sourceId: string
|
||||
label: string
|
||||
sessions: SessionInfo[]
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
interface SidebarSessionsSectionProps {
|
||||
label: string
|
||||
open: boolean
|
||||
@@ -1164,7 +1065,6 @@ interface SidebarSessionsSectionProps {
|
||||
footer?: React.ReactNode
|
||||
groups?: SidebarSessionGroup[]
|
||||
labelMeta?: React.ReactNode
|
||||
labelIcon?: React.ReactNode
|
||||
sortable?: boolean
|
||||
onReorder?: (event: DragEndEvent) => void
|
||||
dndSensors?: ReturnType<typeof useSensors>
|
||||
@@ -1191,7 +1091,6 @@ function SidebarSessionsSection({
|
||||
footer,
|
||||
groups,
|
||||
labelMeta,
|
||||
labelIcon,
|
||||
sortable = false,
|
||||
onReorder,
|
||||
dndSensors
|
||||
@@ -1282,7 +1181,6 @@ function SidebarSessionsSection({
|
||||
inner = (
|
||||
<VirtualSessionList
|
||||
activeSessionId={activeSessionId}
|
||||
className={contentClassName}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
@@ -1311,14 +1209,7 @@ function SidebarSessionsSection({
|
||||
|
||||
return (
|
||||
<SidebarGroup className={rootClassName}>
|
||||
<SidebarSectionHeader
|
||||
action={headerAction}
|
||||
icon={labelIcon}
|
||||
label={label}
|
||||
meta={labelMeta}
|
||||
onToggle={onToggle}
|
||||
open={open}
|
||||
/>
|
||||
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
|
||||
{open && (
|
||||
<SidebarGroupContent className={resolvedContentClassName}>
|
||||
{body}
|
||||
@@ -1507,3 +1398,30 @@ interface SortableSessionRowProps {
|
||||
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
|
||||
return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
|
||||
}
|
||||
|
||||
interface SidebarLoadMoreRowProps {
|
||||
loading: boolean
|
||||
onClick: () => void
|
||||
step: number
|
||||
}
|
||||
|
||||
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
|
||||
const { t } = useI18n()
|
||||
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
disabled={loading}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{/* Seat the icon in the same w-3.5 column session rows use for their dot
|
||||
so the chevron + label line up with the rows above. */}
|
||||
<span className="grid w-3.5 shrink-0 place-items-center">
|
||||
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
interface SidebarLoadMoreRowProps {
|
||||
step: number
|
||||
onClick: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
// "Load N more" affordance shared by the recents, messaging, and cron sections.
|
||||
// The chevron sits in the same w-3.5 column the rows use for their dot, so it
|
||||
// lines up with the list above.
|
||||
export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) {
|
||||
const { t } = useI18n()
|
||||
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
disabled={loading}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid w-3.5 shrink-0 place-items-center">
|
||||
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -83,9 +83,8 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
|
||||
// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned
|
||||
// left, the colored named profiles scrolling between, and Manage pinned right.
|
||||
// The active profile pops in its own color — the "where am I" cue. Single-
|
||||
// profile users see the "+" (create their first profile) and the Manage
|
||||
// overflow (edit the default profile's SOUL.md); the colored named squares
|
||||
// and the default↔all toggle only appear once a second profile exists.
|
||||
// profile users see only the "+" (create their first profile); everything else
|
||||
// appears once a second profile exists.
|
||||
export function ProfileRail() {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
@@ -269,11 +268,9 @@ export function ProfileRail() {
|
||||
</Tip>
|
||||
</div>
|
||||
|
||||
{/* Always reachable, even with only the default profile: the manage
|
||||
overlay is the only place to edit a profile's SOUL.md, and a
|
||||
single-profile user must be able to edit the default's persona
|
||||
without first creating a throwaway second profile. */}
|
||||
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
|
||||
{multiProfile && (
|
||||
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
|
||||
)}
|
||||
|
||||
{/* Land in the new profile on a fresh chat (selectProfile triggers the
|
||||
new-session reset), not stuck on the session you were just in. */}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
interface SessionActions {
|
||||
sessionId: string
|
||||
@@ -69,26 +68,13 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
|
||||
}
|
||||
},
|
||||
...(canOpenSessionWindow()
|
||||
? [
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'link-external',
|
||||
label: r.newWindow,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void openSessionInNewWindow(sessionId)
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'cloud-download',
|
||||
label: r.export,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { profile, title })
|
||||
void exportSession(sessionId, { title })
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,18 +2,14 @@ import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
||||
import { PlatformAvatar } from '@/app/messaging/platform-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds } from '@/store/session'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
||||
|
||||
@@ -71,11 +67,6 @@ export function SidebarSessionRow({
|
||||
const title = sessionTitle(session)
|
||||
const age = formatAge(session.last_active || session.started_at, r)
|
||||
const handleLabel = `Reorder ${title}`
|
||||
// A handed-off session's live source is local, but it originated on a
|
||||
// messaging platform — surface that origin as a small badge so e.g. a
|
||||
// Telegram thread continued here still reads as Telegram.
|
||||
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
|
||||
const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
|
||||
// Subscribe per-row (the leaf) instead of drilling a set through the list —
|
||||
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
||||
// session is waiting on the user.
|
||||
@@ -133,15 +124,11 @@ export function SidebarSessionRow({
|
||||
return
|
||||
}
|
||||
|
||||
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
|
||||
// window — the universal "open in a new window" gesture. Archive
|
||||
// lives in the row's ⋯ and right-click menus. Falls through to a
|
||||
// normal resume when standalone windows aren't available (web embed).
|
||||
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
void openSessionInNewWindow(session.id)
|
||||
onArchive()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -192,15 +179,6 @@ export function SidebarSessionRow({
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
</span>
|
||||
)}
|
||||
{handoffSource && handoffLabel ? (
|
||||
<Tip label={r.handoffOrigin(handoffLabel)}>
|
||||
<PlatformAvatar
|
||||
className="size-4 rounded-[4px] text-[0.5rem] [&_svg]:size-2.5"
|
||||
platformId={handoffSource}
|
||||
platformName={handoffLabel}
|
||||
/>
|
||||
</Tip>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
{title}
|
||||
</span>
|
||||
|
||||
@@ -4,22 +4,19 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
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 { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
|
||||
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
Activity,
|
||||
Archive,
|
||||
BarChart3,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Cpu,
|
||||
Download,
|
||||
Globe,
|
||||
type IconComponent,
|
||||
Info,
|
||||
@@ -33,18 +30,13 @@ import {
|
||||
Settings,
|
||||
Settings2,
|
||||
Sun,
|
||||
Terminal,
|
||||
Users,
|
||||
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'
|
||||
import { luminance } from '@/themes/color'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||
|
||||
import {
|
||||
AGENTS_ROUTE,
|
||||
@@ -62,11 +54,8 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants'
|
||||
import { fieldCopyForSchemaKey } from '../settings/field-copy'
|
||||
import { prettyName } from '../settings/helpers'
|
||||
|
||||
import { MarketplaceThemePage } from './marketplace-theme-page'
|
||||
|
||||
interface PaletteItem {
|
||||
/** Keybind action id — its live combo renders as a hotkey hint. */
|
||||
action?: string
|
||||
active?: boolean
|
||||
icon: IconComponent
|
||||
id: string
|
||||
/** Keep the palette open after running (live-preview pickers like theme/mode). */
|
||||
@@ -80,16 +69,10 @@ interface PaletteItem {
|
||||
}
|
||||
|
||||
interface PaletteGroup {
|
||||
/** Optional: a headingless group renders as a bare action row (e.g. the
|
||||
* "Install theme…" entry pinned atop the theme picker). */
|
||||
heading?: string
|
||||
heading: string
|
||||
items: PaletteItem[]
|
||||
}
|
||||
|
||||
// Nested page → its parent, so Back / Esc step up one level instead of closing
|
||||
// the palette. Pages absent here go straight back to the root list.
|
||||
const PAGE_PARENTS: Record<string, string> = { 'install-theme': 'theme' }
|
||||
|
||||
/** A nested page reachable from a root item via `to`. */
|
||||
interface PalettePage {
|
||||
groups: PaletteGroup[]
|
||||
@@ -103,23 +86,7 @@ interface SessionEntry {
|
||||
title: string
|
||||
}
|
||||
|
||||
// cmdk defaults to fuzzy subsequence scoring, so "color" matches anything with
|
||||
// c…o…l…o…r scattered across it. Use case-insensitive multi-term substring
|
||||
// matching instead: every typed word must literally appear in the item's
|
||||
// value/keywords, which keeps results tight and predictable.
|
||||
const paletteFilter = (value: string, search: string, keywords?: string[]): number => {
|
||||
const needle = search.trim().toLowerCase()
|
||||
|
||||
if (!needle) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const haystack = `${value} ${keywords?.join(' ') ?? ''}`.toLowerCase()
|
||||
|
||||
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
|
||||
}
|
||||
|
||||
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
|
||||
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
|
||||
|
||||
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||
id: session.id,
|
||||
@@ -179,32 +146,11 @@ const THEME_MODES: ReadonlyArray<{ icon: IconComponent; mode: ThemeMode }> = [
|
||||
{ icon: Monitor, mode: 'system' }
|
||||
]
|
||||
|
||||
// Which Light/Dark groups a theme belongs in. Built-ins render in both modes
|
||||
// (the engine synthesises the missing side). Imported VS Code themes only carry
|
||||
// the variant(s) the extension shipped — a single dark theme like Dracula lives
|
||||
// under Dark only, while a GitHub/Solarized family (light + dark) lives in both.
|
||||
function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
|
||||
if (!isUserTheme(name)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const resolved = resolveTheme(name)
|
||||
|
||||
if (!resolved) {
|
||||
return true
|
||||
}
|
||||
|
||||
const background = target === 'dark' ? (resolved.darkColors ?? resolved.colors).background : resolved.colors.background
|
||||
|
||||
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const { t } = useI18n()
|
||||
const open = useStore($commandPaletteOpen)
|
||||
const bindings = useStore($bindings)
|
||||
const navigate = useNavigate()
|
||||
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
||||
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState<string | null>(null)
|
||||
|
||||
@@ -218,13 +164,13 @@ export function CommandPalette() {
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['command-palette', 'sessions'],
|
||||
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
|
||||
queryFn: () => listSessions(200, 1, 'exclude'),
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const archivedQuery = useQuery({
|
||||
queryKey: ['command-palette', 'archived'],
|
||||
queryFn: () => listAllProfileSessions(200, 0, 'only'),
|
||||
queryFn: () => listSessions(200, 0, 'only'),
|
||||
enabled: open
|
||||
})
|
||||
|
||||
@@ -248,19 +194,10 @@ export function CommandPalette() {
|
||||
}, [open])
|
||||
|
||||
const go = useCallback((path: string) => () => navigate(path), [navigate])
|
||||
|
||||
// Step up one nested page (or back to the root list), clearing the filter so
|
||||
// the parent page doesn't reopen mid-search.
|
||||
const goBack = useCallback(() => {
|
||||
setSearch('')
|
||||
setPage(prev => (prev ? (PAGE_PARENTS[prev] ?? null) : null))
|
||||
}, [])
|
||||
|
||||
const settingsSectionLabel = useCallback(
|
||||
(section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label,
|
||||
[t.settings.sections]
|
||||
)
|
||||
|
||||
const configFieldLabel = useCallback(
|
||||
(key: string) =>
|
||||
fieldCopyForSchemaKey(t.settings.fieldLabels, key) ??
|
||||
@@ -277,61 +214,20 @@ export function CommandPalette() {
|
||||
{
|
||||
heading: cc.goTo,
|
||||
items: [
|
||||
{ icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: cc.nav.newChat.title, run: go(NEW_CHAT_ROUTE) },
|
||||
{ icon: Settings, id: 'nav-settings', label: cc.nav.settings.title, run: go(SETTINGS_ROUTE) },
|
||||
{
|
||||
action: 'session.new',
|
||||
icon: Plus,
|
||||
id: 'nav-new',
|
||||
keywords: ['chat', 'create'],
|
||||
label: cc.nav.newChat.title,
|
||||
run: go(NEW_CHAT_ROUTE)
|
||||
},
|
||||
{
|
||||
action: 'view.showTerminal',
|
||||
icon: Terminal,
|
||||
id: 'nav-terminal',
|
||||
keywords: ['terminal', 'shell', 'console'],
|
||||
label: t.keybinds.actions['view.showTerminal'],
|
||||
run: () => setTerminalTakeover(true)
|
||||
},
|
||||
{
|
||||
action: 'nav.settings',
|
||||
icon: Settings,
|
||||
id: 'nav-settings',
|
||||
label: cc.nav.settings.title,
|
||||
run: go(SETTINGS_ROUTE)
|
||||
},
|
||||
{
|
||||
action: 'nav.skills',
|
||||
icon: Wrench,
|
||||
id: 'nav-skills',
|
||||
keywords: ['tools', 'toolsets'],
|
||||
label: cc.nav.skills.title,
|
||||
run: go(SKILLS_ROUTE)
|
||||
},
|
||||
{
|
||||
action: 'nav.messaging',
|
||||
icon: MessageCircle,
|
||||
id: 'nav-messaging',
|
||||
label: cc.nav.messaging.title,
|
||||
run: go(MESSAGING_ROUTE)
|
||||
},
|
||||
{
|
||||
action: 'nav.artifacts',
|
||||
icon: Package,
|
||||
id: 'nav-artifacts',
|
||||
label: cc.nav.artifacts.title,
|
||||
run: go(ARTIFACTS_ROUTE)
|
||||
},
|
||||
{
|
||||
action: 'nav.cron',
|
||||
icon: Clock,
|
||||
id: 'nav-cron',
|
||||
keywords: ['schedule', 'jobs'],
|
||||
label: t.shell.statusbar.cron,
|
||||
run: go(CRON_ROUTE)
|
||||
},
|
||||
{ action: 'nav.profiles', icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
|
||||
{ action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
|
||||
{ icon: MessageCircle, id: 'nav-messaging', label: cc.nav.messaging.title, run: go(MESSAGING_ROUTE) },
|
||||
{ icon: Package, id: 'nav-artifacts', label: cc.nav.artifacts.title, run: go(ARTIFACTS_ROUTE) },
|
||||
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: t.shell.statusbar.cron, run: go(CRON_ROUTE) },
|
||||
{ icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
|
||||
{ icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -477,40 +373,24 @@ export function CommandPalette() {
|
||||
theme: {
|
||||
title: t.settings.appearance.themeTitle,
|
||||
placeholder: t.settings.appearance.themeDesc,
|
||||
groups: [
|
||||
// Pinned at the top: drills into the Marketplace browser.
|
||||
{
|
||||
items: [
|
||||
{
|
||||
icon: Download,
|
||||
id: 'theme-install',
|
||||
keywords: ['install', 'marketplace', 'vscode', 'vs code', 'download', 'new', 'color'],
|
||||
label: t.commandCenter.installTheme.title,
|
||||
to: 'install-theme'
|
||||
}
|
||||
]
|
||||
},
|
||||
// Built-ins and imported families list under the mode(s) they support;
|
||||
// picking sets skin + mode at once. A multi-variant import (GitHub,
|
||||
// Solarized) appears in both groups and switches variants with the mode.
|
||||
...(['light', 'dark'] as const).map(groupMode => ({
|
||||
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
|
||||
items: availableThemes
|
||||
.filter(theme => themeSupportsMode(theme.name, groupMode))
|
||||
.map(theme => ({
|
||||
active: themeName === theme.name && resolvedMode === groupMode,
|
||||
icon: groupMode === 'light' ? Sun : Moon,
|
||||
id: `theme-${theme.name}-${groupMode}`,
|
||||
keepOpen: true,
|
||||
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
|
||||
label: theme.label,
|
||||
run: () => {
|
||||
setTheme(theme.name)
|
||||
setMode(groupMode)
|
||||
}
|
||||
}))
|
||||
// Skins aren't inherently light/dark — the same skin renders in either
|
||||
// mode. Group by appearance so picking an entry sets skin + mode at
|
||||
// once, and keep the palette open so each pick previews live.
|
||||
groups: (['light', 'dark'] as const).map(groupMode => ({
|
||||
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
|
||||
items: availableThemes.map(theme => ({
|
||||
active: themeName === theme.name && resolvedMode === groupMode,
|
||||
icon: groupMode === 'light' ? Sun : Moon,
|
||||
id: `theme-${theme.name}-${groupMode}`,
|
||||
keepOpen: true,
|
||||
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
|
||||
label: theme.label,
|
||||
run: () => {
|
||||
setTheme(theme.name)
|
||||
setMode(groupMode)
|
||||
}
|
||||
}))
|
||||
]
|
||||
}))
|
||||
},
|
||||
'color-mode': {
|
||||
title: t.settings.appearance.colorMode,
|
||||
@@ -519,6 +399,7 @@ export function CommandPalette() {
|
||||
{
|
||||
heading: t.settings.appearance.colorMode,
|
||||
items: THEME_MODES.map(entry => ({
|
||||
active: mode === entry.mode,
|
||||
icon: entry.icon,
|
||||
id: `mode-${entry.mode}`,
|
||||
keepOpen: true,
|
||||
@@ -528,16 +409,9 @@ export function CommandPalette() {
|
||||
}))
|
||||
}
|
||||
]
|
||||
},
|
||||
// Server-driven page: items come from the Marketplace, rendered by
|
||||
// <MarketplaceThemePage> (loader + live search + per-row install).
|
||||
'install-theme': {
|
||||
title: t.commandCenter.installTheme.title,
|
||||
placeholder: t.commandCenter.installTheme.placeholder,
|
||||
groups: []
|
||||
}
|
||||
}),
|
||||
[availableThemes, resolvedMode, setMode, setTheme, t, themeName]
|
||||
[availableThemes, mode, resolvedMode, setMode, setTheme, t, themeName]
|
||||
)
|
||||
|
||||
const activePage = page ? subPages[page] : null
|
||||
@@ -562,22 +436,17 @@ export function CommandPalette() {
|
||||
return (
|
||||
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
|
||||
<DialogPrimitive.Portal>
|
||||
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[200]" />
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
HUD_POSITION,
|
||||
HUD_SURFACE,
|
||||
'z-[210] w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
|
||||
)}
|
||||
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
|
||||
<Command className="bg-transparent" filter={paletteFilter} loop>
|
||||
<Command className="bg-transparent" loop>
|
||||
{activePage && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={goBack}
|
||||
onClick={() => setPage(null)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
@@ -587,7 +456,6 @@ export function CommandPalette() {
|
||||
</button>
|
||||
)}
|
||||
<CommandInput
|
||||
className={HUD_TEXT}
|
||||
onKeyDown={event => {
|
||||
if (!activePage) {
|
||||
return
|
||||
@@ -598,45 +466,38 @@ export function CommandPalette() {
|
||||
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
goBack()
|
||||
setPage(null)
|
||||
}
|
||||
}}
|
||||
onValueChange={setSearch}
|
||||
placeholder={placeholder}
|
||||
value={search}
|
||||
/>
|
||||
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||
{page === 'install-theme' ? (
|
||||
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||
) : (
|
||||
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
||||
)}
|
||||
{visibleGroups.map((group, index) => (
|
||||
<CommandList className="max-h-[min(24rem,60vh)]">
|
||||
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
||||
{visibleGroups.map(group => (
|
||||
<CommandGroup
|
||||
className={HUD_HEADING}
|
||||
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
|
||||
heading={group.heading}
|
||||
key={group.heading ?? `palette-group-${index}`}
|
||||
key={group.heading}
|
||||
>
|
||||
{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
|
||||
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||
className="gap-2.5"
|
||||
key={item.id}
|
||||
keywords={item.keywords}
|
||||
onSelect={() => handleSelect(item)}
|
||||
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{keys && <KbdGroup className="ml-auto" keys={keys} />}
|
||||
{item.to && (
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
|
||||
/>
|
||||
{item.to ? (
|
||||
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground/70" />
|
||||
) : (
|
||||
<Check className={cn('ml-auto size-4 text-foreground', !item.active && 'invisible')} />
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* Cmd-K "Install theme…" page.
|
||||
*
|
||||
* Browses the VS Code Marketplace for color themes: an empty query shows the
|
||||
* most-installed themes, typing runs a live (debounced) search against the
|
||||
* Marketplace. Selecting a row downloads + converts + installs it via the same
|
||||
* pipeline as the settings importer, then activates it — and stays open so the
|
||||
* user can grab several.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
|
||||
import type { DesktopMarketplaceSearchItem } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Download, Loader2, Palette } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
|
||||
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
|
||||
|
||||
function useDebounced<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebounced(value), delayMs)
|
||||
|
||||
return () => clearTimeout(handle)
|
||||
}, [value, delayMs])
|
||||
|
||||
return debounced
|
||||
}
|
||||
|
||||
interface MarketplaceThemePageProps {
|
||||
search: string
|
||||
/** Activate a freshly installed theme by slug. */
|
||||
onPickTheme: (name: string) => void
|
||||
}
|
||||
|
||||
export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePageProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.installTheme
|
||||
const debouncedSearch = useDebounced(search.trim(), 300)
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [installed, setInstalled] = useState<Record<string, true>>({})
|
||||
const [installError, setInstallError] = useState<string | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['marketplace-themes', debouncedSearch],
|
||||
queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debouncedSearch) ?? Promise.resolve([]),
|
||||
staleTime: 5 * 60 * 1000
|
||||
})
|
||||
|
||||
const install = async (item: DesktopMarketplaceSearchItem) => {
|
||||
if (installingId) {
|
||||
return
|
||||
}
|
||||
|
||||
setInstallingId(item.extensionId)
|
||||
setInstallError(null)
|
||||
|
||||
try {
|
||||
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
|
||||
|
||||
triggerHaptic('crisp')
|
||||
setInstalled(prev => ({ ...prev, [item.extensionId]: true }))
|
||||
onPickTheme(theme.name)
|
||||
} catch (error) {
|
||||
setInstallError(error instanceof Error ? error.message : copy.error)
|
||||
} finally {
|
||||
setInstallingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (query.isLoading) {
|
||||
return <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return <Status text={copy.error} tone="error" />
|
||||
}
|
||||
|
||||
const results = query.data ?? []
|
||||
|
||||
if (results.length === 0) {
|
||||
return <Status text={copy.empty} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="listbox">
|
||||
{installError && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{installError}</p>}
|
||||
{results.map(item => {
|
||||
const busy = installingId === item.extensionId
|
||||
const done = installed[item.extensionId]
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-start rounded-md text-left transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60 aria-disabled:opacity-60',
|
||||
HUD_ITEM,
|
||||
HUD_TEXT
|
||||
)}
|
||||
disabled={Boolean(installingId) && !busy}
|
||||
key={item.extensionId}
|
||||
onClick={() => void install(item)}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<Palette className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">{item.displayName}</span>
|
||||
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
|
||||
{item.publisher}
|
||||
{item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto mt-0.5 flex shrink-0 items-center gap-1 text-[0.6875rem] text-muted-foreground">
|
||||
{busy ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{copy.installing}
|
||||
</>
|
||||
) : done ? (
|
||||
<>
|
||||
<Check className="size-3 text-(--ui-green)" />
|
||||
{copy.installed}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="size-3" />
|
||||
{copy.install}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
|
||||
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,16 +11,9 @@ 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'
|
||||
import {
|
||||
isMessagingSource,
|
||||
LOCAL_SESSION_SOURCE_IDS,
|
||||
MESSAGING_SESSION_SOURCE_IDS,
|
||||
normalizeSessionSource
|
||||
} from '../lib/session-source'
|
||||
import { setCronFocusJobId, setCronJobs } from '../store/cron'
|
||||
import {
|
||||
$panesFlipped,
|
||||
@@ -51,14 +44,11 @@ import {
|
||||
$currentCwd,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$messagingSessions,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
CRON_SECTION_LIMIT,
|
||||
getRecentlySettledSessionIds,
|
||||
mergeSessionPage,
|
||||
MESSAGING_SECTION_LIMIT,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
@@ -68,16 +58,12 @@ import {
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setMessages,
|
||||
setMessagingPlatformTotals,
|
||||
setMessagingSessions,
|
||||
setMessagingTruncated,
|
||||
setSessionProfileTotals,
|
||||
setSessions,
|
||||
setSessionsLoading,
|
||||
setSessionsTotal
|
||||
} from '../store/session'
|
||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
|
||||
import { isSecondaryWindow } from '../store/windows'
|
||||
|
||||
import { ChatView } from './chat'
|
||||
import { useComposerActions } from './chat/hooks/use-composer-actions'
|
||||
@@ -99,8 +85,6 @@ import { RightSidebarPane } from './right-sidebar'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||
import { SessionPickerOverlay } from './session-picker-overlay'
|
||||
import { SessionSwitcher } from './session-switcher'
|
||||
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
|
||||
import { useCwdActions } from './session/hooks/use-cwd-actions'
|
||||
import { useHermesConfig } from './session/hooks/use-hermes-config'
|
||||
@@ -136,39 +120,22 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill
|
||||
// this cadence while the app is open + visible so new runs surface promptly
|
||||
// instead of waiting for the next user-triggered refreshSessions().
|
||||
const CRON_POLL_INTERVAL_MS = 30_000
|
||||
// The recents list is local-only: cron rows have their own section, and each
|
||||
// messaging platform (telegram, discord, …) is fetched separately into its own
|
||||
// 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]
|
||||
// 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]
|
||||
|
||||
// Cheap signature compare so the poll only swaps the atom (and re-renders the
|
||||
// sidebar) when the visible cron rows actually changed.
|
||||
function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
if (a.length !== b.length) {return false}
|
||||
|
||||
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
|
||||
}
|
||||
|
||||
// Rows a session refresh must preserve even if the aggregator omits them:
|
||||
// in-flight first turns (message_count 0), pinned rows aged off the page, the
|
||||
// actively-viewed chat (its "working" flag clears a beat before the aggregator
|
||||
// sees the persisted row), and sessions whose turn just settled (same race, but
|
||||
// for a chat the user has already navigated away from). Pass `scope` to only
|
||||
// keep the active row when it belongs to the profile being paged.
|
||||
// in-flight first turns (message_count 0), pinned rows aged off the page, and
|
||||
// the actively-viewed chat (its "working" flag clears a beat before the
|
||||
// aggregator sees the persisted row). Pass `scope` to only keep the active row
|
||||
// when it belongs to the profile being paged.
|
||||
function sessionsToKeep(scope?: string): Set<string> {
|
||||
const keep = new Set<string>([
|
||||
...$workingSessionIds.get(),
|
||||
...$pinnedSessionIds.get(),
|
||||
...getRecentlySettledSessionIds()
|
||||
])
|
||||
|
||||
const keep = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
|
||||
const active = $selectedStoredSessionId.get()
|
||||
|
||||
if (active) {
|
||||
@@ -227,7 +194,7 @@ export function DesktopController() {
|
||||
toggleCommandCenter
|
||||
} = useOverlayRouting()
|
||||
|
||||
const terminalSidebarOpen = chatOpen && terminalTakeover
|
||||
const terminalTakeoverActive = chatOpen && terminalTakeover
|
||||
|
||||
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
|
||||
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
|
||||
@@ -267,31 +234,6 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
|
||||
// Build the equivalent /blueprint slash command from the payload and drop
|
||||
// it into the composer — the user reviews/edits, then sends; the agent (or
|
||||
// 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) => {
|
||||
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?.()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!$filePreviewTarget.get() && !$previewTarget.get()) {
|
||||
@@ -331,51 +273,6 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Messaging-platform sessions as their own slice, fetched separately from
|
||||
// local recents so each platform renders a self-managed section and never
|
||||
// competes with local chats for the recents page budget. One combined fetch
|
||||
// seeds every platform; the sidebar splits the rows per source.
|
||||
const refreshMessagingSessions = useCallback(async () => {
|
||||
try {
|
||||
const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
|
||||
excludeSources: MESSAGING_EXCLUDED_SOURCES
|
||||
})
|
||||
|
||||
// Drop any non-messaging source the broad exclude didn't catch (custom
|
||||
// sources) — those stay in local recents, not a platform section.
|
||||
const rows = result.sessions.filter(s => isMessagingSource(s.source))
|
||||
|
||||
setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows))
|
||||
// Hit the cap → at least one platform may have more on disk than loaded,
|
||||
// so platform sections offer their own per-platform "load more".
|
||||
setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT)
|
||||
} catch {
|
||||
// Non-fatal: the messaging sections just stay empty/stale.
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Page a single platform's section independently (mirrors the per-profile
|
||||
// pager): fetch that source's next window and merge it back in place, leaving
|
||||
// every other platform's rows untouched. Resolves the platform's exact total.
|
||||
const loadMoreMessagingForPlatform = useCallback(async (platform: string) => {
|
||||
const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform
|
||||
const loaded = $messagingSessions.get().filter(inPlatform).length
|
||||
|
||||
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', {
|
||||
source: platform
|
||||
})
|
||||
|
||||
const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform)
|
||||
|
||||
setMessagingSessions(prev => [
|
||||
...prev.filter(s => !inPlatform(s)),
|
||||
...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep())
|
||||
])
|
||||
|
||||
const total = result.total ?? incoming.length
|
||||
setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) }))
|
||||
}, [])
|
||||
|
||||
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
|
||||
// synchronously (agent tool call or the cron UI), so refreshing here right
|
||||
// after an agent turn surfaces a new job immediately; the interval poll keeps
|
||||
@@ -412,7 +309,7 @@ export function DesktopController() {
|
||||
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
|
||||
|
||||
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
|
||||
excludeSources: SIDEBAR_EXCLUDED_SOURCES
|
||||
excludeSources: ['cron']
|
||||
})
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
@@ -428,8 +325,7 @@ export function DesktopController() {
|
||||
|
||||
void refreshCronSessions()
|
||||
void refreshCronJobs()
|
||||
void refreshMessagingSessions()
|
||||
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
|
||||
}, [profileScope, refreshCronSessions, refreshCronJobs])
|
||||
|
||||
const loadMoreSessions = useCallback(() => {
|
||||
bumpSessionsLimit()
|
||||
@@ -444,15 +340,12 @@ export function DesktopController() {
|
||||
const loaded = $sessions.get().filter(inKey).length
|
||||
|
||||
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
|
||||
excludeSources: SIDEBAR_EXCLUDED_SOURCES
|
||||
excludeSources: ['cron']
|
||||
})
|
||||
|
||||
const keep = sessionsToKeep(key)
|
||||
|
||||
setSessions(prev => [
|
||||
...prev.filter(s => !inKey(s)),
|
||||
...mergeSessionPage(prev.filter(inKey), result.sessions, keep)
|
||||
])
|
||||
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
|
||||
|
||||
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
|
||||
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
|
||||
@@ -547,9 +440,7 @@ export function DesktopController() {
|
||||
return
|
||||
}
|
||||
|
||||
const storedProfile = $sessions
|
||||
.get()
|
||||
.find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile
|
||||
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
|
||||
|
||||
for (let index = 0; index < Math.max(1, attempts); index += 1) {
|
||||
try {
|
||||
@@ -715,20 +606,19 @@ export function DesktopController() {
|
||||
submitText,
|
||||
transcribeVoiceAudio
|
||||
} = usePromptActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: branchInNewChat,
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession: resumeSession,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
sttEnabled,
|
||||
updateSessionState
|
||||
})
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: branchInNewChat,
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
sttEnabled,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
useGatewayBoot({
|
||||
handleGatewayEvent: handleDesktopGatewayEvent,
|
||||
@@ -754,14 +644,10 @@ export function DesktopController() {
|
||||
// in the background (advancing next-run/state and creating runs), so poll the
|
||||
// job list on an interval (and on tab re-focus) while connected.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
if (gatewayState !== 'open') {return}
|
||||
|
||||
const tick = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void refreshCronJobs()
|
||||
}
|
||||
if (document.visibilityState === 'visible') {void refreshCronJobs()}
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
|
||||
@@ -773,13 +659,6 @@ export function DesktopController() {
|
||||
}
|
||||
}, [gatewayState, refreshCronJobs])
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
|
||||
void refreshCurrentModel()
|
||||
void refreshHermesConfig()
|
||||
}
|
||||
}, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])
|
||||
|
||||
useRouteResume({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
@@ -798,7 +677,6 @@ export function DesktopController() {
|
||||
|
||||
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
|
||||
agentsOpen,
|
||||
chatOpen,
|
||||
commandCenterOpen,
|
||||
extraLeftItems: statusbarItemGroups.flat.left,
|
||||
extraRightItems: statusbarItemGroups.flat.right,
|
||||
@@ -819,7 +697,6 @@ export function DesktopController() {
|
||||
currentView={currentView}
|
||||
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onLoadMoreMessaging={loadMoreMessagingForPlatform}
|
||||
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
||||
onLoadMoreSessions={loadMoreSessions}
|
||||
onManageCronJob={jobId => {
|
||||
@@ -837,35 +714,27 @@ export function DesktopController() {
|
||||
/>
|
||||
)
|
||||
|
||||
// One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders decide
|
||||
// where it shows. Lives in main's stacking context (not the root overlay layer)
|
||||
// so pane resize handles still paint above it. Toggling never rebuilds the shell.
|
||||
const mainOverlays = (
|
||||
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
||||
)
|
||||
|
||||
const overlays = (
|
||||
<>
|
||||
{!isSecondaryWindow() && <DesktopInstallOverlay />}
|
||||
{!isSecondaryWindow() && (
|
||||
<DesktopOnboardingOverlay
|
||||
enabled={gatewayState === 'open'}
|
||||
onCompleted={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)}
|
||||
<DesktopInstallOverlay />
|
||||
{/* One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders
|
||||
decide where it shows. Toggling fullscreen never rebuilds the shell. */}
|
||||
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
||||
<DesktopOnboardingOverlay
|
||||
enabled={gatewayState === 'open'}
|
||||
onCompleted={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
<SessionPickerOverlay onResume={resumeSession} />
|
||||
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
<CommandPalette />
|
||||
<SessionSwitcher />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
@@ -953,6 +822,12 @@ export function DesktopController() {
|
||||
/>
|
||||
)
|
||||
|
||||
const takeoverTerminalView = (
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background) pt-(--titlebar-height)">
|
||||
<TerminalSlot />
|
||||
</div>
|
||||
)
|
||||
|
||||
// Flipped layout mirrors the default: sessions sidebar → right, file
|
||||
// browser + preview rail → left. Same panes, swapped sides.
|
||||
const sidebarSide = panesFlipped ? 'right' : 'left'
|
||||
@@ -997,56 +872,33 @@ export function DesktopController() {
|
||||
</Pane>
|
||||
)
|
||||
|
||||
const terminalPane = (
|
||||
<Pane
|
||||
defaultOpen
|
||||
disabled={!terminalSidebarOpen}
|
||||
divider
|
||||
id="terminal-sidebar"
|
||||
key="terminal-sidebar"
|
||||
maxWidth="80vw"
|
||||
minWidth="22vw"
|
||||
resizable
|
||||
side={railSide}
|
||||
width="42vw"
|
||||
>
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-editor-surface-background) pt-(--titlebar-height)">
|
||||
<TerminalSlot />
|
||||
</div>
|
||||
</Pane>
|
||||
)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
leftStatusbarItems={leftStatusbarItems}
|
||||
leftTitlebarTools={titlebarToolGroups.flat.left}
|
||||
mainOverlays={mainOverlays}
|
||||
onOpenSettings={openSettings}
|
||||
overlays={overlays}
|
||||
previewPaneOpen={chatOpen && Boolean(previewTarget || filePreviewTarget)}
|
||||
statusbarItems={statusbarItems}
|
||||
terminalPaneOpen={terminalSidebarOpen}
|
||||
titlebarTools={titlebarToolGroups.flat.right}
|
||||
>
|
||||
{!isSecondaryWindow() && (
|
||||
<Pane
|
||||
forceCollapsed={narrowViewport}
|
||||
hoverReveal
|
||||
id="chat-sidebar"
|
||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||
onOverlayActiveChange={setSidebarOverlayMounted}
|
||||
resizable
|
||||
side={sidebarSide}
|
||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||
>
|
||||
{sidebar}
|
||||
</Pane>
|
||||
)}
|
||||
<Pane
|
||||
disabled={terminalTakeoverActive}
|
||||
forceCollapsed={narrowViewport}
|
||||
hoverReveal
|
||||
id="chat-sidebar"
|
||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||
onOverlayActiveChange={setSidebarOverlayMounted}
|
||||
resizable
|
||||
side={sidebarSide}
|
||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||
>
|
||||
{sidebar}
|
||||
</Pane>
|
||||
<PaneMain>
|
||||
<Routes>
|
||||
<Route element={chatView} index />
|
||||
<Route element={chatView} path=":sessionId" />
|
||||
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} index />
|
||||
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} path=":sessionId" />
|
||||
<Route
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
@@ -1083,13 +935,11 @@ export function DesktopController() {
|
||||
</PaneMain>
|
||||
{/*
|
||||
Order within a side maps to column order. Default (rail on the right):
|
||||
main | terminal | preview | file-browser. Flipped (rail on the left):
|
||||
mirror to file-browser | preview | terminal | main so terminal stays
|
||||
adjacent to the chat.
|
||||
main | preview | file-browser. Flipped (rail on the left): mirror it to
|
||||
file-browser | preview | main so preview stays adjacent to the chat.
|
||||
*/}
|
||||
{panesFlipped ? fileBrowserPane : terminalPane}
|
||||
{previewPane}
|
||||
{panesFlipped ? terminalPane : fileBrowserPane}
|
||||
{panesFlipped ? fileBrowserPane : previewPane}
|
||||
{panesFlipped ? previewPane : fileBrowserPane}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// Shared chrome for the top-center floating HUDs (command palette + session
|
||||
// switcher). They pin just under the title bar, centered, and lean on a crisp
|
||||
// border + shadow to separate from the app — no dimming/blurring backdrop.
|
||||
// Each caller layers on its own z-index, width, and overflow.
|
||||
export const HUD_POSITION = 'fixed left-1/2 top-3 -translate-x-1/2'
|
||||
|
||||
// Matches the app's borderless-overlay surface (dialog, keybind panel, …):
|
||||
// hairline `--stroke-nous` paired with the soft `--shadow-nous` float.
|
||||
export const HUD_SURFACE = 'rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous'
|
||||
|
||||
// One row/text size for both HUDs (compact — two notches under `text-sm`).
|
||||
export const HUD_TEXT = 'text-xs'
|
||||
|
||||
// Shared item layout + padding for both HUDs. Tight vertical rhythm so rows
|
||||
// don't feel chunky; overrides the shadcn `CommandItem` default (`px-2 py-1.5`).
|
||||
export const HUD_ITEM = 'gap-2 px-2 py-1'
|
||||
|
||||
// Section headings styled like the sidebar panel labels: brand-tinted, uppercase,
|
||||
// tightly tracked — plain text, no sticky chrome bar. Targets the cmdk group
|
||||
// heading via the universal-descendant variant.
|
||||
export const HUD_HEADING =
|
||||
'**:[[cmdk-group-heading]]:static **:[[cmdk-group-heading]]:bg-transparent **:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:pt-2.5 **:[[cmdk-group-heading]]:text-[0.64rem] **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-[0.16em] **:[[cmdk-group-heading]]:text-(--theme-primary)'
|
||||
@@ -3,7 +3,6 @@ import { useEffect, useRef } from 'react'
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { desktopDefaultCwd } from '@/lib/desktop-fs'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import {
|
||||
$desktopBoot,
|
||||
@@ -26,16 +25,11 @@ import {
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
ensureDefaultWorkspaceCwd,
|
||||
setConnection,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
@@ -357,12 +351,6 @@ export function useGatewayBoot({
|
||||
message: translateNow('boot.steps.loadingSettings'),
|
||||
progress: 97
|
||||
})
|
||||
await ensureDefaultWorkspaceCwd()
|
||||
const remoteDefault = await desktopDefaultCwd().catch(() => null)
|
||||
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
|
||||
setCurrentCwd(remoteDefault.cwd)
|
||||
setCurrentBranch(remoteDefault.branch || '')
|
||||
}
|
||||
await callbacksRef.current.refreshHermesConfig()
|
||||
|
||||
if (cancelled) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { setRightSidebarTab } from '@/app/right-sidebar/store'
|
||||
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
||||
import { matchesQuery } from '@/hooks/use-media-query'
|
||||
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
||||
import { toggleCommandPalette } from '@/store/command-palette'
|
||||
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
|
||||
@@ -18,25 +18,13 @@ import {
|
||||
toggleSidebarOpen
|
||||
} from '@/store/layout'
|
||||
import {
|
||||
$newChatProfile,
|
||||
cycleProfile,
|
||||
requestProfileCreate,
|
||||
switchProfileToSlot,
|
||||
switchToDefaultProfile,
|
||||
toggleShowAllProfiles
|
||||
} from '@/store/profile'
|
||||
import { setModelPickerOpen } from '@/store/session'
|
||||
import {
|
||||
$switcherOpen,
|
||||
closeSwitcher,
|
||||
commitOnCtrlUp,
|
||||
onSwitcherTabDown,
|
||||
onSwitcherTabUp,
|
||||
openOrAdvanceSwitcher,
|
||||
slotSessionId,
|
||||
switcherActive,
|
||||
switcherJustClosed
|
||||
} from '@/store/session-switcher'
|
||||
import { $activeSessionId, $sessions, setModelPickerOpen } from '@/store/session'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { requestComposerFocus } from '../chat/composer/focus'
|
||||
@@ -72,7 +60,6 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
|
||||
// Keep the latest closures without re-subscribing the listener.
|
||||
const handlersRef = useRef<HandlerMap>({})
|
||||
const commitSwitcherRef = useRef<() => void>(() => {})
|
||||
|
||||
const profileSwitchHandlers: HandlerMap = {}
|
||||
|
||||
@@ -80,32 +67,26 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
|
||||
}
|
||||
|
||||
const goToSession = (sessionId: null | string) => {
|
||||
if (sessionId) {
|
||||
navigate(sessionRoute(sessionId))
|
||||
// Move to the adjacent session in recency order, wrapping at the ends.
|
||||
const cycleSession = (direction: 1 | -1) => {
|
||||
const sessions = $sessions.get()
|
||||
|
||||
if (sessions.length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = sessions.findIndex(session => session.id === $activeSessionId.get())
|
||||
const start = current === -1 ? (direction === 1 ? -1 : 0) : current
|
||||
const next = sessions[(start + direction + sessions.length) % sessions.length]
|
||||
|
||||
if (next) {
|
||||
navigate(sessionRoute(next.id))
|
||||
}
|
||||
}
|
||||
|
||||
// ^N jumps straight to the Nth recent session and dismisses the switcher.
|
||||
const sessionSlotHandlers: HandlerMap = {}
|
||||
|
||||
for (let slot = 1; slot <= SESSION_SLOT_COUNT; slot += 1) {
|
||||
sessionSlotHandlers[`session.slot.${slot}`] = () => {
|
||||
closeSwitcher()
|
||||
goToSession(slotSessionId(slot))
|
||||
}
|
||||
}
|
||||
|
||||
commitSwitcherRef.current = () => goToSession(commitOnCtrlUp())
|
||||
|
||||
const stepSession = (direction: 1 | -1) => {
|
||||
onSwitcherTabDown()
|
||||
goToSession(openOrAdvanceSwitcher(direction))
|
||||
}
|
||||
|
||||
const showFiles = () => {
|
||||
const showRightSidebarTab = (tab: 'files' | 'terminal') => {
|
||||
setFileBrowserOpen(true)
|
||||
setTerminalTakeover(false)
|
||||
setRightSidebarTab(tab)
|
||||
}
|
||||
|
||||
handlersRef.current = {
|
||||
@@ -125,16 +106,11 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
'nav.agents': () => navigate(AGENTS_ROUTE),
|
||||
|
||||
'session.new': () => {
|
||||
// Match the sidebar New Session button. A plain keyboard new chat should
|
||||
// target the current live profile, not a stale per-profile quick-create
|
||||
// selection from a prior action.
|
||||
$newChatProfile.set(null)
|
||||
deps.startFreshSession()
|
||||
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
||||
},
|
||||
'session.next': () => stepSession(1),
|
||||
'session.prev': () => stepSession(-1),
|
||||
...sessionSlotHandlers,
|
||||
'session.next': () => cycleSession(1),
|
||||
'session.prev': () => cycleSession(-1),
|
||||
'session.focusSearch': requestSessionSearchFocus,
|
||||
'session.togglePin': deps.toggleSelectedPin,
|
||||
|
||||
@@ -152,8 +128,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
toggleFileBrowserOpen()
|
||||
}
|
||||
},
|
||||
'view.showFiles': showFiles,
|
||||
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
|
||||
'view.showFiles': () => showRightSidebarTab('files'),
|
||||
'view.showTerminal': () => showRightSidebarTab('terminal'),
|
||||
'view.flipPanes': togglePanesFlipped,
|
||||
|
||||
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
||||
@@ -194,16 +170,6 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
return
|
||||
}
|
||||
|
||||
// While the session switcher is up, Esc abandons it (stay put) before any
|
||||
// combo dispatch — ⌃Tab keeps stepping through the existing handler.
|
||||
if (switcherActive() && event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeSwitcher()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const combo = comboFromEvent(event)
|
||||
|
||||
if (!combo) {
|
||||
@@ -230,39 +196,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
handler()
|
||||
}
|
||||
|
||||
// Mac-app-switcher commit: lifting Ctrl with the overlay open lands on the
|
||||
// highlighted session. A window blur (Cmd+Tab away mid-switch) cancels so
|
||||
// the overlay never gets stranded waiting for a keyup that never comes.
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Tab') {
|
||||
onSwitcherTabUp()
|
||||
}
|
||||
|
||||
if (event.key === 'Control') {
|
||||
commitSwitcherRef.current()
|
||||
}
|
||||
}
|
||||
|
||||
const onBlur = () => switcherActive() && closeSwitcher()
|
||||
|
||||
// Swallow trailing contextmenu after Ctrl+click commit (Electron main menu).
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
if ($switcherOpen.get() || switcherJustClosed()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.addEventListener('keyup', onKeyUp, { capture: true })
|
||||
window.addEventListener('blur', onBlur)
|
||||
window.addEventListener('contextmenu', onContextMenu, { capture: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.removeEventListener('keyup', onKeyUp, { capture: true })
|
||||
window.removeEventListener('blur', onBlur)
|
||||
window.removeEventListener('contextmenu', onContextMenu, { capture: true })
|
||||
}
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { createDragDropManager, type DragDropManager } from 'dnd-core'
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
|
||||
let manager: DragDropManager | null = null
|
||||
|
||||
/**
|
||||
* A single, app-lifetime react-dnd manager for the file tree.
|
||||
*
|
||||
* react-arborist mounts its own react-dnd `DndProvider` with `HTML5Backend`
|
||||
* inside every `<Tree>`. react-dnd v14 stores that provider's manager on a
|
||||
* global, ref-counted singleton context and nulls it when the count hits 0.
|
||||
* On a keyed remount (cwd / collapse changes force a fresh `<Tree>`), the
|
||||
* singleton can be torn down and recreated while the previous `HTML5Backend`
|
||||
* still owns the `window.__isReactDndHtml5Backend` setup flag — so the new
|
||||
* backend's `setup()` throws "Cannot have two HTML5 backends at the same
|
||||
* time." and trips the file-tree error boundary (it never recovers, because
|
||||
* "Try again" just remounts into the same race).
|
||||
*
|
||||
* Passing arborist a stable `dndManager` makes it skip the global-singleton
|
||||
* path entirely and reuse one backend for the lifetime of the app, so the
|
||||
* window flag is never double-claimed.
|
||||
*/
|
||||
export function getFileTreeDndManager(): DragDropManager {
|
||||
manager ??= createDragDropManager(HTML5Backend)
|
||||
|
||||
return manager
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
const readFileDataUrl = vi.fn<(path: string) => Promise<string>>()
|
||||
const gitRoot = vi.fn<(path: string) => Promise<string | null>>()
|
||||
|
||||
function ok(entries: HermesReadDirEntry[]): HermesReadDirResult {
|
||||
return { entries }
|
||||
}
|
||||
|
||||
function dataUrl(text: string) {
|
||||
return `data:text/plain;base64,${Buffer.from(text, 'utf8').toString('base64')}`
|
||||
}
|
||||
|
||||
function installBridge() {
|
||||
;(
|
||||
window as unknown as {
|
||||
hermesDesktop: {
|
||||
gitRoot: typeof gitRoot
|
||||
readDir: typeof readDir
|
||||
readFileDataUrl: typeof readFileDataUrl
|
||||
}
|
||||
}
|
||||
).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
|
||||
}
|
||||
|
||||
describe('readProjectDir', () => {
|
||||
beforeEach(() => {
|
||||
clearProjectDirCache()
|
||||
readDir.mockReset()
|
||||
readFileDataUrl.mockReset()
|
||||
gitRoot.mockReset()
|
||||
installBridge()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearProjectDirCache()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('returns no-bridge when the desktop bridge is unavailable', async () => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
|
||||
await expect(readProjectDir('/repo')).resolves.toEqual({ entries: [], error: 'no-bridge' })
|
||||
})
|
||||
|
||||
it('filters gitignored entries when readDir returns Windows-style paths', async () => {
|
||||
gitRoot.mockResolvedValue('C:\\repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === 'C:\\repo\\src') {
|
||||
return ok([
|
||||
{ name: 'debug.log', path: 'C:\\repo\\src\\debug.log', isDirectory: false },
|
||||
{ name: '临时.txt', path: 'C:\\repo\\src\\临时.txt', isDirectory: false },
|
||||
{ name: 'keep.ts', path: 'C:\\repo\\src\\keep.ts', isDirectory: false }
|
||||
])
|
||||
}
|
||||
|
||||
if (path === 'C:/repo') {
|
||||
return ok([{ name: '.gitignore', path: 'C:/repo/.gitignore', isDirectory: false }])
|
||||
}
|
||||
|
||||
if (path === 'C:/repo/src') {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
return ok([])
|
||||
})
|
||||
readFileDataUrl.mockResolvedValue(dataUrl('# Unicode 路径规则\nsrc/*.log\nsrc/临时.txt\n'))
|
||||
|
||||
const result = await readProjectDir('C:\\repo\\src', 'C:\\repo')
|
||||
|
||||
expect(result.entries.map(entry => entry.name)).toEqual(['keep.ts'])
|
||||
expect(gitRoot).toHaveBeenCalledWith('C:/repo')
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('C:/repo/.gitignore')
|
||||
})
|
||||
|
||||
it('does not fetch .gitignore contents when listings do not contain .gitignore', async () => {
|
||||
gitRoot.mockResolvedValue('/repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === '/repo/src') {
|
||||
return ok([{ name: 'debug.log', path: '/repo/src/debug.log', isDirectory: false }])
|
||||
}
|
||||
|
||||
return ok([])
|
||||
})
|
||||
|
||||
const result = await readProjectDir('/repo/src', '/repo')
|
||||
|
||||
expect(result.entries.map(entry => entry.name)).toEqual(['debug.log'])
|
||||
expect(readFileDataUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import ignore from 'ignore'
|
||||
|
||||
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
|
||||
export type ProjectTreeEntry = HermesReadDirEntry
|
||||
@@ -28,7 +27,7 @@ function decodeDataUrl(dataUrl: string) {
|
||||
}
|
||||
|
||||
function clean(path: string) {
|
||||
return path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'
|
||||
return path.replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
|
||||
@@ -64,11 +63,15 @@ function ancestorDirs(root: string, dir: string) {
|
||||
}
|
||||
|
||||
async function gitRootFor(start: string) {
|
||||
const key = `${desktopFsCacheKey()}:${clean(start)}`
|
||||
if (!window.hermesDesktop?.gitRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const key = clean(start)
|
||||
let cached = gitRootCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = desktopGitRoot(start)
|
||||
cached = window.hermesDesktop.gitRoot(key)
|
||||
gitRootCache.set(key, cached)
|
||||
}
|
||||
|
||||
@@ -77,14 +80,18 @@ async function gitRootFor(start: string) {
|
||||
|
||||
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
|
||||
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const listing = await readDesktopDir(dir)
|
||||
const listing = await window.hermesDesktop.readDir(dir)
|
||||
|
||||
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = decodeDataUrl(await readDesktopFileDataUrl(`${dir}/.gitignore`))
|
||||
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
|
||||
|
||||
return { base: dir, ig: ignore().add(text) }
|
||||
} catch {
|
||||
@@ -93,11 +100,11 @@ async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
}
|
||||
|
||||
async function gitignoreFor(dir: string) {
|
||||
const key = `${desktopFsCacheKey()}:${clean(dir)}`
|
||||
const key = clean(dir)
|
||||
let cached = gitignoreCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = readGitignore(clean(dir))
|
||||
cached = readGitignore(key)
|
||||
gitignoreCache.set(key, cached)
|
||||
}
|
||||
|
||||
@@ -135,10 +142,9 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
|
||||
return { entries: [], error: 'no-bridge' }
|
||||
}
|
||||
|
||||
const result = await readDesktopDir(dirPath)
|
||||
const entries = result?.entries ?? []
|
||||
const result = await window.hermesDesktop.readDir(dirPath)
|
||||
|
||||
return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
|
||||
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
|
||||
}
|
||||
|
||||
export function clearProjectDirCache(rootPath?: string) {
|
||||
@@ -149,7 +155,7 @@ export function clearProjectDirCache(rootPath?: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${desktopFsCacheKey()}:${clean(rootPath)}`
|
||||
const key = clean(rootPath)
|
||||
gitRootCache.delete(key)
|
||||
gitignoreCache.delete(key)
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function clean(path: string) {
|
||||
return path.replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
function parentDir(path: string) {
|
||||
const value = clean(path)
|
||||
if (value === '/') {
|
||||
return '/'
|
||||
}
|
||||
const parent = value.slice(0, value.lastIndexOf('/'))
|
||||
return parent || '/'
|
||||
}
|
||||
|
||||
function pathName(path: string) {
|
||||
return path.split('/').filter(Boolean).pop() || path
|
||||
}
|
||||
|
||||
interface PendingSelection {
|
||||
defaultPath: string
|
||||
resolve: (paths: string[]) => void
|
||||
title: string
|
||||
}
|
||||
|
||||
export function RemoteFolderPicker() {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
const [pending, setPending] = useState<PendingSelection | null>(null)
|
||||
const [currentPath, setCurrentPath] = useState('/')
|
||||
const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDesktopFsRemotePicker({
|
||||
selectPaths: options =>
|
||||
new Promise(resolve => {
|
||||
const defaultPath = clean(options?.defaultPath || '/')
|
||||
setCurrentPath(defaultPath)
|
||||
setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle })
|
||||
})
|
||||
})
|
||||
return () => setDesktopFsRemotePicker(null)
|
||||
}, [r.remotePickerTitle])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
void readDesktopDir(currentPath)
|
||||
.then(result => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
setEntries([])
|
||||
return
|
||||
}
|
||||
setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path })))
|
||||
})
|
||||
.catch(err => {
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setEntries([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [currentPath, pending])
|
||||
|
||||
const crumbs = useMemo(() => {
|
||||
const parts = clean(currentPath).split('/').filter(Boolean)
|
||||
const out = [{ label: '/', path: '/' }]
|
||||
let acc = ''
|
||||
for (const part of parts) {
|
||||
acc += `/${part}`
|
||||
out.push({ label: part, path: acc })
|
||||
}
|
||||
return out
|
||||
}, [currentPath])
|
||||
|
||||
const close = (paths: string[] = []) => {
|
||||
pending?.resolve(paths)
|
||||
setPending(null)
|
||||
setEntries([])
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}>
|
||||
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
|
||||
<div className="border-b border-border/70 px-4 py-3">
|
||||
<DialogTitle className="text-sm">{pending?.title || r.remotePickerTitle}</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-xs">{r.remotePickerDescription}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[22rem] flex-col">
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
{crumbs.map((crumb, index) => (
|
||||
<button
|
||||
className={cn('rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground', index === crumbs.length - 1 && 'text-foreground')}
|
||||
key={crumb.path}
|
||||
onClick={() => setCurrentPath(crumb.path)}
|
||||
type="button"
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<FolderRow disabled={currentPath === '/'} name=".." onClick={() => setCurrentPath(parentDir(currentPath))} />
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
|
||||
<Codicon name="loading" size="0.8rem" spinning />
|
||||
{r.loadingFiles}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="px-2 py-3 text-xs text-destructive">{r.unreadableBody(error)}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="px-2 py-3 text-xs text-muted-foreground">{r.emptyBody}</div>
|
||||
) : (
|
||||
entries.map(entry => <FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
|
||||
<div className="min-w-0 truncate text-xs text-muted-foreground">{currentPath}</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button onClick={() => close()} size="sm" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={() => close([currentPath])} size="sm">
|
||||
{r.remotePickerSelect}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="folder" size="0.875rem" />
|
||||
<span className="min-w-0 truncate">{name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getFileTreeDndManager } from './dnd-manager'
|
||||
import type { TreeNode } from './use-project-tree'
|
||||
|
||||
const ROW_HEIGHT = 22
|
||||
@@ -95,7 +94,6 @@ export function ProjectTree({
|
||||
disableDrag
|
||||
disableDrop
|
||||
disableEdit
|
||||
dndManager={getFileTreeDndManager()}
|
||||
height={size.height}
|
||||
indent={INDENT}
|
||||
initialOpenState={openState}
|
||||
@@ -147,8 +145,7 @@ function ProjectTreeRow({
|
||||
}
|
||||
|
||||
const isFolder = node.data.isDirectory
|
||||
const isPlaceholder = Boolean(node.data.placeholder)
|
||||
const isErrorPlaceholder = node.data.placeholder === 'error'
|
||||
const isPlaceholder = node.data.id.endsWith('::__loading__')
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -213,10 +210,8 @@ function ProjectTreeRow({
|
||||
)}
|
||||
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
|
||||
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
|
||||
{isPlaceholder && !isErrorPlaceholder ? (
|
||||
{isPlaceholder ? (
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
) : isErrorPlaceholder ? (
|
||||
<Codicon name="warning" size="0.75rem" />
|
||||
) : isFolder ? (
|
||||
<Codicon name={node.isOpen ? 'folder-opened' : 'folder'} size="0.875rem" />
|
||||
) : (
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
import type { HermesReadDirResult } from '@/global'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
|
||||
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
|
||||
beforeEach(() => {
|
||||
$connection.set(null)
|
||||
resetProjectTreeState()
|
||||
readDir.mockReset()
|
||||
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
resetProjectTreeState()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
@@ -111,37 +106,7 @@ describe('useProjectTree', () => {
|
||||
expect(readDir).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reads gitignore from the real path while caching per connection', async () => {
|
||||
const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`)
|
||||
const gitRoot = vi.fn(async () => '/repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
|
||||
if (path === '/repo/src') {
|
||||
return ok([
|
||||
{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false },
|
||||
{ name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false }
|
||||
])
|
||||
}
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
})
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
|
||||
|
||||
$connection.set({ baseUrl: 'local-a', mode: 'local' } as never)
|
||||
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
|
||||
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
|
||||
})
|
||||
expect(readDir).toHaveBeenCalledWith('/repo')
|
||||
expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a'))
|
||||
|
||||
$connection.set({ baseUrl: 'local-b', mode: 'local' } as never)
|
||||
clearProjectDirCache()
|
||||
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
|
||||
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
|
||||
})
|
||||
expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('captures per-folder error code and shows an error placeholder child', async () => {
|
||||
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
|
||||
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
|
||||
|
||||
@@ -154,14 +119,7 @@ describe('useProjectTree', () => {
|
||||
})
|
||||
|
||||
expect(result.current.data[0].error).toBe('EACCES')
|
||||
expect(result.current.data[0].children).toEqual([
|
||||
{
|
||||
id: '/p/priv::__error__',
|
||||
isDirectory: false,
|
||||
name: 'Unable to read (EACCES)',
|
||||
placeholder: 'error'
|
||||
}
|
||||
])
|
||||
expect(result.current.data[0].children).toEqual([])
|
||||
})
|
||||
|
||||
it('dedupes concurrent loadChildren calls for the same id', async () => {
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
|
||||
export interface TreeNode {
|
||||
@@ -16,14 +14,11 @@ export interface TreeNode {
|
||||
children?: TreeNode[]
|
||||
/** True while a readDir for this folder is in flight. */
|
||||
loading?: boolean
|
||||
/** Synthetic loading/error rows are not real filesystem entries. */
|
||||
placeholder?: 'error' | 'loading'
|
||||
/** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PLACEHOLDER_ID = '__loading__'
|
||||
const ERROR_PLACEHOLDER_ID = '__error__'
|
||||
|
||||
function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
|
||||
return { id: path, isDirectory, name }
|
||||
@@ -48,16 +43,7 @@ function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n:
|
||||
}
|
||||
|
||||
function placeholderChild(parentId: string): TreeNode {
|
||||
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…', placeholder: 'loading' }
|
||||
}
|
||||
|
||||
function errorChild(parentId: string, error: string | undefined): TreeNode {
|
||||
return {
|
||||
id: `${parentId}::${ERROR_PLACEHOLDER_ID}`,
|
||||
isDirectory: false,
|
||||
name: `Unable to read (${error || 'read-error'})`,
|
||||
placeholder: 'error'
|
||||
}
|
||||
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' }
|
||||
}
|
||||
|
||||
export interface UseProjectTreeResult {
|
||||
@@ -98,7 +84,6 @@ const initialState: ProjectTreeState = {
|
||||
const inflight = new Set<string>()
|
||||
const $projectTree = atom<ProjectTreeState>(initialState)
|
||||
let nextRootRequestId = 0
|
||||
let lastConnectionKey = ''
|
||||
|
||||
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
|
||||
$projectTree.set(updater($projectTree.get()))
|
||||
@@ -160,7 +145,6 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
|
||||
}
|
||||
|
||||
export function resetProjectTreeState() {
|
||||
lastConnectionKey = ''
|
||||
clearProjectTree()
|
||||
clearProjectDirCache()
|
||||
}
|
||||
@@ -174,8 +158,6 @@ export function resetProjectTreeState() {
|
||||
*/
|
||||
export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
const state = useStore($projectTree)
|
||||
const connection = useStore($connection)
|
||||
const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
|
||||
|
||||
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
|
||||
|
||||
@@ -245,7 +227,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
...n,
|
||||
loading: false,
|
||||
error: error || undefined,
|
||||
children: error ? [errorChild(n.id, error)] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
|
||||
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -254,15 +236,8 @@ 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])
|
||||
}, [cwd])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -4,22 +4,22 @@ import type { ReactNode } from 'react'
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
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'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
import { $currentBranch, $currentCwd } from '@/store/session'
|
||||
|
||||
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
||||
|
||||
import { RemoteFolderPicker } from './files/remote-picker'
|
||||
import { ProjectTree } from './files/tree'
|
||||
import { useProjectTree } from './files/use-project-tree'
|
||||
import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store'
|
||||
import { TerminalSlot } from './terminal/persistent'
|
||||
|
||||
interface RightSidebarPaneProps {
|
||||
onActivateFile: (path: string) => void
|
||||
@@ -27,10 +27,24 @@ interface RightSidebarPaneProps {
|
||||
onChangeCwd: (path: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
interface RightSidebarTab {
|
||||
icon: string
|
||||
id: RightSidebarTabId
|
||||
labelKey: 'files' | 'terminal'
|
||||
}
|
||||
|
||||
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
|
||||
{ id: 'files', labelKey: 'files', icon: 'list-tree' },
|
||||
{ id: 'terminal', labelKey: 'terminal', icon: 'terminal' }
|
||||
]
|
||||
|
||||
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
const activeTab = useStore($rightSidebarTab)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const currentBranch = useStore($currentBranch).trim()
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
|
||||
@@ -54,9 +68,10 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
} = useProjectTree(currentCwd)
|
||||
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await selectDesktopPaths({
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
defaultPath: hasCwd ? currentCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
@@ -82,6 +97,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = terminalTakeover ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') : RIGHT_SIDEBAR_TABS
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label={r.aria}
|
||||
@@ -92,31 +109,85 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
)}
|
||||
>
|
||||
<RemoteFolderPicker />
|
||||
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
|
||||
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
error={rootError}
|
||||
hasCwd={hasCwd}
|
||||
loading={rootLoading}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onChangeFolder={chooseFolder}
|
||||
onCollapseAll={collapseAll}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
onRefresh={() => void refreshRoot()}
|
||||
openState={openState}
|
||||
/>
|
||||
{effectiveTab === 'terminal' ? (
|
||||
<TerminalSlot />
|
||||
) : (
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
error={rootError}
|
||||
hasCwd={hasCwd}
|
||||
loading={rootLoading}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onChangeFolder={chooseFolder}
|
||||
onCollapseAll={collapseAll}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
onRefresh={() => void refreshRoot()}
|
||||
openState={openState}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function RightSidebarChrome({
|
||||
activeTab,
|
||||
branch,
|
||||
tabs
|
||||
}: {
|
||||
activeTab: RightSidebarTabId
|
||||
branch: string
|
||||
tabs: readonly RightSidebarTab[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
|
||||
return (
|
||||
<header className="shrink-0 bg-transparent text-[0.75rem]">
|
||||
<div className="flex items-center gap-2 px-2.5 py-1">
|
||||
<nav aria-label={r.panelsAria} className="flex min-w-0 items-center gap-1">
|
||||
{tabs.map(tab => {
|
||||
const label = r[tab.labelKey]
|
||||
|
||||
return (
|
||||
<Tip key={tab.id} label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={tab.id === activeTab}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
onClick={() => setRightSidebarTab(tab.id)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={tab.icon} size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{branch && (
|
||||
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
|
||||
<span className="truncate">{branch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
canCollapse: boolean
|
||||
cwdName: string
|
||||
|
||||
@@ -2,10 +2,14 @@ import { atom } from 'nanostores'
|
||||
|
||||
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||
|
||||
export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web'
|
||||
|
||||
const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover'
|
||||
|
||||
export const $rightSidebarTab = atom<RightSidebarTabId>('files')
|
||||
export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
|
||||
|
||||
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
|
||||
|
||||
export const setRightSidebarTab = (tab: RightSidebarTabId) => $rightSidebarTab.set(tab)
|
||||
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
|
||||
// Serialized view of the in-app terminal, handed to the agent's `read_terminal`
|
||||
// tool. Line indices are absolute into xterm's buffer (0 = oldest scrollback
|
||||
// line), so the agent can page with start_line/count against `total_lines`.
|
||||
export interface TerminalReadResult {
|
||||
total_lines: number
|
||||
start: number
|
||||
end: number
|
||||
viewport_rows: number
|
||||
cursor_row: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface TerminalReadOptions {
|
||||
start?: number
|
||||
count?: number
|
||||
}
|
||||
|
||||
type Reader = (opts: TerminalReadOptions) => TerminalReadResult
|
||||
|
||||
// The persistent terminal is a singleton (one xterm mounted forever), so a
|
||||
// module-level slot is enough — set while the session is live, cleared on
|
||||
// dispose. The gateway `terminal.read.request` handler reads through this.
|
||||
let activeReader: Reader | null = null
|
||||
|
||||
export function setActiveTerminalReader(reader: Reader | null): void {
|
||||
activeReader = reader
|
||||
}
|
||||
|
||||
export function readActiveTerminal(opts: TerminalReadOptions = {}): TerminalReadResult | null {
|
||||
return activeReader ? activeReader(opts) : null
|
||||
}
|
||||
|
||||
export function makeTerminalReader(term: Terminal): Reader {
|
||||
return ({ start, count }) => {
|
||||
const buf = term.buffer.active
|
||||
const total = buf.length
|
||||
const rows = term.rows
|
||||
// Default window = the visible screen; baseY is the viewport's top row.
|
||||
const from = Math.max(0, Math.min(start ?? buf.baseY, total))
|
||||
const to = Math.max(from, Math.min(from + Math.max(1, count ?? rows), total))
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
// translateToString(true) right-trims and resolves wide chars, dropping SGR
|
||||
// colors — exactly what the agent wants.
|
||||
for (let i = from; i < to; i += 1) {
|
||||
lines.push(buf.getLine(i)?.translateToString(true) ?? '')
|
||||
}
|
||||
|
||||
while (lines.length && !lines[lines.length - 1].trim()) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
return {
|
||||
total_lines: total,
|
||||
start: from,
|
||||
end: to,
|
||||
viewport_rows: rows,
|
||||
cursor_row: buf.baseY + buf.cursorY,
|
||||
text: lines.join('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
@@ -7,7 +9,7 @@ import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import { setTerminalTakeover } from '../store'
|
||||
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
|
||||
|
||||
import { addSelectionShortcutLabel } from './selection'
|
||||
import { useTerminalSession } from './use-terminal-session'
|
||||
@@ -19,32 +21,41 @@ interface TerminalTabProps {
|
||||
|
||||
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
|
||||
cwd,
|
||||
onAddSelectionToChat
|
||||
})
|
||||
|
||||
const label = t.rightSidebar.terminalHide
|
||||
const takeover = useStore($terminalTakeover)
|
||||
const label = takeover ? t.rightSidebar.terminalSplit : t.rightSidebar.terminalFocus
|
||||
|
||||
const toggleTakeover = () => {
|
||||
// Pre-select the Terminal tab so the slot is ready to host us on return.
|
||||
if (takeover) {
|
||||
setRightSidebarTab('terminal')
|
||||
}
|
||||
|
||||
setTerminalTakeover(!takeover)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
|
||||
<SidebarPanelLabel className="text-(--ui-text-secondary)!">{shellName}</SidebarPanelLabel>
|
||||
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="ml-auto size-6 rounded-md text-(--ui-text-secondary)!"
|
||||
onClick={() => setTerminalTakeover(false)}
|
||||
className="ml-auto size-6 rounded-md text-white!"
|
||||
onClick={toggleTakeover}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.875rem" />
|
||||
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 bg-(--ui-editor-surface-background) p-2">
|
||||
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
|
||||
{status === 'starting' && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
|
||||
<Loader
|
||||
@@ -73,13 +84,12 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Outer div paints terminal inset; inner div is the xterm host so the
|
||||
canvas sizes to the content area and p-2 stays as terminal padding.
|
||||
Screen/viewport inherit the live skin surface so the terminal blends
|
||||
with the app and follows light/dark; the xterm canvas itself is
|
||||
painted the resolved surface color in use-terminal-session. */}
|
||||
{/* Outer div paints the dark inset; inner div is the xterm host so the
|
||||
canvas sizes to the *content* area and p-2 shows as terminal padding.
|
||||
Forcing screen/viewport bg avoids xterm's default black peeking
|
||||
through the unused pixels below the last full row. */}
|
||||
<div
|
||||
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-(--ui-editor-surface-background)! [&_.xterm-viewport]:bg-(--ui-editor-surface-background)!"
|
||||
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[#002b36]! [&_.xterm-viewport]:bg-[#002b36]!"
|
||||
ref={hostRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { TERMINAL_BG } from './selection'
|
||||
|
||||
import { TerminalTab } from './index'
|
||||
|
||||
/**
|
||||
@@ -105,9 +107,7 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
|
||||
visibility: visible ? 'visible' : 'hidden',
|
||||
pointerEvents: visible ? 'auto' : 'none',
|
||||
zIndex: 4,
|
||||
// Match the live skin surface so the header strip (transparent) and body
|
||||
// read as one cohesive pane instead of revealing a near-black slab behind.
|
||||
backgroundColor: 'var(--ui-editor-surface-background)',
|
||||
backgroundColor: TERMINAL_BG,
|
||||
contain: 'layout size paint'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,101 +1,38 @@
|
||||
import type { ITheme, Terminal } from '@xterm/xterm'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import type { DesktopTerminalPalette } from '@/themes/types'
|
||||
// Solarized-derived palette, but with bright ANSI 8–15 promoted to real
|
||||
// accent variants instead of Schoonover's UI grays. Hermes' TUI skins (gold,
|
||||
// crimson, ...) emit bright SGR codes that would otherwise wash out to gray.
|
||||
// We always render the dark canvas — the app's light surfaces can't host the
|
||||
// default skin without dropping below readable contrast.
|
||||
export const TERMINAL_BG = '#002b36'
|
||||
|
||||
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
|
||||
// fixed table per theme type, not luminance-derived. Light/dark diverge on
|
||||
// purpose so each stays legible (e.g. mustard yellow on white).
|
||||
const DARK_THEME: ITheme = {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#cccccc',
|
||||
cursor: '#cccccc',
|
||||
cursorAccent: '#1e1e1e',
|
||||
selectionBackground: '#264f7866',
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#e5e5e5'
|
||||
const THEME: ITheme = {
|
||||
background: TERMINAL_BG,
|
||||
foreground: '#839496',
|
||||
cursor: '#93a1a1',
|
||||
cursorAccent: TERMINAL_BG,
|
||||
selectionBackground: '#586e7555',
|
||||
black: '#073642',
|
||||
red: '#dc322f',
|
||||
green: '#859900',
|
||||
yellow: '#b58900',
|
||||
blue: '#268bd2',
|
||||
magenta: '#d33682',
|
||||
cyan: '#2aa198',
|
||||
white: '#eee8d5',
|
||||
brightBlack: '#586e75',
|
||||
brightRed: '#f25c54',
|
||||
brightGreen: '#b3d437',
|
||||
brightYellow: '#f7c948',
|
||||
brightBlue: '#5fb3ff',
|
||||
brightMagenta: '#ff6ab4',
|
||||
brightCyan: '#5cd9c8',
|
||||
brightWhite: '#fdf6e3'
|
||||
}
|
||||
|
||||
const LIGHT_THEME: ITheme = {
|
||||
background: '#ffffff',
|
||||
foreground: '#333333',
|
||||
cursor: '#333333',
|
||||
cursorAccent: '#ffffff',
|
||||
selectionBackground: '#add6ff80',
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#00bc00',
|
||||
yellow: '#949800',
|
||||
blue: '#0451a5',
|
||||
magenta: '#bc05bc',
|
||||
cyan: '#0598bc',
|
||||
white: '#555555',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#cd3131',
|
||||
brightGreen: '#14ce14',
|
||||
brightYellow: '#b5ba00',
|
||||
brightBlue: '#0451a5',
|
||||
brightMagenta: '#bc05bc',
|
||||
brightCyan: '#0598bc',
|
||||
brightWhite: '#a5a5a5'
|
||||
}
|
||||
|
||||
// Palette by painted mode, optionally overlaid with an imported theme's ANSI
|
||||
// palette (Solarized terminal for the Solarized skin, etc.). `palette` only
|
||||
// fills the slots it defines, so a partial import keeps the mode defaults for
|
||||
// the rest. `background` is a fallback only — withSurface swaps in the live skin
|
||||
// surface at runtime (keeping transparency); minimumContrastRatio keeps colors
|
||||
// crisp against it.
|
||||
export function terminalTheme(mode: 'light' | 'dark', palette?: DesktopTerminalPalette): ITheme {
|
||||
const base = mode === 'dark' ? DARK_THEME : LIGHT_THEME
|
||||
|
||||
if (!palette) {
|
||||
return base
|
||||
}
|
||||
|
||||
const overlay = { ...base } as Record<string, string>
|
||||
|
||||
for (const [slot, value] of Object.entries(palette)) {
|
||||
if (value) {
|
||||
overlay[slot] = value
|
||||
}
|
||||
}
|
||||
|
||||
return overlay as ITheme
|
||||
}
|
||||
|
||||
// Resolve --ui-editor-surface-background (a color-mix on the skin seed) to a
|
||||
// concrete rgb for the WebGL renderer + contrast clamp. Custom props don't
|
||||
// resolve via getComputedStyle, so probe a real background-color. Read AFTER
|
||||
// applyTheme repaints (mount / rAF post-change) or it lags a frame behind.
|
||||
export function resolveSurfaceColor(fallback: string): string {
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const probe = document.createElement('span')
|
||||
probe.style.cssText =
|
||||
'position:absolute;visibility:hidden;pointer-events:none;background-color:var(--ui-editor-surface-background)'
|
||||
document.body.appendChild(probe)
|
||||
const resolved = getComputedStyle(probe).backgroundColor
|
||||
probe.remove()
|
||||
|
||||
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
|
||||
}
|
||||
export const terminalTheme = (): ITheme => THEME
|
||||
|
||||
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
|
||||
|
||||
|
||||
@@ -3,20 +3,12 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
|
||||
import {
|
||||
isAddSelectionShortcut,
|
||||
resolveSurfaceColor,
|
||||
terminalSelectionAnchor,
|
||||
terminalSelectionLabel,
|
||||
terminalTheme
|
||||
} from './selection'
|
||||
import { isAddSelectionShortcut, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection'
|
||||
|
||||
type TerminalStatus = 'closed' | 'open' | 'starting'
|
||||
|
||||
@@ -72,29 +64,10 @@ function stripEscapeSequences(data: string) {
|
||||
return text
|
||||
}
|
||||
|
||||
// Keep only the ANSI escape sequences from a chunk, dropping printable text. Lets
|
||||
// us apply control codes (e.g. a clear-screen) while discarding boot spacers and
|
||||
// zsh's reverse-video "%" partial-line marker.
|
||||
function keepEscapeSequences(data: string) {
|
||||
let index = 0
|
||||
let out = ''
|
||||
function isStartupSpacer(data: string) {
|
||||
const text = stripEscapeSequences(data).replace(/[\s\r\n]/g, '')
|
||||
|
||||
while (index < data.length) {
|
||||
if (data.charCodeAt(index) === 0x1b) {
|
||||
const sequence = readEscapeSequence(data, index)
|
||||
|
||||
if (sequence) {
|
||||
out += sequence
|
||||
index += sequence.length
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
return out
|
||||
return text === '' || text === '%'
|
||||
}
|
||||
|
||||
function stripInitialPromptGap(data: string) {
|
||||
@@ -122,14 +95,6 @@ interface UseTerminalSessionOptions {
|
||||
onAddSelectionToChat: (text: string, label?: string) => void
|
||||
}
|
||||
|
||||
// Bind the palette to the live skin surface so the terminal blends with the app
|
||||
// (and the contrast clamp has a real background to work against).
|
||||
function withSurface(theme: ReturnType<typeof terminalTheme>) {
|
||||
const surface = resolveSurfaceColor(theme.background ?? '#ffffff')
|
||||
|
||||
return { ...theme, background: surface, cursorAccent: surface }
|
||||
}
|
||||
|
||||
function transferHasDropCandidates(t: DataTransfer): boolean {
|
||||
if (t.types?.includes(HERMES_PATHS_MIME)) {
|
||||
return true
|
||||
@@ -219,21 +184,8 @@ function quotePathForShell(path: string, shellName: string): string {
|
||||
}
|
||||
|
||||
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
|
||||
// Key off renderedMode (the painted surface type), not resolvedMode (the
|
||||
// clicked switch) — a skin can keep a light surface in "dark" mode, and we
|
||||
// must match the surface or the ANSI palette inverts against it. themeName
|
||||
// re-resolves the canvas surface on skin switches (same mode, new tint).
|
||||
const { renderedMode, theme, themeName } = useTheme()
|
||||
// Adopt the skin's ANSI palette when it ships one (imported VS Code themes do),
|
||||
// matched to the painted variant; built-in skins carry none, so the terminal
|
||||
// keeps its VS Code defaults. withSurface still owns the background, so this
|
||||
// never touches transparency.
|
||||
const ansiPalette = renderedMode === 'dark' ? (theme.darkTerminal ?? theme.terminal) : theme.terminal
|
||||
const activeTheme = useMemo(() => terminalTheme(renderedMode, ansiPalette), [renderedMode, ansiPalette])
|
||||
const initialThemeRef = useRef(activeTheme)
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const termRef = useRef<Terminal | null>(null)
|
||||
const webglRef = useRef<WebglAddon | null>(null)
|
||||
const sessionIdRef = useRef<string | null>(null)
|
||||
const shellNameRef = useRef('shell')
|
||||
const selectionLabelRef = useRef('')
|
||||
@@ -248,26 +200,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
onAddSelectionToChatRef.current = onAddSelectionToChat
|
||||
}, [onAddSelectionToChat])
|
||||
|
||||
// Live selection at call time. A redraw-heavy TUI (spinners, clocks) outruns
|
||||
// onSelectionChange, so trust xterm directly — fall back to the native
|
||||
// selection — rather than the cached ref / React state.
|
||||
const readSelection = useCallback(
|
||||
() => termRef.current?.getSelection() || window.getSelection()?.toString() || '',
|
||||
[]
|
||||
)
|
||||
|
||||
const addSelectionToChat = useCallback(() => {
|
||||
const selectedText = readSelection() || selectionRef.current
|
||||
const selectedText = selectionRef.current || termRef.current?.getSelection() || ''
|
||||
|
||||
const label =
|
||||
selectionLabelRef.current ||
|
||||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
|
||||
|
||||
const trimmed = selectedText.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
const label =
|
||||
selectionLabelRef.current ||
|
||||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
|
||||
|
||||
onAddSelectionToChatRef.current(trimmed, label)
|
||||
termRef.current?.clearSelection()
|
||||
selectionRef.current = ''
|
||||
@@ -275,14 +220,15 @@ 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
|
||||
// must reach the shell as clear-screen.
|
||||
useEffect(() => {
|
||||
if (!selection.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isAddSelectionShortcut(event) || !readSelection().trim()) {
|
||||
if (!isAddSelectionShortcut(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -294,7 +240,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [addSelectionToChat, readSelection])
|
||||
}, [addSelectionToChat, selection])
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
@@ -315,25 +261,12 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
cursorBlink: true,
|
||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
|
||||
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
|
||||
fontSize: 11,
|
||||
fontWeight: '400',
|
||||
fontWeightBold: '700',
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.12,
|
||||
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
|
||||
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
|
||||
// selection over mouse-mode apps, which ⌘/Ctrl+L then sends to chat.
|
||||
macOptionClickForcesSelection: true,
|
||||
macOptionIsMeta: true,
|
||||
// VS Code/Cursor's secret sauce: terminal.integrated.minimumContrastRatio
|
||||
// defaults to 4.5 there. xterm defaults to 1 (off), which paints the raw
|
||||
// saturated ANSI palette — vivid green/cyan on white reads as candy.
|
||||
// Clamping to 4.5:1 darkens/lightens foregrounds against the background
|
||||
// at render time, matching the muted ink-like look of their terminal.
|
||||
minimumContrastRatio: 4.5,
|
||||
scrollback: 1000,
|
||||
theme: withSurface(initialThemeRef.current)
|
||||
theme: terminalTheme()
|
||||
})
|
||||
|
||||
const fit = new FitAddon()
|
||||
@@ -343,10 +276,18 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
term.loadAddon(new Unicode11Addon())
|
||||
term.loadAddon(new WebLinksAddon())
|
||||
term.unicode.activeVersion = '11'
|
||||
term.open(host)
|
||||
term.focus()
|
||||
|
||||
// Let the GUI chat agent read this pane via the `read_terminal` tool: the
|
||||
// gateway's terminal.read.request handler serializes the buffer through this.
|
||||
setActiveTerminalReader(makeTerminalReader(term))
|
||||
// WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
|
||||
// renderer paints SGR via CSS classes that visibly mute against our skins.
|
||||
try {
|
||||
const webgl = new WebglAddon()
|
||||
webgl.onContextLoss(() => webgl.dispose())
|
||||
term.loadAddon(webgl)
|
||||
} catch (err) {
|
||||
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
|
||||
@@ -387,75 +328,6 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
host.removeEventListener('drop', onDrop)
|
||||
})
|
||||
|
||||
// A fresh prompt should sit at the top. Every resize SIGWINCHes the shell,
|
||||
// which reprints its prompt and can leave stale blank rows above it. While
|
||||
// the session is pristine (nothing run yet) we ask the shell to clear +
|
||||
// redraw via Ctrl-L (\f) after the resize settles. Ctrl-L preserves
|
||||
// multi-line prompts (term.clear() would drop all but the cursor row) and we
|
||||
// stop the moment real output exists, so command scrollback is never wiped.
|
||||
let promptPristine = true
|
||||
let gapCleanupTimer = 0
|
||||
|
||||
// While armed, strip leading blank rows so the prompt lands at the very top
|
||||
// (no starship `add_newline` gap). Re-armed before each Ctrl-L redraw so the
|
||||
// resize cleanup doesn't reintroduce the blank line.
|
||||
let stripLeading = true
|
||||
|
||||
const armedWrite = (data: string) => {
|
||||
if (!stripLeading) {
|
||||
term.write(data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const next = stripInitialPromptGap(data)
|
||||
const visible = stripEscapeSequences(next).replace(/[\s%]/g, '')
|
||||
|
||||
if (!visible) {
|
||||
// Spacer / lone clear-screen / zsh `%` marker: apply control codes but
|
||||
// drop the blank text and stay armed so the prompt still lands at top.
|
||||
const controls = keepEscapeSequences(next)
|
||||
|
||||
if (controls) {
|
||||
term.write(controls)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
stripLeading = false
|
||||
term.write(next)
|
||||
}
|
||||
|
||||
const scheduleGapCleanup = () => {
|
||||
if (!promptPristine) {
|
||||
return
|
||||
}
|
||||
|
||||
if (gapCleanupTimer) {
|
||||
window.clearTimeout(gapCleanupTimer)
|
||||
}
|
||||
|
||||
gapCleanupTimer = window.setTimeout(() => {
|
||||
gapCleanupTimer = 0
|
||||
const id = sessionIdRef.current
|
||||
|
||||
if (disposed || !id || !promptPristine) {
|
||||
return
|
||||
}
|
||||
|
||||
stripLeading = true
|
||||
void terminalApi.write(id, '\f')
|
||||
term.clearSelection()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
cleanup.push(() => {
|
||||
if (gapCleanupTimer) {
|
||||
window.clearTimeout(gapCleanupTimer)
|
||||
}
|
||||
})
|
||||
|
||||
const fitAndResize = () => {
|
||||
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
|
||||
return
|
||||
@@ -472,7 +344,6 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
if (id && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) {
|
||||
lastSentSize = { cols: term.cols, rows: term.rows }
|
||||
void terminalApi.resize(id, { cols: term.cols, rows: term.rows })
|
||||
scheduleGapCleanup()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,12 +380,6 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
const id = sessionIdRef.current
|
||||
|
||||
if (id) {
|
||||
// Once the user submits a line, real output may follow — stop the
|
||||
// pristine-prompt gap cleanup so we never clear command scrollback.
|
||||
if (promptPristine && data.includes('\r')) {
|
||||
promptPristine = false
|
||||
}
|
||||
|
||||
void terminalApi.write(id, data)
|
||||
}
|
||||
})
|
||||
@@ -531,88 +396,87 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
|
||||
cleanup.push(() => selectionDisposable.dispose())
|
||||
|
||||
const startSession = () =>
|
||||
void terminalApi
|
||||
.start({ cols: term.cols, cwd, rows: term.rows })
|
||||
.then(session => {
|
||||
if (disposed) {
|
||||
void terminalApi.dispose(session.id)
|
||||
term.attachCustomKeyEventHandler(event => {
|
||||
if (event.type !== 'keydown') {
|
||||
return true
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
if (isAddSelectionShortcut(event) && term.hasSelection()) {
|
||||
event.preventDefault()
|
||||
addSelectionToChat()
|
||||
|
||||
sessionIdRef.current = session.id
|
||||
lastSentSize = { cols: term.cols, rows: term.rows }
|
||||
shellNameRef.current = session.shell || 'shell'
|
||||
setShellName(session.shell || 'shell')
|
||||
return false
|
||||
}
|
||||
|
||||
const initial = term.hasSelection() ? term.getSelection() : ''
|
||||
selectionRef.current = initial
|
||||
selectionLabelRef.current = initial ? terminalSelectionLabel(term, shellNameRef.current, initial) : ''
|
||||
return true
|
||||
})
|
||||
|
||||
setStatus('open')
|
||||
fitAndResize()
|
||||
|
||||
cleanup.push(
|
||||
terminalApi.onData(session.id, armedWrite),
|
||||
terminalApi.onExit(session.id, ({ code, signal }) => {
|
||||
setStatus('closed')
|
||||
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
|
||||
})
|
||||
)
|
||||
void terminalApi
|
||||
.start({ cols: term.cols, cwd, rows: term.rows })
|
||||
.then(session => {
|
||||
if (disposed) {
|
||||
void terminalApi.dispose(session.id)
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
fitAndResize()
|
||||
term.clearSelection() // drop any selection painted over transient boot rows
|
||||
term.focus()
|
||||
return
|
||||
}
|
||||
|
||||
sessionIdRef.current = session.id
|
||||
lastSentSize = { cols: term.cols, rows: term.rows }
|
||||
shellNameRef.current = session.shell || 'shell'
|
||||
setShellName(session.shell || 'shell')
|
||||
|
||||
if (term.hasSelection()) {
|
||||
const currentSelection = term.getSelection()
|
||||
selectionRef.current = currentSelection
|
||||
selectionLabelRef.current = terminalSelectionLabel(term, shellNameRef.current, currentSelection)
|
||||
} else {
|
||||
selectionRef.current = ''
|
||||
selectionLabelRef.current = ''
|
||||
}
|
||||
|
||||
setStatus('open')
|
||||
let wrotePromptContent = false
|
||||
|
||||
cleanup.push(
|
||||
terminalApi.onData(session.id, data => {
|
||||
if (wrotePromptContent) {
|
||||
term.write(data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isStartupSpacer(data)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = stripInitialPromptGap(data)
|
||||
|
||||
if (next) {
|
||||
wrotePromptContent = true
|
||||
term.write(next)
|
||||
}
|
||||
}),
|
||||
terminalApi.onExit(session.id, sessionExit => {
|
||||
const { code, signal } = sessionExit
|
||||
setStatus('closed')
|
||||
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
|
||||
})
|
||||
)
|
||||
window.requestAnimationFrame(() => {
|
||||
fitAndResize()
|
||||
term.focus()
|
||||
})
|
||||
.catch(error => {
|
||||
setStatus('closed')
|
||||
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
|
||||
})
|
||||
|
||||
// Open + fit + start only once webfonts settle. Fitting with fallback metrics
|
||||
// picks the wrong row count, the shell boots at that size, then the real font
|
||||
// loads -> refit -> SIGWINCH -> the shell reprints its prompt lower, leaving
|
||||
// stale blank rows (and a stray selection) above it.
|
||||
const mount = () => {
|
||||
if (disposed || !host.isConnected) {
|
||||
return
|
||||
}
|
||||
|
||||
term.open(host)
|
||||
term.focus()
|
||||
|
||||
// WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
|
||||
// renderer paints SGR via CSS classes that visibly mute against our skins.
|
||||
try {
|
||||
const webgl = new WebglAddon()
|
||||
webgl.onContextLoss(() => {
|
||||
webgl.dispose()
|
||||
webglRef.current = null
|
||||
})
|
||||
term.loadAddon(webgl)
|
||||
webglRef.current = webgl
|
||||
} catch (err) {
|
||||
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
|
||||
}
|
||||
|
||||
fitAndResize()
|
||||
startSession()
|
||||
}
|
||||
|
||||
// fonts.ready settles only already-requested faces; bold/italic aren't asked
|
||||
// for until styled output paints (past atlas init), so warm them up front.
|
||||
const warm = document.fonts?.load
|
||||
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
|
||||
: Promise.resolve()
|
||||
|
||||
void warm.then(mount, mount)
|
||||
})
|
||||
.catch(error => {
|
||||
setStatus('closed')
|
||||
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
cleanup.forEach(run => run())
|
||||
setActiveTerminalReader(null)
|
||||
|
||||
const id = sessionIdRef.current
|
||||
sessionIdRef.current = null
|
||||
@@ -623,34 +487,12 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
|
||||
term.dispose()
|
||||
termRef.current = null
|
||||
webglRef.current = null
|
||||
shellNameRef.current = 'shell'
|
||||
selectionRef.current = ''
|
||||
selectionLabelRef.current = ''
|
||||
}
|
||||
}, [addSelectionToChat, cwd])
|
||||
|
||||
useEffect(() => {
|
||||
const term = termRef.current
|
||||
|
||||
if (!term) {
|
||||
return
|
||||
}
|
||||
|
||||
// Re-resolve the surface in a rAF: ThemeProvider's applyTheme repaints the
|
||||
// CSS vars in a sibling effect that runs after this one, so reading now
|
||||
// would lag a mode behind. By the next frame the vars are current.
|
||||
const raf = requestAnimationFrame(() => {
|
||||
term.options.theme = withSurface(activeTheme)
|
||||
// The WebGL renderer caches glyph colors in a texture atlas, so a
|
||||
// light/dark switch leaves already-drawn cells stale until the atlas is
|
||||
// cleared. No-op for the DOM fallback.
|
||||
webglRef.current?.clearTextureAtlas()
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [activeTheme, themeName])
|
||||
|
||||
return {
|
||||
addSelectionToChat,
|
||||
hostRef,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { SessionPickerDialog } from '@/components/session-picker'
|
||||
import { $gatewayState, $selectedStoredSessionId, $sessionPickerOpen, setSessionPickerOpen } from '@/store/session'
|
||||
|
||||
interface SessionPickerOverlayProps {
|
||||
onResume: (storedSessionId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the session picker that `/resume` (and `/sessions`, `/switch`) opens —
|
||||
* the desktop equivalent of the TUI's sessions overlay. Resuming runs through
|
||||
* the same `resumeSession` path the sidebar uses.
|
||||
*/
|
||||
export function SessionPickerOverlay({ onResume }: SessionPickerOverlayProps) {
|
||||
const open = useStore($sessionPickerOpen)
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const activeStoredSessionId = useStore($selectedStoredSessionId)
|
||||
|
||||
if (!gatewayOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionPickerDialog
|
||||
activeStoredSessionId={activeStoredSessionId}
|
||||
onOpenChange={setSessionPickerOpen}
|
||||
onResume={onResume}
|
||||
open={open}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds, $workingSessionIds } from '@/store/session'
|
||||
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
|
||||
|
||||
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
|
||||
import { sessionRoute } from './routes'
|
||||
|
||||
// Compact session-switcher HUD — keyboard-driven from `use-keybinds`, rows
|
||||
// clickable via mousedown (Ctrl+click on macOS). No Dialog: Tab stays global.
|
||||
export function SessionSwitcher() {
|
||||
const open = useStore($switcherOpen)
|
||||
const sessions = useStore($switcherSessions)
|
||||
const index = useStore($switcherIndex)
|
||||
const working = useStore($workingSessionIds)
|
||||
const attention = useStore($attentionSessionIds)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const activeRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({ block: 'nearest' })
|
||||
}, [index, open])
|
||||
|
||||
if (!open || sessions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const workingIds = new Set(working)
|
||||
const attentionIds = new Set(attention)
|
||||
|
||||
const pick = (sessionId: string) => {
|
||||
closeSwitcher()
|
||||
navigate(sessionRoute(sessionId))
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Transparent click-catcher: click-away closes, but no dim/blur. */}
|
||||
<div
|
||||
className="fixed inset-0 z-[219]"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault()
|
||||
closeSwitcher()
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
HUD_POSITION,
|
||||
HUD_SURFACE,
|
||||
'dt-portal-scrollbar z-[220] max-h-[min(22rem,64vh)] w-[min(19rem,calc(100vw-2rem))] select-none overflow-y-auto p-1'
|
||||
)}
|
||||
>
|
||||
{sessions.map((session, i) => {
|
||||
const selected = i === index
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center rounded leading-tight',
|
||||
HUD_ITEM,
|
||||
HUD_TEXT,
|
||||
selected ? 'bg-accent text-accent-foreground' : 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background)'
|
||||
)}
|
||||
key={session.id}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault()
|
||||
pick(session.id)
|
||||
}}
|
||||
ref={selected ? activeRef : undefined}
|
||||
>
|
||||
<SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} />
|
||||
<span className="min-w-0 flex-1 truncate">{sessionTitle(session)}</span>
|
||||
{i < 9 && (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 font-mono text-[0.625rem] tabular-nums',
|
||||
selected ? 'text-accent-foreground/70' : 'text-(--ui-text-quaternary)'
|
||||
)}
|
||||
>
|
||||
⌃{i + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
function SwitcherDot({ attention, working }: { attention: boolean; working: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'size-1 shrink-0 rounded-full',
|
||||
attention ? 'bg-amber-400' : working ? 'animate-pulse bg-(--ui-accent)' : 'bg-(--ui-text-quaternary)/50'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
appendReasoningPart,
|
||||
@@ -15,11 +14,9 @@ import {
|
||||
upsertToolPart
|
||||
} from '@/lib/chat-messages'
|
||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { setClarifyRequest } from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
@@ -64,67 +61,6 @@ interface QueuedStreamDeltas {
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
| 'branch'
|
||||
| 'cwd'
|
||||
| 'fast'
|
||||
| 'model'
|
||||
| 'personality'
|
||||
| 'provider'
|
||||
| 'reasoningEffort'
|
||||
| 'serviceTier'
|
||||
| 'yolo'
|
||||
>
|
||||
>
|
||||
|
||||
function sessionInfoStatePatch(payload: GatewayEventPayload | undefined): SessionRuntimeStatePatch {
|
||||
const patch: SessionRuntimeStatePatch = {}
|
||||
|
||||
if (typeof payload?.model === 'string') {
|
||||
patch.model = payload.model || ''
|
||||
}
|
||||
|
||||
if (typeof payload?.provider === 'string') {
|
||||
patch.provider = payload.provider || ''
|
||||
}
|
||||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
patch.cwd = payload.cwd
|
||||
}
|
||||
|
||||
if (typeof payload?.branch === 'string') {
|
||||
patch.branch = payload.branch
|
||||
}
|
||||
|
||||
if (typeof payload?.personality === 'string') {
|
||||
patch.personality = normalizePersonalityValue(payload.personality)
|
||||
}
|
||||
|
||||
if (typeof payload?.reasoning_effort === 'string') {
|
||||
patch.reasoningEffort = payload.reasoning_effort
|
||||
}
|
||||
|
||||
if (typeof payload?.service_tier === 'string') {
|
||||
patch.serviceTier = payload.service_tier
|
||||
}
|
||||
|
||||
if (typeof payload?.fast === 'boolean') {
|
||||
patch.fast = payload.fast
|
||||
}
|
||||
|
||||
if (typeof payload?.yolo === 'boolean') {
|
||||
patch.yolo = payload.yolo
|
||||
}
|
||||
|
||||
return patch
|
||||
}
|
||||
|
||||
function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boolean {
|
||||
return Object.keys(patch).length > 0
|
||||
}
|
||||
|
||||
// Minimum gap between two assistant-text flushes during a stream. Was 16ms
|
||||
// (rAF only), which at typical LLM token rates of ~30-80 tok/sec meant every
|
||||
// token got its own React commit + Streamdown markdown re-parse, scaling
|
||||
@@ -677,9 +613,6 @@ 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
|
||||
|
||||
@@ -689,13 +622,13 @@ export function useMessageStream({
|
||||
// Apply session-scoped fields when the event targets the active
|
||||
// session, OR when it's a global broadcast and we have no session.
|
||||
const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current
|
||||
const statePatch = sessionInfoStatePatch(payload)
|
||||
const hasStatePatch = hasSessionInfoStatePatch(statePatch)
|
||||
const modelChanged = typeof payload?.model === 'string'
|
||||
const providerChanged = typeof payload?.provider === 'string'
|
||||
const runningChanged = typeof payload?.running === 'boolean'
|
||||
|
||||
if (apply) {
|
||||
const runtimeInfo: { branch?: string; cwd?: string } = {}
|
||||
|
||||
if (modelChanged) {
|
||||
setCurrentModel(payload!.model || '')
|
||||
}
|
||||
@@ -706,10 +639,20 @@ export function useMessageStream({
|
||||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
setCurrentCwd(payload.cwd)
|
||||
runtimeInfo.cwd = payload.cwd
|
||||
}
|
||||
|
||||
if (typeof payload?.branch === 'string') {
|
||||
setCurrentBranch(payload.branch)
|
||||
runtimeInfo.branch = payload.branch
|
||||
}
|
||||
|
||||
if (sessionId && (runtimeInfo.cwd !== undefined || runtimeInfo.branch !== undefined)) {
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
branch: runtimeInfo.branch ?? state.branch,
|
||||
cwd: runtimeInfo.cwd ?? state.cwd
|
||||
}))
|
||||
}
|
||||
|
||||
if (typeof payload?.personality === 'string') {
|
||||
@@ -731,18 +674,7 @@ export function useMessageStream({
|
||||
if (typeof payload?.yolo === 'boolean') {
|
||||
setYoloActive(payload.yolo)
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId && hasStatePatch) {
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
...statePatch,
|
||||
branch: statePatch.branch ?? state.branch,
|
||||
cwd: statePatch.cwd ?? state.cwd
|
||||
}))
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
if (runningChanged && sessionId) {
|
||||
updateSessionState(sessionId, state => {
|
||||
const busy = Boolean(payload!.running)
|
||||
@@ -933,8 +865,6 @@ export function useMessageStream({
|
||||
// raise it and wait — the sidebar flags "needs input" and the inline bar
|
||||
// surfaces once the user focuses that chat.
|
||||
setApprovalRequest({
|
||||
// false only when a tirith warning forbids it; backend omits the field otherwise.
|
||||
allowPermanent: payload?.allow_permanent !== false,
|
||||
command: typeof payload?.command === 'string' ? payload.command : '',
|
||||
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
|
||||
sessionId: sessionId ?? null
|
||||
@@ -972,21 +902,6 @@ export function useMessageStream({
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'terminal.read.request') {
|
||||
// read_terminal tool: serialize the renderer's xterm buffer and answer
|
||||
// immediately (Python blocks on the respond). Empty text = no live pane.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
const start = typeof payload?.start === 'number' ? payload.start : undefined
|
||||
const count = typeof payload?.count === 'number' ? payload.count : undefined
|
||||
const result = readActiveTerminal({ start, count })
|
||||
|
||||
void $gateway.get()?.request('terminal.read.respond', {
|
||||
request_id: requestId,
|
||||
text: result ? JSON.stringify(result) : ''
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getGlobalModelInfo } from '@/hermes'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
setCurrentModel,
|
||||
setCurrentProvider
|
||||
} from '@/store/session'
|
||||
|
||||
import { useModelControls } from './use-model-controls'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelInfo: vi.fn(),
|
||||
setGlobalModel: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useModelControls.refreshCurrentModel', () => {
|
||||
beforeEach(() => {
|
||||
$activeSessionId.set(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
$activeSessionId.set(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
})
|
||||
|
||||
it('applies the global model when there is no active runtime session', async () => {
|
||||
vi.mocked(getGlobalModelInfo).mockResolvedValue({
|
||||
model: 'openai/gpt-5.5',
|
||||
provider: 'openai-codex'
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useModelControls({
|
||||
activeSessionId: null,
|
||||
queryClient: new QueryClient(),
|
||||
requestGateway: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
await result.current.refreshCurrentModel()
|
||||
|
||||
expect($currentModel.get()).toBe('openai/gpt-5.5')
|
||||
expect($currentProvider.get()).toBe('openai-codex')
|
||||
})
|
||||
|
||||
it('does not clobber the active session footer state with global model info', async () => {
|
||||
setCurrentModel('deepseek/deepseek-v4-pro')
|
||||
setCurrentProvider('deepseek')
|
||||
$activeSessionId.set('runtime-1')
|
||||
vi.mocked(getGlobalModelInfo).mockResolvedValue({
|
||||
model: 'openai/gpt-5.5',
|
||||
provider: 'openai-codex'
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useModelControls({
|
||||
activeSessionId: 'runtime-1',
|
||||
queryClient: new QueryClient(),
|
||||
requestGateway: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
await result.current.refreshCurrentModel()
|
||||
|
||||
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
|
||||
expect($currentProvider.get()).toBe('deepseek')
|
||||
})
|
||||
})
|
||||
@@ -4,13 +4,7 @@ import { useCallback } from 'react'
|
||||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
setCurrentModel,
|
||||
setCurrentProvider
|
||||
} from '@/store/session'
|
||||
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelSelection {
|
||||
@@ -45,13 +39,6 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
try {
|
||||
const result = await getGlobalModelInfo()
|
||||
|
||||
// A resumed/live session owns the footer model state. Global config
|
||||
// refreshes (gateway boot, profile swap, settings save) must not clobber
|
||||
// the active chat's runtime model/provider in the status bar.
|
||||
if ($activeSessionId.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof result.model === 'string') {
|
||||
setCurrentModel(result.model)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import { $connection, $sessions, setSessions } from '@/store/session'
|
||||
import { $sessions, setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions'
|
||||
import { usePromptActions } from './use-prompt-actions'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
||||
@@ -42,12 +41,8 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
}
|
||||
|
||||
interface HarnessHandle {
|
||||
cancelRun: () => Promise<void>
|
||||
steerPrompt: (text: string) => Promise<boolean>
|
||||
submitText: (
|
||||
text: string,
|
||||
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
|
||||
) => Promise<boolean>
|
||||
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
|
||||
}
|
||||
|
||||
function Harness({
|
||||
@@ -55,29 +50,17 @@ function Harness({
|
||||
onReady,
|
||||
onSeedState,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
storedSessionId
|
||||
requestGateway
|
||||
}: {
|
||||
busyRef?: MutableRefObject<boolean>
|
||||
onReady: (handle: HarnessHandle) => void
|
||||
onSeedState?: (state: Record<string, unknown>) => void
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
|
||||
storedSessionId?: null | string
|
||||
}) {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
|
||||
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
|
||||
}
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
const localBusyRef = busyRef ?? { current: false }
|
||||
const stateRef = useRef({
|
||||
messages: [],
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
interrupted: true
|
||||
} as never)
|
||||
|
||||
const actions = usePromptActions({
|
||||
activeSessionId: RUNTIME_SESSION_ID,
|
||||
@@ -88,14 +71,17 @@ function Harness({
|
||||
handleSkinCommand: () => '',
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession: resumeStoredSession ?? (() => undefined),
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft: () => undefined,
|
||||
sttEnabled: false,
|
||||
updateSessionState: (_sessionId, updater) => {
|
||||
// Seed with interrupted:true so we can prove a fresh submit clears it.
|
||||
const next = updater(stateRef.current) as unknown as Record<string, unknown>
|
||||
stateRef.current = next as never
|
||||
const next = updater({
|
||||
messages: [],
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
interrupted: true
|
||||
} as never) as unknown as Record<string, unknown>
|
||||
onSeedState?.(next)
|
||||
|
||||
return next as never
|
||||
@@ -103,12 +89,8 @@ function Harness({
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onReady({
|
||||
cancelRun: actions.cancelRun,
|
||||
steerPrompt: actions.steerPrompt,
|
||||
submitText: actions.submitText
|
||||
})
|
||||
}, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady])
|
||||
onReady({ steerPrompt: actions.steerPrompt, submitText: actions.submitText })
|
||||
}, [actions.steerPrompt, actions.submitText, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -200,68 +182,6 @@ describe('usePromptActions /title', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions desktop slash pickers', () => {
|
||||
beforeEach(() => {
|
||||
setSessions(() => [sessionInfo({ id: '20260610_120000_abcdef', title: 'Loaded session' })])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resumes an exact session id even when it is not in the loaded sidebar cache', async () => {
|
||||
const resumeStoredSession = vi.fn(async () => undefined)
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
resumeStoredSession={resumeStoredSession}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/resume 20260610_130000_123abc')
|
||||
|
||||
expect(resumeStoredSession).toHaveBeenCalledWith('20260610_130000_123abc')
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
||||
})
|
||||
|
||||
it('marks a timed-out handoff as failed so the next attempt can retry', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'handoff.state') {
|
||||
return { state: 'pending' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
const result = handle!.submitText('/handoff telegram')
|
||||
await vi.advanceTimersByTimeAsync(61_000)
|
||||
await result
|
||||
|
||||
expect(calls.some(call => call.method === 'handoff.request')).toBe(true)
|
||||
expect(calls).toContainEqual({
|
||||
method: 'handoff.fail',
|
||||
params: {
|
||||
error: expect.stringContaining('Timed out'),
|
||||
session_id: RUNTIME_SESSION_ID
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions submit / queue drain semantics', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -394,469 +314,3 @@ describe('usePromptActions steerPrompt', () => {
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions file attachment sync', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function fileAttachment(): ComposerAttachment {
|
||||
return {
|
||||
id: 'file:report.txt',
|
||||
kind: 'file',
|
||||
label: 'report.txt',
|
||||
path: '/Users/alice/Downloads/report.txt',
|
||||
refText: '@file:`/Users/alice/Downloads/report.txt`'
|
||||
}
|
||||
}
|
||||
|
||||
it('uploads file bytes via file.attach on a remote gateway and submits the rewritten ref', async () => {
|
||||
// Remote gateway can't read the client-disk path, so the desktop must upload
|
||||
// the bytes and submit the workspace-relative ref the gateway hands back —
|
||||
// not the original /Users/... path (which would dead-end as "outside the
|
||||
// allowed workspace").
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { readFileDataUrl: vi.fn(async () => 'data:text/plain;base64,aGVsbG8=') }
|
||||
})
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
if (method === 'file.attach') {
|
||||
return {
|
||||
attached: true,
|
||||
path: '/remote/work/.hermes/desktop-attachments/report.txt',
|
||||
ref_text: '@file:.hermes/desktop-attachments/report.txt',
|
||||
uploaded: true
|
||||
} as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
const ok = await handle!.submitText('convert this to epub', { attachments: [fileAttachment()] })
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(calls.map(c => c.method)).toEqual(['file.attach', 'prompt.submit'])
|
||||
expect(calls[0]?.params).toMatchObject({
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
path: '/Users/alice/Downloads/report.txt',
|
||||
name: 'report.txt',
|
||||
data_url: 'data:text/plain;base64,aGVsbG8='
|
||||
})
|
||||
expect(calls[1]?.params).toEqual({
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: '@file:.hermes/desktop-attachments/report.txt\n\nconvert this to epub'
|
||||
})
|
||||
})
|
||||
|
||||
it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => {
|
||||
// Submit-layer contract: only attachments that carry a `path` are upload
|
||||
// candidates. A path-less ref (an @-mention/context ref or pasted text)
|
||||
// has no bytes to send, so syncAttachments leaves it untouched and the ref
|
||||
// reaches the gateway as-is — correct for workspace-relative refs.
|
||||
//
|
||||
// The MahmoudR drag-drop bug (a Finder PDF that became a local-path text
|
||||
// ref in remote mode) is fixed upstream at the DROP layer: OS drops now
|
||||
// carry a path and route through the upload pipeline instead of becoming a
|
||||
// path-less inline ref. See partitionDroppedFiles in use-composer-actions.
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
const readFileDataUrl = vi.fn(async () => 'data:application/pdf;base64,JVBERi0=')
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { readFileDataUrl }
|
||||
})
|
||||
|
||||
const pathlessRef: ComposerAttachment = {
|
||||
id: 'file:devis',
|
||||
kind: 'file',
|
||||
label: 'DEVIS_signed.pdf',
|
||||
// NOTE: no `path` field — only the pre-baked local @file: ref.
|
||||
refText: '@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`'
|
||||
}
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
const ok = await handle!.submitText('read this file', { attachments: [pathlessRef] })
|
||||
|
||||
expect(ok).toBe(true)
|
||||
// No path → no file.attach, no byte read: the ref passes through unchanged.
|
||||
expect(calls.map(c => c.method)).toEqual(['prompt.submit'])
|
||||
expect(readFileDataUrl).not.toHaveBeenCalled()
|
||||
expect(calls[0]?.params?.text).toContain('@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`')
|
||||
})
|
||||
|
||||
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
if (method === 'file.attach') {
|
||||
return { attached: true, ref_text: '@file:data/report.txt', uploaded: false } as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(calls[0]?.method).toBe('file.attach')
|
||||
// Local mode sends no data_url — the gateway shares this disk.
|
||||
expect(calls[0]?.params).not.toHaveProperty('data_url')
|
||||
expect(calls[1]).toEqual({
|
||||
method: 'prompt.submit',
|
||||
params: { session_id: RUNTIME_SESSION_ID, text: '@file:data/report.txt\n\nsummarize' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions eager-upload races', () => {
|
||||
beforeEach(() => {
|
||||
setSessions(() => [sessionInfo()])
|
||||
$composerAttachments.set([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$composerAttachments.set([])
|
||||
$connection.set(null)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('joins an in-flight eager upload at submit instead of staging the file twice', async () => {
|
||||
// Drop-then-immediately-Enter: the drop kicks off an eager file.attach; if
|
||||
// submit doesn't join it, both calls stage the file and leave a duplicate
|
||||
// under .hermes/desktop-attachments/. Submit must await the in-flight upload
|
||||
// and reuse its gateway-side ref.
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
|
||||
})
|
||||
|
||||
let releaseAttach: () => void = () => {}
|
||||
const methods: string[] = []
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
methods.push(method)
|
||||
if (method === 'file.attach') {
|
||||
// Block until released so submit runs while the upload is in flight.
|
||||
await new Promise<void>(resolve => {
|
||||
releaseAttach = resolve
|
||||
})
|
||||
return { attached: true, ref_text: '@file:.hermes/desktop-attachments/doc.pdf', uploaded: true } as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
await waitFor(() => expect(handle).not.toBeNull())
|
||||
|
||||
// Drop a file → the eager effect fires file.attach and blocks on it.
|
||||
$composerAttachments.set([{ id: 'file:doc.pdf', kind: 'file', label: 'doc.pdf', path: '/Users/me/doc.pdf' }])
|
||||
await waitFor(() => expect(methods.filter(m => m === 'file.attach').length).toBe(1))
|
||||
|
||||
// Submit reads the store, sees the upload in flight, and joins it.
|
||||
const submitting = handle!.submitText('here you go')
|
||||
releaseAttach()
|
||||
|
||||
expect(await submitting).toBe(true)
|
||||
// Exactly one file.attach (submit reused the eager result), then the send.
|
||||
expect(methods.filter(m => m === 'file.attach').length).toBe(1)
|
||||
expect(methods).toContain('prompt.submit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions sleep/wake session recovery', () => {
|
||||
const STORED_SESSION_ID = 'stored-db-xyz789'
|
||||
const RECOVERED_SESSION_ID = 'rt-recovered-456'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resumes the stored session and retries once when prompt.submit reports "session not found"', async () => {
|
||||
// After sleep/wake the gateway's in-memory session table is cleared, so the
|
||||
// first prompt.submit with the stale runtime id fails. The hook resumes the
|
||||
// durable stored id (which survives gateway restarts), gets a fresh live id,
|
||||
// and retries the send transparently.
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let submitAttempts = 0
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
if (method === 'prompt.submit') {
|
||||
submitAttempts += 1
|
||||
if (submitAttempts === 1) {
|
||||
throw new Error('session not found')
|
||||
}
|
||||
return {} as never
|
||||
}
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
|
||||
const ok = await handle!.submitText('message after wake')
|
||||
|
||||
expect(ok).toBe(true)
|
||||
// First submit (stale id) → session.resume (stored id) → retry submit (fresh id).
|
||||
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
|
||||
})
|
||||
|
||||
it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => {
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let interruptAttempts = 0
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
if (method === 'session.interrupt') {
|
||||
interruptAttempts += 1
|
||||
if (interruptAttempts === 1) {
|
||||
throw new Error('session not found')
|
||||
}
|
||||
return {} as never
|
||||
}
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(handle).not.toBeNull())
|
||||
|
||||
await handle!.cancelRun()
|
||||
|
||||
expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])
|
||||
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
|
||||
})
|
||||
|
||||
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
|
||||
const calls: string[] = []
|
||||
const states: Record<string, unknown>[] = []
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
calls.push(method)
|
||||
if (method === 'prompt.submit') {
|
||||
throw new Error('session busy')
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => states.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
|
||||
// submitText swallows the error into an inline bubble and returns false.
|
||||
expect(await handle!.submitText('message')).toBe(false)
|
||||
// No resume attempt for a non-recoverable error.
|
||||
expect(calls).not.toContain('session.resume')
|
||||
})
|
||||
|
||||
it('surfaces "session not found" (no resume) when there is no stored session id', async () => {
|
||||
const calls: string[] = []
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
calls.push(method)
|
||||
if (method === 'prompt.submit') {
|
||||
throw new Error('session not found')
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={null}
|
||||
/>
|
||||
)
|
||||
|
||||
// With a null stored ref, the `&& selectedStoredSessionIdRef.current` guard
|
||||
// short-circuits — no resume is attempted and the error surfaces normally.
|
||||
expect(await handle!.submitText('message')).toBe(false)
|
||||
expect(calls).not.toContain('session.resume')
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions eager attachment upload (drop-time)', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
$connection.set(null)
|
||||
$composerAttachments.set([])
|
||||
})
|
||||
|
||||
it('uploads a dropped file the moment it lands (active session) and rewrites the chip with the gateway ref', async () => {
|
||||
// A Finder drop adds a chip with a local path but no attachedSessionId. With
|
||||
// a session already open, the hook should stage it right away — so the send
|
||||
// is instant and the card can show a spinner while bytes upload — instead of
|
||||
// waiting for submit.
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
const readFileDataUrl = vi.fn(async () => 'data:application/pdf;base64,JVBERi0=')
|
||||
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { readFileDataUrl } })
|
||||
|
||||
const calls: string[] = []
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
calls.push(method)
|
||||
if (method === 'file.attach') {
|
||||
return { attached: true, ref_text: '@file:.hermes/desktop-attachments/DEVIS_signed.pdf', uploaded: true } as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
$composerAttachments.set([
|
||||
{ id: 'file:devis', kind: 'file', label: 'DEVIS_signed.pdf', path: '/Users/mahmoud/Downloads/DEVIS_signed.pdf' }
|
||||
])
|
||||
|
||||
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
await waitFor(() => expect(calls).toContain('file.attach'))
|
||||
await waitFor(() => expect($composerAttachments.get()[0]?.attachedSessionId).toBe(RUNTIME_SESSION_ID))
|
||||
|
||||
const chip = $composerAttachments.get()[0]!
|
||||
expect(chip.refText).toBe('@file:.hermes/desktop-attachments/DEVIS_signed.pdf')
|
||||
expect(chip.uploadState).toBeUndefined()
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('/Users/mahmoud/Downloads/DEVIS_signed.pdf')
|
||||
})
|
||||
|
||||
it('flags the chip uploadState=error when the eager upload fails, keeping the path so submit can retry', async () => {
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
|
||||
})
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'file.attach') {
|
||||
throw new Error('[Errno 13] Permission denied')
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
$composerAttachments.set([{ id: 'file:x', kind: 'file', label: 'x.pdf', path: '/abs/x.pdf' }])
|
||||
|
||||
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
await waitFor(() => expect($composerAttachments.get()[0]?.uploadState).toBe('error'))
|
||||
expect($composerAttachments.get()[0]?.attachedSessionId).toBeUndefined()
|
||||
expect($composerAttachments.get()[0]?.path).toBe('/abs/x.pdf')
|
||||
})
|
||||
|
||||
it('does not eagerly re-upload a chip already attached to this session', async () => {
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
$composerAttachments.set([
|
||||
{
|
||||
id: 'file:done',
|
||||
kind: 'file',
|
||||
label: 'done.pdf',
|
||||
path: '/abs/done.pdf',
|
||||
refText: '@file:data/done.pdf',
|
||||
attachedSessionId: RUNTIME_SESSION_ID
|
||||
}
|
||||
])
|
||||
|
||||
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
await Promise.resolve()
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('file.attach', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadComposerAttachment remote read failures', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => {
|
||||
// electron/hardening.cjs rejects the readFileDataUrl IPC with this exact
|
||||
// shape when a file exceeds DATA_URL_READ_MAX_BYTES.
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
readFileDataUrl: vi.fn(async () => {
|
||||
throw new Error('File preview failed: file is too large (20971520 bytes; limit 16777216 bytes).')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
await expect(
|
||||
uploadComposerAttachment(
|
||||
{ id: 'file:big', kind: 'file', label: 'huge.csv', path: '/abs/huge.csv' },
|
||||
{ remote: true, requestGateway, sessionId: RUNTIME_SESSION_ID }
|
||||
)
|
||||
).rejects.toThrow('huge.csv is too large to upload to the remote gateway (max 16 MB).')
|
||||
|
||||
// The cap is hit before any gateway round-trip.
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes non-cap read errors through unchanged', async () => {
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
readFileDataUrl: vi.fn(async () => {
|
||||
throw new Error('ENOENT: no such file')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
uploadComposerAttachment(
|
||||
{ id: 'file:gone', kind: 'file', label: 'gone.csv', path: '/abs/gone.csv' },
|
||||
{ remote: true, requestGateway: vi.fn(async () => ({}) as never), sessionId: RUNTIME_SESSION_ID }
|
||||
)
|
||||
).rejects.toThrow('ENOENT: no such file')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,60 +84,6 @@ describe('useRouteResume', () => {
|
||||
expect(resumeSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('self-heals a stranded routed session (null selected/active, same pathname, not a fresh draft)', () => {
|
||||
const resumeSession = vi.fn(async () => undefined)
|
||||
const startFreshSessionDraft = vi.fn()
|
||||
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
|
||||
const creatingSessionRef = { current: false }
|
||||
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
|
||||
|
||||
const { rerender } = render(
|
||||
<RouteResumeHarness
|
||||
activeSessionId="runtime-1"
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
creatingSessionRef={creatingSessionRef}
|
||||
currentView="chat"
|
||||
freshDraftReady={false}
|
||||
gatewayState="open"
|
||||
locationPathname="/session-1"
|
||||
resumeSession={resumeSession}
|
||||
routedSessionId="session-1"
|
||||
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
||||
selectedStoredSessionId="session-1"
|
||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
||||
startFreshSessionDraft={startFreshSessionDraft}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(resumeSession).not.toHaveBeenCalled()
|
||||
|
||||
// A create/stream race nulls selected/active but the route stays on the
|
||||
// session and freshDraftReady is false (NOT a new-chat transition).
|
||||
activeSessionIdRef.current = null
|
||||
selectedStoredSessionIdRef.current = null
|
||||
rerender(
|
||||
<RouteResumeHarness
|
||||
activeSessionId={null}
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
creatingSessionRef={creatingSessionRef}
|
||||
currentView="chat"
|
||||
freshDraftReady={false}
|
||||
gatewayState="open"
|
||||
locationPathname="/session-1"
|
||||
resumeSession={resumeSession}
|
||||
routedSessionId="session-1"
|
||||
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
||||
selectedStoredSessionId={null}
|
||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
||||
startFreshSessionDraft={startFreshSessionDraft}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(resumeSession).toHaveBeenCalledTimes(1)
|
||||
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
|
||||
})
|
||||
|
||||
it('resumes when pathname changes to a routed session', () => {
|
||||
const resumeSession = vi.fn(async () => undefined)
|
||||
const startFreshSessionDraft = vi.fn()
|
||||
@@ -187,72 +133,4 @@ describe('useRouteResume', () => {
|
||||
expect(resumeSession).toHaveBeenCalledTimes(1)
|
||||
expect(resumeSession).toHaveBeenCalledWith('session-2', true)
|
||||
})
|
||||
|
||||
it('resumes the selected route again when the gateway reconnects', () => {
|
||||
const resumeSession = vi.fn(async () => undefined)
|
||||
const startFreshSessionDraft = vi.fn()
|
||||
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
|
||||
const creatingSessionRef = { current: false }
|
||||
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
|
||||
|
||||
const { rerender } = render(
|
||||
<RouteResumeHarness
|
||||
activeSessionId="runtime-1"
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
creatingSessionRef={creatingSessionRef}
|
||||
currentView="chat"
|
||||
freshDraftReady={false}
|
||||
gatewayState="open"
|
||||
locationPathname="/session-1"
|
||||
resumeSession={resumeSession}
|
||||
routedSessionId="session-1"
|
||||
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
||||
selectedStoredSessionId="session-1"
|
||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
||||
startFreshSessionDraft={startFreshSessionDraft}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(resumeSession).not.toHaveBeenCalled()
|
||||
|
||||
rerender(
|
||||
<RouteResumeHarness
|
||||
activeSessionId="runtime-1"
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
creatingSessionRef={creatingSessionRef}
|
||||
currentView="chat"
|
||||
freshDraftReady={false}
|
||||
gatewayState="closed"
|
||||
locationPathname="/session-1"
|
||||
resumeSession={resumeSession}
|
||||
routedSessionId="session-1"
|
||||
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
||||
selectedStoredSessionId="session-1"
|
||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
||||
startFreshSessionDraft={startFreshSessionDraft}
|
||||
/>
|
||||
)
|
||||
|
||||
rerender(
|
||||
<RouteResumeHarness
|
||||
activeSessionId="runtime-1"
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
creatingSessionRef={creatingSessionRef}
|
||||
currentView="chat"
|
||||
freshDraftReady={false}
|
||||
gatewayState="open"
|
||||
locationPathname="/session-1"
|
||||
resumeSession={resumeSession}
|
||||
routedSessionId="session-1"
|
||||
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
||||
selectedStoredSessionId="session-1"
|
||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
||||
startFreshSessionDraft={startFreshSessionDraft}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(resumeSession).toHaveBeenCalledTimes(1)
|
||||
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,19 +56,13 @@ export function useRouteResume({
|
||||
startFreshSessionDraft
|
||||
}: RouteResumeOptions) {
|
||||
const lastPathnameRef = useRef<string | null>(null)
|
||||
const seenGatewayStateRef = useRef(false)
|
||||
const wasGatewayOpenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const gatewayOpen = gatewayState === 'open'
|
||||
const pathnameChanged = lastPathnameRef.current !== locationPathname
|
||||
// Fire only on a genuine closed->open transition (a reconnect). seenGatewayStateRef
|
||||
// stays false until the first effect run, so a session that mounts with the gateway
|
||||
// already open is not mistaken for "became open" and does not double-resume with the
|
||||
// pathname-driven initial resume below.
|
||||
const gatewayBecameOpen = seenGatewayStateRef.current && !wasGatewayOpenRef.current && gatewayOpen
|
||||
const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen
|
||||
lastPathnameRef.current = locationPathname
|
||||
seenGatewayStateRef.current = true
|
||||
wasGatewayOpenRef.current = gatewayOpen
|
||||
|
||||
if (currentView !== 'chat' || !gatewayOpen) {
|
||||
@@ -83,33 +77,12 @@ export function useRouteResume({
|
||||
Boolean(cachedRuntime) &&
|
||||
cachedRuntime === activeSessionIdRef.current
|
||||
|
||||
// Self-heal a desynced view: the route points at a session that isn't the
|
||||
// loaded one. A create/stream race can leave selected/active null while
|
||||
// the route stays on /:sid (symptom: brand-new chat shows "Thinking" then
|
||||
// an empty transcript even though the turn completed and persisted). The
|
||||
// pathname didn't change, so the normal gate would skip and the view stays
|
||||
// stuck empty forever. selectedStoredSessionIdRef is set synchronously at
|
||||
// resume entry, so this can't loop; the resume's cached fast-path restores
|
||||
// the already-streamed messages without a refetch.
|
||||
//
|
||||
// Crucially this must NOT fire during a /:sid -> /new transition, where
|
||||
// startFreshSessionDraft nulls selected/active one render before the
|
||||
// pathname flips to / (same null+/:sid signature). freshDraftReady is the
|
||||
// discriminator: it's true while heading into a blank new chat, false when
|
||||
// genuinely stranded on a routed session.
|
||||
const stuckOnRoutedSession = routedSessionId !== selectedStoredSessionIdRef.current && !freshDraftReady
|
||||
|
||||
// Resume when the route meaningfully changed, the gateway just opened, or
|
||||
// we're stranded on a routed session that never loaded. The first two
|
||||
// guard against a transient /:sid re-resume during "new chat" state clears
|
||||
// Resume only when the route meaningfully changed (or gateway just opened).
|
||||
// This avoids a transient /:sid re-resume during "new chat" state clears
|
||||
// before the pathname updates from /:sid -> /.
|
||||
const shouldResume = pathnameChanged || gatewayBecameOpen || stuckOnRoutedSession
|
||||
const shouldResume = pathnameChanged || gatewayBecameOpen
|
||||
|
||||
// On a reconnect (gatewayBecameOpen) re-resume even when the route looks
|
||||
// `alreadyActive`: the cached runtime id can be stale once the gateway
|
||||
// rebinds/reaps the session on its side, and trusting it strands Desktop on
|
||||
// a dead id ("session not found"). Otherwise keep skipping when already active.
|
||||
if ((gatewayBecameOpen || !alreadyActive) && shouldResume && !creatingSessionRef.current) {
|
||||
if (!alreadyActive && shouldResume && !creatingSessionRef.current) {
|
||||
void resumeSession(routedSessionId, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { MutableRefObject } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
|
||||
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
|
||||
import { clearQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
$messages,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
getRememberedWorkspaceCwd,
|
||||
sessionPinId,
|
||||
setActiveSessionId,
|
||||
setAwaitingResponse,
|
||||
@@ -39,11 +41,10 @@ import {
|
||||
setSessionStartedAt,
|
||||
setSessionsTotal,
|
||||
setTurnStartedAt,
|
||||
setYoloActive,
|
||||
workspaceCwdForNewSession
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { reportBackendContract } from '@/store/updates'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
|
||||
import type { ClientSessionState, SidebarNavItem } from '../../types'
|
||||
@@ -209,67 +210,14 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
|
||||
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
|
||||
}
|
||||
|
||||
function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
|
||||
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
|
||||
}
|
||||
|
||||
function upsertResolvedSession(session: SessionInfo, storedSessionId: string) {
|
||||
const lineage = session._lineage_root_id ?? session.id
|
||||
|
||||
setSessions(prev => [
|
||||
session,
|
||||
...prev.filter(existing => {
|
||||
if (sessionMatchesStoredId(existing, storedSessionId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (existing._lineage_root_id ?? existing.id) !== lineage
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
async function resolveStoredSession(storedSessionId: string): Promise<SessionInfo | undefined> {
|
||||
const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
|
||||
const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
|
||||
if (resolved) {
|
||||
upsertResolvedSession(resolved, storedSessionId)
|
||||
}
|
||||
|
||||
return resolved
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
| 'branch'
|
||||
| 'cwd'
|
||||
| 'fast'
|
||||
| 'model'
|
||||
| 'personality'
|
||||
| 'provider'
|
||||
| 'reasoningEffort'
|
||||
| 'serviceTier'
|
||||
| 'yolo'
|
||||
>
|
||||
>
|
||||
|
||||
function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null {
|
||||
function applyRuntimeInfo(
|
||||
info: SessionCreateResponse['info'] | undefined
|
||||
): Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> | null {
|
||||
if (!info) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionState: SessionRuntimeStatePatch = {}
|
||||
const sessionState: Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> = {}
|
||||
|
||||
reportBackendContract(info.desktop_contract)
|
||||
|
||||
@@ -277,14 +225,12 @@ function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeS
|
||||
requestDesktopOnboarding(info.credential_warning)
|
||||
}
|
||||
|
||||
if (typeof info.model === 'string') {
|
||||
if (info.model) {
|
||||
setCurrentModel(info.model)
|
||||
sessionState.model = info.model
|
||||
}
|
||||
|
||||
if (typeof info.provider === 'string') {
|
||||
if (info.provider) {
|
||||
setCurrentProvider(info.provider)
|
||||
sessionState.provider = info.provider
|
||||
}
|
||||
|
||||
if (info.cwd) {
|
||||
@@ -298,29 +244,23 @@ function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeS
|
||||
}
|
||||
|
||||
if (typeof info.personality === 'string') {
|
||||
const personality = normalizePersonalityValue(info.personality)
|
||||
setCurrentPersonality(personality)
|
||||
sessionState.personality = personality
|
||||
setCurrentPersonality(normalizePersonalityValue(info.personality))
|
||||
}
|
||||
|
||||
if (typeof info.reasoning_effort === 'string') {
|
||||
setCurrentReasoningEffort(info.reasoning_effort)
|
||||
sessionState.reasoningEffort = info.reasoning_effort
|
||||
}
|
||||
|
||||
if (typeof info.service_tier === 'string') {
|
||||
setCurrentServiceTier(info.service_tier)
|
||||
sessionState.serviceTier = info.service_tier
|
||||
}
|
||||
|
||||
if (typeof info.fast === 'boolean') {
|
||||
setCurrentFastMode(info.fast)
|
||||
sessionState.fast = info.fast
|
||||
}
|
||||
|
||||
if (typeof info.yolo === 'boolean') {
|
||||
setYoloActive(info.yolo)
|
||||
sessionState.yolo = info.yolo
|
||||
}
|
||||
|
||||
if (info.usage) {
|
||||
@@ -330,16 +270,6 @@ function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeS
|
||||
return sessionState
|
||||
}
|
||||
|
||||
function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) {
|
||||
setCurrentModel(stored?.model || '')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
setYoloActive(false)
|
||||
setCurrentPersonality('')
|
||||
}
|
||||
|
||||
export function useSessionActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
@@ -381,17 +311,11 @@ export function useSessionActions({
|
||||
})
|
||||
setSessionStartedAt(null)
|
||||
setTurnStartedAt(null)
|
||||
// New chats start in the configured default project dir when set,
|
||||
// otherwise the sticky last-used workspace (PR #37586).
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
setYoloActive(false)
|
||||
setCurrentCwd(workspaceCwdForNewSession())
|
||||
// New chats inherit the current workspace.
|
||||
setCurrentCwd(getRememberedWorkspaceCwd())
|
||||
setCurrentBranch('')
|
||||
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
setFreshDraftReady(true)
|
||||
},
|
||||
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
|
||||
@@ -409,17 +333,15 @@ export function useSessionActions({
|
||||
// Route the new chat to the chosen profile's backend (null = primary,
|
||||
// so single-profile users are unaffected).
|
||||
await ensureGatewayProfile($newChatProfile.get())
|
||||
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
|
||||
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
|
||||
// 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,
|
||||
...(cwd && { cwd }),
|
||||
...(newChatProfile ? { profile: newChatProfile } : {})
|
||||
})
|
||||
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
if (
|
||||
@@ -520,42 +442,26 @@ export function useSessionActions({
|
||||
|
||||
// Swap the single live gateway to this session's profile before any
|
||||
// gateway call (no-op when it's already on that profile / single-profile).
|
||||
const storedForProfile = await resolveStoredSession(storedSessionId)
|
||||
const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const sessionProfile = storedForProfile?.profile
|
||||
|
||||
if (resumeRequestRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
await ensureGatewayProfile(sessionProfile)
|
||||
|
||||
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
|
||||
|
||||
if (cachedRuntimeId && cachedState) {
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const cachedViewState =
|
||||
!cachedState.model && stored?.model != null
|
||||
? {
|
||||
...cachedState,
|
||||
model: stored.model || ''
|
||||
}
|
||||
: cachedState
|
||||
|
||||
if (cachedViewState !== cachedState) {
|
||||
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setActiveSessionId(cachedRuntimeId)
|
||||
activeSessionIdRef.current = cachedRuntimeId
|
||||
syncSessionStateToView(cachedRuntimeId, cachedViewState)
|
||||
setCurrentCwd(cachedViewState.cwd)
|
||||
setCurrentBranch(cachedViewState.branch)
|
||||
syncSessionStateToView(cachedRuntimeId, cachedState)
|
||||
setCurrentCwd(cachedState.cwd)
|
||||
setCurrentBranch(cachedState.branch)
|
||||
setSessionStartedAt(Date.now())
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
|
||||
try {
|
||||
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
|
||||
@@ -594,8 +500,7 @@ export function useSessionActions({
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setSessionStartedAt(Date.now())
|
||||
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
applyStoredSessionPreviewRuntimeInfo(stored)
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
@@ -686,6 +591,8 @@ export function useSessionActions({
|
||||
}),
|
||||
storedSessionId
|
||||
)
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
} catch (err) {
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
@@ -808,6 +715,8 @@ export function useSessionActions({
|
||||
selectedStoredSessionIdRef.current = routedSessionId
|
||||
navigate(sessionRoute(routedSessionId))
|
||||
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
const runtimeInfo = applyRuntimeInfo(branched.info)
|
||||
|
||||
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
|
||||
@@ -844,7 +753,7 @@ export function useSessionActions({
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
|
||||
const removed = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
const removed = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const closingRuntimeId = wasSelected ? activeSessionId : null
|
||||
const previousMessages = $messages.get()
|
||||
@@ -853,7 +762,7 @@ export function useSessionActions({
|
||||
// live tip after compression. Drop both so the pin can't linger.
|
||||
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
|
||||
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
|
||||
// doesn't keep claiming the removed row is still on the server.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
@@ -888,7 +797,7 @@ export function useSessionActions({
|
||||
setFreshDraftReady(false)
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
@@ -927,7 +836,7 @@ export function useSessionActions({
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
|
||||
const archived = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
const archived = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
// Pins are keyed on the durable lineage-root id; the stored id may be the
|
||||
@@ -935,7 +844,7 @@ export function useSessionActions({
|
||||
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
// Archived sessions are hidden by the listSessions(min_messages=1) query
|
||||
// on the next refresh, so they count as "removed" for the load-more
|
||||
// footer math.
|
||||
@@ -948,16 +857,10 @@ export function useSessionActions({
|
||||
|
||||
try {
|
||||
await setSessionArchived(storedSessionId, true, archived?.profile)
|
||||
// A sidebar refresh can race the optimistic removal while the PATCH is
|
||||
// in flight and briefly reinsert the still-unarchived backend row. Win
|
||||
// that race after the mutation succeeds so right-click → Archive does
|
||||
// not appear to do nothing until the next full refresh.
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
|
||||
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))])
|
||||
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,20 +2,7 @@ import { act, cleanup, render } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$currentServiceTier,
|
||||
$turnStartedAt,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
|
||||
|
||||
import { useSessionStateCache } from './use-session-state-cache'
|
||||
|
||||
@@ -59,22 +46,12 @@ describe('useSessionStateCache — per-session turn timer', () => {
|
||||
return null as unknown as number
|
||||
})
|
||||
setTurnStartedAt(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
setTurnStartedAt(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
})
|
||||
|
||||
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
|
||||
@@ -138,78 +115,4 @@ describe('useSessionStateCache — per-session turn timer', () => {
|
||||
})
|
||||
expect($turnStartedAt.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('mirrors the focused session model metadata when switching from a cached session', () => {
|
||||
let cache!: Cache
|
||||
const { rerender } = render(
|
||||
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
|
||||
)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState(
|
||||
'bg-runtime',
|
||||
state => ({
|
||||
...state,
|
||||
fast: true,
|
||||
model: 'anthropic/claude-opus-4.8',
|
||||
provider: 'anthropic',
|
||||
reasoningEffort: 'high',
|
||||
serviceTier: 'priority'
|
||||
}),
|
||||
'bg-stored'
|
||||
)
|
||||
})
|
||||
|
||||
// Background metadata is cached but must not bleed into the visible statusbar.
|
||||
expect($currentModel.get()).toBe('')
|
||||
expect($currentReasoningEffort.get()).toBe('')
|
||||
expect($currentFastMode.get()).toBe(false)
|
||||
|
||||
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
|
||||
|
||||
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
|
||||
expect(bgState).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
cache.syncSessionStateToView('bg-runtime', bgState!)
|
||||
})
|
||||
|
||||
expect($currentModel.get()).toBe('anthropic/claude-opus-4.8')
|
||||
expect($currentProvider.get()).toBe('anthropic')
|
||||
expect($currentReasoningEffort.get()).toBe('high')
|
||||
expect($currentServiceTier.get()).toBe('priority')
|
||||
expect($currentFastMode.get()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears stale model metadata when the newly focused session has no cached value', () => {
|
||||
setCurrentModel('previous-model')
|
||||
setCurrentProvider('previous-provider')
|
||||
setCurrentReasoningEffort('high')
|
||||
setCurrentServiceTier('priority')
|
||||
setCurrentFastMode(true)
|
||||
|
||||
let cache!: Cache
|
||||
const { rerender } = render(
|
||||
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
|
||||
)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState('bg-runtime', state => ({ ...state }), 'bg-stored')
|
||||
})
|
||||
|
||||
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
|
||||
|
||||
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
|
||||
expect(bgState).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
cache.syncSessionStateToView('bg-runtime', bgState!)
|
||||
})
|
||||
|
||||
expect($currentModel.get()).toBe('')
|
||||
expect($currentProvider.get()).toBe('')
|
||||
expect($currentReasoningEffort.get()).toBe('')
|
||||
expect($currentServiceTier.get()).toBe('')
|
||||
expect($currentFastMode.get()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,21 +5,7 @@ import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import {
|
||||
$busy,
|
||||
$messages,
|
||||
noteSessionActivity,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
setCurrentProvider,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setSessionAttention,
|
||||
setSessionWorking,
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking, setTurnStartedAt } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
@@ -54,16 +40,6 @@ interface SessionStateCacheOptions {
|
||||
setMessages: (messages: ChatMessage[]) => void
|
||||
}
|
||||
|
||||
function syncRuntimeMetadataToView(state: ClientSessionState) {
|
||||
setCurrentModel(state.model ?? '')
|
||||
setCurrentProvider(state.provider ?? '')
|
||||
setCurrentReasoningEffort(state.reasoningEffort ?? '')
|
||||
setCurrentServiceTier(state.serviceTier ?? '')
|
||||
setCurrentFastMode(state.fast ?? false)
|
||||
setYoloActive(state.yolo ?? false)
|
||||
setCurrentPersonality(state.personality ?? '')
|
||||
}
|
||||
|
||||
export function useSessionStateCache({
|
||||
activeSessionId,
|
||||
busyRef,
|
||||
@@ -148,7 +124,6 @@ export function useSessionStateCache({
|
||||
setMessages(nextMessages)
|
||||
}
|
||||
|
||||
syncRuntimeMetadataToView(pending.state)
|
||||
setBusy(pending.state.busy)
|
||||
setMutableRef(busyRef, pending.state.busy)
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
@@ -173,32 +148,8 @@ export function useSessionStateCache({
|
||||
return
|
||||
}
|
||||
|
||||
syncRuntimeMetadataToView(state)
|
||||
pendingViewStateRef.current = { sessionId, state }
|
||||
|
||||
// Terminal / attention transitions (turn finished, error, or the agent is
|
||||
// now waiting on the user) MUST reach the view immediately. Electron
|
||||
// throttles `requestAnimationFrame` to ~0 while the window is
|
||||
// backgrounded, occluded, or unfocused, so an RAF-deferred flush can be
|
||||
// stranded in `pendingViewStateRef` indefinitely — that's the "new chat
|
||||
// stuck on Thinking until I refocus / F5" bug. Flush these synchronously
|
||||
// (cancelling any in-flight RAF, since we're about to publish the latest
|
||||
// state anyway). The plain busy heartbeat stays RAF-batched: that
|
||||
// coalescing exists only to keep periodic `session.info` updates from
|
||||
// churning `$messages` and jerking the scroll position while reading.
|
||||
const isCriticalTransition = !state.busy || state.needsInput
|
||||
|
||||
if (isCriticalTransition) {
|
||||
if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(viewSyncRafRef.current)
|
||||
viewSyncRafRef.current = null
|
||||
}
|
||||
|
||||
flushPendingViewState()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (viewSyncRafRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
|
||||
import { Check, Palette } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||
import { BUILTIN_THEMES } from '@/themes/presets'
|
||||
|
||||
import { MODE_OPTIONS } from './constants'
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
function ThemePreview({ name }: { name: string }) {
|
||||
const t = resolveTheme(name)
|
||||
const t = BUILTIN_THEMES[name]
|
||||
|
||||
if (!t) {
|
||||
return null
|
||||
@@ -56,96 +53,12 @@ function ThemePreview({ name }: { name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function VscodeThemeInstaller() {
|
||||
const { t } = useI18n()
|
||||
const { setTheme } = useTheme()
|
||||
const a = t.settings.appearance
|
||||
const [id, setId] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [status, setStatus] = useState<{ kind: 'error' | 'success'; text: string } | null>(null)
|
||||
|
||||
const install = async () => {
|
||||
const trimmed = id.trim()
|
||||
|
||||
if (!trimmed || busy) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
setStatus(null)
|
||||
|
||||
try {
|
||||
const theme = await installVscodeThemeFromMarketplace(trimmed)
|
||||
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
setStatus({ kind: 'success', text: a.installed(theme.label) })
|
||||
setId('')
|
||||
} catch (error) {
|
||||
setStatus({ kind: 'error', text: error instanceof Error ? error.message : a.installError })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 font-mono text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||
disabled={busy}
|
||||
onChange={event => {
|
||||
setId(event.target.value)
|
||||
setStatus(null)
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
void install()
|
||||
}
|
||||
}}
|
||||
placeholder={a.installPlaceholder}
|
||||
spellCheck={false}
|
||||
value={id}
|
||||
/>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] font-medium transition hover:bg-(--chrome-action-hover) disabled:opacity-50"
|
||||
disabled={busy || !id.trim()}
|
||||
onClick={() => void install()}
|
||||
type="button"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||||
{busy ? a.installing : a.installButton}
|
||||
</button>
|
||||
</div>
|
||||
{status && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
|
||||
status.kind === 'error' ? 'text-(--ui-red)' : 'text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{status.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { t, isSavingLocale } = useI18n()
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const profiles = useStore($profiles)
|
||||
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
const a = t.settings.appearance
|
||||
|
||||
// Themes save per profile. Surface that only when the user actually has more
|
||||
// than one profile (single-profile installs never see the distinction).
|
||||
const showProfileNote = profiles.length > 1
|
||||
|
||||
const activeProfileName =
|
||||
profiles.find(profile => normalizeProfileKey(profile.name) === activeProfileKey)?.name ?? activeProfileKey
|
||||
|
||||
const modeOptions = MODE_OPTIONS.map(({ id, icon }) => ({ icon, id, label: t.settings.modeOptions[id].label }))
|
||||
|
||||
const toolOptions = [
|
||||
@@ -185,72 +98,43 @@ export function AppearanceSettings() {
|
||||
|
||||
<ListRow
|
||||
below={
|
||||
<>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
const removable = isUserTheme(theme.name)
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
|
||||
return (
|
||||
<div className="group relative" key={theme.name}>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
</button>
|
||||
{removable && (
|
||||
<button
|
||||
aria-label={a.removeTheme}
|
||||
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
removeUserTheme(theme.name)
|
||||
|
||||
// Re-normalize off the now-missing skin → default.
|
||||
if (active) {
|
||||
setTheme(theme.name)
|
||||
}
|
||||
}}
|
||||
title={a.removeTheme}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<VscodeThemeInstaller />
|
||||
{showProfileNote && (
|
||||
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.themeProfileNote(activeProfileName)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
description={a.themeDesc}
|
||||
title={a.themeTitle}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import { startManualProviderOAuth } from '@/store/onboarding'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
|
||||
@@ -224,23 +224,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
}, [apiKeyDraft, selectedProviderRow])
|
||||
|
||||
// OAuth / external providers can't be activated with a pasted key — hand off
|
||||
// to the shared onboarding flow scoped to this provider's real sign-in. The
|
||||
// custom / local endpoint is NOT an OAuth provider, so it gets the dedicated
|
||||
// local-endpoint form (URL + optional API key) instead of being dead-ended
|
||||
// on the OAuth picker (the original "booted back to the first screen" loop).
|
||||
// to the shared onboarding flow scoped to this provider's real sign-in.
|
||||
const startProviderSetup = useCallback(() => {
|
||||
const slug = selectedProviderRow?.slug
|
||||
|
||||
if (!slug) {
|
||||
return
|
||||
}
|
||||
|
||||
const lower = slug.toLowerCase()
|
||||
|
||||
if (lower === 'custom' || lower === 'local' || lower.startsWith('custom:')) {
|
||||
startManualLocalEndpoint()
|
||||
} else {
|
||||
startManualProviderOAuth(slug)
|
||||
if (selectedProviderRow?.slug) {
|
||||
startManualProviderOAuth(selectedProviderRow.slug)
|
||||
}
|
||||
}, [selectedProviderRow])
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
|
||||
import { setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
|
||||
@@ -43,14 +43,14 @@ export function SessionsSettings() {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
||||
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
||||
setLocalSessions(result.sessions)
|
||||
} catch (err) {
|
||||
notifyError(err, s.failedLoad)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [s.failedLoad])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
@@ -196,7 +196,6 @@ function DefaultProjectDirSetting() {
|
||||
|
||||
setDir(result.dir)
|
||||
setFallback(result.defaultLabel)
|
||||
applyConfiguredDefaultProjectDir(result.dir)
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -222,8 +221,7 @@ function DefaultProjectDirSetting() {
|
||||
|
||||
const result = await settings.setDefaultProjectDir(picked.dir)
|
||||
setDir(result.dir)
|
||||
applyConfiguredDefaultProjectDir(result.dir)
|
||||
notify({ durationMs: 4_000, kind: 'success', message: s.defaultDirUpdated })
|
||||
notify({ durationMs: 2_000, kind: 'success', message: s.defaultDirUpdated })
|
||||
} catch (err) {
|
||||
notifyError(err, s.updateDirFailed)
|
||||
} finally {
|
||||
@@ -243,8 +241,6 @@ function DefaultProjectDirSetting() {
|
||||
try {
|
||||
await settings.setDefaultProjectDir(null)
|
||||
setDir(null)
|
||||
applyConfiguredDefaultProjectDir(null)
|
||||
await ensureDefaultWorkspaceCwd()
|
||||
} catch (err) {
|
||||
notifyError(err, s.clearDirFailed)
|
||||
} finally {
|
||||
@@ -272,7 +268,7 @@ function DefaultProjectDirSetting() {
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={dir || s.defaultsTo(fallback || '~')}
|
||||
description={dir || s.defaultsTo(fallback || '~/hermes-projects')}
|
||||
title={dir ? dir : s.notSet}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from '@/store/layout'
|
||||
import { $paneWidthOverride } from '@/store/panes'
|
||||
import { $connection } from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
|
||||
@@ -29,19 +28,9 @@ interface AppShellProps {
|
||||
children: ReactNode
|
||||
leftStatusbarItems?: readonly StatusbarItem[]
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
// Fixed-position overlays that must share <main>'s stacking context so pane
|
||||
// resize handles (z-20) paint above them. The persistent terminal lives here:
|
||||
// hoisting it to the root `overlays` layer (sibling of <main>, z above z-3)
|
||||
// would cover every pane's drag handle.
|
||||
mainOverlays?: ReactNode
|
||||
onOpenSettings: () => void
|
||||
overlays?: ReactNode
|
||||
// Rails that sit at the window's left edge in the flipped layout but never
|
||||
// force-collapse to hover-reveal overlays — so they cover the top-left traffic
|
||||
// lights (and zero the titlebar inset) even below the collapse breakpoint.
|
||||
previewPaneOpen?: boolean
|
||||
statusbarItems?: readonly StatusbarItem[]
|
||||
terminalPaneOpen?: boolean
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
}
|
||||
|
||||
@@ -64,12 +53,9 @@ export function AppShell({
|
||||
children,
|
||||
leftStatusbarItems,
|
||||
leftTitlebarTools,
|
||||
mainOverlays,
|
||||
onOpenSettings,
|
||||
overlays,
|
||||
previewPaneOpen = false,
|
||||
statusbarItems,
|
||||
terminalPaneOpen = false,
|
||||
titlebarTools
|
||||
}: AppShellProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
@@ -89,17 +75,10 @@ export function AppShell({
|
||||
|
||||
// The inset clears the top-left titlebar buttons when nothing covers the
|
||||
// window's left edge. Default layout: the sessions sidebar sits there.
|
||||
// Flipped layout: the file browser does instead. Both force-collapse to a
|
||||
// hover-reveal overlay (0px track) below the collapse breakpoint, so the edge
|
||||
// is uncovered there regardless of their stored open state. A standalone
|
||||
// session window renders no sidebar at all, so its edge is always uncovered.
|
||||
const collapsibleLeftPaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
|
||||
// The terminal + preview rails never force-collapse, so when they're the
|
||||
// leftmost open pane (flipped layout) they cover the edge even when narrow.
|
||||
const persistentLeftPaneOpen = panesFlipped && (terminalPaneOpen || previewPaneOpen)
|
||||
|
||||
const leftEdgePaneOpen =
|
||||
!isSecondaryWindow() && ((!narrowViewport && collapsibleLeftPaneOpen) || persistentLeftPaneOpen)
|
||||
// Flipped layout: the file browser does instead. Below the collapse
|
||||
// breakpoint both rails are force-collapsed (hover-reveal overlay), so the
|
||||
// edge is uncovered regardless of their stored open state.
|
||||
const leftEdgePaneOpen = !narrowViewport && (panesFlipped ? fileBrowserOpen : sidebarOpen)
|
||||
|
||||
const titlebarContentInset = leftEdgePaneOpen
|
||||
? 0
|
||||
@@ -178,11 +157,6 @@ export function AppShell({
|
||||
{children}
|
||||
</PaneShell>
|
||||
|
||||
{/* Fixed overlays scoped to main's stacking context (terminal). Rendered
|
||||
after PaneShell so it paints over pane content, but its z stays under
|
||||
the panes' z-20 resize handles, keeping every pane resizable. */}
|
||||
{mainOverlays}
|
||||
|
||||
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
||||
</main>
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
Hash,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Zap,
|
||||
ZapFilled
|
||||
} from '@/lib/icons'
|
||||
@@ -29,7 +27,6 @@ import { $previewServerRestartStatus } from '@/store/preview'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$busy,
|
||||
$connection,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
@@ -43,14 +40,7 @@ import {
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
|
||||
import {
|
||||
$backendUpdateApply,
|
||||
$backendUpdateStatus,
|
||||
$desktopVersion,
|
||||
$updateApply,
|
||||
$updateStatus,
|
||||
openUpdateOverlayFor
|
||||
} from '@/store/updates'
|
||||
import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
import { CRON_ROUTE } from '../../routes'
|
||||
@@ -58,7 +48,6 @@ import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-contr
|
||||
|
||||
interface StatusbarItemsOptions {
|
||||
agentsOpen: boolean
|
||||
chatOpen: boolean
|
||||
commandCenterOpen: boolean
|
||||
extraLeftItems: readonly StatusbarItem[]
|
||||
extraRightItems: readonly StatusbarItem[]
|
||||
@@ -76,7 +65,6 @@ interface StatusbarItemsOptions {
|
||||
|
||||
export function useStatusbarItems({
|
||||
agentsOpen,
|
||||
chatOpen,
|
||||
commandCenterOpen,
|
||||
extraLeftItems,
|
||||
extraRightItems,
|
||||
@@ -94,7 +82,6 @@ export function useStatusbarItems({
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.statusbar
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const yoloActive = useStore($yoloActive)
|
||||
const busy = useStore($busy)
|
||||
const currentFastMode = useStore($currentFastMode)
|
||||
@@ -110,10 +97,7 @@ export function useStatusbarItems({
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
const updateStatus = useStore($updateStatus)
|
||||
const updateApply = useStore($updateApply)
|
||||
const backendUpdateStatus = useStore($backendUpdateStatus)
|
||||
const backendUpdateApply = useStore($backendUpdateApply)
|
||||
const desktopVersion = useStore($desktopVersion)
|
||||
const connection = useStore($connection)
|
||||
|
||||
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
|
||||
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
|
||||
@@ -210,19 +194,18 @@ export function useStatusbarItems({
|
||||
? 'text-amber-600 hover:text-amber-600'
|
||||
: 'text-destructive hover:text-destructive'
|
||||
|
||||
const clientVersionItem = useMemo<StatusbarItem>(() => {
|
||||
const versionItem = useMemo<StatusbarItem>(() => {
|
||||
const appVersion = desktopVersion?.appVersion
|
||||
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
|
||||
const behind = updateStatus?.behind ?? 0
|
||||
const applying = updateApply.applying || updateApply.stage === 'restart'
|
||||
const remote = connection?.mode === 'remote'
|
||||
|
||||
const version = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
|
||||
const base = remote ? copy.clientLabel(appVersion ?? sha ?? copy.unknown) : version
|
||||
const base = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
|
||||
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
|
||||
|
||||
const label = applying
|
||||
? `${base} · ${updateApply.stage === 'restart' ? copy.restart : copy.update}`
|
||||
? updateApply.stage === 'restart'
|
||||
? `${base} · ${copy.restart}`
|
||||
: `${base} · ${copy.update}`
|
||||
: `${base}${behindHint}`
|
||||
|
||||
const tooltip = [
|
||||
@@ -237,18 +220,17 @@ export function useStatusbarItems({
|
||||
|
||||
return {
|
||||
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
|
||||
detail: appVersion && sha && !applying && !remote ? sha : undefined,
|
||||
detail: appVersion && sha && !applying ? sha : undefined,
|
||||
hidden: !appVersion && !sha,
|
||||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version-client',
|
||||
id: 'version',
|
||||
label,
|
||||
onSelect: () => openUpdateOverlayFor('client'),
|
||||
onSelect: () => setUpdateOverlayOpen(true),
|
||||
title: tooltip || undefined,
|
||||
variant: 'action'
|
||||
}
|
||||
}, [
|
||||
desktopVersion?.appVersion,
|
||||
connection?.mode,
|
||||
copy,
|
||||
updateApply.applying,
|
||||
updateApply.message,
|
||||
@@ -258,50 +240,6 @@ export function useStatusbarItems({
|
||||
updateStatus?.currentSha
|
||||
])
|
||||
|
||||
const backendVersionItem = useMemo<StatusbarItem | null>(() => {
|
||||
if (connection?.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
|
||||
const backendVersion = statusSnapshot?.version
|
||||
const behind = backendUpdateStatus?.behind ?? 0
|
||||
const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart'
|
||||
|
||||
const base = copy.backendLabel(backendVersion ?? copy.unknown)
|
||||
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
|
||||
|
||||
const label = applying
|
||||
? `${base} · ${backendUpdateApply.stage === 'restart' ? copy.restart : copy.update}`
|
||||
: `${base}${behindHint}`
|
||||
|
||||
const tooltip = [
|
||||
applying ? backendUpdateApply.message || copy.updateInProgress : null,
|
||||
!applying && behind > 0 && copy.commitsBehind(behind, 'main'),
|
||||
backendVersion && copy.backendVersion(backendVersion)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return {
|
||||
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
|
||||
hidden: !backendVersion,
|
||||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version-backend',
|
||||
label,
|
||||
onSelect: () => openUpdateOverlayFor('backend'),
|
||||
title: tooltip || undefined,
|
||||
variant: 'action'
|
||||
}
|
||||
}, [
|
||||
connection?.mode,
|
||||
statusSnapshot?.version,
|
||||
backendUpdateStatus?.behind,
|
||||
backendUpdateApply.applying,
|
||||
backendUpdateApply.message,
|
||||
backendUpdateApply.stage,
|
||||
copy
|
||||
])
|
||||
|
||||
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -447,21 +385,10 @@ export function useStatusbarItems({
|
||||
variant: 'action' as const
|
||||
})
|
||||
},
|
||||
{
|
||||
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
hidden: !chatOpen,
|
||||
icon: <Terminal className="size-3.5" />,
|
||||
id: 'terminal',
|
||||
onSelect: () => setTerminalTakeover(!$terminalTakeover.get()),
|
||||
title: terminalTakeover ? copy.hideTerminal : copy.showTerminal,
|
||||
variant: 'action'
|
||||
},
|
||||
clientVersionItem,
|
||||
...(backendVersionItem ? [backendVersionItem] : [])
|
||||
versionItem
|
||||
],
|
||||
[
|
||||
busy,
|
||||
chatOpen,
|
||||
contextBar,
|
||||
contextUsage,
|
||||
copy,
|
||||
@@ -472,11 +399,9 @@ export function useStatusbarItems({
|
||||
modelMenuContent,
|
||||
sessionStartedAt,
|
||||
showYoloToggle,
|
||||
terminalTakeover,
|
||||
toggleYolo,
|
||||
turnStartedAt,
|
||||
clientVersionItem,
|
||||
backendVersionItem,
|
||||
versionItem,
|
||||
yoloActive
|
||||
]
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
$visibleModels,
|
||||
collapseModelFamilies,
|
||||
DEFAULT_VISIBLE_PER_PROVIDER,
|
||||
effectiveVisibleKeys,
|
||||
type ModelFamily,
|
||||
modelVisibilityKey,
|
||||
setModelVisibilityOpen
|
||||
@@ -87,17 +86,13 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
: null
|
||||
|
||||
const providers = modelOptions.data?.providers
|
||||
const effectiveVisibleModels = useMemo(
|
||||
() => effectiveVisibleKeys(visibleModels, providers ?? []),
|
||||
[visibleModels, providers]
|
||||
)
|
||||
|
||||
const switchTo = (model: string, provider: string) =>
|
||||
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
|
||||
[providers, search, optionsModel, optionsProvider, effectiveVisibleModels]
|
||||
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, visibleModels),
|
||||
[providers, search, optionsModel, optionsProvider, visibleModels]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -162,9 +157,8 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
currentFastMode
|
||||
)
|
||||
|
||||
// Grayed text is live session state only. Do not label inactive
|
||||
// rows as "Fast" just because they have a fast-capable sibling:
|
||||
// that makes an off Fast toggle look like it is already on.
|
||||
// Grayed text: active row shows live state (Fast + effort);
|
||||
// others show a fast-capability hint.
|
||||
const meta = isCurrent
|
||||
? [
|
||||
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
|
||||
@@ -172,7 +166,9 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: ''
|
||||
: caps?.fast || family.fastId
|
||||
? copy.fast
|
||||
: ''
|
||||
|
||||
// Every row is a hover-Edit submenu trigger. Activating it
|
||||
// (pointer or keyboard) switches to the family's base model;
|
||||
|
||||
@@ -27,20 +27,6 @@ export interface ImageDetachResponse {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export interface FileAttachResponse {
|
||||
attached?: boolean
|
||||
message?: string
|
||||
// Gateway-side absolute path the file was staged to.
|
||||
path?: string
|
||||
// Workspace-relative path used to build ref_text.
|
||||
ref_path?: string
|
||||
// Rewritten @file: ref that resolves on the gateway (workspace-relative).
|
||||
ref_text?: string
|
||||
// True when bytes/host file were copied into the session workspace.
|
||||
uploaded?: boolean
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface SlashExecResponse {
|
||||
output?: string
|
||||
warning?: string
|
||||
@@ -61,26 +47,6 @@ export interface SessionTitleResponse {
|
||||
session_key?: string
|
||||
}
|
||||
|
||||
export interface HandoffRequestResponse {
|
||||
queued?: boolean
|
||||
session_key?: string
|
||||
platform?: string
|
||||
// Human-readable home channel name for the destination platform.
|
||||
home_name?: string
|
||||
}
|
||||
|
||||
export interface HandoffStateResponse {
|
||||
// '' | 'pending' | 'running' | 'completed' | 'failed'
|
||||
state?: string
|
||||
platform?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface HandoffFailResponse {
|
||||
failed?: boolean
|
||||
state?: string
|
||||
}
|
||||
|
||||
export interface ExecCommandDispatchResponse {
|
||||
type: 'exec' | 'plugin'
|
||||
output?: string
|
||||
@@ -123,13 +89,6 @@ export interface ClientSessionState {
|
||||
messages: ChatMessage[]
|
||||
branch: string
|
||||
cwd: string
|
||||
model: string
|
||||
provider: string
|
||||
reasoningEffort: string
|
||||
serviceTier: string
|
||||
fast: boolean
|
||||
yolo: boolean
|
||||
personality: string
|
||||
busy: boolean
|
||||
awaitingResponse: boolean
|
||||
streamId: string | null
|
||||
|
||||
@@ -12,19 +12,12 @@ import { useI18n } from '@/i18n'
|
||||
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
|
||||
import { AlertCircle, Check, CheckCircle2, Copy, Terminal } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { resolveUpdateCopy, type UpdateTarget } from '@/lib/update-copy'
|
||||
import {
|
||||
$backendUpdateApply,
|
||||
$backendUpdateChecking,
|
||||
$backendUpdateStatus,
|
||||
$updateApply,
|
||||
$updateChecking,
|
||||
$updateOverlayOpen,
|
||||
$updateOverlayTarget,
|
||||
$updateStatus,
|
||||
applyBackendUpdate,
|
||||
applyUpdates,
|
||||
checkBackendUpdates,
|
||||
checkUpdates,
|
||||
resetUpdateApplyState,
|
||||
setUpdateOverlayOpen,
|
||||
@@ -37,27 +30,15 @@ function totalItems(groups: readonly CommitGroup[]) {
|
||||
|
||||
export function UpdatesOverlay() {
|
||||
const open = useStore($updateOverlayOpen)
|
||||
const target = useStore($updateOverlayTarget)
|
||||
|
||||
const clientStatus = useStore($updateStatus)
|
||||
const clientChecking = useStore($updateChecking)
|
||||
const clientApply = useStore($updateApply)
|
||||
const backendStatus = useStore($backendUpdateStatus)
|
||||
const backendChecking = useStore($backendUpdateChecking)
|
||||
const backendApply = useStore($backendUpdateApply)
|
||||
|
||||
const isBackend = target === 'backend'
|
||||
const status = isBackend ? backendStatus : clientStatus
|
||||
const checking = isBackend ? backendChecking : clientChecking
|
||||
const apply = isBackend ? backendApply : clientApply
|
||||
const check = isBackend ? checkBackendUpdates : checkUpdates
|
||||
const install = isBackend ? applyBackendUpdate : applyUpdates
|
||||
const status = useStore($updateStatus)
|
||||
const checking = useStore($updateChecking)
|
||||
const apply = useStore($updateApply)
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !status && !checking) {
|
||||
void check()
|
||||
void checkUpdates()
|
||||
}
|
||||
}, [check, checking, open, status])
|
||||
}, [checking, open, status])
|
||||
|
||||
const behind = status?.behind ?? 0
|
||||
|
||||
@@ -83,7 +64,7 @@ export function UpdatesOverlay() {
|
||||
}
|
||||
|
||||
const handleInstall = () => {
|
||||
void install()
|
||||
void applyUpdates()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -92,7 +73,7 @@ export function UpdatesOverlay() {
|
||||
className="max-w-sm overflow-hidden border-border/70 p-0 gap-0"
|
||||
showCloseButton={phase !== 'applying'}
|
||||
>
|
||||
{phase === 'applying' && <ApplyingView apply={apply} isBackend={isBackend} />}
|
||||
{phase === 'applying' && <ApplyingView apply={apply} />}
|
||||
|
||||
{phase === 'manual' && (
|
||||
<ManualView command={apply.command ?? 'hermes update'} onDone={() => handleClose(false)} />
|
||||
@@ -109,9 +90,8 @@ export function UpdatesOverlay() {
|
||||
commits={status?.commits ?? []}
|
||||
onInstall={handleInstall}
|
||||
onLater={() => handleClose(false)}
|
||||
onRetryCheck={() => void check()}
|
||||
onRetryCheck={() => void checkUpdates()}
|
||||
status={status}
|
||||
target={target}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
@@ -126,8 +106,7 @@ function IdleView({
|
||||
onInstall,
|
||||
onLater,
|
||||
onRetryCheck,
|
||||
status,
|
||||
target
|
||||
status
|
||||
}: {
|
||||
behind: number
|
||||
checking: boolean
|
||||
@@ -136,7 +115,6 @@ function IdleView({
|
||||
onLater: () => void
|
||||
onRetryCheck: () => void
|
||||
status: DesktopUpdateStatus | null
|
||||
target: UpdateTarget
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const u = t.updates
|
||||
@@ -189,7 +167,7 @@ function IdleView({
|
||||
if (behind === 0) {
|
||||
return (
|
||||
<CenteredStatus
|
||||
body={target === 'backend' ? u.latestBodyBackend : u.latestBody}
|
||||
body={u.latestBody}
|
||||
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
|
||||
title={u.allSetTitle}
|
||||
/>
|
||||
@@ -200,20 +178,14 @@ function IdleView({
|
||||
const shownItems = totalItems(groups)
|
||||
const remaining = Math.max(0, behind - shownItems)
|
||||
|
||||
// Name what's being updated. In remote mode the overlay acts on the connected
|
||||
// backend, not the local client — say so. When there are no commit rows to
|
||||
// show (e.g. pip/non-git backend), degrade to honest "no release notes" copy
|
||||
// instead of generic filler.
|
||||
const { title, body } = resolveUpdateCopy({ target, shownItems, copy: u })
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<BrandMark className="size-16" />
|
||||
|
||||
<DialogTitle className="text-center text-xl">{title}</DialogTitle>
|
||||
<DialogTitle className="text-center text-xl">{u.availableTitle}</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm">
|
||||
{body}
|
||||
{u.availableBody}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
@@ -309,11 +281,10 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
|
||||
)
|
||||
}
|
||||
|
||||
function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend: boolean }) {
|
||||
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
|
||||
const { t } = useI18n()
|
||||
const u = t.updates
|
||||
const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
|
||||
const body = isBackend ? u.applyingBodyBackend : u.applyingBody
|
||||
|
||||
const percent =
|
||||
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
|
||||
@@ -327,7 +298,7 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
|
||||
|
||||
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm">
|
||||
{body}
|
||||
{u.applyingBody}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export function directiveIconSvg(type: string) {
|
||||
return `<svg ${SVG_ATTRS} class="size-3 shrink-0 opacity-80">${inner}</svg>`
|
||||
}
|
||||
|
||||
function iconElementFromPaths(paths: string[]) {
|
||||
export function directiveIconElement(type: string) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
svg.setAttribute('class', 'size-3 shrink-0 opacity-80')
|
||||
svg.setAttribute('fill', 'none')
|
||||
@@ -74,7 +74,7 @@ function iconElementFromPaths(paths: string[]) {
|
||||
svg.setAttribute('viewBox', '0 0 24 24')
|
||||
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
|
||||
for (const d of paths) {
|
||||
for (const d of iconPathsFor(type)) {
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
path.setAttribute('d', d)
|
||||
svg.append(path)
|
||||
@@ -83,46 +83,6 @@ function iconElementFromPaths(paths: string[]) {
|
||||
return svg
|
||||
}
|
||||
|
||||
export function directiveIconElement(type: string) {
|
||||
return iconElementFromPaths(iconPathsFor(type))
|
||||
}
|
||||
|
||||
/** Per-type slash-command pill styling. The composer inserts these chips when a
|
||||
* command is picked; the kind drives a theme-aware accent so commands, skills,
|
||||
* and themes read distinctly (Cursor-style). */
|
||||
export type SlashChipKind = 'command' | 'skill' | 'theme'
|
||||
|
||||
const SLASH_ICON_PATHS: Record<SlashChipKind, string[]> = {
|
||||
command: ['M5 7l5 5l-5 5', 'M12 19l7 0'],
|
||||
skill: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'],
|
||||
theme: [
|
||||
'M3 21v-4a4 4 0 1 1 4 4h-4',
|
||||
'M21 3a16 16 0 0 0 -12.8 10.2',
|
||||
'M21 3a16 16 0 0 1 -10.2 12.8',
|
||||
'M10.6 9a9 9 0 0 1 4.4 4.4'
|
||||
]
|
||||
}
|
||||
|
||||
const SLASH_CHIP_VARIANT: Record<SlashChipKind, string> = {
|
||||
command:
|
||||
'bg-[color-mix(in_srgb,var(--ui-accent)_14%,transparent)] text-[color-mix(in_srgb,var(--ui-accent)_82%,var(--foreground))]',
|
||||
skill:
|
||||
'bg-[color-mix(in_srgb,var(--ui-warm)_18%,transparent)] text-[color-mix(in_srgb,var(--ui-warm)_82%,var(--foreground))]',
|
||||
theme:
|
||||
'bg-[color-mix(in_srgb,var(--ui-accent-secondary)_16%,transparent)] text-[color-mix(in_srgb,var(--ui-accent-secondary)_82%,var(--foreground))]'
|
||||
}
|
||||
|
||||
export const SLASH_CHIP_BASE_CLASS =
|
||||
'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-medium leading-none'
|
||||
|
||||
export function slashChipClass(kind: SlashChipKind): string {
|
||||
return `${SLASH_CHIP_BASE_CLASS} ${SLASH_CHIP_VARIANT[kind]}`
|
||||
}
|
||||
|
||||
export function slashIconElement(kind: SlashChipKind) {
|
||||
return iconElementFromPaths(SLASH_ICON_PATHS[kind])
|
||||
}
|
||||
|
||||
const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
|
||||
<svg
|
||||
className="size-3 shrink-0 opacity-80"
|
||||
|
||||
@@ -494,9 +494,11 @@ export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: Markdo
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
return (
|
||||
<DeferStreamingText>
|
||||
<MarkdownTextSurface />
|
||||
</DeferStreamingText>
|
||||
<SmoothStreamingText>
|
||||
<DeferStreamingText>
|
||||
<MarkdownTextSurface />
|
||||
</DeferStreamingText>
|
||||
</SmoothStreamingText>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||