From d208f2c2c0b7b1c4eaa24fe42ab07ebc3bd58040 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 11 May 2026 21:38:47 -0400 Subject: [PATCH] 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). --- .../electron/bootstrap-platform.test.cjs | 6 +- apps/desktop/electron/hardening.cjs | 184 +++ apps/desktop/electron/hardening.test.cjs | 116 ++ apps/desktop/electron/main.cjs | 102 +- apps/desktop/package-lock.json | 562 ++++++- apps/desktop/package.json | 5 +- apps/desktop/src/app/artifacts/index.test.ts | 1 + apps/desktop/src/app/artifacts/index.tsx | 5 +- apps/desktop/src/app/chat/composer/index.tsx | 152 +- .../chat/composer/liquid-glass-overrides.css | 82 - .../src/app/chat/composer/text-utils.ts | 91 ++ apps/desktop/src/app/chat/index.tsx | 91 +- .../app/chat/right-rail/preview-console.tsx | 288 ++++ .../src/app/chat/right-rail/preview-file.tsx | 553 +++++++ .../src/app/chat/right-rail/preview-pane.tsx | 843 +--------- apps/desktop/src/app/chat/thread-loading.ts | 4 +- apps/desktop/src/app/command-center/index.tsx | 7 +- apps/desktop/src/app/file-browser/index.tsx | 1 + .../src/app/hooks/use-route-enum-param.ts | 7 +- apps/desktop/src/app/messaging/index.tsx | 45 +- .../app/session/hooks/use-message-stream.ts | 8 +- .../app/session/hooks/use-prompt-actions.ts | 119 +- .../session/hooks/use-route-resume.test.tsx | 136 ++ .../src/app/session/hooks/use-route-resume.ts | 20 +- .../src/app/settings/gateway-settings.tsx | 25 +- apps/desktop/src/app/settings/helpers.ts | 3 +- apps/desktop/src/app/shell/app-shell.tsx | 1 + .../app/shell/hooks/use-statusbar-items.tsx | 3 +- .../src/app/shell/statusbar-controls.tsx | 58 +- apps/desktop/src/app/skills/index.tsx | 5 +- apps/desktop/src/app/updates-overlay.tsx | 17 +- apps/desktop/src/components/Backdrop.tsx | 7 +- .../assistant-ui/streaming.test.tsx | 10 +- .../src/components/assistant-ui/thread.tsx | 127 +- .../src/components/assistant-ui/todo-tool.tsx | 40 +- .../assistant-ui/tool-fallback-model.ts | 1354 ++++++++++++++++ .../components/assistant-ui/tool-fallback.tsx | 1382 +++-------------- .../src/components/chat/activity-timer.ts | 20 +- .../src/components/chat/disclosure-row.tsx | 60 +- .../components/desktop-onboarding-overlay.tsx | 24 +- apps/desktop/src/components/status-dot.tsx | 6 +- .../src/components/ui/braille-spinner.tsx | 61 + apps/desktop/src/global.d.ts | 1 + apps/desktop/src/hermes.ts | 10 +- apps/desktop/src/lib/chat-messages.test.ts | 314 +++- apps/desktop/src/lib/chat-messages.ts | 195 ++- apps/desktop/src/lib/commit-changelog.ts | 5 +- apps/desktop/src/lib/external-link.test.tsx | 26 +- apps/desktop/src/lib/external-link.tsx | 9 +- .../lib/incremental-external-store-runtime.ts | 188 +++ apps/desktop/src/lib/markdown-preprocess.ts | 16 +- .../desktop/src/lib/runtime-readiness.test.ts | 65 + apps/desktop/src/lib/runtime-readiness.ts | 147 ++ apps/desktop/src/lib/todos.ts | 28 +- .../src/lib/tool-result-summary.test.ts | 4 +- apps/desktop/src/lib/tool-result-summary.ts | 230 ++- apps/desktop/src/lib/use-enter-animation.ts | 100 ++ apps/desktop/src/store/notifications.ts | 1 + apps/desktop/src/store/onboarding.test.ts | 144 ++ apps/desktop/src/store/onboarding.ts | 109 +- apps/desktop/src/store/tool-view.ts | 14 +- apps/desktop/src/styles.css | 60 +- package-lock.json | 8 +- tui_gateway/server.py | 12 +- 64 files changed, 5614 insertions(+), 2703 deletions(-) create mode 100644 apps/desktop/electron/hardening.cjs create mode 100644 apps/desktop/electron/hardening.test.cjs delete mode 100644 apps/desktop/src/app/chat/composer/liquid-glass-overrides.css create mode 100644 apps/desktop/src/app/chat/composer/text-utils.ts create mode 100644 apps/desktop/src/app/chat/right-rail/preview-console.tsx create mode 100644 apps/desktop/src/app/chat/right-rail/preview-file.tsx create mode 100644 apps/desktop/src/app/session/hooks/use-route-resume.test.tsx create mode 100644 apps/desktop/src/components/assistant-ui/tool-fallback-model.ts create mode 100644 apps/desktop/src/components/ui/braille-spinner.tsx create mode 100644 apps/desktop/src/lib/incremental-external-store-runtime.ts create mode 100644 apps/desktop/src/lib/runtime-readiness.test.ts create mode 100644 apps/desktop/src/lib/runtime-readiness.ts create mode 100644 apps/desktop/src/lib/use-enter-animation.ts create mode 100644 apps/desktop/src/store/onboarding.test.ts diff --git a/apps/desktop/electron/bootstrap-platform.test.cjs b/apps/desktop/electron/bootstrap-platform.test.cjs index be9e475b01..bbd60611a3 100644 --- a/apps/desktop/electron/bootstrap-platform.test.cjs +++ b/apps/desktop/electron/bootstrap-platform.test.cjs @@ -3,11 +3,7 @@ const fs = require('node:fs') const path = require('node:path') const test = require('node:test') -const { - bundledRuntimeImportCheck, - isWindowsBinaryPathInWsl, - isWslEnvironment -} = require('./bootstrap-platform.cjs') +const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') test('isWslEnvironment detects WSL2 env vars on linux', () => { assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true) diff --git a/apps/desktop/electron/hardening.cjs b/apps/desktop/electron/hardening.cjs new file mode 100644 index 0000000000..4ffdea051b --- /dev/null +++ b/apps/desktop/electron/hardening.cjs @@ -0,0 +1,184 @@ +const fs = require('node:fs') +const path = require('node:path') +const { fileURLToPath } = require('node:url') + +const DEFAULT_FETCH_TIMEOUT_MS = 15_000 +const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024 +const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024 + +const SAFE_ENV_SUFFIXES = new Set(['dist', 'example', 'sample', 'template']) +const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx']) + +function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) { + const fallback = + Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS + const parsed = Number(timeoutMs) + + if (Number.isFinite(parsed) && parsed > 0) { + return Math.round(parsed) + } + + return fallback +} + +function encryptDesktopSecret(value, safeStorageApi) { + const raw = String(value || '') + + if (!raw) { + return null + } + + let encryptionAvailable = false + + try { + encryptionAvailable = Boolean(safeStorageApi?.isEncryptionAvailable?.()) + } catch { + encryptionAvailable = false + } + + if (!encryptionAvailable) { + throw new Error( + 'Secure token storage is unavailable, so Hermes Desktop cannot save remote gateway tokens. ' + + 'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment, or enable OS keychain access and try again.' + ) + } + + try { + return { + encoding: 'safeStorage', + value: safeStorageApi.encryptString(raw).toString('base64') + } + } catch (error) { + const detail = error instanceof Error && error.message ? ` (${error.message})` : '' + throw new Error( + `Failed to encrypt the remote gateway token for secure storage${detail}. ` + + 'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment as a fallback.' + ) + } +} + +function sensitiveFileBlockReason(filePath) { + const normalized = String(filePath || '') + .replace(/\\/g, '/') + .toLowerCase() + const basename = path.basename(normalized) + const ext = path.extname(basename) + + if (!basename) { + return null + } + + if (normalized.includes('/.ssh/')) { + return 'SSH key/config files are blocked.' + } + + if (normalized.includes('/.gnupg/')) { + return 'GPG key material is blocked.' + } + + if (normalized.endsWith('/.aws/credentials')) { + return 'AWS credential files are blocked.' + } + + if (basename === '.env') { + return '.env files are blocked because they commonly contain secrets.' + } + + if (basename.startsWith('.env.')) { + const suffix = basename.slice('.env.'.length) + if (!SAFE_ENV_SUFFIXES.has(suffix)) { + return `${basename} is blocked because it appears to contain environment secrets.` + } + } + + if (/^id_(rsa|dsa|ecdsa|ed25519)(?:\..+)?$/.test(basename) && !basename.endsWith('.pub')) { + return 'SSH private key files are blocked.' + } + + if (SENSITIVE_EXTENSIONS.has(ext)) { + return `${ext} key/certificate files are blocked.` + } + + if (basename === '.npmrc' || basename === '.netrc' || basename === '.pypirc') { + return `${basename} is blocked because it may include auth credentials.` + } + + return null +} + +function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') { + const raw = String(filePath || '').trim() + + if (!raw) { + throw new Error(`${purpose} failed: file path is required.`) + } + + if (raw.includes('\0')) { + throw new Error(`${purpose} failed: file path is invalid.`) + } + + if (/^file:/i.test(raw)) { + try { + return fileURLToPath(raw) + } catch { + throw new Error(`${purpose} failed: file URL is invalid.`) + } + } + + const resolvedBase = path.resolve(String(baseDir || process.cwd())) + return path.resolve(resolvedBase, raw) +} + +async function resolveReadableFileForIpc(filePath, options = {}) { + const purpose = String(options.purpose || 'File read') + const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose) + + if (options.blockSensitive !== false) { + const blockReason = sensitiveFileBlockReason(resolvedPath) + if (blockReason) { + throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`) + } + } + + let stat + try { + stat = await fs.promises.stat(resolvedPath) + } catch (error) { + const code = error && typeof error === 'object' ? error.code : '' + if (code === 'ENOENT' || code === 'ENOTDIR') { + throw new Error(`${purpose} failed: file does not exist.`) + } + throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`) + } + + if (stat.isDirectory()) { + throw new Error(`${purpose} failed: path points to a directory.`) + } + + if (!stat.isFile()) { + throw new Error(`${purpose} failed: only regular files can be read.`) + } + + const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null + if (maxBytes && stat.size > maxBytes) { + throw new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`) + } + + try { + await fs.promises.access(resolvedPath, fs.constants.R_OK) + } catch { + throw new Error(`${purpose} failed: file is not readable.`) + } + + return { resolvedPath, stat } +} + +module.exports = { + DATA_URL_READ_MAX_BYTES, + DEFAULT_FETCH_TIMEOUT_MS, + TEXT_PREVIEW_SOURCE_MAX_BYTES, + encryptDesktopSecret, + resolveReadableFileForIpc, + resolveTimeoutMs, + sensitiveFileBlockReason +} diff --git a/apps/desktop/electron/hardening.test.cjs b/apps/desktop/electron/hardening.test.cjs new file mode 100644 index 0000000000..865da8fe79 --- /dev/null +++ b/apps/desktop/electron/hardening.test.cjs @@ -0,0 +1,116 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') +const { pathToFileURL } = require('node:url') + +const { + DEFAULT_FETCH_TIMEOUT_MS, + encryptDesktopSecret, + resolveReadableFileForIpc, + resolveTimeoutMs, + sensitiveFileBlockReason +} = require('./hardening.cjs') + +test('resolveTimeoutMs falls back to defaults and accepts overrides', () => { + assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS) + assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS) + assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS) + assert.equal(resolveTimeoutMs('2750'), 2750) +}) + +test('encryptDesktopSecret requires available secure storage', () => { + assert.equal( + encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }), + null + ) + + assert.throws( + () => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }), + /Secure token storage is unavailable/ + ) +}) + +test('encryptDesktopSecret stores safeStorage base64 payload', () => { + const secret = encryptDesktopSecret('token-123', { + isEncryptionAvailable: () => true, + encryptString: value => Buffer.from(`enc:${value}`, 'utf8') + }) + + assert.deepEqual(secret, { + encoding: 'safeStorage', + value: Buffer.from('enc:token-123', 'utf8').toString('base64') + }) +}) + +test('sensitiveFileBlockReason blocks obvious secret file patterns', () => { + assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/) + assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null) + assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/) + assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/) +}) + +test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-')) + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const textPath = path.join(tempDir, 'notes.txt') + fs.writeFileSync(textPath, 'hello world', 'utf8') + + const fromRelative = await resolveReadableFileForIpc('notes.txt', { + baseDir: tempDir, + maxBytes: 256, + purpose: 'File preview' + }) + assert.equal(fromRelative.resolvedPath, textPath) + assert.equal(fromRelative.stat.size, 11) + + const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), { + purpose: 'File preview' + }) + assert.equal(fromFileUrl.resolvedPath, textPath) + + await assert.rejects( + resolveReadableFileForIpc('missing.txt', { + baseDir: tempDir, + purpose: 'Text preview' + }), + /file does not exist/ + ) + + const nestedDir = path.join(tempDir, 'directory') + fs.mkdirSync(nestedDir) + await assert.rejects( + resolveReadableFileForIpc(nestedDir, { + purpose: 'Text preview' + }), + /path points to a directory/ + ) + + const largePath = path.join(tempDir, 'large.txt') + fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8') + await assert.rejects( + resolveReadableFileForIpc(largePath, { + maxBytes: 8, + purpose: 'File preview' + }), + /file is too large/ + ) + + const envPath = path.join(tempDir, '.env') + fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8') + await assert.rejects( + resolveReadableFileForIpc(envPath, { + purpose: 'File preview' + }), + /blocked for sensitive file/ + ) + + const envTemplatePath = path.join(tempDir, '.env.example') + fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8') + const envTemplate = await resolveReadableFileForIpc(envTemplatePath, { + purpose: 'File preview' + }) + assert.equal(envTemplate.resolvedPath, envTemplatePath) +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 56430ef660..74fc4636dd 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -21,6 +21,14 @@ const path = require('node:path') const { fileURLToPath, pathToFileURL } = require('node:url') const { spawn } = require('node:child_process') const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') +const { + DATA_URL_READ_MAX_BYTES, + DEFAULT_FETCH_TIMEOUT_MS, + TEXT_PREVIEW_SOURCE_MAX_BYTES, + encryptDesktopSecret: encryptDesktopSecretStrict, + resolveReadableFileForIpc, + resolveTimeoutMs +} = require('./hardening.cjs') const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR if (USER_DATA_OVERRIDE) { @@ -843,7 +851,16 @@ function resolveHermesBackend(dashboardArgs) { if (backend) return backend } - // 2. Existing `hermes` on PATH — installed via install.ps1 / install.sh, or + // 2. Development source — when running `npm run dev` from a checkout, the + // cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE and any + // installed `hermes` on PATH so local Python edits are actually exercised. + // (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.) + if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) { + const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs) + if (backend) return backend + } + + // 3. Existing `hermes` on PATH — installed via install.ps1 / install.sh, or // pip-installed system-wide. Skip when HERMES_DESKTOP_IGNORE_EXISTING=1 // (used by test:desktop:fresh to force the factory-image bootstrap path). if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') { @@ -876,15 +893,6 @@ function resolveHermesBackend(dashboardArgs) { } } - // 3. Development source — when running `npm run dev` from a checkout, the - // cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE so the - // desktop uses the dev's local edits, not whatever's under HERMES_HOME. - // (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.) - if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) { - const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs) - if (backend) return backend - } - // 4. ACTIVE_HERMES_ROOT — the canonical mutable install at // %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent. // On packaged installs this is populated from FACTORY_HERMES_ROOT during @@ -1155,6 +1163,7 @@ function fetchJson(url, token, options = {}) { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) const parsed = new URL(url) const client = parsed.protocol === 'https:' ? https : http + const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) @@ -1190,11 +1199,9 @@ function fetchJson(url, token, options = {}) { ) req.on('error', reject) - if (options.timeoutMs) { - req.setTimeout(options.timeoutMs, () => { - req.destroy(new Error(`Timed out connecting to Hermes backend after ${options.timeoutMs}ms`)) - }) - } + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) + }) if (body) req.write(body) req.end() }) @@ -1456,7 +1463,9 @@ function fetchLinkTitle(rawUrl) { const pending = fetchHtmlTitleWithCurl(url) .catch(() => '') .then(value => usableTitle((value || '').slice(0, 240))) - .then(async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240))) + .then( + async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240)) + ) .then(clean => { cacheTitle(key, clean) titleInflight.delete(key) @@ -2022,24 +2031,7 @@ function tokenPreview(value) { } function encryptDesktopSecret(value) { - const raw = String(value || '') - - if (!raw) { - return null - } - - try { - if (safeStorage.isEncryptionAvailable()) { - return { - encoding: 'safeStorage', - value: safeStorage.encryptString(raw).toString('base64') - } - } - } catch { - // Fall through to plaintext for platforms where Electron cannot encrypt. - } - - return { encoding: 'plain', value: raw } + return encryptDesktopSecretStrict(value, safeStorage) } function decryptDesktopSecret(secret) { @@ -2108,14 +2100,19 @@ function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) } } -function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig()) { +function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) { + const persistToken = options.persistToken !== false const mode = input.mode === 'remote' ? 'remote' : 'local' const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim() const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : '' const existingToken = existing.remote?.token const nextRemote = { url: remoteUrl, - token: incomingToken ? encryptDesktopSecret(incomingToken) : existingToken + token: incomingToken + ? persistToken + ? encryptDesktopSecret(incomingToken) + : { encoding: 'plain', value: incomingToken } + : existingToken } if (mode === 'remote') { @@ -2181,7 +2178,7 @@ function resolveRemoteBackend() { } async function testDesktopConnectionConfig(input = {}) { - const config = coerceDesktopConnectionConfig(input) + const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false }) const remote = config.mode === 'remote' ? { @@ -2455,9 +2452,11 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => { ipcMain.handle('hermes:api', async (_event, request) => { const connection = await startHermes() + const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, { - method: request.method, - body: request.body + method: request?.method, + body: request?.body, + timeoutMs }) }) @@ -2472,18 +2471,21 @@ ipcMain.handle('hermes:notify', (_event, payload) => { }) ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => { - const input = String(filePath || '') - const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input) - const data = await fs.promises.readFile(resolved) - return `data:${mimeTypeForPath(resolved)};base64,${data.toString('base64')}` + const { resolvedPath } = await resolveReadableFileForIpc(filePath, { + maxBytes: DATA_URL_READ_MAX_BYTES, + purpose: 'File preview' + }) + const data = await fs.promises.readFile(resolvedPath) + return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}` }) ipcMain.handle('hermes:readFileText', async (_event, filePath) => { - const input = String(filePath || '') - const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input) - const ext = path.extname(resolved).toLowerCase() - const stat = await fs.promises.stat(resolved) - const handle = await fs.promises.open(resolved, 'r') + const { resolvedPath, stat } = await resolveReadableFileForIpc(filePath, { + maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES, + purpose: 'Text preview' + }) + const ext = path.extname(resolvedPath).toLowerCase() + const handle = await fs.promises.open(resolvedPath, 'r') const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES) try { @@ -2494,8 +2496,8 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => { binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))), byteSize: stat.size, language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text', - mimeType: mimeTypeForPath(resolved), - path: resolved, + mimeType: mimeTypeForPath(resolvedPath), + path: resolvedPath, text: buffer.subarray(0, bytesRead).toString('utf8'), truncated: stat.size > TEXT_PREVIEW_MAX_BYTES } diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index e7973dd27b..2442f71dcf 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -14,17 +14,20 @@ "@chenglou/pretext": "^0.0.6", "@hermes/shared": "file:../shared", "@nanostores/react": "^1.1.0", + "@nous-research/ui": "^0.13.0", "@radix-ui/react-slot": "^1.2.4", "@streamdown/code": "^1.1.1", "@tabler/icons-react": "^3.41.1", + "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.100.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "ignore": "^7.0.5", - "liquid-glass-react": "^1.1.1", + "leva": "^0.10.1", "lucide-react": "^0.577.0", + "motion": "^12.38.0", "nanostores": "^1.3.0", "radix-ui": "^1.4.3", "react": "^19.2.5", @@ -1977,6 +1980,48 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@nous-research/ui": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.0.tgz", + "integrity": "sha512-c07lfMdEv/KL6lYC6mfap1CcmIPbvhCZu1supnFaIIrlUaab8gVNDYl8wMMjNRdYOVxxXKisU48yyfe5qvlwqg==", + "dependencies": { + "@nanostores/react": "^1.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "nanostores": "^1.0.1", + "sanitize-html": "^2.16.0", + "tailwind-merge": "^3.3.1", + "tw-animate-css": "^1.4.0", + "unicode-animations": "^1.0.3" + }, + "peerDependencies": { + "@observablehq/plot": "^0.6.17", + "@react-three/fiber": "^9.4.0", + "gsap": "^3.13.0", + "leva": "^0.10.1", + "motion": "^12.38.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "three": "^0.180.0" + }, + "peerDependenciesMeta": { + "@observablehq/plot": { + "optional": true + }, + "@react-three/fiber": { + "optional": true + }, + "gsap": { + "optional": true + }, + "leva": { + "optional": true + }, + "three": { + "optional": true + } + } + }, "node_modules/@oxc-project/types": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", @@ -5916,6 +5961,14 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@stitches/react": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@stitches/react/-/react-1.2.8.tgz", + "integrity": "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA==", + "peerDependencies": { + "react": ">= 16.3.0" + } + }, "node_modules/@streamdown/code": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@streamdown/code/-/code-1.1.1.tgz", @@ -6285,6 +6338,17 @@ "node": ">= 20" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, "node_modules/@tailwindcss/vite": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", @@ -7097,6 +7161,22 @@ "d3-transition": "^3.0.1" } }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -7723,6 +7803,14 @@ "node": ">=12" } }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/assistant-cloud": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.27.tgz", @@ -7816,6 +7904,14 @@ "node": ">= 4.0.0" } }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "engines": { + "node": ">=4" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -8448,6 +8544,11 @@ "dev": true, "license": "MIT" }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -8623,6 +8724,17 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -9276,6 +9388,14 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -9549,6 +9669,55 @@ "license": "MIT", "peer": true }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dompurify": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", @@ -9558,6 +9727,19 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -10079,7 +10261,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10523,6 +10704,25 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend-shallow/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -10615,6 +10815,17 @@ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.5.0.tgz", + "integrity": "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA==", + "dependencies": { + "tslib": "^2.0.3" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -10730,6 +10941,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -10747,6 +10966,32 @@ "node": ">= 6" } }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -10936,6 +11181,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -11464,6 +11717,35 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -11819,6 +12101,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -11953,6 +12246,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -12132,6 +12436,14 @@ "dev": true, "license": "ISC" }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -12200,7 +12512,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -12445,6 +12756,44 @@ "dev": true, "license": "MIT" }, + "node_modules/leva": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", + "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", + "dependencies": { + "@radix-ui/react-portal": "^1.1.4", + "@radix-ui/react-tooltip": "^1.1.8", + "@stitches/react": "^1.2.8", + "@use-gesture/react": "^10.2.5", + "colord": "^2.9.2", + "dequal": "^2.0.2", + "merge-value": "^1.0.0", + "react-colorful": "^5.5.1", + "react-dropzone": "^12.0.0", + "v8n": "^1.3.3", + "zustand": "^3.6.9" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/leva/node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -12708,19 +13057,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/liquid-glass-react": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/liquid-glass-react/-/liquid-glass-react-1.1.1.tgz", - "integrity": "sha512-pKzaktaMAEztd93wpWcz2Z5Z9qdLJUNJdMX+n00Ca4XsnrLTQ5xJzm/+GQXZUeuFXe/PQ8ziVMZO6531PyaFJw==", - "license": "MIT", - "workspaces": [ - "liquid-glass" - ], - "peerDependencies": { - "react": ">=19", - "react-dom": ">=19" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -12771,7 +13107,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -13169,6 +13504,20 @@ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" }, + "node_modules/merge-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/merge-value/-/merge-value-1.0.0.tgz", + "integrity": "sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ==", + "dependencies": { + "get-value": "^2.0.6", + "is-extendable": "^1.0.0", + "mixin-deep": "^1.2.0", + "set-value": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/mermaid": { "version": "11.14.0", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", @@ -13868,6 +14217,18 @@ "node": ">= 18" } }, + "node_modules/mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dependencies": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -13894,6 +14255,44 @@ "ufo": "^1.6.3" } }, + "node_modules/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "dependencies": { + "framer-motion": "^12.38.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -14111,7 +14510,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14375,6 +14773,11 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -14556,6 +14959,18 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -14650,7 +15065,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -14662,7 +15076,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/proper-lockfile": { @@ -14889,6 +15302,15 @@ "react-dom": ">= 16.14" } }, + "node_modules/react-colorful": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.7.0.tgz", + "integrity": "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-dnd": { "version": "14.0.5", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", @@ -14938,6 +15360,22 @@ "react": "^19.2.5" } }, + "node_modules/react-dropzone": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-12.1.0.tgz", + "integrity": "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog==", + "dependencies": { + "attr-accept": "^2.2.2", + "file-selector": "^0.5.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -15589,6 +16027,27 @@ "truncate-utf8-bytes": "^1.0.0" } }, + "node_modules/sanitize-html": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz", + "integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^10.1.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, + "node_modules/sanitize-html/node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -15727,6 +16186,28 @@ "node": ">= 0.4" } }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/set-value/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -15953,6 +16434,29 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dependencies": { + "extend-shallow": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -16569,6 +17073,14 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/tw-shimmer": { "version": "0.4.11", "resolved": "https://registry.npmjs.org/tw-shimmer/-/tw-shimmer-0.4.11.tgz", @@ -17012,6 +17524,11 @@ "dev": true, "license": "(WTFPL OR MIT)" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/uuid": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", @@ -17025,6 +17542,11 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/v8n": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/v8n/-/v8n-1.5.1.tgz", + "integrity": "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A==" + }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cfd3630dd1..75e4ac76f9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -31,7 +31,7 @@ "test:desktop:dmg": "node scripts/test-desktop.mjs dmg", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs", "type-check": "tsc -b", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", @@ -47,7 +47,7 @@ "@chenglou/pretext": "^0.0.6", "@hermes/shared": "file:../shared", "@nanostores/react": "^1.1.0", - "@nous-research/ui": "^0.12.0", + "@nous-research/ui": "^0.13.0", "@radix-ui/react-slot": "^1.2.4", "@streamdown/code": "^1.1.1", "@tabler/icons-react": "^3.41.1", @@ -59,7 +59,6 @@ "cmdk": "^1.1.1", "ignore": "^7.0.5", "leva": "^0.10.1", - "liquid-glass-react": "^1.1.1", "lucide-react": "^0.577.0", "motion": "^12.38.0", "nanostores": "^1.3.0", diff --git a/apps/desktop/src/app/artifacts/index.test.ts b/apps/desktop/src/app/artifacts/index.test.ts index 509deed8aa..ebca956a2c 100644 --- a/apps/desktop/src/app/artifacts/index.test.ts +++ b/apps/desktop/src/app/artifacts/index.test.ts @@ -49,6 +49,7 @@ describe('collectArtifactsForSession', () => { timestamp: 3000 } ] + const artifacts = collectArtifactsForSession(makeSession({ id: 'session-2' }), messages) expect(artifacts).toHaveLength(1) diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index ba9697518a..e00666286e 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -859,10 +859,7 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) return (
{value} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 06959bddf4..ace13c58cb 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,9 +1,6 @@ -import './liquid-glass-overrides.css' - import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import LiquidGlass from 'liquid-glass-react' import { type ClipboardEvent, type FormEvent, @@ -20,7 +17,7 @@ import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' import { chatMessageText } from '@/lib/chat-messages' import { contextPath } from '@/lib/chat-runtime' -import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images' +import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' import { $composerAttachments, $composerDraft } from '@/store/composer' @@ -45,117 +42,16 @@ import { RICH_INPUT_SLOT } from './rich-editor' import { SkinSlashPopover } from './skin-slash-popover' +import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' import type { ChatBarProps } from './types' import { UrlDialog } from './url-dialog' import { VoiceActivity, VoicePlaybackActivity } from './voice-activity' -function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] { - const blobs: Blob[] = [] - const seen = new Set() - - const push = (blob: Blob | null) => { - if (!blob || blob.size === 0 || seen.has(blob)) { - return - } - - seen.add(blob) - blobs.push(blob) - } - - if (clipboard.items?.length) { - for (const item of clipboard.items) { - if (item.kind === 'file' && item.type.startsWith('image/')) { - push(item.getAsFile()) - } - } - } - - if (clipboard.files?.length) { - for (let i = 0; i < clipboard.files.length; i += 1) { - const file = clipboard.files.item(i) - - if (file && file.type.startsWith('image/')) { - push(file) - } - } - } - - if (blobs.length > 0) { - return blobs - } - - const text = clipboard.getData('text/plain').trim() - - if (DATA_IMAGE_URL_RE.test(text)) { - push(dataUrlToBlob(text)) - } - - if (blobs.length === 0) { - const html = clipboard.getData('text/html') - - if (html) { - const matches = html.matchAll(/]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi) - - for (const match of matches) { - push(dataUrlToBlob(match[1])) - } - } - } - - return blobs -} - const COMPOSER_STACK_BREAKPOINT_PX = 320 -const COMPOSER_GLASS = { - fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))', - liquidKey: ['standard', '0.950', '0.072', '0', '46', '0.00', '128'].join(':'), - showLibraryRims: false, - liquid: { - aberrationIntensity: 0.95, - blurAmount: 0.072, - cornerRadius: 0, - displacementScale: 46, - elasticity: 0, - mode: 'standard' as const, - saturation: 128 - } -} - -interface TriggerState { - kind: '@' | '/' - query: string - tokenLength: number -} - -const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/ - -/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */ -function textBeforeCaret(editor: HTMLDivElement): string | null { - const sel = window.getSelection() - const range = sel?.rangeCount ? sel.getRangeAt(0) : null - - if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) { - return null - } - - const before = range.cloneRange() - before.selectNodeContents(editor) - before.setEnd(range.startContainer, range.startOffset) - - return before.toString() -} - -function detectTrigger(textBefore: string): TriggerState | null { - const match = TRIGGER_RE.exec(textBefore) - - if (!match) { - return null - } - - return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length } -} +const COMPOSER_FADE_BACKGROUND = + 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' export function ChatBar({ busy, @@ -186,7 +82,6 @@ export function ChatBar({ const composerRef = useRef(null) const composerSurfaceRef = useRef(null) const editorRef = useRef(null) - const glassShellRef = useRef(null) const draftRef = useRef(draft) const urlInputRef = useRef(null) @@ -931,38 +826,9 @@ export function ChatBar({
-
- - - -
{dragActive && ( @@ -1057,9 +921,7 @@ export function ChatBarFallback() { '[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]', 'transition-[background-color] duration-150 ease-out', 'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]', - 'group-focus-within/composer:bg-[var(--dt-card)]', - 'group-focus-within/composer:[backdrop-filter:none]', - 'group-focus-within/composer:[-webkit-backdrop-filter:none]' + 'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]' )} />
diff --git a/apps/desktop/src/app/chat/composer/liquid-glass-overrides.css b/apps/desktop/src/app/chat/composer/liquid-glass-overrides.css deleted file mode 100644 index bb493a4789..0000000000 --- a/apps/desktop/src/app/chat/composer/liquid-glass-overrides.css +++ /dev/null @@ -1,82 +0,0 @@ -.composer-liquid-shell-wrap > div:not(.composer-liquid-shell) { - position: absolute !important; - inset: 0 !important; - top: 0 !important; - left: 0 !important; - width: 100% !important; - height: 100% !important; - transform: none !important; - margin: 0 !important; - border-radius: inherit !important; - box-sizing: border-box; -} - -.composer-liquid-shell-wrap:not([data-show-library-rims='true']) > span { - display: none !important; -} - -.composer-liquid-shell-wrap[data-show-library-rims='true'] > span { - position: absolute !important; - inset: 0 !important; - top: 0 !important; - left: 0 !important; - width: 100% !important; - height: 100% !important; - transform: none !important; - margin: 0 !important; - border-radius: inherit !important; - box-sizing: border-box; - display: block !important; -} - -.composer-liquid-shell { - z-index: 1; - top: 0 !important; - left: 0 !important; - transform: none !important; - border-radius: inherit !important; - transition: none !important; -} - -.composer-liquid-shell > svg { - position: absolute !important; - inset: 0 !important; - top: 0 !important; - left: 0 !important; - width: 100% !important; - height: 100% !important; - transform: none !important; - border-radius: inherit !important; -} - -.composer-liquid-shell > .glass, -.composer-liquid-shell > :not(svg):not(.glass) { - position: absolute !important; - inset: 0 !important; - top: 0 !important; - left: 0 !important; - width: 100% !important; - height: 100% !important; - transform: none !important; - border-radius: inherit !important; -} - -.composer-liquid-shell > .glass { - width: 100% !important; - height: 100% !important; - padding: 0 !important; - border-radius: inherit !important; - box-shadow: none !important; -} - -.composer-liquid-shell > .glass > .glass__warp { - border-radius: inherit !important; -} - -.composer-liquid-shell > .glass > div { - width: 100%; - height: 100%; - font: inherit !important; - text-shadow: none !important; - color: inherit !important; -} diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts new file mode 100644 index 0000000000..5725883d83 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -0,0 +1,91 @@ +import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images' + +export interface TriggerState { + kind: '@' | '/' + query: string + tokenLength: number +} + +const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/ + +export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] { + const blobs: Blob[] = [] + const seen = new Set() + + const push = (blob: Blob | null) => { + if (!blob || blob.size === 0 || seen.has(blob)) { + return + } + + seen.add(blob) + blobs.push(blob) + } + + if (clipboard.items?.length) { + for (const item of clipboard.items) { + if (item.kind === 'file' && item.type.startsWith('image/')) { + push(item.getAsFile()) + } + } + } + + if (clipboard.files?.length) { + for (let i = 0; i < clipboard.files.length; i += 1) { + const file = clipboard.files.item(i) + + if (file && file.type.startsWith('image/')) { + push(file) + } + } + } + + if (blobs.length > 0) { + return blobs + } + + const text = clipboard.getData('text/plain').trim() + + if (DATA_IMAGE_URL_RE.test(text)) { + push(dataUrlToBlob(text)) + } + + if (blobs.length === 0) { + const html = clipboard.getData('text/html') + + if (html) { + const matches = html.matchAll(/]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi) + + for (const match of matches) { + push(dataUrlToBlob(match[1])) + } + } + } + + return blobs +} + +/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */ +export function textBeforeCaret(editor: HTMLDivElement): string | null { + const sel = window.getSelection() + const range = sel?.rangeCount ? sel.getRangeAt(0) : null + + if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) { + return null + } + + const before = range.cloneRange() + before.selectNodeContents(editor) + before.setEnd(range.startContainer, range.startOffset) + + return before.toString() +} + +export function detectTrigger(textBefore: string): TriggerState | null { + const match = TRIGGER_RE.exec(textBefore) + + if (!match) { + return null + } + + return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length } +} diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 7336ccf4c8..0afed13a1a 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -2,8 +2,7 @@ import { type AppendMessage, AssistantRuntimeProvider, ExportedMessageRepository, - type ThreadMessage, - useExternalStoreRuntime + type ThreadMessage } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' @@ -18,6 +17,7 @@ import { getGlobalModelOptions, type HermesGateway } from '@/hermes' import type { ChatMessage } from '@/lib/chat-messages' import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime' import { ChevronDown } from '@/lib/icons' +import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { cn } from '@/lib/utils' import { $pinnedSessionIds } from '@/store/layout' import { @@ -70,6 +70,55 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onTranscribeAudio?: (audio: Blob) => Promise } +interface ChatHeaderProps { + activeSessionId: null | string + isRoutedSessionView: boolean + onDeleteSelectedSession: () => void + onToggleSelectedPin: () => void + selectedSessionId: null | string +} + +function ChatHeader({ + activeSessionId, + isRoutedSessionView, + onDeleteSelectedSession, + onToggleSelectedPin, + selectedSessionId +}: ChatHeaderProps) { + const sessions = useStore($sessions) + const pinnedSessionIds = useStore($pinnedSessionIds) + const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null + const title = activeStoredSession ? sessionTitle(activeStoredSession) : '' + const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false + + return ( +
+
+ {title && ( + + + + )} +
+
+ ) +} + export function ChatView({ className, gateway, @@ -107,13 +156,9 @@ export function ChatView({ const introPersonality = useStore($introPersonality) const introSeed = useStore($introSeed) const messages = useStore($messages) - const pinnedSessionIds = useStore($pinnedSessionIds) const selectedSessionId = useStore($selectedStoredSessionId) - const sessions = useStore($sessions) const runtimeMessageCacheRef = useRef(new WeakMap()) - const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null const isRoutedSessionView = Boolean(routeSessionId(location.pathname)) - const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0 @@ -127,7 +172,6 @@ export function ChatView({ const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages)) const showChatBar = !loadingSession const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') - const title = activeStoredSession ? sessionTitle(activeStoredSession) : '' const modelOptionsQuery = useQuery({ queryKey: ['model-options', activeSessionId || 'global'], @@ -207,7 +251,7 @@ export function ChatView({ return ExportedMessageRepository.fromBranchableArray(items, { headId }) }, [messages]) - const runtime = useExternalStoreRuntime({ + const runtime = useIncrementalExternalStoreRuntime({ messageRepository: runtimeMessageRepository, isRunning: busy, setMessages: onThreadMessagesChange, @@ -227,30 +271,13 @@ export function ChatView({ className )} > -
-
- {title && ( - - - - )} -
-
+ diff --git a/apps/desktop/src/app/chat/right-rail/preview-console.tsx b/apps/desktop/src/app/chat/right-rail/preview-console.tsx new file mode 100644 index 0000000000..22841aaa23 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-console.tsx @@ -0,0 +1,288 @@ +import { useStore } from '@nanostores/react' +import type { CSSProperties, MutableRefObject, PointerEvent as ReactPointerEvent, RefObject } from 'react' +import { useEffect, useMemo, useRef } from 'react' + +import { CopyButton } from '@/components/ui/copy-button' +import { PanelBottom, Send, Trash2 } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { $composerDraft, setComposerDraft } from '@/store/composer' +import { notify } from '@/store/notifications' + +import type { ConsoleEntry, PreviewConsoleState } from './preview-console-state' + +const consoleLevelLabel: Record = { + 0: 'log', + 1: 'info', + 2: 'warn', + 3: 'error' +} + +const consoleLevelClass: Record = { + 0: 'text-foreground', + 1: 'text-sky-700 dark:text-sky-300', + 2: 'text-amber-700 dark:text-amber-300', + 3: 'text-destructive' +} + +const CONSOLE_BOTTOM_THRESHOLD = 24 +const CONSOLE_HEADER_HEIGHT = 32 + +export function compactUrl(value: string): string { + try { + const url = new URL(value) + + if (url.protocol === 'file:') { + return decodeURIComponent(url.pathname) + } + + return `${url.host}${url.pathname}${url.search}` + } catch { + return value + } +} + +export function formatLogLine(log: ConsoleEntry): string { + const head = `[${consoleLevelLabel[log.level] || 'log'}]` + const tail = log.source ? ` (${compactUrl(log.source)}${log.line ? `:${log.line}` : ''})` : '' + + return `${head} ${log.message}${tail}`.trim() +} + +export function formatConsoleEntries(entries: ConsoleEntry[]): string { + return entries.map(formatLogLine).join('\n') +} + +export function isNearConsoleBottom(element: HTMLDivElement | null): boolean { + if (!element) { + return true + } + + return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD +} + +export function clampConsoleHeight(value: number): number { + return Math.max(value, CONSOLE_HEADER_HEIGHT) +} + +interface ConsoleRowProps { + copyText: string + log: ConsoleEntry + onSend: () => void + onToggleSelect: () => void + selected: boolean +} + +function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) { + return ( +
+ +
+ + {log.message} + + {log.source && ( + + {compactUrl(log.source)} + {log.line ? `:${log.line}` : ''} + + )} +
+ + + + +
+ ) +} + +export function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) { + const logCount = useStore(consoleState.$logCount) + + return ( + <> + + {logCount > 0 && {logCount} console messages} + + ) +} + +interface PreviewConsolePanelProps { + consoleBodyRef: RefObject + consoleShouldStickRef: MutableRefObject + consoleState: PreviewConsoleState + startConsoleResize: (event: ReactPointerEvent) => void +} + +export function PreviewConsolePanel({ + consoleBodyRef, + consoleShouldStickRef, + consoleState, + startConsoleResize +}: PreviewConsolePanelProps) { + const consoleHeight = useStore(consoleState.$height) + const logs = useStore(consoleState.$logs) + const selectedLogIds = useStore(consoleState.$selectedLogIds) + const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds]) + const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs + const stickScrollRafRef = useRef(null) + + useEffect(() => { + if (!consoleShouldStickRef.current) { + return + } + + if (stickScrollRafRef.current !== null) { + window.cancelAnimationFrame(stickScrollRafRef.current) + stickScrollRafRef.current = null + } + + stickScrollRafRef.current = window.requestAnimationFrame(() => { + stickScrollRafRef.current = null + const consoleBody = consoleBodyRef.current + consoleBody?.scrollTo({ top: consoleBody.scrollHeight }) + }) + + return () => { + if (stickScrollRafRef.current !== null) { + window.cancelAnimationFrame(stickScrollRafRef.current) + stickScrollRafRef.current = null + } + } + }, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs]) + + function sendLogsToComposer(entries: ConsoleEntry[]) { + if (!entries.length) { + return + } + + const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n') + const draft = $composerDraft.get() + const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}` + + setComposerDraft(next) + consoleState.clearSelection() + notify({ + kind: 'success', + title: 'Sent to chat', + message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer` + }) + } + + return ( +
+
consoleState.setHeight(CONSOLE_HEADER_HEIGHT)} + onPointerDown={startConsoleResize} + role="separator" + > + +
+
+
+ + Preview Console + {selectedLogIds.size > 0 && ( + + {selectedLogIds.size} selected + + )} +
+
+ + 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'} + text={() => formatConsoleEntries(sendableLogs)} + > + Copy + + +
+
+
+ {logs.length > 0 ? ( + logs.map(log => { + const selected = selectedLogIds.has(log.id) + + return ( + sendLogsToComposer([log])} + onToggleSelect={() => consoleState.toggleSelection(log.id)} + selected={selected} + /> + ) + }) + ) : ( +
No console messages yet.
+ )} +
+
+ ) +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx new file mode 100644 index 0000000000..7a650da4fa --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -0,0 +1,553 @@ +import type * as React from 'react' +import type { + ComponentProps, + CSSProperties, + DragEvent as ReactDragEvent, + MouseEvent as ReactMouseEvent, + ReactNode +} from 'react' +import { useEffect, useMemo, useState } from 'react' +import ShikiHighlighter from 'react-shiki' +import { Streamdown } from 'streamdown' + +import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' +import { cn } from '@/lib/utils' +import type { PreviewTarget } from '@/store/preview' + +const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const +const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 + +type EmptyStateTone = 'neutral' | 'warning' + +const TONE_STYLES: Record = { + neutral: { + cube: 'text-muted-foreground/35', + primary: 'border-border bg-background text-foreground hover:bg-accent' + }, + warning: { + cube: 'text-amber-500/70 dark:text-amber-300/70', + primary: + 'border-amber-400/40 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-300/30 dark:bg-amber-300/15 dark:text-amber-100 dark:hover:bg-amber-300/20' + } +} + +function PreviewCubeIcon({ className }: { className?: string }) { + return ( + + ) +} + +interface PreviewEmptyStateProps { + body?: ReactNode + consoleHeight?: number + primaryAction?: { disabled?: boolean; label: string; onClick: () => void } + secondaryAction?: { disabled?: boolean; label: string; onClick: () => void } + title: string + tone?: EmptyStateTone +} + +export function PreviewEmptyState({ + body, + consoleHeight = 0, + primaryAction, + secondaryAction, + title, + tone = 'neutral' +}: PreviewEmptyStateProps) { + const styles = TONE_STYLES[tone] + + return ( +
+
+ +
+
{title}
+ {body &&
{body}
} +
+ {(primaryAction || secondaryAction) && ( +
+ {primaryAction && ( + + )} + {secondaryAction && ( + + )} +
+ )} +
+
+ ) +} + +interface LocalPreviewState { + binary?: boolean + byteSize?: number + dataUrl?: string + error?: string + language?: string + loading: boolean + text?: string + truncated?: boolean +} + +function filePathForTarget(target: PreviewTarget) { + if (target.path) { + return target.path + } + + try { + const url = new URL(target.url) + + return url.protocol === 'file:' ? decodeURIComponent(url.pathname) : target.url + } catch { + return target.url + } +} + +function formatBytes(bytes: number | undefined) { + if (!bytes) { + return 'unknown size' + } + + const units = ['B', 'KB', 'MB', 'GB'] + let value = bytes + let unit = 0 + + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + + return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}` +} + +function looksBinaryBytes(bytes: Uint8Array) { + if (!bytes.length) { + return false + } + + let suspicious = 0 + + for (const byte of bytes.slice(0, 4096)) { + if (byte === 0) { + return true + } + + if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) { + suspicious += 1 + } + } + + return suspicious / Math.min(bytes.length, 4096) > 0.12 +} + +async function readTextPreview(filePath: string) { + if (window.hermesDesktop.readFileText) { + try { + return await window.hermesDesktop.readFileText(filePath) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + + if (!message.includes("No handler registered for 'hermes:readFileText'")) { + throw error + } + } + } + + // Back-compat for a running Electron process whose preload hasn't been + // restarted since readFileText was added. readFileDataUrl already existed. + const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath) + const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || [] + const base64 = metadata.includes(';base64') + const mimeType = metadata.replace(/;base64$/, '') || undefined + const raw = base64 ? atob(data) : decodeURIComponent(data) + const bytes = Uint8Array.from(raw, ch => ch.charCodeAt(0)) + + return { + binary: looksBinaryBytes(bytes), + byteSize: bytes.byteLength, + mimeType, + path: filePath, + text: new TextDecoder().decode(bytes) + } +} + +// Lightweight markdown renderer for file previews. Streamdown does the parse; +// our components keep typography simple and route fenced code through Shiki +// without the library's copy/download/fullscreen chrome. +const MD_TAG_CLASSES = { + h1: 'mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0', + h2: 'mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0', + h3: 'mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0', + h4: 'mb-2 mt-3 text-base font-semibold leading-snug first:mt-0', + p: 'mb-4 leading-relaxed text-foreground last:mb-0', + ul: 'mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0', + ol: 'mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0', + li: 'mt-1 leading-relaxed', + blockquote: 'mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0', + pre: 'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono' +} as const + +function tagged(Tag: T) { + const base = MD_TAG_CLASSES[Tag] + + const Component = (({ className, ...rest }: ComponentProps) => { + const Element = Tag as React.ElementType + + return + }) as React.FC> + + Component.displayName = `Md.${Tag}` + + return Component +} + +function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) { + const language = /language-([^\s]+)/.exec(className || '')?.[1] + + if (!language) { + return ( + + {children} + + ) + } + + return ( + + {String(children).replace(/\n$/, '')} + + ) +} + +const MARKDOWN_COMPONENTS = { + h1: tagged('h1'), + h2: tagged('h2'), + h3: tagged('h3'), + h4: tagged('h4'), + p: tagged('p'), + ul: tagged('ul'), + ol: tagged('ol'), + li: tagged('li'), + blockquote: tagged('blockquote'), + pre: tagged('pre'), + code: MarkdownCode +} + +function MarkdownPreview({ text }: { text: string }) { + return ( +
+ + {text} + +
+ ) +} + +function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) { + return ( +
+ +
+ ) +} + +// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so +// each line aligns vertically. The selection overlay relies on the same +// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself. +const SOURCE_LINE_HEIGHT_REM = 1.21875 +const SOURCE_PAD_Y_REM = 0.75 + +interface LineSelection { + end: number + start: number +} + +function startLineDrag(event: ReactDragEvent, filePath: string, { end, start }: LineSelection) { + const lineEnd = end > start ? end : undefined + const label = lineEnd ? `${filePath}:${start}-${end}` : `${filePath}:${start}` + + event.dataTransfer.setData(HERMES_PATHS_MIME, JSON.stringify([{ line: start, lineEnd, path: filePath }])) + event.dataTransfer.setData('text/plain', label) + event.dataTransfer.effectAllowed = 'copy' +} + +function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) { + const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text]) + const [selection, setSelection] = useState(null) + const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end + + const handleLineClick = (event: ReactMouseEvent, line: number) => { + if (event.shiftKey && selection) { + setSelection({ end: Math.max(selection.end, line), start: Math.min(selection.start, line) }) + + return + } + + if (selection?.start === line && selection.end === line) { + setSelection(null) + + return + } + + setSelection({ end: line, start: line }) + } + + const handleDragStart = (event: ReactDragEvent, line: number) => { + startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line }) + } + + return ( +
+
+ {Array.from({ length: lineCount }, (_, index) => { + const line = index + 1 + const selected = inSelection(line) + + return ( +
handleLineClick(event, line)} + onDragStart={event => handleDragStart(event, line)} + title="Click to select · shift-click to extend · drag to composer" + > + {line} +
+ ) + })} +
+
+ {selection && ( +
+ )} + + {text} + +
+
+ ) +} + +export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) { + const [state, setState] = useState({ loading: true }) + const [forcePreview, setForcePreview] = useState(false) + const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false) + const filePath = filePathForTarget(target) + const isImage = target.previewKind === 'image' + + // HTML files are rendered as source code, not in a webview - so they take + // the same path as plain text files. `previewKind === 'binary'` arrives + // when the file is forcibly previewed past the binary refusal screen. + const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html' + + const blockedByTarget = !isImage && !forcePreview && (target.binary || target.large) + + useEffect(() => { + let active = true + + async function load() { + if (blockedByTarget) { + setState({ loading: false }) + + return + } + + if (!isImage && !isText) { + setState({ loading: false }) + + return + } + + setState({ loading: true }) + + try { + if (isImage) { + const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath) + + if (active) { + setState({ dataUrl, loading: false }) + } + + return + } + + const result = await readTextPreview(filePath) + + if (active) { + const shouldBlock = !forcePreview && (result.binary || (result.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES) + + setState({ + binary: result.binary, + byteSize: result.byteSize, + language: result.language || target.language || 'text', + loading: false, + text: shouldBlock ? undefined : result.text, + truncated: result.truncated + }) + } + } catch (error) { + if (active) { + setState({ + error: error instanceof Error ? error.message : String(error), + loading: false + }) + } + } + } + + void load() + + return () => { + active = false + } + }, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language]) + + if (state.loading) { + return
Loading preview…
+ } + + if (state.error) { + return + } + + if ( + !isImage && + !forcePreview && + (target.binary || target.large || state.binary || (state.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES) + ) { + const binary = target.binary || state.binary + const size = target.byteSize || state.byteSize + + return ( + setForcePreview(true) }} + title={binary ? 'This looks like a binary file' : 'This file is large'} + tone="warning" + /> + ) + } + + if (isImage && state.dataUrl) { + return ( +
+ {target.label} +
+ ) + } + + if (isText && state.text !== undefined) { + const isMarkdown = (state.language || target.language) === 'markdown' + const showRendered = isMarkdown && !renderMarkdownAsSource + + return ( +
+ {state.truncated && ( +
+ Showing first 512 KB. +
+ )} + {isMarkdown && setRenderMarkdownAsSource(s => !s)} />} + {showRendered ? ( + + ) : ( + + )} +
+ ) + } + + return ( + + ) +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index 889f3df557..dee253b759 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -1,30 +1,23 @@ import { useStore } from '@nanostores/react' -import type { - ComponentProps, - CSSProperties, - MutableRefObject, - DragEvent as ReactDragEvent, - MouseEvent as ReactMouseEvent, - ReactNode, - PointerEvent as ReactPointerEvent, - RefObject -} from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import ShikiHighlighter from 'react-shiki' -import { Streamdown } from 'streamdown' +import type { PointerEvent as ReactPointerEvent } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' -import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls' -import { CopyButton } from '@/components/ui/copy-button' -import { Bug, PanelBottom, RefreshCw, Send, Trash2, X } from '@/lib/icons' +import { Bug, RefreshCw, X } from '@/lib/icons' import { cn } from '@/lib/utils' -import { $composerDraft, setComposerDraft } from '@/store/composer' import { notify, notifyError } from '@/store/notifications' import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview' -import { type ConsoleEntry, createPreviewConsoleState, type PreviewConsoleState } from './preview-console-state' - -const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const +import { + clampConsoleHeight, + compactUrl, + formatLogLine, + isNearConsoleBottom, + PreviewConsolePanel, + PreviewConsoleTitlebarIcon +} from './preview-console' +import { type ConsoleEntry, createPreviewConsoleState } from './preview-console-state' +import { LocalFilePreview, PreviewEmptyState } from './preview-file' type PreviewWebview = HTMLElement & { closeDevTools?: () => void @@ -50,62 +43,8 @@ interface PreviewLoadErrorState { url: string } -const consoleLevelLabel: Record = { - 0: 'log', - 1: 'info', - 2: 'warn', - 3: 'error' -} - -const consoleLevelClass: Record = { - 0: 'text-foreground', - 1: 'text-sky-700 dark:text-sky-300', - 2: 'text-amber-700 dark:text-amber-300', - 3: 'text-destructive' -} - -const CONSOLE_BOTTOM_THRESHOLD = 24 -const CONSOLE_HEADER_HEIGHT = 32 const FILE_RELOAD_DEBOUNCE_MS = 200 const SERVER_RESTART_TIMEOUT_MS = 45_000 -const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 - -function compactUrl(value: string): string { - try { - const url = new URL(value) - - if (url.protocol === 'file:') { - return decodeURIComponent(url.pathname) - } - - return `${url.host}${url.pathname}${url.search}` - } catch { - return value - } -} - -function formatLogLine(log: ConsoleEntry): string { - const head = `[${consoleLevelLabel[log.level] || 'log'}]` - const tail = log.source ? ` (${compactUrl(log.source)}${log.line ? `:${log.line}` : ''})` : '' - - return `${head} ${log.message}${tail}`.trim() -} - -function formatConsoleEntries(entries: ConsoleEntry[]): string { - return entries.map(formatLogLine).join('\n') -} - -function isNearConsoleBottom(element: HTMLDivElement | null): boolean { - if (!element) { - return true - } - - return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD -} - -function clampConsoleHeight(value: number): number { - return Math.max(value, CONSOLE_HEADER_HEIGHT) -} function loadErrorTitle(error: PreviewLoadErrorState): string { const description = error.description.toLowerCase() @@ -127,176 +66,6 @@ function isModuleMimeError(message: string): boolean { return lower.includes('failed to load module script') && lower.includes('mime type') } -interface ConsoleRowProps { - copyText: string - log: ConsoleEntry - onSend: () => void - onToggleSelect: () => void - selected: boolean -} - -function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) { - return ( -
- -
- - {log.message} - - {log.source && ( - - {compactUrl(log.source)} - {log.line ? `:${log.line}` : ''} - - )} -
- - - - -
- ) -} - -function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) { - const logCount = useStore(consoleState.$logCount) - - return ( - <> - - {logCount > 0 && {logCount} console messages} - - ) -} - -type EmptyStateTone = 'neutral' | 'warning' - -const TONE_STYLES: Record = { - neutral: { - cube: 'text-muted-foreground/35', - primary: 'border-border bg-background text-foreground hover:bg-accent' - }, - warning: { - cube: 'text-amber-500/70 dark:text-amber-300/70', - primary: - 'border-amber-400/40 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-300/30 dark:bg-amber-300/15 dark:text-amber-100 dark:hover:bg-amber-300/20' - } -} - -function PreviewCubeIcon({ className }: { className?: string }) { - return ( - - ) -} - -interface PreviewEmptyStateProps { - body?: ReactNode - consoleHeight?: number - primaryAction?: { disabled?: boolean; label: string; onClick: () => void } - secondaryAction?: { disabled?: boolean; label: string; onClick: () => void } - title: string - tone?: EmptyStateTone -} - -function PreviewEmptyState({ - body, - consoleHeight = 0, - primaryAction, - secondaryAction, - title, - tone = 'neutral' -}: PreviewEmptyStateProps) { - const styles = TONE_STYLES[tone] - - return ( -
-
- -
-
{title}
- {body &&
{body}
} -
- {(primaryAction || secondaryAction) && ( -
- {primaryAction && ( - - )} - {secondaryAction && ( - - )} -
- )} -
-
- ) -} - function PreviewLoadError({ consoleHeight = 0, error, @@ -344,592 +113,6 @@ function PreviewLoadError({ ) } -function PreviewConsolePanel({ - consoleBodyRef, - consoleShouldStickRef, - consoleState, - startConsoleResize -}: { - consoleBodyRef: RefObject - consoleShouldStickRef: MutableRefObject - consoleState: PreviewConsoleState - startConsoleResize: (event: ReactPointerEvent) => void -}) { - const consoleHeight = useStore(consoleState.$height) - const logs = useStore(consoleState.$logs) - const selectedLogIds = useStore(consoleState.$selectedLogIds) - const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds]) - const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs - const stickScrollRafRef = useRef(null) - - useEffect(() => { - if (!consoleShouldStickRef.current) { - return - } - - if (stickScrollRafRef.current !== null) { - window.cancelAnimationFrame(stickScrollRafRef.current) - stickScrollRafRef.current = null - } - - stickScrollRafRef.current = window.requestAnimationFrame(() => { - stickScrollRafRef.current = null - const consoleBody = consoleBodyRef.current - consoleBody?.scrollTo({ top: consoleBody.scrollHeight }) - }) - - return () => { - if (stickScrollRafRef.current !== null) { - window.cancelAnimationFrame(stickScrollRafRef.current) - stickScrollRafRef.current = null - } - } - }, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs]) - - function sendLogsToComposer(entries: ConsoleEntry[]) { - if (!entries.length) { - return - } - - const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n') - const draft = $composerDraft.get() - const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}` - - setComposerDraft(next) - consoleState.clearSelection() - notify({ - kind: 'success', - title: 'Sent to chat', - message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer` - }) - } - - return ( -
-
consoleState.setHeight(CONSOLE_HEADER_HEIGHT)} - onPointerDown={startConsoleResize} - role="separator" - > - -
-
-
- - Preview Console - {selectedLogIds.size > 0 && ( - - {selectedLogIds.size} selected - - )} -
-
- - 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'} - text={() => formatConsoleEntries(sendableLogs)} - > - Copy - - -
-
-
- {logs.length > 0 ? ( - logs.map(log => { - const selected = selectedLogIds.has(log.id) - - return ( - sendLogsToComposer([log])} - onToggleSelect={() => consoleState.toggleSelection(log.id)} - selected={selected} - /> - ) - }) - ) : ( -
No console messages yet.
- )} -
-
- ) -} - -interface LocalPreviewState { - binary?: boolean - byteSize?: number - dataUrl?: string - error?: string - language?: string - loading: boolean - text?: string - truncated?: boolean -} - -function filePathForTarget(target: PreviewTarget) { - if (target.path) { - return target.path - } - - try { - const url = new URL(target.url) - - return url.protocol === 'file:' ? decodeURIComponent(url.pathname) : target.url - } catch { - return target.url - } -} - -function formatBytes(bytes: number | undefined) { - if (!bytes) { - return 'unknown size' - } - - const units = ['B', 'KB', 'MB', 'GB'] - let value = bytes - let unit = 0 - - while (value >= 1024 && unit < units.length - 1) { - value /= 1024 - unit += 1 - } - - return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}` -} - -function looksBinaryBytes(bytes: Uint8Array) { - if (!bytes.length) { - return false - } - - let suspicious = 0 - - for (const byte of bytes.slice(0, 4096)) { - if (byte === 0) { - return true - } - - if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) { - suspicious += 1 - } - } - - return suspicious / Math.min(bytes.length, 4096) > 0.12 -} - -async function readTextPreview(filePath: string) { - if (window.hermesDesktop.readFileText) { - try { - return await window.hermesDesktop.readFileText(filePath) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - - if (!message.includes("No handler registered for 'hermes:readFileText'")) { - throw error - } - } - } - - // Back-compat for a running Electron process whose preload hasn't been - // restarted since readFileText was added. readFileDataUrl already existed. - const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath) - const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || [] - const base64 = metadata.includes(';base64') - const mimeType = metadata.replace(/;base64$/, '') || undefined - const raw = base64 ? atob(data) : decodeURIComponent(data) - const bytes = Uint8Array.from(raw, ch => ch.charCodeAt(0)) - - return { - binary: looksBinaryBytes(bytes), - byteSize: bytes.byteLength, - mimeType, - path: filePath, - text: new TextDecoder().decode(bytes) - } -} - -// Lightweight markdown renderer for file previews. Streamdown does the parse; -// our components keep typography simple and route fenced code through Shiki -// without the library's copy/download/fullscreen chrome. -const MD_TAG_CLASSES = { - h1: 'mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0', - h2: 'mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0', - h3: 'mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0', - h4: 'mb-2 mt-3 text-base font-semibold leading-snug first:mt-0', - p: 'mb-4 leading-relaxed text-foreground last:mb-0', - ul: 'mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0', - ol: 'mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0', - li: 'mt-1 leading-relaxed', - blockquote: 'mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0', - pre: 'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono' -} as const - -function tagged(Tag: T) { - const base = MD_TAG_CLASSES[Tag] - - const Component = (({ className, ...rest }: ComponentProps) => { - const Element = Tag as React.ElementType - - return - }) as React.FC> - - Component.displayName = `Md.${Tag}` - - return Component -} - -function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) { - const language = /language-([^\s]+)/.exec(className || '')?.[1] - - if (!language) { - return ( - - {children} - - ) - } - - return ( - - {String(children).replace(/\n$/, '')} - - ) -} - -const MARKDOWN_COMPONENTS = { - h1: tagged('h1'), - h2: tagged('h2'), - h3: tagged('h3'), - h4: tagged('h4'), - p: tagged('p'), - ul: tagged('ul'), - ol: tagged('ol'), - li: tagged('li'), - blockquote: tagged('blockquote'), - pre: tagged('pre'), - code: MarkdownCode -} - -function MarkdownPreview({ text }: { text: string }) { - return ( -
- - {text} - -
- ) -} - -function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) { - return ( -
- -
- ) -} - -// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so -// each line aligns vertically. The selection overlay relies on the same -// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself. -const SOURCE_LINE_HEIGHT_REM = 1.21875 -const SOURCE_PAD_Y_REM = 0.75 - -interface LineSelection { - end: number - start: number -} - -function startLineDrag(event: ReactDragEvent, filePath: string, { end, start }: LineSelection) { - const lineEnd = end > start ? end : undefined - const label = lineEnd ? `${filePath}:${start}-${end}` : `${filePath}:${start}` - - event.dataTransfer.setData(HERMES_PATHS_MIME, JSON.stringify([{ line: start, lineEnd, path: filePath }])) - event.dataTransfer.setData('text/plain', label) - event.dataTransfer.effectAllowed = 'copy' -} - -function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) { - const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text]) - const [selection, setSelection] = useState(null) - const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end - - const handleLineClick = (event: ReactMouseEvent, line: number) => { - if (event.shiftKey && selection) { - setSelection({ end: Math.max(selection.end, line), start: Math.min(selection.start, line) }) - - return - } - - if (selection?.start === line && selection.end === line) { - setSelection(null) - - return - } - - setSelection({ end: line, start: line }) - } - - const handleDragStart = (event: ReactDragEvent, line: number) => { - startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line }) - } - - return ( -
-
- {Array.from({ length: lineCount }, (_, index) => { - const line = index + 1 - const selected = inSelection(line) - - return ( -
handleLineClick(event, line)} - onDragStart={event => handleDragStart(event, line)} - title="Click to select · shift-click to extend · drag to composer" - > - {line} -
- ) - })} -
-
- {selection && ( -
- )} - - {text} - -
-
- ) -} - -function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) { - const [state, setState] = useState({ loading: true }) - const [forcePreview, setForcePreview] = useState(false) - const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false) - const filePath = filePathForTarget(target) - const isImage = target.previewKind === 'image' - - // HTML files are rendered as source code, not in a webview — so they take - // the same path as plain text files. `previewKind === 'binary'` arrives - // when the file is forcibly previewed past the binary refusal screen. - const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html' - - const blockedByTarget = !isImage && !forcePreview && (target.binary || target.large) - - useEffect(() => { - let active = true - - async function load() { - if (blockedByTarget) { - setState({ loading: false }) - - return - } - - if (!isImage && !isText) { - setState({ loading: false }) - - return - } - - setState({ loading: true }) - - try { - if (isImage) { - const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath) - - if (active) { - setState({ dataUrl, loading: false }) - } - - return - } - - const result = await readTextPreview(filePath) - - if (active) { - const shouldBlock = !forcePreview && (result.binary || (result.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES) - - setState({ - binary: result.binary, - byteSize: result.byteSize, - language: result.language || target.language || 'text', - loading: false, - text: shouldBlock ? undefined : result.text, - truncated: result.truncated - }) - } - } catch (error) { - if (active) { - setState({ - error: error instanceof Error ? error.message : String(error), - loading: false - }) - } - } - } - - void load() - - return () => { - active = false - } - }, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language]) - - if (state.loading) { - return
Loading preview…
- } - - if (state.error) { - return - } - - if ( - !isImage && - !forcePreview && - (target.binary || target.large || state.binary || (state.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES) - ) { - const binary = target.binary || state.binary - const size = target.byteSize || state.byteSize - - return ( - setForcePreview(true) }} - title={binary ? 'This looks like a binary file' : 'This file is large'} - tone="warning" - /> - ) - } - - if (isImage && state.dataUrl) { - return ( -
- {target.label} -
- ) - } - - if (isText && state.text !== undefined) { - const isMarkdown = (state.language || target.language) === 'markdown' - const showRendered = isMarkdown && !renderMarkdownAsSource - - return ( -
- {state.truncated && ( -
- Showing first 512 KB. -
- )} - {isMarkdown && setRenderMarkdownAsSource(s => !s)} />} - {showRendered ? ( - - ) : ( - - )} -
- ) - } - - return ( - - ) -} - const TITLEBAR_GROUP_ID = 'preview' export function PreviewPane({ diff --git a/apps/desktop/src/app/chat/thread-loading.ts b/apps/desktop/src/app/chat/thread-loading.ts index 8f97f7c053..97686c6550 100644 --- a/apps/desktop/src/app/chat/thread-loading.ts +++ b/apps/desktop/src/app/chat/thread-loading.ts @@ -1,5 +1,7 @@ import type { ChatMessage } from '@/lib/chat-messages' +export type ThreadLoadingState = 'response' | 'session' + export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean { const lastVisible = [...messages].reverse().find(message => !message.hidden) @@ -11,7 +13,7 @@ export function threadLoadingState( busy: boolean, awaitingResponse: boolean, lastVisibleIsUser: boolean -) { +): ThreadLoadingState | undefined { if (loadingSession) { return 'session' } diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index bc40711616..a80d5ad06b 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -89,7 +89,12 @@ const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [ { id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New chat', detail: 'Start a fresh session' }, { id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' }, { id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' }, - { id: 'nav-messaging', route: MESSAGING_ROUTE, title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' }, + { + id: 'nav-messaging', + route: MESSAGING_ROUTE, + title: 'Messaging', + detail: 'Set up Telegram, Slack, Discord, and more' + }, { id: 'nav-artifacts', route: ARTIFACTS_ROUTE, title: 'Artifacts', detail: 'Browse generated outputs' } ] diff --git a/apps/desktop/src/app/file-browser/index.tsx b/apps/desktop/src/app/file-browser/index.tsx index 6fdeae01ec..42e27c8efb 100644 --- a/apps/desktop/src/app/file-browser/index.tsx +++ b/apps/desktop/src/app/file-browser/index.tsx @@ -10,6 +10,7 @@ import { setCurrentSessionPreviewTarget } from '@/store/preview' import { $currentCwd } from '@/store/session' import { SidebarPanelLabel } from '../shell/sidebar-label' + import { ProjectTree } from './tree' import { useProjectTree } from './use-project-tree' diff --git a/apps/desktop/src/app/hooks/use-route-enum-param.ts b/apps/desktop/src/app/hooks/use-route-enum-param.ts index 47f505de7e..24de1dfe0f 100644 --- a/apps/desktop/src/app/hooks/use-route-enum-param.ts +++ b/apps/desktop/src/app/hooks/use-route-enum-param.ts @@ -22,8 +22,11 @@ export function useRouteEnumParam( (next: T) => { const params = new URLSearchParams(search) - if (next === fallback) {params.delete(key)} - else {params.set(key, next)} + if (next === fallback) { + params.delete(key) + } else { + params.set(key, next) + } const qs = params.toString() navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true }) diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index 5841e7d1de..b05df9783e 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -78,11 +78,17 @@ const HINT_BY_STATE: Record = { const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown') function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone { - if (!enabled) {return 'muted'} + if (!enabled) { + return 'muted' + } - if (state === 'connected') {return 'good'} + if (state === 'connected') { + return 'good' + } - if (state === 'fatal' || state === 'startup_failed') {return 'bad'} + if (state === 'fatal' || state === 'startup_failed') { + return 'bad' + } return 'warn' } @@ -511,9 +517,7 @@ function PlatformDetail({
Get your credentials -

- {introCopy(platform)} -

+

{introCopy(platform)}

{showAdvanced && (
@@ -632,7 +634,8 @@ const PLATFORM_INTRO: Record = { mattermost: 'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.', matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.', - signal: 'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.', + signal: + 'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.', whatsapp: 'Start the WhatsApp bridge that ships with Hermes, scan the QR code on first run, then enable the platform.', bluebubbles: @@ -642,8 +645,7 @@ const PLATFORM_INTRO: Record = { email: 'Use a dedicated mailbox. For Gmail/Workspace, create an app password and use imap.gmail.com / smtp.gmail.com.', sms: 'Get your Twilio Account SID and Auth Token from the Twilio console, plus a phone number that can send SMS.', - dingtalk: - 'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.', + dingtalk: 'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.', feishu: 'Create a Feishu / Lark app, configure the bot capability, and copy the App ID, App secret, and event encryption keys.', wecom: @@ -655,7 +657,8 @@ const PLATFORM_INTRO: Record = { qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.', api_server: 'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.', - webhook: 'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.' + webhook: + 'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.' } const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description @@ -717,16 +720,15 @@ function MessagingField({ } function SectionTitle({ children }: { children: React.ReactNode }) { - return ( -

{children}

- ) + return

{children}

} function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) { - if (!platform.enabled || platform.state === 'connected') {return null} + if (!platform.enabled || platform.state === 'connected') { + return null + } - const hint = - HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped) + const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped) return hint ?

{hint}

: null } @@ -748,7 +750,10 @@ function StatePill({ children, tone }: { children: string; tone: StatusTone }) { function SetupPill({ active, children }: { active: boolean; children: string }) { return ( {children} diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index 54cefc1be2..b747d1d7a4 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -65,7 +65,9 @@ const STREAM_DELTA_FLUSH_MS = 16 // Anonymous progress events that carry todos but no name still belong to the // todo stream; named todo events are obviously routed there too. function toTodoPayload(payload: GatewayEventPayload | undefined): GatewayEventPayload | undefined { - if (!payload) {return undefined} + if (!payload) { + return undefined + } const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos')) return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined @@ -561,7 +563,9 @@ export function useMessageStream({ setCurrentUsage(current => ({ ...current, ...payload.usage })) } } else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') { - if (!sessionId) {return} + if (!sessionId) { + return + } flushQueuedDeltas(sessionId) upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running') } else if (event.type === 'tool.complete') { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 9ee07cb0af..af6d8dd1cc 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -46,19 +46,12 @@ function blobToDataUrl(blob: Blob): Promise { }) } -interface SetupStatus { - provider_configured?: boolean -} - -interface RuntimeCheck { - error?: string - ok?: boolean -} - function isProviderSetupError(error: unknown) { const message = error instanceof Error ? error.message : String(error) - return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i.test(message) + return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i.test( + message + ) } interface PromptActionsOptions { @@ -197,25 +190,24 @@ export function usePromptActions({ async (rawText: string) => { const visibleText = rawText.trim() const attachments = $composerAttachments.get() - const contextRefs = attachments - .map(attachment => attachment.refText) + .map(a => a.refText) .filter(Boolean) .join('\n') - - const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image') - const attachmentRefs = attachments.map(attachmentDisplayText).filter((ref): ref is string => Boolean(ref)) + const hasImage = attachments.some(a => a.kind === 'image') + const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r)) const text = - [contextRefs, visibleText].filter(Boolean).join('\n\n') || - (hasImageAttachment ? 'What do you see in this image?' : '') + [contextRefs, visibleText].filter(Boolean).join('\n\n') || (hasImage ? 'What do you see in this image?' : '') if (!text || busyRef.current) { return } + const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const userMessage: ChatMessage = { - id: `user-${Date.now()}`, + id: optimisticId, role: 'user', parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))], attachmentRefs @@ -227,61 +219,80 @@ export function usePromptActions({ setAwaitingResponse(false) } + // Idempotent optimistic insert — re-running with the resolved sessionId + // after createBackendSessionForSend just overwrites with the same id. + const seedOptimistic = (sid: string) => + updateSessionState( + sid, + state => ({ + ...state, + messages: state.messages.some(m => m.id === optimisticId) + ? state.messages + : [...state.messages, userMessage], + busy: true, + awaitingResponse: true, + pendingBranchGroup: null, + sawAssistantPayload: false, + interrupted: false + }), + selectedStoredSessionIdRef.current + ) + + const dropOptimistic = (sid: null | string) => { + if (!sid) { + setMessages(current => current.filter(m => m.id !== optimisticId)) + + return + } + + updateSessionState( + sid, + state => ({ + ...state, + messages: state.messages.filter(m => m.id !== optimisticId), + busy: false, + awaitingResponse: false, + pendingBranchGroup: null + }), + selectedStoredSessionIdRef.current + ) + } + busyRef.current = true setBusy(true) setAwaitingResponse(true) clearNotifications() - const [setup, runtime] = await Promise.all([ - requestGateway('setup.status').catch(() => null), - requestGateway('setup.runtime_check').catch(() => null) - ]) + let sessionId: null | string = activeSessionId - const runtimeReady = runtime?.ok !== undefined ? Boolean(runtime?.ok) : setup?.provider_configured !== false - - if (!runtimeReady) { - releaseBusy() - requestDesktopOnboarding( - runtime?.error || 'Add a provider credential before sending your first message.' - ) - - return + if (sessionId) { + seedOptimistic(sessionId) + } else { + setMessages(current => [...current, userMessage]) } - let sessionId = activeSessionId - if (!sessionId) { try { sessionId = await createBackendSessionForSend() } catch (err) { + dropOptimistic(null) releaseBusy() notifyError(err, 'Session unavailable') return } + + if (!sessionId) { + dropOptimistic(null) + releaseBusy() + notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' }) + + return + } + + seedOptimistic(sessionId) } - if (!sessionId) { - releaseBusy() - notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' }) - - return - } - - updateSessionState( - sessionId, - state => ({ - ...state, - messages: [...state.messages, userMessage], - busy: true, - awaitingResponse: true, - pendingBranchGroup: null, - sawAssistantPayload: false, - interrupted: false - }), - selectedStoredSessionIdRef.current - ) - try { await syncImageAttachmentsForSubmit(sessionId, attachments) await requestGateway('prompt.submit', { session_id: sessionId, text }) diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx new file mode 100644 index 0000000000..d0f14f13b1 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx @@ -0,0 +1,136 @@ +import { cleanup, render } from '@testing-library/react' +import type { MutableRefObject } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { useRouteResume } from './use-route-resume' + +interface HarnessProps { + activeSessionId: null | string + activeSessionIdRef: MutableRefObject + creatingSessionRef: MutableRefObject + currentView: string + freshDraftReady: boolean + gatewayState: string + locationPathname: string + resumeSession: (sessionId: string, focus: boolean) => Promise + routedSessionId: null | string + runtimeIdByStoredSessionIdRef: MutableRefObject> + selectedStoredSessionId: null | string + selectedStoredSessionIdRef: MutableRefObject + startFreshSessionDraft: (focus: boolean) => unknown +} + +function RouteResumeHarness(props: HarnessProps) { + useRouteResume(props) + + return null +} + +describe('useRouteResume', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('does not re-resume the old session during a /:sid -> /new transition', () => { + const resumeSession = vi.fn(async () => undefined) + const startFreshSessionDraft = vi.fn() + const activeSessionIdRef: MutableRefObject = { current: 'runtime-1' } + const creatingSessionRef = { current: false } + const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) } + const selectedStoredSessionIdRef: MutableRefObject = { current: 'session-1' } + + const { rerender } = render( + + ) + + expect(resumeSession).not.toHaveBeenCalled() + + // Simulate startFreshSessionDraft state updates landing before route update. + activeSessionIdRef.current = null + selectedStoredSessionIdRef.current = null + rerender( + + ) + + expect(resumeSession).not.toHaveBeenCalled() + }) + + it('resumes when pathname changes to a routed session', () => { + const resumeSession = vi.fn(async () => undefined) + const startFreshSessionDraft = vi.fn() + const activeSessionIdRef: MutableRefObject = { current: null } + const creatingSessionRef = { current: false } + const runtimeIdByStoredSessionIdRef = { current: new Map() } + const selectedStoredSessionIdRef: MutableRefObject = { current: null } + + const { rerender } = render( + + ) + + expect(resumeSession).not.toHaveBeenCalled() + + rerender( + + ) + + expect(resumeSession).toHaveBeenCalledTimes(1) + expect(resumeSession).toHaveBeenCalledWith('session-2', true) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.ts b/apps/desktop/src/app/session/hooks/use-route-resume.ts index 86d7333ea8..9f6fc5e3d5 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.ts +++ b/apps/desktop/src/app/session/hooks/use-route-resume.ts @@ -1,4 +1,4 @@ -import { type MutableRefObject, useEffect } from 'react' +import { type MutableRefObject, useEffect, useRef } from 'react' import { isNewChatRoute } from '@/app/routes' @@ -55,8 +55,17 @@ export function useRouteResume({ selectedStoredSessionIdRef, startFreshSessionDraft }: RouteResumeOptions) { + const lastPathnameRef = useRef(null) + const wasGatewayOpenRef = useRef(false) + useEffect(() => { - if (currentView !== 'chat' || gatewayState !== 'open') { + const gatewayOpen = gatewayState === 'open' + const pathnameChanged = lastPathnameRef.current !== locationPathname + const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen + lastPathnameRef.current = locationPathname + wasGatewayOpenRef.current = gatewayOpen + + if (currentView !== 'chat' || !gatewayOpen) { return } @@ -68,7 +77,12 @@ export function useRouteResume({ Boolean(cachedRuntime) && cachedRuntime === activeSessionIdRef.current - if (!alreadyActive) { + // Resume only when the route meaningfully changed (or gateway just opened). + // This avoids a transient /:sid re-resume during "new chat" state clears + // before the pathname updates from /:sid -> /. + const shouldResume = pathnameChanged || gatewayBecameOpen + + if (!alreadyActive && shouldResume && !creatingSessionRef.current) { void resumeSession(routedSessionId, true) } diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index cce9abd40f..fddfcd2023 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -46,7 +46,9 @@ function ModeCard({

- Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to - control an already-running Hermes backend on another machine or behind a trusted proxy. + Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control + an already-running Hermes backend on another machine or behind a trusted proxy.

@@ -249,7 +256,9 @@ export function GatewaySettings() { className={cn('h-8 font-mono', CONTROL_TEXT)} disabled={state.envOverride} onChange={event => setRemoteToken(event.target.value)} - placeholder={state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'} + placeholder={ + state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token' + } type="password" value={remoteToken} /> @@ -262,7 +271,11 @@ export function GatewaySettings() { {lastTest ?
{lastTest}
: null}
- diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index 61e5679e9c..f27db8478d 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -43,11 +43,12 @@ function safeSet(target: Record, key: string, value: unknown): if (key === '__proto__' || key === 'constructor' || key === 'prototype' || !key) { throw new Error(`Unsafe config key: ${key}`) } + Object.defineProperty(target, key, { value, writable: true, enumerable: true, - configurable: true, + configurable: true }) } diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index 0e5e1437bd..95f8a888a4 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -60,6 +60,7 @@ export function AppShell({ const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen) + const titlebarContentInset = sidebarOpen ? 0 : titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2) diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 61f93def2d..e598a4dbc4 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -123,8 +123,9 @@ export function useStatusbarItems({ const sha = updateStatus?.currentSha?.slice(0, 7) ?? null const behind = updateStatus?.behind ?? 0 const applying = updateApply.applying || updateApply.stage === 'restart' - const base = appVersion ? `v${appVersion}` : sha ?? 'unknown' + const base = appVersion ? `v${appVersion}` : (sha ?? 'unknown') const behindHint = !applying && behind > 0 ? ` (+${behind})` : '' + const label = applying ? updateApply.stage === 'restart' ? `${base} · restart` diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index 6ffad250e1..1020f07150 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -110,37 +110,37 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: : (item.menuItems ?? []) .filter(menuItem => !menuItem.hidden) .map(menuItem => ( - { - if (menuItem.to) { - navigate(menuItem.to) - } + { + if (menuItem.to) { + navigate(menuItem.to) + } - menuItem.onSelect?.() - }} - > - {menuItem.href ? ( - - {menuItem.icon} - {menuItem.label} - - ) : ( - <> - {menuItem.icon} - {menuItem.label} - - )} - - ))} + {menuItem.href ? ( + + {menuItem.icon} + {menuItem.label} + + ) : ( + <> + {menuItem.icon} + {menuItem.label} + + )} + + ))} ) diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 8d57c8ba74..be3a57c4aa 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -379,10 +379,7 @@ function CategoryButton({ type="button" > {label} diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index a74e174be5..398b4013b7 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -46,11 +46,9 @@ export function UpdatesOverlay() { }, [checking, open, status]) const behind = status?.behind ?? 0 - const phase: 'idle' | 'applying' | 'error' = apply.applying || apply.stage === 'restart' - ? 'applying' - : apply.stage === 'error' - ? 'error' - : 'idle' + + const phase: 'idle' | 'applying' | 'error' = + apply.applying || apply.stage === 'restart' ? 'applying' : apply.stage === 'error' ? 'error' : 'idle' const handleClose = (next: boolean) => { if (phase === 'applying') { @@ -114,7 +112,9 @@ function IdleView({ status: DesktopUpdateStatus | null }) { if (!status && checking) { - return } title="Looking for updates…" /> + return ( + } title="Looking for updates…" /> + ) } if (!status) { @@ -223,7 +223,9 @@ function IdleView({
{remaining > 0 && ( -

+ {remaining} more change{remaining === 1 ? '' : 's'} included.

+

+ + {remaining} more change{remaining === 1 ? '' : 's'} included. +

)}
) @@ -231,6 +233,7 @@ function IdleView({ function ApplyingView({ apply }: { apply: UpdateApplyState }) { const label = STAGE_LABELS[apply.stage] ?? 'Updating Hermes…' + const percent = typeof apply.percent === 'number' && Number.isFinite(apply.percent) ? Math.max(2, Math.min(100, Math.round(apply.percent))) diff --git a/apps/desktop/src/components/Backdrop.tsx b/apps/desktop/src/components/Backdrop.tsx index a3be2b3c60..c9039952d3 100644 --- a/apps/desktop/src/components/Backdrop.tsx +++ b/apps/desktop/src/components/Backdrop.tsx @@ -30,7 +30,10 @@ function binaryNoiseDataUrl(tile: number, density: number, size: number, color: return '' } - const dpr = Math.min(window.devicePixelRatio || 1, 2) + // Cap at 1.5x to match the design-language overlay perf work (PR #14): + // with `image-rendering: pixelated` there's no visible win above 1.5x, and + // a full retina (2x) PNG is ~78% larger to keep resident in compositor memory. + const dpr = Math.min(window.devicePixelRatio || 1, 1.5) const physTile = Math.round(tile * dpr) const block = Math.max(1, Math.round(size * dpr)) @@ -165,7 +168,7 @@ export function Backdrop() { {import.meta.env.DEV && } - {statue.enabled && ( + {statue.enabled && gpuTier > 0 && (
{ it('renders archived todos after turn completion regardless of pending state', () => { const first = render( - + ) const ui = within(first.container) @@ -410,9 +406,7 @@ describe('assistant-ui streaming renderer', () => { const second = render( ) diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 1e8f2cd8e7..6dfae16e7c 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -18,7 +18,7 @@ import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text' import { MarkdownText } from '@/components/assistant-ui/markdown-text' import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool' -import { ToolFallback } from '@/components/assistant-ui/tool-fallback' +import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback' import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' @@ -51,6 +51,7 @@ import { XIcon } from '@/lib/icons' import { extractPreviewTargets } from '@/lib/preview-targets' +import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' import { notifyError } from '@/store/notifications' @@ -365,7 +366,8 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> return pickPrimaryPreviewTarget(extractPreviewTargets(messageText)) }, [messageText]) - const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0) + const messageStatus = useAuiState(s => s.message.status?.type) + const isPlaceholder = messageStatus === 'running' && content.length === 0 if (isPlaceholder) { return null @@ -382,14 +384,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> data-slot="aui_assistant-message-content" > {hoistedTodos.length > 0 && } - + {previewTargets.length > 0 && (
{previewTargets.map(target => ( @@ -462,26 +457,70 @@ const ImageGenerateTool: FC = ({ result }) => { const ChainToolFallback: FC = props => { // todo parts are hoisted to a dedicated panel above the message content. - if (props.toolName === 'todo') {return null} + if (props.toolName === 'todo') { + return null + } - if (props.toolName === 'image_generate') {return } + if (props.toolName === 'image_generate') { + return + } - if (props.toolName === 'clarify') {return } + if (props.toolName === 'clarify') { + return + } return } const ThinkingDisclosure: FC<{ children: ReactNode + messageRunning?: boolean pending?: boolean timerKey?: string -}> = ({ children, pending = false, timerKey }) => { - const [open, setOpen] = useState(false) +}> = ({ children, messageRunning = false, pending = false, timerKey }) => { + // `null` = no explicit user toggle yet, defer to the streaming default. + // The default is "auto-open while streaming, auto-collapse when done" so + // reasoning surfaces a live preview without manual interaction. The first + // explicit toggle wins from then on. + const [userOpen, setUserOpen] = useState(null) const elapsed = useElapsedSeconds(pending, timerKey) + const scrollRef = useRef(null) + const contentRef = useRef(null) + const enterRef = useEnterAnimation(messageRunning, timerKey) + + const open = userOpen ?? pending + const isPreview = pending && userOpen === null + + // While the preview is live, pin the scroll container to the bottom on + // every content growth so the latest tokens are always visible. Combined + // with the top mask in styles.css, this reads as text settling in from + // below while older lines fade out at the top. + useEffect(() => { + if (!isPreview) { + return + } + + const el = scrollRef.current + const content = contentRef.current + + if (!el || !content) { + return + } + + const pin = () => { + el.scrollTop = el.scrollHeight + } + + pin() + const observer = new ResizeObserver(pin) + observer.observe(content) + + return () => observer.disconnect() + }, [isPreview]) return ( -
- setOpen(v => !v)} open={open}> +
+ setUserOpen(!open)} open={open}> {open && ( -
- {children} +
+
{children}
)}
) } -const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({ children }) => { - const pending = useAuiState(s => s.thread.isRunning && s.message.status?.type === 'running') +// Self-gate "Thinking…" on this message's own reasoning parts. Reading +// `thread.isRunning` directly would flicker shimmer/timer on every old +// assistant whenever the external-store runtime clears+reimports its +// repository (one ref-identity bump per streaming delta). +const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({ + children, + endIndex, + startIndex +}) => { const messageId = useAuiState(s => s.message.id) + const messageRunning = useAuiState(s => s.message.status?.type === 'running') - return {children} + const pending = useAuiState( + s => + s.thread.isRunning && + s.message.status?.type === 'running' && + s.message.parts + .slice(Math.max(0, startIndex), Math.min(s.message.parts.length, endIndex)) + .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete') + ) + + return ( + + {children} + + ) } const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => { @@ -528,6 +596,21 @@ const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ te ) } +// Module-level constant so the `components` prop on `MessagePrimitive.Parts` +// has a stable identity across renders. Without this every AssistantMessage +// render would create a fresh `components` object, invalidating the memo on +// `MessagePrimitivePartByIndex` and forcing every tool/reasoning child to +// re-render on every streaming delta. Memo invalidation alone doesn't +// remount, but combined with the previous ToolFallback group-swap it was a +// big chunk of the per-delta work. +const MESSAGE_PARTS_COMPONENTS = { + Reasoning: ReasoningTextPart, + ReasoningGroup: ReasoningAccordionGroup, + Text: MarkdownText, + ToolGroup: ToolGroupSlot, + tools: { Fallback: ChainToolFallback } +} as const + const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }) const SHORT_FMT = new Intl.DateTimeFormat(undefined, { diff --git a/apps/desktop/src/components/assistant-ui/todo-tool.tsx b/apps/desktop/src/components/assistant-ui/todo-tool.tsx index 6ec415ca1f..e974b6ba51 100644 --- a/apps/desktop/src/components/assistant-ui/todo-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/todo-tool.tsx @@ -6,28 +6,36 @@ import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos' import { cn } from '@/lib/utils' export function todosFromMessageContent(content: unknown): TodoItem[] { - if (!Array.isArray(content)) {return []} + if (!Array.isArray(content)) { + return [] + } let latest: null | TodoItem[] = null for (const part of content) { - if (!part || typeof part !== 'object') {continue} + if (!part || typeof part !== 'object') { + continue + } const row = part as Record - if (row.type !== 'tool-call' || row.toolName !== 'todo') {continue} + if (row.type !== 'tool-call' || row.toolName !== 'todo') { + continue + } const parsed = parseTodos(row.result) ?? parseTodos(row.args) - if (parsed !== null) {latest = parsed} + if (parsed !== null) { + latest = parsed + } } return latest ?? [] } const headerLabel = (todos: readonly TodoItem[]): string => - todos.find(t => t.status === 'in_progress')?.content - ?? todos.find(t => t.status === 'pending')?.content - ?? todos.at(-1)?.content - ?? 'Tasks' + todos.find(t => t.status === 'in_progress')?.content ?? + todos.find(t => t.status === 'pending')?.content ?? + todos.at(-1)?.content ?? + 'Tasks' const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => { if (status === 'in_progress') { @@ -49,7 +57,8 @@ const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) checked={checked} className={cn( 'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100', - checked && 'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3', + checked && + 'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3', status === 'cancelled' && 'border-muted-foreground/40' )} disabled @@ -58,7 +67,9 @@ const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) } export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => { - if (!todos.length) {return null} + if (!todos.length) { + return null + } const label = headerLabel(todos) return ( @@ -67,7 +78,10 @@ export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => { data-slot="aui_todo-hoisted" >
- + {label}
@@ -83,9 +97,7 @@ export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => { key={todo.id} > - - {todo.content} - + {todo.content} ))} diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts new file mode 100644 index 0000000000..5615fe8fe2 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts @@ -0,0 +1,1354 @@ +import { normalizeExternalUrl } from '@/lib/external-link' +import { Command, FileText, Globe, LinkIcon, Search, Sparkles, Wrench } from '@/lib/icons' +import type { LucideIcon } from '@/lib/icons' +import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary' + +export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web' +export type ToolStatus = 'error' | 'running' | 'success' | 'warning' + +export interface ToolPart { + args?: unknown + isError?: boolean + result?: unknown + toolCallId?: string + toolName: string + type: 'tool-call' +} + +export interface SearchResultRow { + snippet: string + title: string + url: string +} + +interface CountMetric { + count: number + noun: string +} + +export interface ToolView { + countLabel?: string + detail: string + detailLabel: string + durationLabel?: string + icon: LucideIcon + imageUrl?: string + inlineDiff: string + previewTarget?: string + rawArgs: string + rawResult: string + searchHits?: SearchResultRow[] + status: ToolStatus + subtitle: string + title: string + tone: ToolTone +} + +interface ToolMeta { + done: string + icon: LucideIcon + pending: string + tone: ToolTone +} + +export interface MessageRunningStateSlice { + message: { + status?: { + type?: string + } + } + thread: { + isRunning: boolean + } +} + +const TOOL_META: Record = { + browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: Globe, tone: 'browser' }, + browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: Globe, tone: 'browser' }, + browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: Globe, tone: 'browser' }, + browser_snapshot: { + done: 'Captured page snapshot', + pending: 'Capturing page snapshot', + icon: Globe, + tone: 'browser' + }, + browser_take_screenshot: { + done: 'Captured screenshot', + pending: 'Capturing screenshot', + icon: Sparkles, + tone: 'browser' + }, + browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: Globe, tone: 'browser' }, + edit_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' }, + execute_code: { done: 'Ran code', pending: 'Running code', icon: Command, tone: 'terminal' }, + image_generate: { done: 'Generated image', pending: 'Generating image', icon: Sparkles, tone: 'image' }, + list_files: { done: 'Listed files', pending: 'Listing files', icon: FileText, tone: 'file' }, + read_file: { done: 'Read file', pending: 'Reading file', icon: FileText, tone: 'file' }, + search_files: { done: 'Searched files', pending: 'Searching files', icon: FileText, tone: 'file' }, + session_search_recall: { + done: 'Searched session history', + pending: 'Searching session history', + icon: Search, + tone: 'agent' + }, + terminal: { done: 'Ran command', pending: 'Running command', icon: Command, tone: 'terminal' }, + todo: { done: 'Updated todos', pending: 'Updating todos', icon: Wrench, tone: 'agent' }, + web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: LinkIcon, tone: 'web' }, + web_search: { done: 'Searched web', pending: 'Searching web', icon: Search, tone: 'web' }, + write_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' } +} + +const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g +const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*\d+)*)\](?!\()/gu +const BACKTICK_NOISE_RE = /`{3,}/g + +export const selectMessageRunning = (state: MessageRunningStateSlice) => + state.thread.isRunning && state.message.status?.type === 'running' + +function titleForTool(name: string): string { + const normalized = name.replace(/^browser_/, '').replace(/^web_/, '') + + return ( + normalized + .split('_') + .filter(Boolean) + .map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) + .join(' ') || name + ) +} + +const PREFIX_META: { icon: LucideIcon; prefix: string; tone: ToolTone; verb: string }[] = [ + { prefix: 'browser_', verb: 'Browser', icon: Globe, tone: 'browser' }, + { prefix: 'web_', verb: 'Web', icon: Search, tone: 'web' } +] + +function toolMeta(name: string): ToolMeta { + if (TOOL_META[name]) { + return TOOL_META[name] + } + + const action = titleForTool(name) + const prefix = PREFIX_META.find(p => name.startsWith(p.prefix)) + + return prefix + ? { + done: `${prefix.verb} ${action}`, + pending: `Running ${prefix.verb.toLowerCase()} ${action.toLowerCase()}`, + icon: prefix.icon, + tone: prefix.tone + } + : { done: action, pending: `Running ${action.toLowerCase()}`, icon: Wrench, tone: 'default' } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + +export function compactPreview(value: unknown, max = 72): string { + let raw: unknown + + if (typeof value === 'string') { + raw = value + } else { + raw = parseMaybeObject(value).context + } + + if (typeof raw !== 'string') { + if (raw == null) { + raw = '' + } else { + try { + raw = JSON.stringify(raw) + } catch { + raw = String(raw) + } + } + } + + const line = (raw as string).replace(/\s+/g, ' ').trim() + + return line.length > max ? `${line.slice(0, max - 1)}…` : line +} + +function contextValue(value: unknown): string { + const row = parseMaybeObject(value) + + if (typeof row.context === 'string') { + return row.context + } + + if (typeof row.preview === 'string') { + return row.preview + } + + return typeof value === 'string' ? value : '' +} + +function prettyJson(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value, null, 2) +} + +function parseMaybeObject(value: unknown): Record { + if (isRecord(value)) { + return value + } + + if (typeof value !== 'string' || !value.trim()) { + return {} + } + + try { + const parsed = JSON.parse(value) + + return isRecord(parsed) ? parsed : {} + } catch { + return {} + } +} + +function unwrapToolPayload(value: unknown): unknown { + const record = parseMaybeObject(value) + + for (const key of ['data', 'result', 'output', 'response', 'payload']) { + const payload = record[key] + + if (payload !== undefined && payload !== null) { + return payload + } + } + + return value +} + +function numberValue(value: unknown): null | number { + const n = typeof value === 'number' ? value : Number(value) + + return Number.isFinite(n) ? n : null +} + +function formatDurationSeconds(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) { + return '' + } + + if (seconds < 1) { + const ms = Math.max(1, Math.round(seconds * 1000)) + + return `${ms}ms` + } + + if (seconds < 60) { + return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s` + } + + const wholeSeconds = Math.round(seconds) + const minutes = Math.floor(wholeSeconds / 60) + const remSeconds = wholeSeconds % 60 + + if (minutes < 60) { + return remSeconds ? `${minutes}m ${remSeconds}s` : `${minutes}m` + } + + const hours = Math.floor(minutes / 60) + const remMinutes = minutes % 60 + + return remMinutes ? `${hours}h ${remMinutes}m` : `${hours}h` +} + +const COUNT_FIELD_KEYS = [ + 'count', + 'total', + 'result_count', + 'results_count', + 'num_results', + 'match_count', + 'matches_count', + 'file_count', + 'files_count', + 'item_count', + 'items_count', + 'search_count', + 'searches_count', + 'source_count', + 'sources_count', + 'document_count', + 'documents_count', + 'updated', + 'added', + 'removed', + 'deleted', + 'created', + 'changed', + 'processed', + 'steps' +] as const + +const COUNT_ARRAY_KEYS = ['results', 'items', 'matches', 'files', 'documents', 'sources', 'rows'] as const + +const COUNT_EXCLUDED_KEYS = new Set(['duration_s', 'exit_code', 'status_code']) + +const COUNT_NOUN_BY_FIELD: Partial> = { + count: '', + total: '', + result_count: 'result', + results_count: 'result', + num_results: 'result', + match_count: 'match', + matches_count: 'match', + file_count: 'file', + files_count: 'file', + item_count: 'item', + items_count: 'item', + search_count: 'search', + searches_count: 'search', + source_count: 'source', + sources_count: 'source', + document_count: 'document', + documents_count: 'document', + updated: 'item', + added: 'item', + removed: 'item', + deleted: 'item', + created: 'item', + changed: 'item', + processed: 'item', + steps: 'step' +} + +const COUNT_NOUN_BY_ARRAY: Record<(typeof COUNT_ARRAY_KEYS)[number], string> = { + documents: 'document', + files: 'file', + items: 'item', + matches: 'match', + results: 'result', + rows: 'row', + sources: 'source' +} + +const DEFAULT_COUNT_NOUN_BY_TOOL: Record = { + browser_snapshot: 'item', + list_files: 'file', + search_files: 'result', + session_search_recall: 'result', + todo: 'todo', + web_search: 'result' +} + +function countFromUnknown(value: unknown): null | number { + if (Array.isArray(value)) { + return value.length > 0 ? value.length : null + } + + const n = numberValue(value) + + if (n === null || n <= 0) { + return null + } + + return Math.round(n) +} + +function singularizeNoun(noun: string): string { + const normalized = noun.trim().toLowerCase() + + if (!normalized) { + return '' + } + + if (normalized.endsWith('ies') && normalized.length > 3) { + return `${normalized.slice(0, -3)}y` + } + + if (/(xes|zes|ches|shes|sses)$/.test(normalized) && normalized.length > 3) { + return normalized.slice(0, -2) + } + + if (normalized.endsWith('s') && normalized.length > 2 && !normalized.endsWith('ss')) { + return normalized.slice(0, -1) + } + + return normalized +} + +function pluralizeNoun(noun: string, count: number): string { + if (count === 1) { + return noun + } + + if (noun === 'search') { + return 'searches' + } + + if (noun.endsWith('y') && noun.length > 1 && !/[aeiou]y$/i.test(noun)) { + return `${noun.slice(0, -1)}ies` + } + + if (/(s|x|z|ch|sh)$/i.test(noun)) { + return `${noun}es` + } + + return `${noun}s` +} + +function formatCountLabel(metric: CountMetric): string { + return `${metric.count} ${pluralizeNoun(metric.noun, metric.count)}` +} + +function countMetric(count: number, noun: string): CountMetric { + return { count, noun: singularizeNoun(noun) || 'item' } +} + +function normalizeMetricForTool(toolName: string, metric: CountMetric): CountMetric { + if (toolName === 'web_search') { + return countMetric(metric.count, 'result') + } + + return metric +} + +function fallbackCountNoun(toolName: string): string { + return DEFAULT_COUNT_NOUN_BY_TOOL[toolName] || 'item' +} + +function dynamicCountNounFromKey(key: string, fallbackNoun: string): string { + const normalized = key.toLowerCase() + + if (normalized === 'count' || normalized === 'total') { + return fallbackNoun + } + + const stripped = normalized.replace(/_(count|total)$/i, '').replace(/^num_/, '') + + return singularizeNoun(stripped) || fallbackNoun +} + +function countFromRecord(record: Record, fallbackNoun: string): CountMetric | null { + for (const key of COUNT_FIELD_KEYS) { + const value = record[key] + const count = countFromUnknown(value) + + if (count !== null) { + return countMetric(count, COUNT_NOUN_BY_FIELD[key] || fallbackNoun) + } + } + + for (const key of COUNT_ARRAY_KEYS) { + const value = record[key] + const count = countFromUnknown(value) + + if (count !== null) { + return countMetric(count, COUNT_NOUN_BY_ARRAY[key] || fallbackNoun) + } + } + + for (const [key, value] of Object.entries(record)) { + if (COUNT_EXCLUDED_KEYS.has(key)) { + continue + } + + if (!/_count$|_total$/i.test(key)) { + continue + } + + const count = countFromUnknown(value) + + if (count !== null) { + return countMetric(count, dynamicCountNounFromKey(key, fallbackNoun)) + } + } + + return null +} + +function countFromText(value: string, fallbackNoun: string): CountMetric | null { + const text = value.trim() + + if (!text) { + return null + } + + const unitMatch = + text.match(/\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b/i) || + text.match(/\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b/i) + + if (unitMatch?.[1]) { + const n = Number(unitMatch[1]) + const noun = unitMatch[2] ? singularizeNoun(unitMatch[2]) : fallbackNoun + + return Number.isFinite(n) && n > 0 ? countMetric(Math.round(n), noun) : null + } + + return null +} + +function toolResultCount( + part: ToolPart, + argsRecord: Record, + resultRecord: Record +): CountMetric | null { + if (part.result === undefined) { + return null + } + + const fallbackNounByTool = fallbackCountNoun(part.toolName) + + if (part.toolName === 'web_search') { + const hits = collectResultItems(part.result) + + if (hits.length) { + return countMetric(hits.length, 'result') + } + } + + const directCount = countFromRecord(resultRecord, fallbackNounByTool) + + if (directCount !== null) { + return normalizeMetricForTool(part.toolName, directCount) + } + + const payload = unwrapToolPayload(part.result) + + if (isRecord(payload)) { + const payloadCount = countFromRecord(payload, fallbackNounByTool) + + if (payloadCount !== null) { + return normalizeMetricForTool(part.toolName, payloadCount) + } + } + + const summaryText = + firstStringField(resultRecord, ['summary', 'message', 'detail']) || fallbackDetailText(argsRecord, resultRecord) + + const textMetric = countFromText(summaryText, fallbackNounByTool) + + return textMetric ? normalizeMetricForTool(part.toolName, textMetric) : null +} + +function looksLikeUrl(value: string): boolean { + return /^https?:\/\//i.test(value) +} + +function looksLikePath(value: string): boolean { + return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value) +} + +export function isPreviewableTarget(target: string): boolean { + return Boolean( + target && + (/^file:\/\//i.test(target) || + /^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) || + /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target)) + ) +} + +function stableHash(value: string): string { + let hash = 0 + + for (let index = 0; index < value.length; index += 1) { + hash = Math.imul(31, hash) + value.charCodeAt(index) + } + + return Math.abs(hash).toString(36) +} + +export function toolPartDisclosureId(part: ToolPart): string { + if (part.toolCallId) { + return `tool:${part.toolCallId}` + } + + return `tool:${part.toolName}:${stableHash(JSON.stringify(part.args ?? ''))}` +} + +export function toolGroupDisclosureId(parts: ToolPart[]): string { + return `tool-group:${parts.map(toolPartDisclosureId).join('|')}` +} + +const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i + +function findFirstUrl(...sources: unknown[]): string { + for (const src of sources) { + if (typeof src === 'string') { + const m = src.match(URL_PATTERN) + + if (m) { + return m[0] + } + } else if (src && typeof src === 'object') { + for (const v of Object.values(src as Record)) { + const found = findFirstUrl(v) + + if (found) { + return found + } + } + } + } + + return '' +} + +function hostnameOf(value: string): string { + try { + const url = new URL(value) + + return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}` + } catch { + return value + } +} + +export function looksRedundant(title: string, detail: string): boolean { + if (!detail) { + return true + } + + const norm = (input: string) => input.toLowerCase().replace(/\s+/g, ' ').trim() + + return norm(title) === norm(detail) +} + +export function cleanVisibleText(text: string): string { + return text + .split(INLINE_CODE_SPLIT_RE) + .map(part => + part.startsWith('`') + ? part + : part + .replace(BACKTICK_NOISE_RE, '') + .replace(CITATION_MARKER_RE, '') + .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label: string, href: string) => { + const normalized = normalizeExternalUrl(href) + + return `${label} ${normalized}` + }) + ) + .join('') +} + +function summarizeBrowserSnapshot(snapshot: string): string { + const count = (re: RegExp) => snapshot.match(re)?.length ?? 0 + + const stats = [ + `${count(/button\s+"[^"]+"/g)} buttons`, + `${count(/link\s+"[^"]+"/g)} links`, + `${count(/(?:textbox|combobox|searchbox)\s+"[^"]+"/g)} inputs` + ].join(' · ') + + const labels = Array.from(snapshot.matchAll(/(?:button|link|combobox|textbox)\s+"([^"]+)"/g)) + .map(m => m[1].trim()) + .filter(Boolean) + .slice(0, 4) + + return labels.length ? `${stats}\nTop controls: ${labels.join(', ')}` : stats +} + +function firstStringField(record: Record, keys: readonly string[]): string { + for (const key of keys) { + const value = record[key] + + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + } + + return '' +} + +function collectResultItems(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value + } + + const record = parseMaybeObject(value) + + for (const key of [ + 'web', + 'results', + 'search_results', + 'sources', + 'web_sources', + 'items', + 'organic_results', + 'organic', + 'matches', + 'documents' + ]) { + const candidate = record[key] + + if (Array.isArray(candidate)) { + return candidate + } + + if (isRecord(candidate)) { + const nested = collectResultItems(candidate) + + if (nested.length) { + return nested + } + } + } + + const payload = unwrapToolPayload(record) + + return payload === record ? [] : collectResultItems(payload) +} + +function extractSearchResults(result: unknown, limit = 6): SearchResultRow[] { + const list = collectResultItems(result) + + return list + .map(item => { + const r = parseMaybeObject(item) + + return { + title: cleanVisibleText(firstStringField(r, ['title', 'name'])), + url: firstStringField(r, ['url', 'href', 'link']), + snippet: cleanVisibleText(firstStringField(r, ['snippet', 'description', 'body'])) + } + }) + .filter(hit => hit.title || hit.url) + .slice(0, limit) +} + +function toolErrorText(part: ToolPart, result: Record): string { + const extractedError = extractToolErrorMessage(part.result) + + if (part.isError) { + return extractedError || (typeof part.result === 'string' && part.result.trim()) || 'Tool returned an error.' + } + + if (typeof result.error === 'string' && result.error.trim()) { + return result.error.trim() + } + + if (extractedError) { + return extractedError + } + + if (result.success === false || result.ok === false) { + return firstStringField(result, ['message', 'reason', 'detail']) || 'Tool returned success=false.' + } + + if (typeof result.status === 'string' && /\b(error|failed|failure)\b/i.test(result.status)) { + return firstStringField(result, ['message', 'reason', 'detail']) || `Tool returned status "${result.status}".` + } + + const exit = numberValue(result.exit_code) + + return exit !== null && exit !== 0 ? `Command failed with exit code ${exit}.` : '' +} + +function toolStatus(part: ToolPart, resultRecord: Record): ToolStatus { + if (part.result === undefined) { + return 'running' + } + + return toolErrorText(part, resultRecord) ? 'error' : 'success' +} + +function durationLabel(resultRecord: Record): string | undefined { + const seconds = numberValue(resultRecord.duration_s) + + if (seconds === null || seconds < 0) { + return undefined + } + + return formatDurationSeconds(seconds) +} + +function toolPreviewTarget(toolName: string, args: Record, result: Record): string { + const direct = + firstStringField(result, ['preview', 'url', 'target']) || + firstStringField(args, ['preview', 'url', 'target', 'path', 'file', 'filepath']) || + firstStringField(result, ['path', 'file', 'filepath']) + + if (direct && (looksLikeUrl(direct) || looksLikePath(direct))) { + return direct + } + + if (toolName === 'browser_navigate' || toolName === 'web_extract' || toolName === 'web_search') { + const explicit = firstStringField(args, ['url', 'search_term', 'query']) || firstStringField(result, ['url']) + + return looksLikeUrl(explicit) ? explicit : findFirstUrl(args, result) + } + + if (toolName === 'write_file' || toolName === 'edit_file') { + return htmlPathFromInlineDiff(firstStringField(result, ['inline_diff'])) + } + + return '' +} + +function toolImageUrl(args: Record, result: Record): string { + const candidate = + firstStringField(result, ['image_url', 'url', 'path', 'image_path']) || + firstStringField(args, ['image_url', 'url', 'path']) + + if (!candidate) { + return '' + } + + return candidate.toLowerCase().startsWith('data:image/') || /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(candidate) + ? candidate + : '' +} + +function stripAnsi(value: string): string { + return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '') +} + +export function stripInlineDiffChrome(value: string): string { + return value + ? stripAnsi(value) + .replace(/^\s*┊\s*review diff\s*\n/i, '') + .trim() + : '' +} + +function htmlPathFromInlineDiff(value: string): string { + const cleaned = stripInlineDiffChrome(value) + + for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) { + const candidate = match[1]?.trim() + + if (candidate) { + return candidate + } + } + + return '' +} + +function stripDividerLines(value: string): string { + return value + .split('\n') + .filter(line => !/^[-=]{3,}\s*$/.test(line.trim())) + .join('\n') + .trim() +} + +export function inlineDiffFromResult(result: unknown): string { + const value = parseMaybeObject(result).inline_diff + + return typeof value === 'string' ? stripInlineDiffChrome(value) : '' +} + +function minimalValueSummary(value: unknown): string { + if (value == null) { + return '' + } + + if (typeof value === 'string') { + return value + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + + if (Array.isArray(value)) { + return value.length ? `Returned ${value.length} items.` : 'No items returned.' + } + + if (isRecord(value)) { + const count = Object.keys(value).length + + return count ? `Returned object with ${count} fields.` : 'Returned an empty object.' + } + + return String(value) +} + +function fallbackDetailText(args: unknown, result: unknown): string { + const argContext = contextValue(args) + const resultContext = contextValue(result) + + if (resultContext && resultContext !== argContext) { + return resultContext + } + + if (argContext) { + return argContext + } + + if (result !== undefined) { + return formatToolResultSummary(result) || minimalValueSummary(result) + } + + return formatToolResultSummary(args) || minimalValueSummary(args) +} + +function toolSubtitle( + part: ToolPart, + argsRecord: Record, + resultRecord: Record +): string { + const toolName = part.toolName + + if (toolName === 'browser_navigate') { + const url = + firstStringField(argsRecord, ['url', 'target']) || + firstStringField(resultRecord, ['url']) || + findFirstUrl(argsRecord, resultRecord) + + return url ? hostnameOf(url) : 'Navigated in browser' + } + + if (toolName === 'browser_snapshot') { + const snapshot = firstStringField(resultRecord, ['snapshot']) + + return snapshot ? summarizeBrowserSnapshot(snapshot) : 'Captured a browser accessibility snapshot' + } + + if (toolName === 'browser_click') { + const clicked = firstStringField(resultRecord, ['clicked']) || firstStringField(argsRecord, ['ref', 'target']) + + if (!clicked) { + return 'Clicked on page' + } + + return clicked.startsWith('@') ? `Clicked page element (internal ref ${clicked})` : `Clicked ${clicked}` + } + + if (toolName === 'browser_fill' || toolName === 'browser_type') { + const field = firstStringField(argsRecord, ['label', 'field', 'ref', 'target']) + const value = firstStringField(argsRecord, ['value', 'text']) + + return ( + [field && `Field: ${field}`, value && `Value: ${compactPreview(value, 42)}`].filter(Boolean).join(' · ') || + 'Filled page input' + ) + } + + if (toolName === 'web_search') { + const query = firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord) + + return query ? `Query: ${query}` : 'Queried web sources' + } + + if (toolName === 'terminal' || toolName === 'execute_code') { + const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr']) + + const lines = Array.isArray(resultRecord.lines) + ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n') + : '' + + const previewSource = (output || lines).trim() + + if (previewSource) { + const firstMeaningfulLine = previewSource + .split('\n') + .map(line => line.trim()) + .find(line => line.length > 0) + + if (firstMeaningfulLine) { + return compactPreview(firstMeaningfulLine, 160) + } + } + + const command = firstStringField(argsRecord, ['command', 'code']) || contextValue(argsRecord) + + return command ? compactPreview(command, 120) : 'Executed command' + } + + if (toolName === 'read_file' || toolName === 'write_file' || toolName === 'edit_file') { + const path = + firstStringField(argsRecord, ['path', 'file', 'filepath']) || + htmlPathFromInlineDiff(firstStringField(resultRecord, ['inline_diff'])) + + return ( + path || + (firstStringField(resultRecord, ['inline_diff']) ? 'Changed file' : fallbackDetailText(argsRecord, resultRecord)) + ) + } + + if (toolName === 'web_extract') { + const url = + firstStringField(argsRecord, ['url']) || + firstStringField(resultRecord, ['url']) || + findFirstUrl(argsRecord, resultRecord) + + return url ? hostnameOf(url) : 'Fetched webpage' + } + + return ( + compactPreview(formatToolResultSummary(part.result), 120) || + compactPreview(resultRecord, 120) || + compactPreview(argsRecord, 120) || + fallbackDetailText(argsRecord, resultRecord) + ) +} + +function toolDetailLabel(toolName: string): string { + if (toolName === 'web_search') { + return 'Details' + } + + if (toolName === 'browser_snapshot') { + return 'Snapshot summary' + } + + if (toolName === 'terminal' || toolName === 'execute_code') { + return 'Command output' + } + + return '' +} + +function toolDetailText( + part: ToolPart, + argsRecord: Record, + resultRecord: Record +): string { + if (part.toolName === 'browser_snapshot') { + const snapshot = firstStringField(resultRecord, ['snapshot']) + + return snapshot ? summarizeBrowserSnapshot(snapshot) : fallbackDetailText(argsRecord, resultRecord) + } + + if (part.toolName === 'terminal' || part.toolName === 'execute_code') { + const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr']) + + const lines = Array.isArray(resultRecord.lines) + ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n') + : '' + + if (output || lines) { + return [output, lines].filter(Boolean).join('\n') + } + } + + if (part.toolName === 'web_extract') { + const direct = firstStringField(resultRecord, ['content', 'text', 'markdown', 'body', 'summary', 'message']) + + if (direct) { + return direct.replace(/\s*in\s+\d+(?:\.\d+)?s\s*$/i, '').trim() + } + + const results = Array.isArray(resultRecord.results) ? resultRecord.results : [] + + const aggregated = results + .map(item => { + const row = parseMaybeObject(item) + + return firstStringField(row, ['content', 'text', 'markdown', 'body']) + }) + .filter(Boolean) + .join('\n\n---\n\n') + + if (aggregated) { + return aggregated + } + } + + if (part.toolName === 'read_file') { + const content = firstStringField(resultRecord, ['content', 'text', 'data', 'body']) + + if (content) { + return content + } + } + + if (part.toolName === 'write_file' || part.toolName === 'edit_file') { + return inlineDiffFromResult(part.result) ? '' : fallbackDetailText(argsRecord, resultRecord) + } + + if (part.toolName === 'web_search') { + const detail = fallbackDetailText(argsRecord, resultRecord) + const seconds = numberValue(resultRecord.duration_s) + const duration = seconds === null ? '' : formatDurationSeconds(seconds) + + if (!duration) { + return detail + } + + return detail + .replace(/^\s*-\s*Duration\s+S\s*:\s*[-+]?[\d.]+(?:e[-+]?\d+)?\s*$/gim, `- Duration: ${duration}`) + .replace(/\bDuration\s+S\s*:/gi, 'Duration:') + } + + return fallbackDetailText(argsRecord, resultRecord) +} + +export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string; text: string } { + const args = parseMaybeObject(part.args) + const result = parseMaybeObject(part.result) + const detail = view.detail.trim() + const hasSubstantialOutput = detail.length > 16 + + if (part.toolName === 'terminal' || part.toolName === 'execute_code') { + if (hasSubstantialOutput) { + return { label: 'Copy output', text: detail } + } + + const command = firstStringField(args, ['command', 'code']) || contextValue(args) + + if (command) { + return { label: 'Copy command', text: command } + } + } + + if (part.toolName === 'web_extract') { + if (hasSubstantialOutput) { + return { label: 'Copy content', text: detail } + } + + const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result) + + if (url) { + return { label: 'Copy URL', text: url } + } + } + + if (part.toolName === 'browser_navigate') { + const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result) + + if (url) { + return { label: 'Copy URL', text: url } + } + } + + if (part.toolName === 'web_search') { + if (view.searchHits?.length) { + const text = view.searchHits.map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n')).join('\n\n') + + return { label: 'Copy results', text } + } + + const query = firstStringField(args, ['search_term', 'query']) || contextValue(args) + + if (query) { + return { label: 'Copy query', text: query } + } + } + + if (part.toolName === 'read_file') { + if (hasSubstantialOutput) { + return { label: 'Copy file', text: detail } + } + + const path = firstStringField(args, ['path', 'file', 'filepath']) + + if (path) { + return { label: 'Copy path', text: path } + } + } + + if (part.toolName === 'write_file' || part.toolName === 'edit_file') { + const path = firstStringField(args, ['path', 'file', 'filepath']) + + if (path) { + return { label: 'Copy path', text: path } + } + } + + if (detail) { + return { label: 'Copy output', text: detail } + } + + return { label: 'Copy', text: view.title } +} + +function dynamicTitle( + part: ToolPart, + args: Record, + result: Record, + fallback: string +): string { + const verb = (gerund: string, past: string) => (part.result === undefined ? gerund : past) + + if (part.toolName === 'web_extract') { + const url = findFirstUrl(args, result) + + return url ? `${verb('Reading', 'Read')} ${hostnameOf(url)}` : fallback + } + + if (part.toolName === 'browser_navigate') { + const url = findFirstUrl(args, result) + + return url ? `${verb('Opening', 'Opened')} ${hostnameOf(url)}` : fallback + } + + if (part.toolName === 'web_search') { + const query = firstStringField(args, ['search_term', 'query']) || contextValue(args) + + return query ? `${verb('Searching', 'Searched')} “${compactPreview(query, 48)}”` : fallback + } + + if (part.toolName === 'terminal' || part.toolName === 'execute_code') { + const command = firstStringField(args, ['command', 'code']) || contextValue(args) + + if (command) { + const verbText = part.toolName === 'execute_code' ? verb('Running code', 'Ran code') : verb('Running', 'Ran') + + return `${verbText} · ${compactPreview(command, 160)}` + } + } + + return fallback +} + +export function buildToolView(part: ToolPart, inlineDiff: string): ToolView { + const argsRecord = parseMaybeObject(part.args) + const resultRecord = parseMaybeObject(part.result) + const meta = toolMeta(part.toolName) + const status = toolStatus(part, resultRecord) + const error = toolErrorText(part, resultRecord) + const baseTitle = part.result === undefined ? meta.pending : meta.done + const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle) + const titleEnriched = title !== baseTitle + const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord) + const keepSubtitleWithTitle = part.toolName === 'terminal' || part.toolName === 'execute_code' + const subtitle = titleEnriched && !error && !keepSubtitleWithTitle ? '' : baseSubtitle + const detailBody = stripDividerLines(toolDetailText(part, argsRecord, resultRecord)) + + const detail = error + ? [error, detailBody] + .filter(Boolean) + .filter((value, index, list) => list.findIndex(entry => entry.trim() === value.trim()) === index) + .join('\n\n') + : detailBody + + const searchHits = + part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined + const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord) + + return { + countLabel: resultCount ? formatCountLabel(resultCount) : undefined, + detail, + detailLabel: error ? 'Error details' : toolDetailLabel(part.toolName), + durationLabel: durationLabel(resultRecord), + icon: meta.icon, + imageUrl: toolImageUrl(argsRecord, resultRecord), + inlineDiff, + previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord), + rawArgs: prettyJson(part.args), + rawResult: prettyJson(part.result), + searchHits: searchHits?.length ? searchHits : undefined, + status, + subtitle, + title, + tone: meta.tone + } +} + +function isToolPart(part: unknown): part is ToolPart { + if (!part || typeof part !== 'object') { + return false + } + + const row = part as Record + + return row.type === 'tool-call' && typeof row.toolName === 'string' +} + +export function groupToolParts(content: unknown): ToolPart[][] { + if (!Array.isArray(content)) { + return [] + } + + const groups: ToolPart[][] = [] + let current: ToolPart[] = [] + + for (const part of content) { + // todo parts render in their own hoisted panel; skip from grouped tools. + if (isToolPart(part) && part.toolName !== 'todo') { + current.push(part) + + continue + } + + if (current.length) { + groups.push(current) + current = [] + } + } + + if (current.length) { + groups.push(current) + } + + return groups +} + +export function groupStatus(parts: ToolPart[]): ToolStatus { + if (parts.some(p => p.result === undefined)) { + return 'running' + } + + const statuses = parts.map(part => toolStatus(part, parseMaybeObject(part.result))) + const hasError = statuses.includes('error') + + if (!hasError) { + return 'success' + } + + return statuses.at(-1) === 'success' ? 'warning' : 'error' +} + +export function groupTitle(parts: ToolPart[]): string { + const prefix = PREFIX_META.find(p => parts.every(part => part.toolName.startsWith(p.prefix))) + const verb = prefix?.verb || 'Tool' + + return `${verb} actions · ${parts.length} steps` +} + +export function groupPreviewTargets(parts: ToolPart[]): string[] { + const seen = new Set() + const targets: string[] = [] + + for (const part of parts) { + const view = buildToolView(part, inlineDiffFromResult(part.result)) + const target = view.previewTarget + + if (target && isPreviewableTarget(target) && !seen.has(target)) { + seen.add(target) + targets.push(target) + } + } + + return targets +} + +export function groupFailedStepCount(parts: ToolPart[]): number { + return parts.filter(part => toolStatus(part, parseMaybeObject(part.result)) === 'error').length +} + +export function groupTotalDurationLabel(parts: ToolPart[]): string { + const seconds = parts.reduce((sum, part) => { + const value = numberValue(parseMaybeObject(part.result).duration_s) + + return sum + (value && value > 0 ? value : 0) + }, 0) + + if (!seconds) { + return '' + } + + return formatDurationSeconds(seconds) +} + +export function groupTailSubtitle(parts: ToolPart[]): string { + const tail = parts.at(-1) + + return tail ? buildToolView(tail, '').subtitle : '' +} + +export function groupCopyText(parts: ToolPart[]): string { + return parts + .map(part => { + const view = buildToolView(part, '') + const lines = [view.title] + + if (view.subtitle && view.subtitle !== view.title) { + lines.push(view.subtitle) + } + + if (view.detail && view.detail !== view.subtitle) { + lines.push(view.detail) + } + + return lines.join('\n') + }) + .join('\n\n') +} diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index 188dc62a94..09fc3279dc 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -2,7 +2,8 @@ import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type ReactNode, useEffect, useMemo, useRef } from 'react' +import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react' +import { useShallow } from 'zustand/shallow' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' @@ -10,1001 +11,48 @@ import { CompactMarkdown } from '@/components/chat/compact-markdown' import { DisclosureRow } from '@/components/chat/disclosure-row' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { ZoomableImage } from '@/components/chat/zoomable-image' +import { BrailleSpinner } from '@/components/ui/braille-spinner' import { CopyButton } from '@/components/ui/copy-button' import { FadeText } from '@/components/ui/fade-text' -import { normalizeExternalUrl, PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link' -import { - AlertCircle, - CheckCircle2, - Command, - FileText, - Globe, - LinkIcon, - Loader2, - Search, - Sparkles, - Wrench -} from '@/lib/icons' -import type { LucideIcon } from '@/lib/icons' -import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary' +import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link' +import { AlertCircle, CheckCircle2 } from '@/lib/icons' +import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { $toolInlineDiffs } from '@/store/tool-diffs' -import { $toolDisclosureStates, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' - -type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web' -type ToolStatus = 'error' | 'running' | 'success' | 'warning' - -interface ToolPart { - args?: unknown - isError?: boolean - result?: unknown - toolCallId?: string - toolName: string - type: 'tool-call' -} - -interface SearchResultRow { - snippet: string - title: string - url: string -} - -interface ToolView { - detail: string - detailLabel: string - durationLabel?: string - icon: LucideIcon - imageUrl?: string - inlineDiff: string - previewTarget?: string - rawArgs: string - rawResult: string - searchHits?: SearchResultRow[] - status: ToolStatus - subtitle: string - title: string - tone: ToolTone -} - -interface ToolMeta { - done: string - icon: LucideIcon - pending: string - tone: ToolTone -} - -const TOOL_META: Record = { - browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: Globe, tone: 'browser' }, - browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: Globe, tone: 'browser' }, - browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: Globe, tone: 'browser' }, - browser_snapshot: { - done: 'Captured page snapshot', - pending: 'Capturing page snapshot', - icon: Globe, - tone: 'browser' - }, - browser_take_screenshot: { - done: 'Captured screenshot', - pending: 'Capturing screenshot', - icon: Sparkles, - tone: 'browser' - }, - browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: Globe, tone: 'browser' }, - edit_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' }, - execute_code: { done: 'Ran code', pending: 'Running code', icon: Command, tone: 'terminal' }, - image_generate: { done: 'Generated image', pending: 'Generating image', icon: Sparkles, tone: 'image' }, - list_files: { done: 'Listed files', pending: 'Listing files', icon: FileText, tone: 'file' }, - read_file: { done: 'Read file', pending: 'Reading file', icon: FileText, tone: 'file' }, - search_files: { done: 'Searched files', pending: 'Searching files', icon: FileText, tone: 'file' }, - session_search_recall: { - done: 'Searched session history', - pending: 'Searching session history', - icon: Search, - tone: 'agent' - }, - terminal: { done: 'Ran command', pending: 'Running command', icon: Command, tone: 'terminal' }, - todo: { done: 'Updated todos', pending: 'Updating todos', icon: Wrench, tone: 'agent' }, - web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: LinkIcon, tone: 'web' }, - web_search: { done: 'Searched web', pending: 'Searching web', icon: Search, tone: 'web' }, - write_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' } -} - -const TOOL_TONE_CLASS: Record = { - agent: 'bg-amber-500/12 text-amber-700 dark:text-amber-300', - browser: 'bg-sky-500/12 text-sky-700 dark:text-sky-300', - default: 'bg-muted text-muted-foreground', - file: 'bg-slate-500/12 text-slate-700 dark:text-slate-300', - image: 'bg-rose-500/12 text-rose-700 dark:text-rose-300', - terminal: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300', - web: 'bg-violet-500/12 text-violet-700 dark:text-violet-300' -} - -const STATUS_ICON_CLASS: Record = { - error: 'bg-destructive/12 text-destructive', - running: '', - success: '', - warning: 'bg-amber-500/14 text-amber-700 dark:text-amber-300' -} - -const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g -const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*\d+)*)\](?!\()/gu -const BACKTICK_NOISE_RE = /`{3,}/g - -function titleForTool(name: string): string { - const normalized = name.replace(/^browser_/, '').replace(/^web_/, '') - - return ( - normalized - .split('_') - .filter(Boolean) - .map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) - .join(' ') || name - ) -} - -const PREFIX_META: { icon: LucideIcon; prefix: string; tone: ToolTone; verb: string }[] = [ - { prefix: 'browser_', verb: 'Browser', icon: Globe, tone: 'browser' }, - { prefix: 'web_', verb: 'Web', icon: Search, tone: 'web' } -] - -function toolMeta(name: string): ToolMeta { - if (TOOL_META[name]) { - return TOOL_META[name] - } - - const action = titleForTool(name) - const prefix = PREFIX_META.find(p => name.startsWith(p.prefix)) - - return prefix - ? { - done: `${prefix.verb} ${action}`, - pending: `Running ${prefix.verb.toLowerCase()} ${action.toLowerCase()}`, - icon: prefix.icon, - tone: prefix.tone - } - : { done: action, pending: `Running ${action.toLowerCase()}`, icon: Wrench, tone: 'default' } -} - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === 'object' && !Array.isArray(value)) -} - -function compactPreview(value: unknown, max = 72): string { - let raw: unknown - - if (typeof value === 'string') { - raw = value - } else { - raw = parseMaybeObject(value).context - } - - if (typeof raw !== 'string') { - if (raw == null) { - raw = '' - } else { - try { - raw = JSON.stringify(raw) - } catch { - raw = String(raw) - } - } - } - - const line = (raw as string).replace(/\s+/g, ' ').trim() - - return line.length > max ? `${line.slice(0, max - 1)}…` : line -} - -function contextValue(value: unknown): string { - const row = parseMaybeObject(value) - - if (typeof row.context === 'string') { - return row.context - } - - if (typeof row.preview === 'string') { - return row.preview - } - - return typeof value === 'string' ? value : '' -} - -function prettyJson(value: unknown): string { - return typeof value === 'string' ? value : JSON.stringify(value, null, 2) -} - -function parseMaybeObject(value: unknown): Record { - if (isRecord(value)) { - return value - } - - if (typeof value !== 'string' || !value.trim()) { - return {} - } - - try { - const parsed = JSON.parse(value) - - return isRecord(parsed) ? parsed : {} - } catch { - return {} - } -} - -function unwrapToolPayload(value: unknown): unknown { - const record = parseMaybeObject(value) - - for (const key of ['data', 'result', 'output', 'response', 'payload']) { - const payload = record[key] - - if (payload !== undefined && payload !== null) { - return payload - } - } - - return value -} - -function numberValue(value: unknown): null | number { - const n = typeof value === 'number' ? value : Number(value) - - return Number.isFinite(n) ? n : null -} - -function looksLikeUrl(value: string): boolean { - return /^https?:\/\//i.test(value) -} - -function looksLikePath(value: string): boolean { - return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value) -} - -function isPreviewableTarget(target: string): boolean { - return Boolean( - target && - (/^file:\/\//i.test(target) || - /^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) || - /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target)) - ) -} - -function stableHash(value: string): string { - let hash = 0 - - for (let index = 0; index < value.length; index += 1) { - hash = Math.imul(31, hash) + value.charCodeAt(index) - } - - return Math.abs(hash).toString(36) -} - -function toolPartDisclosureId(part: ToolPart): string { - if (part.toolCallId) { - return `tool:${part.toolCallId}` - } - - return `tool:${part.toolName}:${stableHash(JSON.stringify(part.args ?? ''))}` -} - -function toolGroupDisclosureId(parts: ToolPart[]): string { - return `tool-group:${parts.map(toolPartDisclosureId).join('|')}` -} - -const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i - -function findFirstUrl(...sources: unknown[]): string { - for (const src of sources) { - if (typeof src === 'string') { - const m = src.match(URL_PATTERN) - - if (m) { - return m[0] - } - } else if (src && typeof src === 'object') { - for (const v of Object.values(src as Record)) { - const found = findFirstUrl(v) - - if (found) { - return found - } - } - } - } - - return '' -} - -function hostnameOf(value: string): string { - try { - const url = new URL(value) - - return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}` - } catch { - return value - } -} - -function looksRedundant(title: string, detail: string): boolean { - if (!detail) { - return true - } - - const norm = (input: string) => input.toLowerCase().replace(/\s+/g, ' ').trim() - - return norm(title) === norm(detail) -} - -function cleanVisibleText(text: string): string { - return text - .split(INLINE_CODE_SPLIT_RE) - .map(part => - part.startsWith('`') - ? part - : part - .replace(BACKTICK_NOISE_RE, '') - .replace(CITATION_MARKER_RE, '') - .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label: string, href: string) => { - const normalized = normalizeExternalUrl(href) - - return `${label} ${normalized}` - }) - ) - .join('') -} - -function LinkifiedText({ className, text }: { className?: string; text: string }) { - return -} - -function summarizeBrowserSnapshot(snapshot: string): string { - const count = (re: RegExp) => snapshot.match(re)?.length ?? 0 - - const stats = [ - `${count(/button\s+"[^"]+"/g)} buttons`, - `${count(/link\s+"[^"]+"/g)} links`, - `${count(/(?:textbox|combobox|searchbox)\s+"[^"]+"/g)} inputs` - ].join(' · ') - - const labels = Array.from(snapshot.matchAll(/(?:button|link|combobox|textbox)\s+"([^"]+)"/g)) - .map(m => m[1].trim()) - .filter(Boolean) - .slice(0, 4) - - return labels.length ? `${stats}\nTop controls: ${labels.join(', ')}` : stats -} - -function firstStringField(record: Record, keys: readonly string[]): string { - for (const key of keys) { - const value = record[key] - - if (typeof value === 'string' && value.trim()) { - return value.trim() - } - } - - return '' -} - -function collectResultItems(value: unknown): unknown[] { - if (Array.isArray(value)) { - return value - } - - const record = parseMaybeObject(value) - - for (const key of ['web', 'results', 'items', 'organic_results', 'organic', 'matches', 'documents']) { - const candidate = record[key] - - if (Array.isArray(candidate)) { - return candidate - } - - if (isRecord(candidate)) { - const nested = collectResultItems(candidate) - - if (nested.length) { - return nested - } - } - } - - const payload = unwrapToolPayload(record) - - return payload === record ? [] : collectResultItems(payload) -} - -function extractSearchResults(result: unknown, limit = 6): SearchResultRow[] { - const list = collectResultItems(result) - - return list - .map(item => { - const r = parseMaybeObject(item) - - return { - title: cleanVisibleText(firstStringField(r, ['title', 'name'])), - url: firstStringField(r, ['url', 'href', 'link']), - snippet: cleanVisibleText(firstStringField(r, ['snippet', 'description', 'body'])) - } - }) - .filter(hit => hit.title || hit.url) - .slice(0, limit) -} - -function toolErrorText(part: ToolPart, result: Record): string { - const extractedError = extractToolErrorMessage(part.result) - - if (part.isError) { - return extractedError || (typeof part.result === 'string' && part.result.trim()) || 'Tool returned an error.' - } - - if (typeof result.error === 'string' && result.error.trim()) { - return result.error.trim() - } - - if (extractedError) { - return extractedError - } - - if (result.success === false || result.ok === false) { - return firstStringField(result, ['message', 'reason', 'detail']) || 'Tool returned success=false.' - } - - if (typeof result.status === 'string' && /\b(error|failed|failure)\b/i.test(result.status)) { - return firstStringField(result, ['message', 'reason', 'detail']) || `Tool returned status "${result.status}".` - } - - const exit = numberValue(result.exit_code) - - return exit !== null && exit !== 0 ? `Command failed with exit code ${exit}.` : '' -} - -function toolStatus(part: ToolPart, resultRecord: Record): ToolStatus { - if (part.result === undefined) { - return 'running' - } - - return toolErrorText(part, resultRecord) ? 'error' : 'success' -} - -function durationLabel(resultRecord: Record): string | undefined { - const seconds = numberValue(resultRecord.duration_s) - - if (seconds === null || seconds < 0) { - return undefined - } - - return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s` -} - -function toolPreviewTarget(toolName: string, args: Record, result: Record): string { - const direct = - firstStringField(result, ['preview', 'url', 'target']) || - firstStringField(args, ['preview', 'url', 'target', 'path', 'file', 'filepath']) || - firstStringField(result, ['path', 'file', 'filepath']) - - if (direct && (looksLikeUrl(direct) || looksLikePath(direct))) { - return direct - } - - if (toolName === 'browser_navigate' || toolName === 'web_extract' || toolName === 'web_search') { - const explicit = firstStringField(args, ['url', 'search_term', 'query']) || firstStringField(result, ['url']) - - return looksLikeUrl(explicit) ? explicit : findFirstUrl(args, result) - } - - if (toolName === 'write_file' || toolName === 'edit_file') { - return htmlPathFromInlineDiff(firstStringField(result, ['inline_diff'])) - } - - return '' -} - -function toolImageUrl(args: Record, result: Record): string { - const candidate = - firstStringField(result, ['image_url', 'url', 'path', 'image_path']) || - firstStringField(args, ['image_url', 'url', 'path']) - - if (!candidate) { - return '' - } - - return candidate.toLowerCase().startsWith('data:image/') || /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(candidate) - ? candidate - : '' -} - -function stripAnsi(value: string): string { - return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '') -} - -function stripInlineDiffChrome(value: string): string { - return value - ? stripAnsi(value) - .replace(/^\s*┊\s*review diff\s*\n/i, '') - .trim() - : '' -} - -function htmlPathFromInlineDiff(value: string): string { - const cleaned = stripInlineDiffChrome(value) - - for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) { - const candidate = match[1]?.trim() - - if (candidate) { - return candidate - } - } - - return '' -} - -function stripDividerLines(value: string): string { - return value - .split('\n') - .filter(line => !/^[-=]{3,}\s*$/.test(line.trim())) - .join('\n') - .trim() -} - -function inlineDiffFromResult(result: unknown): string { - const value = parseMaybeObject(result).inline_diff - - return typeof value === 'string' ? stripInlineDiffChrome(value) : '' -} - -function minimalValueSummary(value: unknown): string { - if (value == null) { - return '' - } - - if (typeof value === 'string') { - return value - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value) - } - - if (Array.isArray(value)) { - return value.length ? `Returned ${value.length} items.` : 'No items returned.' - } - - if (isRecord(value)) { - const count = Object.keys(value).length - - return count ? `Returned object with ${count} fields.` : 'Returned an empty object.' - } - - return String(value) -} - -function fallbackDetailText(args: unknown, result: unknown): string { - const argContext = contextValue(args) - const resultContext = contextValue(result) - - if (resultContext && resultContext !== argContext) { - return resultContext - } - - if (argContext) { - return argContext - } - - if (result !== undefined) { - return formatToolResultSummary(result) || minimalValueSummary(result) - } - - return formatToolResultSummary(args) || minimalValueSummary(args) -} - -function toolSubtitle( - part: ToolPart, - argsRecord: Record, - resultRecord: Record -): string { - const toolName = part.toolName - - if (toolName === 'browser_navigate') { - const url = - firstStringField(argsRecord, ['url', 'target']) || - firstStringField(resultRecord, ['url']) || - findFirstUrl(argsRecord, resultRecord) - - return url ? hostnameOf(url) : 'Navigated in browser' - } - - if (toolName === 'browser_snapshot') { - const snapshot = firstStringField(resultRecord, ['snapshot']) - - return snapshot ? summarizeBrowserSnapshot(snapshot) : 'Captured a browser accessibility snapshot' - } - - if (toolName === 'browser_click') { - const clicked = firstStringField(resultRecord, ['clicked']) || firstStringField(argsRecord, ['ref', 'target']) - - if (!clicked) { - return 'Clicked on page' - } - - return clicked.startsWith('@') ? `Clicked page element (internal ref ${clicked})` : `Clicked ${clicked}` - } - - if (toolName === 'browser_fill' || toolName === 'browser_type') { - const field = firstStringField(argsRecord, ['label', 'field', 'ref', 'target']) - const value = firstStringField(argsRecord, ['value', 'text']) - - return ( - [field && `Field: ${field}`, value && `Value: ${compactPreview(value, 42)}`].filter(Boolean).join(' · ') || - 'Filled page input' - ) - } - - if (toolName === 'web_search') { - const query = firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord) - - return query ? `Query: ${query}` : 'Queried web sources' - } - - if (toolName === 'terminal' || toolName === 'execute_code') { - const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr']) - - const lines = Array.isArray(resultRecord.lines) - ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n') - : '' - - const previewSource = (output || lines).trim() - - if (previewSource) { - const firstMeaningfulLine = previewSource - .split('\n') - .map(line => line.trim()) - .find(line => line.length > 0) - - if (firstMeaningfulLine) { - return compactPreview(firstMeaningfulLine, 160) - } - } - - const command = firstStringField(argsRecord, ['command', 'code']) || contextValue(argsRecord) - - return command ? compactPreview(command, 120) : 'Executed command' - } - - if (toolName === 'read_file' || toolName === 'write_file' || toolName === 'edit_file') { - const path = - firstStringField(argsRecord, ['path', 'file', 'filepath']) || - htmlPathFromInlineDiff(firstStringField(resultRecord, ['inline_diff'])) - - return ( - path || - (firstStringField(resultRecord, ['inline_diff']) ? 'Changed file' : fallbackDetailText(argsRecord, resultRecord)) - ) - } - - if (toolName === 'web_extract') { - const url = - firstStringField(argsRecord, ['url']) || - firstStringField(resultRecord, ['url']) || - findFirstUrl(argsRecord, resultRecord) - - return url ? hostnameOf(url) : 'Fetched webpage' - } - - return ( - compactPreview(formatToolResultSummary(part.result), 120) || - compactPreview(resultRecord, 120) || - compactPreview(argsRecord, 120) || - fallbackDetailText(argsRecord, resultRecord) - ) -} - -function toolDetailLabel(toolName: string): string { - if (toolName === 'web_search') { - return 'Search results' - } - - if (toolName === 'browser_snapshot') { - return 'Snapshot summary' - } - - if (toolName === 'terminal' || toolName === 'execute_code') { - return 'Command output' - } - - return '' -} - -function toolDetailText( - part: ToolPart, - argsRecord: Record, - resultRecord: Record -): string { - if (part.toolName === 'browser_snapshot') { - const snapshot = firstStringField(resultRecord, ['snapshot']) - - return snapshot ? summarizeBrowserSnapshot(snapshot) : fallbackDetailText(argsRecord, resultRecord) - } - - if (part.toolName === 'web_search') { - // Structured render takes over for search results — see view.searchHits. - // The text fallback below is kept only for the case where extraction - // fails entirely so the user still sees something useful. - const hits = extractSearchResults(part.result) - - if (hits.length) { - return '' - } - } - - if (part.toolName === 'terminal' || part.toolName === 'execute_code') { - const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr']) - - const lines = Array.isArray(resultRecord.lines) - ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n') - : '' - - if (output || lines) { - return [output, lines].filter(Boolean).join('\n') - } - } - - if (part.toolName === 'web_extract') { - const direct = firstStringField(resultRecord, ['content', 'text', 'markdown', 'body', 'summary', 'message']) - - if (direct) { - return direct.replace(/\s*in\s+\d+(?:\.\d+)?s\s*$/i, '').trim() - } - - const results = Array.isArray(resultRecord.results) ? resultRecord.results : [] - - const aggregated = results - .map(item => { - const row = parseMaybeObject(item) - - return firstStringField(row, ['content', 'text', 'markdown', 'body']) - }) - .filter(Boolean) - .join('\n\n---\n\n') - - if (aggregated) { - return aggregated - } - } - - if (part.toolName === 'read_file') { - const content = firstStringField(resultRecord, ['content', 'text', 'data', 'body']) - - if (content) { - return content - } - } - - if (part.toolName === 'write_file' || part.toolName === 'edit_file') { - return inlineDiffFromResult(part.result) ? '' : fallbackDetailText(argsRecord, resultRecord) - } - - return fallbackDetailText(argsRecord, resultRecord) -} - -function toolCopyPayload(part: ToolPart, view: ToolView): { label: string; text: string } { - const args = parseMaybeObject(part.args) - const result = parseMaybeObject(part.result) - const detail = view.detail.trim() - const hasSubstantialOutput = detail.length > 16 - - if (part.toolName === 'terminal' || part.toolName === 'execute_code') { - if (hasSubstantialOutput) { - return { label: 'Copy output', text: detail } - } - - const command = firstStringField(args, ['command', 'code']) || contextValue(args) - - if (command) { - return { label: 'Copy command', text: command } - } - } - - if (part.toolName === 'web_extract') { - if (hasSubstantialOutput) { - return { label: 'Copy content', text: detail } - } - - const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result) - - if (url) { - return { label: 'Copy URL', text: url } - } - } - - if (part.toolName === 'browser_navigate') { - const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result) - - if (url) { - return { label: 'Copy URL', text: url } - } - } - - if (part.toolName === 'web_search') { - if (view.searchHits?.length) { - const text = view.searchHits - .map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n')) - .join('\n\n') - - return { label: 'Copy results', text } - } - - const query = firstStringField(args, ['search_term', 'query']) || contextValue(args) - - if (query) { - return { label: 'Copy query', text: query } - } - } - - if (part.toolName === 'read_file') { - if (hasSubstantialOutput) { - return { label: 'Copy file', text: detail } - } - - const path = firstStringField(args, ['path', 'file', 'filepath']) - - if (path) { - return { label: 'Copy path', text: path } - } - } - - if (part.toolName === 'write_file' || part.toolName === 'edit_file') { - const path = firstStringField(args, ['path', 'file', 'filepath']) - - if (path) { - return { label: 'Copy path', text: path } - } - } - - if (detail) { - return { label: 'Copy output', text: detail } - } - - return { label: 'Copy', text: view.title } -} - -function dynamicTitle( - part: ToolPart, - args: Record, - result: Record, - fallback: string -): string { - const verb = (gerund: string, past: string) => (part.result === undefined ? gerund : past) - - if (part.toolName === 'web_extract') { - const url = findFirstUrl(args, result) - - return url ? `${verb('Reading', 'Read')} ${hostnameOf(url)}` : fallback - } - - if (part.toolName === 'browser_navigate') { - const url = findFirstUrl(args, result) - - return url ? `${verb('Opening', 'Opened')} ${hostnameOf(url)}` : fallback - } - - if (part.toolName === 'web_search') { - const query = firstStringField(args, ['search_term', 'query']) || contextValue(args) - - return query ? `${verb('Searching', 'Searched')} “${compactPreview(query, 48)}”` : fallback - } - - if (part.toolName === 'terminal' || part.toolName === 'execute_code') { - const command = firstStringField(args, ['command', 'code']) || contextValue(args) - - if (command) { - const verbText = part.toolName === 'execute_code' ? verb('Running code', 'Ran code') : verb('Running', 'Ran') - - return `${verbText} · ${compactPreview(command, 160)}` - } - } - - return fallback -} - -function buildToolView(part: ToolPart, inlineDiff: string): ToolView { - const argsRecord = parseMaybeObject(part.args) - const resultRecord = parseMaybeObject(part.result) - const meta = toolMeta(part.toolName) - const status = toolStatus(part, resultRecord) - const error = toolErrorText(part, resultRecord) - const baseTitle = part.result === undefined ? meta.pending : meta.done - const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle) - const titleEnriched = title !== baseTitle - const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord) - const keepSubtitleWithTitle = part.toolName === 'terminal' || part.toolName === 'execute_code' - const subtitle = titleEnriched && !error && !keepSubtitleWithTitle ? '' : baseSubtitle - const detailBody = stripDividerLines(toolDetailText(part, argsRecord, resultRecord)) - - const detail = error - ? [error, detailBody] - .filter(Boolean) - .filter((value, index, list) => list.findIndex(entry => entry.trim() === value.trim()) === index) - .join('\n\n') - : detailBody - - const searchHits = - part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined - - return { - detail, - detailLabel: error ? 'Error details' : toolDetailLabel(part.toolName), - durationLabel: durationLabel(resultRecord), - icon: meta.icon, - imageUrl: toolImageUrl(argsRecord, resultRecord), - inlineDiff, - previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord), - rawArgs: prettyJson(part.args), - rawResult: prettyJson(part.result), - searchHits: searchHits?.length ? searchHits : undefined, - status, - subtitle, - title, - tone: meta.tone - } -} - -function isToolPart(part: unknown): part is ToolPart { - if (!part || typeof part !== 'object') { - return false - } - - const row = part as Record - - return row.type === 'tool-call' && typeof row.toolName === 'string' -} - -function groupToolParts(content: unknown): ToolPart[][] { - if (!Array.isArray(content)) {return []} - - const groups: ToolPart[][] = [] - let current: ToolPart[] = [] - - for (const part of content) { - // todo parts render in their own hoisted panel; skip from grouped tools. - if (isToolPart(part) && part.toolName !== 'todo') { - current.push(part) - - continue - } - - if (current.length) { - groups.push(current) - current = [] - } - } - - if (current.length) {groups.push(current)} - - return groups -} - -function groupStatus(parts: ToolPart[]): ToolStatus { - if (parts.some(p => p.result === undefined)) { - return 'running' - } - - const statuses = parts.map(part => toolStatus(part, parseMaybeObject(part.result))) - const hasError = statuses.includes('error') - - if (!hasError) { - return 'success' - } - - return statuses.at(-1) === 'success' ? 'warning' : 'error' -} - -function groupTitle(parts: ToolPart[]): string { - const prefix = PREFIX_META.find(p => parts.every(part => part.toolName.startsWith(p.prefix))) - const verb = prefix?.verb || 'Tool' - - return `${verb} actions · ${parts.length} steps` -} +import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' + +import { + groupCopyText as buildGroupCopyText, + buildToolView, + cleanVisibleText, + compactPreview, + groupFailedStepCount, + groupPreviewTargets, + groupStatus, + groupTailSubtitle, + groupTitle, + groupTotalDurationLabel, + inlineDiffFromResult, + isPreviewableTarget, + looksRedundant, + type SearchResultRow, + selectMessageRunning, + stripInlineDiffChrome, + toolCopyPayload, + type ToolPart, + toolPartDisclosureId, + type ToolStatus +} from './tool-fallback-model' + +// Tool names that ChainToolFallback intercepts and renders as something +// other than a ToolEntry — they don't count toward "is this a group of +// tool calls?" because they have no visible tool block. +const SPECIAL_TOOL_NAMES = new Set(['todo', 'image_generate', 'clarify']) + +// `true` when the current ToolEntry is being rendered inside a group +// wrapper. Lets ToolEntry suppress per-row chrome (timer / preview) that +// the group already shows. +const ToolEmbedContext = createContext(false) const STATUS_DOT_CLASS: Record = { error: 'bg-destructive', @@ -1031,7 +79,13 @@ function statusDot(status: ToolStatus): ReactNode { function statusGlyph(status: ToolStatus): ReactNode { if (status === 'running') { - return + return ( + + ) } if (status === 'error') { @@ -1074,19 +128,34 @@ function SearchResultsList({ hits }: { hits: SearchResultRow[] }) { ) } +function LinkifiedText({ className, text }: { className?: string; text: string }) { + return +} + interface ToolEntryProps { - embedded?: boolean part: ToolPart } -function ToolEntry({ embedded = false, part }: ToolEntryProps) { - const messageRunning = useAuiState(state => state.thread.isRunning && state.message.status?.type === 'running') +function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean { + const persistedOpen = useStore($toolDisclosureOpen(disclosureId)) + + return persistedOpen ?? fallbackOpen +} + +function ToolEntry({ part }: ToolEntryProps) { + const messageId = useAuiState(s => s.message.id) + const messageRunning = useAuiState(selectMessageRunning) + const embedded = useContext(ToolEmbedContext) const toolViewMode = useStore($toolViewMode) - const disclosureStates = useStore($toolDisclosureStates) - const disclosureId = toolPartDisclosureId(part) + const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}` + const open = useDisclosureOpen(disclosureId) const isPending = messageRunning && part.result === undefined + // Only animate entries that mount while their message is actively + // streaming — historical sessions mount with `messageRunning === false`, + // so they paint statically without a settle cascade. The wrapping group + // handles its own enter animation, so embedded children skip it. + const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`) const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`) - const open = disclosureStates[disclosureId] ?? false const preview = compactPreview(part.args) || compactPreview(part.result) const liveDiffs = useStore($toolInlineDiffs) const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : '' @@ -1139,6 +208,13 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) { (part.toolName === 'terminal' || part.toolName === 'execute_code' || part.toolName === 'read_file') const hasSearchHits = Boolean(view.searchHits?.length) + const searchResultsLabel = part.toolName === 'web_search' ? 'Search results' : view.detailLabel + + const showRawSearchDrilldown = + part.toolName === 'web_search' && + part.result !== undefined && + toolViewMode !== 'technical' && + Boolean(view.rawResult.trim()) const hasExpandableContent = Boolean( (view.previewTarget && isPreviewableTarget(view.previewTarget)) || @@ -1156,16 +232,17 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) { const trailing = isPending && !embedded ? ( - + ) : !isPending && copyAction.text ? ( ) : undefined return ( -
+
setToolDisclosureOpen(disclosureId, !open) : undefined} open={open} @@ -1187,6 +264,9 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) { > {view.title} + {!isPending && view.countLabel && ( + {view.countLabel} + )} {!isPending && view.durationLabel && ( {view.durationLabel} @@ -1215,7 +295,7 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) { ))} {open && ( -
+
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && ( )} @@ -1226,9 +306,9 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) { )} {hasSearchHits && view.searchHits && (
- {view.detailLabel && ( + {searchResultsLabel && (

- {view.detailLabel} + {searchResultsLabel}

)} @@ -1269,6 +349,16 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) { )}
))} + {showRawSearchDrilldown && ( +
+ + Raw response + +
+                {view.rawResult}
+              
+
+ )} {toolViewMode === 'technical' && (
@@ -1295,60 +385,63 @@ function JsonSection({ label, value }: { label: string; value: string }) { ) } -function groupPreviewTargets(parts: ToolPart[]): string[] { - const seen = new Set() - const targets: string[] = [] +/** + * Always-present wrapper around the consecutive tool-call range that + * `MessagePrimitive.Parts` already grouped for us. Renders a header + + * collapsible body when there are 2+ visible tools; otherwise it's a + * transparent passthrough that just owns the entry animation for the + * single ToolEntry inside. + * + * Crucially, the wrapper element is the SAME `
` regardless of + * group size — only the optional header element appears/disappears. + * That preserves React identity for the inner `MessagePartByIndex` + * children when the 1→2 transition happens, so existing tool blocks + * never remount when a new tool joins them mid-stream. + * + * The previous design (per-tool ToolFallback computing its own group + * lookup and conditionally returning either `` or + * ``) flipped the React element type at the 1→2 transition + * and tore down the existing tool entirely, which is what showed up as + * "the previous tool's animation resets every time a new tool arrives." + */ +export const ToolGroupSlot: FC> = ({ + children, + endIndex, + startIndex +}) => { + const messageId = useAuiState(s => s.message.id) + const messageRunning = useAuiState(selectMessageRunning) - for (const part of parts) { - const view = buildToolView(part, inlineDiffFromResult(part.result)) - const target = view.previewTarget + // Pull the visible tool parts in this range. `useShallow` makes this + // re-render only when the actual part references change (assistant-ui + // gives stable refs for unchanged parts), not on every text/reasoning + // delta elsewhere in the message. + const visibleParts = useAuiState( + useShallow((s: { message: { parts: readonly unknown[] } }) => + s.message.parts.slice(startIndex, endIndex + 1).filter((p): p is ToolPart => { + if (!p || typeof p !== 'object') { + return false + } + const row = p as { toolName?: unknown; type?: unknown } - if (target && isPreviewableTarget(target) && !seen.has(target)) { - seen.add(target) - targets.push(target) - } - } - - return targets -} - -function ToolGroup({ parts }: { parts: ToolPart[] }) { - const messageRunning = useAuiState(state => state.thread.isRunning && state.message.status?.type === 'running') - const isRunning = messageRunning && parts.some(part => part.result === undefined) - const disclosureStates = useStore($toolDisclosureStates) - const disclosureId = toolGroupDisclosureId(parts) - const open = disclosureStates[disclosureId] ?? isRunning - const wasRunningRef = useRef(isRunning) - - useEffect(() => { - if (wasRunningRef.current && !isRunning) { - setToolDisclosureOpen(disclosureId, false) - } - - wasRunningRef.current = isRunning - }, [disclosureId, isRunning]) - - const status = groupStatus(parts) - const displayStatus = !isRunning && status === 'running' ? 'warning' : status - - const failedStepCount = useMemo( - () => parts.filter(part => toolStatus(part, parseMaybeObject(part.result)) === 'error').length, - [parts] + return row.type === 'tool-call' && typeof row.toolName === 'string' && !SPECIAL_TOOL_NAMES.has(row.toolName) + }) + ) ) - const totalDurationLabel = useMemo(() => { - const seconds = parts.reduce((sum, part) => { - const value = numberValue(parseMaybeObject(part.result).duration_s) + const isGroup = visibleParts.length > 1 + const isRunning = messageRunning && visibleParts.some(p => p.result === undefined) + // Stable across the group's lifetime (start index doesn't shift when + // tools append to the end), so user-driven open/close persists across + // streaming. + const disclosureId = `tool-group:${messageId}:${startIndex}` + const open = useDisclosureOpen(disclosureId) + const enterRef = useEnterAnimation(messageRunning, disclosureId) - return sum + (value && value > 0 ? value : 0) - }, 0) - - if (!seconds) { - return '' - } - - return seconds >= 10 ? `${seconds.toFixed(0)}s` : `${seconds.toFixed(1)}s` - }, [parts]) + const status = groupStatus(visibleParts) + const displayStatus = !isRunning && status === 'running' ? 'success' : status + const failedStepCount = useMemo(() => groupFailedStepCount(visibleParts), [visibleParts]) + const totalDurationLabel = useMemo(() => groupTotalDurationLabel(visibleParts), [visibleParts]) const statusSummary = displayStatus === 'running' || failedStepCount === 0 @@ -1361,126 +454,89 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) { ? '1 step failed' : `${failedStepCount} steps failed` - const tailSummary = useMemo(() => { - const tail = parts.at(-1) - - return tail ? buildToolView(tail, '').subtitle : '' - }, [parts]) - - const groupCopyText = useMemo(() => { - return parts - .map(part => { - const view = buildToolView(part, '') - const lines = [view.title] - - if (view.subtitle && view.subtitle !== view.title) { - lines.push(view.subtitle) - } - - if (view.detail && view.detail !== view.subtitle) { - lines.push(view.detail) - } - - return lines.join('\n') - }) - .join('\n\n') - }, [parts]) - + const tailSummary = useMemo(() => groupTailSubtitle(visibleParts), [visibleParts]) + const groupCopyText = useMemo(() => buildGroupCopyText(visibleParts), [visibleParts]) + const previewTargets = useMemo(() => groupPreviewTargets(visibleParts), [visibleParts]) const showGroupStatusGlyph = displayStatus !== 'success' - const previewTargets = useMemo(() => groupPreviewTargets(parts), [parts]) return ( -
- setToolDisclosureOpen(disclosureId, !open)} - open={open} - trailing={ - !isRunning && groupCopyText ? ( - - ) : undefined - } - > - - {showGroupStatusGlyph && ( - {statusGlyph(displayStatus)} - )} - +
+ {isGroup && ( + setToolDisclosureOpen(disclosureId, !open)} + open={open} + trailing={ + !isRunning && groupCopyText ? ( + + ) : undefined + } > - {groupTitle(parts)} - - {totalDurationLabel && ( - - {totalDurationLabel} + + {showGroupStatusGlyph && ( + {statusGlyph(displayStatus)} + )} + + {groupTitle(visibleParts)} + + {totalDurationLabel && ( + + {totalDurationLabel} + + )} - )} - - {tailSummary && ( - - {tailSummary.replace(/\n+/g, ' · ')} - - )} - {statusSummary && ( - + {tailSummary.replace(/\n+/g, ' · ')} + )} - > - {statusSummary} - + {statusSummary && ( + + {statusSummary} + + )} + )} - - {previewTargets.length > 0 && ( -
- {previewTargets.map(target => ( - - ))} + {isGroup && previewTargets.length > 0 && ( +
+ {previewTargets.map(target => ( + + ))} +
+ )} + {/* Body is always rendered so children stay mounted across collapse/ + expand and across the 1→2 group transition. `hidden` removes it + from a11y/visual flow without unmounting React subtree. */} + - )} - {open && ( -
- {parts.map(part => ( - - ))} -
- )} -
+
+ ) } +/** + * Per-tool fallback. Now strictly returns a single ToolEntry — the + * grouping decision lives in ToolGroupSlot above, so this never swaps + * its return type and the underlying ToolEntry stays mounted across + * group-shape changes. + */ export const ToolFallback = ({ toolCallId, toolName, args, isError, result }: ToolCallMessagePartProps) => { - const messageContent = useAuiState(state => state.message.content as unknown) - const groups = useMemo(() => groupToolParts(messageContent), [messageContent]) + const part: ToolPart = { args, isError, result, toolCallId, toolName, type: 'tool-call' } - const currentPart: ToolPart = { - args, - isError, - result, - toolCallId, - toolName, - type: 'tool-call' - } - - if (!toolCallId) { - return - } - - const group = groups.find(parts => parts.some(part => part.toolCallId === toolCallId)) - - if (!group || group.length <= 1) { - return - } - - if (group[0]?.toolCallId !== toolCallId) { - return null - } - - return + return } function InlineDiff({ text }: { text: string }) { diff --git a/apps/desktop/src/components/chat/activity-timer.ts b/apps/desktop/src/components/chat/activity-timer.ts index 704c9d4390..29095c32db 100644 --- a/apps/desktop/src/components/chat/activity-timer.ts +++ b/apps/desktop/src/components/chat/activity-timer.ts @@ -6,10 +6,14 @@ import { useEffect, useRef, useState } from 'react' const startedAtByKey = new Map() function startedAt(key?: string): number { - if (!key) {return Date.now()} + if (!key) { + return Date.now() + } const existing = startedAtByKey.get(key) - if (existing !== undefined) {return existing} + if (existing !== undefined) { + return existing + } const now = Date.now() startedAtByKey.set(key, now) @@ -17,7 +21,9 @@ function startedAt(key?: string): number { } export function formatElapsed(seconds: number): string { - if (seconds < 60) {return `${seconds}s`} + if (seconds < 60) { + return `${seconds}s` + } return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` } @@ -33,9 +39,13 @@ export function useElapsedSeconds(active = true, timerKey?: string): number { } useEffect(() => { - if (!active) {return} + if (!active) { + return + } - if (timerKey) {start.current = startedAt(timerKey)} + if (timerKey) { + start.current = startedAt(timerKey) + } const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000))) tick() diff --git a/apps/desktop/src/components/chat/disclosure-row.tsx b/apps/desktop/src/components/chat/disclosure-row.tsx index b588bff410..911be03f95 100644 --- a/apps/desktop/src/components/chat/disclosure-row.tsx +++ b/apps/desktop/src/components/chat/disclosure-row.tsx @@ -4,14 +4,16 @@ import type { ReactNode } from 'react' import { cn } from '@/lib/utils' // Shared header row for any collapsible block (thinking, tool group, single -// tool). Owns the grid indent (chevron column = --message-text-indent), the -// hover surface, and the trailing-slot anchor used for copy buttons / running -// timers. Each parent supplies its own outer wrapper (with the data-slot CSS +// tool). Each parent supplies its own outer wrapper (with the data-slot CSS // uses to escape the message padding) and its own expanded body. // -// Passing `onToggle` makes the row expandable (chevron + hover + click). -// Omitting it renders a static row that still reserves the chevron column so -// nested rows stay vertically aligned with their group header. +// Cursor-style affordance: +// - No leading chevron; a caret appears to the RIGHT of the text on hover +// (and stays visible when the row is open). +// - The hover background is a tight content-shaped pill — sized to the +// title text, NOT the full row — and reaches just past the chevron with +// `-mx-1.5 px-1.5` so it reads as a soft hit-target rather than a slab +// stretching to the message edge. export function DisclosureRow({ children, onToggle, @@ -24,36 +26,44 @@ export function DisclosureRow({ trailing?: ReactNode }) { return ( -
+
{trailing && {trailing}}
diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index 4f0b3789d7..9ebec68522 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -134,6 +134,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway } const { flow } = onboarding + const reason = onboarding.reason?.trim() || null const ready = enabled && onboarding.configured === false const showPicker = flow.status === 'idle' || flow.status === 'success' @@ -142,21 +143,22 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
- {ready ? ( - showPicker ? ( - - ) : ( - - ) - ) : ( - - )} + {reason ? : null} + {ready ? showPicker ? : : }
) } +function ReasonNotice({ reason }: { reason: string }) { + return ( +
+ {reason} +
+ ) +} + function Preparing({ boot }: { boot: DesktopBootState }) { const progress = Math.max(2, Math.min(100, Math.round(boot.progress))) const hasError = Boolean(boot.error) @@ -445,7 +447,9 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow in":

void copyExternalCommand()} text={flow.provider.cli_command} /> - {title} docs : null}> + {title} docs : null} + >