mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 03:39:54 +00:00
feat(desktop): reconcile live tool events, polish thread chrome, harden boot
- chat-messages: match tool rows by overlapping query/context/preview values so preview-first `tool.progress` rows reliably adopt later stable-id `tool.start` payloads instead of spawning ghost rows or mis-merging parallel same-name calls; preserve prior args/result across phases. - tui_gateway: emit full args + parsed result on `tool.start` / `tool.complete`, drop redundant `tool.started` re-emit from `tool.progress`. - electron/main: prefer SOURCE_REPO_ROOT before PATH `hermes` in dev so local backend edits actually run; split hardening helpers into `electron/hardening.cjs` with tests. - thread/tool UI: one-shot enter animation keyed by stable ids, braille spinner for running rows, Cursor-like disclosure rows, drill-down + duration/count formatting via new tool-fallback-model. - composer: extract `text-utils`, drop liquid-glass overrides. - right-rail: split preview-pane into preview-console / preview-file. - runtime: incremental external-store runtime + runtime-readiness gate; onboarding store + tests; route-resume hook test. - regression tests for live tool reconciliation (parallel tools, id-less progress, preview-first rows, structured args/results).
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Generated
+542
-20
@@ -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",
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -47,7 +47,7 @@
|
||||
"@chenglou/pretext": "^0.0.6",
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@nous-research/ui": "^0.12.0",
|
||||
"@nous-research/ui": "^0.13.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
@@ -59,7 +59,6 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"ignore": "^7.0.5",
|
||||
"leva": "^0.10.1",
|
||||
"liquid-glass-react": "^1.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanostores": "^1.3.0",
|
||||
|
||||
@@ -49,6 +49,7 @@ describe('collectArtifactsForSession', () => {
|
||||
timestamp: 3000
|
||||
}
|
||||
]
|
||||
|
||||
const artifacts = collectArtifactsForSession(makeSession({ id: 'session-2' }), messages)
|
||||
|
||||
expect(artifacts).toHaveLength(1)
|
||||
|
||||
@@ -859,10 +859,7 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
|
||||
return (
|
||||
<div className="group/location flex min-w-0 items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-xs text-muted-foreground/85',
|
||||
isLink ? 'font-medium' : 'font-mono'
|
||||
)}
|
||||
className={cn('min-w-0 flex-1 truncate text-xs text-muted-foreground/85', isLink ? 'font-medium' : 'font-mono')}
|
||||
title={artifact.value}
|
||||
>
|
||||
{value}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import './liquid-glass-overrides.css'
|
||||
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import LiquidGlass from 'liquid-glass-react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type FormEvent,
|
||||
@@ -20,7 +17,7 @@ import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { contextPath } from '@/lib/chat-runtime'
|
||||
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerAttachments, $composerDraft } from '@/store/composer'
|
||||
@@ -45,117 +42,16 @@ import {
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { SkinSlashPopover } from './skin-slash-popover'
|
||||
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
import { UrlDialog } from './url-dialog'
|
||||
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
||||
|
||||
function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
|
||||
const blobs: Blob[] = []
|
||||
const seen = new Set<Blob>()
|
||||
|
||||
const push = (blob: Blob | null) => {
|
||||
if (!blob || blob.size === 0 || seen.has(blob)) {
|
||||
return
|
||||
}
|
||||
|
||||
seen.add(blob)
|
||||
blobs.push(blob)
|
||||
}
|
||||
|
||||
if (clipboard.items?.length) {
|
||||
for (const item of clipboard.items) {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
push(item.getAsFile())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (clipboard.files?.length) {
|
||||
for (let i = 0; i < clipboard.files.length; i += 1) {
|
||||
const file = clipboard.files.item(i)
|
||||
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
push(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (blobs.length > 0) {
|
||||
return blobs
|
||||
}
|
||||
|
||||
const text = clipboard.getData('text/plain').trim()
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(text)) {
|
||||
push(dataUrlToBlob(text))
|
||||
}
|
||||
|
||||
if (blobs.length === 0) {
|
||||
const html = clipboard.getData('text/html')
|
||||
|
||||
if (html) {
|
||||
const matches = html.matchAll(/<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi)
|
||||
|
||||
for (const match of matches) {
|
||||
push(dataUrlToBlob(match[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blobs
|
||||
}
|
||||
|
||||
const COMPOSER_STACK_BREAKPOINT_PX = 320
|
||||
|
||||
const COMPOSER_GLASS = {
|
||||
fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))',
|
||||
liquidKey: ['standard', '0.950', '0.072', '0', '46', '0.00', '128'].join(':'),
|
||||
showLibraryRims: false,
|
||||
liquid: {
|
||||
aberrationIntensity: 0.95,
|
||||
blurAmount: 0.072,
|
||||
cornerRadius: 0,
|
||||
displacementScale: 46,
|
||||
elasticity: 0,
|
||||
mode: 'standard' as const,
|
||||
saturation: 128
|
||||
}
|
||||
}
|
||||
|
||||
interface TriggerState {
|
||||
kind: '@' | '/'
|
||||
query: string
|
||||
tokenLength: number
|
||||
}
|
||||
|
||||
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
|
||||
|
||||
/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */
|
||||
function textBeforeCaret(editor: HTMLDivElement): string | null {
|
||||
const sel = window.getSelection()
|
||||
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
|
||||
|
||||
if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const before = range.cloneRange()
|
||||
before.selectNodeContents(editor)
|
||||
before.setEnd(range.startContainer, range.startOffset)
|
||||
|
||||
return before.toString()
|
||||
}
|
||||
|
||||
function detectTrigger(textBefore: string): TriggerState | null {
|
||||
const match = TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
|
||||
}
|
||||
const COMPOSER_FADE_BACKGROUND =
|
||||
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
|
||||
|
||||
export function ChatBar({
|
||||
busy,
|
||||
@@ -186,7 +82,6 @@ export function ChatBar({
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const glassShellRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -931,38 +826,9 @@ export function ChatBar({
|
||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
style={{ background: COMPOSER_GLASS.fadeBackground }}
|
||||
style={{ background: COMPOSER_FADE_BACKGROUND }}
|
||||
/>
|
||||
<div className="relative w-full rounded-[inherit]">
|
||||
<div
|
||||
className={cn(
|
||||
'composer-liquid-shell-wrap absolute -inset-px isolate overflow-hidden rounded-[calc(var(--radius-2xl)+1px)] transition-opacity duration-200 ease-out',
|
||||
scrolledUp
|
||||
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
data-glass-frame="true"
|
||||
data-show-library-rims={COMPOSER_GLASS.showLibraryRims ? 'true' : undefined}
|
||||
data-slot="composer-liquid-shell-wrap"
|
||||
ref={glassShellRef}
|
||||
>
|
||||
<LiquidGlass
|
||||
aberrationIntensity={COMPOSER_GLASS.liquid.aberrationIntensity}
|
||||
blurAmount={COMPOSER_GLASS.liquid.blurAmount}
|
||||
className="composer-liquid-shell pointer-events-none absolute inset-0 h-full w-full"
|
||||
cornerRadius={COMPOSER_GLASS.liquid.cornerRadius}
|
||||
displacementScale={COMPOSER_GLASS.liquid.displacementScale}
|
||||
elasticity={COMPOSER_GLASS.liquid.elasticity}
|
||||
key={COMPOSER_GLASS.liquidKey}
|
||||
mode={COMPOSER_GLASS.liquid.mode}
|
||||
mouseContainer={composerRef}
|
||||
padding="0"
|
||||
saturation={COMPOSER_GLASS.liquid.saturation}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
|
||||
>
|
||||
<span className="block h-full w-full" />
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
|
||||
@@ -983,9 +849,7 @@ export function ChatBar({
|
||||
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
'transition-[background-color] duration-150 ease-out',
|
||||
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
|
||||
'group-focus-within/composer:bg-[var(--dt-card)]',
|
||||
'group-focus-within/composer:[backdrop-filter:none]',
|
||||
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
|
||||
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
|
||||
)}
|
||||
/>
|
||||
{dragActive && (
|
||||
@@ -1057,9 +921,7 @@ export function ChatBarFallback() {
|
||||
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
'transition-[background-color] duration-150 ease-out',
|
||||
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
|
||||
'group-focus-within/composer:bg-[var(--dt-card)]',
|
||||
'group-focus-within/composer:[backdrop-filter:none]',
|
||||
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
|
||||
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
.composer-liquid-shell-wrap > div:not(.composer-liquid-shell) {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
margin: 0 !important;
|
||||
border-radius: inherit !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.composer-liquid-shell-wrap:not([data-show-library-rims='true']) > span {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell-wrap[data-show-library-rims='true'] > span {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
margin: 0 !important;
|
||||
border-radius: inherit !important;
|
||||
box-sizing: border-box;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell {
|
||||
z-index: 1;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
transform: none !important;
|
||||
border-radius: inherit !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > svg {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
border-radius: inherit !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > .glass,
|
||||
.composer-liquid-shell > :not(svg):not(.glass) {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
border-radius: inherit !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > .glass {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
padding: 0 !important;
|
||||
border-radius: inherit !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > .glass > .glass__warp {
|
||||
border-radius: inherit !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > .glass > div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font: inherit !important;
|
||||
text-shadow: none !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
|
||||
|
||||
export interface TriggerState {
|
||||
kind: '@' | '/'
|
||||
query: string
|
||||
tokenLength: number
|
||||
}
|
||||
|
||||
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
|
||||
|
||||
export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
|
||||
const blobs: Blob[] = []
|
||||
const seen = new Set<Blob>()
|
||||
|
||||
const push = (blob: Blob | null) => {
|
||||
if (!blob || blob.size === 0 || seen.has(blob)) {
|
||||
return
|
||||
}
|
||||
|
||||
seen.add(blob)
|
||||
blobs.push(blob)
|
||||
}
|
||||
|
||||
if (clipboard.items?.length) {
|
||||
for (const item of clipboard.items) {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
push(item.getAsFile())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (clipboard.files?.length) {
|
||||
for (let i = 0; i < clipboard.files.length; i += 1) {
|
||||
const file = clipboard.files.item(i)
|
||||
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
push(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (blobs.length > 0) {
|
||||
return blobs
|
||||
}
|
||||
|
||||
const text = clipboard.getData('text/plain').trim()
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(text)) {
|
||||
push(dataUrlToBlob(text))
|
||||
}
|
||||
|
||||
if (blobs.length === 0) {
|
||||
const html = clipboard.getData('text/html')
|
||||
|
||||
if (html) {
|
||||
const matches = html.matchAll(/<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi)
|
||||
|
||||
for (const match of matches) {
|
||||
push(dataUrlToBlob(match[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blobs
|
||||
}
|
||||
|
||||
/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */
|
||||
export function textBeforeCaret(editor: HTMLDivElement): string | null {
|
||||
const sel = window.getSelection()
|
||||
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
|
||||
|
||||
if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const before = range.cloneRange()
|
||||
before.selectNodeContents(editor)
|
||||
before.setEnd(range.startContainer, range.startOffset)
|
||||
|
||||
return before.toString()
|
||||
}
|
||||
|
||||
export function detectTrigger(textBefore: string): TriggerState | null {
|
||||
const match = TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import {
|
||||
type AppendMessage,
|
||||
AssistantRuntimeProvider,
|
||||
ExportedMessageRepository,
|
||||
type ThreadMessage,
|
||||
useExternalStoreRuntime
|
||||
type ThreadMessage
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@@ -18,6 +17,7 @@ import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import {
|
||||
@@ -70,6 +70,55 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
}
|
||||
|
||||
interface ChatHeaderProps {
|
||||
activeSessionId: null | string
|
||||
isRoutedSessionView: boolean
|
||||
onDeleteSelectedSession: () => void
|
||||
onToggleSelectedPin: () => void
|
||||
selectedSessionId: null | string
|
||||
}
|
||||
|
||||
function ChatHeader({
|
||||
activeSessionId,
|
||||
isRoutedSessionView,
|
||||
onDeleteSelectedSession,
|
||||
onToggleSelectedPin,
|
||||
selectedSessionId
|
||||
}: ChatHeaderProps) {
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
|
||||
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
|
||||
|
||||
return (
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
|
||||
pinned={selectedIsPinned}
|
||||
sessionId={selectedSessionId || activeSessionId || ''}
|
||||
sideOffset={8}
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
|
||||
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
className,
|
||||
gateway,
|
||||
@@ -107,13 +156,9 @@ export function ChatView({
|
||||
const introPersonality = useStore($introPersonality)
|
||||
const introSeed = useStore($introSeed)
|
||||
const messages = useStore($messages)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const sessions = useStore($sessions)
|
||||
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
|
||||
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
|
||||
const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
|
||||
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
|
||||
|
||||
const showIntro =
|
||||
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
|
||||
@@ -127,7 +172,6 @@ export function ChatView({
|
||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages))
|
||||
const showChatBar = !loadingSession
|
||||
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
|
||||
|
||||
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
||||
queryKey: ['model-options', activeSessionId || 'global'],
|
||||
@@ -207,7 +251,7 @@ export function ChatView({
|
||||
return ExportedMessageRepository.fromBranchableArray(items, { headId })
|
||||
}, [messages])
|
||||
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
|
||||
messageRepository: runtimeMessageRepository,
|
||||
isRunning: busy,
|
||||
setMessages: onThreadMessagesChange,
|
||||
@@ -227,30 +271,13 @@ export function ChatView({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
|
||||
pinned={selectedIsPinned}
|
||||
sessionId={selectedSessionId || activeSessionId || ''}
|
||||
sideOffset={8}
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
|
||||
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<ChatHeader
|
||||
activeSessionId={activeSessionId}
|
||||
isRoutedSessionView={isRoutedSessionView}
|
||||
onDeleteSelectedSession={onDeleteSelectedSession}
|
||||
onToggleSelectedPin={onToggleSelectedPin}
|
||||
selectedSessionId={selectedSessionId}
|
||||
/>
|
||||
|
||||
<NotificationStack />
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { CSSProperties, MutableRefObject, PointerEvent as ReactPointerEvent, RefObject } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerDraft, setComposerDraft } from '@/store/composer'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
import type { ConsoleEntry, PreviewConsoleState } from './preview-console-state'
|
||||
|
||||
const consoleLevelLabel: Record<number, string> = {
|
||||
0: 'log',
|
||||
1: 'info',
|
||||
2: 'warn',
|
||||
3: 'error'
|
||||
}
|
||||
|
||||
const consoleLevelClass: Record<number, string> = {
|
||||
0: 'text-foreground',
|
||||
1: 'text-sky-700 dark:text-sky-300',
|
||||
2: 'text-amber-700 dark:text-amber-300',
|
||||
3: 'text-destructive'
|
||||
}
|
||||
|
||||
const CONSOLE_BOTTOM_THRESHOLD = 24
|
||||
const CONSOLE_HEADER_HEIGHT = 32
|
||||
|
||||
export function compactUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
if (url.protocol === 'file:') {
|
||||
return decodeURIComponent(url.pathname)
|
||||
}
|
||||
|
||||
return `${url.host}${url.pathname}${url.search}`
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function formatLogLine(log: ConsoleEntry): string {
|
||||
const head = `[${consoleLevelLabel[log.level] || 'log'}]`
|
||||
const tail = log.source ? ` (${compactUrl(log.source)}${log.line ? `:${log.line}` : ''})` : ''
|
||||
|
||||
return `${head} ${log.message}${tail}`.trim()
|
||||
}
|
||||
|
||||
export function formatConsoleEntries(entries: ConsoleEntry[]): string {
|
||||
return entries.map(formatLogLine).join('\n')
|
||||
}
|
||||
|
||||
export function isNearConsoleBottom(element: HTMLDivElement | null): boolean {
|
||||
if (!element) {
|
||||
return true
|
||||
}
|
||||
|
||||
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD
|
||||
}
|
||||
|
||||
export function clampConsoleHeight(value: number): number {
|
||||
return Math.max(value, CONSOLE_HEADER_HEIGHT)
|
||||
}
|
||||
|
||||
interface ConsoleRowProps {
|
||||
copyText: string
|
||||
log: ConsoleEntry
|
||||
onSend: () => void
|
||||
onToggleSelect: () => void
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/row grid grid-cols-[3.25rem_minmax(0,1fr)_auto] items-start gap-2 rounded-md border border-transparent px-1 py-1 transition-colors hover:bg-accent/40',
|
||||
selected && 'border-border/60 bg-accent/40'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
||||
consoleLevelClass[log.level] ?? consoleLevelClass[0]
|
||||
)}
|
||||
onClick={onToggleSelect}
|
||||
title={selected ? 'Deselect entry' : 'Select entry'}
|
||||
type="button"
|
||||
>
|
||||
{consoleLevelLabel[log.level] || 'log'}
|
||||
</button>
|
||||
<div className="min-w-0" data-selectable-text="true">
|
||||
<span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}>
|
||||
{log.message}
|
||||
</span>
|
||||
{log.source && (
|
||||
<span className="block truncate text-muted-foreground/60">
|
||||
{compactUrl(log.source)}
|
||||
{log.line ? `:${log.line}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="opacity-0 transition-opacity group-hover/row:opacity-100">
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
errorMessage="Could not copy console output"
|
||||
iconClassName="size-3"
|
||||
label="Copy this entry"
|
||||
showLabel={false}
|
||||
text={copyText}
|
||||
/>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onSend}
|
||||
title="Send this entry to chat"
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) {
|
||||
const logCount = useStore(consoleState.$logCount)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelBottom />
|
||||
{logCount > 0 && <span className="sr-only">{logCount} console messages</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface PreviewConsolePanelProps {
|
||||
consoleBodyRef: RefObject<HTMLDivElement | null>
|
||||
consoleShouldStickRef: MutableRefObject<boolean>
|
||||
consoleState: PreviewConsoleState
|
||||
startConsoleResize: (event: ReactPointerEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
export function PreviewConsolePanel({
|
||||
consoleBodyRef,
|
||||
consoleShouldStickRef,
|
||||
consoleState,
|
||||
startConsoleResize
|
||||
}: PreviewConsolePanelProps) {
|
||||
const consoleHeight = useStore(consoleState.$height)
|
||||
const logs = useStore(consoleState.$logs)
|
||||
const selectedLogIds = useStore(consoleState.$selectedLogIds)
|
||||
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
|
||||
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
|
||||
const stickScrollRafRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!consoleShouldStickRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (stickScrollRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(stickScrollRafRef.current)
|
||||
stickScrollRafRef.current = null
|
||||
}
|
||||
|
||||
stickScrollRafRef.current = window.requestAnimationFrame(() => {
|
||||
stickScrollRafRef.current = null
|
||||
const consoleBody = consoleBodyRef.current
|
||||
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (stickScrollRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(stickScrollRafRef.current)
|
||||
stickScrollRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs])
|
||||
|
||||
function sendLogsToComposer(entries: ConsoleEntry[]) {
|
||||
if (!entries.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
|
||||
const draft = $composerDraft.get()
|
||||
const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}`
|
||||
|
||||
setComposerDraft(next)
|
||||
consoleState.clearSelection()
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Sent to chat',
|
||||
message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer`
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto absolute inset-x-0 bottom-0 z-20 flex h-(--preview-console-height) min-h-8 flex-col overflow-hidden border-t border-border/60 bg-background"
|
||||
style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
aria-label="Resize preview console"
|
||||
className="group absolute inset-x-0 -top-1 z-1 h-2 cursor-row-resize"
|
||||
onDoubleClick={() => consoleState.setHeight(CONSOLE_HEADER_HEIGHT)}
|
||||
onPointerDown={startConsoleResize}
|
||||
role="separator"
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-0.75 w-23 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.5]" />
|
||||
</div>
|
||||
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/50 px-2">
|
||||
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
|
||||
<PanelBottom className="size-3.5" />
|
||||
Preview Console
|
||||
{selectedLogIds.size > 0 && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
|
||||
{selectedLogIds.size} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={sendableLogs.length === 0}
|
||||
onClick={() => sendLogsToComposer(sendableLogs)}
|
||||
title={
|
||||
visibleSelection.length > 0
|
||||
? `Send ${visibleSelection.length} selected to chat`
|
||||
: 'Send all log entries to chat'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
Send to chat
|
||||
</button>
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={sendableLogs.length === 0}
|
||||
errorMessage="Could not copy console output"
|
||||
iconClassName="size-3"
|
||||
label={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
|
||||
text={() => formatConsoleEntries(sendableLogs)}
|
||||
>
|
||||
Copy
|
||||
</CopyButton>
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={logs.length === 0}
|
||||
onClick={consoleState.clear}
|
||||
title="Clear console"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed"
|
||||
ref={consoleBodyRef}
|
||||
>
|
||||
{logs.length > 0 ? (
|
||||
logs.map(log => {
|
||||
const selected = selectedLogIds.has(log.id)
|
||||
|
||||
return (
|
||||
<ConsoleRow
|
||||
copyText={formatLogLine(log)}
|
||||
key={log.id}
|
||||
log={log}
|
||||
onSend={() => sendLogsToComposer([log])}
|
||||
onToggleSelect={() => consoleState.toggleSelection(log.id)}
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
import type * as React from 'react'
|
||||
import type {
|
||||
ComponentProps,
|
||||
CSSProperties,
|
||||
DragEvent as ReactDragEvent,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode
|
||||
} from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||
|
||||
type EmptyStateTone = 'neutral' | 'warning'
|
||||
|
||||
const TONE_STYLES: Record<EmptyStateTone, { cube: string; primary: string }> = {
|
||||
neutral: {
|
||||
cube: 'text-muted-foreground/35',
|
||||
primary: 'border-border bg-background text-foreground hover:bg-accent'
|
||||
},
|
||||
warning: {
|
||||
cube: 'text-amber-500/70 dark:text-amber-300/70',
|
||||
primary:
|
||||
'border-amber-400/40 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-300/30 dark:bg-amber-300/15 dark:text-amber-100 dark:hover:bg-amber-300/20'
|
||||
}
|
||||
}
|
||||
|
||||
function PreviewCubeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg aria-hidden="true" className={cn('size-16', className)} viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M32 5 56 18.5v27L32 59 8 45.5v-27L32 5Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.25"
|
||||
/>
|
||||
<path
|
||||
d="M8 18.5 32 32l24-13.5M32 32v27"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.25"
|
||||
/>
|
||||
<path d="M20 11.75 44 25.25" fill="none" opacity="0.45" stroke="currentColor" strokeWidth="0.9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface PreviewEmptyStateProps {
|
||||
body?: ReactNode
|
||||
consoleHeight?: number
|
||||
primaryAction?: { disabled?: boolean; label: string; onClick: () => void }
|
||||
secondaryAction?: { disabled?: boolean; label: string; onClick: () => void }
|
||||
title: string
|
||||
tone?: EmptyStateTone
|
||||
}
|
||||
|
||||
export function PreviewEmptyState({
|
||||
body,
|
||||
consoleHeight = 0,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
title,
|
||||
tone = 'neutral'
|
||||
}: PreviewEmptyStateProps) {
|
||||
const styles = TONE_STYLES[tone]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-8 py-10 text-center bottom-(--preview-error-bottom)"
|
||||
style={{ '--preview-error-bottom': `${consoleHeight}px` } as CSSProperties}
|
||||
>
|
||||
<div className="grid max-w-sm justify-items-center gap-5">
|
||||
<PreviewCubeIcon className={styles.cube} />
|
||||
<div className="grid gap-2">
|
||||
<div className="text-sm font-medium text-foreground">{title}</div>
|
||||
{body && <div className="text-xs leading-relaxed text-muted-foreground">{body}</div>}
|
||||
</div>
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="grid justify-items-center gap-2">
|
||||
{primaryAction && (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-full border px-3.5 py-1.5 text-xs font-medium shadow-xs transition-colors disabled:cursor-default disabled:opacity-60',
|
||||
styles.primary
|
||||
)}
|
||||
disabled={primaryAction.disabled}
|
||||
onClick={primaryAction.onClick}
|
||||
type="button"
|
||||
>
|
||||
{primaryAction.label}
|
||||
</button>
|
||||
)}
|
||||
{secondaryAction && (
|
||||
<button
|
||||
className="text-[0.6875rem] font-medium text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55 disabled:cursor-default disabled:text-muted-foreground/55 disabled:no-underline"
|
||||
disabled={secondaryAction.disabled}
|
||||
onClick={secondaryAction.onClick}
|
||||
type="button"
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LocalPreviewState {
|
||||
binary?: boolean
|
||||
byteSize?: number
|
||||
dataUrl?: string
|
||||
error?: string
|
||||
language?: string
|
||||
loading: boolean
|
||||
text?: string
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
function filePathForTarget(target: PreviewTarget) {
|
||||
if (target.path) {
|
||||
return target.path
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(target.url)
|
||||
|
||||
return url.protocol === 'file:' ? decodeURIComponent(url.pathname) : target.url
|
||||
} catch {
|
||||
return target.url
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | undefined) {
|
||||
if (!bytes) {
|
||||
return 'unknown size'
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
|
||||
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
|
||||
function looksBinaryBytes(bytes: Uint8Array) {
|
||||
if (!bytes.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let suspicious = 0
|
||||
|
||||
for (const byte of bytes.slice(0, 4096)) {
|
||||
if (byte === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
|
||||
suspicious += 1
|
||||
}
|
||||
}
|
||||
|
||||
return suspicious / Math.min(bytes.length, 4096) > 0.12
|
||||
}
|
||||
|
||||
async function readTextPreview(filePath: string) {
|
||||
if (window.hermesDesktop.readFileText) {
|
||||
try {
|
||||
return await window.hermesDesktop.readFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back-compat for a running Electron process whose preload hasn't been
|
||||
// restarted since readFileText was added. readFileDataUrl already existed.
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
|
||||
const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
|
||||
const base64 = metadata.includes(';base64')
|
||||
const mimeType = metadata.replace(/;base64$/, '') || undefined
|
||||
const raw = base64 ? atob(data) : decodeURIComponent(data)
|
||||
const bytes = Uint8Array.from(raw, ch => ch.charCodeAt(0))
|
||||
|
||||
return {
|
||||
binary: looksBinaryBytes(bytes),
|
||||
byteSize: bytes.byteLength,
|
||||
mimeType,
|
||||
path: filePath,
|
||||
text: new TextDecoder().decode(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight markdown renderer for file previews. Streamdown does the parse;
|
||||
// our components keep typography simple and route fenced code through Shiki
|
||||
// without the library's copy/download/fullscreen chrome.
|
||||
const MD_TAG_CLASSES = {
|
||||
h1: 'mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0',
|
||||
h2: 'mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0',
|
||||
h3: 'mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0',
|
||||
h4: 'mb-2 mt-3 text-base font-semibold leading-snug first:mt-0',
|
||||
p: 'mb-4 leading-relaxed text-foreground last:mb-0',
|
||||
ul: 'mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0',
|
||||
ol: 'mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0',
|
||||
li: 'mt-1 leading-relaxed',
|
||||
blockquote: 'mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0',
|
||||
pre: 'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono'
|
||||
} as const
|
||||
|
||||
function tagged<T extends keyof typeof MD_TAG_CLASSES>(Tag: T) {
|
||||
const base = MD_TAG_CLASSES[Tag]
|
||||
|
||||
const Component = (({ className, ...rest }: ComponentProps<T>) => {
|
||||
const Element = Tag as React.ElementType
|
||||
|
||||
return <Element className={cn(base, className)} {...rest} />
|
||||
}) as React.FC<ComponentProps<T>>
|
||||
|
||||
Component.displayName = `Md.${Tag}`
|
||||
|
||||
return Component
|
||||
}
|
||||
|
||||
function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) {
|
||||
const language = /language-([^\s]+)/.exec(className || '')?.[1]
|
||||
|
||||
if (!language) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
'rounded bg-muted px-1 py-0.5 font-mono text-[0.86em] text-pink-700 dark:text-pink-300',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</ShikiHighlighter>
|
||||
)
|
||||
}
|
||||
|
||||
const MARKDOWN_COMPONENTS = {
|
||||
h1: tagged('h1'),
|
||||
h2: tagged('h2'),
|
||||
h3: tagged('h3'),
|
||||
h4: tagged('h4'),
|
||||
p: tagged('p'),
|
||||
ul: tagged('ul'),
|
||||
ol: tagged('ol'),
|
||||
li: tagged('li'),
|
||||
blockquote: tagged('blockquote'),
|
||||
pre: tagged('pre'),
|
||||
code: MarkdownCode
|
||||
}
|
||||
|
||||
function MarkdownPreview({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
|
||||
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
|
||||
{text}
|
||||
</Streamdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
|
||||
<button
|
||||
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{asSource ? 'PREVIEW' : 'SOURCE'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
|
||||
// each line aligns vertically. The selection overlay relies on the same
|
||||
// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
|
||||
const SOURCE_LINE_HEIGHT_REM = 1.21875
|
||||
const SOURCE_PAD_Y_REM = 0.75
|
||||
|
||||
interface LineSelection {
|
||||
end: number
|
||||
start: number
|
||||
}
|
||||
|
||||
function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { end, start }: LineSelection) {
|
||||
const lineEnd = end > start ? end : undefined
|
||||
const label = lineEnd ? `${filePath}:${start}-${end}` : `${filePath}:${start}`
|
||||
|
||||
event.dataTransfer.setData(HERMES_PATHS_MIME, JSON.stringify([{ line: start, lineEnd, path: filePath }]))
|
||||
event.dataTransfer.setData('text/plain', label)
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
|
||||
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
|
||||
const [selection, setSelection] = useState<LineSelection | null>(null)
|
||||
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
|
||||
|
||||
const handleLineClick = (event: ReactMouseEvent, line: number) => {
|
||||
if (event.shiftKey && selection) {
|
||||
setSelection({ end: Math.max(selection.end, line), start: Math.min(selection.start, line) })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (selection?.start === line && selection.end === line) {
|
||||
setSelection(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSelection({ end: line, start: line })
|
||||
}
|
||||
|
||||
const handleDragStart = (event: ReactDragEvent<HTMLElement>, line: number) => {
|
||||
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
|
||||
<div className="select-none py-3 text-right text-muted-foreground/55">
|
||||
{Array.from({ length: lineCount }, (_, index) => {
|
||||
const line = index + 1
|
||||
const selected = inSelection(line)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-pointer px-3 tabular-nums transition-colors',
|
||||
selected
|
||||
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
|
||||
: 'hover:text-foreground'
|
||||
)}
|
||||
draggable
|
||||
key={line}
|
||||
onClick={event => handleLineClick(event, line)}
|
||||
onDragStart={event => handleDragStart(event, line)}
|
||||
title="Click to select · shift-click to extend · drag to composer"
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3">
|
||||
{selection && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
|
||||
style={{
|
||||
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
|
||||
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{text}
|
||||
</ShikiHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
|
||||
const [state, setState] = useState<LocalPreviewState>({ loading: true })
|
||||
const [forcePreview, setForcePreview] = useState(false)
|
||||
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
|
||||
const filePath = filePathForTarget(target)
|
||||
const isImage = target.previewKind === 'image'
|
||||
|
||||
// HTML files are rendered as source code, not in a webview - so they take
|
||||
// the same path as plain text files. `previewKind === 'binary'` arrives
|
||||
// when the file is forcibly previewed past the binary refusal screen.
|
||||
const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html'
|
||||
|
||||
const blockedByTarget = !isImage && !forcePreview && (target.binary || target.large)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
async function load() {
|
||||
if (blockedByTarget) {
|
||||
setState({ loading: false })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!isImage && !isText) {
|
||||
setState({ loading: false })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setState({ loading: true })
|
||||
|
||||
try {
|
||||
if (isImage) {
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
|
||||
|
||||
if (active) {
|
||||
setState({ dataUrl, loading: false })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const result = await readTextPreview(filePath)
|
||||
|
||||
if (active) {
|
||||
const shouldBlock = !forcePreview && (result.binary || (result.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
|
||||
|
||||
setState({
|
||||
binary: result.binary,
|
||||
byteSize: result.byteSize,
|
||||
language: result.language || target.language || 'text',
|
||||
loading: false,
|
||||
text: shouldBlock ? undefined : result.text,
|
||||
truncated: result.truncated
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setState({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
loading: false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
|
||||
|
||||
if (state.loading) {
|
||||
return <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview…</div>
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return <PreviewEmptyState body={state.error} title="Preview unavailable" />
|
||||
}
|
||||
|
||||
if (
|
||||
!isImage &&
|
||||
!forcePreview &&
|
||||
(target.binary || target.large || state.binary || (state.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
|
||||
) {
|
||||
const binary = target.binary || state.binary
|
||||
const size = target.byteSize || state.byteSize
|
||||
|
||||
return (
|
||||
<PreviewEmptyState
|
||||
body={
|
||||
binary
|
||||
? `Previewing ${target.label} may show unreadable text.`
|
||||
: `${target.label} is ${formatBytes(size)}. Hermes will only show the first 512 KB.`
|
||||
}
|
||||
primaryAction={{ label: 'Preview anyway', onClick: () => setForcePreview(true) }}
|
||||
title={binary ? 'This looks like a binary file' : 'This file is large'}
|
||||
tone="warning"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isImage && state.dataUrl) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-auto bg-[color-mix(in_srgb,var(--dt-card)_42%,transparent)] p-4">
|
||||
<img
|
||||
alt={target.label}
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-sm"
|
||||
draggable={false}
|
||||
src={state.dataUrl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isText && state.text !== undefined) {
|
||||
const isMarkdown = (state.language || target.language) === 'markdown'
|
||||
const showRendered = isMarkdown && !renderMarkdownAsSource
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-background">
|
||||
{state.truncated && (
|
||||
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
|
||||
Showing first 512 KB.
|
||||
</div>
|
||||
)}
|
||||
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
|
||||
{showRendered ? (
|
||||
<MarkdownPreview text={state.text} />
|
||||
) : (
|
||||
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewEmptyState
|
||||
body={`${target.mimeType || 'This file type'} can still be attached as context.`}
|
||||
title="No inline preview"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,23 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type {
|
||||
ComponentProps,
|
||||
CSSProperties,
|
||||
MutableRefObject,
|
||||
DragEvent as ReactDragEvent,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
RefObject
|
||||
} from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Bug, PanelBottom, RefreshCw, Send, Trash2, X } from '@/lib/icons'
|
||||
import { Bug, RefreshCw, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerDraft, setComposerDraft } from '@/store/composer'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview'
|
||||
|
||||
import { type ConsoleEntry, createPreviewConsoleState, type PreviewConsoleState } from './preview-console-state'
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
import {
|
||||
clampConsoleHeight,
|
||||
compactUrl,
|
||||
formatLogLine,
|
||||
isNearConsoleBottom,
|
||||
PreviewConsolePanel,
|
||||
PreviewConsoleTitlebarIcon
|
||||
} from './preview-console'
|
||||
import { type ConsoleEntry, createPreviewConsoleState } from './preview-console-state'
|
||||
import { LocalFilePreview, PreviewEmptyState } from './preview-file'
|
||||
|
||||
type PreviewWebview = HTMLElement & {
|
||||
closeDevTools?: () => void
|
||||
@@ -50,62 +43,8 @@ interface PreviewLoadErrorState {
|
||||
url: string
|
||||
}
|
||||
|
||||
const consoleLevelLabel: Record<number, string> = {
|
||||
0: 'log',
|
||||
1: 'info',
|
||||
2: 'warn',
|
||||
3: 'error'
|
||||
}
|
||||
|
||||
const consoleLevelClass: Record<number, string> = {
|
||||
0: 'text-foreground',
|
||||
1: 'text-sky-700 dark:text-sky-300',
|
||||
2: 'text-amber-700 dark:text-amber-300',
|
||||
3: 'text-destructive'
|
||||
}
|
||||
|
||||
const CONSOLE_BOTTOM_THRESHOLD = 24
|
||||
const CONSOLE_HEADER_HEIGHT = 32
|
||||
const FILE_RELOAD_DEBOUNCE_MS = 200
|
||||
const SERVER_RESTART_TIMEOUT_MS = 45_000
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||
|
||||
function compactUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
if (url.protocol === 'file:') {
|
||||
return decodeURIComponent(url.pathname)
|
||||
}
|
||||
|
||||
return `${url.host}${url.pathname}${url.search}`
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function formatLogLine(log: ConsoleEntry): string {
|
||||
const head = `[${consoleLevelLabel[log.level] || 'log'}]`
|
||||
const tail = log.source ? ` (${compactUrl(log.source)}${log.line ? `:${log.line}` : ''})` : ''
|
||||
|
||||
return `${head} ${log.message}${tail}`.trim()
|
||||
}
|
||||
|
||||
function formatConsoleEntries(entries: ConsoleEntry[]): string {
|
||||
return entries.map(formatLogLine).join('\n')
|
||||
}
|
||||
|
||||
function isNearConsoleBottom(element: HTMLDivElement | null): boolean {
|
||||
if (!element) {
|
||||
return true
|
||||
}
|
||||
|
||||
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD
|
||||
}
|
||||
|
||||
function clampConsoleHeight(value: number): number {
|
||||
return Math.max(value, CONSOLE_HEADER_HEIGHT)
|
||||
}
|
||||
|
||||
function loadErrorTitle(error: PreviewLoadErrorState): string {
|
||||
const description = error.description.toLowerCase()
|
||||
@@ -127,176 +66,6 @@ function isModuleMimeError(message: string): boolean {
|
||||
return lower.includes('failed to load module script') && lower.includes('mime type')
|
||||
}
|
||||
|
||||
interface ConsoleRowProps {
|
||||
copyText: string
|
||||
log: ConsoleEntry
|
||||
onSend: () => void
|
||||
onToggleSelect: () => void
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/row grid grid-cols-[3.25rem_minmax(0,1fr)_auto] items-start gap-2 rounded-md border border-transparent px-1 py-1 transition-colors hover:bg-accent/40',
|
||||
selected && 'border-border/60 bg-accent/40'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
||||
consoleLevelClass[log.level] ?? consoleLevelClass[0]
|
||||
)}
|
||||
onClick={onToggleSelect}
|
||||
title={selected ? 'Deselect entry' : 'Select entry'}
|
||||
type="button"
|
||||
>
|
||||
{consoleLevelLabel[log.level] || 'log'}
|
||||
</button>
|
||||
<div className="min-w-0" data-selectable-text="true">
|
||||
<span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}>
|
||||
{log.message}
|
||||
</span>
|
||||
{log.source && (
|
||||
<span className="block truncate text-muted-foreground/60">
|
||||
{compactUrl(log.source)}
|
||||
{log.line ? `:${log.line}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="opacity-0 transition-opacity group-hover/row:opacity-100">
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
errorMessage="Could not copy console output"
|
||||
iconClassName="size-3"
|
||||
label="Copy this entry"
|
||||
showLabel={false}
|
||||
text={copyText}
|
||||
/>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onSend}
|
||||
title="Send this entry to chat"
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) {
|
||||
const logCount = useStore(consoleState.$logCount)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelBottom />
|
||||
{logCount > 0 && <span className="sr-only">{logCount} console messages</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type EmptyStateTone = 'neutral' | 'warning'
|
||||
|
||||
const TONE_STYLES: Record<EmptyStateTone, { cube: string; primary: string }> = {
|
||||
neutral: {
|
||||
cube: 'text-muted-foreground/35',
|
||||
primary: 'border-border bg-background text-foreground hover:bg-accent'
|
||||
},
|
||||
warning: {
|
||||
cube: 'text-amber-500/70 dark:text-amber-300/70',
|
||||
primary:
|
||||
'border-amber-400/40 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-300/30 dark:bg-amber-300/15 dark:text-amber-100 dark:hover:bg-amber-300/20'
|
||||
}
|
||||
}
|
||||
|
||||
function PreviewCubeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg aria-hidden="true" className={cn('size-16', className)} viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M32 5 56 18.5v27L32 59 8 45.5v-27L32 5Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.25"
|
||||
/>
|
||||
<path
|
||||
d="M8 18.5 32 32l24-13.5M32 32v27"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.25"
|
||||
/>
|
||||
<path d="M20 11.75 44 25.25" fill="none" opacity="0.45" stroke="currentColor" strokeWidth="0.9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface PreviewEmptyStateProps {
|
||||
body?: ReactNode
|
||||
consoleHeight?: number
|
||||
primaryAction?: { disabled?: boolean; label: string; onClick: () => void }
|
||||
secondaryAction?: { disabled?: boolean; label: string; onClick: () => void }
|
||||
title: string
|
||||
tone?: EmptyStateTone
|
||||
}
|
||||
|
||||
function PreviewEmptyState({
|
||||
body,
|
||||
consoleHeight = 0,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
title,
|
||||
tone = 'neutral'
|
||||
}: PreviewEmptyStateProps) {
|
||||
const styles = TONE_STYLES[tone]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-8 py-10 text-center bottom-(--preview-error-bottom)"
|
||||
style={{ '--preview-error-bottom': `${consoleHeight}px` } as CSSProperties}
|
||||
>
|
||||
<div className="grid max-w-sm justify-items-center gap-5">
|
||||
<PreviewCubeIcon className={styles.cube} />
|
||||
<div className="grid gap-2">
|
||||
<div className="text-sm font-medium text-foreground">{title}</div>
|
||||
{body && <div className="text-xs leading-relaxed text-muted-foreground">{body}</div>}
|
||||
</div>
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="grid justify-items-center gap-2">
|
||||
{primaryAction && (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-full border px-3.5 py-1.5 text-xs font-medium shadow-xs transition-colors disabled:cursor-default disabled:opacity-60',
|
||||
styles.primary
|
||||
)}
|
||||
disabled={primaryAction.disabled}
|
||||
onClick={primaryAction.onClick}
|
||||
type="button"
|
||||
>
|
||||
{primaryAction.label}
|
||||
</button>
|
||||
)}
|
||||
{secondaryAction && (
|
||||
<button
|
||||
className="text-[0.6875rem] font-medium text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55 disabled:cursor-default disabled:text-muted-foreground/55 disabled:no-underline"
|
||||
disabled={secondaryAction.disabled}
|
||||
onClick={secondaryAction.onClick}
|
||||
type="button"
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewLoadError({
|
||||
consoleHeight = 0,
|
||||
error,
|
||||
@@ -344,592 +113,6 @@ function PreviewLoadError({
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewConsolePanel({
|
||||
consoleBodyRef,
|
||||
consoleShouldStickRef,
|
||||
consoleState,
|
||||
startConsoleResize
|
||||
}: {
|
||||
consoleBodyRef: RefObject<HTMLDivElement | null>
|
||||
consoleShouldStickRef: MutableRefObject<boolean>
|
||||
consoleState: PreviewConsoleState
|
||||
startConsoleResize: (event: ReactPointerEvent<HTMLDivElement>) => void
|
||||
}) {
|
||||
const consoleHeight = useStore(consoleState.$height)
|
||||
const logs = useStore(consoleState.$logs)
|
||||
const selectedLogIds = useStore(consoleState.$selectedLogIds)
|
||||
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
|
||||
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
|
||||
const stickScrollRafRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!consoleShouldStickRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (stickScrollRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(stickScrollRafRef.current)
|
||||
stickScrollRafRef.current = null
|
||||
}
|
||||
|
||||
stickScrollRafRef.current = window.requestAnimationFrame(() => {
|
||||
stickScrollRafRef.current = null
|
||||
const consoleBody = consoleBodyRef.current
|
||||
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (stickScrollRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(stickScrollRafRef.current)
|
||||
stickScrollRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs])
|
||||
|
||||
function sendLogsToComposer(entries: ConsoleEntry[]) {
|
||||
if (!entries.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
|
||||
const draft = $composerDraft.get()
|
||||
const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}`
|
||||
|
||||
setComposerDraft(next)
|
||||
consoleState.clearSelection()
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Sent to chat',
|
||||
message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer`
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto absolute inset-x-0 bottom-0 z-20 flex h-(--preview-console-height) min-h-8 flex-col overflow-hidden border-t border-border/60 bg-background"
|
||||
style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
aria-label="Resize preview console"
|
||||
className="group absolute inset-x-0 -top-1 z-1 h-2 cursor-row-resize"
|
||||
onDoubleClick={() => consoleState.setHeight(CONSOLE_HEADER_HEIGHT)}
|
||||
onPointerDown={startConsoleResize}
|
||||
role="separator"
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-0.75 w-23 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.5]" />
|
||||
</div>
|
||||
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/50 px-2">
|
||||
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
|
||||
<PanelBottom className="size-3.5" />
|
||||
Preview Console
|
||||
{selectedLogIds.size > 0 && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
|
||||
{selectedLogIds.size} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={sendableLogs.length === 0}
|
||||
onClick={() => sendLogsToComposer(sendableLogs)}
|
||||
title={
|
||||
visibleSelection.length > 0
|
||||
? `Send ${visibleSelection.length} selected to chat`
|
||||
: 'Send all log entries to chat'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
Send to chat
|
||||
</button>
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={sendableLogs.length === 0}
|
||||
errorMessage="Could not copy console output"
|
||||
iconClassName="size-3"
|
||||
label={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
|
||||
text={() => formatConsoleEntries(sendableLogs)}
|
||||
>
|
||||
Copy
|
||||
</CopyButton>
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={logs.length === 0}
|
||||
onClick={consoleState.clear}
|
||||
title="Clear console"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed"
|
||||
ref={consoleBodyRef}
|
||||
>
|
||||
{logs.length > 0 ? (
|
||||
logs.map(log => {
|
||||
const selected = selectedLogIds.has(log.id)
|
||||
|
||||
return (
|
||||
<ConsoleRow
|
||||
copyText={formatLogLine(log)}
|
||||
key={log.id}
|
||||
log={log}
|
||||
onSend={() => sendLogsToComposer([log])}
|
||||
onToggleSelect={() => consoleState.toggleSelection(log.id)}
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LocalPreviewState {
|
||||
binary?: boolean
|
||||
byteSize?: number
|
||||
dataUrl?: string
|
||||
error?: string
|
||||
language?: string
|
||||
loading: boolean
|
||||
text?: string
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
function filePathForTarget(target: PreviewTarget) {
|
||||
if (target.path) {
|
||||
return target.path
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(target.url)
|
||||
|
||||
return url.protocol === 'file:' ? decodeURIComponent(url.pathname) : target.url
|
||||
} catch {
|
||||
return target.url
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | undefined) {
|
||||
if (!bytes) {
|
||||
return 'unknown size'
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
|
||||
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
|
||||
function looksBinaryBytes(bytes: Uint8Array) {
|
||||
if (!bytes.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let suspicious = 0
|
||||
|
||||
for (const byte of bytes.slice(0, 4096)) {
|
||||
if (byte === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
|
||||
suspicious += 1
|
||||
}
|
||||
}
|
||||
|
||||
return suspicious / Math.min(bytes.length, 4096) > 0.12
|
||||
}
|
||||
|
||||
async function readTextPreview(filePath: string) {
|
||||
if (window.hermesDesktop.readFileText) {
|
||||
try {
|
||||
return await window.hermesDesktop.readFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back-compat for a running Electron process whose preload hasn't been
|
||||
// restarted since readFileText was added. readFileDataUrl already existed.
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
|
||||
const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
|
||||
const base64 = metadata.includes(';base64')
|
||||
const mimeType = metadata.replace(/;base64$/, '') || undefined
|
||||
const raw = base64 ? atob(data) : decodeURIComponent(data)
|
||||
const bytes = Uint8Array.from(raw, ch => ch.charCodeAt(0))
|
||||
|
||||
return {
|
||||
binary: looksBinaryBytes(bytes),
|
||||
byteSize: bytes.byteLength,
|
||||
mimeType,
|
||||
path: filePath,
|
||||
text: new TextDecoder().decode(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight markdown renderer for file previews. Streamdown does the parse;
|
||||
// our components keep typography simple and route fenced code through Shiki
|
||||
// without the library's copy/download/fullscreen chrome.
|
||||
const MD_TAG_CLASSES = {
|
||||
h1: 'mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0',
|
||||
h2: 'mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0',
|
||||
h3: 'mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0',
|
||||
h4: 'mb-2 mt-3 text-base font-semibold leading-snug first:mt-0',
|
||||
p: 'mb-4 leading-relaxed text-foreground last:mb-0',
|
||||
ul: 'mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0',
|
||||
ol: 'mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0',
|
||||
li: 'mt-1 leading-relaxed',
|
||||
blockquote: 'mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0',
|
||||
pre: 'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono'
|
||||
} as const
|
||||
|
||||
function tagged<T extends keyof typeof MD_TAG_CLASSES>(Tag: T) {
|
||||
const base = MD_TAG_CLASSES[Tag]
|
||||
|
||||
const Component = (({ className, ...rest }: ComponentProps<T>) => {
|
||||
const Element = Tag as React.ElementType
|
||||
|
||||
return <Element className={cn(base, className)} {...rest} />
|
||||
}) as React.FC<ComponentProps<T>>
|
||||
|
||||
Component.displayName = `Md.${Tag}`
|
||||
|
||||
return Component
|
||||
}
|
||||
|
||||
function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) {
|
||||
const language = /language-([^\s]+)/.exec(className || '')?.[1]
|
||||
|
||||
if (!language) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
'rounded bg-muted px-1 py-0.5 font-mono text-[0.86em] text-pink-700 dark:text-pink-300',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</ShikiHighlighter>
|
||||
)
|
||||
}
|
||||
|
||||
const MARKDOWN_COMPONENTS = {
|
||||
h1: tagged('h1'),
|
||||
h2: tagged('h2'),
|
||||
h3: tagged('h3'),
|
||||
h4: tagged('h4'),
|
||||
p: tagged('p'),
|
||||
ul: tagged('ul'),
|
||||
ol: tagged('ol'),
|
||||
li: tagged('li'),
|
||||
blockquote: tagged('blockquote'),
|
||||
pre: tagged('pre'),
|
||||
code: MarkdownCode
|
||||
}
|
||||
|
||||
function MarkdownPreview({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
|
||||
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
|
||||
{text}
|
||||
</Streamdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
|
||||
<button
|
||||
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{asSource ? 'PREVIEW' : 'SOURCE'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
|
||||
// each line aligns vertically. The selection overlay relies on the same
|
||||
// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
|
||||
const SOURCE_LINE_HEIGHT_REM = 1.21875
|
||||
const SOURCE_PAD_Y_REM = 0.75
|
||||
|
||||
interface LineSelection {
|
||||
end: number
|
||||
start: number
|
||||
}
|
||||
|
||||
function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { end, start }: LineSelection) {
|
||||
const lineEnd = end > start ? end : undefined
|
||||
const label = lineEnd ? `${filePath}:${start}-${end}` : `${filePath}:${start}`
|
||||
|
||||
event.dataTransfer.setData(HERMES_PATHS_MIME, JSON.stringify([{ line: start, lineEnd, path: filePath }]))
|
||||
event.dataTransfer.setData('text/plain', label)
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
|
||||
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
|
||||
const [selection, setSelection] = useState<LineSelection | null>(null)
|
||||
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
|
||||
|
||||
const handleLineClick = (event: ReactMouseEvent, line: number) => {
|
||||
if (event.shiftKey && selection) {
|
||||
setSelection({ end: Math.max(selection.end, line), start: Math.min(selection.start, line) })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (selection?.start === line && selection.end === line) {
|
||||
setSelection(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSelection({ end: line, start: line })
|
||||
}
|
||||
|
||||
const handleDragStart = (event: ReactDragEvent<HTMLElement>, line: number) => {
|
||||
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
|
||||
<div className="select-none py-3 text-right text-muted-foreground/55">
|
||||
{Array.from({ length: lineCount }, (_, index) => {
|
||||
const line = index + 1
|
||||
const selected = inSelection(line)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-pointer px-3 tabular-nums transition-colors',
|
||||
selected
|
||||
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
|
||||
: 'hover:text-foreground'
|
||||
)}
|
||||
draggable
|
||||
key={line}
|
||||
onClick={event => handleLineClick(event, line)}
|
||||
onDragStart={event => handleDragStart(event, line)}
|
||||
title="Click to select · shift-click to extend · drag to composer"
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3">
|
||||
{selection && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
|
||||
style={{
|
||||
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
|
||||
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{text}
|
||||
</ShikiHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
|
||||
const [state, setState] = useState<LocalPreviewState>({ loading: true })
|
||||
const [forcePreview, setForcePreview] = useState(false)
|
||||
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
|
||||
const filePath = filePathForTarget(target)
|
||||
const isImage = target.previewKind === 'image'
|
||||
|
||||
// HTML files are rendered as source code, not in a webview — so they take
|
||||
// the same path as plain text files. `previewKind === 'binary'` arrives
|
||||
// when the file is forcibly previewed past the binary refusal screen.
|
||||
const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html'
|
||||
|
||||
const blockedByTarget = !isImage && !forcePreview && (target.binary || target.large)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
async function load() {
|
||||
if (blockedByTarget) {
|
||||
setState({ loading: false })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!isImage && !isText) {
|
||||
setState({ loading: false })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setState({ loading: true })
|
||||
|
||||
try {
|
||||
if (isImage) {
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
|
||||
|
||||
if (active) {
|
||||
setState({ dataUrl, loading: false })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const result = await readTextPreview(filePath)
|
||||
|
||||
if (active) {
|
||||
const shouldBlock = !forcePreview && (result.binary || (result.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
|
||||
|
||||
setState({
|
||||
binary: result.binary,
|
||||
byteSize: result.byteSize,
|
||||
language: result.language || target.language || 'text',
|
||||
loading: false,
|
||||
text: shouldBlock ? undefined : result.text,
|
||||
truncated: result.truncated
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setState({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
loading: false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
|
||||
|
||||
if (state.loading) {
|
||||
return <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview…</div>
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return <PreviewEmptyState body={state.error} title="Preview unavailable" />
|
||||
}
|
||||
|
||||
if (
|
||||
!isImage &&
|
||||
!forcePreview &&
|
||||
(target.binary || target.large || state.binary || (state.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
|
||||
) {
|
||||
const binary = target.binary || state.binary
|
||||
const size = target.byteSize || state.byteSize
|
||||
|
||||
return (
|
||||
<PreviewEmptyState
|
||||
body={
|
||||
binary
|
||||
? `Previewing ${target.label} may show unreadable text.`
|
||||
: `${target.label} is ${formatBytes(size)}. Hermes will only show the first 512 KB.`
|
||||
}
|
||||
primaryAction={{ label: 'Preview anyway', onClick: () => setForcePreview(true) }}
|
||||
title={binary ? 'This looks like a binary file' : 'This file is large'}
|
||||
tone="warning"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isImage && state.dataUrl) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-auto bg-[color-mix(in_srgb,var(--dt-card)_42%,transparent)] p-4">
|
||||
<img
|
||||
alt={target.label}
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-sm"
|
||||
draggable={false}
|
||||
src={state.dataUrl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isText && state.text !== undefined) {
|
||||
const isMarkdown = (state.language || target.language) === 'markdown'
|
||||
const showRendered = isMarkdown && !renderMarkdownAsSource
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-background">
|
||||
{state.truncated && (
|
||||
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
|
||||
Showing first 512 KB.
|
||||
</div>
|
||||
)}
|
||||
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
|
||||
{showRendered ? (
|
||||
<MarkdownPreview text={state.text} />
|
||||
) : (
|
||||
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewEmptyState
|
||||
body={`${target.mimeType || 'This file type'} can still be attached as context.`}
|
||||
title="No inline preview"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const TITLEBAR_GROUP_ID = 'preview'
|
||||
|
||||
export function PreviewPane({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
|
||||
export type ThreadLoadingState = 'response' | 'session'
|
||||
|
||||
export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean {
|
||||
const lastVisible = [...messages].reverse().find(message => !message.hidden)
|
||||
|
||||
@@ -11,7 +13,7 @@ export function threadLoadingState(
|
||||
busy: boolean,
|
||||
awaitingResponse: boolean,
|
||||
lastVisibleIsUser: boolean
|
||||
) {
|
||||
): ThreadLoadingState | undefined {
|
||||
if (loadingSession) {
|
||||
return 'session'
|
||||
}
|
||||
|
||||
@@ -89,7 +89,12 @@ const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New chat', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
|
||||
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
|
||||
{ id: 'nav-messaging', route: MESSAGING_ROUTE, title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' },
|
||||
{
|
||||
id: 'nav-messaging',
|
||||
route: MESSAGING_ROUTE,
|
||||
title: 'Messaging',
|
||||
detail: 'Set up Telegram, Slack, Discord, and more'
|
||||
},
|
||||
{ id: 'nav-artifacts', route: ARTIFACTS_ROUTE, title: 'Artifacts', detail: 'Browse generated outputs' }
|
||||
]
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
||||
|
||||
import { ProjectTree } from './tree'
|
||||
import { useProjectTree } from './use-project-tree'
|
||||
|
||||
|
||||
@@ -22,8 +22,11 @@ export function useRouteEnumParam<T extends string>(
|
||||
(next: T) => {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
if (next === fallback) {params.delete(key)}
|
||||
else {params.set(key, next)}
|
||||
if (next === fallback) {
|
||||
params.delete(key)
|
||||
} else {
|
||||
params.set(key, next)
|
||||
}
|
||||
|
||||
const qs = params.toString()
|
||||
navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true })
|
||||
|
||||
@@ -78,11 +78,17 @@ const HINT_BY_STATE: Record<string, string> = {
|
||||
const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown')
|
||||
|
||||
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
|
||||
if (!enabled) {return 'muted'}
|
||||
if (!enabled) {
|
||||
return 'muted'
|
||||
}
|
||||
|
||||
if (state === 'connected') {return 'good'}
|
||||
if (state === 'connected') {
|
||||
return 'good'
|
||||
}
|
||||
|
||||
if (state === 'fatal' || state === 'startup_failed') {return 'bad'}
|
||||
if (state === 'fatal' || state === 'startup_failed') {
|
||||
return 'bad'
|
||||
}
|
||||
|
||||
return 'warn'
|
||||
}
|
||||
@@ -511,9 +517,7 @@ function PlatformDetail({
|
||||
|
||||
<section>
|
||||
<SectionTitle>Get your credentials</SectionTitle>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
{introCopy(platform)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">{introCopy(platform)}</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={platform.docs_url} rel="noreferrer" target="_blank">
|
||||
@@ -572,9 +576,7 @@ function PlatformDetail({
|
||||
type="button"
|
||||
>
|
||||
<span>Advanced ({hiddenCount})</span>
|
||||
<ChevronDown
|
||||
className={cn('size-3.5 transition-transform', !showAdvanced && '-rotate-90')}
|
||||
/>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', !showAdvanced && '-rotate-90')} />
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 space-y-4">
|
||||
@@ -632,7 +634,8 @@ const PLATFORM_INTRO: Record<string, string> = {
|
||||
mattermost:
|
||||
'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.',
|
||||
matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.',
|
||||
signal: 'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.',
|
||||
signal:
|
||||
'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.',
|
||||
whatsapp:
|
||||
'Start the WhatsApp bridge that ships with Hermes, scan the QR code on first run, then enable the platform.',
|
||||
bluebubbles:
|
||||
@@ -642,8 +645,7 @@ const PLATFORM_INTRO: Record<string, string> = {
|
||||
email:
|
||||
'Use a dedicated mailbox. For Gmail/Workspace, create an app password and use imap.gmail.com / smtp.gmail.com.',
|
||||
sms: 'Get your Twilio Account SID and Auth Token from the Twilio console, plus a phone number that can send SMS.',
|
||||
dingtalk:
|
||||
'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.',
|
||||
dingtalk: 'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.',
|
||||
feishu:
|
||||
'Create a Feishu / Lark app, configure the bot capability, and copy the App ID, App secret, and event encryption keys.',
|
||||
wecom:
|
||||
@@ -655,7 +657,8 @@ const PLATFORM_INTRO: Record<string, string> = {
|
||||
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
|
||||
api_server:
|
||||
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',
|
||||
webhook: 'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
|
||||
webhook:
|
||||
'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
|
||||
}
|
||||
|
||||
const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description
|
||||
@@ -717,16 +720,15 @@ function MessagingField({
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{children}</h4>
|
||||
)
|
||||
return <h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{children}</h4>
|
||||
}
|
||||
|
||||
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
|
||||
if (!platform.enabled || platform.state === 'connected') {return null}
|
||||
if (!platform.enabled || platform.state === 'connected') {
|
||||
return null
|
||||
}
|
||||
|
||||
const hint =
|
||||
HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
|
||||
const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
|
||||
|
||||
return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null
|
||||
}
|
||||
@@ -748,7 +750,10 @@ function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
|
||||
function SetupPill({ active, children }: { active: boolean; children: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium', PILL_TONE[active ? 'good' : 'muted'])}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
|
||||
PILL_TONE[active ? 'good' : 'muted']
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
@@ -65,7 +65,9 @@ const STREAM_DELTA_FLUSH_MS = 16
|
||||
// Anonymous progress events that carry todos but no name still belong to the
|
||||
// todo stream; named todo events are obviously routed there too.
|
||||
function toTodoPayload(payload: GatewayEventPayload | undefined): GatewayEventPayload | undefined {
|
||||
if (!payload) {return undefined}
|
||||
if (!payload) {
|
||||
return undefined
|
||||
}
|
||||
const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos'))
|
||||
|
||||
return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined
|
||||
@@ -561,7 +563,9 @@ export function useMessageStream({
|
||||
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
||||
}
|
||||
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
|
||||
if (!sessionId) {return}
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
flushQueuedDeltas(sessionId)
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running')
|
||||
} else if (event.type === 'tool.complete') {
|
||||
|
||||
@@ -46,19 +46,12 @@ function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
})
|
||||
}
|
||||
|
||||
interface SetupStatus {
|
||||
provider_configured?: boolean
|
||||
}
|
||||
|
||||
interface RuntimeCheck {
|
||||
error?: string
|
||||
ok?: boolean
|
||||
}
|
||||
|
||||
function isProviderSetupError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i.test(message)
|
||||
return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i.test(
|
||||
message
|
||||
)
|
||||
}
|
||||
|
||||
interface PromptActionsOptions {
|
||||
@@ -197,25 +190,24 @@ export function usePromptActions({
|
||||
async (rawText: string) => {
|
||||
const visibleText = rawText.trim()
|
||||
const attachments = $composerAttachments.get()
|
||||
|
||||
const contextRefs = attachments
|
||||
.map(attachment => attachment.refText)
|
||||
.map(a => a.refText)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image')
|
||||
const attachmentRefs = attachments.map(attachmentDisplayText).filter((ref): ref is string => Boolean(ref))
|
||||
const hasImage = attachments.some(a => a.kind === 'image')
|
||||
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
|
||||
|
||||
const text =
|
||||
[contextRefs, visibleText].filter(Boolean).join('\n\n') ||
|
||||
(hasImageAttachment ? 'What do you see in this image?' : '')
|
||||
[contextRefs, visibleText].filter(Boolean).join('\n\n') || (hasImage ? 'What do you see in this image?' : '')
|
||||
|
||||
if (!text || busyRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
id: optimisticId,
|
||||
role: 'user',
|
||||
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
|
||||
attachmentRefs
|
||||
@@ -227,61 +219,80 @@ export function usePromptActions({
|
||||
setAwaitingResponse(false)
|
||||
}
|
||||
|
||||
// Idempotent optimistic insert — re-running with the resolved sessionId
|
||||
// after createBackendSessionForSend just overwrites with the same id.
|
||||
const seedOptimistic = (sid: string) =>
|
||||
updateSessionState(
|
||||
sid,
|
||||
state => ({
|
||||
...state,
|
||||
messages: state.messages.some(m => m.id === optimisticId)
|
||||
? state.messages
|
||||
: [...state.messages, userMessage],
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false
|
||||
}),
|
||||
selectedStoredSessionIdRef.current
|
||||
)
|
||||
|
||||
const dropOptimistic = (sid: null | string) => {
|
||||
if (!sid) {
|
||||
setMessages(current => current.filter(m => m.id !== optimisticId))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(
|
||||
sid,
|
||||
state => ({
|
||||
...state,
|
||||
messages: state.messages.filter(m => m.id !== optimisticId),
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
pendingBranchGroup: null
|
||||
}),
|
||||
selectedStoredSessionIdRef.current
|
||||
)
|
||||
}
|
||||
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
clearNotifications()
|
||||
|
||||
const [setup, runtime] = await Promise.all([
|
||||
requestGateway<SetupStatus>('setup.status').catch(() => null),
|
||||
requestGateway<RuntimeCheck>('setup.runtime_check').catch(() => null)
|
||||
])
|
||||
let sessionId: null | string = activeSessionId
|
||||
|
||||
const runtimeReady = runtime?.ok !== undefined ? Boolean(runtime?.ok) : setup?.provider_configured !== false
|
||||
|
||||
if (!runtimeReady) {
|
||||
releaseBusy()
|
||||
requestDesktopOnboarding(
|
||||
runtime?.error || 'Add a provider credential before sending your first message.'
|
||||
)
|
||||
|
||||
return
|
||||
if (sessionId) {
|
||||
seedOptimistic(sessionId)
|
||||
} else {
|
||||
setMessages(current => [...current, userMessage])
|
||||
}
|
||||
|
||||
let sessionId = activeSessionId
|
||||
|
||||
if (!sessionId) {
|
||||
try {
|
||||
sessionId = await createBackendSessionForSend()
|
||||
} catch (err) {
|
||||
dropOptimistic(null)
|
||||
releaseBusy()
|
||||
notifyError(err, 'Session unavailable')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
dropOptimistic(null)
|
||||
releaseBusy()
|
||||
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
seedOptimistic(sessionId)
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
releaseBusy()
|
||||
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(
|
||||
sessionId,
|
||||
state => ({
|
||||
...state,
|
||||
messages: [...state.messages, userMessage],
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false
|
||||
}),
|
||||
selectedStoredSessionIdRef.current
|
||||
)
|
||||
|
||||
try {
|
||||
await syncImageAttachmentsForSubmit(sessionId, attachments)
|
||||
await requestGateway('prompt.submit', { session_id: sessionId, text })
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useRouteResume } from './use-route-resume'
|
||||
|
||||
interface HarnessProps {
|
||||
activeSessionId: null | string
|
||||
activeSessionIdRef: MutableRefObject<null | string>
|
||||
creatingSessionRef: MutableRefObject<boolean>
|
||||
currentView: string
|
||||
freshDraftReady: boolean
|
||||
gatewayState: string
|
||||
locationPathname: string
|
||||
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
||||
routedSessionId: null | string
|
||||
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
||||
selectedStoredSessionId: null | string
|
||||
selectedStoredSessionIdRef: MutableRefObject<null | string>
|
||||
startFreshSessionDraft: (focus: boolean) => unknown
|
||||
}
|
||||
|
||||
function RouteResumeHarness(props: HarnessProps) {
|
||||
useRouteResume(props)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('useRouteResume', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does not re-resume the old session during a /:sid -> /new transition', () => {
|
||||
const resumeSession = vi.fn(async () => undefined)
|
||||
const startFreshSessionDraft = vi.fn()
|
||||
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
|
||||
const creatingSessionRef = { current: false }
|
||||
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
|
||||
|
||||
const { rerender } = render(
|
||||
<RouteResumeHarness
|
||||
activeSessionId="runtime-1"
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
creatingSessionRef={creatingSessionRef}
|
||||
currentView="chat"
|
||||
freshDraftReady={false}
|
||||
gatewayState="open"
|
||||
locationPathname="/session-1"
|
||||
resumeSession={resumeSession}
|
||||
routedSessionId="session-1"
|
||||
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
||||
selectedStoredSessionId="session-1"
|
||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
||||
startFreshSessionDraft={startFreshSessionDraft}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(resumeSession).not.toHaveBeenCalled()
|
||||
|
||||
// Simulate startFreshSessionDraft state updates landing before route update.
|
||||
activeSessionIdRef.current = null
|
||||
selectedStoredSessionIdRef.current = null
|
||||
rerender(
|
||||
<RouteResumeHarness
|
||||
activeSessionId={null}
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
creatingSessionRef={creatingSessionRef}
|
||||
currentView="chat"
|
||||
freshDraftReady
|
||||
gatewayState="open"
|
||||
locationPathname="/session-1"
|
||||
resumeSession={resumeSession}
|
||||
routedSessionId="session-1"
|
||||
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
||||
selectedStoredSessionId={null}
|
||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
||||
startFreshSessionDraft={startFreshSessionDraft}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(resumeSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resumes when pathname changes to a routed session', () => {
|
||||
const resumeSession = vi.fn(async () => undefined)
|
||||
const startFreshSessionDraft = vi.fn()
|
||||
const activeSessionIdRef: MutableRefObject<null | string> = { current: null }
|
||||
const creatingSessionRef = { current: false }
|
||||
const runtimeIdByStoredSessionIdRef = { current: new Map() }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: null }
|
||||
|
||||
const { rerender } = render(
|
||||
<RouteResumeHarness
|
||||
activeSessionId={null}
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
creatingSessionRef={creatingSessionRef}
|
||||
currentView="chat"
|
||||
freshDraftReady
|
||||
gatewayState="open"
|
||||
locationPathname="/"
|
||||
resumeSession={resumeSession}
|
||||
routedSessionId={null}
|
||||
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
||||
selectedStoredSessionId={null}
|
||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
||||
startFreshSessionDraft={startFreshSessionDraft}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(resumeSession).not.toHaveBeenCalled()
|
||||
|
||||
rerender(
|
||||
<RouteResumeHarness
|
||||
activeSessionId={null}
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
creatingSessionRef={creatingSessionRef}
|
||||
currentView="chat"
|
||||
freshDraftReady
|
||||
gatewayState="open"
|
||||
locationPathname="/session-2"
|
||||
resumeSession={resumeSession}
|
||||
routedSessionId="session-2"
|
||||
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
||||
selectedStoredSessionId={null}
|
||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
||||
startFreshSessionDraft={startFreshSessionDraft}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(resumeSession).toHaveBeenCalledTimes(1)
|
||||
expect(resumeSession).toHaveBeenCalledWith('session-2', true)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type MutableRefObject, useEffect } from 'react'
|
||||
import { type MutableRefObject, useEffect, useRef } from 'react'
|
||||
|
||||
import { isNewChatRoute } from '@/app/routes'
|
||||
|
||||
@@ -55,8 +55,17 @@ export function useRouteResume({
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft
|
||||
}: RouteResumeOptions) {
|
||||
const lastPathnameRef = useRef<string | null>(null)
|
||||
const wasGatewayOpenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== 'chat' || gatewayState !== 'open') {
|
||||
const gatewayOpen = gatewayState === 'open'
|
||||
const pathnameChanged = lastPathnameRef.current !== locationPathname
|
||||
const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen
|
||||
lastPathnameRef.current = locationPathname
|
||||
wasGatewayOpenRef.current = gatewayOpen
|
||||
|
||||
if (currentView !== 'chat' || !gatewayOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -68,7 +77,12 @@ export function useRouteResume({
|
||||
Boolean(cachedRuntime) &&
|
||||
cachedRuntime === activeSessionIdRef.current
|
||||
|
||||
if (!alreadyActive) {
|
||||
// Resume only when the route meaningfully changed (or gateway just opened).
|
||||
// This avoids a transient /:sid re-resume during "new chat" state clears
|
||||
// before the pathname updates from /:sid -> /.
|
||||
const shouldResume = pathnameChanged || gatewayBecameOpen
|
||||
|
||||
if (!alreadyActive && shouldResume && !creatingSessionRef.current) {
|
||||
void resumeSession(routedSessionId, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,9 @@ function ModeCard({
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-2xl border p-4 text-left transition',
|
||||
active ? 'border-primary bg-primary/10 ring-2 ring-primary/15' : 'border-border bg-background/60 hover:bg-muted/40',
|
||||
active
|
||||
? 'border-primary bg-primary/10 ring-2 ring-primary/15'
|
||||
: 'border-border bg-background/60 hover:bg-muted/40',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
disabled={disabled}
|
||||
@@ -179,7 +181,12 @@ export function GatewaySettings() {
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.getConnectionConfig) {
|
||||
return <EmptyState description="The desktop IPC bridge does not expose gateway settings." title="Gateway settings unavailable" />
|
||||
return (
|
||||
<EmptyState
|
||||
description="The desktop IPC bridge does not expose gateway settings."
|
||||
title="Gateway settings unavailable"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -191,8 +198,8 @@ export function GatewaySettings() {
|
||||
{state.envOverride ? <Pill tone="primary">env override</Pill> : null}
|
||||
</div>
|
||||
<p className="mt-2 max-w-2xl text-xs leading-5 text-muted-foreground">
|
||||
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to
|
||||
control an already-running Hermes backend on another machine or behind a trusted proxy.
|
||||
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
|
||||
an already-running Hermes backend on another machine or behind a trusted proxy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -249,7 +256,9 @@ export function GatewaySettings() {
|
||||
className={cn('h-8 font-mono', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setRemoteToken(event.target.value)}
|
||||
placeholder={state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'}
|
||||
placeholder={
|
||||
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
|
||||
}
|
||||
type="password"
|
||||
value={remoteToken}
|
||||
/>
|
||||
@@ -262,7 +271,11 @@ export function GatewaySettings() {
|
||||
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-3">
|
||||
<Button disabled={state.envOverride || testing || !canUseRemote} onClick={() => void testRemote()} variant="outline">
|
||||
<Button
|
||||
disabled={state.envOverride || testing || !canUseRemote}
|
||||
onClick={() => void testRemote()}
|
||||
variant="outline"
|
||||
>
|
||||
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Test remote
|
||||
</Button>
|
||||
|
||||
@@ -43,11 +43,12 @@ function safeSet(target: Record<string, unknown>, key: string, value: unknown):
|
||||
if (key === '__proto__' || key === 'constructor' || key === 'prototype' || !key) {
|
||||
throw new Error(`Unsafe config key: ${key}`)
|
||||
}
|
||||
|
||||
Object.defineProperty(target, key, {
|
||||
value,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export function AppShell({
|
||||
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
||||
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
||||
|
||||
const titlebarContentInset = sidebarOpen
|
||||
? 0
|
||||
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
|
||||
|
||||
@@ -123,8 +123,9 @@ export function useStatusbarItems({
|
||||
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
|
||||
const behind = updateStatus?.behind ?? 0
|
||||
const applying = updateApply.applying || updateApply.stage === 'restart'
|
||||
const base = appVersion ? `v${appVersion}` : sha ?? 'unknown'
|
||||
const base = appVersion ? `v${appVersion}` : (sha ?? 'unknown')
|
||||
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
|
||||
|
||||
const label = applying
|
||||
? updateApply.stage === 'restart'
|
||||
? `${base} · restart`
|
||||
|
||||
@@ -110,37 +110,37 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
: (item.menuItems ?? [])
|
||||
.filter(menuItem => !menuItem.hidden)
|
||||
.map(menuItem => (
|
||||
<DropdownMenuItem
|
||||
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
|
||||
disabled={menuItem.disabled}
|
||||
key={menuItem.id}
|
||||
onSelect={() => {
|
||||
if (menuItem.to) {
|
||||
navigate(menuItem.to)
|
||||
}
|
||||
<DropdownMenuItem
|
||||
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
|
||||
disabled={menuItem.disabled}
|
||||
key={menuItem.id}
|
||||
onSelect={() => {
|
||||
if (menuItem.to) {
|
||||
navigate(menuItem.to)
|
||||
}
|
||||
|
||||
menuItem.onSelect?.()
|
||||
}}
|
||||
>
|
||||
{menuItem.href ? (
|
||||
<a
|
||||
className="inline-flex w-full items-center gap-2"
|
||||
href={menuItem.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={menuItem.title ?? menuItem.label}
|
||||
menuItem.onSelect?.()
|
||||
}}
|
||||
>
|
||||
{menuItem.icon}
|
||||
<span className="truncate">{menuItem.label}</span>
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
{menuItem.icon}
|
||||
<span className="truncate">{menuItem.label}</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{menuItem.href ? (
|
||||
<a
|
||||
className="inline-flex w-full items-center gap-2"
|
||||
href={menuItem.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={menuItem.title ?? menuItem.label}
|
||||
>
|
||||
{menuItem.icon}
|
||||
<span className="truncate">{menuItem.label}</span>
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
{menuItem.icon}
|
||||
<span className="truncate">{menuItem.label}</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
@@ -379,10 +379,7 @@ function CategoryButton({
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'underline-offset-4 decoration-current',
|
||||
active ? 'font-medium underline' : 'hover:underline'
|
||||
)}
|
||||
className={cn('underline-offset-4 decoration-current', active ? 'font-medium underline' : 'hover:underline')}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -46,11 +46,9 @@ export function UpdatesOverlay() {
|
||||
}, [checking, open, status])
|
||||
|
||||
const behind = status?.behind ?? 0
|
||||
const phase: 'idle' | 'applying' | 'error' = apply.applying || apply.stage === 'restart'
|
||||
? 'applying'
|
||||
: apply.stage === 'error'
|
||||
? 'error'
|
||||
: 'idle'
|
||||
|
||||
const phase: 'idle' | 'applying' | 'error' =
|
||||
apply.applying || apply.stage === 'restart' ? 'applying' : apply.stage === 'error' ? 'error' : 'idle'
|
||||
|
||||
const handleClose = (next: boolean) => {
|
||||
if (phase === 'applying') {
|
||||
@@ -114,7 +112,9 @@ function IdleView({
|
||||
status: DesktopUpdateStatus | null
|
||||
}) {
|
||||
if (!status && checking) {
|
||||
return <CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title="Looking for updates…" />
|
||||
return (
|
||||
<CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title="Looking for updates…" />
|
||||
)
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
@@ -223,7 +223,9 @@ function IdleView({
|
||||
</div>
|
||||
|
||||
{remaining > 0 && (
|
||||
<p className="text-center text-xs text-muted-foreground">+ {remaining} more change{remaining === 1 ? '' : 's'} included.</p>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
+ {remaining} more change{remaining === 1 ? '' : 's'} included.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -231,6 +233,7 @@ function IdleView({
|
||||
|
||||
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
|
||||
const label = STAGE_LABELS[apply.stage] ?? 'Updating Hermes…'
|
||||
|
||||
const percent =
|
||||
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
|
||||
? Math.max(2, Math.min(100, Math.round(apply.percent)))
|
||||
|
||||
@@ -30,7 +30,10 @@ function binaryNoiseDataUrl(tile: number, density: number, size: number, color:
|
||||
return ''
|
||||
}
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
// Cap at 1.5x to match the design-language overlay perf work (PR #14):
|
||||
// with `image-rendering: pixelated` there's no visible win above 1.5x, and
|
||||
// a full retina (2x) PNG is ~78% larger to keep resident in compositor memory.
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5)
|
||||
const physTile = Math.round(tile * dpr)
|
||||
const block = Math.max(1, Math.round(size * dpr))
|
||||
|
||||
@@ -165,7 +168,7 @@ export function Backdrop() {
|
||||
|
||||
{import.meta.env.DEV && <ThemeControls />}
|
||||
|
||||
{statue.enabled && (
|
||||
{statue.enabled && gpuTier > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-2"
|
||||
|
||||
@@ -395,11 +395,7 @@ describe('assistant-ui streaming renderer', () => {
|
||||
|
||||
it('renders archived todos after turn completion regardless of pending state', () => {
|
||||
const first = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([
|
||||
{ content: 'Boil water', id: 'boil', status: 'pending' }
|
||||
], false)}
|
||||
/>
|
||||
<TodoHarness message={assistantTodoMessage([{ content: 'Boil water', id: 'boil', status: 'pending' }], false)} />
|
||||
)
|
||||
|
||||
const ui = within(first.container)
|
||||
@@ -410,9 +406,7 @@ describe('assistant-ui streaming renderer', () => {
|
||||
|
||||
const second = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([
|
||||
{ content: 'Serve latte', id: 'serve', status: 'completed' }
|
||||
], false)}
|
||||
message={assistantTodoMessage([{ content: 'Serve latte', id: 'serve', status: 'completed' }], false)}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
|
||||
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
||||
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
XIcon
|
||||
} from '@/lib/icons'
|
||||
import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
@@ -365,7 +366,8 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
return pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
|
||||
}, [messageText])
|
||||
|
||||
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
|
||||
const messageStatus = useAuiState(s => s.message.status?.type)
|
||||
const isPlaceholder = messageStatus === 'running' && content.length === 0
|
||||
|
||||
if (isPlaceholder) {
|
||||
return null
|
||||
@@ -382,14 +384,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
data-slot="aui_assistant-message-content"
|
||||
>
|
||||
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: MarkdownText,
|
||||
Reasoning: ReasoningTextPart,
|
||||
ReasoningGroup: ReasoningAccordionGroup,
|
||||
tools: { Fallback: ChainToolFallback }
|
||||
}}
|
||||
/>
|
||||
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
|
||||
{previewTargets.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{previewTargets.map(target => (
|
||||
@@ -462,26 +457,70 @@ const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
|
||||
|
||||
const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
|
||||
// todo parts are hoisted to a dedicated panel above the message content.
|
||||
if (props.toolName === 'todo') {return null}
|
||||
if (props.toolName === 'todo') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (props.toolName === 'image_generate') {return <ImageGenerateTool {...props} />}
|
||||
if (props.toolName === 'image_generate') {
|
||||
return <ImageGenerateTool {...props} />
|
||||
}
|
||||
|
||||
if (props.toolName === 'clarify') {return <ClarifyTool {...props} />}
|
||||
if (props.toolName === 'clarify') {
|
||||
return <ClarifyTool {...props} />
|
||||
}
|
||||
|
||||
return <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
const ThinkingDisclosure: FC<{
|
||||
children: ReactNode
|
||||
messageRunning?: boolean
|
||||
pending?: boolean
|
||||
timerKey?: string
|
||||
}> = ({ children, pending = false, timerKey }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
}> = ({ children, messageRunning = false, pending = false, timerKey }) => {
|
||||
// `null` = no explicit user toggle yet, defer to the streaming default.
|
||||
// The default is "auto-open while streaming, auto-collapse when done" so
|
||||
// reasoning surfaces a live preview without manual interaction. The first
|
||||
// explicit toggle wins from then on.
|
||||
const [userOpen, setUserOpen] = useState<boolean | null>(null)
|
||||
const elapsed = useElapsedSeconds(pending, timerKey)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement | null>(null)
|
||||
const enterRef = useEnterAnimation(messageRunning, timerKey)
|
||||
|
||||
const open = userOpen ?? pending
|
||||
const isPreview = pending && userOpen === null
|
||||
|
||||
// While the preview is live, pin the scroll container to the bottom on
|
||||
// every content growth so the latest tokens are always visible. Combined
|
||||
// with the top mask in styles.css, this reads as text settling in from
|
||||
// below while older lines fade out at the top.
|
||||
useEffect(() => {
|
||||
if (!isPreview) {
|
||||
return
|
||||
}
|
||||
|
||||
const el = scrollRef.current
|
||||
const content = contentRef.current
|
||||
|
||||
if (!el || !content) {
|
||||
return
|
||||
}
|
||||
|
||||
const pin = () => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
pin()
|
||||
const observer = new ResizeObserver(pin)
|
||||
observer.observe(content)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [isPreview])
|
||||
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground" data-slot="aui_thinking-disclosure">
|
||||
<DisclosureRow onToggle={() => setOpen(v => !v)} open={open}>
|
||||
<div className="text-sm text-muted-foreground" data-slot="aui_thinking-disclosure" ref={enterRef}>
|
||||
<DisclosureRow onToggle={() => setUserOpen(!open)} open={open}>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
@@ -497,19 +536,48 @@ const ThinkingDisclosure: FC<{
|
||||
</span>
|
||||
</DisclosureRow>
|
||||
{open && (
|
||||
<div className="mt-2 w-full min-w-0 max-w-full overflow-hidden pl-(--message-text-indent) pr-2 wrap-anywhere pb-1">
|
||||
{children}
|
||||
<div
|
||||
className={cn(
|
||||
// Keep the reasoning body tucked close to the "Thinking" row so
|
||||
// it aligns with tool-group disclosure rhythm.
|
||||
'mt-0.5 w-full min-w-0 max-w-full overflow-hidden pr-2 pl-3 wrap-anywhere pb-1',
|
||||
isPreview && 'thinking-preview max-h-40'
|
||||
)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div ref={contentRef}>{children}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({ children }) => {
|
||||
const pending = useAuiState(s => s.thread.isRunning && s.message.status?.type === 'running')
|
||||
// Self-gate "Thinking…" on this message's own reasoning parts. Reading
|
||||
// `thread.isRunning` directly would flicker shimmer/timer on every old
|
||||
// assistant whenever the external-store runtime clears+reimports its
|
||||
// repository (one ref-identity bump per streaming delta).
|
||||
const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({
|
||||
children,
|
||||
endIndex,
|
||||
startIndex
|
||||
}) => {
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
|
||||
|
||||
return <ThinkingDisclosure pending={pending} timerKey={`reasoning:${messageId}`}>{children}</ThinkingDisclosure>
|
||||
const pending = useAuiState(
|
||||
s =>
|
||||
s.thread.isRunning &&
|
||||
s.message.status?.type === 'running' &&
|
||||
s.message.parts
|
||||
.slice(Math.max(0, startIndex), Math.min(s.message.parts.length, endIndex))
|
||||
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
|
||||
)
|
||||
|
||||
return (
|
||||
<ThinkingDisclosure messageRunning={messageRunning} pending={pending} timerKey={`reasoning:${messageId}`}>
|
||||
{children}
|
||||
</ThinkingDisclosure>
|
||||
)
|
||||
}
|
||||
|
||||
const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
|
||||
@@ -528,6 +596,21 @@ const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ te
|
||||
)
|
||||
}
|
||||
|
||||
// Module-level constant so the `components` prop on `MessagePrimitive.Parts`
|
||||
// has a stable identity across renders. Without this every AssistantMessage
|
||||
// render would create a fresh `components` object, invalidating the memo on
|
||||
// `MessagePrimitivePartByIndex` and forcing every tool/reasoning child to
|
||||
// re-render on every streaming delta. Memo invalidation alone doesn't
|
||||
// remount, but combined with the previous ToolFallback group-swap it was a
|
||||
// big chunk of the per-delta work.
|
||||
const MESSAGE_PARTS_COMPONENTS = {
|
||||
Reasoning: ReasoningTextPart,
|
||||
ReasoningGroup: ReasoningAccordionGroup,
|
||||
Text: MarkdownText,
|
||||
ToolGroup: ToolGroupSlot,
|
||||
tools: { Fallback: ChainToolFallback }
|
||||
} as const
|
||||
|
||||
const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||
|
||||
const SHORT_FMT = new Intl.DateTimeFormat(undefined, {
|
||||
|
||||
@@ -6,28 +6,36 @@ import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function todosFromMessageContent(content: unknown): TodoItem[] {
|
||||
if (!Array.isArray(content)) {return []}
|
||||
if (!Array.isArray(content)) {
|
||||
return []
|
||||
}
|
||||
|
||||
let latest: null | TodoItem[] = null
|
||||
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== 'object') {continue}
|
||||
if (!part || typeof part !== 'object') {
|
||||
continue
|
||||
}
|
||||
const row = part as Record<string, unknown>
|
||||
|
||||
if (row.type !== 'tool-call' || row.toolName !== 'todo') {continue}
|
||||
if (row.type !== 'tool-call' || row.toolName !== 'todo') {
|
||||
continue
|
||||
}
|
||||
const parsed = parseTodos(row.result) ?? parseTodos(row.args)
|
||||
|
||||
if (parsed !== null) {latest = parsed}
|
||||
if (parsed !== null) {
|
||||
latest = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return latest ?? []
|
||||
}
|
||||
|
||||
const headerLabel = (todos: readonly TodoItem[]): string =>
|
||||
todos.find(t => t.status === 'in_progress')?.content
|
||||
?? todos.find(t => t.status === 'pending')?.content
|
||||
?? todos.at(-1)?.content
|
||||
?? 'Tasks'
|
||||
todos.find(t => t.status === 'in_progress')?.content ??
|
||||
todos.find(t => t.status === 'pending')?.content ??
|
||||
todos.at(-1)?.content ??
|
||||
'Tasks'
|
||||
|
||||
const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => {
|
||||
if (status === 'in_progress') {
|
||||
@@ -49,7 +57,8 @@ const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label })
|
||||
checked={checked}
|
||||
className={cn(
|
||||
'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100',
|
||||
checked && 'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
|
||||
checked &&
|
||||
'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
|
||||
status === 'cancelled' && 'border-muted-foreground/40'
|
||||
)}
|
||||
disabled
|
||||
@@ -58,7 +67,9 @@ const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label })
|
||||
}
|
||||
|
||||
export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
|
||||
if (!todos.length) {return null}
|
||||
if (!todos.length) {
|
||||
return null
|
||||
}
|
||||
const label = headerLabel(todos)
|
||||
|
||||
return (
|
||||
@@ -67,7 +78,10 @@ export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
|
||||
data-slot="aui_todo-hoisted"
|
||||
>
|
||||
<header className="px-3 pt-3 pb-2">
|
||||
<span className="block max-w-full truncate text-[0.85rem] font-semibold leading-tight tracking-tight text-foreground" title={label}>
|
||||
<span
|
||||
className="block max-w-full truncate text-[0.85rem] font-semibold leading-tight tracking-tight text-foreground"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</header>
|
||||
@@ -83,9 +97,7 @@ export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
|
||||
key={todo.id}
|
||||
>
|
||||
<Checkmark label={todo.content} status={todo.status} />
|
||||
<span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">
|
||||
{todo.content}
|
||||
</span>
|
||||
<span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">{todo.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,14 @@ import { useEffect, useRef, useState } from 'react'
|
||||
const startedAtByKey = new Map<string, number>()
|
||||
|
||||
function startedAt(key?: string): number {
|
||||
if (!key) {return Date.now()}
|
||||
if (!key) {
|
||||
return Date.now()
|
||||
}
|
||||
const existing = startedAtByKey.get(key)
|
||||
|
||||
if (existing !== undefined) {return existing}
|
||||
if (existing !== undefined) {
|
||||
return existing
|
||||
}
|
||||
const now = Date.now()
|
||||
startedAtByKey.set(key, now)
|
||||
|
||||
@@ -17,7 +21,9 @@ function startedAt(key?: string): number {
|
||||
}
|
||||
|
||||
export function formatElapsed(seconds: number): string {
|
||||
if (seconds < 60) {return `${seconds}s`}
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
@@ -33,9 +39,13 @@ export function useElapsedSeconds(active = true, timerKey?: string): number {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {return}
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (timerKey) {start.current = startedAt(timerKey)}
|
||||
if (timerKey) {
|
||||
start.current = startedAt(timerKey)
|
||||
}
|
||||
|
||||
const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
|
||||
tick()
|
||||
|
||||
@@ -4,14 +4,16 @@ import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared header row for any collapsible block (thinking, tool group, single
|
||||
// tool). Owns the grid indent (chevron column = --message-text-indent), the
|
||||
// hover surface, and the trailing-slot anchor used for copy buttons / running
|
||||
// timers. Each parent supplies its own outer wrapper (with the data-slot CSS
|
||||
// tool). Each parent supplies its own outer wrapper (with the data-slot CSS
|
||||
// uses to escape the message padding) and its own expanded body.
|
||||
//
|
||||
// Passing `onToggle` makes the row expandable (chevron + hover + click).
|
||||
// Omitting it renders a static row that still reserves the chevron column so
|
||||
// nested rows stay vertically aligned with their group header.
|
||||
// Cursor-style affordance:
|
||||
// - No leading chevron; a caret appears to the RIGHT of the text on hover
|
||||
// (and stays visible when the row is open).
|
||||
// - The hover background is a tight content-shaped pill — sized to the
|
||||
// title text, NOT the full row — and reaches just past the chevron with
|
||||
// `-mx-1.5 px-1.5` so it reads as a soft hit-target rather than a slab
|
||||
// stretching to the message edge.
|
||||
export function DisclosureRow({
|
||||
children,
|
||||
onToggle,
|
||||
@@ -24,36 +26,44 @@ export function DisclosureRow({
|
||||
trailing?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/disclosure-row relative flex w-full max-w-full min-w-0 items-start rounded-md text-muted-foreground transition-colors',
|
||||
onToggle && 'hover:bg-[color-mix(in_srgb,var(--dt-midground)_8%,transparent)] hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="group/disclosure-row relative flex w-full max-w-full min-w-0 text-muted-foreground">
|
||||
<button
|
||||
aria-expanded={onToggle ? open : undefined}
|
||||
className={cn(
|
||||
'grid w-full min-w-0 grid-cols-[var(--message-text-indent)_minmax(0,1fr)] items-start py-0.5 pr-2 text-left',
|
||||
onToggle ? 'cursor-pointer' : 'cursor-default'
|
||||
// max-w-fit so the click target hugs the title text width — no
|
||||
// background fill, just the cursor + the affordance caret.
|
||||
'flex min-w-0 max-w-fit items-start gap-2 text-left transition-colors',
|
||||
onToggle
|
||||
? 'cursor-pointer hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
|
||||
: 'cursor-default'
|
||||
)}
|
||||
disabled={!onToggle}
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-[1.1rem] items-center justify-center">
|
||||
{onToggle ? (
|
||||
<span className="flex min-w-0 flex-col">{children}</span>
|
||||
{onToggle && (
|
||||
// Wrapper height matches the title row's line-height so the caret
|
||||
// is vertically centred with the title (not with the full stack
|
||||
// when a subtitle wraps below).
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-[1.1rem] shrink-0 items-center justify-center transition-opacity duration-150',
|
||||
open
|
||||
? 'opacity-80'
|
||||
: 'opacity-0 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80'
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-3 text-midground/55 transition-transform group-hover/disclosure-row:text-midground',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
className={cn('size-3.5 transition-transform duration-150', open && 'rotate-90')}
|
||||
// currentColor + a chunkier stroke so the caret reads as a
|
||||
// confident hover affordance instead of a hairline.
|
||||
color="currentColor"
|
||||
strokeWidth={2.75}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0">{children}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{trailing && <span className="absolute right-1 top-0.5 flex h-[1.1rem] items-center">{trailing}</span>}
|
||||
</div>
|
||||
|
||||
@@ -134,6 +134,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
}
|
||||
|
||||
const { flow } = onboarding
|
||||
const reason = onboarding.reason?.trim() || null
|
||||
const ready = enabled && onboarding.configured === false
|
||||
const showPicker = flow.status === 'idle' || flow.status === 'success'
|
||||
|
||||
@@ -142,21 +143,22 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl">
|
||||
<Header />
|
||||
<div className="grid gap-5 p-6">
|
||||
{ready ? (
|
||||
showPicker ? (
|
||||
<Picker ctx={ctx} />
|
||||
) : (
|
||||
<FlowPanel ctx={ctx} flow={flow} />
|
||||
)
|
||||
) : (
|
||||
<Preparing boot={boot} />
|
||||
)}
|
||||
{reason ? <ReasonNotice reason={reason} /> : null}
|
||||
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReasonNotice({ reason }: { reason: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{reason}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Preparing({ boot }: { boot: DesktopBootState }) {
|
||||
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
|
||||
const hasError = Boolean(boot.error)
|
||||
@@ -445,7 +447,9 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
||||
in":
|
||||
</p>
|
||||
<CodeBlock copied={flow.copied} onCopy={() => void copyExternalCommand()} text={flow.provider.cli_command} />
|
||||
<FlowFooter left={flow.provider.docs_url ? <DocsLink href={flow.provider.docs_url}>{title} docs</DocsLink> : null}>
|
||||
<FlowFooter
|
||||
left={flow.provider.docs_url ? <DocsLink href={flow.provider.docs_url}>{title} docs</DocsLink> : null}
|
||||
>
|
||||
<CancelBtn />
|
||||
<Button onClick={() => void recheckExternalSignin(ctx)}>
|
||||
<Check className="size-4" />
|
||||
|
||||
@@ -17,6 +17,10 @@ interface StatusDotProps extends ComponentProps<'span'> {
|
||||
|
||||
export function StatusDot({ className, tone, ...props }: StatusDotProps) {
|
||||
return (
|
||||
<span aria-hidden="true" className={cn('inline-block size-1.5 rounded-full', TONE_BG[tone], className)} {...props} />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('inline-block size-1.5 rounded-full', TONE_BG[tone], className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NormalisedSpinner {
|
||||
frames: readonly string[]
|
||||
interval: number
|
||||
}
|
||||
|
||||
// Some spinners ship multi-character frames. Pull the first cell so each
|
||||
// frame fits in one monospace box — matches how the TUI uses them.
|
||||
const FRAMES_BY_NAME: Record<BrailleSpinnerName, NormalisedSpinner> = (() => {
|
||||
const out = {} as Record<BrailleSpinnerName, NormalisedSpinner>
|
||||
|
||||
for (const name of Object.keys(spinners) as BrailleSpinnerName[]) {
|
||||
const raw = spinners[name]
|
||||
|
||||
out[name] = {
|
||||
frames: raw.frames.map(frame => [...frame][0] ?? '⠀'),
|
||||
interval: raw.interval
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
})()
|
||||
|
||||
interface BrailleSpinnerProps {
|
||||
ariaLabel?: string
|
||||
className?: string
|
||||
spinner?: BrailleSpinnerName
|
||||
}
|
||||
|
||||
/**
|
||||
* One-char braille spinner driven by `unicode-animations`. Mirrors the
|
||||
* spinner used by the Ink TUI so the desktop and terminal experiences
|
||||
* read the same visually. Renders inside an `inline-flex` cell with
|
||||
* `leading-none` and `items-center` so it sits vertically centred inside
|
||||
* its parent's line-box (e.g. the 1.1rem disclosure row).
|
||||
*/
|
||||
export function BrailleSpinner({ ariaLabel = 'Loading', className, spinner = 'breathe' }: BrailleSpinnerProps) {
|
||||
const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.breathe!
|
||||
const [frame, setFrame] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
setFrame(0)
|
||||
const id = window.setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [spin])
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={ariaLabel}
|
||||
className={cn('inline-flex items-center justify-center font-mono leading-none tabular-nums', className)}
|
||||
role="status"
|
||||
>
|
||||
{spin.frames[frame]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Vendored
+1
@@ -149,6 +149,7 @@ export interface HermesApiRequest {
|
||||
path: string
|
||||
method?: string
|
||||
body?: unknown
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface HermesNotification {
|
||||
|
||||
@@ -31,6 +31,8 @@ import type {
|
||||
ToolsetInfo
|
||||
} from '@/types/hermes'
|
||||
|
||||
const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000
|
||||
|
||||
export type {
|
||||
ActionResponse,
|
||||
ActionStatusResponse,
|
||||
@@ -79,7 +81,7 @@ export class HermesGateway extends JsonRpcGatewayClient {
|
||||
connectErrorMessage: 'Could not connect to Hermes gateway',
|
||||
createRequestId: nextId => nextId,
|
||||
notConnectedErrorMessage: 'Hermes gateway is not connected',
|
||||
requestTimeoutMs: 0
|
||||
requestTimeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -242,11 +244,7 @@ export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse>
|
||||
})
|
||||
}
|
||||
|
||||
export function submitOAuthCode(
|
||||
providerId: string,
|
||||
sessionId: string,
|
||||
code: string
|
||||
): Promise<OAuthSubmitResponse> {
|
||||
export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise<OAuthSubmitResponse> {
|
||||
return window.hermesDesktop.api<OAuthSubmitResponse>({
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatMessagePart } from './chat-messages'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
chatMessageText,
|
||||
@@ -218,10 +219,321 @@ describe('upsertToolPart', () => {
|
||||
'complete'
|
||||
)
|
||||
|
||||
const completedResult = completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {}
|
||||
const completedResult =
|
||||
completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {}
|
||||
const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record<string, unknown>) : {}
|
||||
|
||||
expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }])
|
||||
expect(clearedResult.todos).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps parallel same-name tools distinct without explicit ids', () => {
|
||||
const startedTokyo = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
context: 'tokyo weather',
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const startedReykjavik = upsertToolPart(
|
||||
startedTokyo,
|
||||
{
|
||||
context: 'reykjavik weather',
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const completedTokyo = upsertToolPart(
|
||||
startedReykjavik,
|
||||
{
|
||||
context: 'tokyo weather',
|
||||
message: 'tokyo done',
|
||||
name: 'web_search',
|
||||
summary: 'Did 5 searches'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const completedBoth = upsertToolPart(
|
||||
completedTokyo,
|
||||
{
|
||||
context: 'reykjavik weather',
|
||||
message: 'reykjavik done',
|
||||
name: 'web_search',
|
||||
summary: 'Did 5 searches'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const webParts = completedBoth.filter(
|
||||
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName === 'web_search'
|
||||
)
|
||||
|
||||
const contexts = webParts.map(part => String((part.args as Record<string, unknown>)?.context || ''))
|
||||
|
||||
const summaries = webParts.map(part => {
|
||||
if (!('result' in part) || !part.result || typeof part.result !== 'object') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return String((part.result as Record<string, unknown>).summary || '')
|
||||
})
|
||||
|
||||
expect(webParts).toHaveLength(2)
|
||||
expect(contexts).toEqual(['tokyo weather', 'reykjavik weather'])
|
||||
expect(summaries).toEqual(['Did 5 searches', 'Did 5 searches'])
|
||||
})
|
||||
|
||||
it('preserves query args when completion payload omits context', () => {
|
||||
const started = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
context: 'auckland weather today and tomorrow forecast',
|
||||
name: 'web_search',
|
||||
tool_id: 'search-1'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const completed = upsertToolPart(
|
||||
started,
|
||||
{
|
||||
duration_s: 1.1,
|
||||
name: 'web_search',
|
||||
summary: 'Did 5 searches in 1.1s',
|
||||
tool_id: 'search-1'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const [part] = completed
|
||||
|
||||
expect(part?.type).toBe('tool-call')
|
||||
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).args).toMatchObject({
|
||||
context: 'auckland weather today and tomorrow forecast'
|
||||
})
|
||||
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).result).toMatchObject({
|
||||
summary: 'Did 5 searches in 1.1s'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not append phantom same-name tool rows for id-less progress updates', () => {
|
||||
const startedA = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
context: 'reykjavik weather today and tomorrow forecast',
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const startedB = upsertToolPart(
|
||||
startedA,
|
||||
{
|
||||
context: 'kathmandu weather today and tomorrow forecast',
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const progressed = upsertToolPart(
|
||||
startedB,
|
||||
{
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const webParts = progressed.filter(
|
||||
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName === 'web_search'
|
||||
)
|
||||
|
||||
expect(webParts).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('matches id-less live starts with later identified completions', () => {
|
||||
const started = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
context: 'asuncion paraguay weather today and tomorrow forecast',
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const completed = upsertToolPart(
|
||||
started,
|
||||
{
|
||||
context: 'asuncion paraguay weather today and tomorrow forecast',
|
||||
duration_s: 1.1,
|
||||
name: 'web_search',
|
||||
summary: 'Did 5 searches in 1.1s',
|
||||
tool_id: 'search-asuncion'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const webParts = completed.filter(
|
||||
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName === 'web_search'
|
||||
)
|
||||
|
||||
expect(webParts).toHaveLength(1)
|
||||
expect(webParts[0].toolCallId).toBe('search-asuncion')
|
||||
expect(webParts[0].result).toMatchObject({ summary: 'Did 5 searches in 1.1s' })
|
||||
})
|
||||
|
||||
it('matches id-less live starts with later identified progress updates', () => {
|
||||
const started = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
context: 'reykjavik tashkent uzbekistan weather today and tomorrow forecast',
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const progressed = upsertToolPart(
|
||||
started,
|
||||
{
|
||||
context: 'reykjavik tashkent uzbekistan weather today and tomorrow forecast',
|
||||
name: 'web_search',
|
||||
tool_id: 'search-reykjavik'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const webParts = progressed.filter(
|
||||
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName === 'web_search'
|
||||
)
|
||||
|
||||
expect(webParts).toHaveLength(1)
|
||||
expect(webParts[0].toolCallId).toBe('search-reykjavik')
|
||||
})
|
||||
|
||||
it('reconciles preview-first progress rows with later stable-id starts', () => {
|
||||
const progressA = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
name: 'web_search',
|
||||
preview: 'tokyo weather'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const progressB = upsertToolPart(
|
||||
progressA,
|
||||
{
|
||||
name: 'web_search',
|
||||
preview: 'reykjavik weather'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const startedA = upsertToolPart(
|
||||
progressB,
|
||||
{
|
||||
args: { query: 'tokyo weather' },
|
||||
name: 'web_search',
|
||||
tool_id: 'search-tokyo'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const startedB = upsertToolPart(
|
||||
startedA,
|
||||
{
|
||||
args: { query: 'reykjavik weather' },
|
||||
name: 'web_search',
|
||||
tool_id: 'search-reykjavik'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const completedA = upsertToolPart(
|
||||
startedB,
|
||||
{
|
||||
name: 'web_search',
|
||||
summary: 'Did 5 searches',
|
||||
tool_id: 'search-tokyo'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const completedB = upsertToolPart(
|
||||
completedA,
|
||||
{
|
||||
name: 'web_search',
|
||||
summary: 'Did 5 searches',
|
||||
tool_id: 'search-reykjavik'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const webParts = completedB
|
||||
.filter(
|
||||
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName === 'web_search'
|
||||
)
|
||||
.map(part => ({
|
||||
id: part.toolCallId,
|
||||
query: String((part.args as Record<string, unknown>)?.query || ''),
|
||||
summary:
|
||||
part.result && typeof part.result === 'object'
|
||||
? String((part.result as Record<string, unknown>).summary || '')
|
||||
: ''
|
||||
}))
|
||||
|
||||
expect(webParts).toEqual([
|
||||
{ id: 'search-tokyo', query: 'tokyo weather', summary: 'Did 5 searches' },
|
||||
{ id: 'search-reykjavik', query: 'reykjavik weather', summary: 'Did 5 searches' }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses structured live tool args for titles before hydrate', () => {
|
||||
const started = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
args: { search_term: 'reykjavik bishkek kyrgyzstan weather today and tomorrow forecast' },
|
||||
name: 'web_search',
|
||||
tool_id: 'search-bishkek'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const [part] = started
|
||||
|
||||
expect(part?.type).toBe('tool-call')
|
||||
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).args).toMatchObject({
|
||||
search_term: 'reykjavik bishkek kyrgyzstan weather today and tomorrow forecast'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps structured live tool results before hydrate', () => {
|
||||
const completed = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
args: { query: 'suva weather' },
|
||||
name: 'web_search',
|
||||
result: { data: { web: [{ title: 'Suva forecast', url: 'https://example.test', description: 'Sunny' }] } },
|
||||
summary: 'Did 1 search in 0.5s',
|
||||
tool_id: 'search-suva'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const [part] = completed
|
||||
|
||||
expect(part?.type).toBe('tool-call')
|
||||
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).result).toMatchObject({
|
||||
data: { web: [{ title: 'Suva forecast' }] },
|
||||
summary: 'Did 1 search in 0.5s'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,10 +23,16 @@ export type GatewayEventPayload = {
|
||||
rendered?: string
|
||||
status?: string
|
||||
message?: string
|
||||
id?: string
|
||||
name?: string
|
||||
tool_id?: string
|
||||
tool_call_id?: string
|
||||
args?: unknown
|
||||
arguments?: unknown
|
||||
context?: string
|
||||
input?: unknown
|
||||
preview?: string
|
||||
result?: unknown
|
||||
summary?: string
|
||||
error?: string | boolean
|
||||
inline_diff?: string
|
||||
@@ -209,7 +215,135 @@ export function hasToolPart(message: ChatMessage): boolean {
|
||||
}
|
||||
|
||||
function toolId(payload: GatewayEventPayload | undefined): string {
|
||||
return payload?.tool_id || payload?.name || `tool-${Date.now()}`
|
||||
return payload?.tool_id || payload?.tool_call_id || payload?.id || ''
|
||||
}
|
||||
|
||||
let liveToolCounter = 0
|
||||
|
||||
function nextLiveToolId(name: string): string {
|
||||
liveToolCounter += 1
|
||||
|
||||
return `live-tool:${name}:${liveToolCounter}`
|
||||
}
|
||||
|
||||
function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeToolMatchValue(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function collectToolMatchValues(query: string, context: string, preview: string): string[] {
|
||||
return [...new Set([query, context, preview].map(normalizeToolMatchValue).filter(Boolean))]
|
||||
}
|
||||
|
||||
function toolPayloadMatchValues(payload: GatewayEventPayload | undefined): string[] {
|
||||
const payloadArgs = liveToolArgs(payload)
|
||||
const query = firstStringField(payloadArgs, ['search_term', 'query'])
|
||||
const context = typeof payload?.context === 'string' ? payload.context.trim() : ''
|
||||
const preview = typeof payload?.preview === 'string' ? payload.preview.trim() : ''
|
||||
|
||||
return collectToolMatchValues(query, context, preview)
|
||||
}
|
||||
|
||||
function toolPartMatchValues(part: ChatMessagePart): string[] {
|
||||
if (part.type !== 'tool-call' || !part.args || typeof part.args !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
const args = part.args as Record<string, unknown>
|
||||
const query = firstStringField(args, ['search_term', 'query'])
|
||||
const context = typeof args.context === 'string' ? args.context.trim() : ''
|
||||
const preview = typeof args.preview === 'string' ? args.preview.trim() : ''
|
||||
|
||||
return collectToolMatchValues(query, context, preview)
|
||||
}
|
||||
|
||||
function hasToolMatchOverlap(left: string[], right: string[]): boolean {
|
||||
if (!left.length || !right.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rightSet = new Set(right)
|
||||
|
||||
return left.some(value => rightSet.has(value))
|
||||
}
|
||||
|
||||
function findToolPartIndex(
|
||||
parts: ChatMessagePart[],
|
||||
name: string,
|
||||
stableId: string,
|
||||
payload: GatewayEventPayload | undefined,
|
||||
phase: 'running' | 'complete'
|
||||
): number {
|
||||
const matchValues = toolPayloadMatchValues(payload)
|
||||
const overlaps = (index: number) => hasToolMatchOverlap(matchValues, toolPartMatchValues(parts[index]))
|
||||
|
||||
if (stableId) {
|
||||
const stableIndex = parts.findIndex(part => part.type === 'tool-call' && part.toolCallId === stableId)
|
||||
|
||||
if (stableIndex >= 0) {
|
||||
return stableIndex
|
||||
}
|
||||
|
||||
// Some live streams start without an id, then complete with one. Fall
|
||||
// through to pending same-name/context matching so the completion updates
|
||||
// the synthetic live row instead of appending a duplicate completed row.
|
||||
if (phase === 'running' && !matchValues.length) {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
const pendingIndices = parts
|
||||
.map((part, index) => ({ part, index }))
|
||||
.filter(({ part }) => part.type === 'tool-call' && part.toolName === name && part.result === undefined)
|
||||
.map(({ index }) => index)
|
||||
|
||||
if (pendingIndices.length === 0) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (matchValues.length) {
|
||||
const contextualIndex = pendingIndices.find(overlaps)
|
||||
|
||||
if (contextualIndex !== undefined) {
|
||||
return contextualIndex
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingIndices.length === 1) {
|
||||
const [singlePendingIndex] = pendingIndices
|
||||
|
||||
if (phase === 'running' && matchValues.length && !overlaps(singlePendingIndex)) {
|
||||
return stableId ? singlePendingIndex : -1
|
||||
}
|
||||
|
||||
return singlePendingIndex
|
||||
}
|
||||
|
||||
// Completion events without stable IDs frequently arrive after multiple
|
||||
// same-name starts (parallel tool calls). Resolve them oldest-first so we
|
||||
// don't collapse an entire burst into a single row.
|
||||
if (phase === 'complete') {
|
||||
return pendingIndices[0]
|
||||
}
|
||||
|
||||
if (stableId) {
|
||||
return pendingIndices[0]
|
||||
}
|
||||
|
||||
// For progress/running events with no stable id, update the most-recent
|
||||
// pending same-name tool instead of creating a phantom extra row.
|
||||
return pendingIndices.at(-1) ?? -1
|
||||
}
|
||||
|
||||
// Carry todo state across sparse progress payloads: if this todo event lacks
|
||||
@@ -221,27 +355,43 @@ function carryTodos(payload: GatewayEventPayload | undefined, ...prev: unknown[]
|
||||
return next === null ? undefined : { todos: next }
|
||||
}
|
||||
|
||||
if (payload?.name !== 'todo') {return undefined}
|
||||
if (payload?.name !== 'todo') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const p of prev) {
|
||||
const carried = parseTodos(recordFromUnknown(p)?.todos)
|
||||
|
||||
if (carried !== null) {return { todos: carried }}
|
||||
if (carried !== null) {
|
||||
return { todos: carried }
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown): Record<string, unknown> {
|
||||
const prev = parseMaybeJsonObject(prevArgs)
|
||||
const eventArgs = liveToolArgs(payload)
|
||||
|
||||
return {
|
||||
...prev,
|
||||
...eventArgs,
|
||||
...(payload?.context ? { context: payload.context } : {}),
|
||||
...(payload?.preview ? { preview: payload.preview } : {}),
|
||||
...carryTodos(payload, prevArgs)
|
||||
}
|
||||
}
|
||||
|
||||
function toolResult(payload: GatewayEventPayload | undefined, prevResult?: unknown, prevArgs?: unknown): Record<string, unknown> {
|
||||
function toolResult(
|
||||
payload: GatewayEventPayload | undefined,
|
||||
prevResult?: unknown,
|
||||
prevArgs?: unknown
|
||||
): Record<string, unknown> {
|
||||
const parsedResult = parseMaybeJsonObject(payload?.result)
|
||||
|
||||
return {
|
||||
...parsedResult,
|
||||
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
|
||||
...(payload?.summary ? { summary: payload.summary } : {}),
|
||||
...(payload?.message ? { message: payload.message } : {}),
|
||||
@@ -257,19 +407,22 @@ export function upsertToolPart(
|
||||
payload: GatewayEventPayload | undefined,
|
||||
phase: 'running' | 'complete'
|
||||
): ChatMessagePart[] {
|
||||
const id = toolId(payload)
|
||||
const stableId = toolId(payload)
|
||||
const name = payload?.name || 'tool'
|
||||
const next = [...parts]
|
||||
|
||||
const index = next.findIndex(
|
||||
part => part.type === 'tool-call' && ((part.toolCallId && part.toolCallId === id) || part.toolName === name)
|
||||
)
|
||||
const index = findToolPartIndex(next, name, stableId, payload, phase)
|
||||
|
||||
const prev = index >= 0 ? next[index] : null
|
||||
const prevArgs = prev && 'args' in prev ? prev.args : undefined
|
||||
const prevResult = prev && 'result' in prev ? prev.result : undefined
|
||||
const args = toolArgs(payload, prevArgs)
|
||||
|
||||
const id =
|
||||
stableId ||
|
||||
(prev && 'toolCallId' in prev && typeof prev.toolCallId === 'string' ? prev.toolCallId : '') ||
|
||||
nextLiveToolId(name)
|
||||
|
||||
const base = {
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: id,
|
||||
@@ -279,7 +432,9 @@ export function upsertToolPart(
|
||||
...(phase === 'complete' && { result: toolResult(payload, prevResult, prevArgs), isError: Boolean(payload?.error) })
|
||||
} satisfies ChatMessagePart
|
||||
|
||||
if (index === -1) {return [...next, base]}
|
||||
if (index === -1) {
|
||||
return [...next, base]
|
||||
}
|
||||
next[index] = { ...next[index], ...base }
|
||||
|
||||
return next
|
||||
@@ -319,6 +474,28 @@ function firstNonEmptyObject(...values: unknown[]): Record<string, unknown> {
|
||||
return {}
|
||||
}
|
||||
|
||||
function liveToolArgs(payload: GatewayEventPayload | undefined): Record<string, unknown> {
|
||||
const direct = firstNonEmptyObject(payload?.args, payload?.arguments)
|
||||
const input = firstNonEmptyObject(payload?.input)
|
||||
const fn = recordFromUnknown(input.function)
|
||||
|
||||
const nested = firstNonEmptyObject(
|
||||
input.args,
|
||||
input.arguments,
|
||||
input.parameters,
|
||||
input.input,
|
||||
fn?.arguments,
|
||||
fn?.args,
|
||||
fn?.parameters
|
||||
)
|
||||
|
||||
return {
|
||||
...input,
|
||||
...nested,
|
||||
...direct
|
||||
}
|
||||
}
|
||||
|
||||
function parseStoredToolResult(content: unknown): unknown {
|
||||
if (content && typeof content === 'object') {
|
||||
return content
|
||||
|
||||
@@ -101,7 +101,10 @@ export function parseCommitHeader(raw: string): ParsedCommit {
|
||||
}
|
||||
|
||||
function tidySubject(subject: string): string {
|
||||
const cleaned = subject.replace(/\s+/g, ' ').replace(/[.;,\s]+$/, '').trim()
|
||||
const cleaned = subject
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[.;,\s]+$/, '')
|
||||
.trim()
|
||||
|
||||
if (!cleaned) {
|
||||
return cleaned
|
||||
|
||||
@@ -63,7 +63,8 @@ describe('external link helpers', () => {
|
||||
const bridge = vi.fn().mockResolvedValue('El Yunque Tour Water Slide, Rope Swing & Pickup')
|
||||
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
|
||||
|
||||
const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details'
|
||||
const url =
|
||||
'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details'
|
||||
|
||||
const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)])
|
||||
|
||||
@@ -95,11 +96,7 @@ describe('external link helpers', () => {
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
|
||||
|
||||
render(
|
||||
<ExternalLink href="https://example.com/path/to/resource">
|
||||
Example link
|
||||
</ExternalLink>
|
||||
)
|
||||
render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Example link' }))
|
||||
expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource')
|
||||
@@ -108,11 +105,7 @@ describe('external link helpers', () => {
|
||||
it('shows a trailing external-link icon', () => {
|
||||
installDesktopBridge()
|
||||
|
||||
render(
|
||||
<ExternalLink href="https://example.com/path/to/resource">
|
||||
Example link
|
||||
</ExternalLink>
|
||||
)
|
||||
render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Example link' })
|
||||
expect(link.querySelector('svg')).toBeTruthy()
|
||||
@@ -125,9 +118,7 @@ describe('external link helpers', () => {
|
||||
const url =
|
||||
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
|
||||
|
||||
render(
|
||||
<LinkifiedText text={`Read ${url}`} />
|
||||
)
|
||||
render(<LinkifiedText text={`Read ${url}`} />)
|
||||
|
||||
const link = screen.getByTitle(url)
|
||||
expect(link.textContent).toContain('From Fajardo Full Day Cordillera Islands Catamaran Tour')
|
||||
@@ -152,7 +143,8 @@ describe('external link helpers', () => {
|
||||
it('ignores error-like fetched titles and falls back to slug label', async () => {
|
||||
const bridge = vi.fn().mockResolvedValue('GetYourGuide – Error')
|
||||
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
|
||||
const url = 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
|
||||
const url =
|
||||
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
|
||||
|
||||
render(<PrettyLink href={url} />)
|
||||
|
||||
@@ -168,6 +160,8 @@ describe('external link helpers', () => {
|
||||
render(<LinkifiedText text="Source expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure" />)
|
||||
|
||||
const link = screen.getByRole('link')
|
||||
expect(link.getAttribute('href')).toBe('https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure')
|
||||
expect(link.getAttribute('href')).toBe(
|
||||
'https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -204,7 +204,14 @@ export function ExternalLinkIcon({ className }: { className?: string }) {
|
||||
return <ArrowUpRight aria-hidden className={cn('ml-1 inline size-[0.78em] align-[-0.08em] opacity-70', className)} />
|
||||
}
|
||||
|
||||
export function ExternalLink({ children, className, href, onClick, showExternalIcon = true, ...rest }: ExternalLinkProps) {
|
||||
export function ExternalLink({
|
||||
children,
|
||||
className,
|
||||
href,
|
||||
onClick,
|
||||
showExternalIcon = true,
|
||||
...rest
|
||||
}: ExternalLinkProps) {
|
||||
const target = normalizeExternalUrl(href)
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
AssistantRuntimeImpl,
|
||||
BaseAssistantRuntimeCore,
|
||||
ExternalStoreThreadListRuntimeCore,
|
||||
ExternalStoreThreadRuntimeCore,
|
||||
hasUpcomingMessage
|
||||
} from '@assistant-ui/core/internal'
|
||||
import {
|
||||
type AssistantRuntime,
|
||||
type ExternalStoreAdapter,
|
||||
type ThreadMessage,
|
||||
useRuntimeAdapters
|
||||
} from '@assistant-ui/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
const EMPTY_ARRAY = Object.freeze([])
|
||||
|
||||
const shallowEqual = (a: object, b: object): boolean => {
|
||||
const aKeys = Object.keys(a)
|
||||
|
||||
if (aKeys.length !== Object.keys(b).length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const key of aKeys) {
|
||||
if (a[key as keyof typeof a] !== b[key as keyof typeof b]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const getThreadListAdapter = (store: ExternalStoreAdapter) => store.adapters?.threadList ?? {}
|
||||
|
||||
function syncRepositoryIncrementally(
|
||||
runtime: ExternalStoreThreadRuntimeCore,
|
||||
messageRepository: NonNullable<ExternalStoreAdapter['messageRepository']>
|
||||
): readonly ThreadMessage[] {
|
||||
const repository = (runtime as unknown as { repository: ExternalStoreThreadRuntimeCore['repository'] }).repository
|
||||
const incomingIds = new Set(messageRepository.messages.map(({ message }) => message.id))
|
||||
|
||||
for (const { message, parentId } of messageRepository.messages) {
|
||||
repository.addOrUpdateMessage(parentId, message)
|
||||
}
|
||||
|
||||
for (const { message } of repository.export().messages) {
|
||||
if (!incomingIds.has(message.id)) {
|
||||
repository.deleteMessage(message.id)
|
||||
}
|
||||
}
|
||||
|
||||
const headId = messageRepository.headId ?? messageRepository.messages.at(-1)?.message.id ?? null
|
||||
|
||||
repository.resetHead(headId)
|
||||
|
||||
return repository.getMessages()
|
||||
}
|
||||
|
||||
class IncrementalExternalStoreThreadRuntimeCore extends ExternalStoreThreadRuntimeCore {
|
||||
override __internal_setAdapter(store: ExternalStoreAdapter): void {
|
||||
if (!store.messageRepository) {
|
||||
super.__internal_setAdapter(store)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const self = this as unknown as {
|
||||
_assistantOptimisticId: null | string
|
||||
_capabilities: object
|
||||
_messages: readonly ThreadMessage[]
|
||||
_notifyEventSubscribers: (event: string, payload: object) => void
|
||||
_notifySubscribers: () => void
|
||||
_store?: ExternalStoreAdapter
|
||||
}
|
||||
|
||||
if (self._store === store) {
|
||||
return
|
||||
}
|
||||
|
||||
const isRunning = store.isRunning ?? false
|
||||
this.isDisabled = store.isDisabled ?? false
|
||||
|
||||
const oldStore = self._store
|
||||
self._store = store
|
||||
|
||||
if (this.extras !== store.extras) {
|
||||
this.extras = store.extras
|
||||
}
|
||||
|
||||
const newSuggestions = store.suggestions ?? EMPTY_ARRAY
|
||||
|
||||
if (!shallowEqual(this.suggestions, newSuggestions)) {
|
||||
this.suggestions = newSuggestions
|
||||
}
|
||||
|
||||
const newCapabilities = {
|
||||
switchToBranch: store.setMessages !== undefined,
|
||||
switchBranchDuringRun: false,
|
||||
edit: store.onEdit !== undefined,
|
||||
reload: store.onReload !== undefined,
|
||||
cancel: store.onCancel !== undefined,
|
||||
speech: store.adapters?.speech !== undefined,
|
||||
dictation: store.adapters?.dictation !== undefined,
|
||||
voice: store.adapters?.voice !== undefined,
|
||||
unstable_copy: store.unstable_capabilities?.copy !== false,
|
||||
attachments: !!store.adapters?.attachments,
|
||||
feedback: !!store.adapters?.feedback,
|
||||
queue: false
|
||||
}
|
||||
|
||||
if (!shallowEqual(self._capabilities, newCapabilities)) {
|
||||
self._capabilities = newCapabilities
|
||||
}
|
||||
|
||||
if (oldStore && oldStore.isRunning === store.isRunning && oldStore.messageRepository === store.messageRepository) {
|
||||
self._notifySubscribers()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (self._assistantOptimisticId) {
|
||||
this.repository.deleteMessage(self._assistantOptimisticId)
|
||||
self._assistantOptimisticId = null
|
||||
}
|
||||
|
||||
const messages = syncRepositoryIncrementally(this, store.messageRepository)
|
||||
|
||||
if (messages.length > 0) {
|
||||
this.ensureInitialized()
|
||||
}
|
||||
|
||||
if ((oldStore?.isRunning ?? false) !== (store.isRunning ?? false)) {
|
||||
self._notifyEventSubscribers(store.isRunning ? 'runStart' : 'runEnd', {})
|
||||
}
|
||||
|
||||
if (hasUpcomingMessage(isRunning, messages)) {
|
||||
self._assistantOptimisticId = this.repository.appendOptimisticMessage(messages.at(-1)?.id ?? null, {
|
||||
role: 'assistant',
|
||||
content: []
|
||||
})
|
||||
}
|
||||
|
||||
this.repository.resetHead(self._assistantOptimisticId ?? messages.at(-1)?.id ?? null)
|
||||
self._messages = this.repository.getMessages()
|
||||
self._notifySubscribers()
|
||||
}
|
||||
}
|
||||
|
||||
class IncrementalExternalStoreRuntimeCore extends BaseAssistantRuntimeCore {
|
||||
threads: ExternalStoreThreadListRuntimeCore
|
||||
|
||||
constructor(adapter: ExternalStoreAdapter) {
|
||||
super()
|
||||
|
||||
this.threads = new ExternalStoreThreadListRuntimeCore(
|
||||
getThreadListAdapter(adapter),
|
||||
() => new IncrementalExternalStoreThreadRuntimeCore(this._contextProvider, adapter)
|
||||
)
|
||||
}
|
||||
|
||||
setAdapter(adapter: ExternalStoreAdapter): void {
|
||||
this.threads.__internal_setAdapter(getThreadListAdapter(adapter))
|
||||
this.threads.getMainThreadRuntimeCore().__internal_setAdapter(adapter)
|
||||
}
|
||||
}
|
||||
|
||||
export function useIncrementalExternalStoreRuntime<T extends ThreadMessage>(
|
||||
store: ExternalStoreAdapter<T>
|
||||
): AssistantRuntime {
|
||||
const [runtime] = useState(() => new IncrementalExternalStoreRuntimeCore(store as ExternalStoreAdapter))
|
||||
|
||||
useEffect(() => {
|
||||
runtime.setAdapter(store as ExternalStoreAdapter)
|
||||
})
|
||||
|
||||
const { modelContext } = useRuntimeAdapters() ?? {}
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelContext) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return runtime.registerModelContextProvider(modelContext)
|
||||
}, [modelContext, runtime])
|
||||
|
||||
return useMemo(() => new AssistantRuntimeImpl(runtime), [runtime])
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*
|
||||
*/
|
||||
function hasCloseFenceLine(body: string, marker: string): boolean {
|
||||
const lines = body.split('\n')
|
||||
|
||||
// Original regex required `\n` immediately before the close fence, so the
|
||||
// first line of `body` (which has no preceding newline within `body`)
|
||||
// cannot itself be the close fence.
|
||||
@@ -35,8 +36,15 @@ function hasCloseFenceLine(body: string, marker: string): boolean {
|
||||
const line = lines[i]
|
||||
let lo = 0
|
||||
let hi = line.length
|
||||
while (lo < hi && (line[lo] === ' ' || line[lo] === '\t')) lo += 1
|
||||
while (hi > lo && (line[hi - 1] === ' ' || line[hi - 1] === '\t')) hi -= 1
|
||||
|
||||
while (lo < hi && (line[lo] === ' ' || line[lo] === '\t')) {
|
||||
lo += 1
|
||||
}
|
||||
|
||||
while (hi > lo && (line[hi - 1] === ' ' || line[hi - 1] === '\t')) {
|
||||
hi -= 1
|
||||
}
|
||||
|
||||
if (line.slice(lo, hi) === marker) {
|
||||
return true
|
||||
}
|
||||
@@ -122,7 +130,9 @@ function normalizeVisibleProse(text: string): string {
|
||||
.map(part =>
|
||||
part.startsWith('`')
|
||||
? part
|
||||
: autoLinkRawUrls(part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, ''))
|
||||
: autoLinkRawUrls(
|
||||
part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '')
|
||||
)
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { interpretRuntimeReadiness } from './runtime-readiness'
|
||||
|
||||
describe('interpretRuntimeReadiness', () => {
|
||||
it('prefers runtime_check when both signals exist', () => {
|
||||
const result = interpretRuntimeReadiness({
|
||||
setup: { provider_configured: false },
|
||||
setupError: null,
|
||||
runtime: { ok: true },
|
||||
runtimeError: null
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
checksDisagree: true,
|
||||
ready: true,
|
||||
reason: null,
|
||||
source: 'runtime_check'
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces runtime mismatch details when runtime_check fails', () => {
|
||||
const result = interpretRuntimeReadiness({
|
||||
setup: { provider_configured: true },
|
||||
setupError: null,
|
||||
runtime: { error: 'No provider can serve the selected model.', ok: false },
|
||||
runtimeError: null
|
||||
})
|
||||
|
||||
expect(result.ready).toBe(false)
|
||||
expect(result.source).toBe('runtime_check')
|
||||
expect(result.checksDisagree).toBe(true)
|
||||
expect(result.reason).toContain('No provider can serve the selected model.')
|
||||
expect(result.reason).toContain('setup.status reports configured credentials')
|
||||
})
|
||||
|
||||
it('falls back to setup.status when runtime_check has no boolean result', () => {
|
||||
const result = interpretRuntimeReadiness({
|
||||
setup: { provider_configured: true },
|
||||
setupError: null,
|
||||
runtime: null,
|
||||
runtimeError: 'runtime check RPC unavailable'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
checksDisagree: false,
|
||||
ready: true,
|
||||
reason: null,
|
||||
source: 'setup_status'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses explicit fallback when both checks are missing', () => {
|
||||
const result = interpretRuntimeReadiness({
|
||||
setup: null,
|
||||
setupError: 'setup.status timeout',
|
||||
runtime: null,
|
||||
runtimeError: 'setup.runtime_check timeout'
|
||||
})
|
||||
|
||||
expect(result.ready).toBe(false)
|
||||
expect(result.source).toBe('fallback')
|
||||
expect(result.reason).toBe('setup.runtime_check timeout')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
export interface SetupStatusSnapshot {
|
||||
provider_configured?: boolean
|
||||
}
|
||||
|
||||
export interface RuntimeCheckSnapshot {
|
||||
error?: string
|
||||
ok?: boolean
|
||||
}
|
||||
|
||||
export interface RuntimeReadinessSignals {
|
||||
setup: null | SetupStatusSnapshot
|
||||
setupError: null | string
|
||||
runtime: null | RuntimeCheckSnapshot
|
||||
runtimeError: null | string
|
||||
}
|
||||
|
||||
export interface RuntimeReadinessOptions {
|
||||
defaultReason?: string
|
||||
unknownReady?: boolean
|
||||
}
|
||||
|
||||
export interface RuntimeReadinessResult {
|
||||
checksDisagree: boolean
|
||||
ready: boolean
|
||||
reason: null | string
|
||||
source: 'fallback' | 'runtime_check' | 'setup_status'
|
||||
}
|
||||
|
||||
export type RuntimeReadinessRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
|
||||
const DEFAULT_NOT_READY_REASON = 'Add a provider credential before sending your first message.'
|
||||
|
||||
function toErrorMessage(error: unknown): null | string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
|
||||
if (error === null || error === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function normalizeMessage(value: null | string | undefined): null | string {
|
||||
const next = value?.trim()
|
||||
|
||||
return next ? next : null
|
||||
}
|
||||
|
||||
async function requestWithFallback<T>(
|
||||
requestGateway: RuntimeReadinessRequester,
|
||||
method: string
|
||||
): Promise<{ error: null | string; value: null | T }> {
|
||||
try {
|
||||
return { error: null, value: await requestGateway<T>(method) }
|
||||
} catch (error) {
|
||||
return { error: toErrorMessage(error), value: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRuntimeReadinessSignals(
|
||||
requestGateway: RuntimeReadinessRequester
|
||||
): Promise<RuntimeReadinessSignals> {
|
||||
const [setup, runtime] = await Promise.all([
|
||||
requestWithFallback<SetupStatusSnapshot>(requestGateway, 'setup.status'),
|
||||
requestWithFallback<RuntimeCheckSnapshot>(requestGateway, 'setup.runtime_check')
|
||||
])
|
||||
|
||||
return {
|
||||
setup: setup.value,
|
||||
setupError: setup.error,
|
||||
runtime: runtime.value,
|
||||
runtimeError: runtime.error
|
||||
}
|
||||
}
|
||||
|
||||
export function interpretRuntimeReadiness(
|
||||
signals: RuntimeReadinessSignals,
|
||||
options: RuntimeReadinessOptions = {}
|
||||
): RuntimeReadinessResult {
|
||||
const defaultReason = options.defaultReason ?? DEFAULT_NOT_READY_REASON
|
||||
const unknownReady = options.unknownReady ?? false
|
||||
|
||||
const setupConfigured =
|
||||
typeof signals.setup?.provider_configured === 'boolean' ? Boolean(signals.setup.provider_configured) : undefined
|
||||
|
||||
const runtimeOk = typeof signals.runtime?.ok === 'boolean' ? Boolean(signals.runtime.ok) : undefined
|
||||
const runtimeFailure = normalizeMessage(signals.runtime?.error) ?? normalizeMessage(signals.runtimeError)
|
||||
const setupFailure = normalizeMessage(signals.setupError)
|
||||
|
||||
const checksDisagree =
|
||||
typeof setupConfigured === 'boolean' && typeof runtimeOk === 'boolean' && setupConfigured !== runtimeOk
|
||||
|
||||
if (typeof runtimeOk === 'boolean') {
|
||||
if (runtimeOk) {
|
||||
return {
|
||||
checksDisagree,
|
||||
ready: true,
|
||||
reason: null,
|
||||
source: 'runtime_check'
|
||||
}
|
||||
}
|
||||
|
||||
let reason = runtimeFailure ?? defaultReason
|
||||
|
||||
if (checksDisagree && setupConfigured) {
|
||||
reason = `${reason} setup.status reports configured credentials, but runtime resolution still failed.`
|
||||
}
|
||||
|
||||
return {
|
||||
checksDisagree,
|
||||
ready: false,
|
||||
reason,
|
||||
source: 'runtime_check'
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof setupConfigured === 'boolean') {
|
||||
return {
|
||||
checksDisagree: false,
|
||||
ready: setupConfigured,
|
||||
reason: setupConfigured ? null : (runtimeFailure ?? setupFailure ?? defaultReason),
|
||||
source: 'setup_status'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checksDisagree: false,
|
||||
ready: unknownReady,
|
||||
reason: unknownReady ? null : (runtimeFailure ?? setupFailure ?? defaultReason),
|
||||
source: 'fallback'
|
||||
}
|
||||
}
|
||||
|
||||
export async function evaluateRuntimeReadiness(
|
||||
requestGateway: RuntimeReadinessRequester,
|
||||
options: RuntimeReadinessOptions = {}
|
||||
): Promise<RuntimeReadinessResult> {
|
||||
const signals = await fetchRuntimeReadinessSignals(requestGateway)
|
||||
|
||||
return interpretRuntimeReadiness(signals, options)
|
||||
}
|
||||
@@ -13,7 +13,9 @@ const isStatus = (v: unknown): v is TodoStatus => (STATUSES as readonly string[]
|
||||
|
||||
function parseArray(value: unknown[]): TodoItem[] {
|
||||
return value.flatMap(item => {
|
||||
if (!isRecord(item) || !isStatus(item.status)) {return []}
|
||||
if (!isRecord(item) || !isStatus(item.status)) {
|
||||
return []
|
||||
}
|
||||
const id = String(item.id ?? '').trim()
|
||||
const content = String(item.content ?? '').trim()
|
||||
|
||||
@@ -22,15 +24,25 @@ function parseArray(value: unknown[]): TodoItem[] {
|
||||
}
|
||||
|
||||
function parse(value: unknown, depth: number): null | TodoItem[] {
|
||||
if (depth > 2) {return null}
|
||||
|
||||
if (Array.isArray(value)) {return parseArray(value)}
|
||||
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
try { return parse(JSON.parse(value), depth + 1) } catch { return null }
|
||||
if (depth > 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isRecord(value) && Object.hasOwn(value, 'todos')) {return parse(value.todos, depth + 1)}
|
||||
if (Array.isArray(value)) {
|
||||
return parseArray(value)
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
try {
|
||||
return parse(JSON.parse(value), depth + 1)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecord(value) && Object.hasOwn(value, 'todos')) {
|
||||
return parse(value.todos, depth + 1)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -44,9 +44,7 @@ describe('formatToolResultSummary', () => {
|
||||
details: `prefix ${'x'.repeat(500)}`
|
||||
})
|
||||
|
||||
const detailsLine = summary
|
||||
.split('\n')
|
||||
.find(line => line.startsWith('- Details:'))
|
||||
const detailsLine = summary.split('\n').find(line => line.startsWith('- Details:'))
|
||||
|
||||
expect(detailsLine).toBeTruthy()
|
||||
expect(detailsLine?.length).toBeLessThan(230)
|
||||
|
||||
@@ -2,7 +2,21 @@
|
||||
// mode still gets the raw JSON section.
|
||||
|
||||
const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const
|
||||
const PRIORITY_KEYS = ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'status', 'id', 'message', 'summary', 'description'] as const
|
||||
const PRIORITY_KEYS = [
|
||||
'title',
|
||||
'name',
|
||||
'path',
|
||||
'file',
|
||||
'filepath',
|
||||
'url',
|
||||
'href',
|
||||
'link',
|
||||
'status',
|
||||
'id',
|
||||
'message',
|
||||
'summary',
|
||||
'description'
|
||||
] as const
|
||||
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
|
||||
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
|
||||
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
|
||||
@@ -14,17 +28,29 @@ const isRecord = (v: unknown): v is Json => Boolean(v && typeof v === 'object' &
|
||||
function tryJson(value: string): unknown {
|
||||
const t = value.trim()
|
||||
|
||||
if (!t) {return ''}
|
||||
if (!t) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (!/^[{[]|^"/.test(t)) {return value}
|
||||
if (!/^[{[]|^"/.test(t)) {
|
||||
return value
|
||||
}
|
||||
|
||||
try { return JSON.parse(t) } catch { return value }
|
||||
try {
|
||||
return JSON.parse(t)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const norm = (v: unknown): unknown => (typeof v === 'string' ? tryJson(v) : v)
|
||||
|
||||
const titleCase = (k: string) =>
|
||||
k.split(/[_\-.]+/).filter(Boolean).map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`).join(' ')
|
||||
k
|
||||
.split(/[_\-.]+/)
|
||||
.filter(Boolean)
|
||||
.map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`)
|
||||
.join(' ')
|
||||
|
||||
const pluralize = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
|
||||
|
||||
@@ -37,12 +63,16 @@ function clipInline(value: string, max = 180): string {
|
||||
function clipBlock(value: string, maxChars = 1800, maxLines = 18): string {
|
||||
const t = value.trim()
|
||||
|
||||
if (!t) {return ''}
|
||||
if (!t) {
|
||||
return ''
|
||||
}
|
||||
const lines = t.split('\n')
|
||||
let text = lines.slice(0, maxLines).join('\n')
|
||||
const clipped = lines.length > maxLines || text.length > maxChars
|
||||
|
||||
if (text.length > maxChars) {text = text.slice(0, maxChars - 1).trimEnd()}
|
||||
if (text.length > maxChars) {
|
||||
text = text.slice(0, maxChars - 1).trimEnd()
|
||||
}
|
||||
|
||||
return clipped && !text.endsWith('…') ? `${text}…` : text
|
||||
}
|
||||
@@ -51,7 +81,9 @@ function firstString(record: Json, keys: readonly string[]): string {
|
||||
for (const k of keys) {
|
||||
const v = record[k]
|
||||
|
||||
if (typeof v === 'string' && v.trim()) {return v.trim()}
|
||||
if (typeof v === 'string' && v.trim()) {
|
||||
return v.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
@@ -68,25 +100,37 @@ const isWrapperKey = (k: string) => (WRAPPER_KEYS as readonly string[]).includes
|
||||
const skipField = (k: string, v: unknown) => isWrapperKey(k) || ((k === 'success' || k === 'ok') && v === true)
|
||||
|
||||
function summarizeScalar(v: unknown): string {
|
||||
if (typeof v === 'string') {return clipInline(v)}
|
||||
if (typeof v === 'string') {
|
||||
return clipInline(v)
|
||||
}
|
||||
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {
|
||||
return String(v)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function summarizeRecordInline(record: Json, depth: number): string {
|
||||
if (depth > 3) {return pluralize(Object.keys(record).length, 'field')}
|
||||
if (depth > 3) {
|
||||
return pluralize(Object.keys(record).length, 'field')
|
||||
}
|
||||
|
||||
const title = firstString(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'id'])
|
||||
const status = firstString(record, ['status', 'category', 'type'])
|
||||
const message = firstString(record, ['snippet', 'summary', 'description', 'message'])
|
||||
|
||||
if (title && status) {return `${clipInline(title, 110)} (${clipInline(status, 54)})`}
|
||||
if (title && status) {
|
||||
return `${clipInline(title, 110)} (${clipInline(status, 54)})`
|
||||
}
|
||||
|
||||
if (title && message && title !== message) {return `${clipInline(title, 90)} - ${clipInline(message, 84)}`}
|
||||
if (title && message && title !== message) {
|
||||
return `${clipInline(title, 90)} - ${clipInline(message, 84)}`
|
||||
}
|
||||
|
||||
if (title) {return clipInline(title, 150)}
|
||||
if (title) {
|
||||
return clipInline(title, 150)
|
||||
}
|
||||
|
||||
const pairs = orderedKeys(Object.keys(record))
|
||||
.filter(k => !skipField(k, record[k]))
|
||||
@@ -104,15 +148,25 @@ function summarizeRecordInline(record: Json, depth: number): string {
|
||||
function summarizeListItem(item: unknown, depth: number): string {
|
||||
const v = norm(item)
|
||||
|
||||
if (typeof v === 'string') {return clipInline(v)}
|
||||
if (typeof v === 'string') {
|
||||
return clipInline(v)
|
||||
}
|
||||
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {
|
||||
return String(v)
|
||||
}
|
||||
|
||||
if (v == null) {return ''}
|
||||
if (v == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {return pluralize(v.length, 'item')}
|
||||
if (Array.isArray(v)) {
|
||||
return pluralize(v.length, 'item')
|
||||
}
|
||||
|
||||
if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
|
||||
if (isRecord(v)) {
|
||||
return summarizeRecordInline(v, depth + 1)
|
||||
}
|
||||
|
||||
return clipInline(String(v))
|
||||
}
|
||||
@@ -121,32 +175,50 @@ function formatFieldValue(value: unknown, depth: number): string {
|
||||
const v = norm(value)
|
||||
const scalar = summarizeScalar(v)
|
||||
|
||||
if (scalar) {return scalar}
|
||||
if (scalar) {
|
||||
return scalar
|
||||
}
|
||||
|
||||
if (v == null) {return ''}
|
||||
if (v == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
if (!v.length) {return '0 items'}
|
||||
if (!v.length) {
|
||||
return '0 items'
|
||||
}
|
||||
const scalars = v.map(summarizeScalar).filter(Boolean)
|
||||
|
||||
if (scalars.length === v.length && v.length <= 4) {return clipInline(scalars.join(', '))}
|
||||
if (scalars.length === v.length && v.length <= 4) {
|
||||
return clipInline(scalars.join(', '))
|
||||
}
|
||||
const first = summarizeListItem(v[0], depth + 1)
|
||||
|
||||
return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item')
|
||||
}
|
||||
|
||||
if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
|
||||
if (isRecord(v)) {
|
||||
return summarizeRecordInline(v, depth + 1)
|
||||
}
|
||||
|
||||
return clipInline(String(v))
|
||||
}
|
||||
|
||||
function formatArraySummary(value: unknown[], depth: number): string {
|
||||
if (!value.length) {return 'No items returned.'}
|
||||
if (!value.length) {
|
||||
return 'No items returned.'
|
||||
}
|
||||
|
||||
const max = 6
|
||||
const lines = value.slice(0, max).map(item => summarizeListItem(item, depth + 1)).filter(Boolean).map(l => `- ${l}`)
|
||||
const lines = value
|
||||
.slice(0, max)
|
||||
.map(item => summarizeListItem(item, depth + 1))
|
||||
.filter(Boolean)
|
||||
.map(l => `- ${l}`)
|
||||
|
||||
if (!lines.length) {return `Returned ${pluralize(value.length, 'item')}.`}
|
||||
if (!lines.length) {
|
||||
return `Returned ${pluralize(value.length, 'item')}.`
|
||||
}
|
||||
|
||||
if (value.length > max) {
|
||||
const remaining = value.length - max
|
||||
@@ -159,13 +231,17 @@ function formatArraySummary(value: unknown[], depth: number): string {
|
||||
function formatRecordSummary(record: Json, depth: number): string {
|
||||
const keys = Object.keys(record)
|
||||
|
||||
if (!keys.length) {return 'Returned an empty object.'}
|
||||
if (!keys.length) {
|
||||
return 'Returned an empty object.'
|
||||
}
|
||||
|
||||
if (depth <= 2) {
|
||||
const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content'])
|
||||
const meaningful = keys.filter(k => !skipField(k, record[k]) && !isWrapperKey(k))
|
||||
|
||||
if (direct && meaningful.length <= 1) {return clipBlock(direct)}
|
||||
if (direct && meaningful.length <= 1) {
|
||||
return clipBlock(direct)
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = orderedKeys(keys).filter(k => !skipField(k, record[k]))
|
||||
@@ -175,13 +251,19 @@ function formatRecordSummary(record: Json, depth: number): string {
|
||||
for (const k of candidates) {
|
||||
const v = formatFieldValue(record[k], depth + 1)
|
||||
|
||||
if (!v) {continue}
|
||||
if (!v) {
|
||||
continue
|
||||
}
|
||||
lines.push(`- ${titleCase(k)}: ${v}`)
|
||||
|
||||
if (lines.length >= max) {break}
|
||||
if (lines.length >= max) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!lines.length) {return `Returned object with ${pluralize(keys.length, 'field')}.`}
|
||||
if (!lines.length) {
|
||||
return `Returned object with ${pluralize(keys.length, 'field')}.`
|
||||
}
|
||||
|
||||
if (candidates.length > lines.length) {
|
||||
const remaining = candidates.length - lines.length
|
||||
@@ -192,18 +274,30 @@ function formatRecordSummary(record: Json, depth: number): string {
|
||||
}
|
||||
|
||||
function formatSummaryValue(value: unknown, depth: number): string {
|
||||
if (depth > 4) {return ''}
|
||||
if (depth > 4) {
|
||||
return ''
|
||||
}
|
||||
const v = norm(value)
|
||||
|
||||
if (typeof v === 'string') {return clipBlock(v)}
|
||||
if (typeof v === 'string') {
|
||||
return clipBlock(v)
|
||||
}
|
||||
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {
|
||||
return String(v)
|
||||
}
|
||||
|
||||
if (v == null) {return ''}
|
||||
if (v == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {return formatArraySummary(v, depth + 1)}
|
||||
if (Array.isArray(v)) {
|
||||
return formatArraySummary(v, depth + 1)
|
||||
}
|
||||
|
||||
if (isRecord(v)) {return formatRecordSummary(v, depth + 1)}
|
||||
if (isRecord(v)) {
|
||||
return formatRecordSummary(v, depth + 1)
|
||||
}
|
||||
|
||||
return clipInline(String(v))
|
||||
}
|
||||
@@ -232,17 +326,29 @@ function unwrapPayload(value: unknown): unknown {
|
||||
function hasMeaningfulErrorValue(value: unknown): boolean {
|
||||
const v = norm(value)
|
||||
|
||||
if (v == null) {return false}
|
||||
if (v == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof v === 'string') {return !NON_ERROR_TEXT.has(v.trim().toLowerCase())}
|
||||
if (typeof v === 'string') {
|
||||
return !NON_ERROR_TEXT.has(v.trim().toLowerCase())
|
||||
}
|
||||
|
||||
if (typeof v === 'boolean') {return v}
|
||||
if (typeof v === 'boolean') {
|
||||
return v
|
||||
}
|
||||
|
||||
if (typeof v === 'number') {return v !== 0}
|
||||
if (typeof v === 'number') {
|
||||
return v !== 0
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {return v.some(hasMeaningfulErrorValue)}
|
||||
if (Array.isArray(v)) {
|
||||
return v.some(hasMeaningfulErrorValue)
|
||||
}
|
||||
|
||||
if (isRecord(v)) {return Object.keys(v).length > 0}
|
||||
if (isRecord(v)) {
|
||||
return Object.keys(v).length > 0
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -261,7 +367,9 @@ function hasErrorSignal(record: Json): boolean {
|
||||
function valueErrorText(value: unknown): string {
|
||||
const v = norm(value)
|
||||
|
||||
if (typeof v === 'string') {return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : ''}
|
||||
if (typeof v === 'string') {
|
||||
return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : ''
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
return clipBlock(v.map(valueErrorText).filter(Boolean).slice(0, 3).join('; '), 700, 12)
|
||||
@@ -270,24 +378,32 @@ function valueErrorText(value: unknown): string {
|
||||
if (isRecord(v)) {
|
||||
const direct = firstString(v, ERROR_MSG_KEYS)
|
||||
|
||||
if (direct) {return clipBlock(direct, 700, 12)}
|
||||
if (direct) {
|
||||
return clipBlock(direct, 700, 12)
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function findNestedError(value: unknown, depth: number, seen: Set<unknown>): string {
|
||||
if (depth > 5) {return ''}
|
||||
if (depth > 5) {
|
||||
return ''
|
||||
}
|
||||
const v = norm(value)
|
||||
|
||||
if (!v || typeof v !== 'object' || seen.has(v)) {return ''}
|
||||
if (!v || typeof v !== 'object' || seen.has(v)) {
|
||||
return ''
|
||||
}
|
||||
seen.add(v)
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
for (const item of v) {
|
||||
const nested = findNestedError(item, depth + 1, seen)
|
||||
|
||||
if (nested) {return nested}
|
||||
if (nested) {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
@@ -296,22 +412,30 @@ function findNestedError(value: unknown, depth: number, seen: Set<unknown>): str
|
||||
const record = v as Json
|
||||
|
||||
for (const k of ERROR_KEYS) {
|
||||
if (!hasMeaningfulErrorValue(record[k])) {continue}
|
||||
if (!hasMeaningfulErrorValue(record[k])) {
|
||||
continue
|
||||
}
|
||||
const text = valueErrorText(record[k])
|
||||
|
||||
if (text) {return text}
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrorSignal(record)) {
|
||||
const direct = firstString(record, ERROR_MSG_KEYS)
|
||||
|
||||
if (direct) {return clipBlock(direct, 700, 12)}
|
||||
if (direct) {
|
||||
return clipBlock(direct, 700, 12)
|
||||
}
|
||||
}
|
||||
|
||||
for (const k of [...ERROR_KEYS, ...WRAPPER_KEYS, 'details', 'meta']) {
|
||||
const nested = findNestedError(record[k], depth + 1, seen)
|
||||
|
||||
if (nested) {return nested}
|
||||
if (nested) {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* One-shot enter animation via the Web Animations API.
|
||||
*
|
||||
* Returns a callback ref. The animation fires exactly once when the element
|
||||
* first attaches to the DOM and never replays for an already-mounted node —
|
||||
* this is deliberate. CSS-transition + `@starting-style` is fragile here
|
||||
* because:
|
||||
* - Streaming deltas constantly invalidate ancestor state, which can
|
||||
* re-trigger transitions on unrelated descendants.
|
||||
* - `@starting-style` only covers DOM insertion / first-match, but any
|
||||
* style restart during the message lifecycle replays the transition.
|
||||
* - Some Chromium versions reset transitions when an attribute on an
|
||||
* ancestor toggles, even if the descendant's properties never change.
|
||||
*
|
||||
* `el.animate(...)` runs against the element directly and is independent of
|
||||
* CSS rule churn — it plays once, finishes, and is done. If the element
|
||||
* unmounts and re-mounts, the callback ref runs again and replays it
|
||||
* (correct behaviour).
|
||||
*
|
||||
* `enabled` is captured at mount-time only — flipping it later doesn't
|
||||
* suddenly play the animation on existing nodes.
|
||||
*/
|
||||
const playedAnimationKeys = new Set<string>()
|
||||
const playedAnimationOrder: string[] = []
|
||||
const MAX_TRACKED_KEYS = 2048
|
||||
|
||||
function hasPlayedAnimation(key: string): boolean {
|
||||
return playedAnimationKeys.has(key)
|
||||
}
|
||||
|
||||
function rememberPlayedAnimation(key: string): void {
|
||||
if (playedAnimationKeys.has(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
playedAnimationKeys.add(key)
|
||||
playedAnimationOrder.push(key)
|
||||
|
||||
if (playedAnimationOrder.length > MAX_TRACKED_KEYS) {
|
||||
const evicted = playedAnimationOrder.shift()
|
||||
|
||||
if (evicted) {
|
||||
playedAnimationKeys.delete(evicted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleMicrotask(cb: () => void): void {
|
||||
if (typeof queueMicrotask === 'function') {
|
||||
queueMicrotask(cb)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void Promise.resolve().then(cb)
|
||||
}
|
||||
|
||||
export function useEnterAnimation(enabled: boolean, animationKey?: string): (el: HTMLElement | null) => void {
|
||||
const enabledRef = useRef(enabled)
|
||||
const keyRef = useRef(animationKey)
|
||||
|
||||
enabledRef.current = enabled
|
||||
keyRef.current = animationKey
|
||||
|
||||
return useCallback((el: HTMLElement | null) => {
|
||||
if (!el || !enabledRef.current || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = keyRef.current
|
||||
|
||||
if (key && hasPlayedAnimation(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
el.animate(
|
||||
[
|
||||
{ opacity: 0, transform: 'translateY(0.5rem)' },
|
||||
{ opacity: 1, transform: 'translateY(0)' }
|
||||
],
|
||||
{ duration: 220, easing: 'linear', fill: 'both' }
|
||||
)
|
||||
|
||||
if (key) {
|
||||
// In React StrictMode the first mount can be immediately torn down.
|
||||
// Only persist "played" once the element survives to the microtask tick.
|
||||
scheduleMicrotask(() => {
|
||||
if (el.isConnected) {
|
||||
rememberPlayedAnimation(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -154,6 +154,7 @@ export function clearNotifications() {
|
||||
timers.clear()
|
||||
const all = $notifications.get()
|
||||
$notifications.set([])
|
||||
|
||||
for (const item of all) {
|
||||
item.onDismiss?.()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { OAuthProvider } from '@/types/hermes'
|
||||
|
||||
import {
|
||||
$desktopOnboarding,
|
||||
type DesktopOnboardingState,
|
||||
type OnboardingContext,
|
||||
refreshOnboarding,
|
||||
requestDesktopOnboarding
|
||||
} from './onboarding'
|
||||
|
||||
function provider(id: string, name = id): OAuthProvider {
|
||||
return {
|
||||
cli_command: `hermes login ${id}`,
|
||||
docs_url: `https://example.com/${id}`,
|
||||
flow: 'pkce',
|
||||
id,
|
||||
name,
|
||||
status: { logged_in: false }
|
||||
}
|
||||
}
|
||||
|
||||
function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnboardingState {
|
||||
return {
|
||||
configured: false,
|
||||
flow: { status: 'idle' },
|
||||
mode: 'oauth',
|
||||
providers: null,
|
||||
reason: null,
|
||||
requested: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function installApiMock(api: (request: { path: string }) => Promise<unknown>) {
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { api }
|
||||
})
|
||||
}
|
||||
|
||||
function runtimeMismatchGateway(): OnboardingContext['requestGateway'] {
|
||||
return async method => {
|
||||
if (method === 'setup.status') {
|
||||
return { provider_configured: true } as never
|
||||
}
|
||||
|
||||
if (method === 'setup.runtime_check') {
|
||||
return { error: 'Selected runtime is not available.', ok: false } as never
|
||||
}
|
||||
|
||||
throw new Error(`unexpected gateway method: ${method}`)
|
||||
}
|
||||
}
|
||||
|
||||
function onboardingContext(requestGateway: OnboardingContext['requestGateway']): OnboardingContext {
|
||||
return { requestGateway }
|
||||
}
|
||||
|
||||
describe('refreshOnboarding', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
$desktopOnboarding.set(baseState())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear()
|
||||
$desktopOnboarding.set(baseState())
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('refreshes OAuth providers again when onboarding was explicitly requested', async () => {
|
||||
const api = vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path === '/api/providers/oauth') {
|
||||
return { providers: [provider('fresh')] }
|
||||
}
|
||||
|
||||
throw new Error(`unexpected api path: ${path}`)
|
||||
})
|
||||
|
||||
installApiMock(api)
|
||||
$desktopOnboarding.set(baseState({ providers: [provider('cached')] }))
|
||||
requestDesktopOnboarding('Need provider setup')
|
||||
|
||||
const ready = await refreshOnboarding(onboardingContext(runtimeMismatchGateway()))
|
||||
|
||||
expect(ready).toBe(false)
|
||||
expect(api).toHaveBeenCalledTimes(1)
|
||||
expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['fresh'])
|
||||
expect($desktopOnboarding.get().reason).toContain('Selected runtime is not available.')
|
||||
expect($desktopOnboarding.get().reason).toContain('setup.status reports configured credentials')
|
||||
})
|
||||
|
||||
it('keeps cached providers when onboarding was not re-requested', async () => {
|
||||
const api = vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path === '/api/providers/oauth') {
|
||||
return { providers: [provider('fresh')] }
|
||||
}
|
||||
|
||||
throw new Error(`unexpected api path: ${path}`)
|
||||
})
|
||||
|
||||
installApiMock(api)
|
||||
$desktopOnboarding.set(baseState({ providers: [provider('cached')] }))
|
||||
|
||||
const ready = await refreshOnboarding(onboardingContext(runtimeMismatchGateway()))
|
||||
|
||||
expect(ready).toBe(false)
|
||||
expect(api).not.toHaveBeenCalled()
|
||||
expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['cached'])
|
||||
})
|
||||
|
||||
it('deduplicates concurrent provider refresh calls', async () => {
|
||||
let resolveProviders!: (value: { providers: OAuthProvider[] }) => void
|
||||
|
||||
const providersPromise = new Promise<{ providers: OAuthProvider[] }>(resolve => {
|
||||
resolveProviders = value => {
|
||||
resolve(value)
|
||||
}
|
||||
})
|
||||
|
||||
const api = vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path === '/api/providers/oauth') {
|
||||
return providersPromise
|
||||
}
|
||||
|
||||
throw new Error(`unexpected api path: ${path}`)
|
||||
})
|
||||
|
||||
installApiMock(api)
|
||||
$desktopOnboarding.set(baseState({ requested: true }))
|
||||
|
||||
const first = refreshOnboarding(onboardingContext(runtimeMismatchGateway()))
|
||||
const second = refreshOnboarding(onboardingContext(runtimeMismatchGateway()))
|
||||
|
||||
await vi.waitFor(() => expect(api).toHaveBeenCalledTimes(1))
|
||||
|
||||
resolveProviders({ providers: [provider('shared')] })
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['shared'])
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
startOAuthLogin,
|
||||
submitOAuthCode
|
||||
} from '@/hermes'
|
||||
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes'
|
||||
|
||||
@@ -46,6 +47,7 @@ export interface OnboardingContext {
|
||||
const CONFIGURED_CACHE_KEY = 'hermes-desktop-onboarded-v1'
|
||||
const POLL_MS = 2000
|
||||
const COPY_FLASH_MS = 1500
|
||||
const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.'
|
||||
|
||||
function readCachedConfigured(): boolean | null {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -87,6 +89,7 @@ const INITIAL: DesktopOnboardingState = {
|
||||
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
|
||||
|
||||
let pollTimer: number | null = null
|
||||
let providersRefreshPromise: null | Promise<void> = null
|
||||
|
||||
const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
||||
|
||||
@@ -104,42 +107,61 @@ function clearPoll() {
|
||||
}
|
||||
}
|
||||
|
||||
async function safeReq<T>(ctx: OnboardingContext, method: string, fallback: T): Promise<T> {
|
||||
try {
|
||||
return await ctx.requestGateway<T>(method)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRuntime(ctx: OnboardingContext) {
|
||||
const [status, runtime] = await Promise.all([
|
||||
safeReq<{ provider_configured?: boolean }>(ctx, 'setup.status', {}),
|
||||
safeReq<{ error?: string; ok?: boolean }>(ctx, 'setup.runtime_check', { ok: false })
|
||||
])
|
||||
|
||||
return runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured)
|
||||
async function checkRuntime(ctx: OnboardingContext): Promise<RuntimeReadinessResult> {
|
||||
return evaluateRuntimeReadiness(ctx.requestGateway, {
|
||||
defaultReason: DEFAULT_ONBOARDING_REASON,
|
||||
unknownReady: false
|
||||
})
|
||||
}
|
||||
|
||||
function notifyReady(provider: string) {
|
||||
notify({ kind: 'success', title: 'Hermes is ready', message: `${provider} connected.` })
|
||||
}
|
||||
|
||||
async function reloadAndConnect(ctx: OnboardingContext, providerName: string, onFail: () => void) {
|
||||
async function reloadAndConnect(ctx: OnboardingContext, providerName: string, onFail: (reason: null | string) => void) {
|
||||
await ctx.requestGateway('reload.env').catch(() => undefined)
|
||||
const ok = await checkRuntime(ctx)
|
||||
const runtime = await checkRuntime(ctx)
|
||||
|
||||
if (ok) {
|
||||
if (runtime.ready) {
|
||||
notifyReady(providerName)
|
||||
completeDesktopOnboarding()
|
||||
ctx.onCompleted?.()
|
||||
} else {
|
||||
onFail()
|
||||
onFail(runtime.reason)
|
||||
}
|
||||
}
|
||||
|
||||
export function requestDesktopOnboarding(reason = 'No inference provider is configured.') {
|
||||
patch({ reason, requested: true })
|
||||
function providerResolutionFailure(reason: null | string) {
|
||||
const detail = reason?.trim()
|
||||
|
||||
return detail
|
||||
? `Connected, but Hermes still cannot resolve a usable provider. ${detail}`
|
||||
: 'Connected, but Hermes still cannot resolve a usable provider.'
|
||||
}
|
||||
|
||||
async function refreshProviders() {
|
||||
if (providersRefreshPromise) {
|
||||
await providersRefreshPromise
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
providersRefreshPromise = (async () => {
|
||||
try {
|
||||
const { providers } = await listOAuthProviders()
|
||||
patch({ mode: providers.length > 0 ? 'oauth' : 'apikey', providers })
|
||||
} catch {
|
||||
patch({ mode: 'apikey', providers: [] })
|
||||
} finally {
|
||||
providersRefreshPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
await providersRefreshPromise
|
||||
}
|
||||
|
||||
export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
|
||||
patch({ reason: reason.trim() || DEFAULT_ONBOARDING_REASON, requested: true })
|
||||
}
|
||||
|
||||
export function completeDesktopOnboarding() {
|
||||
@@ -160,26 +182,26 @@ export function setOnboardingMode(mode: OnboardingMode) {
|
||||
}
|
||||
|
||||
export async function refreshOnboarding(ctx: OnboardingContext) {
|
||||
if (await checkRuntime(ctx)) {
|
||||
const runtime = await checkRuntime(ctx)
|
||||
|
||||
if (runtime.ready) {
|
||||
completeDesktopOnboarding()
|
||||
ctx.onCompleted?.()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
writeCachedConfigured(false)
|
||||
patch({ configured: false })
|
||||
const state = $desktopOnboarding.get()
|
||||
const reason = runtime.reason || state.reason || DEFAULT_ONBOARDING_REASON
|
||||
|
||||
if ($desktopOnboarding.get().providers !== null) {
|
||||
writeCachedConfigured(false)
|
||||
patch({ configured: false, reason })
|
||||
|
||||
if (state.providers !== null && !state.requested) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const { providers } = await listOAuthProviders()
|
||||
patch({ providers, mode: providers.length > 0 ? 'oauth' : 'apikey' })
|
||||
} catch {
|
||||
patch({ providers: [], mode: 'apikey' })
|
||||
}
|
||||
await refreshProviders()
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -219,11 +241,11 @@ async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: Onbo
|
||||
if (status === 'approved') {
|
||||
clearPoll()
|
||||
setFlow({ status: 'success', provider })
|
||||
await reloadAndConnect(ctx, provider.name, () =>
|
||||
await reloadAndConnect(ctx, provider.name, reason =>
|
||||
setFlow({
|
||||
status: 'error',
|
||||
provider,
|
||||
message: 'Connected, but Hermes still cannot resolve a usable provider.'
|
||||
message: providerResolutionFailure(reason)
|
||||
})
|
||||
)
|
||||
} else if (status !== 'pending') {
|
||||
@@ -259,11 +281,11 @@ export async function submitOnboardingCode(ctx: OnboardingContext) {
|
||||
|
||||
if (resp.ok && resp.status === 'approved') {
|
||||
setFlow({ status: 'success', provider })
|
||||
await reloadAndConnect(ctx, provider.name, () =>
|
||||
await reloadAndConnect(ctx, provider.name, reason =>
|
||||
setFlow({
|
||||
status: 'error',
|
||||
provider,
|
||||
message: 'Connected, but Hermes still cannot resolve a usable provider.'
|
||||
message: providerResolutionFailure(reason)
|
||||
})
|
||||
)
|
||||
} else {
|
||||
@@ -338,11 +360,13 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
|
||||
}
|
||||
|
||||
const { provider } = flow
|
||||
await reloadAndConnect(ctx, provider.name, () =>
|
||||
await reloadAndConnect(ctx, provider.name, reason =>
|
||||
setFlow({
|
||||
status: 'error',
|
||||
provider,
|
||||
message: `Hermes still cannot reach ${provider.name}. Run \`${provider.cli_command}\` in a terminal first.`
|
||||
message:
|
||||
reason?.trim() ||
|
||||
`Hermes still cannot reach ${provider.name}. Run \`${provider.cli_command}\` in a terminal first.`
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -357,12 +381,19 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
|
||||
try {
|
||||
await setEnvVar(envKey, trimmed)
|
||||
let stillFailing = false
|
||||
await reloadAndConnect(ctx, label, () => {
|
||||
let runtimeFailure: null | string = null
|
||||
await reloadAndConnect(ctx, label, reason => {
|
||||
stillFailing = true
|
||||
runtimeFailure = reason
|
||||
})
|
||||
|
||||
if (stillFailing) {
|
||||
return { ok: false, message: `Saved, but Hermes still cannot reach ${label}. Double-check the value.` }
|
||||
const failureDetail = (runtimeFailure ?? '').trim()
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: failureDetail || `Saved, but Hermes still cannot reach ${label}. Double-check the value.`
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { atom } from 'nanostores'
|
||||
import { atom, computed, type ReadableAtom } from 'nanostores'
|
||||
|
||||
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||
|
||||
@@ -14,6 +14,7 @@ export const $toolViewMode = atom<ToolViewMode>(
|
||||
storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product'
|
||||
)
|
||||
export const $toolDisclosureStates = atom<ToolDisclosureStates>(loadToolDisclosureStates())
|
||||
const disclosureOpenCache = new Map<string, ReadableAtom<boolean | undefined>>()
|
||||
|
||||
$toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical'))
|
||||
$toolDisclosureStates.subscribe(persistToolDisclosureStates)
|
||||
@@ -22,6 +23,17 @@ export function setToolViewMode(mode: ToolViewMode) {
|
||||
$toolViewMode.set(mode)
|
||||
}
|
||||
|
||||
export function $toolDisclosureOpen(id: string): ReadableAtom<boolean | undefined> {
|
||||
let cached = disclosureOpenCache.get(id)
|
||||
|
||||
if (!cached) {
|
||||
cached = computed($toolDisclosureStates, states => states[id])
|
||||
disclosureOpenCache.set(id, cached)
|
||||
}
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
function loadToolDisclosureStates(): ToolDisclosureStates {
|
||||
if (typeof window === 'undefined') {
|
||||
return {}
|
||||
|
||||
@@ -432,11 +432,13 @@ canvas {
|
||||
padding-left: var(--message-text-indent);
|
||||
}
|
||||
|
||||
/* Tool/thinking blocks now live at message-text alignment (no leading
|
||||
chevron column to escape into), so their headers and bodies share a
|
||||
common left edge with the model's text. */
|
||||
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block'],
|
||||
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'] {
|
||||
margin-inline-start: calc(-1 * var(--message-text-indent));
|
||||
width: calc(100% + var(--message-text-indent));
|
||||
max-width: calc(100% + var(--message-text-indent));
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code {
|
||||
@@ -525,3 +527,55 @@ canvas {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
/* Streaming caret + comet tail. Anchored to the cursor (not the block's
|
||||
right edge) so it reads on short last lines. Spread inflates the bg-color
|
||||
veil so it overlays text instead of haloing the tiny dither block. */
|
||||
[data-slot='aui_assistant-message-content']
|
||||
[data-status='running']
|
||||
> div
|
||||
> *:last-child::after {
|
||||
content: '' !important;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 0.6em;
|
||||
height: 1em;
|
||||
margin-left: 0.18em;
|
||||
vertical-align: middle;
|
||||
border-radius: 1.5px;
|
||||
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 2px 2px;
|
||||
color: color-mix(in srgb, var(--dt-foreground) 72%, transparent);
|
||||
box-shadow:
|
||||
-0.8ch 0 1.4ch 0.55em color-mix(in srgb, var(--dt-background) 80%, transparent),
|
||||
-3ch 0 2.4ch 0.55em color-mix(in srgb, var(--dt-background) 64%, transparent),
|
||||
-6.5ch 0 3.6ch 0.5em color-mix(in srgb, var(--dt-background) 44%, transparent),
|
||||
-11ch 0 5ch 0.45em color-mix(in srgb, var(--dt-background) 28%, transparent),
|
||||
-16ch 0 6.4ch 0.4em color-mix(in srgb, var(--dt-background) 16%, transparent),
|
||||
-22ch 0 7.6ch 0.35em color-mix(in srgb, var(--dt-background) 8%, transparent),
|
||||
0 0 0.4ch color-mix(in srgb, var(--dt-foreground) 28%, transparent);
|
||||
animation: hermes-stream-caret 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes hermes-stream-caret {
|
||||
0%, 100% { opacity: 0.95; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot='aui_assistant-message-content']
|
||||
[data-status='running']
|
||||
> div
|
||||
> *:last-child::after {
|
||||
animation: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Live thinking preview window. Pairs with the ResizeObserver in
|
||||
ThinkingDisclosure that pins scrollTop to the bottom — older lines fade
|
||||
into the top mask while the latest tokens settle in below. */
|
||||
.thinking-preview {
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 28%, black 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 28%, black 100%);
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -71,7 +71,7 @@
|
||||
"@chenglou/pretext": "^0.0.6",
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@nous-research/ui": "^0.12.0",
|
||||
"@nous-research/ui": "^0.13.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
@@ -131,9 +131,9 @@
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/@nous-research/ui": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.12.0.tgz",
|
||||
"integrity": "sha512-OHv7z9J0r5Yp9iOl8uVUZAdIkTtDwqAL2RbgsQczXAhSPMOQle5X6ROWI4j6WK9riYhh4iCtRaCMsLcmSAuaXg==",
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.0.tgz",
|
||||
"integrity": "sha512-c07lfMdEv/KL6lYC6mfap1CcmIPbvhCZu1supnFaIIrlUaab8gVNDYl8wMMjNRdYOVxxXKisU48yyfe5qvlwqg==",
|
||||
"dependencies": {
|
||||
"@nanostores/react": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -1591,12 +1591,12 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict):
|
||||
_emit(
|
||||
"tool.start",
|
||||
sid,
|
||||
{"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)},
|
||||
{"tool_id": tool_call_id, "name": name, "args": args, "context": _tool_ctx(name, args)},
|
||||
)
|
||||
|
||||
|
||||
def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str):
|
||||
payload = {"tool_id": tool_call_id, "name": name}
|
||||
payload = {"tool_id": tool_call_id, "name": name, "args": args}
|
||||
session = _sessions.get(sid)
|
||||
snapshot = None
|
||||
started_at = None
|
||||
@@ -1606,6 +1606,10 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result
|
||||
duration_s = time.time() - started_at if started_at else None
|
||||
if duration_s is not None:
|
||||
payload["duration_s"] = duration_s
|
||||
try:
|
||||
payload["result"] = json.loads(result)
|
||||
except Exception:
|
||||
payload["result"] = result
|
||||
summary = _tool_summary(name, result, duration_s)
|
||||
if summary:
|
||||
payload["summary"] = summary
|
||||
@@ -1645,7 +1649,9 @@ def _on_tool_progress(
|
||||
if not _tool_progress_enabled(sid):
|
||||
return
|
||||
if event_type == "tool.started" and name:
|
||||
_emit("tool.progress", sid, {"name": name, "preview": preview or ""})
|
||||
# `_on_tool_start` already emits the authoritative `tool.start` with
|
||||
# the stable tool id and args. Emitting another id-less progress row
|
||||
# here makes the desktop live view diverge from hydrated history.
|
||||
return
|
||||
if event_type == "reasoning.available" and preview:
|
||||
_emit("reasoning.available", sid, {"text": str(preview)})
|
||||
|
||||
Reference in New Issue
Block a user