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 test = require('node:test')
const {
bundledRuntimeImportCheck,
isWindowsBinaryPathInWsl,
isWslEnvironment
} = require('./bootstrap-platform.cjs')
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
+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 { spawn } = require('node:child_process')
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const {
DATA_URL_READ_MAX_BYTES,
DEFAULT_FETCH_TIMEOUT_MS,
TEXT_PREVIEW_SOURCE_MAX_BYTES,
encryptDesktopSecret: encryptDesktopSecretStrict,
resolveReadableFileForIpc,
resolveTimeoutMs
} = require('./hardening.cjs')
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
if (USER_DATA_OVERRIDE) {
@@ -843,7 +851,16 @@ function resolveHermesBackend(dashboardArgs) {
if (backend) return backend
}
// 2. Existing `hermes` on PATH — installed via install.ps1 / install.sh, or
// 2. Development source — when running `npm run dev` from a checkout, the
// cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE and any
// installed `hermes` on PATH so local Python edits are actually exercised.
// (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.)
if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) {
const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs)
if (backend) return backend
}
// 3. Existing `hermes` on PATH — installed via install.ps1 / install.sh, or
// pip-installed system-wide. Skip when HERMES_DESKTOP_IGNORE_EXISTING=1
// (used by test:desktop:fresh to force the factory-image bootstrap path).
if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') {
@@ -876,15 +893,6 @@ function resolveHermesBackend(dashboardArgs) {
}
}
// 3. Development source — when running `npm run dev` from a checkout, the
// cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE so the
// desktop uses the dev's local edits, not whatever's under HERMES_HOME.
// (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.)
if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) {
const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs)
if (backend) return backend
}
// 4. ACTIVE_HERMES_ROOT — the canonical mutable install at
// %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent.
// On packaged installs this is populated from FACTORY_HERMES_ROOT during
@@ -1155,6 +1163,7 @@ function fetchJson(url, token, options = {}) {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const parsed = new URL(url)
const client = parsed.protocol === 'https:' ? https : http
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
@@ -1190,11 +1199,9 @@ function fetchJson(url, token, options = {}) {
)
req.on('error', reject)
if (options.timeoutMs) {
req.setTimeout(options.timeoutMs, () => {
req.destroy(new Error(`Timed out connecting to Hermes backend after ${options.timeoutMs}ms`))
})
}
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
})
if (body) req.write(body)
req.end()
})
@@ -1456,7 +1463,9 @@ function fetchLinkTitle(rawUrl) {
const pending = fetchHtmlTitleWithCurl(url)
.catch(() => '')
.then(value => usableTitle((value || '').slice(0, 240)))
.then(async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240)))
.then(
async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240))
)
.then(clean => {
cacheTitle(key, clean)
titleInflight.delete(key)
@@ -2022,24 +2031,7 @@ function tokenPreview(value) {
}
function encryptDesktopSecret(value) {
const raw = String(value || '')
if (!raw) {
return null
}
try {
if (safeStorage.isEncryptionAvailable()) {
return {
encoding: 'safeStorage',
value: safeStorage.encryptString(raw).toString('base64')
}
}
} catch {
// Fall through to plaintext for platforms where Electron cannot encrypt.
}
return { encoding: 'plain', value: raw }
return encryptDesktopSecretStrict(value, safeStorage)
}
function decryptDesktopSecret(secret) {
@@ -2108,14 +2100,19 @@ function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig())
}
}
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig()) {
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
const persistToken = options.persistToken !== false
const mode = input.mode === 'remote' ? 'remote' : 'local'
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
const existingToken = existing.remote?.token
const nextRemote = {
url: remoteUrl,
token: incomingToken ? encryptDesktopSecret(incomingToken) : existingToken
token: incomingToken
? persistToken
? encryptDesktopSecret(incomingToken)
: { encoding: 'plain', value: incomingToken }
: existingToken
}
if (mode === 'remote') {
@@ -2181,7 +2178,7 @@ function resolveRemoteBackend() {
}
async function testDesktopConnectionConfig(input = {}) {
const config = coerceDesktopConnectionConfig(input)
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
const remote =
config.mode === 'remote'
? {
@@ -2455,9 +2452,11 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
ipcMain.handle('hermes:api', async (_event, request) => {
const connection = await startHermes()
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, {
method: request.method,
body: request.body
method: request?.method,
body: request?.body,
timeoutMs
})
})
@@ -2472,18 +2471,21 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
})
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
const input = String(filePath || '')
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
const data = await fs.promises.readFile(resolved)
return `data:${mimeTypeForPath(resolved)};base64,${data.toString('base64')}`
const { resolvedPath } = await resolveReadableFileForIpc(filePath, {
maxBytes: DATA_URL_READ_MAX_BYTES,
purpose: 'File preview'
})
const data = await fs.promises.readFile(resolvedPath)
return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}`
})
ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
const input = String(filePath || '')
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
const ext = path.extname(resolved).toLowerCase()
const stat = await fs.promises.stat(resolved)
const handle = await fs.promises.open(resolved, 'r')
const { resolvedPath, stat } = await resolveReadableFileForIpc(filePath, {
maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES,
purpose: 'Text preview'
})
const ext = path.extname(resolvedPath).toLowerCase()
const handle = await fs.promises.open(resolvedPath, 'r')
const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES)
try {
@@ -2494,8 +2496,8 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))),
byteSize: stat.size,
language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text',
mimeType: mimeTypeForPath(resolved),
path: resolved,
mimeType: mimeTypeForPath(resolvedPath),
path: resolvedPath,
text: buffer.subarray(0, bytesRead).toString('utf8'),
truncated: stat.size > TEXT_PREVIEW_MAX_BYTES
}
+542 -20
View File
@@ -14,17 +14,20 @@
"@chenglou/pretext": "^0.0.6",
"@hermes/shared": "file:../shared",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
"@streamdown/code": "^1.1.1",
"@tabler/icons-react": "^3.41.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"ignore": "^7.0.5",
"liquid-glass-react": "^1.1.1",
"leva": "^0.10.1",
"lucide-react": "^0.577.0",
"motion": "^12.38.0",
"nanostores": "^1.3.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
@@ -1977,6 +1980,48 @@
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@nous-research/ui": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.0.tgz",
"integrity": "sha512-c07lfMdEv/KL6lYC6mfap1CcmIPbvhCZu1supnFaIIrlUaab8gVNDYl8wMMjNRdYOVxxXKisU48yyfe5qvlwqg==",
"dependencies": {
"@nanostores/react": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"nanostores": "^1.0.1",
"sanitize-html": "^2.16.0",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.4.0",
"unicode-animations": "^1.0.3"
},
"peerDependencies": {
"@observablehq/plot": "^0.6.17",
"@react-three/fiber": "^9.4.0",
"gsap": "^3.13.0",
"leva": "^0.10.1",
"motion": "^12.38.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"three": "^0.180.0"
},
"peerDependenciesMeta": {
"@observablehq/plot": {
"optional": true
},
"@react-three/fiber": {
"optional": true
},
"gsap": {
"optional": true
},
"leva": {
"optional": true
},
"three": {
"optional": true
}
}
},
"node_modules/@oxc-project/types": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
@@ -5916,6 +5961,14 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@stitches/react": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@stitches/react/-/react-1.2.8.tgz",
"integrity": "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA==",
"peerDependencies": {
"react": ">= 16.3.0"
}
},
"node_modules/@streamdown/code": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@streamdown/code/-/code-1.1.1.tgz",
@@ -6285,6 +6338,17 @@
"node": ">= 20"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz",
@@ -7097,6 +7161,22 @@
"d3-transition": "^3.0.1"
}
},
"node_modules/@use-gesture/core": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="
},
"node_modules/@use-gesture/react": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
"dependencies": {
"@use-gesture/core": "10.3.1"
},
"peerDependencies": {
"react": ">= 16.8.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@@ -7723,6 +7803,14 @@
"node": ">=12"
}
},
"node_modules/assign-symbols": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
"integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/assistant-cloud": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.27.tgz",
@@ -7816,6 +7904,14 @@
"node": ">= 4.0.0"
}
},
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
"engines": {
"node": ">=4"
}
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -8448,6 +8544,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/colord": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -8623,6 +8724,17 @@
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"bin": {
"cssesc": "bin/cssesc"
},
"engines": {
"node": ">=4"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -9276,6 +9388,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
@@ -9549,6 +9669,55 @@
"license": "MIT",
"peer": true
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom-serializer/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
]
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
@@ -9558,6 +9727,19 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -10079,7 +10261,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -10523,6 +10704,25 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/extend-shallow/node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -10615,6 +10815,17 @@
"node": ">=16.0.0"
}
},
"node_modules/file-selector": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.5.0.tgz",
"integrity": "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA==",
"dependencies": {
"tslib": "^2.0.3"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -10730,6 +10941,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/for-in": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
"integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -10747,6 +10966,32 @@
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"dependencies": {
"motion-dom": "^12.38.0",
"motion-utils": "^12.36.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -10936,6 +11181,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-value": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
"integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -11464,6 +11717,35 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -11819,6 +12101,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-extendable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
"integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
"dependencies": {
"is-plain-object": "^2.0.4"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -11953,6 +12246,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-plain-object": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
"dependencies": {
"isobject": "^3.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -12132,6 +12436,14 @@
"dev": true,
"license": "ISC"
},
"node_modules/isobject": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -12200,7 +12512,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -12445,6 +12756,44 @@
"dev": true,
"license": "MIT"
},
"node_modules/leva": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz",
"integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==",
"dependencies": {
"@radix-ui/react-portal": "^1.1.4",
"@radix-ui/react-tooltip": "^1.1.8",
"@stitches/react": "^1.2.8",
"@use-gesture/react": "^10.2.5",
"colord": "^2.9.2",
"dequal": "^2.0.2",
"merge-value": "^1.0.0",
"react-colorful": "^5.5.1",
"react-dropzone": "^12.0.0",
"v8n": "^1.3.3",
"zustand": "^3.6.9"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/leva/node_modules/zustand": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz",
"integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==",
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"react": ">=16.8"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -12708,19 +13057,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/liquid-glass-react": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/liquid-glass-react/-/liquid-glass-react-1.1.1.tgz",
"integrity": "sha512-pKzaktaMAEztd93wpWcz2Z5Z9qdLJUNJdMX+n00Ca4XsnrLTQ5xJzm/+GQXZUeuFXe/PQ8ziVMZO6531PyaFJw==",
"license": "MIT",
"workspaces": [
"liquid-glass"
],
"peerDependencies": {
"react": ">=19",
"react-dom": ">=19"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -12771,7 +13107,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -13169,6 +13504,20 @@
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"node_modules/merge-value": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/merge-value/-/merge-value-1.0.0.tgz",
"integrity": "sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ==",
"dependencies": {
"get-value": "^2.0.6",
"is-extendable": "^1.0.0",
"mixin-deep": "^1.2.0",
"set-value": "^2.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/mermaid": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
@@ -13868,6 +14217,18 @@
"node": ">= 18"
}
},
"node_modules/mixin-deep": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz",
"integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==",
"dependencies": {
"for-in": "^1.0.2",
"is-extendable": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
@@ -13894,6 +14255,44 @@
"ufo": "^1.6.3"
}
},
"node_modules/motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
"integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
"dependencies": {
"framer-motion": "^12.38.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"dependencies": {
"motion-utils": "^12.36.0"
}
},
"node_modules/motion-utils": {
"version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -14111,7 +14510,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -14375,6 +14773,11 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -14556,6 +14959,18 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -14650,7 +15065,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -14662,7 +15076,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/proper-lockfile": {
@@ -14889,6 +15302,15 @@
"react-dom": ">= 16.14"
}
},
"node_modules/react-colorful": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.7.0.tgz",
"integrity": "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-dnd": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
@@ -14938,6 +15360,22 @@
"react": "^19.2.5"
}
},
"node_modules/react-dropzone": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-12.1.0.tgz",
"integrity": "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog==",
"dependencies": {
"attr-accept": "^2.2.2",
"file-selector": "^0.5.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -15589,6 +16027,27 @@
"truncate-utf8-bytes": "^1.0.0"
}
},
"node_modules/sanitize-html": {
"version": "2.17.3",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz",
"integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^10.1.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/sanitize-html/node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
@@ -15727,6 +16186,28 @@
"node": ">= 0.4"
}
},
"node_modules/set-value": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
"integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
"dependencies": {
"extend-shallow": "^2.0.1",
"is-extendable": "^0.1.1",
"is-plain-object": "^2.0.3",
"split-string": "^3.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/set-value/node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -15953,6 +16434,29 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/split-string": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
"integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==",
"dependencies": {
"extend-shallow": "^3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split-string/node_modules/extend-shallow": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
"integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==",
"dependencies": {
"assign-symbols": "^1.0.0",
"is-extendable": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@@ -16569,6 +17073,14 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
"funding": {
"url": "https://github.com/sponsors/Wombosvideo"
}
},
"node_modules/tw-shimmer": {
"version": "0.4.11",
"resolved": "https://registry.npmjs.org/tw-shimmer/-/tw-shimmer-0.4.11.tgz",
@@ -17012,6 +17524,11 @@
"dev": true,
"license": "(WTFPL OR MIT)"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
@@ -17025,6 +17542,11 @@
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/v8n": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/v8n/-/v8n-1.5.1.tgz",
"integrity": "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A=="
},
"node_modules/verror": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
+2 -3
View File
@@ -31,7 +31,7 @@
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -47,7 +47,7 @@
"@chenglou/pretext": "^0.0.6",
"@hermes/shared": "file:../shared",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.12.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
"@streamdown/code": "^1.1.1",
"@tabler/icons-react": "^3.41.1",
@@ -59,7 +59,6 @@
"cmdk": "^1.1.1",
"ignore": "^7.0.5",
"leva": "^0.10.1",
"liquid-glass-react": "^1.1.1",
"lucide-react": "^0.577.0",
"motion": "^12.38.0",
"nanostores": "^1.3.0",
@@ -49,6 +49,7 @@ describe('collectArtifactsForSession', () => {
timestamp: 3000
}
]
const artifacts = collectArtifactsForSession(makeSession({ id: 'session-2' }), messages)
expect(artifacts).toHaveLength(1)
+1 -4
View File
@@ -859,10 +859,7 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
return (
<div className="group/location flex min-w-0 items-center gap-1.5">
<div
className={cn(
'min-w-0 flex-1 truncate text-xs text-muted-foreground/85',
isLink ? 'font-medium' : 'font-mono'
)}
className={cn('min-w-0 flex-1 truncate text-xs text-muted-foreground/85', isLink ? 'font-medium' : 'font-mono')}
title={artifact.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 { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import LiquidGlass from 'liquid-glass-react'
import {
type ClipboardEvent,
type FormEvent,
@@ -20,7 +17,7 @@ import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { chatMessageText } from '@/lib/chat-messages'
import { contextPath } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments, $composerDraft } from '@/store/composer'
@@ -45,117 +42,16 @@ import {
RICH_INPUT_SLOT
} from './rich-editor'
import { SkinSlashPopover } from './skin-slash-popover'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
import { UrlDialog } from './url-dialog'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
const blobs: Blob[] = []
const seen = new Set<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_GLASS = {
fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))',
liquidKey: ['standard', '0.950', '0.072', '0', '46', '0.00', '128'].join(':'),
showLibraryRims: false,
liquid: {
aberrationIntensity: 0.95,
blurAmount: 0.072,
cornerRadius: 0,
displacementScale: 46,
elasticity: 0,
mode: 'standard' as const,
saturation: 128
}
}
interface TriggerState {
kind: '@' | '/'
query: string
tokenLength: number
}
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */
function textBeforeCaret(editor: HTMLDivElement): string | null {
const sel = window.getSelection()
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {
return null
}
const before = range.cloneRange()
before.selectNodeContents(editor)
before.setEnd(range.startContainer, range.startOffset)
return before.toString()
}
function detectTrigger(textBefore: string): TriggerState | null {
const match = TRIGGER_RE.exec(textBefore)
if (!match) {
return null
}
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
}
const COMPOSER_FADE_BACKGROUND =
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
export function ChatBar({
busy,
@@ -186,7 +82,6 @@ export function ChatBar({
const composerRef = useRef<HTMLFormElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<HTMLDivElement | null>(null)
const glassShellRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef(draft)
const urlInputRef = useRef<HTMLInputElement | null>(null)
@@ -931,38 +826,9 @@ export function ChatBar({
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
<div
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={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
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',
@@ -983,9 +849,7 @@ export function ChatBar({
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
'group-focus-within/composer:bg-[var(--dt-card)]',
'group-focus-within/composer:[backdrop-filter:none]',
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
)}
/>
{dragActive && (
@@ -1057,9 +921,7 @@ export function ChatBarFallback() {
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
'group-focus-within/composer:bg-[var(--dt-card)]',
'group-focus-within/composer:[backdrop-filter:none]',
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
)}
/>
</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,
AssistantRuntimeProvider,
ExportedMessageRepository,
type ThreadMessage,
useExternalStoreRuntime
type ThreadMessage
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
@@ -18,6 +17,7 @@ import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
import { ChevronDown } from '@/lib/icons'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { cn } from '@/lib/utils'
import { $pinnedSessionIds } from '@/store/layout'
import {
@@ -70,6 +70,55 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
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({
className,
gateway,
@@ -107,13 +156,9 @@ export function ChatView({
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
const messages = useStore($messages)
const pinnedSessionIds = useStore($pinnedSessionIds)
const selectedSessionId = useStore($selectedStoredSessionId)
const sessions = useStore($sessions)
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
const showIntro =
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
@@ -127,7 +172,6 @@ export function ChatView({
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages))
const showChatBar = !loadingSession
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
queryKey: ['model-options', activeSessionId || 'global'],
@@ -207,7 +251,7 @@ export function ChatView({
return ExportedMessageRepository.fromBranchableArray(items, { headId })
}, [messages])
const runtime = useExternalStoreRuntime<ThreadMessage>({
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: runtimeMessageRepository,
isRunning: busy,
setMessages: onThreadMessagesChange,
@@ -227,30 +271,13 @@ export function ChatView({
className
)}
>
<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>
<ChatHeader
activeSessionId={activeSessionId}
isRoutedSessionView={isRoutedSessionView}
onDeleteSelectedSession={onDeleteSelectedSession}
onToggleSelectedPin={onToggleSelectedPin}
selectedSessionId={selectedSessionId}
/>
<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 type {
ComponentProps,
CSSProperties,
MutableRefObject,
DragEvent as ReactDragEvent,
MouseEvent as ReactMouseEvent,
ReactNode,
PointerEvent as ReactPointerEvent,
RefObject
} from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
import type { PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { CopyButton } from '@/components/ui/copy-button'
import { Bug, PanelBottom, RefreshCw, Send, Trash2, X } from '@/lib/icons'
import { Bug, RefreshCw, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $composerDraft, setComposerDraft } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview'
import { type ConsoleEntry, createPreviewConsoleState, type PreviewConsoleState } from './preview-console-state'
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
import {
clampConsoleHeight,
compactUrl,
formatLogLine,
isNearConsoleBottom,
PreviewConsolePanel,
PreviewConsoleTitlebarIcon
} from './preview-console'
import { type ConsoleEntry, createPreviewConsoleState } from './preview-console-state'
import { LocalFilePreview, PreviewEmptyState } from './preview-file'
type PreviewWebview = HTMLElement & {
closeDevTools?: () => void
@@ -50,62 +43,8 @@ interface PreviewLoadErrorState {
url: string
}
const consoleLevelLabel: Record<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 SERVER_RESTART_TIMEOUT_MS = 45_000
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
function compactUrl(value: string): string {
try {
const url = new URL(value)
if (url.protocol === 'file:') {
return decodeURIComponent(url.pathname)
}
return `${url.host}${url.pathname}${url.search}`
} catch {
return value
}
}
function formatLogLine(log: ConsoleEntry): string {
const head = `[${consoleLevelLabel[log.level] || 'log'}]`
const tail = log.source ? ` (${compactUrl(log.source)}${log.line ? `:${log.line}` : ''})` : ''
return `${head} ${log.message}${tail}`.trim()
}
function formatConsoleEntries(entries: ConsoleEntry[]): string {
return entries.map(formatLogLine).join('\n')
}
function isNearConsoleBottom(element: HTMLDivElement | null): boolean {
if (!element) {
return true
}
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD
}
function clampConsoleHeight(value: number): number {
return Math.max(value, CONSOLE_HEADER_HEIGHT)
}
function loadErrorTitle(error: PreviewLoadErrorState): string {
const description = error.description.toLowerCase()
@@ -127,176 +66,6 @@ function isModuleMimeError(message: string): boolean {
return lower.includes('failed to load module script') && lower.includes('mime type')
}
interface ConsoleRowProps {
copyText: string
log: ConsoleEntry
onSend: () => void
onToggleSelect: () => void
selected: boolean
}
function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) {
return (
<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({
consoleHeight = 0,
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'
export function PreviewPane({
+3 -1
View File
@@ -1,5 +1,7 @@
import type { ChatMessage } from '@/lib/chat-messages'
export type ThreadLoadingState = 'response' | 'session'
export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean {
const lastVisible = [...messages].reverse().find(message => !message.hidden)
@@ -11,7 +13,7 @@ export function threadLoadingState(
busy: boolean,
awaitingResponse: boolean,
lastVisibleIsUser: boolean
) {
): ThreadLoadingState | undefined {
if (loadingSession) {
return 'session'
}
@@ -89,7 +89,12 @@ const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New chat', detail: 'Start a fresh session' },
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
{ id: 'nav-messaging', route: MESSAGING_ROUTE, title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' },
{
id: 'nav-messaging',
route: MESSAGING_ROUTE,
title: 'Messaging',
detail: 'Set up Telegram, Slack, Discord, and more'
},
{ id: 'nav-artifacts', route: ARTIFACTS_ROUTE, title: 'Artifacts', detail: 'Browse generated outputs' }
]
@@ -10,6 +10,7 @@ import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { ProjectTree } from './tree'
import { useProjectTree } from './use-project-tree'
@@ -22,8 +22,11 @@ export function useRouteEnumParam<T extends string>(
(next: T) => {
const params = new URLSearchParams(search)
if (next === fallback) {params.delete(key)}
else {params.set(key, next)}
if (next === fallback) {
params.delete(key)
} else {
params.set(key, next)
}
const qs = params.toString()
navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true })
+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')
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
if (!enabled) {return 'muted'}
if (!enabled) {
return 'muted'
}
if (state === 'connected') {return 'good'}
if (state === 'connected') {
return 'good'
}
if (state === 'fatal' || state === 'startup_failed') {return 'bad'}
if (state === 'fatal' || state === 'startup_failed') {
return 'bad'
}
return 'warn'
}
@@ -511,9 +517,7 @@ function PlatformDetail({
<section>
<SectionTitle>Get your credentials</SectionTitle>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{introCopy(platform)}
</p>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{introCopy(platform)}</p>
<div className="mt-3">
<Button asChild size="sm" variant="outline">
<a href={platform.docs_url} rel="noreferrer" target="_blank">
@@ -572,9 +576,7 @@ function PlatformDetail({
type="button"
>
<span>Advanced ({hiddenCount})</span>
<ChevronDown
className={cn('size-3.5 transition-transform', !showAdvanced && '-rotate-90')}
/>
<ChevronDown className={cn('size-3.5 transition-transform', !showAdvanced && '-rotate-90')} />
</button>
{showAdvanced && (
<div className="mt-3 space-y-4">
@@ -632,7 +634,8 @@ const PLATFORM_INTRO: Record<string, string> = {
mattermost:
'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.',
matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.',
signal: 'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.',
signal:
'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.',
whatsapp:
'Start the WhatsApp bridge that ships with Hermes, scan the QR code on first run, then enable the platform.',
bluebubbles:
@@ -642,8 +645,7 @@ const PLATFORM_INTRO: Record<string, string> = {
email:
'Use a dedicated mailbox. For Gmail/Workspace, create an app password and use imap.gmail.com / smtp.gmail.com.',
sms: 'Get your Twilio Account SID and Auth Token from the Twilio console, plus a phone number that can send SMS.',
dingtalk:
'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.',
dingtalk: 'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.',
feishu:
'Create a Feishu / Lark app, configure the bot capability, and copy the App ID, App secret, and event encryption keys.',
wecom:
@@ -655,7 +657,8 @@ const PLATFORM_INTRO: Record<string, string> = {
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
api_server:
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',
webhook: 'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
webhook:
'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
}
const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description
@@ -717,16 +720,15 @@ function MessagingField({
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{children}</h4>
)
return <h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{children}</h4>
}
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
if (!platform.enabled || platform.state === 'connected') {return null}
if (!platform.enabled || platform.state === 'connected') {
return null
}
const hint =
HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
return hint ? <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 }) {
return (
<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}
</span>
@@ -65,7 +65,9 @@ const STREAM_DELTA_FLUSH_MS = 16
// Anonymous progress events that carry todos but no name still belong to the
// todo stream; named todo events are obviously routed there too.
function toTodoPayload(payload: GatewayEventPayload | undefined): GatewayEventPayload | undefined {
if (!payload) {return undefined}
if (!payload) {
return undefined
}
const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos'))
return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined
@@ -561,7 +563,9 @@ export function useMessageStream({
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
if (!sessionId) {return}
if (!sessionId) {
return
}
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running')
} else if (event.type === 'tool.complete') {
@@ -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) {
const message = error instanceof Error ? error.message : String(error)
return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i.test(message)
return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i.test(
message
)
}
interface PromptActionsOptions {
@@ -197,25 +190,24 @@ export function usePromptActions({
async (rawText: string) => {
const visibleText = rawText.trim()
const attachments = $composerAttachments.get()
const contextRefs = attachments
.map(attachment => attachment.refText)
.map(a => a.refText)
.filter(Boolean)
.join('\n')
const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image')
const attachmentRefs = attachments.map(attachmentDisplayText).filter((ref): ref is string => Boolean(ref))
const hasImage = attachments.some(a => a.kind === 'image')
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
const text =
[contextRefs, visibleText].filter(Boolean).join('\n\n') ||
(hasImageAttachment ? 'What do you see in this image?' : '')
[contextRefs, visibleText].filter(Boolean).join('\n\n') || (hasImage ? 'What do you see in this image?' : '')
if (!text || busyRef.current) {
return
}
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
id: optimisticId,
role: 'user',
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
attachmentRefs
@@ -227,61 +219,80 @@ export function usePromptActions({
setAwaitingResponse(false)
}
// Idempotent optimistic insert — re-running with the resolved sessionId
// after createBackendSessionForSend just overwrites with the same id.
const seedOptimistic = (sid: string) =>
updateSessionState(
sid,
state => ({
...state,
messages: state.messages.some(m => m.id === optimisticId)
? state.messages
: [...state.messages, userMessage],
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false
}),
selectedStoredSessionIdRef.current
)
const dropOptimistic = (sid: null | string) => {
if (!sid) {
setMessages(current => current.filter(m => m.id !== optimisticId))
return
}
updateSessionState(
sid,
state => ({
...state,
messages: state.messages.filter(m => m.id !== optimisticId),
busy: false,
awaitingResponse: false,
pendingBranchGroup: null
}),
selectedStoredSessionIdRef.current
)
}
busyRef.current = true
setBusy(true)
setAwaitingResponse(true)
clearNotifications()
const [setup, runtime] = await Promise.all([
requestGateway<SetupStatus>('setup.status').catch(() => null),
requestGateway<RuntimeCheck>('setup.runtime_check').catch(() => null)
])
let sessionId: null | string = activeSessionId
const runtimeReady = runtime?.ok !== undefined ? Boolean(runtime?.ok) : setup?.provider_configured !== false
if (!runtimeReady) {
releaseBusy()
requestDesktopOnboarding(
runtime?.error || 'Add a provider credential before sending your first message.'
)
return
if (sessionId) {
seedOptimistic(sessionId)
} else {
setMessages(current => [...current, userMessage])
}
let sessionId = activeSessionId
if (!sessionId) {
try {
sessionId = await createBackendSessionForSend()
} catch (err) {
dropOptimistic(null)
releaseBusy()
notifyError(err, 'Session unavailable')
return
}
if (!sessionId) {
dropOptimistic(null)
releaseBusy()
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
return
}
seedOptimistic(sessionId)
}
if (!sessionId) {
releaseBusy()
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
return
}
updateSessionState(
sessionId,
state => ({
...state,
messages: [...state.messages, userMessage],
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false
}),
selectedStoredSessionIdRef.current
)
try {
await syncImageAttachmentsForSubmit(sessionId, attachments)
await requestGateway('prompt.submit', { session_id: sessionId, text })
@@ -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'
@@ -55,8 +55,17 @@ export function useRouteResume({
selectedStoredSessionIdRef,
startFreshSessionDraft
}: RouteResumeOptions) {
const lastPathnameRef = useRef<string | null>(null)
const wasGatewayOpenRef = useRef(false)
useEffect(() => {
if (currentView !== 'chat' || gatewayState !== 'open') {
const gatewayOpen = gatewayState === 'open'
const pathnameChanged = lastPathnameRef.current !== locationPathname
const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen
lastPathnameRef.current = locationPathname
wasGatewayOpenRef.current = gatewayOpen
if (currentView !== 'chat' || !gatewayOpen) {
return
}
@@ -68,7 +77,12 @@ export function useRouteResume({
Boolean(cachedRuntime) &&
cachedRuntime === activeSessionIdRef.current
if (!alreadyActive) {
// Resume only when the route meaningfully changed (or gateway just opened).
// This avoids a transient /:sid re-resume during "new chat" state clears
// before the pathname updates from /:sid -> /.
const shouldResume = pathnameChanged || gatewayBecameOpen
if (!alreadyActive && shouldResume && !creatingSessionRef.current) {
void resumeSession(routedSessionId, true)
}
@@ -46,7 +46,9 @@ function ModeCard({
<button
className={cn(
'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={disabled}
@@ -179,7 +181,12 @@ export function GatewaySettings() {
}
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 (
@@ -191,8 +198,8 @@ export function GatewaySettings() {
{state.envOverride ? <Pill tone="primary">env override</Pill> : null}
</div>
<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
control an already-running Hermes backend on another machine or behind a trusted proxy.
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
an already-running Hermes backend on another machine or behind a trusted proxy.
</p>
</div>
@@ -249,7 +256,9 @@ export function GatewaySettings() {
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'}
placeholder={
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
}
type="password"
value={remoteToken}
/>
@@ -262,7 +271,11 @@ export function GatewaySettings() {
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
<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}
Test remote
</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) {
throw new Error(`Unsafe config key: ${key}`)
}
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true,
configurable: true
})
}
+1
View File
@@ -60,6 +60,7 @@ export function AppShell({
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
const titlebarContentInset = sidebarOpen
? 0
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
@@ -123,8 +123,9 @@ export function useStatusbarItems({
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
const behind = updateStatus?.behind ?? 0
const applying = updateApply.applying || updateApply.stage === 'restart'
const base = appVersion ? `v${appVersion}` : sha ?? 'unknown'
const base = appVersion ? `v${appVersion}` : (sha ?? 'unknown')
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
const label = applying
? updateApply.stage === 'restart'
? `${base} · restart`
@@ -110,37 +110,37 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
: (item.menuItems ?? [])
.filter(menuItem => !menuItem.hidden)
.map(menuItem => (
<DropdownMenuItem
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
disabled={menuItem.disabled}
key={menuItem.id}
onSelect={() => {
if (menuItem.to) {
navigate(menuItem.to)
}
<DropdownMenuItem
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
disabled={menuItem.disabled}
key={menuItem.id}
onSelect={() => {
if (menuItem.to) {
navigate(menuItem.to)
}
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.onSelect?.()
}}
>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
</a>
) : (
<>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
</>
)}
</DropdownMenuItem>
))}
{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}
<span className="truncate">{menuItem.label}</span>
</a>
) : (
<>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
</>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
+1 -4
View File
@@ -379,10 +379,7 @@ function CategoryButton({
type="button"
>
<span
className={cn(
'underline-offset-4 decoration-current',
active ? 'font-medium underline' : 'hover:underline'
)}
className={cn('underline-offset-4 decoration-current', active ? 'font-medium underline' : 'hover:underline')}
>
{label}
</span>
+10 -7
View File
@@ -46,11 +46,9 @@ export function UpdatesOverlay() {
}, [checking, open, status])
const behind = status?.behind ?? 0
const phase: 'idle' | 'applying' | 'error' = apply.applying || apply.stage === 'restart'
? 'applying'
: apply.stage === 'error'
? 'error'
: 'idle'
const phase: 'idle' | 'applying' | 'error' =
apply.applying || apply.stage === 'restart' ? 'applying' : apply.stage === 'error' ? 'error' : 'idle'
const handleClose = (next: boolean) => {
if (phase === 'applying') {
@@ -114,7 +112,9 @@ function IdleView({
status: DesktopUpdateStatus | null
}) {
if (!status && checking) {
return <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) {
@@ -223,7 +223,9 @@ function IdleView({
</div>
{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>
)
@@ -231,6 +233,7 @@ function IdleView({
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
const label = STAGE_LABELS[apply.stage] ?? 'Updating Hermes…'
const percent =
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
? Math.max(2, Math.min(100, Math.round(apply.percent)))
+5 -2
View File
@@ -30,7 +30,10 @@ function binaryNoiseDataUrl(tile: number, density: number, size: number, color:
return ''
}
const dpr = Math.min(window.devicePixelRatio || 1, 2)
// Cap at 1.5x to match the design-language overlay perf work (PR #14):
// with `image-rendering: pixelated` there's no visible win above 1.5x, and
// a full retina (2x) PNG is ~78% larger to keep resident in compositor memory.
const dpr = Math.min(window.devicePixelRatio || 1, 1.5)
const physTile = Math.round(tile * dpr)
const block = Math.max(1, Math.round(size * dpr))
@@ -165,7 +168,7 @@ export function Backdrop() {
{import.meta.env.DEV && <ThemeControls />}
{statue.enabled && (
{statue.enabled && gpuTier > 0 && (
<div
aria-hidden
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', () => {
const first = render(
<TodoHarness
message={assistantTodoMessage([
{ content: 'Boil water', id: 'boil', status: 'pending' }
], false)}
/>
<TodoHarness message={assistantTodoMessage([{ content: 'Boil water', id: 'boil', status: 'pending' }], false)} />
)
const ui = within(first.container)
@@ -410,9 +406,7 @@ describe('assistant-ui streaming renderer', () => {
const second = render(
<TodoHarness
message={assistantTodoMessage([
{ content: 'Serve latte', id: 'serve', status: 'completed' }
], false)}
message={assistantTodoMessage([{ 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 { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
@@ -51,6 +51,7 @@ import {
XIcon
} from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { notifyError } from '@/store/notifications'
@@ -365,7 +366,8 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
return pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
}, [messageText])
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
const messageStatus = useAuiState(s => s.message.status?.type)
const isPlaceholder = messageStatus === 'running' && content.length === 0
if (isPlaceholder) {
return null
@@ -382,14 +384,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
data-slot="aui_assistant-message-content"
>
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
Reasoning: ReasoningTextPart,
ReasoningGroup: ReasoningAccordionGroup,
tools: { Fallback: ChainToolFallback }
}}
/>
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
{previewTargets.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{previewTargets.map(target => (
@@ -462,26 +457,70 @@ const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
// todo parts are hoisted to a dedicated panel above the message content.
if (props.toolName === 'todo') {return null}
if (props.toolName === 'todo') {
return null
}
if (props.toolName === 'image_generate') {return <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} />
}
const ThinkingDisclosure: FC<{
children: ReactNode
messageRunning?: boolean
pending?: boolean
timerKey?: string
}> = ({ children, pending = false, timerKey }) => {
const [open, setOpen] = useState(false)
}> = ({ children, messageRunning = false, pending = false, timerKey }) => {
// `null` = no explicit user toggle yet, defer to the streaming default.
// The default is "auto-open while streaming, auto-collapse when done" so
// reasoning surfaces a live preview without manual interaction. The first
// explicit toggle wins from then on.
const [userOpen, setUserOpen] = useState<boolean | null>(null)
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 (
<div className="text-sm text-muted-foreground" data-slot="aui_thinking-disclosure">
<DisclosureRow onToggle={() => setOpen(v => !v)} open={open}>
<div className="text-sm text-muted-foreground" data-slot="aui_thinking-disclosure" ref={enterRef}>
<DisclosureRow onToggle={() => setUserOpen(!open)} open={open}>
<span className="flex min-w-0 items-baseline gap-1.5">
<span
className={cn(
@@ -497,19 +536,48 @@ const ThinkingDisclosure: FC<{
</span>
</DisclosureRow>
{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">
{children}
<div
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>
)
}
const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({ children }) => {
const pending = useAuiState(s => s.thread.isRunning && s.message.status?.type === 'running')
// Self-gate "Thinking…" on this message's own reasoning parts. Reading
// `thread.isRunning` directly would flicker shimmer/timer on every old
// assistant whenever the external-store runtime clears+reimports its
// repository (one ref-identity bump per streaming delta).
const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({
children,
endIndex,
startIndex
}) => {
const messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
return <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 }) => {
@@ -528,6 +596,21 @@ const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ te
)
}
// Module-level constant so the `components` prop on `MessagePrimitive.Parts`
// has a stable identity across renders. Without this every AssistantMessage
// render would create a fresh `components` object, invalidating the memo on
// `MessagePrimitivePartByIndex` and forcing every tool/reasoning child to
// re-render on every streaming delta. Memo invalidation alone doesn't
// remount, but combined with the previous ToolFallback group-swap it was a
// big chunk of the per-delta work.
const MESSAGE_PARTS_COMPONENTS = {
Reasoning: ReasoningTextPart,
ReasoningGroup: ReasoningAccordionGroup,
Text: MarkdownText,
ToolGroup: ToolGroupSlot,
tools: { Fallback: ChainToolFallback }
} as const
const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
const SHORT_FMT = new Intl.DateTimeFormat(undefined, {
@@ -6,28 +6,36 @@ import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos'
import { cn } from '@/lib/utils'
export function todosFromMessageContent(content: unknown): TodoItem[] {
if (!Array.isArray(content)) {return []}
if (!Array.isArray(content)) {
return []
}
let latest: null | TodoItem[] = null
for (const part of content) {
if (!part || typeof part !== 'object') {continue}
if (!part || typeof part !== 'object') {
continue
}
const row = part as Record<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)
if (parsed !== null) {latest = parsed}
if (parsed !== null) {
latest = parsed
}
}
return latest ?? []
}
const headerLabel = (todos: readonly TodoItem[]): string =>
todos.find(t => t.status === 'in_progress')?.content
?? todos.find(t => t.status === 'pending')?.content
?? todos.at(-1)?.content
?? 'Tasks'
todos.find(t => t.status === 'in_progress')?.content ??
todos.find(t => t.status === 'pending')?.content ??
todos.at(-1)?.content ??
'Tasks'
const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => {
if (status === 'in_progress') {
@@ -49,7 +57,8 @@ const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label })
checked={checked}
className={cn(
'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100',
checked && 'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
checked &&
'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
status === 'cancelled' && 'border-muted-foreground/40'
)}
disabled
@@ -58,7 +67,9 @@ const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label })
}
export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
if (!todos.length) {return null}
if (!todos.length) {
return null
}
const label = headerLabel(todos)
return (
@@ -67,7 +78,10 @@ export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
data-slot="aui_todo-hoisted"
>
<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}
</span>
</header>
@@ -83,9 +97,7 @@ export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
key={todo.id}
>
<Checkmark label={todo.content} status={todo.status} />
<span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">
{todo.content}
</span>
<span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">{todo.content}</span>
</li>
))}
</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>()
function startedAt(key?: string): number {
if (!key) {return Date.now()}
if (!key) {
return Date.now()
}
const existing = startedAtByKey.get(key)
if (existing !== undefined) {return existing}
if (existing !== undefined) {
return existing
}
const now = Date.now()
startedAtByKey.set(key, now)
@@ -17,7 +21,9 @@ function startedAt(key?: string): number {
}
export function formatElapsed(seconds: number): string {
if (seconds < 60) {return `${seconds}s`}
if (seconds < 60) {
return `${seconds}s`
}
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
}
@@ -33,9 +39,13 @@ export function useElapsedSeconds(active = true, timerKey?: string): number {
}
useEffect(() => {
if (!active) {return}
if (!active) {
return
}
if (timerKey) {start.current = startedAt(timerKey)}
if (timerKey) {
start.current = startedAt(timerKey)
}
const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
tick()
@@ -4,14 +4,16 @@ import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
// Shared header row for any collapsible block (thinking, tool group, single
// tool). Owns the grid indent (chevron column = --message-text-indent), the
// hover surface, and the trailing-slot anchor used for copy buttons / running
// timers. Each parent supplies its own outer wrapper (with the data-slot CSS
// tool). Each parent supplies its own outer wrapper (with the data-slot CSS
// uses to escape the message padding) and its own expanded body.
//
// Passing `onToggle` makes the row expandable (chevron + hover + click).
// Omitting it renders a static row that still reserves the chevron column so
// nested rows stay vertically aligned with their group header.
// Cursor-style affordance:
// - No leading chevron; a caret appears to the RIGHT of the text on hover
// (and stays visible when the row is open).
// - The hover background is a tight content-shaped pill — sized to the
// title text, NOT the full row — and reaches just past the chevron with
// `-mx-1.5 px-1.5` so it reads as a soft hit-target rather than a slab
// stretching to the message edge.
export function DisclosureRow({
children,
onToggle,
@@ -24,36 +26,44 @@ export function DisclosureRow({
trailing?: ReactNode
}) {
return (
<div
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'
)}
>
<div className="group/disclosure-row relative flex w-full max-w-full min-w-0 text-muted-foreground">
<button
aria-expanded={onToggle ? open : undefined}
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',
onToggle ? 'cursor-pointer' : 'cursor-default'
// max-w-fit so the click target hugs the title text width — no
// 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}
onClick={onToggle}
type="button"
>
<span className="flex h-[1.1rem] items-center justify-center">
{onToggle ? (
<span className="flex min-w-0 flex-col">{children}</span>
{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
aria-hidden
className={cn(
'size-3 text-midground/55 transition-transform group-hover/disclosure-row:text-midground',
open && 'rotate-90'
)}
className={cn('size-3.5 transition-transform duration-150', open && 'rotate-90')}
// currentColor + a chunkier stroke so the caret reads as a
// confident hover affordance instead of a hairline.
color="currentColor"
strokeWidth={2.75}
/>
) : (
<span aria-hidden className="size-3" />
)}
</span>
<span className="min-w-0">{children}</span>
</span>
)}
</button>
{trailing && <span className="absolute right-1 top-0.5 flex h-[1.1rem] items-center">{trailing}</span>}
</div>
@@ -134,6 +134,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
}
const { flow } = onboarding
const reason = onboarding.reason?.trim() || null
const ready = enabled && onboarding.configured === false
const showPicker = flow.status === 'idle' || flow.status === 'success'
@@ -142,21 +143,22 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl">
<Header />
<div className="grid gap-5 p-6">
{ready ? (
showPicker ? (
<Picker ctx={ctx} />
) : (
<FlowPanel ctx={ctx} flow={flow} />
)
) : (
<Preparing boot={boot} />
)}
{reason ? <ReasonNotice reason={reason} /> : null}
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
</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 }) {
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
const hasError = Boolean(boot.error)
@@ -445,7 +447,9 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
in":
</p>
<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 />
<Button onClick={() => void recheckExternalSignin(ctx)}>
<Check className="size-4" />
+5 -1
View File
@@ -17,6 +17,10 @@ interface StatusDotProps extends ComponentProps<'span'> {
export function StatusDot({ className, tone, ...props }: StatusDotProps) {
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
method?: string
body?: unknown
timeoutMs?: number
}
export interface HermesNotification {
+4 -6
View File
@@ -31,6 +31,8 @@ import type {
ToolsetInfo
} from '@/types/hermes'
const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000
export type {
ActionResponse,
ActionStatusResponse,
@@ -79,7 +81,7 @@ export class HermesGateway extends JsonRpcGatewayClient {
connectErrorMessage: 'Could not connect to Hermes gateway',
createRequestId: nextId => nextId,
notConnectedErrorMessage: 'Hermes gateway is not connected',
requestTimeoutMs: 0
requestTimeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS
})
}
}
@@ -242,11 +244,7 @@ export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse>
})
}
export function submitOAuthCode(
providerId: string,
sessionId: string,
code: string
): Promise<OAuthSubmitResponse> {
export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise<OAuthSubmitResponse> {
return window.hermesDesktop.api<OAuthSubmitResponse>({
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
method: 'POST',
+313 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { ChatMessagePart } from './chat-messages'
import {
appendAssistantTextPart,
chatMessageText,
@@ -218,10 +219,321 @@ describe('upsertToolPart', () => {
'complete'
)
const completedResult = completed[0] && 'result' in completed[0] ? (completed[0].result as Record<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>) : {}
expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }])
expect(clearedResult.todos).toEqual([])
})
it('keeps parallel same-name tools distinct without explicit ids', () => {
const startedTokyo = upsertToolPart(
[],
{
context: 'tokyo weather',
name: 'web_search'
},
'running'
)
const startedReykjavik = upsertToolPart(
startedTokyo,
{
context: 'reykjavik weather',
name: 'web_search'
},
'running'
)
const completedTokyo = upsertToolPart(
startedReykjavik,
{
context: 'tokyo weather',
message: 'tokyo done',
name: 'web_search',
summary: 'Did 5 searches'
},
'complete'
)
const completedBoth = upsertToolPart(
completedTokyo,
{
context: 'reykjavik weather',
message: 'reykjavik done',
name: 'web_search',
summary: 'Did 5 searches'
},
'complete'
)
const webParts = completedBoth.filter(
(part): part is Extract<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
status?: string
message?: string
id?: string
name?: string
tool_id?: string
tool_call_id?: string
args?: unknown
arguments?: unknown
context?: string
input?: unknown
preview?: string
result?: unknown
summary?: string
error?: string | boolean
inline_diff?: string
@@ -209,7 +215,135 @@ export function hasToolPart(message: ChatMessage): boolean {
}
function toolId(payload: GatewayEventPayload | undefined): string {
return payload?.tool_id || payload?.name || `tool-${Date.now()}`
return payload?.tool_id || payload?.tool_call_id || payload?.id || ''
}
let liveToolCounter = 0
function nextLiveToolId(name: string): string {
liveToolCounter += 1
return `live-tool:${name}:${liveToolCounter}`
}
function firstStringField(record: Record<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
@@ -221,27 +355,43 @@ function carryTodos(payload: GatewayEventPayload | undefined, ...prev: unknown[]
return next === null ? undefined : { todos: next }
}
if (payload?.name !== 'todo') {return undefined}
if (payload?.name !== 'todo') {
return undefined
}
for (const p of prev) {
const carried = parseTodos(recordFromUnknown(p)?.todos)
if (carried !== null) {return { todos: carried }}
if (carried !== null) {
return { todos: carried }
}
}
return undefined
}
function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown): Record<string, unknown> {
const prev = parseMaybeJsonObject(prevArgs)
const eventArgs = liveToolArgs(payload)
return {
...prev,
...eventArgs,
...(payload?.context ? { context: payload.context } : {}),
...(payload?.preview ? { preview: payload.preview } : {}),
...carryTodos(payload, prevArgs)
}
}
function toolResult(payload: GatewayEventPayload | undefined, prevResult?: unknown, prevArgs?: unknown): Record<string, unknown> {
function toolResult(
payload: GatewayEventPayload | undefined,
prevResult?: unknown,
prevArgs?: unknown
): Record<string, unknown> {
const parsedResult = parseMaybeJsonObject(payload?.result)
return {
...parsedResult,
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
...(payload?.summary ? { summary: payload.summary } : {}),
...(payload?.message ? { message: payload.message } : {}),
@@ -257,19 +407,22 @@ export function upsertToolPart(
payload: GatewayEventPayload | undefined,
phase: 'running' | 'complete'
): ChatMessagePart[] {
const id = toolId(payload)
const stableId = toolId(payload)
const name = payload?.name || 'tool'
const next = [...parts]
const index = next.findIndex(
part => part.type === 'tool-call' && ((part.toolCallId && part.toolCallId === id) || part.toolName === name)
)
const index = findToolPartIndex(next, name, stableId, payload, phase)
const prev = index >= 0 ? next[index] : null
const prevArgs = prev && 'args' in prev ? prev.args : undefined
const prevResult = prev && 'result' in prev ? prev.result : undefined
const args = toolArgs(payload, prevArgs)
const id =
stableId ||
(prev && 'toolCallId' in prev && typeof prev.toolCallId === 'string' ? prev.toolCallId : '') ||
nextLiveToolId(name)
const base = {
type: 'tool-call' as const,
toolCallId: id,
@@ -279,7 +432,9 @@ export function upsertToolPart(
...(phase === 'complete' && { result: toolResult(payload, prevResult, prevArgs), isError: Boolean(payload?.error) })
} satisfies ChatMessagePart
if (index === -1) {return [...next, base]}
if (index === -1) {
return [...next, base]
}
next[index] = { ...next[index], ...base }
return next
@@ -319,6 +474,28 @@ function firstNonEmptyObject(...values: unknown[]): Record<string, unknown> {
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 {
if (content && typeof content === 'object') {
return content
+4 -1
View File
@@ -101,7 +101,10 @@ export function parseCommitHeader(raw: string): ParsedCommit {
}
function tidySubject(subject: string): string {
const cleaned = subject.replace(/\s+/g, ' ').replace(/[.;,\s]+$/, '').trim()
const cleaned = subject
.replace(/\s+/g, ' ')
.replace(/[.;,\s]+$/, '')
.trim()
if (!cleaned) {
return cleaned
+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')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details'
const url =
'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details'
const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)])
@@ -95,11 +96,7 @@ describe('external link helpers', () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
render(
<ExternalLink href="https://example.com/path/to/resource">
Example link
</ExternalLink>
)
render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
fireEvent.click(screen.getByRole('link', { name: 'Example link' }))
expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource')
@@ -108,11 +105,7 @@ describe('external link helpers', () => {
it('shows a trailing external-link icon', () => {
installDesktopBridge()
render(
<ExternalLink href="https://example.com/path/to/resource">
Example link
</ExternalLink>
)
render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
const link = screen.getByRole('link', { name: 'Example link' })
expect(link.querySelector('svg')).toBeTruthy()
@@ -125,9 +118,7 @@ describe('external link helpers', () => {
const url =
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
render(
<LinkifiedText text={`Read ${url}`} />
)
render(<LinkifiedText text={`Read ${url}`} />)
const link = screen.getByTitle(url)
expect(link.textContent).toContain('From Fajardo Full Day Cordillera Islands Catamaran Tour')
@@ -152,7 +143,8 @@ describe('external link helpers', () => {
it('ignores error-like fetched titles and falls back to slug label', async () => {
const bridge = vi.fn().mockResolvedValue('GetYourGuide Error')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
const url = 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
const url =
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
render(<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" />)
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)} />
}
export function ExternalLink({ children, className, href, onClick, showExternalIcon = true, ...rest }: ExternalLinkProps) {
export function ExternalLink({
children,
className,
href,
onClick,
showExternalIcon = true,
...rest
}: ExternalLinkProps) {
const target = normalizeExternalUrl(href)
return (
@@ -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 {
const lines = body.split('\n')
// Original regex required `\n` immediately before the close fence, so the
// first line of `body` (which has no preceding newline within `body`)
// cannot itself be the close fence.
@@ -35,8 +36,15 @@ function hasCloseFenceLine(body: string, marker: string): boolean {
const line = lines[i]
let lo = 0
let hi = line.length
while (lo < hi && (line[lo] === ' ' || line[lo] === '\t')) lo += 1
while (hi > lo && (line[hi - 1] === ' ' || line[hi - 1] === '\t')) hi -= 1
while (lo < hi && (line[lo] === ' ' || line[lo] === '\t')) {
lo += 1
}
while (hi > lo && (line[hi - 1] === ' ' || line[hi - 1] === '\t')) {
hi -= 1
}
if (line.slice(lo, hi) === marker) {
return true
}
@@ -122,7 +130,9 @@ function normalizeVisibleProse(text: string): string {
.map(part =>
part.startsWith('`')
? part
: autoLinkRawUrls(part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, ''))
: autoLinkRawUrls(
part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '')
)
)
.join('')
}
@@ -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[] {
return value.flatMap(item => {
if (!isRecord(item) || !isStatus(item.status)) {return []}
if (!isRecord(item) || !isStatus(item.status)) {
return []
}
const id = String(item.id ?? '').trim()
const content = String(item.content ?? '').trim()
@@ -22,15 +24,25 @@ function parseArray(value: unknown[]): TodoItem[] {
}
function parse(value: unknown, depth: number): null | TodoItem[] {
if (depth > 2) {return null}
if (Array.isArray(value)) {return parseArray(value)}
if (typeof value === 'string' && value.trim()) {
try { return parse(JSON.parse(value), depth + 1) } catch { return null }
if (depth > 2) {
return null
}
if (isRecord(value) && Object.hasOwn(value, 'todos')) {return parse(value.todos, depth + 1)}
if (Array.isArray(value)) {
return parseArray(value)
}
if (typeof value === 'string' && value.trim()) {
try {
return parse(JSON.parse(value), depth + 1)
} catch {
return null
}
}
if (isRecord(value) && Object.hasOwn(value, 'todos')) {
return parse(value.todos, depth + 1)
}
return null
}
@@ -44,9 +44,7 @@ describe('formatToolResultSummary', () => {
details: `prefix ${'x'.repeat(500)}`
})
const detailsLine = summary
.split('\n')
.find(line => line.startsWith('- Details:'))
const detailsLine = summary.split('\n').find(line => line.startsWith('- Details:'))
expect(detailsLine).toBeTruthy()
expect(detailsLine?.length).toBeLessThan(230)
+177 -53
View File
@@ -2,7 +2,21 @@
// mode still gets the raw JSON section.
const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const
const PRIORITY_KEYS = ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'status', 'id', 'message', 'summary', 'description'] as const
const PRIORITY_KEYS = [
'title',
'name',
'path',
'file',
'filepath',
'url',
'href',
'link',
'status',
'id',
'message',
'summary',
'description'
] as const
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
@@ -14,17 +28,29 @@ const isRecord = (v: unknown): v is Json => Boolean(v && typeof v === 'object' &
function tryJson(value: string): unknown {
const t = value.trim()
if (!t) {return ''}
if (!t) {
return ''
}
if (!/^[{[]|^"/.test(t)) {return value}
if (!/^[{[]|^"/.test(t)) {
return value
}
try { return JSON.parse(t) } catch { return value }
try {
return JSON.parse(t)
} catch {
return value
}
}
const norm = (v: unknown): unknown => (typeof v === 'string' ? tryJson(v) : v)
const titleCase = (k: string) =>
k.split(/[_\-.]+/).filter(Boolean).map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`).join(' ')
k
.split(/[_\-.]+/)
.filter(Boolean)
.map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`)
.join(' ')
const pluralize = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
@@ -37,12 +63,16 @@ function clipInline(value: string, max = 180): string {
function clipBlock(value: string, maxChars = 1800, maxLines = 18): string {
const t = value.trim()
if (!t) {return ''}
if (!t) {
return ''
}
const lines = t.split('\n')
let text = lines.slice(0, maxLines).join('\n')
const clipped = lines.length > maxLines || text.length > maxChars
if (text.length > maxChars) {text = text.slice(0, maxChars - 1).trimEnd()}
if (text.length > maxChars) {
text = text.slice(0, maxChars - 1).trimEnd()
}
return clipped && !text.endsWith('…') ? `${text}` : text
}
@@ -51,7 +81,9 @@ function firstString(record: Json, keys: readonly string[]): string {
for (const k of keys) {
const v = record[k]
if (typeof v === 'string' && v.trim()) {return v.trim()}
if (typeof v === 'string' && v.trim()) {
return v.trim()
}
}
return ''
@@ -68,25 +100,37 @@ const isWrapperKey = (k: string) => (WRAPPER_KEYS as readonly string[]).includes
const skipField = (k: string, v: unknown) => isWrapperKey(k) || ((k === 'success' || k === 'ok') && v === true)
function summarizeScalar(v: unknown): string {
if (typeof v === 'string') {return clipInline(v)}
if (typeof v === 'string') {
return clipInline(v)
}
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
if (typeof v === 'number' || typeof v === 'boolean') {
return String(v)
}
return ''
}
function summarizeRecordInline(record: Json, depth: number): string {
if (depth > 3) {return pluralize(Object.keys(record).length, 'field')}
if (depth > 3) {
return pluralize(Object.keys(record).length, 'field')
}
const title = firstString(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'id'])
const status = firstString(record, ['status', 'category', 'type'])
const message = firstString(record, ['snippet', 'summary', 'description', 'message'])
if (title && status) {return `${clipInline(title, 110)} (${clipInline(status, 54)})`}
if (title && status) {
return `${clipInline(title, 110)} (${clipInline(status, 54)})`
}
if (title && message && title !== message) {return `${clipInline(title, 90)} - ${clipInline(message, 84)}`}
if (title && message && title !== message) {
return `${clipInline(title, 90)} - ${clipInline(message, 84)}`
}
if (title) {return clipInline(title, 150)}
if (title) {
return clipInline(title, 150)
}
const pairs = orderedKeys(Object.keys(record))
.filter(k => !skipField(k, record[k]))
@@ -104,15 +148,25 @@ function summarizeRecordInline(record: Json, depth: number): string {
function summarizeListItem(item: unknown, depth: number): string {
const v = norm(item)
if (typeof v === 'string') {return clipInline(v)}
if (typeof v === 'string') {
return clipInline(v)
}
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
if (typeof v === 'number' || typeof v === 'boolean') {
return String(v)
}
if (v == null) {return ''}
if (v == null) {
return ''
}
if (Array.isArray(v)) {return pluralize(v.length, 'item')}
if (Array.isArray(v)) {
return pluralize(v.length, 'item')
}
if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
if (isRecord(v)) {
return summarizeRecordInline(v, depth + 1)
}
return clipInline(String(v))
}
@@ -121,32 +175,50 @@ function formatFieldValue(value: unknown, depth: number): string {
const v = norm(value)
const scalar = summarizeScalar(v)
if (scalar) {return scalar}
if (scalar) {
return scalar
}
if (v == null) {return ''}
if (v == null) {
return ''
}
if (Array.isArray(v)) {
if (!v.length) {return '0 items'}
if (!v.length) {
return '0 items'
}
const scalars = v.map(summarizeScalar).filter(Boolean)
if (scalars.length === v.length && v.length <= 4) {return clipInline(scalars.join(', '))}
if (scalars.length === v.length && v.length <= 4) {
return clipInline(scalars.join(', '))
}
const first = summarizeListItem(v[0], depth + 1)
return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item')
}
if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
if (isRecord(v)) {
return summarizeRecordInline(v, depth + 1)
}
return clipInline(String(v))
}
function formatArraySummary(value: unknown[], depth: number): string {
if (!value.length) {return 'No items returned.'}
if (!value.length) {
return 'No items returned.'
}
const max = 6
const lines = value.slice(0, max).map(item => summarizeListItem(item, depth + 1)).filter(Boolean).map(l => `- ${l}`)
const lines = value
.slice(0, max)
.map(item => summarizeListItem(item, depth + 1))
.filter(Boolean)
.map(l => `- ${l}`)
if (!lines.length) {return `Returned ${pluralize(value.length, 'item')}.`}
if (!lines.length) {
return `Returned ${pluralize(value.length, 'item')}.`
}
if (value.length > max) {
const remaining = value.length - max
@@ -159,13 +231,17 @@ function formatArraySummary(value: unknown[], depth: number): string {
function formatRecordSummary(record: Json, depth: number): string {
const keys = Object.keys(record)
if (!keys.length) {return 'Returned an empty object.'}
if (!keys.length) {
return 'Returned an empty object.'
}
if (depth <= 2) {
const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content'])
const meaningful = keys.filter(k => !skipField(k, record[k]) && !isWrapperKey(k))
if (direct && meaningful.length <= 1) {return clipBlock(direct)}
if (direct && meaningful.length <= 1) {
return clipBlock(direct)
}
}
const candidates = orderedKeys(keys).filter(k => !skipField(k, record[k]))
@@ -175,13 +251,19 @@ function formatRecordSummary(record: Json, depth: number): string {
for (const k of candidates) {
const v = formatFieldValue(record[k], depth + 1)
if (!v) {continue}
if (!v) {
continue
}
lines.push(`- ${titleCase(k)}: ${v}`)
if (lines.length >= max) {break}
if (lines.length >= max) {
break
}
}
if (!lines.length) {return `Returned object with ${pluralize(keys.length, 'field')}.`}
if (!lines.length) {
return `Returned object with ${pluralize(keys.length, 'field')}.`
}
if (candidates.length > lines.length) {
const remaining = candidates.length - lines.length
@@ -192,18 +274,30 @@ function formatRecordSummary(record: Json, depth: number): string {
}
function formatSummaryValue(value: unknown, depth: number): string {
if (depth > 4) {return ''}
if (depth > 4) {
return ''
}
const v = norm(value)
if (typeof v === 'string') {return clipBlock(v)}
if (typeof v === 'string') {
return clipBlock(v)
}
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
if (typeof v === 'number' || typeof v === 'boolean') {
return String(v)
}
if (v == null) {return ''}
if (v == null) {
return ''
}
if (Array.isArray(v)) {return formatArraySummary(v, depth + 1)}
if (Array.isArray(v)) {
return formatArraySummary(v, depth + 1)
}
if (isRecord(v)) {return formatRecordSummary(v, depth + 1)}
if (isRecord(v)) {
return formatRecordSummary(v, depth + 1)
}
return clipInline(String(v))
}
@@ -232,17 +326,29 @@ function unwrapPayload(value: unknown): unknown {
function hasMeaningfulErrorValue(value: unknown): boolean {
const v = norm(value)
if (v == null) {return false}
if (v == null) {
return false
}
if (typeof v === 'string') {return !NON_ERROR_TEXT.has(v.trim().toLowerCase())}
if (typeof v === 'string') {
return !NON_ERROR_TEXT.has(v.trim().toLowerCase())
}
if (typeof v === 'boolean') {return v}
if (typeof v === 'boolean') {
return v
}
if (typeof v === 'number') {return v !== 0}
if (typeof v === 'number') {
return v !== 0
}
if (Array.isArray(v)) {return v.some(hasMeaningfulErrorValue)}
if (Array.isArray(v)) {
return v.some(hasMeaningfulErrorValue)
}
if (isRecord(v)) {return Object.keys(v).length > 0}
if (isRecord(v)) {
return Object.keys(v).length > 0
}
return true
}
@@ -261,7 +367,9 @@ function hasErrorSignal(record: Json): boolean {
function valueErrorText(value: unknown): string {
const v = norm(value)
if (typeof v === 'string') {return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : ''}
if (typeof v === 'string') {
return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : ''
}
if (Array.isArray(v)) {
return clipBlock(v.map(valueErrorText).filter(Boolean).slice(0, 3).join('; '), 700, 12)
@@ -270,24 +378,32 @@ function valueErrorText(value: unknown): string {
if (isRecord(v)) {
const direct = firstString(v, ERROR_MSG_KEYS)
if (direct) {return clipBlock(direct, 700, 12)}
if (direct) {
return clipBlock(direct, 700, 12)
}
}
return ''
}
function findNestedError(value: unknown, depth: number, seen: Set<unknown>): string {
if (depth > 5) {return ''}
if (depth > 5) {
return ''
}
const v = norm(value)
if (!v || typeof v !== 'object' || seen.has(v)) {return ''}
if (!v || typeof v !== 'object' || seen.has(v)) {
return ''
}
seen.add(v)
if (Array.isArray(v)) {
for (const item of v) {
const nested = findNestedError(item, depth + 1, seen)
if (nested) {return nested}
if (nested) {
return nested
}
}
return ''
@@ -296,22 +412,30 @@ function findNestedError(value: unknown, depth: number, seen: Set<unknown>): str
const record = v as Json
for (const k of ERROR_KEYS) {
if (!hasMeaningfulErrorValue(record[k])) {continue}
if (!hasMeaningfulErrorValue(record[k])) {
continue
}
const text = valueErrorText(record[k])
if (text) {return text}
if (text) {
return text
}
}
if (hasErrorSignal(record)) {
const direct = firstString(record, ERROR_MSG_KEYS)
if (direct) {return clipBlock(direct, 700, 12)}
if (direct) {
return clipBlock(direct, 700, 12)
}
}
for (const k of [...ERROR_KEYS, ...WRAPPER_KEYS, 'details', 'meta']) {
const nested = findNestedError(record[k], depth + 1, seen)
if (nested) {return nested}
if (nested) {
return nested
}
}
return ''
+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()
const all = $notifications.get()
$notifications.set([])
for (const item of all) {
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,
submitOAuthCode
} from '@/hermes'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { notify, notifyError } from '@/store/notifications'
import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes'
@@ -46,6 +47,7 @@ export interface OnboardingContext {
const CONFIGURED_CACHE_KEY = 'hermes-desktop-onboarded-v1'
const POLL_MS = 2000
const COPY_FLASH_MS = 1500
const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.'
function readCachedConfigured(): boolean | null {
if (typeof window === 'undefined') {
@@ -87,6 +89,7 @@ const INITIAL: DesktopOnboardingState = {
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
let pollTimer: number | null = null
let providersRefreshPromise: null | Promise<void> = null
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> {
try {
return await ctx.requestGateway<T>(method)
} catch {
return fallback
}
}
async function checkRuntime(ctx: OnboardingContext) {
const [status, runtime] = await Promise.all([
safeReq<{ provider_configured?: boolean }>(ctx, 'setup.status', {}),
safeReq<{ error?: string; ok?: boolean }>(ctx, 'setup.runtime_check', { ok: false })
])
return runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured)
async function checkRuntime(ctx: OnboardingContext): Promise<RuntimeReadinessResult> {
return evaluateRuntimeReadiness(ctx.requestGateway, {
defaultReason: DEFAULT_ONBOARDING_REASON,
unknownReady: false
})
}
function notifyReady(provider: string) {
notify({ kind: 'success', title: 'Hermes is ready', message: `${provider} connected.` })
}
async function reloadAndConnect(ctx: OnboardingContext, providerName: string, onFail: () => void) {
async function reloadAndConnect(ctx: OnboardingContext, providerName: string, onFail: (reason: null | string) => void) {
await ctx.requestGateway('reload.env').catch(() => undefined)
const ok = await checkRuntime(ctx)
const runtime = await checkRuntime(ctx)
if (ok) {
if (runtime.ready) {
notifyReady(providerName)
completeDesktopOnboarding()
ctx.onCompleted?.()
} else {
onFail()
onFail(runtime.reason)
}
}
export function requestDesktopOnboarding(reason = 'No inference provider is configured.') {
patch({ reason, requested: true })
function providerResolutionFailure(reason: null | string) {
const detail = reason?.trim()
return detail
? `Connected, but Hermes still cannot resolve a usable provider. ${detail}`
: 'Connected, but Hermes still cannot resolve a usable provider.'
}
async function refreshProviders() {
if (providersRefreshPromise) {
await providersRefreshPromise
return
}
providersRefreshPromise = (async () => {
try {
const { providers } = await listOAuthProviders()
patch({ mode: providers.length > 0 ? 'oauth' : 'apikey', providers })
} catch {
patch({ mode: 'apikey', providers: [] })
} finally {
providersRefreshPromise = null
}
})()
await providersRefreshPromise
}
export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
patch({ reason: reason.trim() || DEFAULT_ONBOARDING_REASON, requested: true })
}
export function completeDesktopOnboarding() {
@@ -160,26 +182,26 @@ export function setOnboardingMode(mode: OnboardingMode) {
}
export async function refreshOnboarding(ctx: OnboardingContext) {
if (await checkRuntime(ctx)) {
const runtime = await checkRuntime(ctx)
if (runtime.ready) {
completeDesktopOnboarding()
ctx.onCompleted?.()
return true
}
writeCachedConfigured(false)
patch({ configured: false })
const state = $desktopOnboarding.get()
const reason = runtime.reason || state.reason || DEFAULT_ONBOARDING_REASON
if ($desktopOnboarding.get().providers !== null) {
writeCachedConfigured(false)
patch({ configured: false, reason })
if (state.providers !== null && !state.requested) {
return false
}
try {
const { providers } = await listOAuthProviders()
patch({ providers, mode: providers.length > 0 ? 'oauth' : 'apikey' })
} catch {
patch({ providers: [], mode: 'apikey' })
}
await refreshProviders()
return false
}
@@ -219,11 +241,11 @@ async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: Onbo
if (status === 'approved') {
clearPoll()
setFlow({ status: 'success', provider })
await reloadAndConnect(ctx, provider.name, () =>
await reloadAndConnect(ctx, provider.name, reason =>
setFlow({
status: 'error',
provider,
message: 'Connected, but Hermes still cannot resolve a usable provider.'
message: providerResolutionFailure(reason)
})
)
} else if (status !== 'pending') {
@@ -259,11 +281,11 @@ export async function submitOnboardingCode(ctx: OnboardingContext) {
if (resp.ok && resp.status === 'approved') {
setFlow({ status: 'success', provider })
await reloadAndConnect(ctx, provider.name, () =>
await reloadAndConnect(ctx, provider.name, reason =>
setFlow({
status: 'error',
provider,
message: 'Connected, but Hermes still cannot resolve a usable provider.'
message: providerResolutionFailure(reason)
})
)
} else {
@@ -338,11 +360,13 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
}
const { provider } = flow
await reloadAndConnect(ctx, provider.name, () =>
await reloadAndConnect(ctx, provider.name, reason =>
setFlow({
status: 'error',
provider,
message: `Hermes still cannot reach ${provider.name}. Run \`${provider.cli_command}\` in a terminal first.`
message:
reason?.trim() ||
`Hermes still cannot reach ${provider.name}. Run \`${provider.cli_command}\` in a terminal first.`
})
)
}
@@ -357,12 +381,19 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
try {
await setEnvVar(envKey, trimmed)
let stillFailing = false
await reloadAndConnect(ctx, label, () => {
let runtimeFailure: null | string = null
await reloadAndConnect(ctx, label, reason => {
stillFailing = true
runtimeFailure = reason
})
if (stillFailing) {
return { ok: false, message: `Saved, but Hermes still cannot reach ${label}. Double-check the value.` }
const failureDetail = (runtimeFailure ?? '').trim()
return {
ok: false,
message: failureDetail || `Saved, but Hermes still cannot reach ${label}. Double-check the value.`
}
}
return { ok: true }
+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'
@@ -14,6 +14,7 @@ export const $toolViewMode = atom<ToolViewMode>(
storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product'
)
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'))
$toolDisclosureStates.subscribe(persistToolDisclosureStates)
@@ -22,6 +23,17 @@ export function setToolViewMode(mode: ToolViewMode) {
$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 {
if (typeof window === 'undefined') {
return {}
+57 -3
View File
@@ -432,11 +432,13 @@ canvas {
padding-left: var(--message-text-indent);
}
/* Tool/thinking blocks now live at message-text alignment (no leading
chevron column to escape into), so their headers and bodies share a
common left edge with the model's text. */
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block'],
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'] {
margin-inline-start: calc(-1 * var(--message-text-indent));
width: calc(100% + var(--message-text-indent));
max-width: calc(100% + var(--message-text-indent));
width: 100%;
max-width: 100%;
}
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code {
@@ -525,3 +527,55 @@ canvas {
width: 0.875rem;
height: 0.875rem;
}
/* Streaming caret + comet tail. Anchored to the cursor (not the block's
right edge) so it reads on short last lines. Spread inflates the bg-color
veil so it overlays text instead of haloing the tiny dither block. */
[data-slot='aui_assistant-message-content']
[data-status='running']
> div
> *:last-child::after {
content: '' !important;
display: inline-block;
position: relative;
z-index: 2;
width: 0.6em;
height: 1em;
margin-left: 0.18em;
vertical-align: middle;
border-radius: 1.5px;
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 2px 2px;
color: color-mix(in srgb, var(--dt-foreground) 72%, transparent);
box-shadow:
-0.8ch 0 1.4ch 0.55em color-mix(in srgb, var(--dt-background) 80%, transparent),
-3ch 0 2.4ch 0.55em color-mix(in srgb, var(--dt-background) 64%, transparent),
-6.5ch 0 3.6ch 0.5em color-mix(in srgb, var(--dt-background) 44%, transparent),
-11ch 0 5ch 0.45em color-mix(in srgb, var(--dt-background) 28%, transparent),
-16ch 0 6.4ch 0.4em color-mix(in srgb, var(--dt-background) 16%, transparent),
-22ch 0 7.6ch 0.35em color-mix(in srgb, var(--dt-background) 8%, transparent),
0 0 0.4ch color-mix(in srgb, var(--dt-foreground) 28%, transparent);
animation: hermes-stream-caret 1.1s ease-in-out infinite;
}
@keyframes hermes-stream-caret {
0%, 100% { opacity: 0.95; }
50% { opacity: 0.45; }
}
@media (prefers-reduced-motion: reduce) {
[data-slot='aui_assistant-message-content']
[data-status='running']
> div
> *:last-child::after {
animation: none;
opacity: 0.8;
}
}
/* Live thinking preview window. Pairs with the ResizeObserver in
ThinkingDisclosure that pins scrollTop to the bottom older lines fade
into the top mask while the latest tokens settle in below. */
.thinking-preview {
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 28%, black 100%);
mask-image: linear-gradient(to bottom, transparent 0%, black 28%, black 100%);
}
+4 -4
View File
@@ -71,7 +71,7 @@
"@chenglou/pretext": "^0.0.6",
"@hermes/shared": "file:../shared",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.12.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
"@streamdown/code": "^1.1.1",
"@tabler/icons-react": "^3.41.1",
@@ -131,9 +131,9 @@
}
},
"apps/desktop/node_modules/@nous-research/ui": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.12.0.tgz",
"integrity": "sha512-OHv7z9J0r5Yp9iOl8uVUZAdIkTtDwqAL2RbgsQczXAhSPMOQle5X6ROWI4j6WK9riYhh4iCtRaCMsLcmSAuaXg==",
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.0.tgz",
"integrity": "sha512-c07lfMdEv/KL6lYC6mfap1CcmIPbvhCZu1supnFaIIrlUaab8gVNDYl8wMMjNRdYOVxxXKisU48yyfe5qvlwqg==",
"dependencies": {
"@nanostores/react": "^1.0.0",
"class-variance-authority": "^0.7.1",
+9 -3
View File
@@ -1591,12 +1591,12 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict):
_emit(
"tool.start",
sid,
{"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)},
{"tool_id": tool_call_id, "name": name, "args": args, "context": _tool_ctx(name, args)},
)
def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str):
payload = {"tool_id": tool_call_id, "name": name}
payload = {"tool_id": tool_call_id, "name": name, "args": args}
session = _sessions.get(sid)
snapshot = None
started_at = None
@@ -1606,6 +1606,10 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result
duration_s = time.time() - started_at if started_at else None
if duration_s is not None:
payload["duration_s"] = duration_s
try:
payload["result"] = json.loads(result)
except Exception:
payload["result"] = result
summary = _tool_summary(name, result, duration_s)
if summary:
payload["summary"] = summary
@@ -1645,7 +1649,9 @@ def _on_tool_progress(
if not _tool_progress_enabled(sid):
return
if event_type == "tool.started" and name:
_emit("tool.progress", sid, {"name": name, "preview": preview or ""})
# `_on_tool_start` already emits the authoritative `tool.start` with
# the stable tool id and args. Emitting another id-less progress row
# here makes the desktop live view diverge from hydrated history.
return
if event_type == "reasoning.available" and preview:
_emit("reasoning.available", sid, {"text": str(preview)})