diff --git a/apps/desktop/electron/bootstrap-platform.test.cjs b/apps/desktop/electron/bootstrap-platform.test.cjs
index be9e475b01..bbd60611a3 100644
--- a/apps/desktop/electron/bootstrap-platform.test.cjs
+++ b/apps/desktop/electron/bootstrap-platform.test.cjs
@@ -3,11 +3,7 @@ const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
-const {
- bundledRuntimeImportCheck,
- isWindowsBinaryPathInWsl,
- isWslEnvironment
-} = require('./bootstrap-platform.cjs')
+const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
diff --git a/apps/desktop/electron/hardening.cjs b/apps/desktop/electron/hardening.cjs
new file mode 100644
index 0000000000..4ffdea051b
--- /dev/null
+++ b/apps/desktop/electron/hardening.cjs
@@ -0,0 +1,184 @@
+const fs = require('node:fs')
+const path = require('node:path')
+const { fileURLToPath } = require('node:url')
+
+const DEFAULT_FETCH_TIMEOUT_MS = 15_000
+const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
+const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024
+
+const SAFE_ENV_SUFFIXES = new Set(['dist', 'example', 'sample', 'template'])
+const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
+
+function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) {
+ const fallback =
+ Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS
+ const parsed = Number(timeoutMs)
+
+ if (Number.isFinite(parsed) && parsed > 0) {
+ return Math.round(parsed)
+ }
+
+ return fallback
+}
+
+function encryptDesktopSecret(value, safeStorageApi) {
+ const raw = String(value || '')
+
+ if (!raw) {
+ return null
+ }
+
+ let encryptionAvailable = false
+
+ try {
+ encryptionAvailable = Boolean(safeStorageApi?.isEncryptionAvailable?.())
+ } catch {
+ encryptionAvailable = false
+ }
+
+ if (!encryptionAvailable) {
+ throw new Error(
+ 'Secure token storage is unavailable, so Hermes Desktop cannot save remote gateway tokens. ' +
+ 'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment, or enable OS keychain access and try again.'
+ )
+ }
+
+ try {
+ return {
+ encoding: 'safeStorage',
+ value: safeStorageApi.encryptString(raw).toString('base64')
+ }
+ } catch (error) {
+ const detail = error instanceof Error && error.message ? ` (${error.message})` : ''
+ throw new Error(
+ `Failed to encrypt the remote gateway token for secure storage${detail}. ` +
+ 'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment as a fallback.'
+ )
+ }
+}
+
+function sensitiveFileBlockReason(filePath) {
+ const normalized = String(filePath || '')
+ .replace(/\\/g, '/')
+ .toLowerCase()
+ const basename = path.basename(normalized)
+ const ext = path.extname(basename)
+
+ if (!basename) {
+ return null
+ }
+
+ if (normalized.includes('/.ssh/')) {
+ return 'SSH key/config files are blocked.'
+ }
+
+ if (normalized.includes('/.gnupg/')) {
+ return 'GPG key material is blocked.'
+ }
+
+ if (normalized.endsWith('/.aws/credentials')) {
+ return 'AWS credential files are blocked.'
+ }
+
+ if (basename === '.env') {
+ return '.env files are blocked because they commonly contain secrets.'
+ }
+
+ if (basename.startsWith('.env.')) {
+ const suffix = basename.slice('.env.'.length)
+ if (!SAFE_ENV_SUFFIXES.has(suffix)) {
+ return `${basename} is blocked because it appears to contain environment secrets.`
+ }
+ }
+
+ if (/^id_(rsa|dsa|ecdsa|ed25519)(?:\..+)?$/.test(basename) && !basename.endsWith('.pub')) {
+ return 'SSH private key files are blocked.'
+ }
+
+ if (SENSITIVE_EXTENSIONS.has(ext)) {
+ return `${ext} key/certificate files are blocked.`
+ }
+
+ if (basename === '.npmrc' || basename === '.netrc' || basename === '.pypirc') {
+ return `${basename} is blocked because it may include auth credentials.`
+ }
+
+ return null
+}
+
+function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') {
+ const raw = String(filePath || '').trim()
+
+ if (!raw) {
+ throw new Error(`${purpose} failed: file path is required.`)
+ }
+
+ if (raw.includes('\0')) {
+ throw new Error(`${purpose} failed: file path is invalid.`)
+ }
+
+ if (/^file:/i.test(raw)) {
+ try {
+ return fileURLToPath(raw)
+ } catch {
+ throw new Error(`${purpose} failed: file URL is invalid.`)
+ }
+ }
+
+ 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 resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose)
+
+ if (options.blockSensitive !== false) {
+ const blockReason = sensitiveFileBlockReason(resolvedPath)
+ if (blockReason) {
+ throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`)
+ }
+ }
+
+ 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 new Error(`${purpose} failed: path points to a directory.`)
+ }
+
+ if (!stat.isFile()) {
+ 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 new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
+ }
+
+ try {
+ await fs.promises.access(resolvedPath, fs.constants.R_OK)
+ } catch {
+ throw new Error(`${purpose} failed: file is not readable.`)
+ }
+
+ return { resolvedPath, stat }
+}
+
+module.exports = {
+ DATA_URL_READ_MAX_BYTES,
+ DEFAULT_FETCH_TIMEOUT_MS,
+ TEXT_PREVIEW_SOURCE_MAX_BYTES,
+ encryptDesktopSecret,
+ resolveReadableFileForIpc,
+ resolveTimeoutMs,
+ sensitiveFileBlockReason
+}
diff --git a/apps/desktop/electron/hardening.test.cjs b/apps/desktop/electron/hardening.test.cjs
new file mode 100644
index 0000000000..865da8fe79
--- /dev/null
+++ b/apps/desktop/electron/hardening.test.cjs
@@ -0,0 +1,116 @@
+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 {
+ DEFAULT_FETCH_TIMEOUT_MS,
+ encryptDesktopSecret,
+ resolveReadableFileForIpc,
+ resolveTimeoutMs,
+ sensitiveFileBlockReason
+} = require('./hardening.cjs')
+
+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)
+ assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS)
+ assert.equal(resolveTimeoutMs('2750'), 2750)
+})
+
+test('encryptDesktopSecret requires available secure storage', () => {
+ assert.equal(
+ encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }),
+ null
+ )
+
+ assert.throws(
+ () => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }),
+ /Secure token storage is unavailable/
+ )
+})
+
+test('encryptDesktopSecret stores safeStorage base64 payload', () => {
+ const secret = encryptDesktopSecret('token-123', {
+ isEncryptionAvailable: () => true,
+ encryptString: value => Buffer.from(`enc:${value}`, 'utf8')
+ })
+
+ assert.deepEqual(secret, {
+ encoding: 'safeStorage',
+ value: Buffer.from('enc:token-123', 'utf8').toString('base64')
+ })
+})
+
+test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
+ assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/)
+ assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null)
+ assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/)
+ assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
+})
+
+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 }))
+
+ const textPath = path.join(tempDir, 'notes.txt')
+ fs.writeFileSync(textPath, 'hello world', 'utf8')
+
+ const fromRelative = await resolveReadableFileForIpc('notes.txt', {
+ baseDir: tempDir,
+ maxBytes: 256,
+ purpose: 'File preview'
+ })
+ assert.equal(fromRelative.resolvedPath, textPath)
+ assert.equal(fromRelative.stat.size, 11)
+
+ const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
+ purpose: 'File preview'
+ })
+ assert.equal(fromFileUrl.resolvedPath, textPath)
+
+ await assert.rejects(
+ resolveReadableFileForIpc('missing.txt', {
+ baseDir: tempDir,
+ purpose: 'Text preview'
+ }),
+ /file does not exist/
+ )
+
+ const nestedDir = path.join(tempDir, 'directory')
+ fs.mkdirSync(nestedDir)
+ await assert.rejects(
+ resolveReadableFileForIpc(nestedDir, {
+ purpose: 'Text preview'
+ }),
+ /path points to a directory/
+ )
+
+ const largePath = path.join(tempDir, 'large.txt')
+ fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8')
+ await assert.rejects(
+ resolveReadableFileForIpc(largePath, {
+ maxBytes: 8,
+ purpose: 'File preview'
+ }),
+ /file is too large/
+ )
+
+ const envPath = path.join(tempDir, '.env')
+ fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
+ await assert.rejects(
+ resolveReadableFileForIpc(envPath, {
+ purpose: 'File preview'
+ }),
+ /blocked for sensitive file/
+ )
+
+ const envTemplatePath = path.join(tempDir, '.env.example')
+ fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
+ const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
+ purpose: 'File preview'
+ })
+ assert.equal(envTemplate.resolvedPath, envTemplatePath)
+})
diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
index 56430ef660..74fc4636dd 100644
--- a/apps/desktop/electron/main.cjs
+++ b/apps/desktop/electron/main.cjs
@@ -21,6 +21,14 @@ const path = require('node:path')
const { fileURLToPath, pathToFileURL } = require('node:url')
const { spawn } = require('node:child_process')
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
+const {
+ DATA_URL_READ_MAX_BYTES,
+ DEFAULT_FETCH_TIMEOUT_MS,
+ TEXT_PREVIEW_SOURCE_MAX_BYTES,
+ encryptDesktopSecret: encryptDesktopSecretStrict,
+ resolveReadableFileForIpc,
+ resolveTimeoutMs
+} = require('./hardening.cjs')
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
if (USER_DATA_OVERRIDE) {
@@ -843,7 +851,16 @@ function resolveHermesBackend(dashboardArgs) {
if (backend) return backend
}
- // 2. Existing `hermes` on PATH — installed via install.ps1 / install.sh, or
+ // 2. Development source — when running `npm run dev` from a checkout, the
+ // cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE and any
+ // installed `hermes` on PATH so local Python edits are actually exercised.
+ // (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.)
+ if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) {
+ const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs)
+ if (backend) return backend
+ }
+
+ // 3. Existing `hermes` on PATH — installed via install.ps1 / install.sh, or
// pip-installed system-wide. Skip when HERMES_DESKTOP_IGNORE_EXISTING=1
// (used by test:desktop:fresh to force the factory-image bootstrap path).
if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') {
@@ -876,15 +893,6 @@ function resolveHermesBackend(dashboardArgs) {
}
}
- // 3. Development source — when running `npm run dev` from a checkout, the
- // cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE so the
- // desktop uses the dev's local edits, not whatever's under HERMES_HOME.
- // (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.)
- if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) {
- const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs)
- if (backend) return backend
- }
-
// 4. ACTIVE_HERMES_ROOT — the canonical mutable install at
// %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent.
// On packaged installs this is populated from FACTORY_HERMES_ROOT during
@@ -1155,6 +1163,7 @@ function fetchJson(url, token, options = {}) {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const parsed = new URL(url)
const client = parsed.protocol === 'https:' ? https : http
+ const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
@@ -1190,11 +1199,9 @@ function fetchJson(url, token, options = {}) {
)
req.on('error', reject)
- if (options.timeoutMs) {
- req.setTimeout(options.timeoutMs, () => {
- req.destroy(new Error(`Timed out connecting to Hermes backend after ${options.timeoutMs}ms`))
- })
- }
+ req.setTimeout(timeoutMs, () => {
+ req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
+ })
if (body) req.write(body)
req.end()
})
@@ -1456,7 +1463,9 @@ function fetchLinkTitle(rawUrl) {
const pending = fetchHtmlTitleWithCurl(url)
.catch(() => '')
.then(value => usableTitle((value || '').slice(0, 240)))
- .then(async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240)))
+ .then(
+ async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240))
+ )
.then(clean => {
cacheTitle(key, clean)
titleInflight.delete(key)
@@ -2022,24 +2031,7 @@ function tokenPreview(value) {
}
function encryptDesktopSecret(value) {
- const raw = String(value || '')
-
- if (!raw) {
- return null
- }
-
- try {
- if (safeStorage.isEncryptionAvailable()) {
- return {
- encoding: 'safeStorage',
- value: safeStorage.encryptString(raw).toString('base64')
- }
- }
- } catch {
- // Fall through to plaintext for platforms where Electron cannot encrypt.
- }
-
- return { encoding: 'plain', value: raw }
+ return encryptDesktopSecretStrict(value, safeStorage)
}
function decryptDesktopSecret(secret) {
@@ -2108,14 +2100,19 @@ function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig())
}
}
-function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig()) {
+function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
+ const persistToken = options.persistToken !== false
const mode = input.mode === 'remote' ? 'remote' : 'local'
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
const existingToken = existing.remote?.token
const nextRemote = {
url: remoteUrl,
- token: incomingToken ? encryptDesktopSecret(incomingToken) : existingToken
+ token: incomingToken
+ ? persistToken
+ ? encryptDesktopSecret(incomingToken)
+ : { encoding: 'plain', value: incomingToken }
+ : existingToken
}
if (mode === 'remote') {
@@ -2181,7 +2178,7 @@ function resolveRemoteBackend() {
}
async function testDesktopConnectionConfig(input = {}) {
- const config = coerceDesktopConnectionConfig(input)
+ const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
const remote =
config.mode === 'remote'
? {
@@ -2455,9 +2452,11 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
ipcMain.handle('hermes:api', async (_event, request) => {
const connection = await startHermes()
+ const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, {
- method: request.method,
- body: request.body
+ method: request?.method,
+ body: request?.body,
+ timeoutMs
})
})
@@ -2472,18 +2471,21 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
})
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
- const input = String(filePath || '')
- const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
- const data = await fs.promises.readFile(resolved)
- return `data:${mimeTypeForPath(resolved)};base64,${data.toString('base64')}`
+ const { resolvedPath } = await resolveReadableFileForIpc(filePath, {
+ maxBytes: DATA_URL_READ_MAX_BYTES,
+ purpose: 'File preview'
+ })
+ const data = await fs.promises.readFile(resolvedPath)
+ return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}`
})
ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
- const input = String(filePath || '')
- const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
- const ext = path.extname(resolved).toLowerCase()
- const stat = await fs.promises.stat(resolved)
- const handle = await fs.promises.open(resolved, 'r')
+ const { resolvedPath, stat } = await resolveReadableFileForIpc(filePath, {
+ maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES,
+ purpose: 'Text preview'
+ })
+ const ext = path.extname(resolvedPath).toLowerCase()
+ const handle = await fs.promises.open(resolvedPath, 'r')
const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES)
try {
@@ -2494,8 +2496,8 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))),
byteSize: stat.size,
language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text',
- mimeType: mimeTypeForPath(resolved),
- path: resolved,
+ mimeType: mimeTypeForPath(resolvedPath),
+ path: resolvedPath,
text: buffer.subarray(0, bytesRead).toString('utf8'),
truncated: stat.size > TEXT_PREVIEW_MAX_BYTES
}
diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json
index e7973dd27b..2442f71dcf 100644
--- a/apps/desktop/package-lock.json
+++ b/apps/desktop/package-lock.json
@@ -14,17 +14,20 @@
"@chenglou/pretext": "^0.0.6",
"@hermes/shared": "file:../shared",
"@nanostores/react": "^1.1.0",
+ "@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
"@streamdown/code": "^1.1.1",
"@tabler/icons-react": "^3.41.1",
+ "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"ignore": "^7.0.5",
- "liquid-glass-react": "^1.1.1",
+ "leva": "^0.10.1",
"lucide-react": "^0.577.0",
+ "motion": "^12.38.0",
"nanostores": "^1.3.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
@@ -1977,6 +1980,48 @@
"@emnapi/runtime": "^1.7.1"
}
},
+ "node_modules/@nous-research/ui": {
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.0.tgz",
+ "integrity": "sha512-c07lfMdEv/KL6lYC6mfap1CcmIPbvhCZu1supnFaIIrlUaab8gVNDYl8wMMjNRdYOVxxXKisU48yyfe5qvlwqg==",
+ "dependencies": {
+ "@nanostores/react": "^1.0.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "nanostores": "^1.0.1",
+ "sanitize-html": "^2.16.0",
+ "tailwind-merge": "^3.3.1",
+ "tw-animate-css": "^1.4.0",
+ "unicode-animations": "^1.0.3"
+ },
+ "peerDependencies": {
+ "@observablehq/plot": "^0.6.17",
+ "@react-three/fiber": "^9.4.0",
+ "gsap": "^3.13.0",
+ "leva": "^0.10.1",
+ "motion": "^12.38.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "three": "^0.180.0"
+ },
+ "peerDependenciesMeta": {
+ "@observablehq/plot": {
+ "optional": true
+ },
+ "@react-three/fiber": {
+ "optional": true
+ },
+ "gsap": {
+ "optional": true
+ },
+ "leva": {
+ "optional": true
+ },
+ "three": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@oxc-project/types": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
@@ -5916,6 +5961,14 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
+ "node_modules/@stitches/react": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@stitches/react/-/react-1.2.8.tgz",
+ "integrity": "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA==",
+ "peerDependencies": {
+ "react": ">= 16.3.0"
+ }
+ },
"node_modules/@streamdown/code": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@streamdown/code/-/code-1.1.1.tgz",
@@ -6285,6 +6338,17 @@
"node": ">= 20"
}
},
+ "node_modules/@tailwindcss/typography": {
+ "version": "0.5.19",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
+ "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
+ "dependencies": {
+ "postcss-selector-parser": "6.0.10"
+ },
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
+ }
+ },
"node_modules/@tailwindcss/vite": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz",
@@ -7097,6 +7161,22 @@
"d3-transition": "^3.0.1"
}
},
+ "node_modules/@use-gesture/core": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
+ "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="
+ },
+ "node_modules/@use-gesture/react": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
+ "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
+ "dependencies": {
+ "@use-gesture/core": "10.3.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@@ -7723,6 +7803,14 @@
"node": ">=12"
}
},
+ "node_modules/assign-symbols": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
+ "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/assistant-cloud": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.27.tgz",
@@ -7816,6 +7904,14 @@
"node": ">= 4.0.0"
}
},
+ "node_modules/attr-accept": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
+ "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -8448,6 +8544,11 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/colord": {
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
+ "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
+ },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -8623,6 +8724,17 @@
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -9276,6 +9388,14 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
@@ -9549,6 +9669,55 @@
"license": "MIT",
"peer": true
},
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ]
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
"node_modules/dompurify": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
@@ -9558,6 +9727,19 @@
"@types/trusted-types": "^2.0.7"
}
},
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -10079,7 +10261,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -10523,6 +10704,25 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
+ "node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/extend-shallow/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -10615,6 +10815,17 @@
"node": ">=16.0.0"
}
},
+ "node_modules/file-selector": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.5.0.tgz",
+ "integrity": "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA==",
+ "dependencies": {
+ "tslib": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -10730,6 +10941,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/for-in": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
+ "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -10747,6 +10966,32 @@
"node": ">= 6"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.38.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
+ "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
+ "dependencies": {
+ "motion-dom": "^12.38.0",
+ "motion-utils": "^12.36.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -10936,6 +11181,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-value": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
+ "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -11464,6 +11717,35 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -11819,6 +12101,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/is-extendable": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
+ "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
+ "dependencies": {
+ "is-plain-object": "^2.0.4"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -11953,6 +12246,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "dependencies": {
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -12132,6 +12436,14 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -12200,7 +12512,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -12445,6 +12756,44 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/leva": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz",
+ "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==",
+ "dependencies": {
+ "@radix-ui/react-portal": "^1.1.4",
+ "@radix-ui/react-tooltip": "^1.1.8",
+ "@stitches/react": "^1.2.8",
+ "@use-gesture/react": "^10.2.5",
+ "colord": "^2.9.2",
+ "dequal": "^2.0.2",
+ "merge-value": "^1.0.0",
+ "react-colorful": "^5.5.1",
+ "react-dropzone": "^12.0.0",
+ "v8n": "^1.3.3",
+ "zustand": "^3.6.9"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/leva/node_modules/zustand": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz",
+ "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==",
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -12708,19 +13057,6 @@
"url": "https://opencollective.com/parcel"
}
},
- "node_modules/liquid-glass-react": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/liquid-glass-react/-/liquid-glass-react-1.1.1.tgz",
- "integrity": "sha512-pKzaktaMAEztd93wpWcz2Z5Z9qdLJUNJdMX+n00Ca4XsnrLTQ5xJzm/+GQXZUeuFXe/PQ8ziVMZO6531PyaFJw==",
- "license": "MIT",
- "workspaces": [
- "liquid-glass"
- ],
- "peerDependencies": {
- "react": ">=19",
- "react-dom": ">=19"
- }
- },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -12771,7 +13107,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -13169,6 +13504,20 @@
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
+ "node_modules/merge-value": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/merge-value/-/merge-value-1.0.0.tgz",
+ "integrity": "sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ==",
+ "dependencies": {
+ "get-value": "^2.0.6",
+ "is-extendable": "^1.0.0",
+ "mixin-deep": "^1.2.0",
+ "set-value": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/mermaid": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
@@ -13868,6 +14217,18 @@
"node": ">= 18"
}
},
+ "node_modules/mixin-deep": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz",
+ "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==",
+ "dependencies": {
+ "for-in": "^1.0.2",
+ "is-extendable": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
@@ -13894,6 +14255,44 @@
"ufo": "^1.6.3"
}
},
+ "node_modules/motion": {
+ "version": "12.38.0",
+ "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
+ "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
+ "dependencies": {
+ "framer-motion": "^12.38.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "12.38.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
+ "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
+ "dependencies": {
+ "motion-utils": "^12.36.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.36.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
+ "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -14111,7 +14510,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -14375,6 +14773,11 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
+ "node_modules/parse-srcset": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
+ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
+ },
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -14556,6 +14959,18 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/postcss-selector-parser": {
+ "version": "6.0.10",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+ "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -14650,7 +15065,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -14662,7 +15076,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/proper-lockfile": {
@@ -14889,6 +15302,15 @@
"react-dom": ">= 16.14"
}
},
+ "node_modules/react-colorful": {
+ "version": "5.7.0",
+ "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.7.0.tgz",
+ "integrity": "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==",
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
"node_modules/react-dnd": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
@@ -14938,6 +15360,22 @@
"react": "^19.2.5"
}
},
+ "node_modules/react-dropzone": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-12.1.0.tgz",
+ "integrity": "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog==",
+ "dependencies": {
+ "attr-accept": "^2.2.2",
+ "file-selector": "^0.5.0",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">= 10.13"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8"
+ }
+ },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -15589,6 +16027,27 @@
"truncate-utf8-bytes": "^1.0.0"
}
},
+ "node_modules/sanitize-html": {
+ "version": "2.17.3",
+ "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz",
+ "integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==",
+ "dependencies": {
+ "deepmerge": "^4.2.2",
+ "escape-string-regexp": "^4.0.0",
+ "htmlparser2": "^10.1.0",
+ "is-plain-object": "^5.0.0",
+ "parse-srcset": "^1.0.2",
+ "postcss": "^8.3.11"
+ }
+ },
+ "node_modules/sanitize-html/node_modules/is-plain-object": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+ "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
@@ -15727,6 +16186,28 @@
"node": ">= 0.4"
}
},
+ "node_modules/set-value": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
+ "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-extendable": "^0.1.1",
+ "is-plain-object": "^2.0.3",
+ "split-string": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/set-value/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -15953,6 +16434,29 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/split-string": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
+ "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==",
+ "dependencies": {
+ "extend-shallow": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split-string/node_modules/extend-shallow": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
+ "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==",
+ "dependencies": {
+ "assign-symbols": "^1.0.0",
+ "is-extendable": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@@ -16569,6 +17073,14 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/tw-animate-css": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
+ "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
+ "funding": {
+ "url": "https://github.com/sponsors/Wombosvideo"
+ }
+ },
"node_modules/tw-shimmer": {
"version": "0.4.11",
"resolved": "https://registry.npmjs.org/tw-shimmer/-/tw-shimmer-0.4.11.tgz",
@@ -17012,6 +17524,11 @@
"dev": true,
"license": "(WTFPL OR MIT)"
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+ },
"node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
@@ -17025,6 +17542,11 @@
"uuid": "dist/esm/bin/uuid"
}
},
+ "node_modules/v8n": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/v8n/-/v8n-1.5.1.tgz",
+ "integrity": "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A=="
+ },
"node_modules/verror": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index cfd3630dd1..75e4ac76f9 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -31,7 +31,7 @@
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
"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",
+ "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -47,7 +47,7 @@
"@chenglou/pretext": "^0.0.6",
"@hermes/shared": "file:../shared",
"@nanostores/react": "^1.1.0",
- "@nous-research/ui": "^0.12.0",
+ "@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
"@streamdown/code": "^1.1.1",
"@tabler/icons-react": "^3.41.1",
@@ -59,7 +59,6 @@
"cmdk": "^1.1.1",
"ignore": "^7.0.5",
"leva": "^0.10.1",
- "liquid-glass-react": "^1.1.1",
"lucide-react": "^0.577.0",
"motion": "^12.38.0",
"nanostores": "^1.3.0",
diff --git a/apps/desktop/src/app/artifacts/index.test.ts b/apps/desktop/src/app/artifacts/index.test.ts
index 509deed8aa..ebca956a2c 100644
--- a/apps/desktop/src/app/artifacts/index.test.ts
+++ b/apps/desktop/src/app/artifacts/index.test.ts
@@ -49,6 +49,7 @@ describe('collectArtifactsForSession', () => {
timestamp: 3000
}
]
+
const artifacts = collectArtifactsForSession(makeSession({ id: 'session-2' }), messages)
expect(artifacts).toHaveLength(1)
diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx
index ba9697518a..e00666286e 100644
--- a/apps/desktop/src/app/artifacts/index.tsx
+++ b/apps/desktop/src/app/artifacts/index.tsx
@@ -859,10 +859,7 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
return (
{value}
diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index 06959bddf4..ace13c58cb 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -1,9 +1,6 @@
-import './liquid-glass-overrides.css'
-
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
-import LiquidGlass from 'liquid-glass-react'
import {
type ClipboardEvent,
type FormEvent,
@@ -20,7 +17,7 @@ import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { chatMessageText } from '@/lib/chat-messages'
import { contextPath } from '@/lib/chat-runtime'
-import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
+import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments, $composerDraft } from '@/store/composer'
@@ -45,117 +42,16 @@ import {
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'
import { UrlDialog } from './url-dialog'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
-function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
- const blobs: Blob[] = []
- const seen = new Set
()
-
- const push = (blob: Blob | null) => {
- if (!blob || blob.size === 0 || seen.has(blob)) {
- return
- }
-
- seen.add(blob)
- blobs.push(blob)
- }
-
- if (clipboard.items?.length) {
- for (const item of clipboard.items) {
- if (item.kind === 'file' && item.type.startsWith('image/')) {
- push(item.getAsFile())
- }
- }
- }
-
- if (clipboard.files?.length) {
- for (let i = 0; i < clipboard.files.length; i += 1) {
- const file = clipboard.files.item(i)
-
- if (file && file.type.startsWith('image/')) {
- push(file)
- }
- }
- }
-
- if (blobs.length > 0) {
- return blobs
- }
-
- const text = clipboard.getData('text/plain').trim()
-
- if (DATA_IMAGE_URL_RE.test(text)) {
- push(dataUrlToBlob(text))
- }
-
- if (blobs.length === 0) {
- const html = clipboard.getData('text/html')
-
- if (html) {
- const matches = html.matchAll(/ ]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi)
-
- for (const match of matches) {
- push(dataUrlToBlob(match[1]))
- }
- }
- }
-
- return blobs
-}
-
const COMPOSER_STACK_BREAKPOINT_PX = 320
-const COMPOSER_GLASS = {
- fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))',
- liquidKey: ['standard', '0.950', '0.072', '0', '46', '0.00', '128'].join(':'),
- showLibraryRims: false,
- liquid: {
- aberrationIntensity: 0.95,
- blurAmount: 0.072,
- cornerRadius: 0,
- displacementScale: 46,
- elasticity: 0,
- mode: 'standard' as const,
- saturation: 128
- }
-}
-
-interface TriggerState {
- kind: '@' | '/'
- query: string
- tokenLength: number
-}
-
-const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
-
-/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */
-function textBeforeCaret(editor: HTMLDivElement): string | null {
- const sel = window.getSelection()
- const range = sel?.rangeCount ? sel.getRangeAt(0) : null
-
- if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {
- return null
- }
-
- const before = range.cloneRange()
- before.selectNodeContents(editor)
- before.setEnd(range.startContainer, range.startOffset)
-
- return before.toString()
-}
-
-function detectTrigger(textBefore: string): TriggerState | null {
- const match = TRIGGER_RE.exec(textBefore)
-
- if (!match) {
- return null
- }
-
- return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
-}
+const COMPOSER_FADE_BACKGROUND =
+ 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
export function ChatBar({
busy,
@@ -186,7 +82,6 @@ export function ChatBar({
const composerRef = useRef(null)
const composerSurfaceRef = useRef(null)
const editorRef = useRef(null)
- const glassShellRef = useRef(null)
const draftRef = useRef(draft)
const urlInputRef = useRef(null)
@@ -931,38 +826,9 @@ export function ChatBar({
-
-
-
-
-
{dragActive && (
@@ -1057,9 +921,7 @@ export function ChatBarFallback() {
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
- 'group-focus-within/composer:bg-[var(--dt-card)]',
- 'group-focus-within/composer:[backdrop-filter:none]',
- 'group-focus-within/composer:[-webkit-backdrop-filter:none]'
+ 'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
)}
/>
diff --git a/apps/desktop/src/app/chat/composer/liquid-glass-overrides.css b/apps/desktop/src/app/chat/composer/liquid-glass-overrides.css
deleted file mode 100644
index bb493a4789..0000000000
--- a/apps/desktop/src/app/chat/composer/liquid-glass-overrides.css
+++ /dev/null
@@ -1,82 +0,0 @@
-.composer-liquid-shell-wrap > div:not(.composer-liquid-shell) {
- position: absolute !important;
- inset: 0 !important;
- top: 0 !important;
- left: 0 !important;
- width: 100% !important;
- height: 100% !important;
- transform: none !important;
- margin: 0 !important;
- border-radius: inherit !important;
- box-sizing: border-box;
-}
-
-.composer-liquid-shell-wrap:not([data-show-library-rims='true']) > span {
- display: none !important;
-}
-
-.composer-liquid-shell-wrap[data-show-library-rims='true'] > span {
- position: absolute !important;
- inset: 0 !important;
- top: 0 !important;
- left: 0 !important;
- width: 100% !important;
- height: 100% !important;
- transform: none !important;
- margin: 0 !important;
- border-radius: inherit !important;
- box-sizing: border-box;
- display: block !important;
-}
-
-.composer-liquid-shell {
- z-index: 1;
- top: 0 !important;
- left: 0 !important;
- transform: none !important;
- border-radius: inherit !important;
- transition: none !important;
-}
-
-.composer-liquid-shell > svg {
- position: absolute !important;
- inset: 0 !important;
- top: 0 !important;
- left: 0 !important;
- width: 100% !important;
- height: 100% !important;
- transform: none !important;
- border-radius: inherit !important;
-}
-
-.composer-liquid-shell > .glass,
-.composer-liquid-shell > :not(svg):not(.glass) {
- position: absolute !important;
- inset: 0 !important;
- top: 0 !important;
- left: 0 !important;
- width: 100% !important;
- height: 100% !important;
- transform: none !important;
- border-radius: inherit !important;
-}
-
-.composer-liquid-shell > .glass {
- width: 100% !important;
- height: 100% !important;
- padding: 0 !important;
- border-radius: inherit !important;
- box-shadow: none !important;
-}
-
-.composer-liquid-shell > .glass > .glass__warp {
- border-radius: inherit !important;
-}
-
-.composer-liquid-shell > .glass > div {
- width: 100%;
- height: 100%;
- font: inherit !important;
- text-shadow: none !important;
- color: inherit !important;
-}
diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts
new file mode 100644
index 0000000000..5725883d83
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/text-utils.ts
@@ -0,0 +1,91 @@
+import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
+
+export interface TriggerState {
+ kind: '@' | '/'
+ query: string
+ tokenLength: number
+}
+
+const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
+
+export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
+ const blobs: Blob[] = []
+ const seen = new Set()
+
+ const push = (blob: Blob | null) => {
+ if (!blob || blob.size === 0 || seen.has(blob)) {
+ return
+ }
+
+ seen.add(blob)
+ blobs.push(blob)
+ }
+
+ if (clipboard.items?.length) {
+ for (const item of clipboard.items) {
+ if (item.kind === 'file' && item.type.startsWith('image/')) {
+ push(item.getAsFile())
+ }
+ }
+ }
+
+ if (clipboard.files?.length) {
+ for (let i = 0; i < clipboard.files.length; i += 1) {
+ const file = clipboard.files.item(i)
+
+ if (file && file.type.startsWith('image/')) {
+ push(file)
+ }
+ }
+ }
+
+ if (blobs.length > 0) {
+ return blobs
+ }
+
+ const text = clipboard.getData('text/plain').trim()
+
+ if (DATA_IMAGE_URL_RE.test(text)) {
+ push(dataUrlToBlob(text))
+ }
+
+ if (blobs.length === 0) {
+ const html = clipboard.getData('text/html')
+
+ if (html) {
+ const matches = html.matchAll(/ ]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi)
+
+ for (const match of matches) {
+ push(dataUrlToBlob(match[1]))
+ }
+ }
+ }
+
+ return blobs
+}
+
+/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */
+export function textBeforeCaret(editor: HTMLDivElement): string | null {
+ const sel = window.getSelection()
+ const range = sel?.rangeCount ? sel.getRangeAt(0) : null
+
+ if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {
+ return null
+ }
+
+ const before = range.cloneRange()
+ before.selectNodeContents(editor)
+ before.setEnd(range.startContainer, range.startOffset)
+
+ return before.toString()
+}
+
+export function detectTrigger(textBefore: string): TriggerState | null {
+ const match = TRIGGER_RE.exec(textBefore)
+
+ if (!match) {
+ return null
+ }
+
+ return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
+}
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 7336ccf4c8..0afed13a1a 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -2,8 +2,7 @@ import {
type AppendMessage,
AssistantRuntimeProvider,
ExportedMessageRepository,
- type ThreadMessage,
- useExternalStoreRuntime
+ type ThreadMessage
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
@@ -18,6 +17,7 @@ import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
import { ChevronDown } from '@/lib/icons'
+import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { cn } from '@/lib/utils'
import { $pinnedSessionIds } from '@/store/layout'
import {
@@ -70,6 +70,55 @@ interface ChatViewProps extends Omit, 'onSubmit'> {
onTranscribeAudio?: (audio: Blob) => Promise
}
+interface ChatHeaderProps {
+ activeSessionId: null | string
+ isRoutedSessionView: boolean
+ onDeleteSelectedSession: () => void
+ onToggleSelectedPin: () => void
+ selectedSessionId: null | string
+}
+
+function ChatHeader({
+ activeSessionId,
+ isRoutedSessionView,
+ onDeleteSelectedSession,
+ onToggleSelectedPin,
+ selectedSessionId
+}: ChatHeaderProps) {
+ const sessions = useStore($sessions)
+ const pinnedSessionIds = useStore($pinnedSessionIds)
+ const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
+ const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
+ const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
+
+ return (
+
+ )
+}
+
export function ChatView({
className,
gateway,
@@ -107,13 +156,9 @@ export function ChatView({
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
const messages = useStore($messages)
- const pinnedSessionIds = useStore($pinnedSessionIds)
const selectedSessionId = useStore($selectedStoredSessionId)
- const sessions = useStore($sessions)
const runtimeMessageCacheRef = useRef(new WeakMap())
- const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
- const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
const showIntro =
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
@@ -127,7 +172,6 @@ export function ChatView({
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages))
const showChatBar = !loadingSession
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
- const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
const modelOptionsQuery = useQuery({
queryKey: ['model-options', activeSessionId || 'global'],
@@ -207,7 +251,7 @@ export function ChatView({
return ExportedMessageRepository.fromBranchableArray(items, { headId })
}, [messages])
- const runtime = useExternalStoreRuntime({
+ const runtime = useIncrementalExternalStoreRuntime({
messageRepository: runtimeMessageRepository,
isRunning: busy,
setMessages: onThreadMessagesChange,
@@ -227,30 +271,13 @@ export function ChatView({
className
)}
>
-
+
diff --git a/apps/desktop/src/app/chat/right-rail/preview-console.tsx b/apps/desktop/src/app/chat/right-rail/preview-console.tsx
new file mode 100644
index 0000000000..22841aaa23
--- /dev/null
+++ b/apps/desktop/src/app/chat/right-rail/preview-console.tsx
@@ -0,0 +1,288 @@
+import { useStore } from '@nanostores/react'
+import type { CSSProperties, MutableRefObject, PointerEvent as ReactPointerEvent, RefObject } from 'react'
+import { useEffect, useMemo, useRef } from 'react'
+
+import { CopyButton } from '@/components/ui/copy-button'
+import { PanelBottom, Send, Trash2 } from '@/lib/icons'
+import { cn } from '@/lib/utils'
+import { $composerDraft, setComposerDraft } from '@/store/composer'
+import { notify } from '@/store/notifications'
+
+import type { ConsoleEntry, PreviewConsoleState } from './preview-console-state'
+
+const consoleLevelLabel: Record = {
+ 0: 'log',
+ 1: 'info',
+ 2: 'warn',
+ 3: 'error'
+}
+
+const consoleLevelClass: Record = {
+ 0: 'text-foreground',
+ 1: 'text-sky-700 dark:text-sky-300',
+ 2: 'text-amber-700 dark:text-amber-300',
+ 3: 'text-destructive'
+}
+
+const CONSOLE_BOTTOM_THRESHOLD = 24
+const CONSOLE_HEADER_HEIGHT = 32
+
+export function compactUrl(value: string): string {
+ try {
+ const url = new URL(value)
+
+ if (url.protocol === 'file:') {
+ return decodeURIComponent(url.pathname)
+ }
+
+ return `${url.host}${url.pathname}${url.search}`
+ } catch {
+ return value
+ }
+}
+
+export function formatLogLine(log: ConsoleEntry): string {
+ const head = `[${consoleLevelLabel[log.level] || 'log'}]`
+ const tail = log.source ? ` (${compactUrl(log.source)}${log.line ? `:${log.line}` : ''})` : ''
+
+ return `${head} ${log.message}${tail}`.trim()
+}
+
+export function formatConsoleEntries(entries: ConsoleEntry[]): string {
+ return entries.map(formatLogLine).join('\n')
+}
+
+export function isNearConsoleBottom(element: HTMLDivElement | null): boolean {
+ if (!element) {
+ return true
+ }
+
+ return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD
+}
+
+export function clampConsoleHeight(value: number): number {
+ return Math.max(value, CONSOLE_HEADER_HEIGHT)
+}
+
+interface ConsoleRowProps {
+ copyText: string
+ log: ConsoleEntry
+ onSend: () => void
+ onToggleSelect: () => void
+ selected: boolean
+}
+
+function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) {
+ return (
+
+
+ {consoleLevelLabel[log.level] || 'log'}
+
+
+
+ {log.message}
+
+ {log.source && (
+
+ {compactUrl(log.source)}
+ {log.line ? `:${log.line}` : ''}
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
+
+export function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) {
+ const logCount = useStore(consoleState.$logCount)
+
+ return (
+ <>
+
+ {logCount > 0 && {logCount} console messages }
+ >
+ )
+}
+
+interface PreviewConsolePanelProps {
+ consoleBodyRef: RefObject
+ consoleShouldStickRef: MutableRefObject
+ consoleState: PreviewConsoleState
+ startConsoleResize: (event: ReactPointerEvent) => void
+}
+
+export function PreviewConsolePanel({
+ consoleBodyRef,
+ consoleShouldStickRef,
+ consoleState,
+ startConsoleResize
+}: PreviewConsolePanelProps) {
+ const consoleHeight = useStore(consoleState.$height)
+ const logs = useStore(consoleState.$logs)
+ const selectedLogIds = useStore(consoleState.$selectedLogIds)
+ const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
+ const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
+ const stickScrollRafRef = useRef(null)
+
+ useEffect(() => {
+ if (!consoleShouldStickRef.current) {
+ return
+ }
+
+ if (stickScrollRafRef.current !== null) {
+ window.cancelAnimationFrame(stickScrollRafRef.current)
+ stickScrollRafRef.current = null
+ }
+
+ stickScrollRafRef.current = window.requestAnimationFrame(() => {
+ stickScrollRafRef.current = null
+ const consoleBody = consoleBodyRef.current
+ consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
+ })
+
+ return () => {
+ if (stickScrollRafRef.current !== null) {
+ window.cancelAnimationFrame(stickScrollRafRef.current)
+ stickScrollRafRef.current = null
+ }
+ }
+ }, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs])
+
+ function sendLogsToComposer(entries: ConsoleEntry[]) {
+ if (!entries.length) {
+ return
+ }
+
+ const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
+ const draft = $composerDraft.get()
+ const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}`
+
+ setComposerDraft(next)
+ consoleState.clearSelection()
+ notify({
+ kind: 'success',
+ title: 'Sent to chat',
+ message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer`
+ })
+ }
+
+ return (
+
+
consoleState.setHeight(CONSOLE_HEADER_HEIGHT)}
+ onPointerDown={startConsoleResize}
+ role="separator"
+ >
+
+
+
+
+
+ Preview Console
+ {selectedLogIds.size > 0 && (
+
+ {selectedLogIds.size} selected
+
+ )}
+
+
+ sendLogsToComposer(sendableLogs)}
+ title={
+ visibleSelection.length > 0
+ ? `Send ${visibleSelection.length} selected to chat`
+ : 'Send all log entries to chat'
+ }
+ type="button"
+ >
+
+ Send to chat
+
+ 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
+ text={() => formatConsoleEntries(sendableLogs)}
+ >
+ Copy
+
+
+
+ Clear
+
+
+
+
+ {logs.length > 0 ? (
+ logs.map(log => {
+ const selected = selectedLogIds.has(log.id)
+
+ return (
+
sendLogsToComposer([log])}
+ onToggleSelect={() => consoleState.toggleSelection(log.id)}
+ selected={selected}
+ />
+ )
+ })
+ ) : (
+ No console messages yet.
+ )}
+
+
+ )
+}
diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx
new file mode 100644
index 0000000000..7a650da4fa
--- /dev/null
+++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx
@@ -0,0 +1,553 @@
+import type * as React from 'react'
+import type {
+ ComponentProps,
+ CSSProperties,
+ DragEvent as ReactDragEvent,
+ MouseEvent as ReactMouseEvent,
+ ReactNode
+} from 'react'
+import { useEffect, useMemo, useState } from 'react'
+import ShikiHighlighter from 'react-shiki'
+import { Streamdown } from 'streamdown'
+
+import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
+import { cn } from '@/lib/utils'
+import type { PreviewTarget } from '@/store/preview'
+
+const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
+const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
+
+type EmptyStateTone = 'neutral' | 'warning'
+
+const TONE_STYLES: Record = {
+ neutral: {
+ cube: 'text-muted-foreground/35',
+ primary: 'border-border bg-background text-foreground hover:bg-accent'
+ },
+ warning: {
+ cube: 'text-amber-500/70 dark:text-amber-300/70',
+ primary:
+ 'border-amber-400/40 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-300/30 dark:bg-amber-300/15 dark:text-amber-100 dark:hover:bg-amber-300/20'
+ }
+}
+
+function PreviewCubeIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+ )
+}
+
+interface PreviewEmptyStateProps {
+ body?: ReactNode
+ consoleHeight?: number
+ primaryAction?: { disabled?: boolean; label: string; onClick: () => void }
+ secondaryAction?: { disabled?: boolean; label: string; onClick: () => void }
+ title: string
+ tone?: EmptyStateTone
+}
+
+export function PreviewEmptyState({
+ body,
+ consoleHeight = 0,
+ primaryAction,
+ secondaryAction,
+ title,
+ tone = 'neutral'
+}: PreviewEmptyStateProps) {
+ const styles = TONE_STYLES[tone]
+
+ return (
+
+
+
+
+
{title}
+ {body &&
{body}
}
+
+ {(primaryAction || secondaryAction) && (
+
+ {primaryAction && (
+
+ {primaryAction.label}
+
+ )}
+ {secondaryAction && (
+
+ {secondaryAction.label}
+
+ )}
+
+ )}
+
+
+ )
+}
+
+interface LocalPreviewState {
+ binary?: boolean
+ byteSize?: number
+ dataUrl?: string
+ error?: string
+ language?: string
+ loading: boolean
+ text?: string
+ truncated?: boolean
+}
+
+function filePathForTarget(target: PreviewTarget) {
+ if (target.path) {
+ return target.path
+ }
+
+ try {
+ const url = new URL(target.url)
+
+ return url.protocol === 'file:' ? decodeURIComponent(url.pathname) : target.url
+ } catch {
+ return target.url
+ }
+}
+
+function formatBytes(bytes: number | undefined) {
+ if (!bytes) {
+ return 'unknown size'
+ }
+
+ const units = ['B', 'KB', 'MB', 'GB']
+ let value = bytes
+ let unit = 0
+
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024
+ unit += 1
+ }
+
+ return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`
+}
+
+function looksBinaryBytes(bytes: Uint8Array) {
+ if (!bytes.length) {
+ return false
+ }
+
+ let suspicious = 0
+
+ for (const byte of bytes.slice(0, 4096)) {
+ if (byte === 0) {
+ return true
+ }
+
+ if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
+ suspicious += 1
+ }
+ }
+
+ return suspicious / Math.min(bytes.length, 4096) > 0.12
+}
+
+async function readTextPreview(filePath: string) {
+ 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
+ }
+ }
+ }
+
+ // Back-compat for a running Electron process whose preload hasn't been
+ // restarted since readFileText was added. readFileDataUrl already existed.
+ const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
+ const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
+ const base64 = metadata.includes(';base64')
+ const mimeType = metadata.replace(/;base64$/, '') || undefined
+ const raw = base64 ? atob(data) : decodeURIComponent(data)
+ const bytes = Uint8Array.from(raw, ch => ch.charCodeAt(0))
+
+ return {
+ binary: looksBinaryBytes(bytes),
+ byteSize: bytes.byteLength,
+ mimeType,
+ path: filePath,
+ text: new TextDecoder().decode(bytes)
+ }
+}
+
+// Lightweight markdown renderer for file previews. Streamdown does the parse;
+// our components keep typography simple and route fenced code through Shiki
+// without the library's copy/download/fullscreen chrome.
+const MD_TAG_CLASSES = {
+ h1: 'mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0',
+ h2: 'mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0',
+ h3: 'mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0',
+ h4: 'mb-2 mt-3 text-base font-semibold leading-snug first:mt-0',
+ p: 'mb-4 leading-relaxed text-foreground last:mb-0',
+ ul: 'mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0',
+ ol: 'mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0',
+ li: 'mt-1 leading-relaxed',
+ blockquote: 'mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0',
+ pre: 'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono'
+} as const
+
+function tagged(Tag: T) {
+ const base = MD_TAG_CLASSES[Tag]
+
+ const Component = (({ className, ...rest }: ComponentProps) => {
+ const Element = Tag as React.ElementType
+
+ return
+ }) as React.FC>
+
+ Component.displayName = `Md.${Tag}`
+
+ return Component
+}
+
+function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) {
+ const language = /language-([^\s]+)/.exec(className || '')?.[1]
+
+ if (!language) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+ {String(children).replace(/\n$/, '')}
+
+ )
+}
+
+const MARKDOWN_COMPONENTS = {
+ h1: tagged('h1'),
+ h2: tagged('h2'),
+ h3: tagged('h3'),
+ h4: tagged('h4'),
+ p: tagged('p'),
+ ul: tagged('ul'),
+ ol: tagged('ol'),
+ li: tagged('li'),
+ blockquote: tagged('blockquote'),
+ pre: tagged('pre'),
+ code: MarkdownCode
+}
+
+function MarkdownPreview({ text }: { text: string }) {
+ return (
+
+
+ {text}
+
+
+ )
+}
+
+function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
+ return (
+
+
+ {asSource ? 'PREVIEW' : 'SOURCE'}
+
+
+ )
+}
+
+// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
+// each line aligns vertically. The selection overlay relies on the same
+// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
+const SOURCE_LINE_HEIGHT_REM = 1.21875
+const SOURCE_PAD_Y_REM = 0.75
+
+interface LineSelection {
+ end: number
+ start: number
+}
+
+function startLineDrag(event: ReactDragEvent, filePath: string, { end, start }: LineSelection) {
+ const lineEnd = end > start ? end : undefined
+ const label = lineEnd ? `${filePath}:${start}-${end}` : `${filePath}:${start}`
+
+ event.dataTransfer.setData(HERMES_PATHS_MIME, JSON.stringify([{ line: start, lineEnd, path: filePath }]))
+ event.dataTransfer.setData('text/plain', label)
+ event.dataTransfer.effectAllowed = 'copy'
+}
+
+function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
+ const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
+ const [selection, setSelection] = useState(null)
+ const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
+
+ const handleLineClick = (event: ReactMouseEvent, line: number) => {
+ if (event.shiftKey && selection) {
+ setSelection({ end: Math.max(selection.end, line), start: Math.min(selection.start, line) })
+
+ return
+ }
+
+ if (selection?.start === line && selection.end === line) {
+ setSelection(null)
+
+ return
+ }
+
+ setSelection({ end: line, start: line })
+ }
+
+ const handleDragStart = (event: ReactDragEvent, line: number) => {
+ startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
+ }
+
+ return (
+
+
+ {Array.from({ length: lineCount }, (_, index) => {
+ const line = index + 1
+ const selected = inSelection(line)
+
+ return (
+
handleLineClick(event, line)}
+ onDragStart={event => handleDragStart(event, line)}
+ title="Click to select · shift-click to extend · drag to composer"
+ >
+ {line}
+
+ )
+ })}
+
+
+ {selection && (
+
+ )}
+
+ {text}
+
+
+
+ )
+}
+
+export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
+ const [state, setState] = useState({ loading: true })
+ const [forcePreview, setForcePreview] = useState(false)
+ const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
+ const filePath = filePathForTarget(target)
+ const isImage = target.previewKind === 'image'
+
+ // HTML files are rendered as source code, not in a webview - so they take
+ // the same path as plain text files. `previewKind === 'binary'` arrives
+ // when the file is forcibly previewed past the binary refusal screen.
+ const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html'
+
+ const blockedByTarget = !isImage && !forcePreview && (target.binary || target.large)
+
+ useEffect(() => {
+ let active = true
+
+ async function load() {
+ if (blockedByTarget) {
+ setState({ loading: false })
+
+ return
+ }
+
+ if (!isImage && !isText) {
+ setState({ loading: false })
+
+ return
+ }
+
+ setState({ loading: true })
+
+ try {
+ if (isImage) {
+ const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
+
+ if (active) {
+ setState({ dataUrl, loading: false })
+ }
+
+ return
+ }
+
+ const result = await readTextPreview(filePath)
+
+ if (active) {
+ const shouldBlock = !forcePreview && (result.binary || (result.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
+
+ setState({
+ binary: result.binary,
+ byteSize: result.byteSize,
+ language: result.language || target.language || 'text',
+ loading: false,
+ text: shouldBlock ? undefined : result.text,
+ truncated: result.truncated
+ })
+ }
+ } catch (error) {
+ if (active) {
+ setState({
+ error: error instanceof Error ? error.message : String(error),
+ loading: false
+ })
+ }
+ }
+ }
+
+ void load()
+
+ return () => {
+ active = false
+ }
+ }, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
+
+ if (state.loading) {
+ return Loading preview…
+ }
+
+ if (state.error) {
+ return
+ }
+
+ if (
+ !isImage &&
+ !forcePreview &&
+ (target.binary || target.large || state.binary || (state.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
+ ) {
+ const binary = target.binary || state.binary
+ const size = target.byteSize || state.byteSize
+
+ return (
+ setForcePreview(true) }}
+ title={binary ? 'This looks like a binary file' : 'This file is large'}
+ tone="warning"
+ />
+ )
+ }
+
+ if (isImage && state.dataUrl) {
+ return (
+
+
+
+ )
+ }
+
+ if (isText && state.text !== undefined) {
+ const isMarkdown = (state.language || target.language) === 'markdown'
+ const showRendered = isMarkdown && !renderMarkdownAsSource
+
+ return (
+
+ {state.truncated && (
+
+ Showing first 512 KB.
+
+ )}
+ {isMarkdown &&
setRenderMarkdownAsSource(s => !s)} />}
+ {showRendered ? (
+
+ ) : (
+
+ )}
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx
index 889f3df557..dee253b759 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx
@@ -1,30 +1,23 @@
import { useStore } from '@nanostores/react'
-import type {
- ComponentProps,
- CSSProperties,
- MutableRefObject,
- DragEvent as ReactDragEvent,
- MouseEvent as ReactMouseEvent,
- ReactNode,
- PointerEvent as ReactPointerEvent,
- RefObject
-} from 'react'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import ShikiHighlighter from 'react-shiki'
-import { Streamdown } from 'streamdown'
+import type { PointerEvent as ReactPointerEvent } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
-import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
-import { CopyButton } from '@/components/ui/copy-button'
-import { Bug, PanelBottom, RefreshCw, Send, Trash2, X } from '@/lib/icons'
+import { Bug, RefreshCw, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
-import { $composerDraft, setComposerDraft } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview'
-import { type ConsoleEntry, createPreviewConsoleState, type PreviewConsoleState } from './preview-console-state'
-
-const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
+import {
+ clampConsoleHeight,
+ compactUrl,
+ formatLogLine,
+ isNearConsoleBottom,
+ PreviewConsolePanel,
+ PreviewConsoleTitlebarIcon
+} from './preview-console'
+import { type ConsoleEntry, createPreviewConsoleState } from './preview-console-state'
+import { LocalFilePreview, PreviewEmptyState } from './preview-file'
type PreviewWebview = HTMLElement & {
closeDevTools?: () => void
@@ -50,62 +43,8 @@ interface PreviewLoadErrorState {
url: string
}
-const consoleLevelLabel: Record = {
- 0: 'log',
- 1: 'info',
- 2: 'warn',
- 3: 'error'
-}
-
-const consoleLevelClass: Record = {
- 0: 'text-foreground',
- 1: 'text-sky-700 dark:text-sky-300',
- 2: 'text-amber-700 dark:text-amber-300',
- 3: 'text-destructive'
-}
-
-const CONSOLE_BOTTOM_THRESHOLD = 24
-const CONSOLE_HEADER_HEIGHT = 32
const FILE_RELOAD_DEBOUNCE_MS = 200
const SERVER_RESTART_TIMEOUT_MS = 45_000
-const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
-
-function compactUrl(value: string): string {
- try {
- const url = new URL(value)
-
- if (url.protocol === 'file:') {
- return decodeURIComponent(url.pathname)
- }
-
- return `${url.host}${url.pathname}${url.search}`
- } catch {
- return value
- }
-}
-
-function formatLogLine(log: ConsoleEntry): string {
- const head = `[${consoleLevelLabel[log.level] || 'log'}]`
- const tail = log.source ? ` (${compactUrl(log.source)}${log.line ? `:${log.line}` : ''})` : ''
-
- return `${head} ${log.message}${tail}`.trim()
-}
-
-function formatConsoleEntries(entries: ConsoleEntry[]): string {
- return entries.map(formatLogLine).join('\n')
-}
-
-function isNearConsoleBottom(element: HTMLDivElement | null): boolean {
- if (!element) {
- return true
- }
-
- return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD
-}
-
-function clampConsoleHeight(value: number): number {
- return Math.max(value, CONSOLE_HEADER_HEIGHT)
-}
function loadErrorTitle(error: PreviewLoadErrorState): string {
const description = error.description.toLowerCase()
@@ -127,176 +66,6 @@ function isModuleMimeError(message: string): boolean {
return lower.includes('failed to load module script') && lower.includes('mime type')
}
-interface ConsoleRowProps {
- copyText: string
- log: ConsoleEntry
- onSend: () => void
- onToggleSelect: () => void
- selected: boolean
-}
-
-function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) {
- return (
-
-
- {consoleLevelLabel[log.level] || 'log'}
-
-
-
- {log.message}
-
- {log.source && (
-
- {compactUrl(log.source)}
- {log.line ? `:${log.line}` : ''}
-
- )}
-
-
-
-
-
-
-
-
- )
-}
-
-function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) {
- const logCount = useStore(consoleState.$logCount)
-
- return (
- <>
-
- {logCount > 0 && {logCount} console messages }
- >
- )
-}
-
-type EmptyStateTone = 'neutral' | 'warning'
-
-const TONE_STYLES: Record = {
- neutral: {
- cube: 'text-muted-foreground/35',
- primary: 'border-border bg-background text-foreground hover:bg-accent'
- },
- warning: {
- cube: 'text-amber-500/70 dark:text-amber-300/70',
- primary:
- 'border-amber-400/40 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-300/30 dark:bg-amber-300/15 dark:text-amber-100 dark:hover:bg-amber-300/20'
- }
-}
-
-function PreviewCubeIcon({ className }: { className?: string }) {
- return (
-
-
-
-
-
- )
-}
-
-interface PreviewEmptyStateProps {
- body?: ReactNode
- consoleHeight?: number
- primaryAction?: { disabled?: boolean; label: string; onClick: () => void }
- secondaryAction?: { disabled?: boolean; label: string; onClick: () => void }
- title: string
- tone?: EmptyStateTone
-}
-
-function PreviewEmptyState({
- body,
- consoleHeight = 0,
- primaryAction,
- secondaryAction,
- title,
- tone = 'neutral'
-}: PreviewEmptyStateProps) {
- const styles = TONE_STYLES[tone]
-
- return (
-
-
-
-
-
{title}
- {body &&
{body}
}
-
- {(primaryAction || secondaryAction) && (
-
- {primaryAction && (
-
- {primaryAction.label}
-
- )}
- {secondaryAction && (
-
- {secondaryAction.label}
-
- )}
-
- )}
-
-
- )
-}
-
function PreviewLoadError({
consoleHeight = 0,
error,
@@ -344,592 +113,6 @@ function PreviewLoadError({
)
}
-function PreviewConsolePanel({
- consoleBodyRef,
- consoleShouldStickRef,
- consoleState,
- startConsoleResize
-}: {
- consoleBodyRef: RefObject
- consoleShouldStickRef: MutableRefObject
- consoleState: PreviewConsoleState
- startConsoleResize: (event: ReactPointerEvent) => void
-}) {
- const consoleHeight = useStore(consoleState.$height)
- const logs = useStore(consoleState.$logs)
- const selectedLogIds = useStore(consoleState.$selectedLogIds)
- const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
- const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
- const stickScrollRafRef = useRef(null)
-
- useEffect(() => {
- if (!consoleShouldStickRef.current) {
- return
- }
-
- if (stickScrollRafRef.current !== null) {
- window.cancelAnimationFrame(stickScrollRafRef.current)
- stickScrollRafRef.current = null
- }
-
- stickScrollRafRef.current = window.requestAnimationFrame(() => {
- stickScrollRafRef.current = null
- const consoleBody = consoleBodyRef.current
- consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
- })
-
- return () => {
- if (stickScrollRafRef.current !== null) {
- window.cancelAnimationFrame(stickScrollRafRef.current)
- stickScrollRafRef.current = null
- }
- }
- }, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs])
-
- function sendLogsToComposer(entries: ConsoleEntry[]) {
- if (!entries.length) {
- return
- }
-
- const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
- const draft = $composerDraft.get()
- const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}`
-
- setComposerDraft(next)
- consoleState.clearSelection()
- notify({
- kind: 'success',
- title: 'Sent to chat',
- message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer`
- })
- }
-
- return (
-
-
consoleState.setHeight(CONSOLE_HEADER_HEIGHT)}
- onPointerDown={startConsoleResize}
- role="separator"
- >
-
-
-
-
-
- Preview Console
- {selectedLogIds.size > 0 && (
-
- {selectedLogIds.size} selected
-
- )}
-
-
- sendLogsToComposer(sendableLogs)}
- title={
- visibleSelection.length > 0
- ? `Send ${visibleSelection.length} selected to chat`
- : 'Send all log entries to chat'
- }
- type="button"
- >
-
- Send to chat
-
- 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
- text={() => formatConsoleEntries(sendableLogs)}
- >
- Copy
-
-
-
- Clear
-
-
-
-
- {logs.length > 0 ? (
- logs.map(log => {
- const selected = selectedLogIds.has(log.id)
-
- return (
-
sendLogsToComposer([log])}
- onToggleSelect={() => consoleState.toggleSelection(log.id)}
- selected={selected}
- />
- )
- })
- ) : (
- No console messages yet.
- )}
-
-
- )
-}
-
-interface LocalPreviewState {
- binary?: boolean
- byteSize?: number
- dataUrl?: string
- error?: string
- language?: string
- loading: boolean
- text?: string
- truncated?: boolean
-}
-
-function filePathForTarget(target: PreviewTarget) {
- if (target.path) {
- return target.path
- }
-
- try {
- const url = new URL(target.url)
-
- return url.protocol === 'file:' ? decodeURIComponent(url.pathname) : target.url
- } catch {
- return target.url
- }
-}
-
-function formatBytes(bytes: number | undefined) {
- if (!bytes) {
- return 'unknown size'
- }
-
- const units = ['B', 'KB', 'MB', 'GB']
- let value = bytes
- let unit = 0
-
- while (value >= 1024 && unit < units.length - 1) {
- value /= 1024
- unit += 1
- }
-
- return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`
-}
-
-function looksBinaryBytes(bytes: Uint8Array) {
- if (!bytes.length) {
- return false
- }
-
- let suspicious = 0
-
- for (const byte of bytes.slice(0, 4096)) {
- if (byte === 0) {
- return true
- }
-
- if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
- suspicious += 1
- }
- }
-
- return suspicious / Math.min(bytes.length, 4096) > 0.12
-}
-
-async function readTextPreview(filePath: string) {
- 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
- }
- }
- }
-
- // Back-compat for a running Electron process whose preload hasn't been
- // restarted since readFileText was added. readFileDataUrl already existed.
- const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
- const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
- const base64 = metadata.includes(';base64')
- const mimeType = metadata.replace(/;base64$/, '') || undefined
- const raw = base64 ? atob(data) : decodeURIComponent(data)
- const bytes = Uint8Array.from(raw, ch => ch.charCodeAt(0))
-
- return {
- binary: looksBinaryBytes(bytes),
- byteSize: bytes.byteLength,
- mimeType,
- path: filePath,
- text: new TextDecoder().decode(bytes)
- }
-}
-
-// Lightweight markdown renderer for file previews. Streamdown does the parse;
-// our components keep typography simple and route fenced code through Shiki
-// without the library's copy/download/fullscreen chrome.
-const MD_TAG_CLASSES = {
- h1: 'mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0',
- h2: 'mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0',
- h3: 'mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0',
- h4: 'mb-2 mt-3 text-base font-semibold leading-snug first:mt-0',
- p: 'mb-4 leading-relaxed text-foreground last:mb-0',
- ul: 'mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0',
- ol: 'mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0',
- li: 'mt-1 leading-relaxed',
- blockquote: 'mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0',
- pre: 'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono'
-} as const
-
-function tagged(Tag: T) {
- const base = MD_TAG_CLASSES[Tag]
-
- const Component = (({ className, ...rest }: ComponentProps) => {
- const Element = Tag as React.ElementType
-
- return
- }) as React.FC>
-
- Component.displayName = `Md.${Tag}`
-
- return Component
-}
-
-function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) {
- const language = /language-([^\s]+)/.exec(className || '')?.[1]
-
- if (!language) {
- return (
-
- {children}
-
- )
- }
-
- return (
-
- {String(children).replace(/\n$/, '')}
-
- )
-}
-
-const MARKDOWN_COMPONENTS = {
- h1: tagged('h1'),
- h2: tagged('h2'),
- h3: tagged('h3'),
- h4: tagged('h4'),
- p: tagged('p'),
- ul: tagged('ul'),
- ol: tagged('ol'),
- li: tagged('li'),
- blockquote: tagged('blockquote'),
- pre: tagged('pre'),
- code: MarkdownCode
-}
-
-function MarkdownPreview({ text }: { text: string }) {
- return (
-
-
- {text}
-
-
- )
-}
-
-function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
- return (
-
-
- {asSource ? 'PREVIEW' : 'SOURCE'}
-
-
- )
-}
-
-// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
-// each line aligns vertically. The selection overlay relies on the same
-// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
-const SOURCE_LINE_HEIGHT_REM = 1.21875
-const SOURCE_PAD_Y_REM = 0.75
-
-interface LineSelection {
- end: number
- start: number
-}
-
-function startLineDrag(event: ReactDragEvent, filePath: string, { end, start }: LineSelection) {
- const lineEnd = end > start ? end : undefined
- const label = lineEnd ? `${filePath}:${start}-${end}` : `${filePath}:${start}`
-
- event.dataTransfer.setData(HERMES_PATHS_MIME, JSON.stringify([{ line: start, lineEnd, path: filePath }]))
- event.dataTransfer.setData('text/plain', label)
- event.dataTransfer.effectAllowed = 'copy'
-}
-
-function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
- const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
- const [selection, setSelection] = useState(null)
- const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
-
- const handleLineClick = (event: ReactMouseEvent, line: number) => {
- if (event.shiftKey && selection) {
- setSelection({ end: Math.max(selection.end, line), start: Math.min(selection.start, line) })
-
- return
- }
-
- if (selection?.start === line && selection.end === line) {
- setSelection(null)
-
- return
- }
-
- setSelection({ end: line, start: line })
- }
-
- const handleDragStart = (event: ReactDragEvent, line: number) => {
- startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
- }
-
- return (
-
-
- {Array.from({ length: lineCount }, (_, index) => {
- const line = index + 1
- const selected = inSelection(line)
-
- return (
-
handleLineClick(event, line)}
- onDragStart={event => handleDragStart(event, line)}
- title="Click to select · shift-click to extend · drag to composer"
- >
- {line}
-
- )
- })}
-
-
- {selection && (
-
- )}
-
- {text}
-
-
-
- )
-}
-
-function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
- const [state, setState] = useState({ loading: true })
- const [forcePreview, setForcePreview] = useState(false)
- const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
- const filePath = filePathForTarget(target)
- const isImage = target.previewKind === 'image'
-
- // HTML files are rendered as source code, not in a webview — so they take
- // the same path as plain text files. `previewKind === 'binary'` arrives
- // when the file is forcibly previewed past the binary refusal screen.
- const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html'
-
- const blockedByTarget = !isImage && !forcePreview && (target.binary || target.large)
-
- useEffect(() => {
- let active = true
-
- async function load() {
- if (blockedByTarget) {
- setState({ loading: false })
-
- return
- }
-
- if (!isImage && !isText) {
- setState({ loading: false })
-
- return
- }
-
- setState({ loading: true })
-
- try {
- if (isImage) {
- const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
-
- if (active) {
- setState({ dataUrl, loading: false })
- }
-
- return
- }
-
- const result = await readTextPreview(filePath)
-
- if (active) {
- const shouldBlock = !forcePreview && (result.binary || (result.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
-
- setState({
- binary: result.binary,
- byteSize: result.byteSize,
- language: result.language || target.language || 'text',
- loading: false,
- text: shouldBlock ? undefined : result.text,
- truncated: result.truncated
- })
- }
- } catch (error) {
- if (active) {
- setState({
- error: error instanceof Error ? error.message : String(error),
- loading: false
- })
- }
- }
- }
-
- void load()
-
- return () => {
- active = false
- }
- }, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
-
- if (state.loading) {
- return Loading preview…
- }
-
- if (state.error) {
- return
- }
-
- if (
- !isImage &&
- !forcePreview &&
- (target.binary || target.large || state.binary || (state.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
- ) {
- const binary = target.binary || state.binary
- const size = target.byteSize || state.byteSize
-
- return (
- setForcePreview(true) }}
- title={binary ? 'This looks like a binary file' : 'This file is large'}
- tone="warning"
- />
- )
- }
-
- if (isImage && state.dataUrl) {
- return (
-
-
-
- )
- }
-
- if (isText && state.text !== undefined) {
- const isMarkdown = (state.language || target.language) === 'markdown'
- const showRendered = isMarkdown && !renderMarkdownAsSource
-
- return (
-
- {state.truncated && (
-
- Showing first 512 KB.
-
- )}
- {isMarkdown &&
setRenderMarkdownAsSource(s => !s)} />}
- {showRendered ? (
-
- ) : (
-
- )}
-
- )
- }
-
- return (
-
- )
-}
-
const TITLEBAR_GROUP_ID = 'preview'
export function PreviewPane({
diff --git a/apps/desktop/src/app/chat/thread-loading.ts b/apps/desktop/src/app/chat/thread-loading.ts
index 8f97f7c053..97686c6550 100644
--- a/apps/desktop/src/app/chat/thread-loading.ts
+++ b/apps/desktop/src/app/chat/thread-loading.ts
@@ -1,5 +1,7 @@
import type { ChatMessage } from '@/lib/chat-messages'
+export type ThreadLoadingState = 'response' | 'session'
+
export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean {
const lastVisible = [...messages].reverse().find(message => !message.hidden)
@@ -11,7 +13,7 @@ export function threadLoadingState(
busy: boolean,
awaitingResponse: boolean,
lastVisibleIsUser: boolean
-) {
+): ThreadLoadingState | undefined {
if (loadingSession) {
return 'session'
}
diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx
index bc40711616..a80d5ad06b 100644
--- a/apps/desktop/src/app/command-center/index.tsx
+++ b/apps/desktop/src/app/command-center/index.tsx
@@ -89,7 +89,12 @@ const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New chat', detail: 'Start a fresh session' },
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
- { id: 'nav-messaging', route: MESSAGING_ROUTE, title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' },
+ {
+ id: 'nav-messaging',
+ route: MESSAGING_ROUTE,
+ title: 'Messaging',
+ detail: 'Set up Telegram, Slack, Discord, and more'
+ },
{ id: 'nav-artifacts', route: ARTIFACTS_ROUTE, title: 'Artifacts', detail: 'Browse generated outputs' }
]
diff --git a/apps/desktop/src/app/file-browser/index.tsx b/apps/desktop/src/app/file-browser/index.tsx
index 6fdeae01ec..42e27c8efb 100644
--- a/apps/desktop/src/app/file-browser/index.tsx
+++ b/apps/desktop/src/app/file-browser/index.tsx
@@ -10,6 +10,7 @@ import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
+
import { ProjectTree } from './tree'
import { useProjectTree } from './use-project-tree'
diff --git a/apps/desktop/src/app/hooks/use-route-enum-param.ts b/apps/desktop/src/app/hooks/use-route-enum-param.ts
index 47f505de7e..24de1dfe0f 100644
--- a/apps/desktop/src/app/hooks/use-route-enum-param.ts
+++ b/apps/desktop/src/app/hooks/use-route-enum-param.ts
@@ -22,8 +22,11 @@ export function useRouteEnumParam(
(next: T) => {
const params = new URLSearchParams(search)
- if (next === fallback) {params.delete(key)}
- else {params.set(key, next)}
+ if (next === fallback) {
+ params.delete(key)
+ } else {
+ params.set(key, next)
+ }
const qs = params.toString()
navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true })
diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx
index 5841e7d1de..b05df9783e 100644
--- a/apps/desktop/src/app/messaging/index.tsx
+++ b/apps/desktop/src/app/messaging/index.tsx
@@ -78,11 +78,17 @@ const HINT_BY_STATE: Record = {
const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown')
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
- if (!enabled) {return 'muted'}
+ if (!enabled) {
+ return 'muted'
+ }
- if (state === 'connected') {return 'good'}
+ if (state === 'connected') {
+ return 'good'
+ }
- if (state === 'fatal' || state === 'startup_failed') {return 'bad'}
+ if (state === 'fatal' || state === 'startup_failed') {
+ return 'bad'
+ }
return 'warn'
}
@@ -511,9 +517,7 @@ function PlatformDetail({
Get your credentials
-
- {introCopy(platform)}
-
+ {introCopy(platform)}
@@ -572,9 +576,7 @@ function PlatformDetail({
type="button"
>
Advanced ({hiddenCount})
-
+
{showAdvanced && (
@@ -632,7 +634,8 @@ const PLATFORM_INTRO: Record
= {
mattermost:
'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.',
matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.',
- signal: 'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.',
+ signal:
+ 'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.',
whatsapp:
'Start the WhatsApp bridge that ships with Hermes, scan the QR code on first run, then enable the platform.',
bluebubbles:
@@ -642,8 +645,7 @@ const PLATFORM_INTRO: Record = {
email:
'Use a dedicated mailbox. For Gmail/Workspace, create an app password and use imap.gmail.com / smtp.gmail.com.',
sms: 'Get your Twilio Account SID and Auth Token from the Twilio console, plus a phone number that can send SMS.',
- dingtalk:
- 'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.',
+ dingtalk: 'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.',
feishu:
'Create a Feishu / Lark app, configure the bot capability, and copy the App ID, App secret, and event encryption keys.',
wecom:
@@ -655,7 +657,8 @@ const PLATFORM_INTRO: Record = {
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
api_server:
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',
- webhook: 'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
+ webhook:
+ 'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
}
const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description
@@ -717,16 +720,15 @@ function MessagingField({
}
function SectionTitle({ children }: { children: React.ReactNode }) {
- return (
- {children}
- )
+ return {children}
}
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
- if (!platform.enabled || platform.state === 'connected') {return null}
+ if (!platform.enabled || platform.state === 'connected') {
+ return null
+ }
- const hint =
- HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
+ const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
return hint ? {hint}
: null
}
@@ -748,7 +750,10 @@ function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
function SetupPill({ active, children }: { active: boolean; children: string }) {
return (
{children}
diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts
index 54cefc1be2..b747d1d7a4 100644
--- a/apps/desktop/src/app/session/hooks/use-message-stream.ts
+++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts
@@ -65,7 +65,9 @@ const STREAM_DELTA_FLUSH_MS = 16
// Anonymous progress events that carry todos but no name still belong to the
// todo stream; named todo events are obviously routed there too.
function toTodoPayload(payload: GatewayEventPayload | undefined): GatewayEventPayload | undefined {
- if (!payload) {return undefined}
+ if (!payload) {
+ return undefined
+ }
const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos'))
return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined
@@ -561,7 +563,9 @@ export function useMessageStream({
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
- if (!sessionId) {return}
+ if (!sessionId) {
+ return
+ }
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running')
} else if (event.type === 'tool.complete') {
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
index 9ee07cb0af..af6d8dd1cc 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
@@ -46,19 +46,12 @@ function blobToDataUrl(blob: Blob): Promise {
})
}
-interface SetupStatus {
- provider_configured?: boolean
-}
-
-interface RuntimeCheck {
- error?: string
- ok?: boolean
-}
-
function isProviderSetupError(error: unknown) {
const message = error instanceof Error ? error.message : String(error)
- return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i.test(message)
+ return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i.test(
+ message
+ )
}
interface PromptActionsOptions {
@@ -197,25 +190,24 @@ export function usePromptActions({
async (rawText: string) => {
const visibleText = rawText.trim()
const attachments = $composerAttachments.get()
-
const contextRefs = attachments
- .map(attachment => attachment.refText)
+ .map(a => a.refText)
.filter(Boolean)
.join('\n')
-
- const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image')
- const attachmentRefs = attachments.map(attachmentDisplayText).filter((ref): ref is string => Boolean(ref))
+ const hasImage = attachments.some(a => a.kind === 'image')
+ const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
const text =
- [contextRefs, visibleText].filter(Boolean).join('\n\n') ||
- (hasImageAttachment ? 'What do you see in this image?' : '')
+ [contextRefs, visibleText].filter(Boolean).join('\n\n') || (hasImage ? 'What do you see in this image?' : '')
if (!text || busyRef.current) {
return
}
+ const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+
const userMessage: ChatMessage = {
- id: `user-${Date.now()}`,
+ id: optimisticId,
role: 'user',
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
attachmentRefs
@@ -227,61 +219,80 @@ export function usePromptActions({
setAwaitingResponse(false)
}
+ // Idempotent optimistic insert — re-running with the resolved sessionId
+ // after createBackendSessionForSend just overwrites with the same id.
+ const seedOptimistic = (sid: string) =>
+ updateSessionState(
+ sid,
+ state => ({
+ ...state,
+ messages: state.messages.some(m => m.id === optimisticId)
+ ? state.messages
+ : [...state.messages, userMessage],
+ busy: true,
+ awaitingResponse: true,
+ pendingBranchGroup: null,
+ sawAssistantPayload: false,
+ interrupted: false
+ }),
+ selectedStoredSessionIdRef.current
+ )
+
+ const dropOptimistic = (sid: null | string) => {
+ if (!sid) {
+ setMessages(current => current.filter(m => m.id !== optimisticId))
+
+ return
+ }
+
+ updateSessionState(
+ sid,
+ state => ({
+ ...state,
+ messages: state.messages.filter(m => m.id !== optimisticId),
+ busy: false,
+ awaitingResponse: false,
+ pendingBranchGroup: null
+ }),
+ selectedStoredSessionIdRef.current
+ )
+ }
+
busyRef.current = true
setBusy(true)
setAwaitingResponse(true)
clearNotifications()
- const [setup, runtime] = await Promise.all([
- requestGateway('setup.status').catch(() => null),
- requestGateway('setup.runtime_check').catch(() => null)
- ])
+ let sessionId: null | string = activeSessionId
- const runtimeReady = runtime?.ok !== undefined ? Boolean(runtime?.ok) : setup?.provider_configured !== false
-
- if (!runtimeReady) {
- releaseBusy()
- requestDesktopOnboarding(
- runtime?.error || 'Add a provider credential before sending your first message.'
- )
-
- return
+ if (sessionId) {
+ seedOptimistic(sessionId)
+ } else {
+ setMessages(current => [...current, userMessage])
}
- let sessionId = activeSessionId
-
if (!sessionId) {
try {
sessionId = await createBackendSessionForSend()
} catch (err) {
+ dropOptimistic(null)
releaseBusy()
notifyError(err, 'Session unavailable')
return
}
+
+ if (!sessionId) {
+ dropOptimistic(null)
+ releaseBusy()
+ notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
+
+ return
+ }
+
+ seedOptimistic(sessionId)
}
- if (!sessionId) {
- releaseBusy()
- notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
-
- return
- }
-
- updateSessionState(
- sessionId,
- state => ({
- ...state,
- messages: [...state.messages, userMessage],
- busy: true,
- awaitingResponse: true,
- pendingBranchGroup: null,
- sawAssistantPayload: false,
- interrupted: false
- }),
- selectedStoredSessionIdRef.current
- )
-
try {
await syncImageAttachmentsForSubmit(sessionId, attachments)
await requestGateway('prompt.submit', { session_id: sessionId, text })
diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx
new file mode 100644
index 0000000000..d0f14f13b1
--- /dev/null
+++ b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx
@@ -0,0 +1,136 @@
+import { cleanup, render } from '@testing-library/react'
+import type { MutableRefObject } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { useRouteResume } from './use-route-resume'
+
+interface HarnessProps {
+ activeSessionId: null | string
+ activeSessionIdRef: MutableRefObject
+ creatingSessionRef: MutableRefObject
+ currentView: string
+ freshDraftReady: boolean
+ gatewayState: string
+ locationPathname: string
+ resumeSession: (sessionId: string, focus: boolean) => Promise
+ routedSessionId: null | string
+ runtimeIdByStoredSessionIdRef: MutableRefObject>
+ selectedStoredSessionId: null | string
+ selectedStoredSessionIdRef: MutableRefObject
+ startFreshSessionDraft: (focus: boolean) => unknown
+}
+
+function RouteResumeHarness(props: HarnessProps) {
+ useRouteResume(props)
+
+ return null
+}
+
+describe('useRouteResume', () => {
+ afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ })
+
+ it('does not re-resume the old session during a /:sid -> /new transition', () => {
+ const resumeSession = vi.fn(async () => undefined)
+ const startFreshSessionDraft = vi.fn()
+ const activeSessionIdRef: MutableRefObject = { current: 'runtime-1' }
+ const creatingSessionRef = { current: false }
+ const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
+ const selectedStoredSessionIdRef: MutableRefObject = { current: 'session-1' }
+
+ const { rerender } = render(
+
+ )
+
+ expect(resumeSession).not.toHaveBeenCalled()
+
+ // Simulate startFreshSessionDraft state updates landing before route update.
+ activeSessionIdRef.current = null
+ selectedStoredSessionIdRef.current = null
+ rerender(
+
+ )
+
+ expect(resumeSession).not.toHaveBeenCalled()
+ })
+
+ it('resumes when pathname changes to a routed session', () => {
+ const resumeSession = vi.fn(async () => undefined)
+ const startFreshSessionDraft = vi.fn()
+ const activeSessionIdRef: MutableRefObject = { current: null }
+ const creatingSessionRef = { current: false }
+ const runtimeIdByStoredSessionIdRef = { current: new Map() }
+ const selectedStoredSessionIdRef: MutableRefObject = { current: null }
+
+ const { rerender } = render(
+
+ )
+
+ expect(resumeSession).not.toHaveBeenCalled()
+
+ rerender(
+
+ )
+
+ expect(resumeSession).toHaveBeenCalledTimes(1)
+ expect(resumeSession).toHaveBeenCalledWith('session-2', true)
+ })
+})
diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.ts b/apps/desktop/src/app/session/hooks/use-route-resume.ts
index 86d7333ea8..9f6fc5e3d5 100644
--- a/apps/desktop/src/app/session/hooks/use-route-resume.ts
+++ b/apps/desktop/src/app/session/hooks/use-route-resume.ts
@@ -1,4 +1,4 @@
-import { type MutableRefObject, useEffect } from 'react'
+import { type MutableRefObject, useEffect, useRef } from 'react'
import { isNewChatRoute } from '@/app/routes'
@@ -55,8 +55,17 @@ export function useRouteResume({
selectedStoredSessionIdRef,
startFreshSessionDraft
}: RouteResumeOptions) {
+ const lastPathnameRef = useRef(null)
+ const wasGatewayOpenRef = useRef(false)
+
useEffect(() => {
- if (currentView !== 'chat' || gatewayState !== 'open') {
+ const gatewayOpen = gatewayState === 'open'
+ const pathnameChanged = lastPathnameRef.current !== locationPathname
+ const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen
+ lastPathnameRef.current = locationPathname
+ wasGatewayOpenRef.current = gatewayOpen
+
+ if (currentView !== 'chat' || !gatewayOpen) {
return
}
@@ -68,7 +77,12 @@ export function useRouteResume({
Boolean(cachedRuntime) &&
cachedRuntime === activeSessionIdRef.current
- if (!alreadyActive) {
+ // 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
+
+ if (!alreadyActive && shouldResume && !creatingSessionRef.current) {
void resumeSession(routedSessionId, true)
}
diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx
index cce9abd40f..fddfcd2023 100644
--- a/apps/desktop/src/app/settings/gateway-settings.tsx
+++ b/apps/desktop/src/app/settings/gateway-settings.tsx
@@ -46,7 +46,9 @@ function ModeCard({
+ return (
+
+ )
}
return (
@@ -191,8 +198,8 @@ export function GatewaySettings() {
{state.envOverride ? env override : null}
- Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to
- control an already-running Hermes backend on another machine or behind a trusted proxy.
+ Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
+ an already-running Hermes backend on another machine or behind a trusted proxy.
@@ -249,7 +256,9 @@ export function GatewaySettings() {
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
- placeholder={state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'}
+ placeholder={
+ state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
+ }
type="password"
value={remoteToken}
/>
@@ -262,7 +271,11 @@ export function GatewaySettings() {
{lastTest ? {lastTest}
: null}
-
void testRemote()} variant="outline">
+ void testRemote()}
+ variant="outline"
+ >
{testing ? : null}
Test remote
diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts
index 61e5679e9c..f27db8478d 100644
--- a/apps/desktop/src/app/settings/helpers.ts
+++ b/apps/desktop/src/app/settings/helpers.ts
@@ -43,11 +43,12 @@ function safeSet(target: Record, key: string, value: unknown):
if (key === '__proto__' || key === 'constructor' || key === 'prototype' || !key) {
throw new Error(`Unsafe config key: ${key}`)
}
+
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
- configurable: true,
+ configurable: true
})
}
diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx
index 0e5e1437bd..95f8a888a4 100644
--- a/apps/desktop/src/app/shell/app-shell.tsx
+++ b/apps/desktop/src/app/shell/app-shell.tsx
@@ -60,6 +60,7 @@ export function AppShell({
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
+
const titlebarContentInset = sidebarOpen
? 0
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
index 61f93def2d..e598a4dbc4 100644
--- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
+++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
@@ -123,8 +123,9 @@ export function useStatusbarItems({
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
const behind = updateStatus?.behind ?? 0
const applying = updateApply.applying || updateApply.stage === 'restart'
- const base = appVersion ? `v${appVersion}` : sha ?? 'unknown'
+ const base = appVersion ? `v${appVersion}` : (sha ?? 'unknown')
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
+
const label = applying
? updateApply.stage === 'restart'
? `${base} · restart`
diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx
index 6ffad250e1..1020f07150 100644
--- a/apps/desktop/src/app/shell/statusbar-controls.tsx
+++ b/apps/desktop/src/app/shell/statusbar-controls.tsx
@@ -110,37 +110,37 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
: (item.menuItems ?? [])
.filter(menuItem => !menuItem.hidden)
.map(menuItem => (
- {
- if (menuItem.to) {
- navigate(menuItem.to)
- }
+ {
+ if (menuItem.to) {
+ navigate(menuItem.to)
+ }
- menuItem.onSelect?.()
- }}
- >
- {menuItem.href ? (
-
- {menuItem.icon}
- {menuItem.label}
-
- ) : (
- <>
- {menuItem.icon}
- {menuItem.label}
- >
- )}
-
- ))}
+ {menuItem.href ? (
+
+ {menuItem.icon}
+ {menuItem.label}
+
+ ) : (
+ <>
+ {menuItem.icon}
+ {menuItem.label}
+ >
+ )}
+
+ ))}
)
diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx
index 8d57c8ba74..be3a57c4aa 100644
--- a/apps/desktop/src/app/skills/index.tsx
+++ b/apps/desktop/src/app/skills/index.tsx
@@ -379,10 +379,7 @@ function CategoryButton({
type="button"
>
{label}
diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx
index a74e174be5..398b4013b7 100644
--- a/apps/desktop/src/app/updates-overlay.tsx
+++ b/apps/desktop/src/app/updates-overlay.tsx
@@ -46,11 +46,9 @@ export function UpdatesOverlay() {
}, [checking, open, status])
const behind = status?.behind ?? 0
- const phase: 'idle' | 'applying' | 'error' = apply.applying || apply.stage === 'restart'
- ? 'applying'
- : apply.stage === 'error'
- ? 'error'
- : 'idle'
+
+ const phase: 'idle' | 'applying' | 'error' =
+ apply.applying || apply.stage === 'restart' ? 'applying' : apply.stage === 'error' ? 'error' : 'idle'
const handleClose = (next: boolean) => {
if (phase === 'applying') {
@@ -114,7 +112,9 @@ function IdleView({
status: DesktopUpdateStatus | null
}) {
if (!status && checking) {
- return } title="Looking for updates…" />
+ return (
+ } title="Looking for updates…" />
+ )
}
if (!status) {
@@ -223,7 +223,9 @@ function IdleView({
{remaining > 0 && (
- + {remaining} more change{remaining === 1 ? '' : 's'} included.
+
+ + {remaining} more change{remaining === 1 ? '' : 's'} included.
+
)}
)
@@ -231,6 +233,7 @@ function IdleView({
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
const label = STAGE_LABELS[apply.stage] ?? 'Updating Hermes…'
+
const percent =
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
? Math.max(2, Math.min(100, Math.round(apply.percent)))
diff --git a/apps/desktop/src/components/Backdrop.tsx b/apps/desktop/src/components/Backdrop.tsx
index a3be2b3c60..c9039952d3 100644
--- a/apps/desktop/src/components/Backdrop.tsx
+++ b/apps/desktop/src/components/Backdrop.tsx
@@ -30,7 +30,10 @@ function binaryNoiseDataUrl(tile: number, density: number, size: number, color:
return ''
}
- const dpr = Math.min(window.devicePixelRatio || 1, 2)
+ // Cap at 1.5x to match the design-language overlay perf work (PR #14):
+ // with `image-rendering: pixelated` there's no visible win above 1.5x, and
+ // a full retina (2x) PNG is ~78% larger to keep resident in compositor memory.
+ const dpr = Math.min(window.devicePixelRatio || 1, 1.5)
const physTile = Math.round(tile * dpr)
const block = Math.max(1, Math.round(size * dpr))
@@ -165,7 +168,7 @@ export function Backdrop() {
{import.meta.env.DEV &&
}
- {statue.enabled && (
+ {statue.enabled && gpuTier > 0 && (
{
it('renders archived todos after turn completion regardless of pending state', () => {
const first = render(
-
+
)
const ui = within(first.container)
@@ -410,9 +406,7 @@ describe('assistant-ui streaming renderer', () => {
const second = render(
)
diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx
index 1e8f2cd8e7..6dfae16e7c 100644
--- a/apps/desktop/src/components/assistant-ui/thread.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread.tsx
@@ -18,7 +18,7 @@ import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
-import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
+import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
@@ -51,6 +51,7 @@ import {
XIcon
} from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
+import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { notifyError } from '@/store/notifications'
@@ -365,7 +366,8 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
return pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
}, [messageText])
- const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
+ const messageStatus = useAuiState(s => s.message.status?.type)
+ const isPlaceholder = messageStatus === 'running' && content.length === 0
if (isPlaceholder) {
return null
@@ -382,14 +384,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
data-slot="aui_assistant-message-content"
>
{hoistedTodos.length > 0 &&
}
-
+
{previewTargets.length > 0 && (
{previewTargets.map(target => (
@@ -462,26 +457,70 @@ const ImageGenerateTool: FC
= ({ result }) => {
const ChainToolFallback: FC = props => {
// todo parts are hoisted to a dedicated panel above the message content.
- if (props.toolName === 'todo') {return null}
+ if (props.toolName === 'todo') {
+ return null
+ }
- if (props.toolName === 'image_generate') {return }
+ if (props.toolName === 'image_generate') {
+ return
+ }
- if (props.toolName === 'clarify') {return }
+ if (props.toolName === 'clarify') {
+ return
+ }
return
}
const ThinkingDisclosure: FC<{
children: ReactNode
+ messageRunning?: boolean
pending?: boolean
timerKey?: string
-}> = ({ children, pending = false, timerKey }) => {
- const [open, setOpen] = useState(false)
+}> = ({ children, messageRunning = false, pending = false, timerKey }) => {
+ // `null` = no explicit user toggle yet, defer to the streaming default.
+ // The default is "auto-open while streaming, auto-collapse when done" so
+ // reasoning surfaces a live preview without manual interaction. The first
+ // explicit toggle wins from then on.
+ const [userOpen, setUserOpen] = useState(null)
const elapsed = useElapsedSeconds(pending, timerKey)
+ const scrollRef = useRef(null)
+ const contentRef = useRef(null)
+ const enterRef = useEnterAnimation(messageRunning, timerKey)
+
+ const open = userOpen ?? pending
+ const isPreview = pending && userOpen === null
+
+ // While the preview is live, pin the scroll container to the bottom on
+ // every content growth so the latest tokens are always visible. Combined
+ // with the top mask in styles.css, this reads as text settling in from
+ // below while older lines fade out at the top.
+ useEffect(() => {
+ if (!isPreview) {
+ return
+ }
+
+ const el = scrollRef.current
+ const content = contentRef.current
+
+ if (!el || !content) {
+ return
+ }
+
+ const pin = () => {
+ el.scrollTop = el.scrollHeight
+ }
+
+ pin()
+ const observer = new ResizeObserver(pin)
+ observer.observe(content)
+
+ return () => observer.disconnect()
+ }, [isPreview])
return (
-
-
setOpen(v => !v)} open={open}>
+
+
setUserOpen(!open)} open={open}>
{open && (
-
)
}
-const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({ children }) => {
- const pending = useAuiState(s => s.thread.isRunning && s.message.status?.type === 'running')
+// Self-gate "Thinking…" on this message's own reasoning parts. Reading
+// `thread.isRunning` directly would flicker shimmer/timer on every old
+// assistant whenever the external-store runtime clears+reimports its
+// repository (one ref-identity bump per streaming delta).
+const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({
+ children,
+ endIndex,
+ startIndex
+}) => {
const messageId = useAuiState(s => s.message.id)
+ const messageRunning = useAuiState(s => s.message.status?.type === 'running')
- return
{children}
+ const pending = useAuiState(
+ s =>
+ s.thread.isRunning &&
+ s.message.status?.type === 'running' &&
+ s.message.parts
+ .slice(Math.max(0, startIndex), Math.min(s.message.parts.length, endIndex))
+ .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
+ )
+
+ return (
+
+ {children}
+
+ )
}
const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
@@ -528,6 +596,21 @@ const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ te
)
}
+// Module-level constant so the `components` prop on `MessagePrimitive.Parts`
+// has a stable identity across renders. Without this every AssistantMessage
+// render would create a fresh `components` object, invalidating the memo on
+// `MessagePrimitivePartByIndex` and forcing every tool/reasoning child to
+// re-render on every streaming delta. Memo invalidation alone doesn't
+// remount, but combined with the previous ToolFallback group-swap it was a
+// big chunk of the per-delta work.
+const MESSAGE_PARTS_COMPONENTS = {
+ Reasoning: ReasoningTextPart,
+ ReasoningGroup: ReasoningAccordionGroup,
+ Text: MarkdownText,
+ ToolGroup: ToolGroupSlot,
+ tools: { Fallback: ChainToolFallback }
+} as const
+
const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
const SHORT_FMT = new Intl.DateTimeFormat(undefined, {
diff --git a/apps/desktop/src/components/assistant-ui/todo-tool.tsx b/apps/desktop/src/components/assistant-ui/todo-tool.tsx
index 6ec415ca1f..e974b6ba51 100644
--- a/apps/desktop/src/components/assistant-ui/todo-tool.tsx
+++ b/apps/desktop/src/components/assistant-ui/todo-tool.tsx
@@ -6,28 +6,36 @@ import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos'
import { cn } from '@/lib/utils'
export function todosFromMessageContent(content: unknown): TodoItem[] {
- if (!Array.isArray(content)) {return []}
+ if (!Array.isArray(content)) {
+ return []
+ }
let latest: null | TodoItem[] = null
for (const part of content) {
- if (!part || typeof part !== 'object') {continue}
+ if (!part || typeof part !== 'object') {
+ continue
+ }
const row = part as Record
- if (row.type !== 'tool-call' || row.toolName !== 'todo') {continue}
+ if (row.type !== 'tool-call' || row.toolName !== 'todo') {
+ continue
+ }
const parsed = parseTodos(row.result) ?? parseTodos(row.args)
- if (parsed !== null) {latest = parsed}
+ if (parsed !== null) {
+ latest = parsed
+ }
}
return latest ?? []
}
const headerLabel = (todos: readonly TodoItem[]): string =>
- todos.find(t => t.status === 'in_progress')?.content
- ?? todos.find(t => t.status === 'pending')?.content
- ?? todos.at(-1)?.content
- ?? 'Tasks'
+ todos.find(t => t.status === 'in_progress')?.content ??
+ todos.find(t => t.status === 'pending')?.content ??
+ todos.at(-1)?.content ??
+ 'Tasks'
const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => {
if (status === 'in_progress') {
@@ -49,7 +57,8 @@ const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label })
checked={checked}
className={cn(
'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100',
- checked && 'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
+ checked &&
+ 'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
status === 'cancelled' && 'border-muted-foreground/40'
)}
disabled
@@ -58,7 +67,9 @@ const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label })
}
export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
- if (!todos.length) {return null}
+ if (!todos.length) {
+ return null
+ }
const label = headerLabel(todos)
return (
@@ -67,7 +78,10 @@ export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
data-slot="aui_todo-hoisted"
>
@@ -83,9 +97,7 @@ export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
key={todo.id}
>
-
- {todo.content}
-
+ {todo.content}
))}
diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
new file mode 100644
index 0000000000..5615fe8fe2
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
@@ -0,0 +1,1354 @@
+import { normalizeExternalUrl } from '@/lib/external-link'
+import { Command, FileText, Globe, LinkIcon, Search, Sparkles, Wrench } from '@/lib/icons'
+import type { LucideIcon } from '@/lib/icons'
+import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
+
+export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
+export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
+
+export interface ToolPart {
+ args?: unknown
+ isError?: boolean
+ result?: unknown
+ toolCallId?: string
+ toolName: string
+ type: 'tool-call'
+}
+
+export interface SearchResultRow {
+ snippet: string
+ title: string
+ url: string
+}
+
+interface CountMetric {
+ count: number
+ noun: string
+}
+
+export interface ToolView {
+ countLabel?: string
+ detail: string
+ detailLabel: string
+ durationLabel?: string
+ icon: LucideIcon
+ imageUrl?: string
+ inlineDiff: string
+ previewTarget?: string
+ rawArgs: string
+ rawResult: string
+ searchHits?: SearchResultRow[]
+ status: ToolStatus
+ subtitle: string
+ title: string
+ tone: ToolTone
+}
+
+interface ToolMeta {
+ done: string
+ icon: LucideIcon
+ pending: string
+ tone: ToolTone
+}
+
+export interface MessageRunningStateSlice {
+ message: {
+ status?: {
+ type?: string
+ }
+ }
+ thread: {
+ isRunning: boolean
+ }
+}
+
+const TOOL_META: Record = {
+ browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: Globe, tone: 'browser' },
+ browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: Globe, tone: 'browser' },
+ browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: Globe, tone: 'browser' },
+ browser_snapshot: {
+ done: 'Captured page snapshot',
+ pending: 'Capturing page snapshot',
+ icon: Globe,
+ tone: 'browser'
+ },
+ browser_take_screenshot: {
+ done: 'Captured screenshot',
+ pending: 'Capturing screenshot',
+ icon: Sparkles,
+ tone: 'browser'
+ },
+ browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: Globe, tone: 'browser' },
+ edit_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' },
+ execute_code: { done: 'Ran code', pending: 'Running code', icon: Command, tone: 'terminal' },
+ image_generate: { done: 'Generated image', pending: 'Generating image', icon: Sparkles, tone: 'image' },
+ list_files: { done: 'Listed files', pending: 'Listing files', icon: FileText, tone: 'file' },
+ read_file: { done: 'Read file', pending: 'Reading file', icon: FileText, tone: 'file' },
+ search_files: { done: 'Searched files', pending: 'Searching files', icon: FileText, tone: 'file' },
+ session_search_recall: {
+ done: 'Searched session history',
+ pending: 'Searching session history',
+ icon: Search,
+ tone: 'agent'
+ },
+ terminal: { done: 'Ran command', pending: 'Running command', icon: Command, tone: 'terminal' },
+ todo: { done: 'Updated todos', pending: 'Updating todos', icon: Wrench, tone: 'agent' },
+ web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: LinkIcon, tone: 'web' },
+ web_search: { done: 'Searched web', pending: 'Searching web', icon: Search, tone: 'web' },
+ write_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' }
+}
+
+const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
+const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*\d+)*)\](?!\()/gu
+const BACKTICK_NOISE_RE = /`{3,}/g
+
+export const selectMessageRunning = (state: MessageRunningStateSlice) =>
+ state.thread.isRunning && state.message.status?.type === 'running'
+
+function titleForTool(name: string): string {
+ const normalized = name.replace(/^browser_/, '').replace(/^web_/, '')
+
+ return (
+ normalized
+ .split('_')
+ .filter(Boolean)
+ .map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
+ .join(' ') || name
+ )
+}
+
+const PREFIX_META: { icon: LucideIcon; prefix: string; tone: ToolTone; verb: string }[] = [
+ { prefix: 'browser_', verb: 'Browser', icon: Globe, tone: 'browser' },
+ { prefix: 'web_', verb: 'Web', icon: Search, tone: 'web' }
+]
+
+function toolMeta(name: string): ToolMeta {
+ if (TOOL_META[name]) {
+ return TOOL_META[name]
+ }
+
+ const action = titleForTool(name)
+ const prefix = PREFIX_META.find(p => name.startsWith(p.prefix))
+
+ return prefix
+ ? {
+ done: `${prefix.verb} ${action}`,
+ pending: `Running ${prefix.verb.toLowerCase()} ${action.toLowerCase()}`,
+ icon: prefix.icon,
+ tone: prefix.tone
+ }
+ : { done: action, pending: `Running ${action.toLowerCase()}`, icon: Wrench, tone: 'default' }
+}
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value))
+}
+
+export function compactPreview(value: unknown, max = 72): string {
+ let raw: unknown
+
+ if (typeof value === 'string') {
+ raw = value
+ } else {
+ raw = parseMaybeObject(value).context
+ }
+
+ if (typeof raw !== 'string') {
+ if (raw == null) {
+ raw = ''
+ } else {
+ try {
+ raw = JSON.stringify(raw)
+ } catch {
+ raw = String(raw)
+ }
+ }
+ }
+
+ const line = (raw as string).replace(/\s+/g, ' ').trim()
+
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line
+}
+
+function contextValue(value: unknown): string {
+ const row = parseMaybeObject(value)
+
+ if (typeof row.context === 'string') {
+ return row.context
+ }
+
+ if (typeof row.preview === 'string') {
+ return row.preview
+ }
+
+ return typeof value === 'string' ? value : ''
+}
+
+function prettyJson(value: unknown): string {
+ return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
+}
+
+function parseMaybeObject(value: unknown): Record {
+ if (isRecord(value)) {
+ return value
+ }
+
+ if (typeof value !== 'string' || !value.trim()) {
+ return {}
+ }
+
+ try {
+ const parsed = JSON.parse(value)
+
+ return isRecord(parsed) ? parsed : {}
+ } catch {
+ return {}
+ }
+}
+
+function unwrapToolPayload(value: unknown): unknown {
+ const record = parseMaybeObject(value)
+
+ for (const key of ['data', 'result', 'output', 'response', 'payload']) {
+ const payload = record[key]
+
+ if (payload !== undefined && payload !== null) {
+ return payload
+ }
+ }
+
+ return value
+}
+
+function numberValue(value: unknown): null | number {
+ const n = typeof value === 'number' ? value : Number(value)
+
+ return Number.isFinite(n) ? n : null
+}
+
+function formatDurationSeconds(seconds: number): string {
+ if (!Number.isFinite(seconds) || seconds < 0) {
+ return ''
+ }
+
+ if (seconds < 1) {
+ const ms = Math.max(1, Math.round(seconds * 1000))
+
+ return `${ms}ms`
+ }
+
+ if (seconds < 60) {
+ return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s`
+ }
+
+ const wholeSeconds = Math.round(seconds)
+ const minutes = Math.floor(wholeSeconds / 60)
+ const remSeconds = wholeSeconds % 60
+
+ if (minutes < 60) {
+ return remSeconds ? `${minutes}m ${remSeconds}s` : `${minutes}m`
+ }
+
+ const hours = Math.floor(minutes / 60)
+ const remMinutes = minutes % 60
+
+ return remMinutes ? `${hours}h ${remMinutes}m` : `${hours}h`
+}
+
+const COUNT_FIELD_KEYS = [
+ 'count',
+ 'total',
+ 'result_count',
+ 'results_count',
+ 'num_results',
+ 'match_count',
+ 'matches_count',
+ 'file_count',
+ 'files_count',
+ 'item_count',
+ 'items_count',
+ 'search_count',
+ 'searches_count',
+ 'source_count',
+ 'sources_count',
+ 'document_count',
+ 'documents_count',
+ 'updated',
+ 'added',
+ 'removed',
+ 'deleted',
+ 'created',
+ 'changed',
+ 'processed',
+ 'steps'
+] as const
+
+const COUNT_ARRAY_KEYS = ['results', 'items', 'matches', 'files', 'documents', 'sources', 'rows'] as const
+
+const COUNT_EXCLUDED_KEYS = new Set(['duration_s', 'exit_code', 'status_code'])
+
+const COUNT_NOUN_BY_FIELD: Partial> = {
+ count: '',
+ total: '',
+ result_count: 'result',
+ results_count: 'result',
+ num_results: 'result',
+ match_count: 'match',
+ matches_count: 'match',
+ file_count: 'file',
+ files_count: 'file',
+ item_count: 'item',
+ items_count: 'item',
+ search_count: 'search',
+ searches_count: 'search',
+ source_count: 'source',
+ sources_count: 'source',
+ document_count: 'document',
+ documents_count: 'document',
+ updated: 'item',
+ added: 'item',
+ removed: 'item',
+ deleted: 'item',
+ created: 'item',
+ changed: 'item',
+ processed: 'item',
+ steps: 'step'
+}
+
+const COUNT_NOUN_BY_ARRAY: Record<(typeof COUNT_ARRAY_KEYS)[number], string> = {
+ documents: 'document',
+ files: 'file',
+ items: 'item',
+ matches: 'match',
+ results: 'result',
+ rows: 'row',
+ sources: 'source'
+}
+
+const DEFAULT_COUNT_NOUN_BY_TOOL: Record = {
+ browser_snapshot: 'item',
+ list_files: 'file',
+ search_files: 'result',
+ session_search_recall: 'result',
+ todo: 'todo',
+ web_search: 'result'
+}
+
+function countFromUnknown(value: unknown): null | number {
+ if (Array.isArray(value)) {
+ return value.length > 0 ? value.length : null
+ }
+
+ const n = numberValue(value)
+
+ if (n === null || n <= 0) {
+ return null
+ }
+
+ return Math.round(n)
+}
+
+function singularizeNoun(noun: string): string {
+ const normalized = noun.trim().toLowerCase()
+
+ if (!normalized) {
+ return ''
+ }
+
+ if (normalized.endsWith('ies') && normalized.length > 3) {
+ return `${normalized.slice(0, -3)}y`
+ }
+
+ if (/(xes|zes|ches|shes|sses)$/.test(normalized) && normalized.length > 3) {
+ return normalized.slice(0, -2)
+ }
+
+ if (normalized.endsWith('s') && normalized.length > 2 && !normalized.endsWith('ss')) {
+ return normalized.slice(0, -1)
+ }
+
+ return normalized
+}
+
+function pluralizeNoun(noun: string, count: number): string {
+ if (count === 1) {
+ return noun
+ }
+
+ if (noun === 'search') {
+ return 'searches'
+ }
+
+ if (noun.endsWith('y') && noun.length > 1 && !/[aeiou]y$/i.test(noun)) {
+ return `${noun.slice(0, -1)}ies`
+ }
+
+ if (/(s|x|z|ch|sh)$/i.test(noun)) {
+ return `${noun}es`
+ }
+
+ return `${noun}s`
+}
+
+function formatCountLabel(metric: CountMetric): string {
+ return `${metric.count} ${pluralizeNoun(metric.noun, metric.count)}`
+}
+
+function countMetric(count: number, noun: string): CountMetric {
+ return { count, noun: singularizeNoun(noun) || 'item' }
+}
+
+function normalizeMetricForTool(toolName: string, metric: CountMetric): CountMetric {
+ if (toolName === 'web_search') {
+ return countMetric(metric.count, 'result')
+ }
+
+ return metric
+}
+
+function fallbackCountNoun(toolName: string): string {
+ return DEFAULT_COUNT_NOUN_BY_TOOL[toolName] || 'item'
+}
+
+function dynamicCountNounFromKey(key: string, fallbackNoun: string): string {
+ const normalized = key.toLowerCase()
+
+ if (normalized === 'count' || normalized === 'total') {
+ return fallbackNoun
+ }
+
+ const stripped = normalized.replace(/_(count|total)$/i, '').replace(/^num_/, '')
+
+ return singularizeNoun(stripped) || fallbackNoun
+}
+
+function countFromRecord(record: Record, fallbackNoun: string): CountMetric | null {
+ for (const key of COUNT_FIELD_KEYS) {
+ const value = record[key]
+ const count = countFromUnknown(value)
+
+ if (count !== null) {
+ return countMetric(count, COUNT_NOUN_BY_FIELD[key] || fallbackNoun)
+ }
+ }
+
+ for (const key of COUNT_ARRAY_KEYS) {
+ const value = record[key]
+ const count = countFromUnknown(value)
+
+ if (count !== null) {
+ return countMetric(count, COUNT_NOUN_BY_ARRAY[key] || fallbackNoun)
+ }
+ }
+
+ for (const [key, value] of Object.entries(record)) {
+ if (COUNT_EXCLUDED_KEYS.has(key)) {
+ continue
+ }
+
+ if (!/_count$|_total$/i.test(key)) {
+ continue
+ }
+
+ const count = countFromUnknown(value)
+
+ if (count !== null) {
+ return countMetric(count, dynamicCountNounFromKey(key, fallbackNoun))
+ }
+ }
+
+ return null
+}
+
+function countFromText(value: string, fallbackNoun: string): CountMetric | null {
+ const text = value.trim()
+
+ if (!text) {
+ return null
+ }
+
+ const unitMatch =
+ text.match(/\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b/i) ||
+ text.match(/\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b/i)
+
+ if (unitMatch?.[1]) {
+ const n = Number(unitMatch[1])
+ const noun = unitMatch[2] ? singularizeNoun(unitMatch[2]) : fallbackNoun
+
+ return Number.isFinite(n) && n > 0 ? countMetric(Math.round(n), noun) : null
+ }
+
+ return null
+}
+
+function toolResultCount(
+ part: ToolPart,
+ argsRecord: Record,
+ resultRecord: Record
+): CountMetric | null {
+ if (part.result === undefined) {
+ return null
+ }
+
+ const fallbackNounByTool = fallbackCountNoun(part.toolName)
+
+ if (part.toolName === 'web_search') {
+ const hits = collectResultItems(part.result)
+
+ if (hits.length) {
+ return countMetric(hits.length, 'result')
+ }
+ }
+
+ const directCount = countFromRecord(resultRecord, fallbackNounByTool)
+
+ if (directCount !== null) {
+ return normalizeMetricForTool(part.toolName, directCount)
+ }
+
+ const payload = unwrapToolPayload(part.result)
+
+ if (isRecord(payload)) {
+ const payloadCount = countFromRecord(payload, fallbackNounByTool)
+
+ if (payloadCount !== null) {
+ return normalizeMetricForTool(part.toolName, payloadCount)
+ }
+ }
+
+ const summaryText =
+ firstStringField(resultRecord, ['summary', 'message', 'detail']) || fallbackDetailText(argsRecord, resultRecord)
+
+ const textMetric = countFromText(summaryText, fallbackNounByTool)
+
+ return textMetric ? normalizeMetricForTool(part.toolName, textMetric) : null
+}
+
+function looksLikeUrl(value: string): boolean {
+ return /^https?:\/\//i.test(value)
+}
+
+function looksLikePath(value: string): boolean {
+ return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
+}
+
+export function isPreviewableTarget(target: string): boolean {
+ return Boolean(
+ target &&
+ (/^file:\/\//i.test(target) ||
+ /^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) ||
+ /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target))
+ )
+}
+
+function stableHash(value: string): string {
+ let hash = 0
+
+ for (let index = 0; index < value.length; index += 1) {
+ hash = Math.imul(31, hash) + value.charCodeAt(index)
+ }
+
+ return Math.abs(hash).toString(36)
+}
+
+export function toolPartDisclosureId(part: ToolPart): string {
+ if (part.toolCallId) {
+ return `tool:${part.toolCallId}`
+ }
+
+ return `tool:${part.toolName}:${stableHash(JSON.stringify(part.args ?? ''))}`
+}
+
+export function toolGroupDisclosureId(parts: ToolPart[]): string {
+ return `tool-group:${parts.map(toolPartDisclosureId).join('|')}`
+}
+
+const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i
+
+function findFirstUrl(...sources: unknown[]): string {
+ for (const src of sources) {
+ if (typeof src === 'string') {
+ const m = src.match(URL_PATTERN)
+
+ if (m) {
+ return m[0]
+ }
+ } else if (src && typeof src === 'object') {
+ for (const v of Object.values(src as Record)) {
+ const found = findFirstUrl(v)
+
+ if (found) {
+ return found
+ }
+ }
+ }
+ }
+
+ return ''
+}
+
+function hostnameOf(value: string): string {
+ try {
+ const url = new URL(value)
+
+ return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}`
+ } catch {
+ return value
+ }
+}
+
+export function looksRedundant(title: string, detail: string): boolean {
+ if (!detail) {
+ return true
+ }
+
+ const norm = (input: string) => input.toLowerCase().replace(/\s+/g, ' ').trim()
+
+ return norm(title) === norm(detail)
+}
+
+export function cleanVisibleText(text: string): string {
+ return text
+ .split(INLINE_CODE_SPLIT_RE)
+ .map(part =>
+ part.startsWith('`')
+ ? part
+ : part
+ .replace(BACKTICK_NOISE_RE, '')
+ .replace(CITATION_MARKER_RE, '')
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label: string, href: string) => {
+ const normalized = normalizeExternalUrl(href)
+
+ return `${label} ${normalized}`
+ })
+ )
+ .join('')
+}
+
+function summarizeBrowserSnapshot(snapshot: string): string {
+ const count = (re: RegExp) => snapshot.match(re)?.length ?? 0
+
+ const stats = [
+ `${count(/button\s+"[^"]+"/g)} buttons`,
+ `${count(/link\s+"[^"]+"/g)} links`,
+ `${count(/(?:textbox|combobox|searchbox)\s+"[^"]+"/g)} inputs`
+ ].join(' · ')
+
+ const labels = Array.from(snapshot.matchAll(/(?:button|link|combobox|textbox)\s+"([^"]+)"/g))
+ .map(m => m[1].trim())
+ .filter(Boolean)
+ .slice(0, 4)
+
+ return labels.length ? `${stats}\nTop controls: ${labels.join(', ')}` : stats
+}
+
+function firstStringField(record: Record, keys: readonly string[]): string {
+ for (const key of keys) {
+ const value = record[key]
+
+ if (typeof value === 'string' && value.trim()) {
+ return value.trim()
+ }
+ }
+
+ return ''
+}
+
+function collectResultItems(value: unknown): unknown[] {
+ if (Array.isArray(value)) {
+ return value
+ }
+
+ const record = parseMaybeObject(value)
+
+ for (const key of [
+ 'web',
+ 'results',
+ 'search_results',
+ 'sources',
+ 'web_sources',
+ 'items',
+ 'organic_results',
+ 'organic',
+ 'matches',
+ 'documents'
+ ]) {
+ const candidate = record[key]
+
+ if (Array.isArray(candidate)) {
+ return candidate
+ }
+
+ if (isRecord(candidate)) {
+ const nested = collectResultItems(candidate)
+
+ if (nested.length) {
+ return nested
+ }
+ }
+ }
+
+ const payload = unwrapToolPayload(record)
+
+ return payload === record ? [] : collectResultItems(payload)
+}
+
+function extractSearchResults(result: unknown, limit = 6): SearchResultRow[] {
+ const list = collectResultItems(result)
+
+ return list
+ .map(item => {
+ const r = parseMaybeObject(item)
+
+ return {
+ title: cleanVisibleText(firstStringField(r, ['title', 'name'])),
+ url: firstStringField(r, ['url', 'href', 'link']),
+ snippet: cleanVisibleText(firstStringField(r, ['snippet', 'description', 'body']))
+ }
+ })
+ .filter(hit => hit.title || hit.url)
+ .slice(0, limit)
+}
+
+function toolErrorText(part: ToolPart, result: Record): string {
+ const extractedError = extractToolErrorMessage(part.result)
+
+ if (part.isError) {
+ return extractedError || (typeof part.result === 'string' && part.result.trim()) || 'Tool returned an error.'
+ }
+
+ if (typeof result.error === 'string' && result.error.trim()) {
+ return result.error.trim()
+ }
+
+ if (extractedError) {
+ return extractedError
+ }
+
+ if (result.success === false || result.ok === false) {
+ return firstStringField(result, ['message', 'reason', 'detail']) || 'Tool returned success=false.'
+ }
+
+ if (typeof result.status === 'string' && /\b(error|failed|failure)\b/i.test(result.status)) {
+ return firstStringField(result, ['message', 'reason', 'detail']) || `Tool returned status "${result.status}".`
+ }
+
+ const exit = numberValue(result.exit_code)
+
+ return exit !== null && exit !== 0 ? `Command failed with exit code ${exit}.` : ''
+}
+
+function toolStatus(part: ToolPart, resultRecord: Record): ToolStatus {
+ if (part.result === undefined) {
+ return 'running'
+ }
+
+ return toolErrorText(part, resultRecord) ? 'error' : 'success'
+}
+
+function durationLabel(resultRecord: Record): string | undefined {
+ const seconds = numberValue(resultRecord.duration_s)
+
+ if (seconds === null || seconds < 0) {
+ return undefined
+ }
+
+ return formatDurationSeconds(seconds)
+}
+
+function toolPreviewTarget(toolName: string, args: Record, result: Record): string {
+ const direct =
+ firstStringField(result, ['preview', 'url', 'target']) ||
+ firstStringField(args, ['preview', 'url', 'target', 'path', 'file', 'filepath']) ||
+ firstStringField(result, ['path', 'file', 'filepath'])
+
+ if (direct && (looksLikeUrl(direct) || looksLikePath(direct))) {
+ return direct
+ }
+
+ if (toolName === 'browser_navigate' || toolName === 'web_extract' || toolName === 'web_search') {
+ const explicit = firstStringField(args, ['url', 'search_term', 'query']) || firstStringField(result, ['url'])
+
+ return looksLikeUrl(explicit) ? explicit : findFirstUrl(args, result)
+ }
+
+ if (toolName === 'write_file' || toolName === 'edit_file') {
+ return htmlPathFromInlineDiff(firstStringField(result, ['inline_diff']))
+ }
+
+ return ''
+}
+
+function toolImageUrl(args: Record, result: Record): string {
+ const candidate =
+ firstStringField(result, ['image_url', 'url', 'path', 'image_path']) ||
+ firstStringField(args, ['image_url', 'url', 'path'])
+
+ if (!candidate) {
+ return ''
+ }
+
+ return candidate.toLowerCase().startsWith('data:image/') || /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(candidate)
+ ? candidate
+ : ''
+}
+
+function stripAnsi(value: string): string {
+ return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
+}
+
+export function stripInlineDiffChrome(value: string): string {
+ return value
+ ? stripAnsi(value)
+ .replace(/^\s*┊\s*review diff\s*\n/i, '')
+ .trim()
+ : ''
+}
+
+function htmlPathFromInlineDiff(value: string): string {
+ const cleaned = stripInlineDiffChrome(value)
+
+ for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) {
+ const candidate = match[1]?.trim()
+
+ if (candidate) {
+ return candidate
+ }
+ }
+
+ return ''
+}
+
+function stripDividerLines(value: string): string {
+ return value
+ .split('\n')
+ .filter(line => !/^[-=]{3,}\s*$/.test(line.trim()))
+ .join('\n')
+ .trim()
+}
+
+export function inlineDiffFromResult(result: unknown): string {
+ const value = parseMaybeObject(result).inline_diff
+
+ return typeof value === 'string' ? stripInlineDiffChrome(value) : ''
+}
+
+function minimalValueSummary(value: unknown): string {
+ if (value == null) {
+ return ''
+ }
+
+ if (typeof value === 'string') {
+ return value
+ }
+
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value)
+ }
+
+ if (Array.isArray(value)) {
+ return value.length ? `Returned ${value.length} items.` : 'No items returned.'
+ }
+
+ if (isRecord(value)) {
+ const count = Object.keys(value).length
+
+ return count ? `Returned object with ${count} fields.` : 'Returned an empty object.'
+ }
+
+ return String(value)
+}
+
+function fallbackDetailText(args: unknown, result: unknown): string {
+ const argContext = contextValue(args)
+ const resultContext = contextValue(result)
+
+ if (resultContext && resultContext !== argContext) {
+ return resultContext
+ }
+
+ if (argContext) {
+ return argContext
+ }
+
+ if (result !== undefined) {
+ return formatToolResultSummary(result) || minimalValueSummary(result)
+ }
+
+ return formatToolResultSummary(args) || minimalValueSummary(args)
+}
+
+function toolSubtitle(
+ part: ToolPart,
+ argsRecord: Record,
+ resultRecord: Record
+): string {
+ const toolName = part.toolName
+
+ if (toolName === 'browser_navigate') {
+ const url =
+ firstStringField(argsRecord, ['url', 'target']) ||
+ firstStringField(resultRecord, ['url']) ||
+ findFirstUrl(argsRecord, resultRecord)
+
+ return url ? hostnameOf(url) : 'Navigated in browser'
+ }
+
+ if (toolName === 'browser_snapshot') {
+ const snapshot = firstStringField(resultRecord, ['snapshot'])
+
+ return snapshot ? summarizeBrowserSnapshot(snapshot) : 'Captured a browser accessibility snapshot'
+ }
+
+ if (toolName === 'browser_click') {
+ const clicked = firstStringField(resultRecord, ['clicked']) || firstStringField(argsRecord, ['ref', 'target'])
+
+ if (!clicked) {
+ return 'Clicked on page'
+ }
+
+ return clicked.startsWith('@') ? `Clicked page element (internal ref ${clicked})` : `Clicked ${clicked}`
+ }
+
+ if (toolName === 'browser_fill' || toolName === 'browser_type') {
+ const field = firstStringField(argsRecord, ['label', 'field', 'ref', 'target'])
+ const value = firstStringField(argsRecord, ['value', 'text'])
+
+ return (
+ [field && `Field: ${field}`, value && `Value: ${compactPreview(value, 42)}`].filter(Boolean).join(' · ') ||
+ 'Filled page input'
+ )
+ }
+
+ if (toolName === 'web_search') {
+ const query = firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord)
+
+ return query ? `Query: ${query}` : 'Queried web sources'
+ }
+
+ if (toolName === 'terminal' || toolName === 'execute_code') {
+ const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr'])
+
+ const lines = Array.isArray(resultRecord.lines)
+ ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n')
+ : ''
+
+ const previewSource = (output || lines).trim()
+
+ if (previewSource) {
+ const firstMeaningfulLine = previewSource
+ .split('\n')
+ .map(line => line.trim())
+ .find(line => line.length > 0)
+
+ if (firstMeaningfulLine) {
+ return compactPreview(firstMeaningfulLine, 160)
+ }
+ }
+
+ const command = firstStringField(argsRecord, ['command', 'code']) || contextValue(argsRecord)
+
+ return command ? compactPreview(command, 120) : 'Executed command'
+ }
+
+ if (toolName === 'read_file' || toolName === 'write_file' || toolName === 'edit_file') {
+ const path =
+ firstStringField(argsRecord, ['path', 'file', 'filepath']) ||
+ htmlPathFromInlineDiff(firstStringField(resultRecord, ['inline_diff']))
+
+ return (
+ path ||
+ (firstStringField(resultRecord, ['inline_diff']) ? 'Changed file' : fallbackDetailText(argsRecord, resultRecord))
+ )
+ }
+
+ if (toolName === 'web_extract') {
+ const url =
+ firstStringField(argsRecord, ['url']) ||
+ firstStringField(resultRecord, ['url']) ||
+ findFirstUrl(argsRecord, resultRecord)
+
+ return url ? hostnameOf(url) : 'Fetched webpage'
+ }
+
+ return (
+ compactPreview(formatToolResultSummary(part.result), 120) ||
+ compactPreview(resultRecord, 120) ||
+ compactPreview(argsRecord, 120) ||
+ fallbackDetailText(argsRecord, resultRecord)
+ )
+}
+
+function toolDetailLabel(toolName: string): string {
+ if (toolName === 'web_search') {
+ return 'Details'
+ }
+
+ if (toolName === 'browser_snapshot') {
+ return 'Snapshot summary'
+ }
+
+ if (toolName === 'terminal' || toolName === 'execute_code') {
+ return 'Command output'
+ }
+
+ return ''
+}
+
+function toolDetailText(
+ part: ToolPart,
+ argsRecord: Record,
+ resultRecord: Record
+): string {
+ if (part.toolName === 'browser_snapshot') {
+ const snapshot = firstStringField(resultRecord, ['snapshot'])
+
+ return snapshot ? summarizeBrowserSnapshot(snapshot) : fallbackDetailText(argsRecord, resultRecord)
+ }
+
+ if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
+ const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr'])
+
+ const lines = Array.isArray(resultRecord.lines)
+ ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n')
+ : ''
+
+ if (output || lines) {
+ return [output, lines].filter(Boolean).join('\n')
+ }
+ }
+
+ if (part.toolName === 'web_extract') {
+ const direct = firstStringField(resultRecord, ['content', 'text', 'markdown', 'body', 'summary', 'message'])
+
+ if (direct) {
+ return direct.replace(/\s*in\s+\d+(?:\.\d+)?s\s*$/i, '').trim()
+ }
+
+ const results = Array.isArray(resultRecord.results) ? resultRecord.results : []
+
+ const aggregated = results
+ .map(item => {
+ const row = parseMaybeObject(item)
+
+ return firstStringField(row, ['content', 'text', 'markdown', 'body'])
+ })
+ .filter(Boolean)
+ .join('\n\n---\n\n')
+
+ if (aggregated) {
+ return aggregated
+ }
+ }
+
+ if (part.toolName === 'read_file') {
+ const content = firstStringField(resultRecord, ['content', 'text', 'data', 'body'])
+
+ if (content) {
+ return content
+ }
+ }
+
+ if (part.toolName === 'write_file' || part.toolName === 'edit_file') {
+ return inlineDiffFromResult(part.result) ? '' : fallbackDetailText(argsRecord, resultRecord)
+ }
+
+ if (part.toolName === 'web_search') {
+ const detail = fallbackDetailText(argsRecord, resultRecord)
+ const seconds = numberValue(resultRecord.duration_s)
+ const duration = seconds === null ? '' : formatDurationSeconds(seconds)
+
+ if (!duration) {
+ return detail
+ }
+
+ return detail
+ .replace(/^\s*-\s*Duration\s+S\s*:\s*[-+]?[\d.]+(?:e[-+]?\d+)?\s*$/gim, `- Duration: ${duration}`)
+ .replace(/\bDuration\s+S\s*:/gi, 'Duration:')
+ }
+
+ return fallbackDetailText(argsRecord, resultRecord)
+}
+
+export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string; text: string } {
+ const args = parseMaybeObject(part.args)
+ const result = parseMaybeObject(part.result)
+ const detail = view.detail.trim()
+ const hasSubstantialOutput = detail.length > 16
+
+ if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
+ if (hasSubstantialOutput) {
+ return { label: 'Copy output', text: detail }
+ }
+
+ const command = firstStringField(args, ['command', 'code']) || contextValue(args)
+
+ if (command) {
+ return { label: 'Copy command', text: command }
+ }
+ }
+
+ if (part.toolName === 'web_extract') {
+ if (hasSubstantialOutput) {
+ return { label: 'Copy content', text: detail }
+ }
+
+ const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
+
+ if (url) {
+ return { label: 'Copy URL', text: url }
+ }
+ }
+
+ if (part.toolName === 'browser_navigate') {
+ const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
+
+ if (url) {
+ return { label: 'Copy URL', text: url }
+ }
+ }
+
+ if (part.toolName === 'web_search') {
+ if (view.searchHits?.length) {
+ const text = view.searchHits.map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n')).join('\n\n')
+
+ return { label: 'Copy results', text }
+ }
+
+ const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
+
+ if (query) {
+ return { label: 'Copy query', text: query }
+ }
+ }
+
+ if (part.toolName === 'read_file') {
+ if (hasSubstantialOutput) {
+ return { label: 'Copy file', text: detail }
+ }
+
+ const path = firstStringField(args, ['path', 'file', 'filepath'])
+
+ if (path) {
+ return { label: 'Copy path', text: path }
+ }
+ }
+
+ if (part.toolName === 'write_file' || part.toolName === 'edit_file') {
+ const path = firstStringField(args, ['path', 'file', 'filepath'])
+
+ if (path) {
+ return { label: 'Copy path', text: path }
+ }
+ }
+
+ if (detail) {
+ return { label: 'Copy output', text: detail }
+ }
+
+ return { label: 'Copy', text: view.title }
+}
+
+function dynamicTitle(
+ part: ToolPart,
+ args: Record,
+ result: Record,
+ fallback: string
+): string {
+ const verb = (gerund: string, past: string) => (part.result === undefined ? gerund : past)
+
+ if (part.toolName === 'web_extract') {
+ const url = findFirstUrl(args, result)
+
+ return url ? `${verb('Reading', 'Read')} ${hostnameOf(url)}` : fallback
+ }
+
+ if (part.toolName === 'browser_navigate') {
+ const url = findFirstUrl(args, result)
+
+ return url ? `${verb('Opening', 'Opened')} ${hostnameOf(url)}` : fallback
+ }
+
+ if (part.toolName === 'web_search') {
+ const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
+
+ return query ? `${verb('Searching', 'Searched')} “${compactPreview(query, 48)}”` : fallback
+ }
+
+ if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
+ const command = firstStringField(args, ['command', 'code']) || contextValue(args)
+
+ if (command) {
+ const verbText = part.toolName === 'execute_code' ? verb('Running code', 'Ran code') : verb('Running', 'Ran')
+
+ return `${verbText} · ${compactPreview(command, 160)}`
+ }
+ }
+
+ return fallback
+}
+
+export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
+ const argsRecord = parseMaybeObject(part.args)
+ const resultRecord = parseMaybeObject(part.result)
+ const meta = toolMeta(part.toolName)
+ const status = toolStatus(part, resultRecord)
+ const error = toolErrorText(part, resultRecord)
+ const baseTitle = part.result === undefined ? meta.pending : meta.done
+ const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle)
+ const titleEnriched = title !== baseTitle
+ const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord)
+ const keepSubtitleWithTitle = part.toolName === 'terminal' || part.toolName === 'execute_code'
+ const subtitle = titleEnriched && !error && !keepSubtitleWithTitle ? '' : baseSubtitle
+ const detailBody = stripDividerLines(toolDetailText(part, argsRecord, resultRecord))
+
+ const detail = error
+ ? [error, detailBody]
+ .filter(Boolean)
+ .filter((value, index, list) => list.findIndex(entry => entry.trim() === value.trim()) === index)
+ .join('\n\n')
+ : detailBody
+
+ const searchHits =
+ part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined
+ const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
+
+ return {
+ countLabel: resultCount ? formatCountLabel(resultCount) : undefined,
+ detail,
+ detailLabel: error ? 'Error details' : toolDetailLabel(part.toolName),
+ durationLabel: durationLabel(resultRecord),
+ icon: meta.icon,
+ imageUrl: toolImageUrl(argsRecord, resultRecord),
+ inlineDiff,
+ previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
+ rawArgs: prettyJson(part.args),
+ rawResult: prettyJson(part.result),
+ searchHits: searchHits?.length ? searchHits : undefined,
+ status,
+ subtitle,
+ title,
+ tone: meta.tone
+ }
+}
+
+function isToolPart(part: unknown): part is ToolPart {
+ if (!part || typeof part !== 'object') {
+ return false
+ }
+
+ const row = part as Record
+
+ return row.type === 'tool-call' && typeof row.toolName === 'string'
+}
+
+export function groupToolParts(content: unknown): ToolPart[][] {
+ if (!Array.isArray(content)) {
+ return []
+ }
+
+ const groups: ToolPart[][] = []
+ let current: ToolPart[] = []
+
+ for (const part of content) {
+ // todo parts render in their own hoisted panel; skip from grouped tools.
+ if (isToolPart(part) && part.toolName !== 'todo') {
+ current.push(part)
+
+ continue
+ }
+
+ if (current.length) {
+ groups.push(current)
+ current = []
+ }
+ }
+
+ if (current.length) {
+ groups.push(current)
+ }
+
+ return groups
+}
+
+export function groupStatus(parts: ToolPart[]): ToolStatus {
+ if (parts.some(p => p.result === undefined)) {
+ return 'running'
+ }
+
+ const statuses = parts.map(part => toolStatus(part, parseMaybeObject(part.result)))
+ const hasError = statuses.includes('error')
+
+ if (!hasError) {
+ return 'success'
+ }
+
+ return statuses.at(-1) === 'success' ? 'warning' : 'error'
+}
+
+export function groupTitle(parts: ToolPart[]): string {
+ const prefix = PREFIX_META.find(p => parts.every(part => part.toolName.startsWith(p.prefix)))
+ const verb = prefix?.verb || 'Tool'
+
+ return `${verb} actions · ${parts.length} steps`
+}
+
+export function groupPreviewTargets(parts: ToolPart[]): string[] {
+ const seen = new Set()
+ const targets: string[] = []
+
+ for (const part of parts) {
+ const view = buildToolView(part, inlineDiffFromResult(part.result))
+ const target = view.previewTarget
+
+ if (target && isPreviewableTarget(target) && !seen.has(target)) {
+ seen.add(target)
+ targets.push(target)
+ }
+ }
+
+ return targets
+}
+
+export function groupFailedStepCount(parts: ToolPart[]): number {
+ return parts.filter(part => toolStatus(part, parseMaybeObject(part.result)) === 'error').length
+}
+
+export function groupTotalDurationLabel(parts: ToolPart[]): string {
+ const seconds = parts.reduce((sum, part) => {
+ const value = numberValue(parseMaybeObject(part.result).duration_s)
+
+ return sum + (value && value > 0 ? value : 0)
+ }, 0)
+
+ if (!seconds) {
+ return ''
+ }
+
+ return formatDurationSeconds(seconds)
+}
+
+export function groupTailSubtitle(parts: ToolPart[]): string {
+ const tail = parts.at(-1)
+
+ return tail ? buildToolView(tail, '').subtitle : ''
+}
+
+export function groupCopyText(parts: ToolPart[]): string {
+ return parts
+ .map(part => {
+ const view = buildToolView(part, '')
+ const lines = [view.title]
+
+ if (view.subtitle && view.subtitle !== view.title) {
+ lines.push(view.subtitle)
+ }
+
+ if (view.detail && view.detail !== view.subtitle) {
+ lines.push(view.detail)
+ }
+
+ return lines.join('\n')
+ })
+ .join('\n\n')
+}
diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
index 188dc62a94..09fc3279dc 100644
--- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
@@ -2,7 +2,8 @@
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
-import { type ReactNode, useEffect, useMemo, useRef } from 'react'
+import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react'
+import { useShallow } from 'zustand/shallow'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
@@ -10,1001 +11,48 @@ import { CompactMarkdown } from '@/components/chat/compact-markdown'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
+import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
-import { normalizeExternalUrl, PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
-import {
- AlertCircle,
- CheckCircle2,
- Command,
- FileText,
- Globe,
- LinkIcon,
- Loader2,
- Search,
- Sparkles,
- Wrench
-} from '@/lib/icons'
-import type { LucideIcon } from '@/lib/icons'
-import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
+import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
+import { AlertCircle, CheckCircle2 } from '@/lib/icons'
+import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { $toolInlineDiffs } from '@/store/tool-diffs'
-import { $toolDisclosureStates, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
-
-type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
-type ToolStatus = 'error' | 'running' | 'success' | 'warning'
-
-interface ToolPart {
- args?: unknown
- isError?: boolean
- result?: unknown
- toolCallId?: string
- toolName: string
- type: 'tool-call'
-}
-
-interface SearchResultRow {
- snippet: string
- title: string
- url: string
-}
-
-interface ToolView {
- detail: string
- detailLabel: string
- durationLabel?: string
- icon: LucideIcon
- imageUrl?: string
- inlineDiff: string
- previewTarget?: string
- rawArgs: string
- rawResult: string
- searchHits?: SearchResultRow[]
- status: ToolStatus
- subtitle: string
- title: string
- tone: ToolTone
-}
-
-interface ToolMeta {
- done: string
- icon: LucideIcon
- pending: string
- tone: ToolTone
-}
-
-const TOOL_META: Record = {
- browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: Globe, tone: 'browser' },
- browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: Globe, tone: 'browser' },
- browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: Globe, tone: 'browser' },
- browser_snapshot: {
- done: 'Captured page snapshot',
- pending: 'Capturing page snapshot',
- icon: Globe,
- tone: 'browser'
- },
- browser_take_screenshot: {
- done: 'Captured screenshot',
- pending: 'Capturing screenshot',
- icon: Sparkles,
- tone: 'browser'
- },
- browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: Globe, tone: 'browser' },
- edit_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' },
- execute_code: { done: 'Ran code', pending: 'Running code', icon: Command, tone: 'terminal' },
- image_generate: { done: 'Generated image', pending: 'Generating image', icon: Sparkles, tone: 'image' },
- list_files: { done: 'Listed files', pending: 'Listing files', icon: FileText, tone: 'file' },
- read_file: { done: 'Read file', pending: 'Reading file', icon: FileText, tone: 'file' },
- search_files: { done: 'Searched files', pending: 'Searching files', icon: FileText, tone: 'file' },
- session_search_recall: {
- done: 'Searched session history',
- pending: 'Searching session history',
- icon: Search,
- tone: 'agent'
- },
- terminal: { done: 'Ran command', pending: 'Running command', icon: Command, tone: 'terminal' },
- todo: { done: 'Updated todos', pending: 'Updating todos', icon: Wrench, tone: 'agent' },
- web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: LinkIcon, tone: 'web' },
- web_search: { done: 'Searched web', pending: 'Searching web', icon: Search, tone: 'web' },
- write_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' }
-}
-
-const TOOL_TONE_CLASS: Record = {
- agent: 'bg-amber-500/12 text-amber-700 dark:text-amber-300',
- browser: 'bg-sky-500/12 text-sky-700 dark:text-sky-300',
- default: 'bg-muted text-muted-foreground',
- file: 'bg-slate-500/12 text-slate-700 dark:text-slate-300',
- image: 'bg-rose-500/12 text-rose-700 dark:text-rose-300',
- terminal: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300',
- web: 'bg-violet-500/12 text-violet-700 dark:text-violet-300'
-}
-
-const STATUS_ICON_CLASS: Record = {
- error: 'bg-destructive/12 text-destructive',
- running: '',
- success: '',
- warning: 'bg-amber-500/14 text-amber-700 dark:text-amber-300'
-}
-
-const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
-const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*\d+)*)\](?!\()/gu
-const BACKTICK_NOISE_RE = /`{3,}/g
-
-function titleForTool(name: string): string {
- const normalized = name.replace(/^browser_/, '').replace(/^web_/, '')
-
- return (
- normalized
- .split('_')
- .filter(Boolean)
- .map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
- .join(' ') || name
- )
-}
-
-const PREFIX_META: { icon: LucideIcon; prefix: string; tone: ToolTone; verb: string }[] = [
- { prefix: 'browser_', verb: 'Browser', icon: Globe, tone: 'browser' },
- { prefix: 'web_', verb: 'Web', icon: Search, tone: 'web' }
-]
-
-function toolMeta(name: string): ToolMeta {
- if (TOOL_META[name]) {
- return TOOL_META[name]
- }
-
- const action = titleForTool(name)
- const prefix = PREFIX_META.find(p => name.startsWith(p.prefix))
-
- return prefix
- ? {
- done: `${prefix.verb} ${action}`,
- pending: `Running ${prefix.verb.toLowerCase()} ${action.toLowerCase()}`,
- icon: prefix.icon,
- tone: prefix.tone
- }
- : { done: action, pending: `Running ${action.toLowerCase()}`, icon: Wrench, tone: 'default' }
-}
-
-function isRecord(value: unknown): value is Record {
- return Boolean(value && typeof value === 'object' && !Array.isArray(value))
-}
-
-function compactPreview(value: unknown, max = 72): string {
- let raw: unknown
-
- if (typeof value === 'string') {
- raw = value
- } else {
- raw = parseMaybeObject(value).context
- }
-
- if (typeof raw !== 'string') {
- if (raw == null) {
- raw = ''
- } else {
- try {
- raw = JSON.stringify(raw)
- } catch {
- raw = String(raw)
- }
- }
- }
-
- const line = (raw as string).replace(/\s+/g, ' ').trim()
-
- return line.length > max ? `${line.slice(0, max - 1)}…` : line
-}
-
-function contextValue(value: unknown): string {
- const row = parseMaybeObject(value)
-
- if (typeof row.context === 'string') {
- return row.context
- }
-
- if (typeof row.preview === 'string') {
- return row.preview
- }
-
- return typeof value === 'string' ? value : ''
-}
-
-function prettyJson(value: unknown): string {
- return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
-}
-
-function parseMaybeObject(value: unknown): Record {
- if (isRecord(value)) {
- return value
- }
-
- if (typeof value !== 'string' || !value.trim()) {
- return {}
- }
-
- try {
- const parsed = JSON.parse(value)
-
- return isRecord(parsed) ? parsed : {}
- } catch {
- return {}
- }
-}
-
-function unwrapToolPayload(value: unknown): unknown {
- const record = parseMaybeObject(value)
-
- for (const key of ['data', 'result', 'output', 'response', 'payload']) {
- const payload = record[key]
-
- if (payload !== undefined && payload !== null) {
- return payload
- }
- }
-
- return value
-}
-
-function numberValue(value: unknown): null | number {
- const n = typeof value === 'number' ? value : Number(value)
-
- return Number.isFinite(n) ? n : null
-}
-
-function looksLikeUrl(value: string): boolean {
- return /^https?:\/\//i.test(value)
-}
-
-function looksLikePath(value: string): boolean {
- return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
-}
-
-function isPreviewableTarget(target: string): boolean {
- return Boolean(
- target &&
- (/^file:\/\//i.test(target) ||
- /^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) ||
- /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target))
- )
-}
-
-function stableHash(value: string): string {
- let hash = 0
-
- for (let index = 0; index < value.length; index += 1) {
- hash = Math.imul(31, hash) + value.charCodeAt(index)
- }
-
- return Math.abs(hash).toString(36)
-}
-
-function toolPartDisclosureId(part: ToolPart): string {
- if (part.toolCallId) {
- return `tool:${part.toolCallId}`
- }
-
- return `tool:${part.toolName}:${stableHash(JSON.stringify(part.args ?? ''))}`
-}
-
-function toolGroupDisclosureId(parts: ToolPart[]): string {
- return `tool-group:${parts.map(toolPartDisclosureId).join('|')}`
-}
-
-const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i
-
-function findFirstUrl(...sources: unknown[]): string {
- for (const src of sources) {
- if (typeof src === 'string') {
- const m = src.match(URL_PATTERN)
-
- if (m) {
- return m[0]
- }
- } else if (src && typeof src === 'object') {
- for (const v of Object.values(src as Record)) {
- const found = findFirstUrl(v)
-
- if (found) {
- return found
- }
- }
- }
- }
-
- return ''
-}
-
-function hostnameOf(value: string): string {
- try {
- const url = new URL(value)
-
- return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}`
- } catch {
- return value
- }
-}
-
-function looksRedundant(title: string, detail: string): boolean {
- if (!detail) {
- return true
- }
-
- const norm = (input: string) => input.toLowerCase().replace(/\s+/g, ' ').trim()
-
- return norm(title) === norm(detail)
-}
-
-function cleanVisibleText(text: string): string {
- return text
- .split(INLINE_CODE_SPLIT_RE)
- .map(part =>
- part.startsWith('`')
- ? part
- : part
- .replace(BACKTICK_NOISE_RE, '')
- .replace(CITATION_MARKER_RE, '')
- .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label: string, href: string) => {
- const normalized = normalizeExternalUrl(href)
-
- return `${label} ${normalized}`
- })
- )
- .join('')
-}
-
-function LinkifiedText({ className, text }: { className?: string; text: string }) {
- return
-}
-
-function summarizeBrowserSnapshot(snapshot: string): string {
- const count = (re: RegExp) => snapshot.match(re)?.length ?? 0
-
- const stats = [
- `${count(/button\s+"[^"]+"/g)} buttons`,
- `${count(/link\s+"[^"]+"/g)} links`,
- `${count(/(?:textbox|combobox|searchbox)\s+"[^"]+"/g)} inputs`
- ].join(' · ')
-
- const labels = Array.from(snapshot.matchAll(/(?:button|link|combobox|textbox)\s+"([^"]+)"/g))
- .map(m => m[1].trim())
- .filter(Boolean)
- .slice(0, 4)
-
- return labels.length ? `${stats}\nTop controls: ${labels.join(', ')}` : stats
-}
-
-function firstStringField(record: Record, keys: readonly string[]): string {
- for (const key of keys) {
- const value = record[key]
-
- if (typeof value === 'string' && value.trim()) {
- return value.trim()
- }
- }
-
- return ''
-}
-
-function collectResultItems(value: unknown): unknown[] {
- if (Array.isArray(value)) {
- return value
- }
-
- const record = parseMaybeObject(value)
-
- for (const key of ['web', 'results', 'items', 'organic_results', 'organic', 'matches', 'documents']) {
- const candidate = record[key]
-
- if (Array.isArray(candidate)) {
- return candidate
- }
-
- if (isRecord(candidate)) {
- const nested = collectResultItems(candidate)
-
- if (nested.length) {
- return nested
- }
- }
- }
-
- const payload = unwrapToolPayload(record)
-
- return payload === record ? [] : collectResultItems(payload)
-}
-
-function extractSearchResults(result: unknown, limit = 6): SearchResultRow[] {
- const list = collectResultItems(result)
-
- return list
- .map(item => {
- const r = parseMaybeObject(item)
-
- return {
- title: cleanVisibleText(firstStringField(r, ['title', 'name'])),
- url: firstStringField(r, ['url', 'href', 'link']),
- snippet: cleanVisibleText(firstStringField(r, ['snippet', 'description', 'body']))
- }
- })
- .filter(hit => hit.title || hit.url)
- .slice(0, limit)
-}
-
-function toolErrorText(part: ToolPart, result: Record): string {
- const extractedError = extractToolErrorMessage(part.result)
-
- if (part.isError) {
- return extractedError || (typeof part.result === 'string' && part.result.trim()) || 'Tool returned an error.'
- }
-
- if (typeof result.error === 'string' && result.error.trim()) {
- return result.error.trim()
- }
-
- if (extractedError) {
- return extractedError
- }
-
- if (result.success === false || result.ok === false) {
- return firstStringField(result, ['message', 'reason', 'detail']) || 'Tool returned success=false.'
- }
-
- if (typeof result.status === 'string' && /\b(error|failed|failure)\b/i.test(result.status)) {
- return firstStringField(result, ['message', 'reason', 'detail']) || `Tool returned status "${result.status}".`
- }
-
- const exit = numberValue(result.exit_code)
-
- return exit !== null && exit !== 0 ? `Command failed with exit code ${exit}.` : ''
-}
-
-function toolStatus(part: ToolPart, resultRecord: Record): ToolStatus {
- if (part.result === undefined) {
- return 'running'
- }
-
- return toolErrorText(part, resultRecord) ? 'error' : 'success'
-}
-
-function durationLabel(resultRecord: Record): string | undefined {
- const seconds = numberValue(resultRecord.duration_s)
-
- if (seconds === null || seconds < 0) {
- return undefined
- }
-
- return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s`
-}
-
-function toolPreviewTarget(toolName: string, args: Record, result: Record): string {
- const direct =
- firstStringField(result, ['preview', 'url', 'target']) ||
- firstStringField(args, ['preview', 'url', 'target', 'path', 'file', 'filepath']) ||
- firstStringField(result, ['path', 'file', 'filepath'])
-
- if (direct && (looksLikeUrl(direct) || looksLikePath(direct))) {
- return direct
- }
-
- if (toolName === 'browser_navigate' || toolName === 'web_extract' || toolName === 'web_search') {
- const explicit = firstStringField(args, ['url', 'search_term', 'query']) || firstStringField(result, ['url'])
-
- return looksLikeUrl(explicit) ? explicit : findFirstUrl(args, result)
- }
-
- if (toolName === 'write_file' || toolName === 'edit_file') {
- return htmlPathFromInlineDiff(firstStringField(result, ['inline_diff']))
- }
-
- return ''
-}
-
-function toolImageUrl(args: Record, result: Record): string {
- const candidate =
- firstStringField(result, ['image_url', 'url', 'path', 'image_path']) ||
- firstStringField(args, ['image_url', 'url', 'path'])
-
- if (!candidate) {
- return ''
- }
-
- return candidate.toLowerCase().startsWith('data:image/') || /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(candidate)
- ? candidate
- : ''
-}
-
-function stripAnsi(value: string): string {
- return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
-}
-
-function stripInlineDiffChrome(value: string): string {
- return value
- ? stripAnsi(value)
- .replace(/^\s*┊\s*review diff\s*\n/i, '')
- .trim()
- : ''
-}
-
-function htmlPathFromInlineDiff(value: string): string {
- const cleaned = stripInlineDiffChrome(value)
-
- for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) {
- const candidate = match[1]?.trim()
-
- if (candidate) {
- return candidate
- }
- }
-
- return ''
-}
-
-function stripDividerLines(value: string): string {
- return value
- .split('\n')
- .filter(line => !/^[-=]{3,}\s*$/.test(line.trim()))
- .join('\n')
- .trim()
-}
-
-function inlineDiffFromResult(result: unknown): string {
- const value = parseMaybeObject(result).inline_diff
-
- return typeof value === 'string' ? stripInlineDiffChrome(value) : ''
-}
-
-function minimalValueSummary(value: unknown): string {
- if (value == null) {
- return ''
- }
-
- if (typeof value === 'string') {
- return value
- }
-
- if (typeof value === 'number' || typeof value === 'boolean') {
- return String(value)
- }
-
- if (Array.isArray(value)) {
- return value.length ? `Returned ${value.length} items.` : 'No items returned.'
- }
-
- if (isRecord(value)) {
- const count = Object.keys(value).length
-
- return count ? `Returned object with ${count} fields.` : 'Returned an empty object.'
- }
-
- return String(value)
-}
-
-function fallbackDetailText(args: unknown, result: unknown): string {
- const argContext = contextValue(args)
- const resultContext = contextValue(result)
-
- if (resultContext && resultContext !== argContext) {
- return resultContext
- }
-
- if (argContext) {
- return argContext
- }
-
- if (result !== undefined) {
- return formatToolResultSummary(result) || minimalValueSummary(result)
- }
-
- return formatToolResultSummary(args) || minimalValueSummary(args)
-}
-
-function toolSubtitle(
- part: ToolPart,
- argsRecord: Record,
- resultRecord: Record
-): string {
- const toolName = part.toolName
-
- if (toolName === 'browser_navigate') {
- const url =
- firstStringField(argsRecord, ['url', 'target']) ||
- firstStringField(resultRecord, ['url']) ||
- findFirstUrl(argsRecord, resultRecord)
-
- return url ? hostnameOf(url) : 'Navigated in browser'
- }
-
- if (toolName === 'browser_snapshot') {
- const snapshot = firstStringField(resultRecord, ['snapshot'])
-
- return snapshot ? summarizeBrowserSnapshot(snapshot) : 'Captured a browser accessibility snapshot'
- }
-
- if (toolName === 'browser_click') {
- const clicked = firstStringField(resultRecord, ['clicked']) || firstStringField(argsRecord, ['ref', 'target'])
-
- if (!clicked) {
- return 'Clicked on page'
- }
-
- return clicked.startsWith('@') ? `Clicked page element (internal ref ${clicked})` : `Clicked ${clicked}`
- }
-
- if (toolName === 'browser_fill' || toolName === 'browser_type') {
- const field = firstStringField(argsRecord, ['label', 'field', 'ref', 'target'])
- const value = firstStringField(argsRecord, ['value', 'text'])
-
- return (
- [field && `Field: ${field}`, value && `Value: ${compactPreview(value, 42)}`].filter(Boolean).join(' · ') ||
- 'Filled page input'
- )
- }
-
- if (toolName === 'web_search') {
- const query = firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord)
-
- return query ? `Query: ${query}` : 'Queried web sources'
- }
-
- if (toolName === 'terminal' || toolName === 'execute_code') {
- const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr'])
-
- const lines = Array.isArray(resultRecord.lines)
- ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n')
- : ''
-
- const previewSource = (output || lines).trim()
-
- if (previewSource) {
- const firstMeaningfulLine = previewSource
- .split('\n')
- .map(line => line.trim())
- .find(line => line.length > 0)
-
- if (firstMeaningfulLine) {
- return compactPreview(firstMeaningfulLine, 160)
- }
- }
-
- const command = firstStringField(argsRecord, ['command', 'code']) || contextValue(argsRecord)
-
- return command ? compactPreview(command, 120) : 'Executed command'
- }
-
- if (toolName === 'read_file' || toolName === 'write_file' || toolName === 'edit_file') {
- const path =
- firstStringField(argsRecord, ['path', 'file', 'filepath']) ||
- htmlPathFromInlineDiff(firstStringField(resultRecord, ['inline_diff']))
-
- return (
- path ||
- (firstStringField(resultRecord, ['inline_diff']) ? 'Changed file' : fallbackDetailText(argsRecord, resultRecord))
- )
- }
-
- if (toolName === 'web_extract') {
- const url =
- firstStringField(argsRecord, ['url']) ||
- firstStringField(resultRecord, ['url']) ||
- findFirstUrl(argsRecord, resultRecord)
-
- return url ? hostnameOf(url) : 'Fetched webpage'
- }
-
- return (
- compactPreview(formatToolResultSummary(part.result), 120) ||
- compactPreview(resultRecord, 120) ||
- compactPreview(argsRecord, 120) ||
- fallbackDetailText(argsRecord, resultRecord)
- )
-}
-
-function toolDetailLabel(toolName: string): string {
- if (toolName === 'web_search') {
- return 'Search results'
- }
-
- if (toolName === 'browser_snapshot') {
- return 'Snapshot summary'
- }
-
- if (toolName === 'terminal' || toolName === 'execute_code') {
- return 'Command output'
- }
-
- return ''
-}
-
-function toolDetailText(
- part: ToolPart,
- argsRecord: Record,
- resultRecord: Record
-): string {
- if (part.toolName === 'browser_snapshot') {
- const snapshot = firstStringField(resultRecord, ['snapshot'])
-
- return snapshot ? summarizeBrowserSnapshot(snapshot) : fallbackDetailText(argsRecord, resultRecord)
- }
-
- if (part.toolName === 'web_search') {
- // Structured render takes over for search results — see view.searchHits.
- // The text fallback below is kept only for the case where extraction
- // fails entirely so the user still sees something useful.
- const hits = extractSearchResults(part.result)
-
- if (hits.length) {
- return ''
- }
- }
-
- if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
- const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr'])
-
- const lines = Array.isArray(resultRecord.lines)
- ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n')
- : ''
-
- if (output || lines) {
- return [output, lines].filter(Boolean).join('\n')
- }
- }
-
- if (part.toolName === 'web_extract') {
- const direct = firstStringField(resultRecord, ['content', 'text', 'markdown', 'body', 'summary', 'message'])
-
- if (direct) {
- return direct.replace(/\s*in\s+\d+(?:\.\d+)?s\s*$/i, '').trim()
- }
-
- const results = Array.isArray(resultRecord.results) ? resultRecord.results : []
-
- const aggregated = results
- .map(item => {
- const row = parseMaybeObject(item)
-
- return firstStringField(row, ['content', 'text', 'markdown', 'body'])
- })
- .filter(Boolean)
- .join('\n\n---\n\n')
-
- if (aggregated) {
- return aggregated
- }
- }
-
- if (part.toolName === 'read_file') {
- const content = firstStringField(resultRecord, ['content', 'text', 'data', 'body'])
-
- if (content) {
- return content
- }
- }
-
- if (part.toolName === 'write_file' || part.toolName === 'edit_file') {
- return inlineDiffFromResult(part.result) ? '' : fallbackDetailText(argsRecord, resultRecord)
- }
-
- return fallbackDetailText(argsRecord, resultRecord)
-}
-
-function toolCopyPayload(part: ToolPart, view: ToolView): { label: string; text: string } {
- const args = parseMaybeObject(part.args)
- const result = parseMaybeObject(part.result)
- const detail = view.detail.trim()
- const hasSubstantialOutput = detail.length > 16
-
- if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
- if (hasSubstantialOutput) {
- return { label: 'Copy output', text: detail }
- }
-
- const command = firstStringField(args, ['command', 'code']) || contextValue(args)
-
- if (command) {
- return { label: 'Copy command', text: command }
- }
- }
-
- if (part.toolName === 'web_extract') {
- if (hasSubstantialOutput) {
- return { label: 'Copy content', text: detail }
- }
-
- const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
-
- if (url) {
- return { label: 'Copy URL', text: url }
- }
- }
-
- if (part.toolName === 'browser_navigate') {
- const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
-
- if (url) {
- return { label: 'Copy URL', text: url }
- }
- }
-
- if (part.toolName === 'web_search') {
- if (view.searchHits?.length) {
- const text = view.searchHits
- .map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n'))
- .join('\n\n')
-
- return { label: 'Copy results', text }
- }
-
- const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
-
- if (query) {
- return { label: 'Copy query', text: query }
- }
- }
-
- if (part.toolName === 'read_file') {
- if (hasSubstantialOutput) {
- return { label: 'Copy file', text: detail }
- }
-
- const path = firstStringField(args, ['path', 'file', 'filepath'])
-
- if (path) {
- return { label: 'Copy path', text: path }
- }
- }
-
- if (part.toolName === 'write_file' || part.toolName === 'edit_file') {
- const path = firstStringField(args, ['path', 'file', 'filepath'])
-
- if (path) {
- return { label: 'Copy path', text: path }
- }
- }
-
- if (detail) {
- return { label: 'Copy output', text: detail }
- }
-
- return { label: 'Copy', text: view.title }
-}
-
-function dynamicTitle(
- part: ToolPart,
- args: Record,
- result: Record,
- fallback: string
-): string {
- const verb = (gerund: string, past: string) => (part.result === undefined ? gerund : past)
-
- if (part.toolName === 'web_extract') {
- const url = findFirstUrl(args, result)
-
- return url ? `${verb('Reading', 'Read')} ${hostnameOf(url)}` : fallback
- }
-
- if (part.toolName === 'browser_navigate') {
- const url = findFirstUrl(args, result)
-
- return url ? `${verb('Opening', 'Opened')} ${hostnameOf(url)}` : fallback
- }
-
- if (part.toolName === 'web_search') {
- const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
-
- return query ? `${verb('Searching', 'Searched')} “${compactPreview(query, 48)}”` : fallback
- }
-
- if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
- const command = firstStringField(args, ['command', 'code']) || contextValue(args)
-
- if (command) {
- const verbText = part.toolName === 'execute_code' ? verb('Running code', 'Ran code') : verb('Running', 'Ran')
-
- return `${verbText} · ${compactPreview(command, 160)}`
- }
- }
-
- return fallback
-}
-
-function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
- const argsRecord = parseMaybeObject(part.args)
- const resultRecord = parseMaybeObject(part.result)
- const meta = toolMeta(part.toolName)
- const status = toolStatus(part, resultRecord)
- const error = toolErrorText(part, resultRecord)
- const baseTitle = part.result === undefined ? meta.pending : meta.done
- const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle)
- const titleEnriched = title !== baseTitle
- const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord)
- const keepSubtitleWithTitle = part.toolName === 'terminal' || part.toolName === 'execute_code'
- const subtitle = titleEnriched && !error && !keepSubtitleWithTitle ? '' : baseSubtitle
- const detailBody = stripDividerLines(toolDetailText(part, argsRecord, resultRecord))
-
- const detail = error
- ? [error, detailBody]
- .filter(Boolean)
- .filter((value, index, list) => list.findIndex(entry => entry.trim() === value.trim()) === index)
- .join('\n\n')
- : detailBody
-
- const searchHits =
- part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined
-
- return {
- detail,
- detailLabel: error ? 'Error details' : toolDetailLabel(part.toolName),
- durationLabel: durationLabel(resultRecord),
- icon: meta.icon,
- imageUrl: toolImageUrl(argsRecord, resultRecord),
- inlineDiff,
- previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
- rawArgs: prettyJson(part.args),
- rawResult: prettyJson(part.result),
- searchHits: searchHits?.length ? searchHits : undefined,
- status,
- subtitle,
- title,
- tone: meta.tone
- }
-}
-
-function isToolPart(part: unknown): part is ToolPart {
- if (!part || typeof part !== 'object') {
- return false
- }
-
- const row = part as Record
-
- return row.type === 'tool-call' && typeof row.toolName === 'string'
-}
-
-function groupToolParts(content: unknown): ToolPart[][] {
- if (!Array.isArray(content)) {return []}
-
- const groups: ToolPart[][] = []
- let current: ToolPart[] = []
-
- for (const part of content) {
- // todo parts render in their own hoisted panel; skip from grouped tools.
- if (isToolPart(part) && part.toolName !== 'todo') {
- current.push(part)
-
- continue
- }
-
- if (current.length) {
- groups.push(current)
- current = []
- }
- }
-
- if (current.length) {groups.push(current)}
-
- return groups
-}
-
-function groupStatus(parts: ToolPart[]): ToolStatus {
- if (parts.some(p => p.result === undefined)) {
- return 'running'
- }
-
- const statuses = parts.map(part => toolStatus(part, parseMaybeObject(part.result)))
- const hasError = statuses.includes('error')
-
- if (!hasError) {
- return 'success'
- }
-
- return statuses.at(-1) === 'success' ? 'warning' : 'error'
-}
-
-function groupTitle(parts: ToolPart[]): string {
- const prefix = PREFIX_META.find(p => parts.every(part => part.toolName.startsWith(p.prefix)))
- const verb = prefix?.verb || 'Tool'
-
- return `${verb} actions · ${parts.length} steps`
-}
+import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
+
+import {
+ groupCopyText as buildGroupCopyText,
+ buildToolView,
+ cleanVisibleText,
+ compactPreview,
+ groupFailedStepCount,
+ groupPreviewTargets,
+ groupStatus,
+ groupTailSubtitle,
+ groupTitle,
+ groupTotalDurationLabel,
+ inlineDiffFromResult,
+ isPreviewableTarget,
+ looksRedundant,
+ type SearchResultRow,
+ selectMessageRunning,
+ stripInlineDiffChrome,
+ toolCopyPayload,
+ type ToolPart,
+ toolPartDisclosureId,
+ type ToolStatus
+} from './tool-fallback-model'
+
+// Tool names that ChainToolFallback intercepts and renders as something
+// other than a ToolEntry — they don't count toward "is this a group of
+// tool calls?" because they have no visible tool block.
+const SPECIAL_TOOL_NAMES = new Set(['todo', 'image_generate', 'clarify'])
+
+// `true` when the current ToolEntry is being rendered inside a group
+// wrapper. Lets ToolEntry suppress per-row chrome (timer / preview) that
+// the group already shows.
+const ToolEmbedContext = createContext(false)
const STATUS_DOT_CLASS: Record = {
error: 'bg-destructive',
@@ -1031,7 +79,13 @@ function statusDot(status: ToolStatus): ReactNode {
function statusGlyph(status: ToolStatus): ReactNode {
if (status === 'running') {
- return
+ return (
+
+ )
}
if (status === 'error') {
@@ -1074,19 +128,34 @@ function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
)
}
+function LinkifiedText({ className, text }: { className?: string; text: string }) {
+ return
+}
+
interface ToolEntryProps {
- embedded?: boolean
part: ToolPart
}
-function ToolEntry({ embedded = false, part }: ToolEntryProps) {
- const messageRunning = useAuiState(state => state.thread.isRunning && state.message.status?.type === 'running')
+function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean {
+ const persistedOpen = useStore($toolDisclosureOpen(disclosureId))
+
+ return persistedOpen ?? fallbackOpen
+}
+
+function ToolEntry({ part }: ToolEntryProps) {
+ const messageId = useAuiState(s => s.message.id)
+ const messageRunning = useAuiState(selectMessageRunning)
+ const embedded = useContext(ToolEmbedContext)
const toolViewMode = useStore($toolViewMode)
- const disclosureStates = useStore($toolDisclosureStates)
- const disclosureId = toolPartDisclosureId(part)
+ const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}`
+ const open = useDisclosureOpen(disclosureId)
const isPending = messageRunning && part.result === undefined
+ // Only animate entries that mount while their message is actively
+ // streaming — historical sessions mount with `messageRunning === false`,
+ // so they paint statically without a settle cascade. The wrapping group
+ // handles its own enter animation, so embedded children skip it.
+ const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`)
const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`)
- const open = disclosureStates[disclosureId] ?? false
const preview = compactPreview(part.args) || compactPreview(part.result)
const liveDiffs = useStore($toolInlineDiffs)
const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : ''
@@ -1139,6 +208,13 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
(part.toolName === 'terminal' || part.toolName === 'execute_code' || part.toolName === 'read_file')
const hasSearchHits = Boolean(view.searchHits?.length)
+ const searchResultsLabel = part.toolName === 'web_search' ? 'Search results' : view.detailLabel
+
+ const showRawSearchDrilldown =
+ part.toolName === 'web_search' &&
+ part.result !== undefined &&
+ toolViewMode !== 'technical' &&
+ Boolean(view.rawResult.trim())
const hasExpandableContent = Boolean(
(view.previewTarget && isPreviewableTarget(view.previewTarget)) ||
@@ -1156,16 +232,17 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
const trailing =
isPending && !embedded ? (
-
+
) : !isPending && copyAction.text ? (
) : undefined
return (
-
+
setToolDisclosureOpen(disclosureId, !open) : undefined}
open={open}
@@ -1187,6 +264,9 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
>
{view.title}
+ {!isPending && view.countLabel && (
+ {view.countLabel}
+ )}
{!isPending && view.durationLabel && (
{view.durationLabel}
@@ -1215,7 +295,7 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
))}
{open && (
-
+
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
)}
@@ -1226,9 +306,9 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
)}
{hasSearchHits && view.searchHits && (
- {view.detailLabel && (
+ {searchResultsLabel && (
- {view.detailLabel}
+ {searchResultsLabel}
)}
@@ -1269,6 +349,16 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
)}
))}
+ {showRawSearchDrilldown && (
+
+
+ Raw response
+
+
+ {view.rawResult}
+
+
+ )}
{toolViewMode === 'technical' && (
@@ -1295,60 +385,63 @@ function JsonSection({ label, value }: { label: string; value: string }) {
)
}
-function groupPreviewTargets(parts: ToolPart[]): string[] {
- const seen = new Set
()
- const targets: string[] = []
+/**
+ * Always-present wrapper around the consecutive tool-call range that
+ * `MessagePrimitive.Parts` already grouped for us. Renders a header +
+ * collapsible body when there are 2+ visible tools; otherwise it's a
+ * transparent passthrough that just owns the entry animation for the
+ * single ToolEntry inside.
+ *
+ * Crucially, the wrapper element is the SAME `` regardless of
+ * group size — only the optional header element appears/disappears.
+ * That preserves React identity for the inner `MessagePartByIndex`
+ * children when the 1→2 transition happens, so existing tool blocks
+ * never remount when a new tool joins them mid-stream.
+ *
+ * The previous design (per-tool ToolFallback computing its own group
+ * lookup and conditionally returning either `
` or
+ * ``) flipped the React element type at the 1→2 transition
+ * and tore down the existing tool entirely, which is what showed up as
+ * "the previous tool's animation resets every time a new tool arrives."
+ */
+export const ToolGroupSlot: FC> = ({
+ children,
+ endIndex,
+ startIndex
+}) => {
+ const messageId = useAuiState(s => s.message.id)
+ const messageRunning = useAuiState(selectMessageRunning)
- for (const part of parts) {
- const view = buildToolView(part, inlineDiffFromResult(part.result))
- const target = view.previewTarget
+ // Pull the visible tool parts in this range. `useShallow` makes this
+ // re-render only when the actual part references change (assistant-ui
+ // gives stable refs for unchanged parts), not on every text/reasoning
+ // delta elsewhere in the message.
+ const visibleParts = useAuiState(
+ useShallow((s: { message: { parts: readonly unknown[] } }) =>
+ s.message.parts.slice(startIndex, endIndex + 1).filter((p): p is ToolPart => {
+ if (!p || typeof p !== 'object') {
+ return false
+ }
+ const row = p as { toolName?: unknown; type?: unknown }
- if (target && isPreviewableTarget(target) && !seen.has(target)) {
- seen.add(target)
- targets.push(target)
- }
- }
-
- return targets
-}
-
-function ToolGroup({ parts }: { parts: ToolPart[] }) {
- const messageRunning = useAuiState(state => state.thread.isRunning && state.message.status?.type === 'running')
- const isRunning = messageRunning && parts.some(part => part.result === undefined)
- const disclosureStates = useStore($toolDisclosureStates)
- const disclosureId = toolGroupDisclosureId(parts)
- const open = disclosureStates[disclosureId] ?? isRunning
- const wasRunningRef = useRef(isRunning)
-
- useEffect(() => {
- if (wasRunningRef.current && !isRunning) {
- setToolDisclosureOpen(disclosureId, false)
- }
-
- wasRunningRef.current = isRunning
- }, [disclosureId, isRunning])
-
- const status = groupStatus(parts)
- const displayStatus = !isRunning && status === 'running' ? 'warning' : status
-
- const failedStepCount = useMemo(
- () => parts.filter(part => toolStatus(part, parseMaybeObject(part.result)) === 'error').length,
- [parts]
+ return row.type === 'tool-call' && typeof row.toolName === 'string' && !SPECIAL_TOOL_NAMES.has(row.toolName)
+ })
+ )
)
- const totalDurationLabel = useMemo(() => {
- const seconds = parts.reduce((sum, part) => {
- const value = numberValue(parseMaybeObject(part.result).duration_s)
+ const isGroup = visibleParts.length > 1
+ const isRunning = messageRunning && visibleParts.some(p => p.result === undefined)
+ // Stable across the group's lifetime (start index doesn't shift when
+ // tools append to the end), so user-driven open/close persists across
+ // streaming.
+ const disclosureId = `tool-group:${messageId}:${startIndex}`
+ const open = useDisclosureOpen(disclosureId)
+ const enterRef = useEnterAnimation(messageRunning, disclosureId)
- return sum + (value && value > 0 ? value : 0)
- }, 0)
-
- if (!seconds) {
- return ''
- }
-
- return seconds >= 10 ? `${seconds.toFixed(0)}s` : `${seconds.toFixed(1)}s`
- }, [parts])
+ const status = groupStatus(visibleParts)
+ const displayStatus = !isRunning && status === 'running' ? 'success' : status
+ const failedStepCount = useMemo(() => groupFailedStepCount(visibleParts), [visibleParts])
+ const totalDurationLabel = useMemo(() => groupTotalDurationLabel(visibleParts), [visibleParts])
const statusSummary =
displayStatus === 'running' || failedStepCount === 0
@@ -1361,126 +454,89 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
? '1 step failed'
: `${failedStepCount} steps failed`
- const tailSummary = useMemo(() => {
- const tail = parts.at(-1)
-
- return tail ? buildToolView(tail, '').subtitle : ''
- }, [parts])
-
- const groupCopyText = useMemo(() => {
- return parts
- .map(part => {
- const view = buildToolView(part, '')
- const lines = [view.title]
-
- if (view.subtitle && view.subtitle !== view.title) {
- lines.push(view.subtitle)
- }
-
- if (view.detail && view.detail !== view.subtitle) {
- lines.push(view.detail)
- }
-
- return lines.join('\n')
- })
- .join('\n\n')
- }, [parts])
-
+ const tailSummary = useMemo(() => groupTailSubtitle(visibleParts), [visibleParts])
+ const groupCopyText = useMemo(() => buildGroupCopyText(visibleParts), [visibleParts])
+ const previewTargets = useMemo(() => groupPreviewTargets(visibleParts), [visibleParts])
const showGroupStatusGlyph = displayStatus !== 'success'
- const previewTargets = useMemo(() => groupPreviewTargets(parts), [parts])
return (
-
-
setToolDisclosureOpen(disclosureId, !open)}
- open={open}
- trailing={
- !isRunning && groupCopyText ? (
-
- ) : undefined
- }
- >
-
- {showGroupStatusGlyph && (
- {statusGlyph(displayStatus)}
- )}
-
+
+ {isGroup && (
+
setToolDisclosureOpen(disclosureId, !open)}
+ open={open}
+ trailing={
+ !isRunning && groupCopyText ? (
+
+ ) : undefined
+ }
>
- {groupTitle(parts)}
-
- {totalDurationLabel && (
-
- {totalDurationLabel}
+
+ {showGroupStatusGlyph && (
+ {statusGlyph(displayStatus)}
+ )}
+
+ {groupTitle(visibleParts)}
+
+ {totalDurationLabel && (
+
+ {totalDurationLabel}
+
+ )}
- )}
-
- {tailSummary && (
-
- {tailSummary.replace(/\n+/g, ' · ')}
-
- )}
- {statusSummary && (
-
+ {tailSummary.replace(/\n+/g, ' · ')}
+
)}
- >
- {statusSummary}
-
+ {statusSummary && (
+
+ {statusSummary}
+
+ )}
+
)}
-
- {previewTargets.length > 0 && (
-
- {previewTargets.map(target => (
-
- ))}
+ {isGroup && previewTargets.length > 0 && (
+
+ {previewTargets.map(target => (
+
+ ))}
+
+ )}
+ {/* Body is always rendered so children stay mounted across collapse/
+ expand and across the 1→2 group transition. `hidden` removes it
+ from a11y/visual flow without unmounting React subtree. */}
+
+ {children}
- )}
- {open && (
-
- {parts.map(part => (
-
- ))}
-
- )}
-
+
+
)
}
+/**
+ * Per-tool fallback. Now strictly returns a single ToolEntry — the
+ * grouping decision lives in ToolGroupSlot above, so this never swaps
+ * its return type and the underlying ToolEntry stays mounted across
+ * group-shape changes.
+ */
export const ToolFallback = ({ toolCallId, toolName, args, isError, result }: ToolCallMessagePartProps) => {
- const messageContent = useAuiState(state => state.message.content as unknown)
- const groups = useMemo(() => groupToolParts(messageContent), [messageContent])
+ const part: ToolPart = { args, isError, result, toolCallId, toolName, type: 'tool-call' }
- const currentPart: ToolPart = {
- args,
- isError,
- result,
- toolCallId,
- toolName,
- type: 'tool-call'
- }
-
- if (!toolCallId) {
- return
- }
-
- const group = groups.find(parts => parts.some(part => part.toolCallId === toolCallId))
-
- if (!group || group.length <= 1) {
- return
- }
-
- if (group[0]?.toolCallId !== toolCallId) {
- return null
- }
-
- return
+ return
}
function InlineDiff({ text }: { text: string }) {
diff --git a/apps/desktop/src/components/chat/activity-timer.ts b/apps/desktop/src/components/chat/activity-timer.ts
index 704c9d4390..29095c32db 100644
--- a/apps/desktop/src/components/chat/activity-timer.ts
+++ b/apps/desktop/src/components/chat/activity-timer.ts
@@ -6,10 +6,14 @@ import { useEffect, useRef, useState } from 'react'
const startedAtByKey = new Map()
function startedAt(key?: string): number {
- if (!key) {return Date.now()}
+ if (!key) {
+ return Date.now()
+ }
const existing = startedAtByKey.get(key)
- if (existing !== undefined) {return existing}
+ if (existing !== undefined) {
+ return existing
+ }
const now = Date.now()
startedAtByKey.set(key, now)
@@ -17,7 +21,9 @@ function startedAt(key?: string): number {
}
export function formatElapsed(seconds: number): string {
- if (seconds < 60) {return `${seconds}s`}
+ if (seconds < 60) {
+ return `${seconds}s`
+ }
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
}
@@ -33,9 +39,13 @@ export function useElapsedSeconds(active = true, timerKey?: string): number {
}
useEffect(() => {
- if (!active) {return}
+ if (!active) {
+ return
+ }
- if (timerKey) {start.current = startedAt(timerKey)}
+ if (timerKey) {
+ start.current = startedAt(timerKey)
+ }
const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
tick()
diff --git a/apps/desktop/src/components/chat/disclosure-row.tsx b/apps/desktop/src/components/chat/disclosure-row.tsx
index b588bff410..911be03f95 100644
--- a/apps/desktop/src/components/chat/disclosure-row.tsx
+++ b/apps/desktop/src/components/chat/disclosure-row.tsx
@@ -4,14 +4,16 @@ import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
// Shared header row for any collapsible block (thinking, tool group, single
-// tool). Owns the grid indent (chevron column = --message-text-indent), the
-// hover surface, and the trailing-slot anchor used for copy buttons / running
-// timers. Each parent supplies its own outer wrapper (with the data-slot CSS
+// tool). Each parent supplies its own outer wrapper (with the data-slot CSS
// uses to escape the message padding) and its own expanded body.
//
-// Passing `onToggle` makes the row expandable (chevron + hover + click).
-// Omitting it renders a static row that still reserves the chevron column so
-// nested rows stay vertically aligned with their group header.
+// Cursor-style affordance:
+// - No leading chevron; a caret appears to the RIGHT of the text on hover
+// (and stays visible when the row is open).
+// - The hover background is a tight content-shaped pill — sized to the
+// title text, NOT the full row — and reaches just past the chevron with
+// `-mx-1.5 px-1.5` so it reads as a soft hit-target rather than a slab
+// stretching to the message edge.
export function DisclosureRow({
children,
onToggle,
@@ -24,36 +26,44 @@ export function DisclosureRow({
trailing?: ReactNode
}) {
return (
-
+
-
- {onToggle ? (
+ {children}
+ {onToggle && (
+ // Wrapper height matches the title row's line-height so the caret
+ // is vertically centred with the title (not with the full stack
+ // when a subtitle wraps below).
+
- ) : (
-
- )}
-
- {children}
+
+ )}
{trailing && {trailing} }
diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx
index 4f0b3789d7..9ebec68522 100644
--- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx
+++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx
@@ -134,6 +134,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
}
const { flow } = onboarding
+ const reason = onboarding.reason?.trim() || null
const ready = enabled && onboarding.configured === false
const showPicker = flow.status === 'idle' || flow.status === 'success'
@@ -142,21 +143,22 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
- {ready ? (
- showPicker ? (
-
- ) : (
-
- )
- ) : (
-
- )}
+ {reason ?
: null}
+ {ready ? showPicker ?
:
:
}
)
}
+function ReasonNotice({ reason }: { reason: string }) {
+ return (
+
+ {reason}
+
+ )
+}
+
function Preparing({ boot }: { boot: DesktopBootState }) {
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
const hasError = Boolean(boot.error)
@@ -445,7 +447,9 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
in":
void copyExternalCommand()} text={flow.provider.cli_command} />
- {title} docs : null}>
+ {title} docs : null}
+ >
void recheckExternalSignin(ctx)}>
diff --git a/apps/desktop/src/components/status-dot.tsx b/apps/desktop/src/components/status-dot.tsx
index 4617b2181d..3b9c20d361 100644
--- a/apps/desktop/src/components/status-dot.tsx
+++ b/apps/desktop/src/components/status-dot.tsx
@@ -17,6 +17,10 @@ interface StatusDotProps extends ComponentProps<'span'> {
export function StatusDot({ className, tone, ...props }: StatusDotProps) {
return (
-
+
)
}
diff --git a/apps/desktop/src/components/ui/braille-spinner.tsx b/apps/desktop/src/components/ui/braille-spinner.tsx
new file mode 100644
index 0000000000..3b6b8985c6
--- /dev/null
+++ b/apps/desktop/src/components/ui/braille-spinner.tsx
@@ -0,0 +1,61 @@
+import { useEffect, useState } from 'react'
+import spinners, { type BrailleSpinnerName } from 'unicode-animations'
+
+import { cn } from '@/lib/utils'
+
+interface NormalisedSpinner {
+ frames: readonly string[]
+ interval: number
+}
+
+// Some spinners ship multi-character frames. Pull the first cell so each
+// frame fits in one monospace box — matches how the TUI uses them.
+const FRAMES_BY_NAME: Record = (() => {
+ const out = {} as Record
+
+ for (const name of Object.keys(spinners) as BrailleSpinnerName[]) {
+ const raw = spinners[name]
+
+ out[name] = {
+ frames: raw.frames.map(frame => [...frame][0] ?? '⠀'),
+ interval: raw.interval
+ }
+ }
+
+ return out
+})()
+
+interface BrailleSpinnerProps {
+ ariaLabel?: string
+ className?: string
+ spinner?: BrailleSpinnerName
+}
+
+/**
+ * One-char braille spinner driven by `unicode-animations`. Mirrors the
+ * spinner used by the Ink TUI so the desktop and terminal experiences
+ * read the same visually. Renders inside an `inline-flex` cell with
+ * `leading-none` and `items-center` so it sits vertically centred inside
+ * its parent's line-box (e.g. the 1.1rem disclosure row).
+ */
+export function BrailleSpinner({ ariaLabel = 'Loading', className, spinner = 'breathe' }: BrailleSpinnerProps) {
+ const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.breathe!
+ const [frame, setFrame] = useState(0)
+
+ useEffect(() => {
+ setFrame(0)
+ const id = window.setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)
+
+ return () => window.clearInterval(id)
+ }, [spin])
+
+ return (
+
+ {spin.frames[frame]}
+
+ )
+}
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts
index 0ddcb15380..a272436ef9 100644
--- a/apps/desktop/src/global.d.ts
+++ b/apps/desktop/src/global.d.ts
@@ -149,6 +149,7 @@ export interface HermesApiRequest {
path: string
method?: string
body?: unknown
+ timeoutMs?: number
}
export interface HermesNotification {
diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts
index c8267e3f6a..776780725d 100644
--- a/apps/desktop/src/hermes.ts
+++ b/apps/desktop/src/hermes.ts
@@ -31,6 +31,8 @@ import type {
ToolsetInfo
} from '@/types/hermes'
+const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000
+
export type {
ActionResponse,
ActionStatusResponse,
@@ -79,7 +81,7 @@ export class HermesGateway extends JsonRpcGatewayClient {
connectErrorMessage: 'Could not connect to Hermes gateway',
createRequestId: nextId => nextId,
notConnectedErrorMessage: 'Hermes gateway is not connected',
- requestTimeoutMs: 0
+ requestTimeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS
})
}
}
@@ -242,11 +244,7 @@ export function startOAuthLogin(providerId: string): Promise
})
}
-export function submitOAuthCode(
- providerId: string,
- sessionId: string,
- code: string
-): Promise {
+export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise {
return window.hermesDesktop.api({
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
method: 'POST',
diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts
index beaf18c764..6f62de758b 100644
--- a/apps/desktop/src/lib/chat-messages.test.ts
+++ b/apps/desktop/src/lib/chat-messages.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
+import type { ChatMessagePart } from './chat-messages'
import {
appendAssistantTextPart,
chatMessageText,
@@ -218,10 +219,321 @@ describe('upsertToolPart', () => {
'complete'
)
- const completedResult = completed[0] && 'result' in completed[0] ? (completed[0].result as Record) : {}
+ const completedResult =
+ completed[0] && 'result' in completed[0] ? (completed[0].result as Record) : {}
const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record) : {}
expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }])
expect(clearedResult.todos).toEqual([])
})
+
+ it('keeps parallel same-name tools distinct without explicit ids', () => {
+ const startedTokyo = upsertToolPart(
+ [],
+ {
+ context: 'tokyo weather',
+ name: 'web_search'
+ },
+ 'running'
+ )
+
+ const startedReykjavik = upsertToolPart(
+ startedTokyo,
+ {
+ context: 'reykjavik weather',
+ name: 'web_search'
+ },
+ 'running'
+ )
+
+ const completedTokyo = upsertToolPart(
+ startedReykjavik,
+ {
+ context: 'tokyo weather',
+ message: 'tokyo done',
+ name: 'web_search',
+ summary: 'Did 5 searches'
+ },
+ 'complete'
+ )
+
+ const completedBoth = upsertToolPart(
+ completedTokyo,
+ {
+ context: 'reykjavik weather',
+ message: 'reykjavik done',
+ name: 'web_search',
+ summary: 'Did 5 searches'
+ },
+ 'complete'
+ )
+
+ const webParts = completedBoth.filter(
+ (part): part is Extract =>
+ part.type === 'tool-call' && part.toolName === 'web_search'
+ )
+
+ const contexts = webParts.map(part => String((part.args as Record)?.context || ''))
+
+ const summaries = webParts.map(part => {
+ if (!('result' in part) || !part.result || typeof part.result !== 'object') {
+ return ''
+ }
+
+ return String((part.result as Record).summary || '')
+ })
+
+ expect(webParts).toHaveLength(2)
+ expect(contexts).toEqual(['tokyo weather', 'reykjavik weather'])
+ expect(summaries).toEqual(['Did 5 searches', 'Did 5 searches'])
+ })
+
+ it('preserves query args when completion payload omits context', () => {
+ const started = upsertToolPart(
+ [],
+ {
+ context: 'auckland weather today and tomorrow forecast',
+ name: 'web_search',
+ tool_id: 'search-1'
+ },
+ 'running'
+ )
+
+ const completed = upsertToolPart(
+ started,
+ {
+ duration_s: 1.1,
+ name: 'web_search',
+ summary: 'Did 5 searches in 1.1s',
+ tool_id: 'search-1'
+ },
+ 'complete'
+ )
+
+ const [part] = completed
+
+ expect(part?.type).toBe('tool-call')
+ expect((part as Extract).args).toMatchObject({
+ context: 'auckland weather today and tomorrow forecast'
+ })
+ expect((part as Extract).result).toMatchObject({
+ summary: 'Did 5 searches in 1.1s'
+ })
+ })
+
+ it('does not append phantom same-name tool rows for id-less progress updates', () => {
+ const startedA = upsertToolPart(
+ [],
+ {
+ context: 'reykjavik weather today and tomorrow forecast',
+ name: 'web_search'
+ },
+ 'running'
+ )
+
+ const startedB = upsertToolPart(
+ startedA,
+ {
+ context: 'kathmandu weather today and tomorrow forecast',
+ name: 'web_search'
+ },
+ 'running'
+ )
+
+ const progressed = upsertToolPart(
+ startedB,
+ {
+ name: 'web_search'
+ },
+ 'running'
+ )
+
+ const webParts = progressed.filter(
+ (part): part is Extract =>
+ part.type === 'tool-call' && part.toolName === 'web_search'
+ )
+
+ expect(webParts).toHaveLength(2)
+ })
+
+ it('matches id-less live starts with later identified completions', () => {
+ const started = upsertToolPart(
+ [],
+ {
+ context: 'asuncion paraguay weather today and tomorrow forecast',
+ name: 'web_search'
+ },
+ 'running'
+ )
+
+ const completed = upsertToolPart(
+ started,
+ {
+ context: 'asuncion paraguay weather today and tomorrow forecast',
+ duration_s: 1.1,
+ name: 'web_search',
+ summary: 'Did 5 searches in 1.1s',
+ tool_id: 'search-asuncion'
+ },
+ 'complete'
+ )
+
+ const webParts = completed.filter(
+ (part): part is Extract =>
+ part.type === 'tool-call' && part.toolName === 'web_search'
+ )
+
+ expect(webParts).toHaveLength(1)
+ expect(webParts[0].toolCallId).toBe('search-asuncion')
+ expect(webParts[0].result).toMatchObject({ summary: 'Did 5 searches in 1.1s' })
+ })
+
+ it('matches id-less live starts with later identified progress updates', () => {
+ const started = upsertToolPart(
+ [],
+ {
+ context: 'reykjavik tashkent uzbekistan weather today and tomorrow forecast',
+ name: 'web_search'
+ },
+ 'running'
+ )
+
+ const progressed = upsertToolPart(
+ started,
+ {
+ context: 'reykjavik tashkent uzbekistan weather today and tomorrow forecast',
+ name: 'web_search',
+ tool_id: 'search-reykjavik'
+ },
+ 'running'
+ )
+
+ const webParts = progressed.filter(
+ (part): part is Extract =>
+ part.type === 'tool-call' && part.toolName === 'web_search'
+ )
+
+ expect(webParts).toHaveLength(1)
+ expect(webParts[0].toolCallId).toBe('search-reykjavik')
+ })
+
+ it('reconciles preview-first progress rows with later stable-id starts', () => {
+ const progressA = upsertToolPart(
+ [],
+ {
+ name: 'web_search',
+ preview: 'tokyo weather'
+ },
+ 'running'
+ )
+
+ const progressB = upsertToolPart(
+ progressA,
+ {
+ name: 'web_search',
+ preview: 'reykjavik weather'
+ },
+ 'running'
+ )
+
+ const startedA = upsertToolPart(
+ progressB,
+ {
+ args: { query: 'tokyo weather' },
+ name: 'web_search',
+ tool_id: 'search-tokyo'
+ },
+ 'running'
+ )
+
+ const startedB = upsertToolPart(
+ startedA,
+ {
+ args: { query: 'reykjavik weather' },
+ name: 'web_search',
+ tool_id: 'search-reykjavik'
+ },
+ 'running'
+ )
+
+ const completedA = upsertToolPart(
+ startedB,
+ {
+ name: 'web_search',
+ summary: 'Did 5 searches',
+ tool_id: 'search-tokyo'
+ },
+ 'complete'
+ )
+
+ const completedB = upsertToolPart(
+ completedA,
+ {
+ name: 'web_search',
+ summary: 'Did 5 searches',
+ tool_id: 'search-reykjavik'
+ },
+ 'complete'
+ )
+
+ const webParts = completedB
+ .filter(
+ (part): part is Extract =>
+ part.type === 'tool-call' && part.toolName === 'web_search'
+ )
+ .map(part => ({
+ id: part.toolCallId,
+ query: String((part.args as Record)?.query || ''),
+ summary:
+ part.result && typeof part.result === 'object'
+ ? String((part.result as Record).summary || '')
+ : ''
+ }))
+
+ expect(webParts).toEqual([
+ { id: 'search-tokyo', query: 'tokyo weather', summary: 'Did 5 searches' },
+ { id: 'search-reykjavik', query: 'reykjavik weather', summary: 'Did 5 searches' }
+ ])
+ })
+
+ it('uses structured live tool args for titles before hydrate', () => {
+ const started = upsertToolPart(
+ [],
+ {
+ args: { search_term: 'reykjavik bishkek kyrgyzstan weather today and tomorrow forecast' },
+ name: 'web_search',
+ tool_id: 'search-bishkek'
+ },
+ 'running'
+ )
+
+ const [part] = started
+
+ expect(part?.type).toBe('tool-call')
+ expect((part as Extract).args).toMatchObject({
+ search_term: 'reykjavik bishkek kyrgyzstan weather today and tomorrow forecast'
+ })
+ })
+
+ it('keeps structured live tool results before hydrate', () => {
+ const completed = upsertToolPart(
+ [],
+ {
+ args: { query: 'suva weather' },
+ name: 'web_search',
+ result: { data: { web: [{ title: 'Suva forecast', url: 'https://example.test', description: 'Sunny' }] } },
+ summary: 'Did 1 search in 0.5s',
+ tool_id: 'search-suva'
+ },
+ 'complete'
+ )
+
+ const [part] = completed
+
+ expect(part?.type).toBe('tool-call')
+ expect((part as Extract).result).toMatchObject({
+ data: { web: [{ title: 'Suva forecast' }] },
+ summary: 'Did 1 search in 0.5s'
+ })
+ })
})
diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts
index 77b018a274..0839daae01 100644
--- a/apps/desktop/src/lib/chat-messages.ts
+++ b/apps/desktop/src/lib/chat-messages.ts
@@ -23,10 +23,16 @@ export type GatewayEventPayload = {
rendered?: string
status?: string
message?: string
+ id?: string
name?: string
tool_id?: string
+ tool_call_id?: string
+ args?: unknown
+ arguments?: unknown
context?: string
+ input?: unknown
preview?: string
+ result?: unknown
summary?: string
error?: string | boolean
inline_diff?: string
@@ -209,7 +215,135 @@ export function hasToolPart(message: ChatMessage): boolean {
}
function toolId(payload: GatewayEventPayload | undefined): string {
- return payload?.tool_id || payload?.name || `tool-${Date.now()}`
+ return payload?.tool_id || payload?.tool_call_id || payload?.id || ''
+}
+
+let liveToolCounter = 0
+
+function nextLiveToolId(name: string): string {
+ liveToolCounter += 1
+
+ return `live-tool:${name}:${liveToolCounter}`
+}
+
+function firstStringField(record: Record, keys: readonly string[]): string {
+ for (const key of keys) {
+ const value = record[key]
+
+ if (typeof value === 'string' && value.trim()) {
+ return value.trim()
+ }
+ }
+
+ return ''
+}
+
+function normalizeToolMatchValue(value: string): string {
+ return value.trim().toLowerCase()
+}
+
+function collectToolMatchValues(query: string, context: string, preview: string): string[] {
+ return [...new Set([query, context, preview].map(normalizeToolMatchValue).filter(Boolean))]
+}
+
+function toolPayloadMatchValues(payload: GatewayEventPayload | undefined): string[] {
+ const payloadArgs = liveToolArgs(payload)
+ const query = firstStringField(payloadArgs, ['search_term', 'query'])
+ const context = typeof payload?.context === 'string' ? payload.context.trim() : ''
+ const preview = typeof payload?.preview === 'string' ? payload.preview.trim() : ''
+
+ return collectToolMatchValues(query, context, preview)
+}
+
+function toolPartMatchValues(part: ChatMessagePart): string[] {
+ if (part.type !== 'tool-call' || !part.args || typeof part.args !== 'object') {
+ return []
+ }
+
+ const args = part.args as Record
+ const query = firstStringField(args, ['search_term', 'query'])
+ const context = typeof args.context === 'string' ? args.context.trim() : ''
+ const preview = typeof args.preview === 'string' ? args.preview.trim() : ''
+
+ return collectToolMatchValues(query, context, preview)
+}
+
+function hasToolMatchOverlap(left: string[], right: string[]): boolean {
+ if (!left.length || !right.length) {
+ return false
+ }
+
+ const rightSet = new Set(right)
+
+ return left.some(value => rightSet.has(value))
+}
+
+function findToolPartIndex(
+ parts: ChatMessagePart[],
+ name: string,
+ stableId: string,
+ payload: GatewayEventPayload | undefined,
+ phase: 'running' | 'complete'
+): number {
+ const matchValues = toolPayloadMatchValues(payload)
+ const overlaps = (index: number) => hasToolMatchOverlap(matchValues, toolPartMatchValues(parts[index]))
+
+ if (stableId) {
+ const stableIndex = parts.findIndex(part => part.type === 'tool-call' && part.toolCallId === stableId)
+
+ if (stableIndex >= 0) {
+ return stableIndex
+ }
+
+ // Some live streams start without an id, then complete with one. Fall
+ // through to pending same-name/context matching so the completion updates
+ // the synthetic live row instead of appending a duplicate completed row.
+ if (phase === 'running' && !matchValues.length) {
+ return -1
+ }
+ }
+
+ const pendingIndices = parts
+ .map((part, index) => ({ part, index }))
+ .filter(({ part }) => part.type === 'tool-call' && part.toolName === name && part.result === undefined)
+ .map(({ index }) => index)
+
+ if (pendingIndices.length === 0) {
+ return -1
+ }
+
+ if (matchValues.length) {
+ const contextualIndex = pendingIndices.find(overlaps)
+
+ if (contextualIndex !== undefined) {
+ return contextualIndex
+ }
+ }
+
+ if (pendingIndices.length === 1) {
+ const [singlePendingIndex] = pendingIndices
+
+ if (phase === 'running' && matchValues.length && !overlaps(singlePendingIndex)) {
+ return stableId ? singlePendingIndex : -1
+ }
+
+ return singlePendingIndex
+ }
+
+ // Completion events without stable IDs frequently arrive after multiple
+ // same-name starts (parallel tool calls). Resolve them oldest-first so we
+ // don't collapse an entire burst into a single row.
+ if (phase === 'complete') {
+ return pendingIndices[0]
+ }
+
+ if (stableId) {
+ return pendingIndices[0]
+ }
+
+ // For progress/running events with no stable id, update the most-recent
+ // pending same-name tool instead of creating a phantom extra row.
+ return pendingIndices.at(-1) ?? -1
}
// Carry todo state across sparse progress payloads: if this todo event lacks
@@ -221,27 +355,43 @@ function carryTodos(payload: GatewayEventPayload | undefined, ...prev: unknown[]
return next === null ? undefined : { todos: next }
}
- if (payload?.name !== 'todo') {return undefined}
+ if (payload?.name !== 'todo') {
+ return undefined
+ }
for (const p of prev) {
const carried = parseTodos(recordFromUnknown(p)?.todos)
- if (carried !== null) {return { todos: carried }}
+ if (carried !== null) {
+ return { todos: carried }
+ }
}
return undefined
}
function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown): Record {
+ const prev = parseMaybeJsonObject(prevArgs)
+ const eventArgs = liveToolArgs(payload)
+
return {
+ ...prev,
+ ...eventArgs,
...(payload?.context ? { context: payload.context } : {}),
...(payload?.preview ? { preview: payload.preview } : {}),
...carryTodos(payload, prevArgs)
}
}
-function toolResult(payload: GatewayEventPayload | undefined, prevResult?: unknown, prevArgs?: unknown): Record {
+function toolResult(
+ payload: GatewayEventPayload | undefined,
+ prevResult?: unknown,
+ prevArgs?: unknown
+): Record {
+ const parsedResult = parseMaybeJsonObject(payload?.result)
+
return {
+ ...parsedResult,
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
...(payload?.summary ? { summary: payload.summary } : {}),
...(payload?.message ? { message: payload.message } : {}),
@@ -257,19 +407,22 @@ export function upsertToolPart(
payload: GatewayEventPayload | undefined,
phase: 'running' | 'complete'
): ChatMessagePart[] {
- const id = toolId(payload)
+ const stableId = toolId(payload)
const name = payload?.name || 'tool'
const next = [...parts]
- const index = next.findIndex(
- part => part.type === 'tool-call' && ((part.toolCallId && part.toolCallId === id) || part.toolName === name)
- )
+ const index = findToolPartIndex(next, name, stableId, payload, phase)
const prev = index >= 0 ? next[index] : null
const prevArgs = prev && 'args' in prev ? prev.args : undefined
const prevResult = prev && 'result' in prev ? prev.result : undefined
const args = toolArgs(payload, prevArgs)
+ const id =
+ stableId ||
+ (prev && 'toolCallId' in prev && typeof prev.toolCallId === 'string' ? prev.toolCallId : '') ||
+ nextLiveToolId(name)
+
const base = {
type: 'tool-call' as const,
toolCallId: id,
@@ -279,7 +432,9 @@ export function upsertToolPart(
...(phase === 'complete' && { result: toolResult(payload, prevResult, prevArgs), isError: Boolean(payload?.error) })
} satisfies ChatMessagePart
- if (index === -1) {return [...next, base]}
+ if (index === -1) {
+ return [...next, base]
+ }
next[index] = { ...next[index], ...base }
return next
@@ -319,6 +474,28 @@ function firstNonEmptyObject(...values: unknown[]): Record {
return {}
}
+function liveToolArgs(payload: GatewayEventPayload | undefined): Record {
+ const direct = firstNonEmptyObject(payload?.args, payload?.arguments)
+ const input = firstNonEmptyObject(payload?.input)
+ const fn = recordFromUnknown(input.function)
+
+ const nested = firstNonEmptyObject(
+ input.args,
+ input.arguments,
+ input.parameters,
+ input.input,
+ fn?.arguments,
+ fn?.args,
+ fn?.parameters
+ )
+
+ return {
+ ...input,
+ ...nested,
+ ...direct
+ }
+}
+
function parseStoredToolResult(content: unknown): unknown {
if (content && typeof content === 'object') {
return content
diff --git a/apps/desktop/src/lib/commit-changelog.ts b/apps/desktop/src/lib/commit-changelog.ts
index 8c0b04acb9..5cd91c4040 100644
--- a/apps/desktop/src/lib/commit-changelog.ts
+++ b/apps/desktop/src/lib/commit-changelog.ts
@@ -101,7 +101,10 @@ export function parseCommitHeader(raw: string): ParsedCommit {
}
function tidySubject(subject: string): string {
- const cleaned = subject.replace(/\s+/g, ' ').replace(/[.;,\s]+$/, '').trim()
+ const cleaned = subject
+ .replace(/\s+/g, ' ')
+ .replace(/[.;,\s]+$/, '')
+ .trim()
if (!cleaned) {
return cleaned
diff --git a/apps/desktop/src/lib/external-link.test.tsx b/apps/desktop/src/lib/external-link.test.tsx
index a6646abdfe..3023d79d0d 100644
--- a/apps/desktop/src/lib/external-link.test.tsx
+++ b/apps/desktop/src/lib/external-link.test.tsx
@@ -63,7 +63,8 @@ describe('external link helpers', () => {
const bridge = vi.fn().mockResolvedValue('El Yunque Tour Water Slide, Rope Swing & Pickup')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
- const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details'
+ const url =
+ 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details'
const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)])
@@ -95,11 +96,7 @@ describe('external link helpers', () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
- render(
-
- Example link
-
- )
+ render(Example link )
fireEvent.click(screen.getByRole('link', { name: 'Example link' }))
expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource')
@@ -108,11 +105,7 @@ describe('external link helpers', () => {
it('shows a trailing external-link icon', () => {
installDesktopBridge()
- render(
-
- Example link
-
- )
+ render(Example link )
const link = screen.getByRole('link', { name: 'Example link' })
expect(link.querySelector('svg')).toBeTruthy()
@@ -125,9 +118,7 @@ describe('external link helpers', () => {
const url =
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
- render(
-
- )
+ render( )
const link = screen.getByTitle(url)
expect(link.textContent).toContain('From Fajardo Full Day Cordillera Islands Catamaran Tour')
@@ -152,7 +143,8 @@ describe('external link helpers', () => {
it('ignores error-like fetched titles and falls back to slug label', async () => {
const bridge = vi.fn().mockResolvedValue('GetYourGuide – Error')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
- const url = 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
+ const url =
+ 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
render( )
@@ -168,6 +160,8 @@ describe('external link helpers', () => {
render( )
const link = screen.getByRole('link')
- expect(link.getAttribute('href')).toBe('https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure')
+ expect(link.getAttribute('href')).toBe(
+ 'https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure'
+ )
})
})
diff --git a/apps/desktop/src/lib/external-link.tsx b/apps/desktop/src/lib/external-link.tsx
index 5ff2e611e0..cd3912cf99 100644
--- a/apps/desktop/src/lib/external-link.tsx
+++ b/apps/desktop/src/lib/external-link.tsx
@@ -204,7 +204,14 @@ export function ExternalLinkIcon({ className }: { className?: string }) {
return
}
-export function ExternalLink({ children, className, href, onClick, showExternalIcon = true, ...rest }: ExternalLinkProps) {
+export function ExternalLink({
+ children,
+ className,
+ href,
+ onClick,
+ showExternalIcon = true,
+ ...rest
+}: ExternalLinkProps) {
const target = normalizeExternalUrl(href)
return (
diff --git a/apps/desktop/src/lib/incremental-external-store-runtime.ts b/apps/desktop/src/lib/incremental-external-store-runtime.ts
new file mode 100644
index 0000000000..c055175091
--- /dev/null
+++ b/apps/desktop/src/lib/incremental-external-store-runtime.ts
@@ -0,0 +1,188 @@
+import {
+ AssistantRuntimeImpl,
+ BaseAssistantRuntimeCore,
+ ExternalStoreThreadListRuntimeCore,
+ ExternalStoreThreadRuntimeCore,
+ hasUpcomingMessage
+} from '@assistant-ui/core/internal'
+import {
+ type AssistantRuntime,
+ type ExternalStoreAdapter,
+ type ThreadMessage,
+ useRuntimeAdapters
+} from '@assistant-ui/react'
+import { useEffect, useMemo, useState } from 'react'
+
+const EMPTY_ARRAY = Object.freeze([])
+
+const shallowEqual = (a: object, b: object): boolean => {
+ const aKeys = Object.keys(a)
+
+ if (aKeys.length !== Object.keys(b).length) {
+ return false
+ }
+
+ for (const key of aKeys) {
+ if (a[key as keyof typeof a] !== b[key as keyof typeof b]) {
+ return false
+ }
+ }
+
+ return true
+}
+
+const getThreadListAdapter = (store: ExternalStoreAdapter) => store.adapters?.threadList ?? {}
+
+function syncRepositoryIncrementally(
+ runtime: ExternalStoreThreadRuntimeCore,
+ messageRepository: NonNullable
+): readonly ThreadMessage[] {
+ const repository = (runtime as unknown as { repository: ExternalStoreThreadRuntimeCore['repository'] }).repository
+ const incomingIds = new Set(messageRepository.messages.map(({ message }) => message.id))
+
+ for (const { message, parentId } of messageRepository.messages) {
+ repository.addOrUpdateMessage(parentId, message)
+ }
+
+ for (const { message } of repository.export().messages) {
+ if (!incomingIds.has(message.id)) {
+ repository.deleteMessage(message.id)
+ }
+ }
+
+ const headId = messageRepository.headId ?? messageRepository.messages.at(-1)?.message.id ?? null
+
+ repository.resetHead(headId)
+
+ return repository.getMessages()
+}
+
+class IncrementalExternalStoreThreadRuntimeCore extends ExternalStoreThreadRuntimeCore {
+ override __internal_setAdapter(store: ExternalStoreAdapter): void {
+ if (!store.messageRepository) {
+ super.__internal_setAdapter(store)
+
+ return
+ }
+
+ const self = this as unknown as {
+ _assistantOptimisticId: null | string
+ _capabilities: object
+ _messages: readonly ThreadMessage[]
+ _notifyEventSubscribers: (event: string, payload: object) => void
+ _notifySubscribers: () => void
+ _store?: ExternalStoreAdapter
+ }
+
+ if (self._store === store) {
+ return
+ }
+
+ const isRunning = store.isRunning ?? false
+ this.isDisabled = store.isDisabled ?? false
+
+ const oldStore = self._store
+ self._store = store
+
+ if (this.extras !== store.extras) {
+ this.extras = store.extras
+ }
+
+ const newSuggestions = store.suggestions ?? EMPTY_ARRAY
+
+ if (!shallowEqual(this.suggestions, newSuggestions)) {
+ this.suggestions = newSuggestions
+ }
+
+ const newCapabilities = {
+ switchToBranch: store.setMessages !== undefined,
+ switchBranchDuringRun: false,
+ edit: store.onEdit !== undefined,
+ reload: store.onReload !== undefined,
+ cancel: store.onCancel !== undefined,
+ speech: store.adapters?.speech !== undefined,
+ dictation: store.adapters?.dictation !== undefined,
+ voice: store.adapters?.voice !== undefined,
+ unstable_copy: store.unstable_capabilities?.copy !== false,
+ attachments: !!store.adapters?.attachments,
+ feedback: !!store.adapters?.feedback,
+ queue: false
+ }
+
+ if (!shallowEqual(self._capabilities, newCapabilities)) {
+ self._capabilities = newCapabilities
+ }
+
+ if (oldStore && oldStore.isRunning === store.isRunning && oldStore.messageRepository === store.messageRepository) {
+ self._notifySubscribers()
+
+ return
+ }
+
+ if (self._assistantOptimisticId) {
+ this.repository.deleteMessage(self._assistantOptimisticId)
+ self._assistantOptimisticId = null
+ }
+
+ const messages = syncRepositoryIncrementally(this, store.messageRepository)
+
+ if (messages.length > 0) {
+ this.ensureInitialized()
+ }
+
+ if ((oldStore?.isRunning ?? false) !== (store.isRunning ?? false)) {
+ self._notifyEventSubscribers(store.isRunning ? 'runStart' : 'runEnd', {})
+ }
+
+ if (hasUpcomingMessage(isRunning, messages)) {
+ self._assistantOptimisticId = this.repository.appendOptimisticMessage(messages.at(-1)?.id ?? null, {
+ role: 'assistant',
+ content: []
+ })
+ }
+
+ this.repository.resetHead(self._assistantOptimisticId ?? messages.at(-1)?.id ?? null)
+ self._messages = this.repository.getMessages()
+ self._notifySubscribers()
+ }
+}
+
+class IncrementalExternalStoreRuntimeCore extends BaseAssistantRuntimeCore {
+ threads: ExternalStoreThreadListRuntimeCore
+
+ constructor(adapter: ExternalStoreAdapter) {
+ super()
+
+ this.threads = new ExternalStoreThreadListRuntimeCore(
+ getThreadListAdapter(adapter),
+ () => new IncrementalExternalStoreThreadRuntimeCore(this._contextProvider, adapter)
+ )
+ }
+
+ setAdapter(adapter: ExternalStoreAdapter): void {
+ this.threads.__internal_setAdapter(getThreadListAdapter(adapter))
+ this.threads.getMainThreadRuntimeCore().__internal_setAdapter(adapter)
+ }
+}
+
+export function useIncrementalExternalStoreRuntime(
+ store: ExternalStoreAdapter
+): AssistantRuntime {
+ const [runtime] = useState(() => new IncrementalExternalStoreRuntimeCore(store as ExternalStoreAdapter))
+
+ useEffect(() => {
+ runtime.setAdapter(store as ExternalStoreAdapter)
+ })
+
+ const { modelContext } = useRuntimeAdapters() ?? {}
+
+ useEffect(() => {
+ if (!modelContext) {
+ return undefined
+ }
+
+ return runtime.registerModelContextProvider(modelContext)
+ }, [modelContext, runtime])
+
+ return useMemo(() => new AssistantRuntimeImpl(runtime), [runtime])
+}
diff --git a/apps/desktop/src/lib/markdown-preprocess.ts b/apps/desktop/src/lib/markdown-preprocess.ts
index 37c4eb1050..b7eb47f222 100644
--- a/apps/desktop/src/lib/markdown-preprocess.ts
+++ b/apps/desktop/src/lib/markdown-preprocess.ts
@@ -28,6 +28,7 @@ const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*
*/
function hasCloseFenceLine(body: string, marker: string): boolean {
const lines = body.split('\n')
+
// Original regex required `\n` immediately before the close fence, so the
// first line of `body` (which has no preceding newline within `body`)
// cannot itself be the close fence.
@@ -35,8 +36,15 @@ function hasCloseFenceLine(body: string, marker: string): boolean {
const line = lines[i]
let lo = 0
let hi = line.length
- while (lo < hi && (line[lo] === ' ' || line[lo] === '\t')) lo += 1
- while (hi > lo && (line[hi - 1] === ' ' || line[hi - 1] === '\t')) hi -= 1
+
+ while (lo < hi && (line[lo] === ' ' || line[lo] === '\t')) {
+ lo += 1
+ }
+
+ while (hi > lo && (line[hi - 1] === ' ' || line[hi - 1] === '\t')) {
+ hi -= 1
+ }
+
if (line.slice(lo, hi) === marker) {
return true
}
@@ -122,7 +130,9 @@ function normalizeVisibleProse(text: string): string {
.map(part =>
part.startsWith('`')
? part
- : autoLinkRawUrls(part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, ''))
+ : autoLinkRawUrls(
+ part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '')
+ )
)
.join('')
}
diff --git a/apps/desktop/src/lib/runtime-readiness.test.ts b/apps/desktop/src/lib/runtime-readiness.test.ts
new file mode 100644
index 0000000000..54a25828c7
--- /dev/null
+++ b/apps/desktop/src/lib/runtime-readiness.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest'
+
+import { interpretRuntimeReadiness } from './runtime-readiness'
+
+describe('interpretRuntimeReadiness', () => {
+ it('prefers runtime_check when both signals exist', () => {
+ const result = interpretRuntimeReadiness({
+ setup: { provider_configured: false },
+ setupError: null,
+ runtime: { ok: true },
+ runtimeError: null
+ })
+
+ expect(result).toEqual({
+ checksDisagree: true,
+ ready: true,
+ reason: null,
+ source: 'runtime_check'
+ })
+ })
+
+ it('surfaces runtime mismatch details when runtime_check fails', () => {
+ const result = interpretRuntimeReadiness({
+ setup: { provider_configured: true },
+ setupError: null,
+ runtime: { error: 'No provider can serve the selected model.', ok: false },
+ runtimeError: null
+ })
+
+ expect(result.ready).toBe(false)
+ expect(result.source).toBe('runtime_check')
+ expect(result.checksDisagree).toBe(true)
+ expect(result.reason).toContain('No provider can serve the selected model.')
+ expect(result.reason).toContain('setup.status reports configured credentials')
+ })
+
+ it('falls back to setup.status when runtime_check has no boolean result', () => {
+ const result = interpretRuntimeReadiness({
+ setup: { provider_configured: true },
+ setupError: null,
+ runtime: null,
+ runtimeError: 'runtime check RPC unavailable'
+ })
+
+ expect(result).toEqual({
+ checksDisagree: false,
+ ready: true,
+ reason: null,
+ source: 'setup_status'
+ })
+ })
+
+ it('uses explicit fallback when both checks are missing', () => {
+ const result = interpretRuntimeReadiness({
+ setup: null,
+ setupError: 'setup.status timeout',
+ runtime: null,
+ runtimeError: 'setup.runtime_check timeout'
+ })
+
+ expect(result.ready).toBe(false)
+ expect(result.source).toBe('fallback')
+ expect(result.reason).toBe('setup.runtime_check timeout')
+ })
+})
diff --git a/apps/desktop/src/lib/runtime-readiness.ts b/apps/desktop/src/lib/runtime-readiness.ts
new file mode 100644
index 0000000000..47f3406eac
--- /dev/null
+++ b/apps/desktop/src/lib/runtime-readiness.ts
@@ -0,0 +1,147 @@
+export interface SetupStatusSnapshot {
+ provider_configured?: boolean
+}
+
+export interface RuntimeCheckSnapshot {
+ error?: string
+ ok?: boolean
+}
+
+export interface RuntimeReadinessSignals {
+ setup: null | SetupStatusSnapshot
+ setupError: null | string
+ runtime: null | RuntimeCheckSnapshot
+ runtimeError: null | string
+}
+
+export interface RuntimeReadinessOptions {
+ defaultReason?: string
+ unknownReady?: boolean
+}
+
+export interface RuntimeReadinessResult {
+ checksDisagree: boolean
+ ready: boolean
+ reason: null | string
+ source: 'fallback' | 'runtime_check' | 'setup_status'
+}
+
+export type RuntimeReadinessRequester = (method: string, params?: Record) => Promise
+
+const DEFAULT_NOT_READY_REASON = 'Add a provider credential before sending your first message.'
+
+function toErrorMessage(error: unknown): null | string {
+ if (error instanceof Error) {
+ return error.message
+ }
+
+ if (typeof error === 'string') {
+ return error
+ }
+
+ if (error === null || error === undefined) {
+ return null
+ }
+
+ return String(error)
+}
+
+function normalizeMessage(value: null | string | undefined): null | string {
+ const next = value?.trim()
+
+ return next ? next : null
+}
+
+async function requestWithFallback(
+ requestGateway: RuntimeReadinessRequester,
+ method: string
+): Promise<{ error: null | string; value: null | T }> {
+ try {
+ return { error: null, value: await requestGateway(method) }
+ } catch (error) {
+ return { error: toErrorMessage(error), value: null }
+ }
+}
+
+export async function fetchRuntimeReadinessSignals(
+ requestGateway: RuntimeReadinessRequester
+): Promise {
+ const [setup, runtime] = await Promise.all([
+ requestWithFallback(requestGateway, 'setup.status'),
+ requestWithFallback(requestGateway, 'setup.runtime_check')
+ ])
+
+ return {
+ setup: setup.value,
+ setupError: setup.error,
+ runtime: runtime.value,
+ runtimeError: runtime.error
+ }
+}
+
+export function interpretRuntimeReadiness(
+ signals: RuntimeReadinessSignals,
+ options: RuntimeReadinessOptions = {}
+): RuntimeReadinessResult {
+ const defaultReason = options.defaultReason ?? DEFAULT_NOT_READY_REASON
+ const unknownReady = options.unknownReady ?? false
+
+ const setupConfigured =
+ typeof signals.setup?.provider_configured === 'boolean' ? Boolean(signals.setup.provider_configured) : undefined
+
+ const runtimeOk = typeof signals.runtime?.ok === 'boolean' ? Boolean(signals.runtime.ok) : undefined
+ const runtimeFailure = normalizeMessage(signals.runtime?.error) ?? normalizeMessage(signals.runtimeError)
+ const setupFailure = normalizeMessage(signals.setupError)
+
+ const checksDisagree =
+ typeof setupConfigured === 'boolean' && typeof runtimeOk === 'boolean' && setupConfigured !== runtimeOk
+
+ if (typeof runtimeOk === 'boolean') {
+ if (runtimeOk) {
+ return {
+ checksDisagree,
+ ready: true,
+ reason: null,
+ source: 'runtime_check'
+ }
+ }
+
+ let reason = runtimeFailure ?? defaultReason
+
+ if (checksDisagree && setupConfigured) {
+ reason = `${reason} setup.status reports configured credentials, but runtime resolution still failed.`
+ }
+
+ return {
+ checksDisagree,
+ ready: false,
+ reason,
+ source: 'runtime_check'
+ }
+ }
+
+ if (typeof setupConfigured === 'boolean') {
+ return {
+ checksDisagree: false,
+ ready: setupConfigured,
+ reason: setupConfigured ? null : (runtimeFailure ?? setupFailure ?? defaultReason),
+ source: 'setup_status'
+ }
+ }
+
+ return {
+ checksDisagree: false,
+ ready: unknownReady,
+ reason: unknownReady ? null : (runtimeFailure ?? setupFailure ?? defaultReason),
+ source: 'fallback'
+ }
+}
+
+export async function evaluateRuntimeReadiness(
+ requestGateway: RuntimeReadinessRequester,
+ options: RuntimeReadinessOptions = {}
+): Promise {
+ const signals = await fetchRuntimeReadinessSignals(requestGateway)
+
+ return interpretRuntimeReadiness(signals, options)
+}
diff --git a/apps/desktop/src/lib/todos.ts b/apps/desktop/src/lib/todos.ts
index cd328fc286..01071a3f0e 100644
--- a/apps/desktop/src/lib/todos.ts
+++ b/apps/desktop/src/lib/todos.ts
@@ -13,7 +13,9 @@ const isStatus = (v: unknown): v is TodoStatus => (STATUSES as readonly string[]
function parseArray(value: unknown[]): TodoItem[] {
return value.flatMap(item => {
- if (!isRecord(item) || !isStatus(item.status)) {return []}
+ if (!isRecord(item) || !isStatus(item.status)) {
+ return []
+ }
const id = String(item.id ?? '').trim()
const content = String(item.content ?? '').trim()
@@ -22,15 +24,25 @@ function parseArray(value: unknown[]): TodoItem[] {
}
function parse(value: unknown, depth: number): null | TodoItem[] {
- if (depth > 2) {return null}
-
- if (Array.isArray(value)) {return parseArray(value)}
-
- if (typeof value === 'string' && value.trim()) {
- try { return parse(JSON.parse(value), depth + 1) } catch { return null }
+ if (depth > 2) {
+ return null
}
- if (isRecord(value) && Object.hasOwn(value, 'todos')) {return parse(value.todos, depth + 1)}
+ if (Array.isArray(value)) {
+ return parseArray(value)
+ }
+
+ if (typeof value === 'string' && value.trim()) {
+ try {
+ return parse(JSON.parse(value), depth + 1)
+ } catch {
+ return null
+ }
+ }
+
+ if (isRecord(value) && Object.hasOwn(value, 'todos')) {
+ return parse(value.todos, depth + 1)
+ }
return null
}
diff --git a/apps/desktop/src/lib/tool-result-summary.test.ts b/apps/desktop/src/lib/tool-result-summary.test.ts
index 4a0a841caf..fc095db6e8 100644
--- a/apps/desktop/src/lib/tool-result-summary.test.ts
+++ b/apps/desktop/src/lib/tool-result-summary.test.ts
@@ -44,9 +44,7 @@ describe('formatToolResultSummary', () => {
details: `prefix ${'x'.repeat(500)}`
})
- const detailsLine = summary
- .split('\n')
- .find(line => line.startsWith('- Details:'))
+ const detailsLine = summary.split('\n').find(line => line.startsWith('- Details:'))
expect(detailsLine).toBeTruthy()
expect(detailsLine?.length).toBeLessThan(230)
diff --git a/apps/desktop/src/lib/tool-result-summary.ts b/apps/desktop/src/lib/tool-result-summary.ts
index 912bebacea..8defcd626e 100644
--- a/apps/desktop/src/lib/tool-result-summary.ts
+++ b/apps/desktop/src/lib/tool-result-summary.ts
@@ -2,7 +2,21 @@
// mode still gets the raw JSON section.
const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const
-const PRIORITY_KEYS = ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'status', 'id', 'message', 'summary', 'description'] as const
+const PRIORITY_KEYS = [
+ 'title',
+ 'name',
+ 'path',
+ 'file',
+ 'filepath',
+ 'url',
+ 'href',
+ 'link',
+ 'status',
+ 'id',
+ 'message',
+ 'summary',
+ 'description'
+] as const
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
@@ -14,17 +28,29 @@ const isRecord = (v: unknown): v is Json => Boolean(v && typeof v === 'object' &
function tryJson(value: string): unknown {
const t = value.trim()
- if (!t) {return ''}
+ if (!t) {
+ return ''
+ }
- if (!/^[{[]|^"/.test(t)) {return value}
+ if (!/^[{[]|^"/.test(t)) {
+ return value
+ }
- try { return JSON.parse(t) } catch { return value }
+ try {
+ return JSON.parse(t)
+ } catch {
+ return value
+ }
}
const norm = (v: unknown): unknown => (typeof v === 'string' ? tryJson(v) : v)
const titleCase = (k: string) =>
- k.split(/[_\-.]+/).filter(Boolean).map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`).join(' ')
+ k
+ .split(/[_\-.]+/)
+ .filter(Boolean)
+ .map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`)
+ .join(' ')
const pluralize = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
@@ -37,12 +63,16 @@ function clipInline(value: string, max = 180): string {
function clipBlock(value: string, maxChars = 1800, maxLines = 18): string {
const t = value.trim()
- if (!t) {return ''}
+ if (!t) {
+ return ''
+ }
const lines = t.split('\n')
let text = lines.slice(0, maxLines).join('\n')
const clipped = lines.length > maxLines || text.length > maxChars
- if (text.length > maxChars) {text = text.slice(0, maxChars - 1).trimEnd()}
+ if (text.length > maxChars) {
+ text = text.slice(0, maxChars - 1).trimEnd()
+ }
return clipped && !text.endsWith('…') ? `${text}…` : text
}
@@ -51,7 +81,9 @@ function firstString(record: Json, keys: readonly string[]): string {
for (const k of keys) {
const v = record[k]
- if (typeof v === 'string' && v.trim()) {return v.trim()}
+ if (typeof v === 'string' && v.trim()) {
+ return v.trim()
+ }
}
return ''
@@ -68,25 +100,37 @@ const isWrapperKey = (k: string) => (WRAPPER_KEYS as readonly string[]).includes
const skipField = (k: string, v: unknown) => isWrapperKey(k) || ((k === 'success' || k === 'ok') && v === true)
function summarizeScalar(v: unknown): string {
- if (typeof v === 'string') {return clipInline(v)}
+ if (typeof v === 'string') {
+ return clipInline(v)
+ }
- if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
+ if (typeof v === 'number' || typeof v === 'boolean') {
+ return String(v)
+ }
return ''
}
function summarizeRecordInline(record: Json, depth: number): string {
- if (depth > 3) {return pluralize(Object.keys(record).length, 'field')}
+ if (depth > 3) {
+ return pluralize(Object.keys(record).length, 'field')
+ }
const title = firstString(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'id'])
const status = firstString(record, ['status', 'category', 'type'])
const message = firstString(record, ['snippet', 'summary', 'description', 'message'])
- if (title && status) {return `${clipInline(title, 110)} (${clipInline(status, 54)})`}
+ if (title && status) {
+ return `${clipInline(title, 110)} (${clipInline(status, 54)})`
+ }
- if (title && message && title !== message) {return `${clipInline(title, 90)} - ${clipInline(message, 84)}`}
+ if (title && message && title !== message) {
+ return `${clipInline(title, 90)} - ${clipInline(message, 84)}`
+ }
- if (title) {return clipInline(title, 150)}
+ if (title) {
+ return clipInline(title, 150)
+ }
const pairs = orderedKeys(Object.keys(record))
.filter(k => !skipField(k, record[k]))
@@ -104,15 +148,25 @@ function summarizeRecordInline(record: Json, depth: number): string {
function summarizeListItem(item: unknown, depth: number): string {
const v = norm(item)
- if (typeof v === 'string') {return clipInline(v)}
+ if (typeof v === 'string') {
+ return clipInline(v)
+ }
- if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
+ if (typeof v === 'number' || typeof v === 'boolean') {
+ return String(v)
+ }
- if (v == null) {return ''}
+ if (v == null) {
+ return ''
+ }
- if (Array.isArray(v)) {return pluralize(v.length, 'item')}
+ if (Array.isArray(v)) {
+ return pluralize(v.length, 'item')
+ }
- if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
+ if (isRecord(v)) {
+ return summarizeRecordInline(v, depth + 1)
+ }
return clipInline(String(v))
}
@@ -121,32 +175,50 @@ function formatFieldValue(value: unknown, depth: number): string {
const v = norm(value)
const scalar = summarizeScalar(v)
- if (scalar) {return scalar}
+ if (scalar) {
+ return scalar
+ }
- if (v == null) {return ''}
+ if (v == null) {
+ return ''
+ }
if (Array.isArray(v)) {
- if (!v.length) {return '0 items'}
+ if (!v.length) {
+ return '0 items'
+ }
const scalars = v.map(summarizeScalar).filter(Boolean)
- if (scalars.length === v.length && v.length <= 4) {return clipInline(scalars.join(', '))}
+ if (scalars.length === v.length && v.length <= 4) {
+ return clipInline(scalars.join(', '))
+ }
const first = summarizeListItem(v[0], depth + 1)
return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item')
}
- if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
+ if (isRecord(v)) {
+ return summarizeRecordInline(v, depth + 1)
+ }
return clipInline(String(v))
}
function formatArraySummary(value: unknown[], depth: number): string {
- if (!value.length) {return 'No items returned.'}
+ if (!value.length) {
+ return 'No items returned.'
+ }
const max = 6
- const lines = value.slice(0, max).map(item => summarizeListItem(item, depth + 1)).filter(Boolean).map(l => `- ${l}`)
+ const lines = value
+ .slice(0, max)
+ .map(item => summarizeListItem(item, depth + 1))
+ .filter(Boolean)
+ .map(l => `- ${l}`)
- if (!lines.length) {return `Returned ${pluralize(value.length, 'item')}.`}
+ if (!lines.length) {
+ return `Returned ${pluralize(value.length, 'item')}.`
+ }
if (value.length > max) {
const remaining = value.length - max
@@ -159,13 +231,17 @@ function formatArraySummary(value: unknown[], depth: number): string {
function formatRecordSummary(record: Json, depth: number): string {
const keys = Object.keys(record)
- if (!keys.length) {return 'Returned an empty object.'}
+ if (!keys.length) {
+ return 'Returned an empty object.'
+ }
if (depth <= 2) {
const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content'])
const meaningful = keys.filter(k => !skipField(k, record[k]) && !isWrapperKey(k))
- if (direct && meaningful.length <= 1) {return clipBlock(direct)}
+ if (direct && meaningful.length <= 1) {
+ return clipBlock(direct)
+ }
}
const candidates = orderedKeys(keys).filter(k => !skipField(k, record[k]))
@@ -175,13 +251,19 @@ function formatRecordSummary(record: Json, depth: number): string {
for (const k of candidates) {
const v = formatFieldValue(record[k], depth + 1)
- if (!v) {continue}
+ if (!v) {
+ continue
+ }
lines.push(`- ${titleCase(k)}: ${v}`)
- if (lines.length >= max) {break}
+ if (lines.length >= max) {
+ break
+ }
}
- if (!lines.length) {return `Returned object with ${pluralize(keys.length, 'field')}.`}
+ if (!lines.length) {
+ return `Returned object with ${pluralize(keys.length, 'field')}.`
+ }
if (candidates.length > lines.length) {
const remaining = candidates.length - lines.length
@@ -192,18 +274,30 @@ function formatRecordSummary(record: Json, depth: number): string {
}
function formatSummaryValue(value: unknown, depth: number): string {
- if (depth > 4) {return ''}
+ if (depth > 4) {
+ return ''
+ }
const v = norm(value)
- if (typeof v === 'string') {return clipBlock(v)}
+ if (typeof v === 'string') {
+ return clipBlock(v)
+ }
- if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
+ if (typeof v === 'number' || typeof v === 'boolean') {
+ return String(v)
+ }
- if (v == null) {return ''}
+ if (v == null) {
+ return ''
+ }
- if (Array.isArray(v)) {return formatArraySummary(v, depth + 1)}
+ if (Array.isArray(v)) {
+ return formatArraySummary(v, depth + 1)
+ }
- if (isRecord(v)) {return formatRecordSummary(v, depth + 1)}
+ if (isRecord(v)) {
+ return formatRecordSummary(v, depth + 1)
+ }
return clipInline(String(v))
}
@@ -232,17 +326,29 @@ function unwrapPayload(value: unknown): unknown {
function hasMeaningfulErrorValue(value: unknown): boolean {
const v = norm(value)
- if (v == null) {return false}
+ if (v == null) {
+ return false
+ }
- if (typeof v === 'string') {return !NON_ERROR_TEXT.has(v.trim().toLowerCase())}
+ if (typeof v === 'string') {
+ return !NON_ERROR_TEXT.has(v.trim().toLowerCase())
+ }
- if (typeof v === 'boolean') {return v}
+ if (typeof v === 'boolean') {
+ return v
+ }
- if (typeof v === 'number') {return v !== 0}
+ if (typeof v === 'number') {
+ return v !== 0
+ }
- if (Array.isArray(v)) {return v.some(hasMeaningfulErrorValue)}
+ if (Array.isArray(v)) {
+ return v.some(hasMeaningfulErrorValue)
+ }
- if (isRecord(v)) {return Object.keys(v).length > 0}
+ if (isRecord(v)) {
+ return Object.keys(v).length > 0
+ }
return true
}
@@ -261,7 +367,9 @@ function hasErrorSignal(record: Json): boolean {
function valueErrorText(value: unknown): string {
const v = norm(value)
- if (typeof v === 'string') {return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : ''}
+ if (typeof v === 'string') {
+ return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : ''
+ }
if (Array.isArray(v)) {
return clipBlock(v.map(valueErrorText).filter(Boolean).slice(0, 3).join('; '), 700, 12)
@@ -270,24 +378,32 @@ function valueErrorText(value: unknown): string {
if (isRecord(v)) {
const direct = firstString(v, ERROR_MSG_KEYS)
- if (direct) {return clipBlock(direct, 700, 12)}
+ if (direct) {
+ return clipBlock(direct, 700, 12)
+ }
}
return ''
}
function findNestedError(value: unknown, depth: number, seen: Set): string {
- if (depth > 5) {return ''}
+ if (depth > 5) {
+ return ''
+ }
const v = norm(value)
- if (!v || typeof v !== 'object' || seen.has(v)) {return ''}
+ if (!v || typeof v !== 'object' || seen.has(v)) {
+ return ''
+ }
seen.add(v)
if (Array.isArray(v)) {
for (const item of v) {
const nested = findNestedError(item, depth + 1, seen)
- if (nested) {return nested}
+ if (nested) {
+ return nested
+ }
}
return ''
@@ -296,22 +412,30 @@ function findNestedError(value: unknown, depth: number, seen: Set): str
const record = v as Json
for (const k of ERROR_KEYS) {
- if (!hasMeaningfulErrorValue(record[k])) {continue}
+ if (!hasMeaningfulErrorValue(record[k])) {
+ continue
+ }
const text = valueErrorText(record[k])
- if (text) {return text}
+ if (text) {
+ return text
+ }
}
if (hasErrorSignal(record)) {
const direct = firstString(record, ERROR_MSG_KEYS)
- if (direct) {return clipBlock(direct, 700, 12)}
+ if (direct) {
+ return clipBlock(direct, 700, 12)
+ }
}
for (const k of [...ERROR_KEYS, ...WRAPPER_KEYS, 'details', 'meta']) {
const nested = findNestedError(record[k], depth + 1, seen)
- if (nested) {return nested}
+ if (nested) {
+ return nested
+ }
}
return ''
diff --git a/apps/desktop/src/lib/use-enter-animation.ts b/apps/desktop/src/lib/use-enter-animation.ts
new file mode 100644
index 0000000000..c95878f417
--- /dev/null
+++ b/apps/desktop/src/lib/use-enter-animation.ts
@@ -0,0 +1,100 @@
+import { useCallback, useRef } from 'react'
+
+/**
+ * One-shot enter animation via the Web Animations API.
+ *
+ * Returns a callback ref. The animation fires exactly once when the element
+ * first attaches to the DOM and never replays for an already-mounted node —
+ * this is deliberate. CSS-transition + `@starting-style` is fragile here
+ * because:
+ * - Streaming deltas constantly invalidate ancestor state, which can
+ * re-trigger transitions on unrelated descendants.
+ * - `@starting-style` only covers DOM insertion / first-match, but any
+ * style restart during the message lifecycle replays the transition.
+ * - Some Chromium versions reset transitions when an attribute on an
+ * ancestor toggles, even if the descendant's properties never change.
+ *
+ * `el.animate(...)` runs against the element directly and is independent of
+ * CSS rule churn — it plays once, finishes, and is done. If the element
+ * unmounts and re-mounts, the callback ref runs again and replays it
+ * (correct behaviour).
+ *
+ * `enabled` is captured at mount-time only — flipping it later doesn't
+ * suddenly play the animation on existing nodes.
+ */
+const playedAnimationKeys = new Set()
+const playedAnimationOrder: string[] = []
+const MAX_TRACKED_KEYS = 2048
+
+function hasPlayedAnimation(key: string): boolean {
+ return playedAnimationKeys.has(key)
+}
+
+function rememberPlayedAnimation(key: string): void {
+ if (playedAnimationKeys.has(key)) {
+ return
+ }
+
+ playedAnimationKeys.add(key)
+ playedAnimationOrder.push(key)
+
+ if (playedAnimationOrder.length > MAX_TRACKED_KEYS) {
+ const evicted = playedAnimationOrder.shift()
+
+ if (evicted) {
+ playedAnimationKeys.delete(evicted)
+ }
+ }
+}
+
+function scheduleMicrotask(cb: () => void): void {
+ if (typeof queueMicrotask === 'function') {
+ queueMicrotask(cb)
+
+ return
+ }
+
+ void Promise.resolve().then(cb)
+}
+
+export function useEnterAnimation(enabled: boolean, animationKey?: string): (el: HTMLElement | null) => void {
+ const enabledRef = useRef(enabled)
+ const keyRef = useRef(animationKey)
+
+ enabledRef.current = enabled
+ keyRef.current = animationKey
+
+ return useCallback((el: HTMLElement | null) => {
+ if (!el || !enabledRef.current || typeof window === 'undefined') {
+ return
+ }
+
+ if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
+ return
+ }
+
+ const key = keyRef.current
+
+ if (key && hasPlayedAnimation(key)) {
+ return
+ }
+
+ el.animate(
+ [
+ { opacity: 0, transform: 'translateY(0.5rem)' },
+ { opacity: 1, transform: 'translateY(0)' }
+ ],
+ { duration: 220, easing: 'linear', fill: 'both' }
+ )
+
+ if (key) {
+ // In React StrictMode the first mount can be immediately torn down.
+ // Only persist "played" once the element survives to the microtask tick.
+ scheduleMicrotask(() => {
+ if (el.isConnected) {
+ rememberPlayedAnimation(key)
+ }
+ })
+ }
+ }, [])
+}
diff --git a/apps/desktop/src/store/notifications.ts b/apps/desktop/src/store/notifications.ts
index 47eda493f8..2c091b6b95 100644
--- a/apps/desktop/src/store/notifications.ts
+++ b/apps/desktop/src/store/notifications.ts
@@ -154,6 +154,7 @@ export function clearNotifications() {
timers.clear()
const all = $notifications.get()
$notifications.set([])
+
for (const item of all) {
item.onDismiss?.()
}
diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts
new file mode 100644
index 0000000000..55418166aa
--- /dev/null
+++ b/apps/desktop/src/store/onboarding.test.ts
@@ -0,0 +1,144 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { OAuthProvider } from '@/types/hermes'
+
+import {
+ $desktopOnboarding,
+ type DesktopOnboardingState,
+ type OnboardingContext,
+ refreshOnboarding,
+ requestDesktopOnboarding
+} from './onboarding'
+
+function provider(id: string, name = id): OAuthProvider {
+ return {
+ cli_command: `hermes login ${id}`,
+ docs_url: `https://example.com/${id}`,
+ flow: 'pkce',
+ id,
+ name,
+ status: { logged_in: false }
+ }
+}
+
+function baseState(overrides: Partial = {}): DesktopOnboardingState {
+ return {
+ configured: false,
+ flow: { status: 'idle' },
+ mode: 'oauth',
+ providers: null,
+ reason: null,
+ requested: false,
+ ...overrides
+ }
+}
+
+function installApiMock(api: (request: { path: string }) => Promise) {
+ Object.defineProperty(window, 'hermesDesktop', {
+ configurable: true,
+ value: { api }
+ })
+}
+
+function runtimeMismatchGateway(): OnboardingContext['requestGateway'] {
+ return async method => {
+ if (method === 'setup.status') {
+ return { provider_configured: true } as never
+ }
+
+ if (method === 'setup.runtime_check') {
+ return { error: 'Selected runtime is not available.', ok: false } as never
+ }
+
+ throw new Error(`unexpected gateway method: ${method}`)
+ }
+}
+
+function onboardingContext(requestGateway: OnboardingContext['requestGateway']): OnboardingContext {
+ return { requestGateway }
+}
+
+describe('refreshOnboarding', () => {
+ beforeEach(() => {
+ window.localStorage.clear()
+ $desktopOnboarding.set(baseState())
+ })
+
+ afterEach(() => {
+ window.localStorage.clear()
+ $desktopOnboarding.set(baseState())
+ vi.restoreAllMocks()
+ })
+
+ it('refreshes OAuth providers again when onboarding was explicitly requested', async () => {
+ const api = vi.fn(async ({ path }: { path: string }) => {
+ if (path === '/api/providers/oauth') {
+ return { providers: [provider('fresh')] }
+ }
+
+ throw new Error(`unexpected api path: ${path}`)
+ })
+
+ installApiMock(api)
+ $desktopOnboarding.set(baseState({ providers: [provider('cached')] }))
+ requestDesktopOnboarding('Need provider setup')
+
+ const ready = await refreshOnboarding(onboardingContext(runtimeMismatchGateway()))
+
+ expect(ready).toBe(false)
+ expect(api).toHaveBeenCalledTimes(1)
+ expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['fresh'])
+ expect($desktopOnboarding.get().reason).toContain('Selected runtime is not available.')
+ expect($desktopOnboarding.get().reason).toContain('setup.status reports configured credentials')
+ })
+
+ it('keeps cached providers when onboarding was not re-requested', async () => {
+ const api = vi.fn(async ({ path }: { path: string }) => {
+ if (path === '/api/providers/oauth') {
+ return { providers: [provider('fresh')] }
+ }
+
+ throw new Error(`unexpected api path: ${path}`)
+ })
+
+ installApiMock(api)
+ $desktopOnboarding.set(baseState({ providers: [provider('cached')] }))
+
+ const ready = await refreshOnboarding(onboardingContext(runtimeMismatchGateway()))
+
+ expect(ready).toBe(false)
+ expect(api).not.toHaveBeenCalled()
+ expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['cached'])
+ })
+
+ it('deduplicates concurrent provider refresh calls', async () => {
+ let resolveProviders!: (value: { providers: OAuthProvider[] }) => void
+
+ const providersPromise = new Promise<{ providers: OAuthProvider[] }>(resolve => {
+ resolveProviders = value => {
+ resolve(value)
+ }
+ })
+
+ const api = vi.fn(async ({ path }: { path: string }) => {
+ if (path === '/api/providers/oauth') {
+ return providersPromise
+ }
+
+ throw new Error(`unexpected api path: ${path}`)
+ })
+
+ installApiMock(api)
+ $desktopOnboarding.set(baseState({ requested: true }))
+
+ const first = refreshOnboarding(onboardingContext(runtimeMismatchGateway()))
+ const second = refreshOnboarding(onboardingContext(runtimeMismatchGateway()))
+
+ await vi.waitFor(() => expect(api).toHaveBeenCalledTimes(1))
+
+ resolveProviders({ providers: [provider('shared')] })
+ await Promise.all([first, second])
+
+ expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['shared'])
+ })
+})
diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts
index edf07af6aa..7738e2cde0 100644
--- a/apps/desktop/src/store/onboarding.ts
+++ b/apps/desktop/src/store/onboarding.ts
@@ -8,6 +8,7 @@ import {
startOAuthLogin,
submitOAuthCode
} from '@/hermes'
+import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { notify, notifyError } from '@/store/notifications'
import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes'
@@ -46,6 +47,7 @@ export interface OnboardingContext {
const CONFIGURED_CACHE_KEY = 'hermes-desktop-onboarded-v1'
const POLL_MS = 2000
const COPY_FLASH_MS = 1500
+const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.'
function readCachedConfigured(): boolean | null {
if (typeof window === 'undefined') {
@@ -87,6 +89,7 @@ const INITIAL: DesktopOnboardingState = {
export const $desktopOnboarding = atom(INITIAL)
let pollTimer: number | null = null
+let providersRefreshPromise: null | Promise = null
const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e))
@@ -104,42 +107,61 @@ function clearPoll() {
}
}
-async function safeReq(ctx: OnboardingContext, method: string, fallback: T): Promise {
- try {
- return await ctx.requestGateway(method)
- } catch {
- return fallback
- }
-}
-
-async function checkRuntime(ctx: OnboardingContext) {
- const [status, runtime] = await Promise.all([
- safeReq<{ provider_configured?: boolean }>(ctx, 'setup.status', {}),
- safeReq<{ error?: string; ok?: boolean }>(ctx, 'setup.runtime_check', { ok: false })
- ])
-
- return runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured)
+async function checkRuntime(ctx: OnboardingContext): Promise {
+ return evaluateRuntimeReadiness(ctx.requestGateway, {
+ defaultReason: DEFAULT_ONBOARDING_REASON,
+ unknownReady: false
+ })
}
function notifyReady(provider: string) {
notify({ kind: 'success', title: 'Hermes is ready', message: `${provider} connected.` })
}
-async function reloadAndConnect(ctx: OnboardingContext, providerName: string, onFail: () => void) {
+async function reloadAndConnect(ctx: OnboardingContext, providerName: string, onFail: (reason: null | string) => void) {
await ctx.requestGateway('reload.env').catch(() => undefined)
- const ok = await checkRuntime(ctx)
+ const runtime = await checkRuntime(ctx)
- if (ok) {
+ if (runtime.ready) {
notifyReady(providerName)
completeDesktopOnboarding()
ctx.onCompleted?.()
} else {
- onFail()
+ onFail(runtime.reason)
}
}
-export function requestDesktopOnboarding(reason = 'No inference provider is configured.') {
- patch({ reason, requested: true })
+function providerResolutionFailure(reason: null | string) {
+ const detail = reason?.trim()
+
+ return detail
+ ? `Connected, but Hermes still cannot resolve a usable provider. ${detail}`
+ : 'Connected, but Hermes still cannot resolve a usable provider.'
+}
+
+async function refreshProviders() {
+ if (providersRefreshPromise) {
+ await providersRefreshPromise
+
+ return
+ }
+
+ providersRefreshPromise = (async () => {
+ try {
+ const { providers } = await listOAuthProviders()
+ patch({ mode: providers.length > 0 ? 'oauth' : 'apikey', providers })
+ } catch {
+ patch({ mode: 'apikey', providers: [] })
+ } finally {
+ providersRefreshPromise = null
+ }
+ })()
+
+ await providersRefreshPromise
+}
+
+export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
+ patch({ reason: reason.trim() || DEFAULT_ONBOARDING_REASON, requested: true })
}
export function completeDesktopOnboarding() {
@@ -160,26 +182,26 @@ export function setOnboardingMode(mode: OnboardingMode) {
}
export async function refreshOnboarding(ctx: OnboardingContext) {
- if (await checkRuntime(ctx)) {
+ const runtime = await checkRuntime(ctx)
+
+ if (runtime.ready) {
completeDesktopOnboarding()
ctx.onCompleted?.()
return true
}
- writeCachedConfigured(false)
- patch({ configured: false })
+ const state = $desktopOnboarding.get()
+ const reason = runtime.reason || state.reason || DEFAULT_ONBOARDING_REASON
- if ($desktopOnboarding.get().providers !== null) {
+ writeCachedConfigured(false)
+ patch({ configured: false, reason })
+
+ if (state.providers !== null && !state.requested) {
return false
}
- try {
- const { providers } = await listOAuthProviders()
- patch({ providers, mode: providers.length > 0 ? 'oauth' : 'apikey' })
- } catch {
- patch({ providers: [], mode: 'apikey' })
- }
+ await refreshProviders()
return false
}
@@ -219,11 +241,11 @@ async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: Onbo
if (status === 'approved') {
clearPoll()
setFlow({ status: 'success', provider })
- await reloadAndConnect(ctx, provider.name, () =>
+ await reloadAndConnect(ctx, provider.name, reason =>
setFlow({
status: 'error',
provider,
- message: 'Connected, but Hermes still cannot resolve a usable provider.'
+ message: providerResolutionFailure(reason)
})
)
} else if (status !== 'pending') {
@@ -259,11 +281,11 @@ export async function submitOnboardingCode(ctx: OnboardingContext) {
if (resp.ok && resp.status === 'approved') {
setFlow({ status: 'success', provider })
- await reloadAndConnect(ctx, provider.name, () =>
+ await reloadAndConnect(ctx, provider.name, reason =>
setFlow({
status: 'error',
provider,
- message: 'Connected, but Hermes still cannot resolve a usable provider.'
+ message: providerResolutionFailure(reason)
})
)
} else {
@@ -338,11 +360,13 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
}
const { provider } = flow
- await reloadAndConnect(ctx, provider.name, () =>
+ await reloadAndConnect(ctx, provider.name, reason =>
setFlow({
status: 'error',
provider,
- message: `Hermes still cannot reach ${provider.name}. Run \`${provider.cli_command}\` in a terminal first.`
+ message:
+ reason?.trim() ||
+ `Hermes still cannot reach ${provider.name}. Run \`${provider.cli_command}\` in a terminal first.`
})
)
}
@@ -357,12 +381,19 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
try {
await setEnvVar(envKey, trimmed)
let stillFailing = false
- await reloadAndConnect(ctx, label, () => {
+ let runtimeFailure: null | string = null
+ await reloadAndConnect(ctx, label, reason => {
stillFailing = true
+ runtimeFailure = reason
})
if (stillFailing) {
- return { ok: false, message: `Saved, but Hermes still cannot reach ${label}. Double-check the value.` }
+ const failureDetail = (runtimeFailure ?? '').trim()
+
+ return {
+ ok: false,
+ message: failureDetail || `Saved, but Hermes still cannot reach ${label}. Double-check the value.`
+ }
}
return { ok: true }
diff --git a/apps/desktop/src/store/tool-view.ts b/apps/desktop/src/store/tool-view.ts
index 072ae65477..1929321651 100644
--- a/apps/desktop/src/store/tool-view.ts
+++ b/apps/desktop/src/store/tool-view.ts
@@ -1,4 +1,4 @@
-import { atom } from 'nanostores'
+import { atom, computed, type ReadableAtom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
@@ -14,6 +14,7 @@ export const $toolViewMode = atom(
storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product'
)
export const $toolDisclosureStates = atom(loadToolDisclosureStates())
+const disclosureOpenCache = new Map>()
$toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical'))
$toolDisclosureStates.subscribe(persistToolDisclosureStates)
@@ -22,6 +23,17 @@ export function setToolViewMode(mode: ToolViewMode) {
$toolViewMode.set(mode)
}
+export function $toolDisclosureOpen(id: string): ReadableAtom {
+ let cached = disclosureOpenCache.get(id)
+
+ if (!cached) {
+ cached = computed($toolDisclosureStates, states => states[id])
+ disclosureOpenCache.set(id, cached)
+ }
+
+ return cached
+}
+
function loadToolDisclosureStates(): ToolDisclosureStates {
if (typeof window === 'undefined') {
return {}
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index 112d3cb16c..a2dd6a990a 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -432,11 +432,13 @@ canvas {
padding-left: var(--message-text-indent);
}
+/* Tool/thinking blocks now live at message-text alignment (no leading
+ chevron column to escape into), so their headers and bodies share a
+ common left edge with the model's text. */
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block'],
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'] {
- margin-inline-start: calc(-1 * var(--message-text-indent));
- width: calc(100% + var(--message-text-indent));
- max-width: calc(100% + var(--message-text-indent));
+ width: 100%;
+ max-width: 100%;
}
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code {
@@ -525,3 +527,55 @@ canvas {
width: 0.875rem;
height: 0.875rem;
}
+
+/* Streaming caret + comet tail. Anchored to the cursor (not the block's
+ right edge) so it reads on short last lines. Spread inflates the bg-color
+ veil so it overlays text instead of haloing the tiny dither block. */
+[data-slot='aui_assistant-message-content']
+ [data-status='running']
+ > div
+ > *:last-child::after {
+ content: '' !important;
+ display: inline-block;
+ position: relative;
+ z-index: 2;
+ width: 0.6em;
+ height: 1em;
+ margin-left: 0.18em;
+ vertical-align: middle;
+ border-radius: 1.5px;
+ background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 2px 2px;
+ color: color-mix(in srgb, var(--dt-foreground) 72%, transparent);
+ box-shadow:
+ -0.8ch 0 1.4ch 0.55em color-mix(in srgb, var(--dt-background) 80%, transparent),
+ -3ch 0 2.4ch 0.55em color-mix(in srgb, var(--dt-background) 64%, transparent),
+ -6.5ch 0 3.6ch 0.5em color-mix(in srgb, var(--dt-background) 44%, transparent),
+ -11ch 0 5ch 0.45em color-mix(in srgb, var(--dt-background) 28%, transparent),
+ -16ch 0 6.4ch 0.4em color-mix(in srgb, var(--dt-background) 16%, transparent),
+ -22ch 0 7.6ch 0.35em color-mix(in srgb, var(--dt-background) 8%, transparent),
+ 0 0 0.4ch color-mix(in srgb, var(--dt-foreground) 28%, transparent);
+ animation: hermes-stream-caret 1.1s ease-in-out infinite;
+}
+
+@keyframes hermes-stream-caret {
+ 0%, 100% { opacity: 0.95; }
+ 50% { opacity: 0.45; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ [data-slot='aui_assistant-message-content']
+ [data-status='running']
+ > div
+ > *:last-child::after {
+ animation: none;
+ opacity: 0.8;
+ }
+}
+
+/* Live thinking preview window. Pairs with the ResizeObserver in
+ ThinkingDisclosure that pins scrollTop to the bottom — older lines fade
+ into the top mask while the latest tokens settle in below. */
+.thinking-preview {
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 28%, black 100%);
+ mask-image: linear-gradient(to bottom, transparent 0%, black 28%, black 100%);
+}
diff --git a/package-lock.json b/package-lock.json
index 0b54c720ad..308a8fb935 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -71,7 +71,7 @@
"@chenglou/pretext": "^0.0.6",
"@hermes/shared": "file:../shared",
"@nanostores/react": "^1.1.0",
- "@nous-research/ui": "^0.12.0",
+ "@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
"@streamdown/code": "^1.1.1",
"@tabler/icons-react": "^3.41.1",
@@ -131,9 +131,9 @@
}
},
"apps/desktop/node_modules/@nous-research/ui": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.12.0.tgz",
- "integrity": "sha512-OHv7z9J0r5Yp9iOl8uVUZAdIkTtDwqAL2RbgsQczXAhSPMOQle5X6ROWI4j6WK9riYhh4iCtRaCMsLcmSAuaXg==",
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.0.tgz",
+ "integrity": "sha512-c07lfMdEv/KL6lYC6mfap1CcmIPbvhCZu1supnFaIIrlUaab8gVNDYl8wMMjNRdYOVxxXKisU48yyfe5qvlwqg==",
"dependencies": {
"@nanostores/react": "^1.0.0",
"class-variance-authority": "^0.7.1",
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 1aa355c0a4..c4bfe8274f 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -1591,12 +1591,12 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict):
_emit(
"tool.start",
sid,
- {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)},
+ {"tool_id": tool_call_id, "name": name, "args": args, "context": _tool_ctx(name, args)},
)
def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str):
- payload = {"tool_id": tool_call_id, "name": name}
+ payload = {"tool_id": tool_call_id, "name": name, "args": args}
session = _sessions.get(sid)
snapshot = None
started_at = None
@@ -1606,6 +1606,10 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result
duration_s = time.time() - started_at if started_at else None
if duration_s is not None:
payload["duration_s"] = duration_s
+ try:
+ payload["result"] = json.loads(result)
+ except Exception:
+ payload["result"] = result
summary = _tool_summary(name, result, duration_s)
if summary:
payload["summary"] = summary
@@ -1645,7 +1649,9 @@ def _on_tool_progress(
if not _tool_progress_enabled(sid):
return
if event_type == "tool.started" and name:
- _emit("tool.progress", sid, {"name": name, "preview": preview or ""})
+ # `_on_tool_start` already emits the authoritative `tool.start` with
+ # the stable tool id and args. Emitting another id-less progress row
+ # here makes the desktop live view diverge from hydrated history.
return
if event_type == "reasoning.available" and preview:
_emit("reasoning.available", sid, {"text": str(preview)})