feat(desktop): reconcile live tool events, polish thread chrome, harden boot

- chat-messages: match tool rows by overlapping query/context/preview values
  so preview-first `tool.progress` rows reliably adopt later stable-id
  `tool.start` payloads instead of spawning ghost rows or mis-merging
  parallel same-name calls; preserve prior args/result across phases.
- tui_gateway: emit full args + parsed result on `tool.start` / `tool.complete`,
  drop redundant `tool.started` re-emit from `tool.progress`.
- electron/main: prefer SOURCE_REPO_ROOT before PATH `hermes` in dev so
  local backend edits actually run; split hardening helpers into
  `electron/hardening.cjs` with tests.
- thread/tool UI: one-shot enter animation keyed by stable ids, braille
  spinner for running rows, Cursor-like disclosure rows, drill-down +
  duration/count formatting via new tool-fallback-model.
- composer: extract `text-utils`, drop liquid-glass overrides.
- right-rail: split preview-pane into preview-console / preview-file.
- runtime: incremental external-store runtime + runtime-readiness gate;
  onboarding store + tests; route-resume hook test.
- regression tests for live tool reconciliation (parallel tools, id-less
  progress, preview-first rows, structured args/results).
This commit is contained in:
Brooklyn Nicholson
2026-05-11 21:38:47 -04:00
parent fdf73f0adf
commit d208f2c2c0
64 changed files with 5614 additions and 2703 deletions
@@ -3,11 +3,7 @@ const fs = require('node:fs')
const path = require('node:path') const path = require('node:path')
const test = require('node:test') const test = require('node:test')
const { const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
bundledRuntimeImportCheck,
isWindowsBinaryPathInWsl,
isWslEnvironment
} = require('./bootstrap-platform.cjs')
test('isWslEnvironment detects WSL2 env vars on linux', () => { test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true) assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
+184
View File
@@ -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
}
+116
View File
@@ -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)
})
+52 -50
View File
@@ -21,6 +21,14 @@ const path = require('node:path')
const { fileURLToPath, pathToFileURL } = require('node:url') const { fileURLToPath, pathToFileURL } = require('node:url')
const { spawn } = require('node:child_process') const { spawn } = require('node:child_process')
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') 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 const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
if (USER_DATA_OVERRIDE) { if (USER_DATA_OVERRIDE) {
@@ -843,7 +851,16 @@ function resolveHermesBackend(dashboardArgs) {
if (backend) return backend 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 // pip-installed system-wide. Skip when HERMES_DESKTOP_IGNORE_EXISTING=1
// (used by test:desktop:fresh to force the factory-image bootstrap path). // (used by test:desktop:fresh to force the factory-image bootstrap path).
if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') { 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 // 4. ACTIVE_HERMES_ROOT — the canonical mutable install at
// %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent. // %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent.
// On packaged installs this is populated from FACTORY_HERMES_ROOT during // 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 body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const parsed = new URL(url) const parsed = new URL(url)
const client = parsed.protocol === 'https:' ? https : http const client = parsed.protocol === 'https:' ? https : http
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
@@ -1190,11 +1199,9 @@ function fetchJson(url, token, options = {}) {
) )
req.on('error', reject) req.on('error', reject)
if (options.timeoutMs) { req.setTimeout(timeoutMs, () => {
req.setTimeout(options.timeoutMs, () => { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
req.destroy(new Error(`Timed out connecting to Hermes backend after ${options.timeoutMs}ms`)) })
})
}
if (body) req.write(body) if (body) req.write(body)
req.end() req.end()
}) })
@@ -1456,7 +1463,9 @@ function fetchLinkTitle(rawUrl) {
const pending = fetchHtmlTitleWithCurl(url) const pending = fetchHtmlTitleWithCurl(url)
.catch(() => '') .catch(() => '')
.then(value => usableTitle((value || '').slice(0, 240))) .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 => { .then(clean => {
cacheTitle(key, clean) cacheTitle(key, clean)
titleInflight.delete(key) titleInflight.delete(key)
@@ -2022,24 +2031,7 @@ function tokenPreview(value) {
} }
function encryptDesktopSecret(value) { function encryptDesktopSecret(value) {
const raw = String(value || '') return encryptDesktopSecretStrict(value, safeStorage)
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 }
} }
function decryptDesktopSecret(secret) { 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 mode = input.mode === 'remote' ? 'remote' : 'local'
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim() const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : '' const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
const existingToken = existing.remote?.token const existingToken = existing.remote?.token
const nextRemote = { const nextRemote = {
url: remoteUrl, url: remoteUrl,
token: incomingToken ? encryptDesktopSecret(incomingToken) : existingToken token: incomingToken
? persistToken
? encryptDesktopSecret(incomingToken)
: { encoding: 'plain', value: incomingToken }
: existingToken
} }
if (mode === 'remote') { if (mode === 'remote') {
@@ -2181,7 +2178,7 @@ function resolveRemoteBackend() {
} }
async function testDesktopConnectionConfig(input = {}) { async function testDesktopConnectionConfig(input = {}) {
const config = coerceDesktopConnectionConfig(input) const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
const remote = const remote =
config.mode === 'remote' config.mode === 'remote'
? { ? {
@@ -2455,9 +2452,11 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
ipcMain.handle('hermes:api', async (_event, request) => { ipcMain.handle('hermes:api', async (_event, request) => {
const connection = await startHermes() const connection = await startHermes()
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, { return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, {
method: request.method, method: request?.method,
body: request.body body: request?.body,
timeoutMs
}) })
}) })
@@ -2472,18 +2471,21 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
}) })
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => { ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
const input = String(filePath || '') const { resolvedPath } = await resolveReadableFileForIpc(filePath, {
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input) maxBytes: DATA_URL_READ_MAX_BYTES,
const data = await fs.promises.readFile(resolved) purpose: 'File preview'
return `data:${mimeTypeForPath(resolved)};base64,${data.toString('base64')}` })
const data = await fs.promises.readFile(resolvedPath)
return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}`
}) })
ipcMain.handle('hermes:readFileText', async (_event, filePath) => { ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
const input = String(filePath || '') const { resolvedPath, stat } = await resolveReadableFileForIpc(filePath, {
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input) maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES,
const ext = path.extname(resolved).toLowerCase() purpose: 'Text preview'
const stat = await fs.promises.stat(resolved) })
const handle = await fs.promises.open(resolved, 'r') const ext = path.extname(resolvedPath).toLowerCase()
const handle = await fs.promises.open(resolvedPath, 'r')
const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES) const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES)
try { try {
@@ -2494,8 +2496,8 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))), binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))),
byteSize: stat.size, byteSize: stat.size,
language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text', language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text',
mimeType: mimeTypeForPath(resolved), mimeType: mimeTypeForPath(resolvedPath),
path: resolved, path: resolvedPath,
text: buffer.subarray(0, bytesRead).toString('utf8'), text: buffer.subarray(0, bytesRead).toString('utf8'),
truncated: stat.size > TEXT_PREVIEW_MAX_BYTES truncated: stat.size > TEXT_PREVIEW_MAX_BYTES
} }
+542 -20
View File
@@ -14,17 +14,20 @@
"@chenglou/pretext": "^0.0.6", "@chenglou/pretext": "^0.0.6",
"@hermes/shared": "file:../shared", "@hermes/shared": "file:../shared",
"@nanostores/react": "^1.1.0", "@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@streamdown/code": "^1.1.1", "@streamdown/code": "^1.1.1",
"@tabler/icons-react": "^3.41.1", "@tabler/icons-react": "^3.41.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.6", "@tanstack/react-query": "^5.100.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"liquid-glass-react": "^1.1.1", "leva": "^0.10.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"motion": "^12.38.0",
"nanostores": "^1.3.0", "nanostores": "^1.3.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.5", "react": "^19.2.5",
@@ -1977,6 +1980,48 @@
"@emnapi/runtime": "^1.7.1" "@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": { "node_modules/@oxc-project/types": {
"version": "0.127.0", "version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
@@ -5916,6 +5961,14 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT" "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": { "node_modules/@streamdown/code": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@streamdown/code/-/code-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@streamdown/code/-/code-1.1.1.tgz",
@@ -6285,6 +6338,17 @@
"node": ">= 20" "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": { "node_modules/@tailwindcss/vite": {
"version": "4.2.4", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz",
@@ -7097,6 +7161,22 @@
"d3-transition": "^3.0.1" "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": { "node_modules/@vitejs/plugin-react": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@@ -7723,6 +7803,14 @@
"node": ">=12" "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": { "node_modules/assistant-cloud": {
"version": "0.1.27", "version": "0.1.27",
"resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.27.tgz", "resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.27.tgz",
@@ -7816,6 +7904,14 @@
"node": ">= 4.0.0" "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": { "node_modules/available-typed-arrays": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -8448,6 +8544,11 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "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": "^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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -9276,6 +9388,14 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/defer-to-connect": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
@@ -9549,6 +9669,55 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/dompurify": {
"version": "3.4.2", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
@@ -9558,6 +9727,19 @@
"@types/trusted-types": "^2.0.7" "@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": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -10079,7 +10261,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@@ -10523,6 +10704,25 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT" "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": { "node_modules/extract-zip": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -10615,6 +10815,17 @@
"node": ">=16.0.0" "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": { "node_modules/filelist": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -10730,6 +10941,14 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -10747,6 +10966,32 @@
"node": ">= 6" "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": { "node_modules/fs-extra": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -10936,6 +11181,14 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -11464,6 +11717,35 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "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" "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": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -11953,6 +12246,17 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/is-potential-custom-element-name": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "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, "dev": true,
"license": "ISC" "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": { "node_modules/iterator.prototype": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -12200,7 +12512,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@@ -12445,6 +12756,44 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -12708,19 +13057,6 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -12771,7 +13107,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "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", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" "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": { "node_modules/mermaid": {
"version": "11.14.0", "version": "11.14.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
@@ -13868,6 +14217,18 @@
"node": ">= 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": { "node_modules/mkdirp": {
"version": "0.5.6", "version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
@@ -13894,6 +14255,44 @@
"ufo": "^1.6.3" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -14111,7 +14510,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -14375,6 +14773,11 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT" "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": { "node_modules/parse5": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -14556,6 +14959,18 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -14650,7 +15065,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@@ -14662,7 +15076,6 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/proper-lockfile": { "node_modules/proper-lockfile": {
@@ -14889,6 +15302,15 @@
"react-dom": ">= 16.14" "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": { "node_modules/react-dnd": {
"version": "14.0.5", "version": "14.0.5",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
@@ -14938,6 +15360,22 @@
"react": "^19.2.5" "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": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -15589,6 +16027,27 @@
"truncate-utf8-bytes": "^1.0.0" "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": { "node_modules/sax": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
@@ -15727,6 +16186,28 @@
"node": ">= 0.4" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -15953,6 +16434,29 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/sprintf-js": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@@ -16569,6 +17073,14 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/tw-shimmer": {
"version": "0.4.11", "version": "0.4.11",
"resolved": "https://registry.npmjs.org/tw-shimmer/-/tw-shimmer-0.4.11.tgz", "resolved": "https://registry.npmjs.org/tw-shimmer/-/tw-shimmer-0.4.11.tgz",
@@ -17012,6 +17524,11 @@
"dev": true, "dev": true,
"license": "(WTFPL OR MIT)" "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": { "node_modules/uuid": {
"version": "11.1.1", "version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
@@ -17025,6 +17542,11 @@
"uuid": "dist/esm/bin/uuid" "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": { "node_modules/verror": {
"version": "1.10.1", "version": "1.10.1",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
+2 -3
View File
@@ -31,7 +31,7 @@
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg", "test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
"test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh", "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", "type-check": "tsc -b",
"lint": "eslint src/ electron/", "lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix", "lint:fix": "eslint src/ electron/ --fix",
@@ -47,7 +47,7 @@
"@chenglou/pretext": "^0.0.6", "@chenglou/pretext": "^0.0.6",
"@hermes/shared": "file:../shared", "@hermes/shared": "file:../shared",
"@nanostores/react": "^1.1.0", "@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.12.0", "@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@streamdown/code": "^1.1.1", "@streamdown/code": "^1.1.1",
"@tabler/icons-react": "^3.41.1", "@tabler/icons-react": "^3.41.1",
@@ -59,7 +59,6 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"leva": "^0.10.1", "leva": "^0.10.1",
"liquid-glass-react": "^1.1.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"nanostores": "^1.3.0", "nanostores": "^1.3.0",
@@ -49,6 +49,7 @@ describe('collectArtifactsForSession', () => {
timestamp: 3000 timestamp: 3000
} }
] ]
const artifacts = collectArtifactsForSession(makeSession({ id: 'session-2' }), messages) const artifacts = collectArtifactsForSession(makeSession({ id: 'session-2' }), messages)
expect(artifacts).toHaveLength(1) expect(artifacts).toHaveLength(1)
+1 -4
View File
@@ -859,10 +859,7 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
return ( return (
<div className="group/location flex min-w-0 items-center gap-1.5"> <div className="group/location flex min-w-0 items-center gap-1.5">
<div <div
className={cn( className={cn('min-w-0 flex-1 truncate text-xs text-muted-foreground/85', isLink ? 'font-medium' : 'font-mono')}
'min-w-0 flex-1 truncate text-xs text-muted-foreground/85',
isLink ? 'font-medium' : 'font-mono'
)}
title={artifact.value} title={artifact.value}
> >
{value} {value}
+7 -145
View File
@@ -1,9 +1,6 @@
import './liquid-glass-overrides.css'
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react' import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import LiquidGlass from 'liquid-glass-react'
import { import {
type ClipboardEvent, type ClipboardEvent,
type FormEvent, type FormEvent,
@@ -20,7 +17,7 @@ import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer' import { useResizeObserver } from '@/hooks/use-resize-observer'
import { chatMessageText } from '@/lib/chat-messages' import { chatMessageText } from '@/lib/chat-messages'
import { contextPath } from '@/lib/chat-runtime' 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 { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { $composerAttachments, $composerDraft } from '@/store/composer' import { $composerAttachments, $composerDraft } from '@/store/composer'
@@ -45,117 +42,16 @@ import {
RICH_INPUT_SLOT RICH_INPUT_SLOT
} from './rich-editor' } from './rich-editor'
import { SkinSlashPopover } from './skin-slash-popover' import { SkinSlashPopover } from './skin-slash-popover'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover' import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types' import type { ChatBarProps } from './types'
import { UrlDialog } from './url-dialog' import { UrlDialog } from './url-dialog'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity' import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
const blobs: Blob[] = []
const seen = new Set<Blob>()
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(/<img\b[^>]*?\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_STACK_BREAKPOINT_PX = 320
const COMPOSER_GLASS = { const COMPOSER_FADE_BACKGROUND =
fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))', '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 }
}
export function ChatBar({ export function ChatBar({
busy, busy,
@@ -186,7 +82,6 @@ export function ChatBar({
const composerRef = useRef<HTMLFormElement | null>(null) const composerRef = useRef<HTMLFormElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null) const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<HTMLDivElement | null>(null) const editorRef = useRef<HTMLDivElement | null>(null)
const glassShellRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef(draft) const draftRef = useRef(draft)
const urlInputRef = useRef<HTMLInputElement | null>(null) const urlInputRef = useRef<HTMLInputElement | null>(null)
@@ -931,38 +826,9 @@ export function ChatBar({
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} /> <SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
<div <div
className="pointer-events-none absolute inset-0 rounded-[inherit]" className="pointer-events-none absolute inset-0 rounded-[inherit]"
style={{ background: COMPOSER_GLASS.fadeBackground }} style={{ background: COMPOSER_FADE_BACKGROUND }}
/> />
<div className="relative w-full rounded-[inherit]"> <div className="relative w-full rounded-[inherit]">
<div
className={cn(
'composer-liquid-shell-wrap absolute -inset-px isolate overflow-hidden rounded-[calc(var(--radius-2xl)+1px)] transition-opacity duration-200 ease-out',
scrolledUp
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
: 'opacity-100'
)}
data-glass-frame="true"
data-show-library-rims={COMPOSER_GLASS.showLibraryRims ? 'true' : undefined}
data-slot="composer-liquid-shell-wrap"
ref={glassShellRef}
>
<LiquidGlass
aberrationIntensity={COMPOSER_GLASS.liquid.aberrationIntensity}
blurAmount={COMPOSER_GLASS.liquid.blurAmount}
className="composer-liquid-shell pointer-events-none absolute inset-0 h-full w-full"
cornerRadius={COMPOSER_GLASS.liquid.cornerRadius}
displacementScale={COMPOSER_GLASS.liquid.displacementScale}
elasticity={COMPOSER_GLASS.liquid.elasticity}
key={COMPOSER_GLASS.liquidKey}
mode={COMPOSER_GLASS.liquid.mode}
mouseContainer={composerRef}
padding="0"
saturation={COMPOSER_GLASS.liquid.saturation}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
>
<span className="block h-full w-full" />
</LiquidGlass>
</div>
<div <div
className={cn( className={cn(
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer transition-[border-color,box-shadow] duration-200 ease-out', 'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
@@ -983,9 +849,7 @@ export function ChatBar({
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]', '[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out', 'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]', '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:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
'group-focus-within/composer:[backdrop-filter:none]',
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
)} )}
/> />
{dragActive && ( {dragActive && (
@@ -1057,9 +921,7 @@ export function ChatBarFallback() {
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]', '[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out', 'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]', '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:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
'group-focus-within/composer:[backdrop-filter:none]',
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
)} )}
/> />
</div> </div>
@@ -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;
}
@@ -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<Blob>()
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(/<img\b[^>]*?\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 }
}
+59 -32
View File
@@ -2,8 +2,7 @@ import {
type AppendMessage, type AppendMessage,
AssistantRuntimeProvider, AssistantRuntimeProvider,
ExportedMessageRepository, ExportedMessageRepository,
type ThreadMessage, type ThreadMessage
useExternalStoreRuntime
} from '@assistant-ui/react' } from '@assistant-ui/react'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
@@ -18,6 +17,7 @@ import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import type { ChatMessage } from '@/lib/chat-messages' import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime' import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
import { ChevronDown } from '@/lib/icons' import { ChevronDown } from '@/lib/icons'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { $pinnedSessionIds } from '@/store/layout' import { $pinnedSessionIds } from '@/store/layout'
import { import {
@@ -70,6 +70,55 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onTranscribeAudio?: (audio: Blob) => Promise<string> onTranscribeAudio?: (audio: Blob) => Promise<string>
} }
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 (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
{title && (
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
pinned={selectedIsPinned}
sessionId={selectedSessionId || activeSessionId || ''}
sideOffset={8}
title={title}
>
<Button
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
</Button>
</SessionActionsMenu>
)}
</div>
</header>
)
}
export function ChatView({ export function ChatView({
className, className,
gateway, gateway,
@@ -107,13 +156,9 @@ export function ChatView({
const introPersonality = useStore($introPersonality) const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed) const introSeed = useStore($introSeed)
const messages = useStore($messages) const messages = useStore($messages)
const pinnedSessionIds = useStore($pinnedSessionIds)
const selectedSessionId = useStore($selectedStoredSessionId) const selectedSessionId = useStore($selectedStoredSessionId)
const sessions = useStore($sessions)
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>()) const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const isRoutedSessionView = Boolean(routeSessionId(location.pathname)) const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
const showIntro = const showIntro =
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0 freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
@@ -127,7 +172,6 @@ export function ChatView({
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages)) const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages))
const showChatBar = !loadingSession const showChatBar = !loadingSession
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
const modelOptionsQuery = useQuery<ModelOptionsResponse>({ const modelOptionsQuery = useQuery<ModelOptionsResponse>({
queryKey: ['model-options', activeSessionId || 'global'], queryKey: ['model-options', activeSessionId || 'global'],
@@ -207,7 +251,7 @@ export function ChatView({
return ExportedMessageRepository.fromBranchableArray(items, { headId }) return ExportedMessageRepository.fromBranchableArray(items, { headId })
}, [messages]) }, [messages])
const runtime = useExternalStoreRuntime<ThreadMessage>({ const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: runtimeMessageRepository, messageRepository: runtimeMessageRepository,
isRunning: busy, isRunning: busy,
setMessages: onThreadMessagesChange, setMessages: onThreadMessagesChange,
@@ -227,30 +271,13 @@ export function ChatView({
className className
)} )}
> >
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}> <ChatHeader
<div className="min-w-0 flex-1"> activeSessionId={activeSessionId}
{title && ( isRoutedSessionView={isRoutedSessionView}
<SessionActionsMenu onDeleteSelectedSession={onDeleteSelectedSession}
align="start" onToggleSelectedPin={onToggleSelectedPin}
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined} selectedSessionId={selectedSessionId}
onPin={selectedSessionId ? onToggleSelectedPin : undefined} />
pinned={selectedIsPinned}
sessionId={selectedSessionId || activeSessionId || ''}
sideOffset={8}
title={title}
>
<Button
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
</Button>
</SessionActionsMenu>
)}
</div>
</header>
<NotificationStack /> <NotificationStack />
@@ -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<number, string> = {
0: 'log',
1: 'info',
2: 'warn',
3: 'error'
}
const consoleLevelClass: Record<number, string> = {
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 (
<div
className={cn(
'group/row grid grid-cols-[3.25rem_minmax(0,1fr)_auto] items-start gap-2 rounded-md border border-transparent px-1 py-1 transition-colors hover:bg-accent/40',
selected && 'border-border/60 bg-accent/40'
)}
>
<button
className={cn(
'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100',
consoleLevelClass[log.level] ?? consoleLevelClass[0]
)}
onClick={onToggleSelect}
title={selected ? 'Deselect entry' : 'Select entry'}
type="button"
>
{consoleLevelLabel[log.level] || 'log'}
</button>
<div className="min-w-0" data-selectable-text="true">
<span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}>
{log.message}
</span>
{log.source && (
<span className="block truncate text-muted-foreground/60">
{compactUrl(log.source)}
{log.line ? `:${log.line}` : ''}
</span>
)}
</div>
<span className="opacity-0 transition-opacity group-hover/row:opacity-100">
<CopyButton
appearance="inline"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
errorMessage="Could not copy console output"
iconClassName="size-3"
label="Copy this entry"
showLabel={false}
text={copyText}
/>
<button
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onSend}
title="Send this entry to chat"
type="button"
>
<Send className="size-3" />
</button>
</span>
</div>
)
}
export function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) {
const logCount = useStore(consoleState.$logCount)
return (
<>
<PanelBottom />
{logCount > 0 && <span className="sr-only">{logCount} console messages</span>}
</>
)
}
interface PreviewConsolePanelProps {
consoleBodyRef: RefObject<HTMLDivElement | null>
consoleShouldStickRef: MutableRefObject<boolean>
consoleState: PreviewConsoleState
startConsoleResize: (event: ReactPointerEvent<HTMLDivElement>) => 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<number | null>(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 (
<div
className="pointer-events-auto absolute inset-x-0 bottom-0 z-20 flex h-(--preview-console-height) min-h-8 flex-col overflow-hidden border-t border-border/60 bg-background"
style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties}
>
<div
aria-label="Resize preview console"
className="group absolute inset-x-0 -top-1 z-1 h-2 cursor-row-resize"
onDoubleClick={() => consoleState.setHeight(CONSOLE_HEADER_HEIGHT)}
onPointerDown={startConsoleResize}
role="separator"
>
<span className="absolute left-1/2 top-1/2 h-0.75 w-23 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.5]" />
</div>
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/50 px-2">
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
<PanelBottom className="size-3.5" />
Preview Console
{selectedLogIds.size > 0 && (
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
{selectedLogIds.size} selected
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={() => sendLogsToComposer(sendableLogs)}
title={
visibleSelection.length > 0
? `Send ${visibleSelection.length} selected to chat`
: 'Send all log entries to chat'
}
type="button"
>
<Send className="size-3" />
Send to chat
</button>
<CopyButton
appearance="inline"
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
errorMessage="Could not copy console output"
iconClassName="size-3"
label={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
text={() => formatConsoleEntries(sendableLogs)}
>
Copy
</CopyButton>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={logs.length === 0}
onClick={consoleState.clear}
title="Clear console"
type="button"
>
<Trash2 className="size-3" />
Clear
</button>
</div>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed"
ref={consoleBodyRef}
>
{logs.length > 0 ? (
logs.map(log => {
const selected = selectedLogIds.has(log.id)
return (
<ConsoleRow
copyText={formatLogLine(log)}
key={log.id}
log={log}
onSend={() => sendLogsToComposer([log])}
onToggleSelect={() => consoleState.toggleSelection(log.id)}
selected={selected}
/>
)
})
) : (
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
)}
</div>
</div>
)
}
@@ -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<EmptyStateTone, { cube: string; primary: string }> = {
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 (
<svg aria-hidden="true" className={cn('size-16', className)} viewBox="0 0 64 64">
<path
d="M32 5 56 18.5v27L32 59 8 45.5v-27L32 5Z"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path
d="M8 18.5 32 32l24-13.5M32 32v27"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path d="M20 11.75 44 25.25" fill="none" opacity="0.45" stroke="currentColor" strokeWidth="0.9" />
</svg>
)
}
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 (
<div
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-8 py-10 text-center bottom-(--preview-error-bottom)"
style={{ '--preview-error-bottom': `${consoleHeight}px` } as CSSProperties}
>
<div className="grid max-w-sm justify-items-center gap-5">
<PreviewCubeIcon className={styles.cube} />
<div className="grid gap-2">
<div className="text-sm font-medium text-foreground">{title}</div>
{body && <div className="text-xs leading-relaxed text-muted-foreground">{body}</div>}
</div>
{(primaryAction || secondaryAction) && (
<div className="grid justify-items-center gap-2">
{primaryAction && (
<button
className={cn(
'rounded-full border px-3.5 py-1.5 text-xs font-medium shadow-xs transition-colors disabled:cursor-default disabled:opacity-60',
styles.primary
)}
disabled={primaryAction.disabled}
onClick={primaryAction.onClick}
type="button"
>
{primaryAction.label}
</button>
)}
{secondaryAction && (
<button
className="text-[0.6875rem] font-medium text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55 disabled:cursor-default disabled:text-muted-foreground/55 disabled:no-underline"
disabled={secondaryAction.disabled}
onClick={secondaryAction.onClick}
type="button"
>
{secondaryAction.label}
</button>
)}
</div>
)}
</div>
</div>
)
}
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<T extends keyof typeof MD_TAG_CLASSES>(Tag: T) {
const base = MD_TAG_CLASSES[Tag]
const Component = (({ className, ...rest }: ComponentProps<T>) => {
const Element = Tag as React.ElementType
return <Element className={cn(base, className)} {...rest} />
}) as React.FC<ComponentProps<T>>
Component.displayName = `Md.${Tag}`
return Component
}
function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) {
const language = /language-([^\s]+)/.exec(className || '')?.[1]
if (!language) {
return (
<code
className={cn(
'rounded bg-muted px-1 py-0.5 font-mono text-[0.86em] text-pink-700 dark:text-pink-300',
className
)}
{...props}
>
{children}
</code>
)
}
return (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language}
showLanguage={false}
theme={SHIKI_THEME}
>
{String(children).replace(/\n$/, '')}
</ShikiHighlighter>
)
}
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 (
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
{text}
</Streamdown>
</div>
)
}
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
return (
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
onClick={onToggle}
type="button"
>
{asSource ? 'PREVIEW' : 'SOURCE'}
</button>
</div>
)
}
// 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<HTMLElement>, 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<LineSelection | null>(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<HTMLElement>, line: number) => {
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
}
return (
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
<div className="select-none py-3 text-right text-muted-foreground/55">
{Array.from({ length: lineCount }, (_, index) => {
const line = index + 1
const selected = inSelection(line)
return (
<div
className={cn(
'cursor-pointer px-3 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title="Click to select · shift-click to extend · drag to composer"
>
{line}
</div>
)
})}
</div>
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3">
{selection && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
style={{
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
}}
/>
)}
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{text}
</ShikiHighlighter>
</div>
</div>
)
}
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
const [state, setState] = useState<LocalPreviewState>({ 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 <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview</div>
}
if (state.error) {
return <PreviewEmptyState body={state.error} title="Preview unavailable" />
}
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 (
<PreviewEmptyState
body={
binary
? `Previewing ${target.label} may show unreadable text.`
: `${target.label} is ${formatBytes(size)}. Hermes will only show the first 512 KB.`
}
primaryAction={{ label: 'Preview anyway', onClick: () => setForcePreview(true) }}
title={binary ? 'This looks like a binary file' : 'This file is large'}
tone="warning"
/>
)
}
if (isImage && state.dataUrl) {
return (
<div className="flex h-full w-full items-center justify-center overflow-auto bg-[color-mix(in_srgb,var(--dt-card)_42%,transparent)] p-4">
<img
alt={target.label}
className="max-h-full max-w-full rounded-lg object-contain shadow-sm"
draggable={false}
src={state.dataUrl}
/>
</div>
)
}
if (isText && state.text !== undefined) {
const isMarkdown = (state.language || target.language) === 'markdown'
const showRendered = isMarkdown && !renderMarkdownAsSource
return (
<div className="h-full overflow-auto bg-background">
{state.truncated && (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
Showing first 512 KB.
</div>
)}
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
{showRendered ? (
<MarkdownPreview text={state.text} />
) : (
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
)}
</div>
)
}
return (
<PreviewEmptyState
body={`${target.mimeType || 'This file type'} can still be attached as context.`}
title="No inline preview"
/>
)
}
@@ -1,30 +1,23 @@
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import type { import type { PointerEvent as ReactPointerEvent } from 'react'
ComponentProps, import { useCallback, useEffect, useRef, useState } from 'react'
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 { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls' import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { CopyButton } from '@/components/ui/copy-button' import { Bug, RefreshCw, X } from '@/lib/icons'
import { Bug, PanelBottom, RefreshCw, Send, Trash2, X } from '@/lib/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { $composerDraft, setComposerDraft } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications' import { notify, notifyError } from '@/store/notifications'
import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview' import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview'
import { type ConsoleEntry, createPreviewConsoleState, type PreviewConsoleState } from './preview-console-state' import {
clampConsoleHeight,
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const 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 & { type PreviewWebview = HTMLElement & {
closeDevTools?: () => void closeDevTools?: () => void
@@ -50,62 +43,8 @@ interface PreviewLoadErrorState {
url: string url: string
} }
const consoleLevelLabel: Record<number, string> = {
0: 'log',
1: 'info',
2: 'warn',
3: 'error'
}
const consoleLevelClass: Record<number, string> = {
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 FILE_RELOAD_DEBOUNCE_MS = 200
const SERVER_RESTART_TIMEOUT_MS = 45_000 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 { function loadErrorTitle(error: PreviewLoadErrorState): string {
const description = error.description.toLowerCase() 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') 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 (
<div
className={cn(
'group/row grid grid-cols-[3.25rem_minmax(0,1fr)_auto] items-start gap-2 rounded-md border border-transparent px-1 py-1 transition-colors hover:bg-accent/40',
selected && 'border-border/60 bg-accent/40'
)}
>
<button
className={cn(
'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100',
consoleLevelClass[log.level] ?? consoleLevelClass[0]
)}
onClick={onToggleSelect}
title={selected ? 'Deselect entry' : 'Select entry'}
type="button"
>
{consoleLevelLabel[log.level] || 'log'}
</button>
<div className="min-w-0" data-selectable-text="true">
<span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}>
{log.message}
</span>
{log.source && (
<span className="block truncate text-muted-foreground/60">
{compactUrl(log.source)}
{log.line ? `:${log.line}` : ''}
</span>
)}
</div>
<span className="opacity-0 transition-opacity group-hover/row:opacity-100">
<CopyButton
appearance="inline"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
errorMessage="Could not copy console output"
iconClassName="size-3"
label="Copy this entry"
showLabel={false}
text={copyText}
/>
<button
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onSend}
title="Send this entry to chat"
type="button"
>
<Send className="size-3" />
</button>
</span>
</div>
)
}
function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) {
const logCount = useStore(consoleState.$logCount)
return (
<>
<PanelBottom />
{logCount > 0 && <span className="sr-only">{logCount} console messages</span>}
</>
)
}
type EmptyStateTone = 'neutral' | 'warning'
const TONE_STYLES: Record<EmptyStateTone, { cube: string; primary: string }> = {
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 (
<svg aria-hidden="true" className={cn('size-16', className)} viewBox="0 0 64 64">
<path
d="M32 5 56 18.5v27L32 59 8 45.5v-27L32 5Z"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path
d="M8 18.5 32 32l24-13.5M32 32v27"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path d="M20 11.75 44 25.25" fill="none" opacity="0.45" stroke="currentColor" strokeWidth="0.9" />
</svg>
)
}
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 (
<div
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-8 py-10 text-center bottom-(--preview-error-bottom)"
style={{ '--preview-error-bottom': `${consoleHeight}px` } as CSSProperties}
>
<div className="grid max-w-sm justify-items-center gap-5">
<PreviewCubeIcon className={styles.cube} />
<div className="grid gap-2">
<div className="text-sm font-medium text-foreground">{title}</div>
{body && <div className="text-xs leading-relaxed text-muted-foreground">{body}</div>}
</div>
{(primaryAction || secondaryAction) && (
<div className="grid justify-items-center gap-2">
{primaryAction && (
<button
className={cn(
'rounded-full border px-3.5 py-1.5 text-xs font-medium shadow-xs transition-colors disabled:cursor-default disabled:opacity-60',
styles.primary
)}
disabled={primaryAction.disabled}
onClick={primaryAction.onClick}
type="button"
>
{primaryAction.label}
</button>
)}
{secondaryAction && (
<button
className="text-[0.6875rem] font-medium text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55 disabled:cursor-default disabled:text-muted-foreground/55 disabled:no-underline"
disabled={secondaryAction.disabled}
onClick={secondaryAction.onClick}
type="button"
>
{secondaryAction.label}
</button>
)}
</div>
)}
</div>
</div>
)
}
function PreviewLoadError({ function PreviewLoadError({
consoleHeight = 0, consoleHeight = 0,
error, error,
@@ -344,592 +113,6 @@ function PreviewLoadError({
) )
} }
function PreviewConsolePanel({
consoleBodyRef,
consoleShouldStickRef,
consoleState,
startConsoleResize
}: {
consoleBodyRef: RefObject<HTMLDivElement | null>
consoleShouldStickRef: MutableRefObject<boolean>
consoleState: PreviewConsoleState
startConsoleResize: (event: ReactPointerEvent<HTMLDivElement>) => 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<number | null>(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 (
<div
className="pointer-events-auto absolute inset-x-0 bottom-0 z-20 flex h-(--preview-console-height) min-h-8 flex-col overflow-hidden border-t border-border/60 bg-background"
style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties}
>
<div
aria-label="Resize preview console"
className="group absolute inset-x-0 -top-1 z-1 h-2 cursor-row-resize"
onDoubleClick={() => consoleState.setHeight(CONSOLE_HEADER_HEIGHT)}
onPointerDown={startConsoleResize}
role="separator"
>
<span className="absolute left-1/2 top-1/2 h-0.75 w-23 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.5]" />
</div>
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/50 px-2">
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
<PanelBottom className="size-3.5" />
Preview Console
{selectedLogIds.size > 0 && (
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
{selectedLogIds.size} selected
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={() => sendLogsToComposer(sendableLogs)}
title={
visibleSelection.length > 0
? `Send ${visibleSelection.length} selected to chat`
: 'Send all log entries to chat'
}
type="button"
>
<Send className="size-3" />
Send to chat
</button>
<CopyButton
appearance="inline"
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
errorMessage="Could not copy console output"
iconClassName="size-3"
label={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
text={() => formatConsoleEntries(sendableLogs)}
>
Copy
</CopyButton>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={logs.length === 0}
onClick={consoleState.clear}
title="Clear console"
type="button"
>
<Trash2 className="size-3" />
Clear
</button>
</div>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed"
ref={consoleBodyRef}
>
{logs.length > 0 ? (
logs.map(log => {
const selected = selectedLogIds.has(log.id)
return (
<ConsoleRow
copyText={formatLogLine(log)}
key={log.id}
log={log}
onSend={() => sendLogsToComposer([log])}
onToggleSelect={() => consoleState.toggleSelection(log.id)}
selected={selected}
/>
)
})
) : (
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
)}
</div>
</div>
)
}
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<T extends keyof typeof MD_TAG_CLASSES>(Tag: T) {
const base = MD_TAG_CLASSES[Tag]
const Component = (({ className, ...rest }: ComponentProps<T>) => {
const Element = Tag as React.ElementType
return <Element className={cn(base, className)} {...rest} />
}) as React.FC<ComponentProps<T>>
Component.displayName = `Md.${Tag}`
return Component
}
function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) {
const language = /language-([^\s]+)/.exec(className || '')?.[1]
if (!language) {
return (
<code
className={cn(
'rounded bg-muted px-1 py-0.5 font-mono text-[0.86em] text-pink-700 dark:text-pink-300',
className
)}
{...props}
>
{children}
</code>
)
}
return (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language}
showLanguage={false}
theme={SHIKI_THEME}
>
{String(children).replace(/\n$/, '')}
</ShikiHighlighter>
)
}
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 (
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
{text}
</Streamdown>
</div>
)
}
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
return (
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
onClick={onToggle}
type="button"
>
{asSource ? 'PREVIEW' : 'SOURCE'}
</button>
</div>
)
}
// 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<HTMLElement>, 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<LineSelection | null>(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<HTMLElement>, line: number) => {
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
}
return (
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
<div className="select-none py-3 text-right text-muted-foreground/55">
{Array.from({ length: lineCount }, (_, index) => {
const line = index + 1
const selected = inSelection(line)
return (
<div
className={cn(
'cursor-pointer px-3 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title="Click to select · shift-click to extend · drag to composer"
>
{line}
</div>
)
})}
</div>
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3">
{selection && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
style={{
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
}}
/>
)}
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{text}
</ShikiHighlighter>
</div>
</div>
)
}
function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
const [state, setState] = useState<LocalPreviewState>({ 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 <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview</div>
}
if (state.error) {
return <PreviewEmptyState body={state.error} title="Preview unavailable" />
}
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 (
<PreviewEmptyState
body={
binary
? `Previewing ${target.label} may show unreadable text.`
: `${target.label} is ${formatBytes(size)}. Hermes will only show the first 512 KB.`
}
primaryAction={{ label: 'Preview anyway', onClick: () => setForcePreview(true) }}
title={binary ? 'This looks like a binary file' : 'This file is large'}
tone="warning"
/>
)
}
if (isImage && state.dataUrl) {
return (
<div className="flex h-full w-full items-center justify-center overflow-auto bg-[color-mix(in_srgb,var(--dt-card)_42%,transparent)] p-4">
<img
alt={target.label}
className="max-h-full max-w-full rounded-lg object-contain shadow-sm"
draggable={false}
src={state.dataUrl}
/>
</div>
)
}
if (isText && state.text !== undefined) {
const isMarkdown = (state.language || target.language) === 'markdown'
const showRendered = isMarkdown && !renderMarkdownAsSource
return (
<div className="h-full overflow-auto bg-background">
{state.truncated && (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
Showing first 512 KB.
</div>
)}
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
{showRendered ? (
<MarkdownPreview text={state.text} />
) : (
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
)}
</div>
)
}
return (
<PreviewEmptyState
body={`${target.mimeType || 'This file type'} can still be attached as context.`}
title="No inline preview"
/>
)
}
const TITLEBAR_GROUP_ID = 'preview' const TITLEBAR_GROUP_ID = 'preview'
export function PreviewPane({ export function PreviewPane({
+3 -1
View File
@@ -1,5 +1,7 @@
import type { ChatMessage } from '@/lib/chat-messages' import type { ChatMessage } from '@/lib/chat-messages'
export type ThreadLoadingState = 'response' | 'session'
export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean { export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean {
const lastVisible = [...messages].reverse().find(message => !message.hidden) const lastVisible = [...messages].reverse().find(message => !message.hidden)
@@ -11,7 +13,7 @@ export function threadLoadingState(
busy: boolean, busy: boolean,
awaitingResponse: boolean, awaitingResponse: boolean,
lastVisibleIsUser: boolean lastVisibleIsUser: boolean
) { ): ThreadLoadingState | undefined {
if (loadingSession) { if (loadingSession) {
return 'session' return 'session'
} }
@@ -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-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-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-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' } { id: 'nav-artifacts', route: ARTIFACTS_ROUTE, title: 'Artifacts', detail: 'Browse generated outputs' }
] ]
@@ -10,6 +10,7 @@ import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session' import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label' import { SidebarPanelLabel } from '../shell/sidebar-label'
import { ProjectTree } from './tree' import { ProjectTree } from './tree'
import { useProjectTree } from './use-project-tree' import { useProjectTree } from './use-project-tree'
@@ -22,8 +22,11 @@ export function useRouteEnumParam<T extends string>(
(next: T) => { (next: T) => {
const params = new URLSearchParams(search) const params = new URLSearchParams(search)
if (next === fallback) {params.delete(key)} if (next === fallback) {
else {params.set(key, next)} params.delete(key)
} else {
params.set(key, next)
}
const qs = params.toString() const qs = params.toString()
navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true }) navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true })
+25 -20
View File
@@ -78,11 +78,17 @@ const HINT_BY_STATE: Record<string, string> = {
const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown') const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown')
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone { 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' return 'warn'
} }
@@ -511,9 +517,7 @@ function PlatformDetail({
<section> <section>
<SectionTitle>Get your credentials</SectionTitle> <SectionTitle>Get your credentials</SectionTitle>
<p className="mt-1 text-sm leading-6 text-muted-foreground"> <p className="mt-1 text-sm leading-6 text-muted-foreground">{introCopy(platform)}</p>
{introCopy(platform)}
</p>
<div className="mt-3"> <div className="mt-3">
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="outline">
<a href={platform.docs_url} rel="noreferrer" target="_blank"> <a href={platform.docs_url} rel="noreferrer" target="_blank">
@@ -572,9 +576,7 @@ function PlatformDetail({
type="button" type="button"
> >
<span>Advanced ({hiddenCount})</span> <span>Advanced ({hiddenCount})</span>
<ChevronDown <ChevronDown className={cn('size-3.5 transition-transform', !showAdvanced && '-rotate-90')} />
className={cn('size-3.5 transition-transform', !showAdvanced && '-rotate-90')}
/>
</button> </button>
{showAdvanced && ( {showAdvanced && (
<div className="mt-3 space-y-4"> <div className="mt-3 space-y-4">
@@ -632,7 +634,8 @@ const PLATFORM_INTRO: Record<string, string> = {
mattermost: mattermost:
'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.', '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.', 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: whatsapp:
'Start the WhatsApp bridge that ships with Hermes, scan the QR code on first run, then enable the platform.', 'Start the WhatsApp bridge that ships with Hermes, scan the QR code on first run, then enable the platform.',
bluebubbles: bluebubbles:
@@ -642,8 +645,7 @@ const PLATFORM_INTRO: Record<string, string> = {
email: email:
'Use a dedicated mailbox. For Gmail/Workspace, create an app password and use imap.gmail.com / smtp.gmail.com.', '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.', sms: 'Get your Twilio Account SID and Auth Token from the Twilio console, plus a phone number that can send SMS.',
dingtalk: dingtalk: 'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.',
'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.',
feishu: feishu:
'Create a Feishu / Lark app, configure the bot capability, and copy the App ID, App secret, and event encryption keys.', 'Create a Feishu / Lark app, configure the bot capability, and copy the App ID, App secret, and event encryption keys.',
wecom: wecom:
@@ -655,7 +657,8 @@ const PLATFORM_INTRO: Record<string, string> = {
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.', qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
api_server: api_server:
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.', '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 const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description
@@ -717,16 +720,15 @@ function MessagingField({
} }
function SectionTitle({ children }: { children: React.ReactNode }) { function SectionTitle({ children }: { children: React.ReactNode }) {
return ( return <h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{children}</h4>
<h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{children}</h4>
)
} }
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) { function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
if (!platform.enabled || platform.state === 'connected') {return null} if (!platform.enabled || platform.state === 'connected') {
return null
}
const hint = const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null
} }
@@ -748,7 +750,10 @@ function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
function SetupPill({ active, children }: { active: boolean; children: string }) { function SetupPill({ active, children }: { active: boolean; children: string }) {
return ( return (
<span <span
className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium', PILL_TONE[active ? 'good' : 'muted'])} className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
PILL_TONE[active ? 'good' : 'muted']
)}
> >
{children} {children}
</span> </span>
@@ -65,7 +65,9 @@ const STREAM_DELTA_FLUSH_MS = 16
// Anonymous progress events that carry todos but no name still belong to the // Anonymous progress events that carry todos but no name still belong to the
// todo stream; named todo events are obviously routed there too. // todo stream; named todo events are obviously routed there too.
function toTodoPayload(payload: GatewayEventPayload | undefined): GatewayEventPayload | undefined { 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')) const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos'))
return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined 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 })) setCurrentUsage(current => ({ ...current, ...payload.usage }))
} }
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') { } else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
if (!sessionId) {return} if (!sessionId) {
return
}
flushQueuedDeltas(sessionId) flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running') upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running')
} else if (event.type === 'tool.complete') { } else if (event.type === 'tool.complete') {
@@ -46,19 +46,12 @@ function blobToDataUrl(blob: Blob): Promise<string> {
}) })
} }
interface SetupStatus {
provider_configured?: boolean
}
interface RuntimeCheck {
error?: string
ok?: boolean
}
function isProviderSetupError(error: unknown) { function isProviderSetupError(error: unknown) {
const message = error instanceof Error ? error.message : String(error) 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 { interface PromptActionsOptions {
@@ -197,25 +190,24 @@ export function usePromptActions({
async (rawText: string) => { async (rawText: string) => {
const visibleText = rawText.trim() const visibleText = rawText.trim()
const attachments = $composerAttachments.get() const attachments = $composerAttachments.get()
const contextRefs = attachments const contextRefs = attachments
.map(attachment => attachment.refText) .map(a => a.refText)
.filter(Boolean) .filter(Boolean)
.join('\n') .join('\n')
const hasImage = attachments.some(a => a.kind === 'image')
const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image') const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
const attachmentRefs = attachments.map(attachmentDisplayText).filter((ref): ref is string => Boolean(ref))
const text = const text =
[contextRefs, visibleText].filter(Boolean).join('\n\n') || [contextRefs, visibleText].filter(Boolean).join('\n\n') || (hasImage ? 'What do you see in this image?' : '')
(hasImageAttachment ? 'What do you see in this image?' : '')
if (!text || busyRef.current) { if (!text || busyRef.current) {
return return
} }
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const userMessage: ChatMessage = { const userMessage: ChatMessage = {
id: `user-${Date.now()}`, id: optimisticId,
role: 'user', role: 'user',
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))], parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
attachmentRefs attachmentRefs
@@ -227,61 +219,80 @@ export function usePromptActions({
setAwaitingResponse(false) 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 busyRef.current = true
setBusy(true) setBusy(true)
setAwaitingResponse(true) setAwaitingResponse(true)
clearNotifications() clearNotifications()
const [setup, runtime] = await Promise.all([ let sessionId: null | string = activeSessionId
requestGateway<SetupStatus>('setup.status').catch(() => null),
requestGateway<RuntimeCheck>('setup.runtime_check').catch(() => null)
])
const runtimeReady = runtime?.ok !== undefined ? Boolean(runtime?.ok) : setup?.provider_configured !== false if (sessionId) {
seedOptimistic(sessionId)
if (!runtimeReady) { } else {
releaseBusy() setMessages(current => [...current, userMessage])
requestDesktopOnboarding(
runtime?.error || 'Add a provider credential before sending your first message.'
)
return
} }
let sessionId = activeSessionId
if (!sessionId) { if (!sessionId) {
try { try {
sessionId = await createBackendSessionForSend() sessionId = await createBackendSessionForSend()
} catch (err) { } catch (err) {
dropOptimistic(null)
releaseBusy() releaseBusy()
notifyError(err, 'Session unavailable') notifyError(err, 'Session unavailable')
return 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 { try {
await syncImageAttachmentsForSubmit(sessionId, attachments) await syncImageAttachmentsForSubmit(sessionId, attachments)
await requestGateway('prompt.submit', { session_id: sessionId, text }) await requestGateway('prompt.submit', { session_id: sessionId, text })
@@ -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<null | string>
creatingSessionRef: MutableRefObject<boolean>
currentView: string
freshDraftReady: boolean
gatewayState: string
locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
routedSessionId: null | string
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: null | string
selectedStoredSessionIdRef: MutableRefObject<null | string>
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<null | string> = { current: 'runtime-1' }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
// Simulate startFreshSessionDraft state updates landing before route update.
activeSessionIdRef.current = null
selectedStoredSessionIdRef.current = null
rerender(
<RouteResumeHarness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId={null}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
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<null | string> = { current: null }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map() }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: null }
const { rerender } = render(
<RouteResumeHarness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady
gatewayState="open"
locationPathname="/"
resumeSession={resumeSession}
routedSessionId={null}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId={null}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
rerender(
<RouteResumeHarness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady
gatewayState="open"
locationPathname="/session-2"
resumeSession={resumeSession}
routedSessionId="session-2"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId={null}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-2', true)
})
})
@@ -1,4 +1,4 @@
import { type MutableRefObject, useEffect } from 'react' import { type MutableRefObject, useEffect, useRef } from 'react'
import { isNewChatRoute } from '@/app/routes' import { isNewChatRoute } from '@/app/routes'
@@ -55,8 +55,17 @@ export function useRouteResume({
selectedStoredSessionIdRef, selectedStoredSessionIdRef,
startFreshSessionDraft startFreshSessionDraft
}: RouteResumeOptions) { }: RouteResumeOptions) {
const lastPathnameRef = useRef<string | null>(null)
const wasGatewayOpenRef = useRef(false)
useEffect(() => { 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 return
} }
@@ -68,7 +77,12 @@ export function useRouteResume({
Boolean(cachedRuntime) && Boolean(cachedRuntime) &&
cachedRuntime === activeSessionIdRef.current 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) void resumeSession(routedSessionId, true)
} }
@@ -46,7 +46,9 @@ function ModeCard({
<button <button
className={cn( className={cn(
'rounded-2xl border p-4 text-left transition', 'rounded-2xl border p-4 text-left transition',
active ? 'border-primary bg-primary/10 ring-2 ring-primary/15' : 'border-border bg-background/60 hover:bg-muted/40', active
? 'border-primary bg-primary/10 ring-2 ring-primary/15'
: 'border-border bg-background/60 hover:bg-muted/40',
disabled && 'cursor-not-allowed opacity-50' disabled && 'cursor-not-allowed opacity-50'
)} )}
disabled={disabled} disabled={disabled}
@@ -179,7 +181,12 @@ export function GatewaySettings() {
} }
if (!window.hermesDesktop?.getConnectionConfig) { if (!window.hermesDesktop?.getConnectionConfig) {
return <EmptyState description="The desktop IPC bridge does not expose gateway settings." title="Gateway settings unavailable" /> return (
<EmptyState
description="The desktop IPC bridge does not expose gateway settings."
title="Gateway settings unavailable"
/>
)
} }
return ( return (
@@ -191,8 +198,8 @@ export function GatewaySettings() {
{state.envOverride ? <Pill tone="primary">env override</Pill> : null} {state.envOverride ? <Pill tone="primary">env override</Pill> : null}
</div> </div>
<p className="mt-2 max-w-2xl text-xs leading-5 text-muted-foreground"> <p className="mt-2 max-w-2xl text-xs leading-5 text-muted-foreground">
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
control an already-running Hermes backend on another machine or behind a trusted proxy. an already-running Hermes backend on another machine or behind a trusted proxy.
</p> </p>
</div> </div>
@@ -249,7 +256,9 @@ export function GatewaySettings() {
className={cn('h-8 font-mono', CONTROL_TEXT)} className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride} disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)} 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" type="password"
value={remoteToken} value={remoteToken}
/> />
@@ -262,7 +271,11 @@ export function GatewaySettings() {
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null} {lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
<div className="mt-6 flex flex-wrap justify-end gap-3"> <div className="mt-6 flex flex-wrap justify-end gap-3">
<Button disabled={state.envOverride || testing || !canUseRemote} onClick={() => void testRemote()} variant="outline"> <Button
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
variant="outline"
>
{testing ? <Loader2 className="size-4 animate-spin" /> : null} {testing ? <Loader2 className="size-4 animate-spin" /> : null}
Test remote Test remote
</Button> </Button>
+2 -1
View File
@@ -43,11 +43,12 @@ function safeSet(target: Record<string, unknown>, key: string, value: unknown):
if (key === '__proto__' || key === 'constructor' || key === 'prototype' || !key) { if (key === '__proto__' || key === 'constructor' || key === 'prototype' || !key) {
throw new Error(`Unsafe config key: ${key}`) throw new Error(`Unsafe config key: ${key}`)
} }
Object.defineProperty(target, key, { Object.defineProperty(target, key, {
value, value,
writable: true, writable: true,
enumerable: true, enumerable: true,
configurable: true, configurable: true
}) })
} }
+1
View File
@@ -60,6 +60,7 @@ export function AppShell({
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen) const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
const titlebarContentInset = sidebarOpen const titlebarContentInset = sidebarOpen
? 0 ? 0
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2) : titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
@@ -123,8 +123,9 @@ export function useStatusbarItems({
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
const behind = updateStatus?.behind ?? 0 const behind = updateStatus?.behind ?? 0
const applying = updateApply.applying || updateApply.stage === 'restart' 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 behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
const label = applying const label = applying
? updateApply.stage === 'restart' ? updateApply.stage === 'restart'
? `${base} · restart` ? `${base} · restart`
@@ -110,37 +110,37 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
: (item.menuItems ?? []) : (item.menuItems ?? [])
.filter(menuItem => !menuItem.hidden) .filter(menuItem => !menuItem.hidden)
.map(menuItem => ( .map(menuItem => (
<DropdownMenuItem <DropdownMenuItem
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)} className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
disabled={menuItem.disabled} disabled={menuItem.disabled}
key={menuItem.id} key={menuItem.id}
onSelect={() => { onSelect={() => {
if (menuItem.to) { if (menuItem.to) {
navigate(menuItem.to) navigate(menuItem.to)
} }
menuItem.onSelect?.() menuItem.onSelect?.()
}} }}
>
{menuItem.href ? (
<a
className="inline-flex w-full items-center gap-2"
href={menuItem.href}
rel="noreferrer"
target="_blank"
title={menuItem.title ?? menuItem.label}
> >
{menuItem.icon} {menuItem.href ? (
<span className="truncate">{menuItem.label}</span> <a
</a> className="inline-flex w-full items-center gap-2"
) : ( href={menuItem.href}
<> rel="noreferrer"
{menuItem.icon} target="_blank"
<span className="truncate">{menuItem.label}</span> title={menuItem.title ?? menuItem.label}
</> >
)} {menuItem.icon}
</DropdownMenuItem> <span className="truncate">{menuItem.label}</span>
))} </a>
) : (
<>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
</>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) )
+1 -4
View File
@@ -379,10 +379,7 @@ function CategoryButton({
type="button" type="button"
> >
<span <span
className={cn( className={cn('underline-offset-4 decoration-current', active ? 'font-medium underline' : 'hover:underline')}
'underline-offset-4 decoration-current',
active ? 'font-medium underline' : 'hover:underline'
)}
> >
{label} {label}
</span> </span>
+10 -7
View File
@@ -46,11 +46,9 @@ export function UpdatesOverlay() {
}, [checking, open, status]) }, [checking, open, status])
const behind = status?.behind ?? 0 const behind = status?.behind ?? 0
const phase: 'idle' | 'applying' | 'error' = apply.applying || apply.stage === 'restart'
? 'applying' const phase: 'idle' | 'applying' | 'error' =
: apply.stage === 'error' apply.applying || apply.stage === 'restart' ? 'applying' : apply.stage === 'error' ? 'error' : 'idle'
? 'error'
: 'idle'
const handleClose = (next: boolean) => { const handleClose = (next: boolean) => {
if (phase === 'applying') { if (phase === 'applying') {
@@ -114,7 +112,9 @@ function IdleView({
status: DesktopUpdateStatus | null status: DesktopUpdateStatus | null
}) { }) {
if (!status && checking) { if (!status && checking) {
return <CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title="Looking for updates…" /> return (
<CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title="Looking for updates…" />
)
} }
if (!status) { if (!status) {
@@ -223,7 +223,9 @@ function IdleView({
</div> </div>
{remaining > 0 && ( {remaining > 0 && (
<p className="text-center text-xs text-muted-foreground">+ {remaining} more change{remaining === 1 ? '' : 's'} included.</p> <p className="text-center text-xs text-muted-foreground">
+ {remaining} more change{remaining === 1 ? '' : 's'} included.
</p>
)} )}
</div> </div>
) )
@@ -231,6 +233,7 @@ function IdleView({
function ApplyingView({ apply }: { apply: UpdateApplyState }) { function ApplyingView({ apply }: { apply: UpdateApplyState }) {
const label = STAGE_LABELS[apply.stage] ?? 'Updating Hermes…' const label = STAGE_LABELS[apply.stage] ?? 'Updating Hermes…'
const percent = const percent =
typeof apply.percent === 'number' && Number.isFinite(apply.percent) typeof apply.percent === 'number' && Number.isFinite(apply.percent)
? Math.max(2, Math.min(100, Math.round(apply.percent))) ? Math.max(2, Math.min(100, Math.round(apply.percent)))
+5 -2
View File
@@ -30,7 +30,10 @@ function binaryNoiseDataUrl(tile: number, density: number, size: number, color:
return '' 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 physTile = Math.round(tile * dpr)
const block = Math.max(1, Math.round(size * dpr)) const block = Math.max(1, Math.round(size * dpr))
@@ -165,7 +168,7 @@ export function Backdrop() {
{import.meta.env.DEV && <ThemeControls />} {import.meta.env.DEV && <ThemeControls />}
{statue.enabled && ( {statue.enabled && gpuTier > 0 && (
<div <div
aria-hidden aria-hidden
className="pointer-events-none fixed inset-0 z-2" className="pointer-events-none fixed inset-0 z-2"
@@ -395,11 +395,7 @@ describe('assistant-ui streaming renderer', () => {
it('renders archived todos after turn completion regardless of pending state', () => { it('renders archived todos after turn completion regardless of pending state', () => {
const first = render( const first = render(
<TodoHarness <TodoHarness message={assistantTodoMessage([{ content: 'Boil water', id: 'boil', status: 'pending' }], false)} />
message={assistantTodoMessage([
{ content: 'Boil water', id: 'boil', status: 'pending' }
], false)}
/>
) )
const ui = within(first.container) const ui = within(first.container)
@@ -410,9 +406,7 @@ describe('assistant-ui streaming renderer', () => {
const second = render( const second = render(
<TodoHarness <TodoHarness
message={assistantTodoMessage([ message={assistantTodoMessage([{ content: 'Serve latte', id: 'serve', status: 'completed' }], false)}
{ content: 'Serve latte', id: 'serve', status: 'completed' }
], false)}
/> />
) )
@@ -18,7 +18,7 @@ import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text' import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
import { MarkdownText } from '@/components/assistant-ui/markdown-text' import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool' 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 { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { useElapsedSeconds } from '@/components/chat/activity-timer' import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { ActivityTimerText } from '@/components/chat/activity-timer-text'
@@ -51,6 +51,7 @@ import {
XIcon XIcon
} from '@/lib/icons' } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets' import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { notifyError } from '@/store/notifications' import { notifyError } from '@/store/notifications'
@@ -365,7 +366,8 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
return pickPrimaryPreviewTarget(extractPreviewTargets(messageText)) return pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
}, [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) { if (isPlaceholder) {
return null return null
@@ -382,14 +384,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
data-slot="aui_assistant-message-content" data-slot="aui_assistant-message-content"
> >
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />} {hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
<MessagePrimitive.Parts <MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
components={{
Text: MarkdownText,
Reasoning: ReasoningTextPart,
ReasoningGroup: ReasoningAccordionGroup,
tools: { Fallback: ChainToolFallback }
}}
/>
{previewTargets.length > 0 && ( {previewTargets.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{previewTargets.map(target => ( {previewTargets.map(target => (
@@ -462,26 +457,70 @@ const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
const ChainToolFallback: FC<ToolCallMessagePartProps> = props => { const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
// todo parts are hoisted to a dedicated panel above the message content. // 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 <ImageGenerateTool {...props} />} if (props.toolName === 'image_generate') {
return <ImageGenerateTool {...props} />
}
if (props.toolName === 'clarify') {return <ClarifyTool {...props} />} if (props.toolName === 'clarify') {
return <ClarifyTool {...props} />
}
return <ToolFallback {...props} /> return <ToolFallback {...props} />
} }
const ThinkingDisclosure: FC<{ const ThinkingDisclosure: FC<{
children: ReactNode children: ReactNode
messageRunning?: boolean
pending?: boolean pending?: boolean
timerKey?: string timerKey?: string
}> = ({ children, pending = false, timerKey }) => { }> = ({ children, messageRunning = false, pending = false, timerKey }) => {
const [open, setOpen] = useState(false) // `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<boolean | null>(null)
const elapsed = useElapsedSeconds(pending, timerKey) const elapsed = useElapsedSeconds(pending, timerKey)
const scrollRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(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 ( return (
<div className="text-sm text-muted-foreground" data-slot="aui_thinking-disclosure"> <div className="text-sm text-muted-foreground" data-slot="aui_thinking-disclosure" ref={enterRef}>
<DisclosureRow onToggle={() => setOpen(v => !v)} open={open}> <DisclosureRow onToggle={() => setUserOpen(!open)} open={open}>
<span className="flex min-w-0 items-baseline gap-1.5"> <span className="flex min-w-0 items-baseline gap-1.5">
<span <span
className={cn( className={cn(
@@ -497,19 +536,48 @@ const ThinkingDisclosure: FC<{
</span> </span>
</DisclosureRow> </DisclosureRow>
{open && ( {open && (
<div className="mt-2 w-full min-w-0 max-w-full overflow-hidden pl-(--message-text-indent) pr-2 wrap-anywhere pb-1"> <div
{children} className={cn(
// Keep the reasoning body tucked close to the "Thinking" row so
// it aligns with tool-group disclosure rhythm.
'mt-0.5 w-full min-w-0 max-w-full overflow-hidden pr-2 pl-3 wrap-anywhere pb-1',
isPreview && 'thinking-preview max-h-40'
)}
ref={scrollRef}
>
<div ref={contentRef}>{children}</div>
</div> </div>
)} )}
</div> </div>
) )
} }
const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({ children }) => { // Self-gate "Thinking…" on this message's own reasoning parts. Reading
const pending = useAuiState(s => s.thread.isRunning && s.message.status?.type === 'running') // `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 messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
return <ThinkingDisclosure pending={pending} timerKey={`reasoning:${messageId}`}>{children}</ThinkingDisclosure> 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 (
<ThinkingDisclosure messageRunning={messageRunning} pending={pending} timerKey={`reasoning:${messageId}`}>
{children}
</ThinkingDisclosure>
)
} }
const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => { 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 TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
const SHORT_FMT = new Intl.DateTimeFormat(undefined, { const SHORT_FMT = new Intl.DateTimeFormat(undefined, {
@@ -6,28 +6,36 @@ import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export function todosFromMessageContent(content: unknown): TodoItem[] { export function todosFromMessageContent(content: unknown): TodoItem[] {
if (!Array.isArray(content)) {return []} if (!Array.isArray(content)) {
return []
}
let latest: null | TodoItem[] = null let latest: null | TodoItem[] = null
for (const part of content) { for (const part of content) {
if (!part || typeof part !== 'object') {continue} if (!part || typeof part !== 'object') {
continue
}
const row = part as Record<string, unknown> const row = part as Record<string, unknown>
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) const parsed = parseTodos(row.result) ?? parseTodos(row.args)
if (parsed !== null) {latest = parsed} if (parsed !== null) {
latest = parsed
}
} }
return latest ?? [] return latest ?? []
} }
const headerLabel = (todos: readonly TodoItem[]): string => const headerLabel = (todos: readonly TodoItem[]): string =>
todos.find(t => t.status === 'in_progress')?.content todos.find(t => t.status === 'in_progress')?.content ??
?? todos.find(t => t.status === 'pending')?.content todos.find(t => t.status === 'pending')?.content ??
?? todos.at(-1)?.content todos.at(-1)?.content ??
?? 'Tasks' 'Tasks'
const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => { const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => {
if (status === 'in_progress') { if (status === 'in_progress') {
@@ -49,7 +57,8 @@ const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label })
checked={checked} checked={checked}
className={cn( className={cn(
'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100', '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' status === 'cancelled' && 'border-muted-foreground/40'
)} )}
disabled disabled
@@ -58,7 +67,9 @@ const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label })
} }
export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => { export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
if (!todos.length) {return null} if (!todos.length) {
return null
}
const label = headerLabel(todos) const label = headerLabel(todos)
return ( return (
@@ -67,7 +78,10 @@ export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
data-slot="aui_todo-hoisted" data-slot="aui_todo-hoisted"
> >
<header className="px-3 pt-3 pb-2"> <header className="px-3 pt-3 pb-2">
<span className="block max-w-full truncate text-[0.85rem] font-semibold leading-tight tracking-tight text-foreground" title={label}> <span
className="block max-w-full truncate text-[0.85rem] font-semibold leading-tight tracking-tight text-foreground"
title={label}
>
{label} {label}
</span> </span>
</header> </header>
@@ -83,9 +97,7 @@ export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
key={todo.id} key={todo.id}
> >
<Checkmark label={todo.content} status={todo.status} /> <Checkmark label={todo.content} status={todo.status} />
<span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground"> <span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">{todo.content}</span>
{todo.content}
</span>
</li> </li>
))} ))}
</ul> </ul>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -6,10 +6,14 @@ import { useEffect, useRef, useState } from 'react'
const startedAtByKey = new Map<string, number>() const startedAtByKey = new Map<string, number>()
function startedAt(key?: string): number { function startedAt(key?: string): number {
if (!key) {return Date.now()} if (!key) {
return Date.now()
}
const existing = startedAtByKey.get(key) const existing = startedAtByKey.get(key)
if (existing !== undefined) {return existing} if (existing !== undefined) {
return existing
}
const now = Date.now() const now = Date.now()
startedAtByKey.set(key, now) startedAtByKey.set(key, now)
@@ -17,7 +21,9 @@ function startedAt(key?: string): number {
} }
export function formatElapsed(seconds: number): string { 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')}` return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
} }
@@ -33,9 +39,13 @@ export function useElapsedSeconds(active = true, timerKey?: string): number {
} }
useEffect(() => { 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))) const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
tick() tick()
@@ -4,14 +4,16 @@ import type { ReactNode } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
// Shared header row for any collapsible block (thinking, tool group, single // Shared header row for any collapsible block (thinking, tool group, single
// tool). Owns the grid indent (chevron column = --message-text-indent), the // tool). Each parent supplies its own outer wrapper (with the data-slot CSS
// 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
// uses to escape the message padding) and its own expanded body. // uses to escape the message padding) and its own expanded body.
// //
// Passing `onToggle` makes the row expandable (chevron + hover + click). // Cursor-style affordance:
// Omitting it renders a static row that still reserves the chevron column so // - No leading chevron; a caret appears to the RIGHT of the text on hover
// nested rows stay vertically aligned with their group header. // (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({ export function DisclosureRow({
children, children,
onToggle, onToggle,
@@ -24,36 +26,44 @@ export function DisclosureRow({
trailing?: ReactNode trailing?: ReactNode
}) { }) {
return ( return (
<div <div className="group/disclosure-row relative flex w-full max-w-full min-w-0 text-muted-foreground">
className={cn(
'group/disclosure-row relative flex w-full max-w-full min-w-0 items-start rounded-md text-muted-foreground transition-colors',
onToggle && 'hover:bg-[color-mix(in_srgb,var(--dt-midground)_8%,transparent)] hover:text-foreground'
)}
>
<button <button
aria-expanded={onToggle ? open : undefined} aria-expanded={onToggle ? open : undefined}
className={cn( className={cn(
'grid w-full min-w-0 grid-cols-[var(--message-text-indent)_minmax(0,1fr)] items-start py-0.5 pr-2 text-left', // max-w-fit so the click target hugs the title text width — no
onToggle ? 'cursor-pointer' : 'cursor-default' // background fill, just the cursor + the affordance caret.
'flex min-w-0 max-w-fit items-start gap-2 text-left transition-colors',
onToggle
? 'cursor-pointer hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
: 'cursor-default'
)} )}
disabled={!onToggle} disabled={!onToggle}
onClick={onToggle} onClick={onToggle}
type="button" type="button"
> >
<span className="flex h-[1.1rem] items-center justify-center"> <span className="flex min-w-0 flex-col">{children}</span>
{onToggle ? ( {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).
<span
className={cn(
'flex h-[1.1rem] shrink-0 items-center justify-center transition-opacity duration-150',
open
? 'opacity-80'
: 'opacity-0 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80'
)}
>
<ChevronRight <ChevronRight
aria-hidden aria-hidden
className={cn( className={cn('size-3.5 transition-transform duration-150', open && 'rotate-90')}
'size-3 text-midground/55 transition-transform group-hover/disclosure-row:text-midground', // currentColor + a chunkier stroke so the caret reads as a
open && 'rotate-90' // confident hover affordance instead of a hairline.
)} color="currentColor"
strokeWidth={2.75}
/> />
) : ( </span>
<span aria-hidden className="size-3" /> )}
)}
</span>
<span className="min-w-0">{children}</span>
</button> </button>
{trailing && <span className="absolute right-1 top-0.5 flex h-[1.1rem] items-center">{trailing}</span>} {trailing && <span className="absolute right-1 top-0.5 flex h-[1.1rem] items-center">{trailing}</span>}
</div> </div>
@@ -134,6 +134,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
} }
const { flow } = onboarding const { flow } = onboarding
const reason = onboarding.reason?.trim() || null
const ready = enabled && onboarding.configured === false const ready = enabled && onboarding.configured === false
const showPicker = flow.status === 'idle' || flow.status === 'success' const showPicker = flow.status === 'idle' || flow.status === 'success'
@@ -142,21 +143,22 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl"> <div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl">
<Header /> <Header />
<div className="grid gap-5 p-6"> <div className="grid gap-5 p-6">
{ready ? ( {reason ? <ReasonNotice reason={reason} /> : null}
showPicker ? ( {ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
<Picker ctx={ctx} />
) : (
<FlowPanel ctx={ctx} flow={flow} />
)
) : (
<Preparing boot={boot} />
)}
</div> </div>
</div> </div>
</div> </div>
) )
} }
function ReasonNotice({ reason }: { reason: string }) {
return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{reason}
</div>
)
}
function Preparing({ boot }: { boot: DesktopBootState }) { function Preparing({ boot }: { boot: DesktopBootState }) {
const progress = Math.max(2, Math.min(100, Math.round(boot.progress))) const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
const hasError = Boolean(boot.error) const hasError = Boolean(boot.error)
@@ -445,7 +447,9 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
in": in":
</p> </p>
<CodeBlock copied={flow.copied} onCopy={() => void copyExternalCommand()} text={flow.provider.cli_command} /> <CodeBlock copied={flow.copied} onCopy={() => void copyExternalCommand()} text={flow.provider.cli_command} />
<FlowFooter left={flow.provider.docs_url ? <DocsLink href={flow.provider.docs_url}>{title} docs</DocsLink> : null}> <FlowFooter
left={flow.provider.docs_url ? <DocsLink href={flow.provider.docs_url}>{title} docs</DocsLink> : null}
>
<CancelBtn /> <CancelBtn />
<Button onClick={() => void recheckExternalSignin(ctx)}> <Button onClick={() => void recheckExternalSignin(ctx)}>
<Check className="size-4" /> <Check className="size-4" />
+5 -1
View File
@@ -17,6 +17,10 @@ interface StatusDotProps extends ComponentProps<'span'> {
export function StatusDot({ className, tone, ...props }: StatusDotProps) { export function StatusDot({ className, tone, ...props }: StatusDotProps) {
return ( return (
<span aria-hidden="true" className={cn('inline-block size-1.5 rounded-full', TONE_BG[tone], className)} {...props} /> <span
aria-hidden="true"
className={cn('inline-block size-1.5 rounded-full', TONE_BG[tone], className)}
{...props}
/>
) )
} }
@@ -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<BrailleSpinnerName, NormalisedSpinner> = (() => {
const out = {} as Record<BrailleSpinnerName, NormalisedSpinner>
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 (
<span
aria-label={ariaLabel}
className={cn('inline-flex items-center justify-center font-mono leading-none tabular-nums', className)}
role="status"
>
{spin.frames[frame]}
</span>
)
}
+1
View File
@@ -149,6 +149,7 @@ export interface HermesApiRequest {
path: string path: string
method?: string method?: string
body?: unknown body?: unknown
timeoutMs?: number
} }
export interface HermesNotification { export interface HermesNotification {
+4 -6
View File
@@ -31,6 +31,8 @@ import type {
ToolsetInfo ToolsetInfo
} from '@/types/hermes' } from '@/types/hermes'
const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000
export type { export type {
ActionResponse, ActionResponse,
ActionStatusResponse, ActionStatusResponse,
@@ -79,7 +81,7 @@ export class HermesGateway extends JsonRpcGatewayClient {
connectErrorMessage: 'Could not connect to Hermes gateway', connectErrorMessage: 'Could not connect to Hermes gateway',
createRequestId: nextId => nextId, createRequestId: nextId => nextId,
notConnectedErrorMessage: 'Hermes gateway is not connected', notConnectedErrorMessage: 'Hermes gateway is not connected',
requestTimeoutMs: 0 requestTimeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS
}) })
} }
} }
@@ -242,11 +244,7 @@ export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse>
}) })
} }
export function submitOAuthCode( export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise<OAuthSubmitResponse> {
providerId: string,
sessionId: string,
code: string
): Promise<OAuthSubmitResponse> {
return window.hermesDesktop.api<OAuthSubmitResponse>({ return window.hermesDesktop.api<OAuthSubmitResponse>({
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
method: 'POST', method: 'POST',
+313 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { ChatMessagePart } from './chat-messages'
import { import {
appendAssistantTextPart, appendAssistantTextPart,
chatMessageText, chatMessageText,
@@ -218,10 +219,321 @@ describe('upsertToolPart', () => {
'complete' 'complete'
) )
const completedResult = completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {} const completedResult =
completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {}
const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record<string, unknown>) : {} const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record<string, unknown>) : {}
expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }]) expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }])
expect(clearedResult.todos).toEqual([]) 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<ChatMessagePart, { type: 'tool-call' }> =>
part.type === 'tool-call' && part.toolName === 'web_search'
)
const contexts = webParts.map(part => String((part.args as Record<string, unknown>)?.context || ''))
const summaries = webParts.map(part => {
if (!('result' in part) || !part.result || typeof part.result !== 'object') {
return ''
}
return String((part.result as Record<string, unknown>).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<ChatMessagePart, { type: 'tool-call' }>).args).toMatchObject({
context: 'auckland weather today and tomorrow forecast'
})
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).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<ChatMessagePart, { type: 'tool-call' }> =>
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<ChatMessagePart, { type: 'tool-call' }> =>
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<ChatMessagePart, { type: 'tool-call' }> =>
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<ChatMessagePart, { type: 'tool-call' }> =>
part.type === 'tool-call' && part.toolName === 'web_search'
)
.map(part => ({
id: part.toolCallId,
query: String((part.args as Record<string, unknown>)?.query || ''),
summary:
part.result && typeof part.result === 'object'
? String((part.result as Record<string, unknown>).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<ChatMessagePart, { type: 'tool-call' }>).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<ChatMessagePart, { type: 'tool-call' }>).result).toMatchObject({
data: { web: [{ title: 'Suva forecast' }] },
summary: 'Did 1 search in 0.5s'
})
})
}) })
+186 -9
View File
@@ -23,10 +23,16 @@ export type GatewayEventPayload = {
rendered?: string rendered?: string
status?: string status?: string
message?: string message?: string
id?: string
name?: string name?: string
tool_id?: string tool_id?: string
tool_call_id?: string
args?: unknown
arguments?: unknown
context?: string context?: string
input?: unknown
preview?: string preview?: string
result?: unknown
summary?: string summary?: string
error?: string | boolean error?: string | boolean
inline_diff?: string inline_diff?: string
@@ -209,7 +215,135 @@ export function hasToolPart(message: ChatMessage): boolean {
} }
function toolId(payload: GatewayEventPayload | undefined): string { 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<string, unknown>, 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<string, unknown>
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 // 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 } return next === null ? undefined : { todos: next }
} }
if (payload?.name !== 'todo') {return undefined} if (payload?.name !== 'todo') {
return undefined
}
for (const p of prev) { for (const p of prev) {
const carried = parseTodos(recordFromUnknown(p)?.todos) const carried = parseTodos(recordFromUnknown(p)?.todos)
if (carried !== null) {return { todos: carried }} if (carried !== null) {
return { todos: carried }
}
} }
return undefined return undefined
} }
function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown): Record<string, unknown> { function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown): Record<string, unknown> {
const prev = parseMaybeJsonObject(prevArgs)
const eventArgs = liveToolArgs(payload)
return { return {
...prev,
...eventArgs,
...(payload?.context ? { context: payload.context } : {}), ...(payload?.context ? { context: payload.context } : {}),
...(payload?.preview ? { preview: payload.preview } : {}), ...(payload?.preview ? { preview: payload.preview } : {}),
...carryTodos(payload, prevArgs) ...carryTodos(payload, prevArgs)
} }
} }
function toolResult(payload: GatewayEventPayload | undefined, prevResult?: unknown, prevArgs?: unknown): Record<string, unknown> { function toolResult(
payload: GatewayEventPayload | undefined,
prevResult?: unknown,
prevArgs?: unknown
): Record<string, unknown> {
const parsedResult = parseMaybeJsonObject(payload?.result)
return { return {
...parsedResult,
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}), ...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
...(payload?.summary ? { summary: payload.summary } : {}), ...(payload?.summary ? { summary: payload.summary } : {}),
...(payload?.message ? { message: payload.message } : {}), ...(payload?.message ? { message: payload.message } : {}),
@@ -257,19 +407,22 @@ export function upsertToolPart(
payload: GatewayEventPayload | undefined, payload: GatewayEventPayload | undefined,
phase: 'running' | 'complete' phase: 'running' | 'complete'
): ChatMessagePart[] { ): ChatMessagePart[] {
const id = toolId(payload) const stableId = toolId(payload)
const name = payload?.name || 'tool' const name = payload?.name || 'tool'
const next = [...parts] const next = [...parts]
const index = next.findIndex( const index = findToolPartIndex(next, name, stableId, payload, phase)
part => part.type === 'tool-call' && ((part.toolCallId && part.toolCallId === id) || part.toolName === name)
)
const prev = index >= 0 ? next[index] : null const prev = index >= 0 ? next[index] : null
const prevArgs = prev && 'args' in prev ? prev.args : undefined const prevArgs = prev && 'args' in prev ? prev.args : undefined
const prevResult = prev && 'result' in prev ? prev.result : undefined const prevResult = prev && 'result' in prev ? prev.result : undefined
const args = toolArgs(payload, prevArgs) const args = toolArgs(payload, prevArgs)
const id =
stableId ||
(prev && 'toolCallId' in prev && typeof prev.toolCallId === 'string' ? prev.toolCallId : '') ||
nextLiveToolId(name)
const base = { const base = {
type: 'tool-call' as const, type: 'tool-call' as const,
toolCallId: id, toolCallId: id,
@@ -279,7 +432,9 @@ export function upsertToolPart(
...(phase === 'complete' && { result: toolResult(payload, prevResult, prevArgs), isError: Boolean(payload?.error) }) ...(phase === 'complete' && { result: toolResult(payload, prevResult, prevArgs), isError: Boolean(payload?.error) })
} satisfies ChatMessagePart } satisfies ChatMessagePart
if (index === -1) {return [...next, base]} if (index === -1) {
return [...next, base]
}
next[index] = { ...next[index], ...base } next[index] = { ...next[index], ...base }
return next return next
@@ -319,6 +474,28 @@ function firstNonEmptyObject(...values: unknown[]): Record<string, unknown> {
return {} return {}
} }
function liveToolArgs(payload: GatewayEventPayload | undefined): Record<string, unknown> {
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 { function parseStoredToolResult(content: unknown): unknown {
if (content && typeof content === 'object') { if (content && typeof content === 'object') {
return content return content
+4 -1
View File
@@ -101,7 +101,10 @@ export function parseCommitHeader(raw: string): ParsedCommit {
} }
function tidySubject(subject: string): string { 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) { if (!cleaned) {
return cleaned return cleaned
+10 -16
View File
@@ -63,7 +63,8 @@ describe('external link helpers', () => {
const bridge = vi.fn().mockResolvedValue('El Yunque Tour Water Slide, Rope Swing & Pickup') const bridge = vi.fn().mockResolvedValue('El Yunque Tour Water Slide, Rope Swing & Pickup')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] }) 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)]) const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)])
@@ -95,11 +96,7 @@ describe('external link helpers', () => {
const openExternal = vi.fn().mockResolvedValue(undefined) const openExternal = vi.fn().mockResolvedValue(undefined)
installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] }) installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
render( render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
<ExternalLink href="https://example.com/path/to/resource">
Example link
</ExternalLink>
)
fireEvent.click(screen.getByRole('link', { name: 'Example link' })) fireEvent.click(screen.getByRole('link', { name: 'Example link' }))
expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource') expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource')
@@ -108,11 +105,7 @@ describe('external link helpers', () => {
it('shows a trailing external-link icon', () => { it('shows a trailing external-link icon', () => {
installDesktopBridge() installDesktopBridge()
render( render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
<ExternalLink href="https://example.com/path/to/resource">
Example link
</ExternalLink>
)
const link = screen.getByRole('link', { name: 'Example link' }) const link = screen.getByRole('link', { name: 'Example link' })
expect(link.querySelector('svg')).toBeTruthy() expect(link.querySelector('svg')).toBeTruthy()
@@ -125,9 +118,7 @@ describe('external link helpers', () => {
const url = const url =
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/' 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
render( render(<LinkifiedText text={`Read ${url}`} />)
<LinkifiedText text={`Read ${url}`} />
)
const link = screen.getByTitle(url) const link = screen.getByTitle(url)
expect(link.textContent).toContain('From Fajardo Full Day Cordillera Islands Catamaran Tour') 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 () => { it('ignores error-like fetched titles and falls back to slug label', async () => {
const bridge = vi.fn().mockResolvedValue('GetYourGuide Error') const bridge = vi.fn().mockResolvedValue('GetYourGuide Error')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] }) 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(<PrettyLink href={url} />) render(<PrettyLink href={url} />)
@@ -168,6 +160,8 @@ describe('external link helpers', () => {
render(<LinkifiedText text="Source expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure" />) render(<LinkifiedText text="Source expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure" />)
const link = screen.getByRole('link') 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'
)
}) })
}) })
+8 -1
View File
@@ -204,7 +204,14 @@ export function ExternalLinkIcon({ className }: { className?: string }) {
return <ArrowUpRight aria-hidden className={cn('ml-1 inline size-[0.78em] align-[-0.08em] opacity-70', className)} /> return <ArrowUpRight aria-hidden className={cn('ml-1 inline size-[0.78em] align-[-0.08em] opacity-70', className)} />
} }
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) const target = normalizeExternalUrl(href)
return ( return (
@@ -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<ExternalStoreAdapter['messageRepository']>
): 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<T extends ThreadMessage>(
store: ExternalStoreAdapter<T>
): 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])
}
+13 -3
View File
@@ -28,6 +28,7 @@ const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*
*/ */
function hasCloseFenceLine(body: string, marker: string): boolean { function hasCloseFenceLine(body: string, marker: string): boolean {
const lines = body.split('\n') const lines = body.split('\n')
// Original regex required `\n` immediately before the close fence, so the // Original regex required `\n` immediately before the close fence, so the
// first line of `body` (which has no preceding newline within `body`) // first line of `body` (which has no preceding newline within `body`)
// cannot itself be the close fence. // cannot itself be the close fence.
@@ -35,8 +36,15 @@ function hasCloseFenceLine(body: string, marker: string): boolean {
const line = lines[i] const line = lines[i]
let lo = 0 let lo = 0
let hi = line.length 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) { if (line.slice(lo, hi) === marker) {
return true return true
} }
@@ -122,7 +130,9 @@ function normalizeVisibleProse(text: string): string {
.map(part => .map(part =>
part.startsWith('`') part.startsWith('`')
? part ? 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('') .join('')
} }
@@ -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')
})
})
+147
View File
@@ -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 = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
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<T>(
requestGateway: RuntimeReadinessRequester,
method: string
): Promise<{ error: null | string; value: null | T }> {
try {
return { error: null, value: await requestGateway<T>(method) }
} catch (error) {
return { error: toErrorMessage(error), value: null }
}
}
export async function fetchRuntimeReadinessSignals(
requestGateway: RuntimeReadinessRequester
): Promise<RuntimeReadinessSignals> {
const [setup, runtime] = await Promise.all([
requestWithFallback<SetupStatusSnapshot>(requestGateway, 'setup.status'),
requestWithFallback<RuntimeCheckSnapshot>(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<RuntimeReadinessResult> {
const signals = await fetchRuntimeReadinessSignals(requestGateway)
return interpretRuntimeReadiness(signals, options)
}
+20 -8
View File
@@ -13,7 +13,9 @@ const isStatus = (v: unknown): v is TodoStatus => (STATUSES as readonly string[]
function parseArray(value: unknown[]): TodoItem[] { function parseArray(value: unknown[]): TodoItem[] {
return value.flatMap(item => { 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 id = String(item.id ?? '').trim()
const content = String(item.content ?? '').trim() const content = String(item.content ?? '').trim()
@@ -22,15 +24,25 @@ function parseArray(value: unknown[]): TodoItem[] {
} }
function parse(value: unknown, depth: number): null | TodoItem[] { function parse(value: unknown, depth: number): null | TodoItem[] {
if (depth > 2) {return null} 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 (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 return null
} }
@@ -44,9 +44,7 @@ describe('formatToolResultSummary', () => {
details: `prefix ${'x'.repeat(500)}` details: `prefix ${'x'.repeat(500)}`
}) })
const detailsLine = summary const detailsLine = summary.split('\n').find(line => line.startsWith('- Details:'))
.split('\n')
.find(line => line.startsWith('- Details:'))
expect(detailsLine).toBeTruthy() expect(detailsLine).toBeTruthy()
expect(detailsLine?.length).toBeLessThan(230) expect(detailsLine?.length).toBeLessThan(230)
+177 -53
View File
@@ -2,7 +2,21 @@
// mode still gets the raw JSON section. // mode still gets the raw JSON section.
const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const 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_KEYS = ['error', 'errors', 'failure', 'exception'] as const
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] 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']) 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 { function tryJson(value: string): unknown {
const t = value.trim() 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 norm = (v: unknown): unknown => (typeof v === 'string' ? tryJson(v) : v)
const titleCase = (k: string) => 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'}` 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 { function clipBlock(value: string, maxChars = 1800, maxLines = 18): string {
const t = value.trim() const t = value.trim()
if (!t) {return ''} if (!t) {
return ''
}
const lines = t.split('\n') const lines = t.split('\n')
let text = lines.slice(0, maxLines).join('\n') let text = lines.slice(0, maxLines).join('\n')
const clipped = lines.length > maxLines || text.length > maxChars 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 return clipped && !text.endsWith('…') ? `${text}` : text
} }
@@ -51,7 +81,9 @@ function firstString(record: Json, keys: readonly string[]): string {
for (const k of keys) { for (const k of keys) {
const v = record[k] const v = record[k]
if (typeof v === 'string' && v.trim()) {return v.trim()} if (typeof v === 'string' && v.trim()) {
return v.trim()
}
} }
return '' 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) const skipField = (k: string, v: unknown) => isWrapperKey(k) || ((k === 'success' || k === 'ok') && v === true)
function summarizeScalar(v: unknown): string { 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 '' return ''
} }
function summarizeRecordInline(record: Json, depth: number): string { 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 title = firstString(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'id'])
const status = firstString(record, ['status', 'category', 'type']) const status = firstString(record, ['status', 'category', 'type'])
const message = firstString(record, ['snippet', 'summary', 'description', 'message']) 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)) const pairs = orderedKeys(Object.keys(record))
.filter(k => !skipField(k, record[k])) .filter(k => !skipField(k, record[k]))
@@ -104,15 +148,25 @@ function summarizeRecordInline(record: Json, depth: number): string {
function summarizeListItem(item: unknown, depth: number): string { function summarizeListItem(item: unknown, depth: number): string {
const v = norm(item) 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)) return clipInline(String(v))
} }
@@ -121,32 +175,50 @@ function formatFieldValue(value: unknown, depth: number): string {
const v = norm(value) const v = norm(value)
const scalar = summarizeScalar(v) 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 (Array.isArray(v)) {
if (!v.length) {return '0 items'} if (!v.length) {
return '0 items'
}
const scalars = v.map(summarizeScalar).filter(Boolean) 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) const first = summarizeListItem(v[0], depth + 1)
return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item') 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)) return clipInline(String(v))
} }
function formatArraySummary(value: unknown[], depth: number): string { 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 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) { if (value.length > max) {
const remaining = 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 { function formatRecordSummary(record: Json, depth: number): string {
const keys = Object.keys(record) const keys = Object.keys(record)
if (!keys.length) {return 'Returned an empty object.'} if (!keys.length) {
return 'Returned an empty object.'
}
if (depth <= 2) { if (depth <= 2) {
const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content']) const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content'])
const meaningful = keys.filter(k => !skipField(k, record[k]) && !isWrapperKey(k)) 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])) 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) { for (const k of candidates) {
const v = formatFieldValue(record[k], depth + 1) const v = formatFieldValue(record[k], depth + 1)
if (!v) {continue} if (!v) {
continue
}
lines.push(`- ${titleCase(k)}: ${v}`) 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) { if (candidates.length > lines.length) {
const remaining = 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 { function formatSummaryValue(value: unknown, depth: number): string {
if (depth > 4) {return ''} if (depth > 4) {
return ''
}
const v = norm(value) 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)) return clipInline(String(v))
} }
@@ -232,17 +326,29 @@ function unwrapPayload(value: unknown): unknown {
function hasMeaningfulErrorValue(value: unknown): boolean { function hasMeaningfulErrorValue(value: unknown): boolean {
const v = norm(value) 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 return true
} }
@@ -261,7 +367,9 @@ function hasErrorSignal(record: Json): boolean {
function valueErrorText(value: unknown): string { function valueErrorText(value: unknown): string {
const v = norm(value) 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)) { if (Array.isArray(v)) {
return clipBlock(v.map(valueErrorText).filter(Boolean).slice(0, 3).join('; '), 700, 12) 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)) { if (isRecord(v)) {
const direct = firstString(v, ERROR_MSG_KEYS) const direct = firstString(v, ERROR_MSG_KEYS)
if (direct) {return clipBlock(direct, 700, 12)} if (direct) {
return clipBlock(direct, 700, 12)
}
} }
return '' return ''
} }
function findNestedError(value: unknown, depth: number, seen: Set<unknown>): string { function findNestedError(value: unknown, depth: number, seen: Set<unknown>): string {
if (depth > 5) {return ''} if (depth > 5) {
return ''
}
const v = norm(value) 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) seen.add(v)
if (Array.isArray(v)) { if (Array.isArray(v)) {
for (const item of v) { for (const item of v) {
const nested = findNestedError(item, depth + 1, seen) const nested = findNestedError(item, depth + 1, seen)
if (nested) {return nested} if (nested) {
return nested
}
} }
return '' return ''
@@ -296,22 +412,30 @@ function findNestedError(value: unknown, depth: number, seen: Set<unknown>): str
const record = v as Json const record = v as Json
for (const k of ERROR_KEYS) { for (const k of ERROR_KEYS) {
if (!hasMeaningfulErrorValue(record[k])) {continue} if (!hasMeaningfulErrorValue(record[k])) {
continue
}
const text = valueErrorText(record[k]) const text = valueErrorText(record[k])
if (text) {return text} if (text) {
return text
}
} }
if (hasErrorSignal(record)) { if (hasErrorSignal(record)) {
const direct = firstString(record, ERROR_MSG_KEYS) 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']) { for (const k of [...ERROR_KEYS, ...WRAPPER_KEYS, 'details', 'meta']) {
const nested = findNestedError(record[k], depth + 1, seen) const nested = findNestedError(record[k], depth + 1, seen)
if (nested) {return nested} if (nested) {
return nested
}
} }
return '' return ''
+100
View File
@@ -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<string>()
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)
}
})
}
}, [])
}
+1
View File
@@ -154,6 +154,7 @@ export function clearNotifications() {
timers.clear() timers.clear()
const all = $notifications.get() const all = $notifications.get()
$notifications.set([]) $notifications.set([])
for (const item of all) { for (const item of all) {
item.onDismiss?.() item.onDismiss?.()
} }
+144
View File
@@ -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> = {}): DesktopOnboardingState {
return {
configured: false,
flow: { status: 'idle' },
mode: 'oauth',
providers: null,
reason: null,
requested: false,
...overrides
}
}
function installApiMock(api: (request: { path: string }) => Promise<unknown>) {
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'])
})
})
+70 -39
View File
@@ -8,6 +8,7 @@ import {
startOAuthLogin, startOAuthLogin,
submitOAuthCode submitOAuthCode
} from '@/hermes' } from '@/hermes'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { notify, notifyError } from '@/store/notifications' import { notify, notifyError } from '@/store/notifications'
import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes' import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes'
@@ -46,6 +47,7 @@ export interface OnboardingContext {
const CONFIGURED_CACHE_KEY = 'hermes-desktop-onboarded-v1' const CONFIGURED_CACHE_KEY = 'hermes-desktop-onboarded-v1'
const POLL_MS = 2000 const POLL_MS = 2000
const COPY_FLASH_MS = 1500 const COPY_FLASH_MS = 1500
const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.'
function readCachedConfigured(): boolean | null { function readCachedConfigured(): boolean | null {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -87,6 +89,7 @@ const INITIAL: DesktopOnboardingState = {
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL) export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
let pollTimer: number | null = null let pollTimer: number | null = null
let providersRefreshPromise: null | Promise<void> = null
const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)) const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e))
@@ -104,42 +107,61 @@ function clearPoll() {
} }
} }
async function safeReq<T>(ctx: OnboardingContext, method: string, fallback: T): Promise<T> { async function checkRuntime(ctx: OnboardingContext): Promise<RuntimeReadinessResult> {
try { return evaluateRuntimeReadiness(ctx.requestGateway, {
return await ctx.requestGateway<T>(method) defaultReason: DEFAULT_ONBOARDING_REASON,
} catch { unknownReady: false
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)
} }
function notifyReady(provider: string) { function notifyReady(provider: string) {
notify({ kind: 'success', title: 'Hermes is ready', message: `${provider} connected.` }) 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) await ctx.requestGateway('reload.env').catch(() => undefined)
const ok = await checkRuntime(ctx) const runtime = await checkRuntime(ctx)
if (ok) { if (runtime.ready) {
notifyReady(providerName) notifyReady(providerName)
completeDesktopOnboarding() completeDesktopOnboarding()
ctx.onCompleted?.() ctx.onCompleted?.()
} else { } else {
onFail() onFail(runtime.reason)
} }
} }
export function requestDesktopOnboarding(reason = 'No inference provider is configured.') { function providerResolutionFailure(reason: null | string) {
patch({ reason, requested: true }) 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() { export function completeDesktopOnboarding() {
@@ -160,26 +182,26 @@ export function setOnboardingMode(mode: OnboardingMode) {
} }
export async function refreshOnboarding(ctx: OnboardingContext) { export async function refreshOnboarding(ctx: OnboardingContext) {
if (await checkRuntime(ctx)) { const runtime = await checkRuntime(ctx)
if (runtime.ready) {
completeDesktopOnboarding() completeDesktopOnboarding()
ctx.onCompleted?.() ctx.onCompleted?.()
return true return true
} }
writeCachedConfigured(false) const state = $desktopOnboarding.get()
patch({ configured: false }) 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 return false
} }
try { await refreshProviders()
const { providers } = await listOAuthProviders()
patch({ providers, mode: providers.length > 0 ? 'oauth' : 'apikey' })
} catch {
patch({ providers: [], mode: 'apikey' })
}
return false return false
} }
@@ -219,11 +241,11 @@ async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: Onbo
if (status === 'approved') { if (status === 'approved') {
clearPoll() clearPoll()
setFlow({ status: 'success', provider }) setFlow({ status: 'success', provider })
await reloadAndConnect(ctx, provider.name, () => await reloadAndConnect(ctx, provider.name, reason =>
setFlow({ setFlow({
status: 'error', status: 'error',
provider, provider,
message: 'Connected, but Hermes still cannot resolve a usable provider.' message: providerResolutionFailure(reason)
}) })
) )
} else if (status !== 'pending') { } else if (status !== 'pending') {
@@ -259,11 +281,11 @@ export async function submitOnboardingCode(ctx: OnboardingContext) {
if (resp.ok && resp.status === 'approved') { if (resp.ok && resp.status === 'approved') {
setFlow({ status: 'success', provider }) setFlow({ status: 'success', provider })
await reloadAndConnect(ctx, provider.name, () => await reloadAndConnect(ctx, provider.name, reason =>
setFlow({ setFlow({
status: 'error', status: 'error',
provider, provider,
message: 'Connected, but Hermes still cannot resolve a usable provider.' message: providerResolutionFailure(reason)
}) })
) )
} else { } else {
@@ -338,11 +360,13 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
} }
const { provider } = flow const { provider } = flow
await reloadAndConnect(ctx, provider.name, () => await reloadAndConnect(ctx, provider.name, reason =>
setFlow({ setFlow({
status: 'error', status: 'error',
provider, 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 { try {
await setEnvVar(envKey, trimmed) await setEnvVar(envKey, trimmed)
let stillFailing = false let stillFailing = false
await reloadAndConnect(ctx, label, () => { let runtimeFailure: null | string = null
await reloadAndConnect(ctx, label, reason => {
stillFailing = true stillFailing = true
runtimeFailure = reason
}) })
if (stillFailing) { 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 } return { ok: true }
+13 -1
View File
@@ -1,4 +1,4 @@
import { atom } from 'nanostores' import { atom, computed, type ReadableAtom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage' import { persistBoolean, storedBoolean } from '@/lib/storage'
@@ -14,6 +14,7 @@ export const $toolViewMode = atom<ToolViewMode>(
storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product' storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product'
) )
export const $toolDisclosureStates = atom<ToolDisclosureStates>(loadToolDisclosureStates()) export const $toolDisclosureStates = atom<ToolDisclosureStates>(loadToolDisclosureStates())
const disclosureOpenCache = new Map<string, ReadableAtom<boolean | undefined>>()
$toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical')) $toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical'))
$toolDisclosureStates.subscribe(persistToolDisclosureStates) $toolDisclosureStates.subscribe(persistToolDisclosureStates)
@@ -22,6 +23,17 @@ export function setToolViewMode(mode: ToolViewMode) {
$toolViewMode.set(mode) $toolViewMode.set(mode)
} }
export function $toolDisclosureOpen(id: string): ReadableAtom<boolean | undefined> {
let cached = disclosureOpenCache.get(id)
if (!cached) {
cached = computed($toolDisclosureStates, states => states[id])
disclosureOpenCache.set(id, cached)
}
return cached
}
function loadToolDisclosureStates(): ToolDisclosureStates { function loadToolDisclosureStates(): ToolDisclosureStates {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return {} return {}
+57 -3
View File
@@ -432,11 +432,13 @@ canvas {
padding-left: var(--message-text-indent); 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='tool-block'],
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'] { [data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'] {
margin-inline-start: calc(-1 * var(--message-text-indent)); width: 100%;
width: calc(100% + var(--message-text-indent)); max-width: 100%;
max-width: calc(100% + var(--message-text-indent));
} }
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code { [data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code {
@@ -525,3 +527,55 @@ canvas {
width: 0.875rem; width: 0.875rem;
height: 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%);
}
+4 -4
View File
@@ -71,7 +71,7 @@
"@chenglou/pretext": "^0.0.6", "@chenglou/pretext": "^0.0.6",
"@hermes/shared": "file:../shared", "@hermes/shared": "file:../shared",
"@nanostores/react": "^1.1.0", "@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.12.0", "@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@streamdown/code": "^1.1.1", "@streamdown/code": "^1.1.1",
"@tabler/icons-react": "^3.41.1", "@tabler/icons-react": "^3.41.1",
@@ -131,9 +131,9 @@
} }
}, },
"apps/desktop/node_modules/@nous-research/ui": { "apps/desktop/node_modules/@nous-research/ui": {
"version": "0.12.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.12.0.tgz", "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.0.tgz",
"integrity": "sha512-OHv7z9J0r5Yp9iOl8uVUZAdIkTtDwqAL2RbgsQczXAhSPMOQle5X6ROWI4j6WK9riYhh4iCtRaCMsLcmSAuaXg==", "integrity": "sha512-c07lfMdEv/KL6lYC6mfap1CcmIPbvhCZu1supnFaIIrlUaab8gVNDYl8wMMjNRdYOVxxXKisU48yyfe5qvlwqg==",
"dependencies": { "dependencies": {
"@nanostores/react": "^1.0.0", "@nanostores/react": "^1.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
+9 -3
View File
@@ -1591,12 +1591,12 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict):
_emit( _emit(
"tool.start", "tool.start",
sid, 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): 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) session = _sessions.get(sid)
snapshot = None snapshot = None
started_at = 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 duration_s = time.time() - started_at if started_at else None
if duration_s is not None: if duration_s is not None:
payload["duration_s"] = duration_s payload["duration_s"] = duration_s
try:
payload["result"] = json.loads(result)
except Exception:
payload["result"] = result
summary = _tool_summary(name, result, duration_s) summary = _tool_summary(name, result, duration_s)
if summary: if summary:
payload["summary"] = summary payload["summary"] = summary
@@ -1645,7 +1649,9 @@ def _on_tool_progress(
if not _tool_progress_enabled(sid): if not _tool_progress_enabled(sid):
return return
if event_type == "tool.started" and name: 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 return
if event_type == "reasoning.available" and preview: if event_type == "reasoning.available" and preview:
_emit("reasoning.available", sid, {"text": str(preview)}) _emit("reasoning.available", sid, {"text": str(preview)})