From a91faf90f074fedb7d24bf0ac3c22a177cddc95a Mon Sep 17 00:00:00 2001 From: Om Gupta Date: Fri, 1 May 2026 04:04:30 +0530 Subject: [PATCH] fix(attachments): persist images on disk + lazy-load chips on demand Image attachments now round-trip through session reload: history payloads carry only metadata, the renderer fetches bytes via a request_attachment WS round-trip, and a three-tier cache (in-memory blob URL, IndexedDB, WS) keeps repeated views cheap. Also fixes a project-scoped loader that called hydrateSessionContent on a whole message instead of hydrateSessionMessage, adds atomic image writes + a traversal-guarded resolveSessionImagePath, introduces user-initiated session rename with optimistic UI, and adds the first vitest suite covering the round-trip. Co-Authored-By: Claude Opus 4.7 --- package.json | 7 +- packages/agent-config/src/attachments.test.ts | 170 ++++++++++ packages/agent-config/src/config.ts | 74 +++- packages/agent-config/tsconfig.json | 3 +- packages/agent-core/src/session.ts | 39 ++- packages/agent-server/src/server.ts | 150 +++++++++ packages/desktop/src/components/Sidebar.tsx | 145 ++++++-- .../src/components/chat/MessageBubble.tsx | 137 ++++++-- .../src/components/chat/MessageList.tsx | 8 +- .../components/chat/ProviderSettingsModal.tsx | 36 +- .../components/customize/CustomizeView.tsx | 29 +- .../src/components/home/TasksListView.tsx | 166 +++++++-- packages/desktop/src/index.css | 134 +++++++- .../desktop/src/lib/attachmentDiskCache.ts | 195 +++++++++++ packages/desktop/src/lib/attachments.ts | 309 +++++++++++++++++ packages/desktop/src/lib/connection.ts | 10 + packages/desktop/src/lib/store.ts | 16 + .../desktop/src/lib/store/sessionStore.ts | 2 + packages/protocol/src/messages.ts | 38 +++ pnpm-lock.yaml | 318 ++++++++++++++++++ vitest.config.ts | 9 + 21 files changed, 1801 insertions(+), 194 deletions(-) create mode 100644 packages/agent-config/src/attachments.test.ts create mode 100644 packages/desktop/src/lib/attachmentDiskCache.ts create mode 100644 packages/desktop/src/lib/attachments.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index 4667b67d..8462b62c 100644 --- a/package.json +++ b/package.json @@ -24,14 +24,17 @@ "typecheck:cli": "pnpm --filter @anton/cli typecheck", "typecheck:desktop": "pnpm --filter @anton/desktop typecheck", "typecheck:protocol": "pnpm --filter @anton/protocol typecheck", - "verify": "pnpm typecheck && pnpm check", + "test": "vitest run", + "test:watch": "vitest", + "verify": "pnpm typecheck && pnpm check && pnpm test", "deploy": "./deploy/sync.sh", "dev": "pnpm protocol:build && concurrently \"pnpm agent:dev\" \"pnpm desktop:dev\"", "dev:local": "ANTON_LOCAL=1 pnpm dev" }, "devDependencies": { "@biomejs/biome": "^1.9.0", - "esbuild": "^0.27.4" + "esbuild": "^0.27.4", + "vitest": "^3" }, "keywords": [ "ai", diff --git a/packages/agent-config/src/attachments.test.ts b/packages/agent-config/src/attachments.test.ts new file mode 100644 index 00000000..34b63618 --- /dev/null +++ b/packages/agent-config/src/attachments.test.ts @@ -0,0 +1,170 @@ +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' + +// Pin ANTON_DIR to a freshly-created temp dir BEFORE importing config.ts — +// the module reads the env var once at load time. afterAll wipes the dir. +const TEMP_ROOT = mkdtempSync(join(tmpdir(), 'anton-test-')) +process.env.ANTON_DIR = TEMP_ROOT + +const { loadSession, resolveSessionImagePath, saveSession } = await import('./config.js') +type ConfigModule = typeof import('./config.js') +type PersistedSession = Parameters[0] + +afterAll(() => { + try { + rmSync(TEMP_ROOT, { recursive: true, force: true }) + } catch { + /* ignore */ + } +}) + +describe('resolveSessionImagePath', () => { + it('rejects empty / non-string / null-byte input', () => { + expect(resolveSessionImagePath('s', '')).toBeNull() + expect(resolveSessionImagePath('s', '\0images/foo.png')).toBeNull() + expect(resolveSessionImagePath('s', 'images/foo\0.png')).toBeNull() + }) + + it('rejects paths that do not start with images/', () => { + expect(resolveSessionImagePath('s', 'meta.json')).toBeNull() + expect(resolveSessionImagePath('s', '../etc/passwd')).toBeNull() + expect(resolveSessionImagePath('s', '/etc/passwd')).toBeNull() + }) + + it('rejects traversal attempts that escape the images dir', () => { + expect(resolveSessionImagePath('s', 'images/../../etc/passwd')).toBeNull() + expect(resolveSessionImagePath('s', 'images/../../../root/.ssh/id_rsa')).toBeNull() + expect(resolveSessionImagePath('s', 'images/../meta.json')).toBeNull() + }) + + it('accepts well-formed image paths', () => { + const ok = resolveSessionImagePath('sess-1', 'images/0001-01-image.png') + expect(ok).not.toBeNull() + expect(ok).toContain('sess-1') + expect(ok).toContain('images') + }) + + it('normalizes backslashes from windows-style input', () => { + const ok = resolveSessionImagePath('sess-2', 'images\\nested\\foo.png') + expect(ok).not.toBeNull() + }) + + it('rejects malformed sessionIds', () => { + // Empty / dot-segments / path-separator-bearing IDs would let a + // crafted request escape the per-session sandbox. + expect(resolveSessionImagePath('', 'images/foo.png')).toBeNull() + expect(resolveSessionImagePath('.', 'images/foo.png')).toBeNull() + expect(resolveSessionImagePath('..', 'images/foo.png')).toBeNull() + expect(resolveSessionImagePath('a/b', 'images/foo.png')).toBeNull() + expect(resolveSessionImagePath('a\\b', 'images/foo.png')).toBeNull() + expect(resolveSessionImagePath('a\0b', 'images/foo.png')).toBeNull() + }) +}) + +// 1×1 transparent PNG +const PNG_BYTES = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4DwABBQECzr1vQwAAAABJRU5ErkJggg==', + 'base64', +) + +function makeUserMessage() { + return { + role: 'user', + content: [ + { type: 'text', text: 'see this' }, + { + type: 'image', + mimeType: 'image/png', + data: PNG_BYTES.toString('base64'), + name: 'pixel.png', + sizeBytes: PNG_BYTES.byteLength, + }, + ], + timestamp: 1, + } +} + +function persistedSession(id: string): PersistedSession { + return { + id, + provider: 'anthropic', + model: 'claude-opus-4', + title: 'test', + createdAt: 1, + lastActiveAt: 2, + messages: [makeUserMessage()], + } satisfies PersistedSession +} + +type ImageBlock = { + type: 'image' + mimeType?: string + data?: string + storagePath?: string + name?: string + sizeBytes?: number +} + +function findImageBlock(loaded: ReturnType): ImageBlock { + expect(loaded).not.toBeNull() + const userMsg = loaded?.messages[0] as { role: string; content: ImageBlock[] } + expect(userMsg.role).toBe('user') + const imgBlock = userMsg.content.find((b) => b.type === 'image') + expect(imgBlock).toBeDefined() + return imgBlock as ImageBlock +} + +describe('saveSession + loadSession round-trip with image attachments', () => { + it('global session: persists image bytes to disk and rehydrates data on load', () => { + const id = 'sess-global' + saveSession(persistedSession(id)) + + const imgBlock = findImageBlock(loadSession(id)) + expect(imgBlock.data).toBe(PNG_BYTES.toString('base64')) + expect(imgBlock.storagePath).toMatch(/^images\//) + expect(imgBlock.mimeType).toBe('image/png') + }) + + it('project (basePath) session: rehydrates image data via hydrateSessionMessage, not hydrateSessionContent', () => { + // Regression: the project-scoped loader previously called + // hydrateSessionContent on a whole message object instead of + // hydrateSessionMessage. hydrateSessionContent expects content (an + // array) and returned undefined for objects, so messages reached + // consumers with only storagePath and no `data` after a reload — + // chips disappeared from "My Computer" sessions despite the bytes + // being safely on disk. + const id = 'sess-project' + const projectDir = join(TEMP_ROOT, 'projects/proj-1/conversations') + mkdirSync(projectDir, { recursive: true }) + saveSession(persistedSession(id), projectDir) + + const imgBlock = findImageBlock(loadSession(id, projectDir)) + expect(imgBlock.data).toBe(PNG_BYTES.toString('base64')) + }) + + it('gracefully degrades when the image file on disk is missing', () => { + const id = 'sess-missing-file' + saveSession(persistedSession(id)) + + rmSync(join(TEMP_ROOT, 'conversations', id, 'images'), { recursive: true, force: true }) + + const imgBlock = findImageBlock(loadSession(id)) + expect(imgBlock.storagePath).toMatch(/^images\//) + expect(imgBlock.data).toBeUndefined() + }) + + it('writes images atomically (no .tmp residue on success)', () => { + const id = 'sess-atomic' + saveSession(persistedSession(id)) + + const imagesDir = join(TEMP_ROOT, 'conversations', id, 'images') + const entries = readdirSync(imagesDir) + const pngs = entries.filter((f) => f.endsWith('.png')) + expect(pngs).toHaveLength(1) + const written = readFileSync(join(imagesDir, pngs[0])) + expect(written.byteLength).toBe(PNG_BYTES.byteLength) + expect(entries.some((f) => f.endsWith('.tmp'))).toBe(false) + }) +}) diff --git a/packages/agent-config/src/config.ts b/packages/agent-config/src/config.ts index f289f235..4eaecc1b 100644 --- a/packages/agent-config/src/config.ts +++ b/packages/agent-config/src/config.ts @@ -5,12 +5,13 @@ import { mkdirSync, readFileSync, readdirSync, + renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs' import { homedir, hostname } from 'node:os' -import { dirname, join } from 'node:path' +import { dirname, join, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' import { copyImageToWorkspace } from './image-storage.js' @@ -306,7 +307,12 @@ export interface ConnectorConfig { // ── Paths ─────────────────────────────────────────────────────────── -const ANTON_DIR = join(homedir(), '.anton') +// `ANTON_DIR` env var lets tests (and unusual deployments) point persistence +// at a non-default root without monkey-patching homedir(). +const ANTON_DIR = + typeof process.env.ANTON_DIR === 'string' && process.env.ANTON_DIR.length > 0 + ? process.env.ANTON_DIR + : join(homedir(), '.anton') const CONFIG_PATH = join(ANTON_DIR, 'config.yaml') const ENV_FILE_PATH = join(ANTON_DIR, 'agent.env') const CONVERSATIONS_DIR = join(ANTON_DIR, 'conversations') @@ -351,7 +357,7 @@ export const DEFAULT_PROVIDERS: ProvidersMap = { }, openai: { apiKey: process.env.OPENAI_API_KEY || '', - models: ['gpt-4o'], + models: ['gpt-5.5', 'gpt-5.4', 'gpt-4o'], }, google: { apiKey: process.env.GOOGLE_API_KEY || '', @@ -800,6 +806,31 @@ function sessionImagesDir(id: string): string { return join(sessionDir(id), 'images') } +/** + * Resolve a session-relative attachment storagePath to an absolute file path, + * with traversal protection. Returns null for any malformed or escaping input. + * + * Why: both inputs come from the client and are read directly off disk — + * without this guard, `images/../../foo` (or `..` / empty-string sessionId) + * would let an attacker reach files outside the session's images dir. + */ +export function resolveSessionImagePath(sessionId: string, storagePath: string): string | null { + // sessionId guard — must be a non-empty string with no path syntax. + if (typeof sessionId !== 'string' || sessionId.length === 0) return null + if (sessionId.includes('\0') || sessionId.includes('/') || sessionId.includes('\\')) return null + if (sessionId === '.' || sessionId === '..') return null + + if (typeof storagePath !== 'string' || storagePath.length === 0) return null + if (storagePath.includes('\0')) return null + const normalized = storagePath.replaceAll('\\', '/') + if (!normalized.startsWith('images/')) return null + + const imagesBase = resolve(sessionImagesDir(sessionId)) + const resolved = resolve(sessionDir(sessionId), normalized) + if (resolved !== imagesBase && !resolved.startsWith(imagesBase + sep)) return null + return resolved +} + function clearSessionImages(id: string): void { const dir = sessionImagesDir(id) if (!existsSync(dir)) return @@ -888,8 +919,22 @@ function serializeSessionContent( const absolutePath = join(sessionDir(sessionId), relativePath) const imageBuffer = Buffer.from(value.data, 'base64') - // Always write to session images dir (safety net) - writeFileSync(absolutePath, imageBuffer) + // Atomic write: tmp + rename. A crash mid-write could otherwise leave + // a zero-byte file at the final path that hydrateSessionContent would + // try to load as a real image. + const tmpPath = `${absolutePath}.${randomBytes(6).toString('hex')}.tmp` + writeFileSync(tmpPath, imageBuffer) + try { + renameSync(tmpPath, absolutePath) + } catch { + // Cleanup on rename failure (e.g. cross-device); fall back to direct write. + try { + unlinkSync(tmpPath) + } catch { + /* ignore */ + } + writeFileSync(absolutePath, imageBuffer) + } // Dual-write to workspace .uploads/ if available const sanitizedName = @@ -1103,10 +1148,7 @@ export function loadSession(id: string, basePath?: string): PersistedSession | n .trim() .split('\n') .filter(Boolean) - .map((l) => { - const parsed = JSON.parse(l) - return hydrateSessionContent(id, parsed) || parsed - }) + .map((l) => hydrateSessionMessage(id, JSON.parse(l) as SessionMessage)) : [] let compactionState: PersistedSession['compactionState'] | undefined const compPath = join(dir, 'compaction.json') @@ -1294,6 +1336,20 @@ export function archiveSession(id: string): boolean { return true } +/** + * Re-publish an existing session's meta into the global sync index. + * + * Used when a side-channel writer (e.g. `writeHarnessSessionTitle` after + * a user-initiated rename) updates meta.json directly: that path does + * not pass through `updateIndex`, so connected clients would otherwise + * miss the change until reconnect. No-op for project-scoped sessions + * (their meta lives outside this index). + */ +export function refreshSessionIndex(id: string): void { + const meta = loadSessionMeta(id) + if (meta) updateIndex(meta) +} + /** Clean expired sessions */ export function cleanExpiredSessions(ttlDays = 30): number { const archiveCutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000 diff --git a/packages/agent-config/tsconfig.json b/packages/agent-config/tsconfig.json index cfd14008..a5c661f0 100644 --- a/packages/agent-config/tsconfig.json +++ b/packages/agent-config/tsconfig.json @@ -10,5 +10,6 @@ "esModuleInterop": true, "skipLibCheck": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] } diff --git a/packages/agent-core/src/session.ts b/packages/agent-core/src/session.ts index b971fbd1..48ebd7bf 100644 --- a/packages/agent-core/src/session.ts +++ b/packages/agent-core/src/session.ts @@ -1553,32 +1553,35 @@ export class Session { messageIndex: number, ): SessionImageAttachment[] | undefined => { if (!Array.isArray(content)) return undefined + // History payloads only carry metadata. The renderer fetches bytes + // on demand via the request_attachment WS message, so we never + // serialize base64 data into history responses — that kept ~1MB per + // image resident on the wire and in client memory for every page. + // The LLM-facing path (piAgent.state.messages) still carries `data` + // because it's hydrated at resume time directly from disk. const attachments = content .filter( (block) => block.type === 'image' && typeof block.mimeType === 'string' && - typeof block.data === 'string', + (typeof block.data === 'string' || typeof block.storagePath === 'string'), ) - .map((block, index) => ({ - id: + .map((block, index) => { + const storagePath = typeof block.storagePath === 'string' ? block.storagePath - : inferStoragePath(messageIndex, index, block), - name: - typeof block.name === 'string' - ? block.name - : typeof block.storagePath === 'string' - ? block.storagePath.split('/').pop() || `image-${index + 1}` - : `image-${index + 1}`, - mimeType: block.mimeType!, - storagePath: - typeof block.storagePath === 'string' - ? block.storagePath - : inferStoragePath(messageIndex, index, block), - sizeBytes: typeof block.sizeBytes === 'number' ? block.sizeBytes : 0, - data: block.data, - })) + : inferStoragePath(messageIndex, index, block) + return { + id: storagePath, + name: + typeof block.name === 'string' && block.name.length > 0 + ? block.name + : storagePath.split('/').pop() || `image-${index + 1}`, + mimeType: block.mimeType!, + storagePath, + sizeBytes: typeof block.sizeBytes === 'number' ? block.sizeBytes : 0, + } + }) return attachments.length > 0 ? attachments : undefined } diff --git a/packages/agent-server/src/server.ts b/packages/agent-server/src/server.ts index 182b0485..ab6cef26 100644 --- a/packages/agent-server/src/server.ts +++ b/packages/agent-server/src/server.ts @@ -21,6 +21,7 @@ import { unlinkSync, writeFileSync, } from 'node:fs' +import { readFile } from 'node:fs/promises' import { createServer as createHttpServer } from 'node:http' import { createServer as createHttpsServer } from 'node:https' import { join, resolve } from 'node:path' @@ -59,7 +60,9 @@ import { loadProjects, loadUserRules, onSyncChange, + refreshSessionIndex, removePublished, + resolveSessionImagePath, saveConfig, saveProjectInstructions, savePublishedMeta, @@ -163,6 +166,23 @@ const log = createLogger('server') const DEFAULT_SESSION_ID = 'default' +const IMAGE_MIME_BY_EXT: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + gif: 'image/gif', + svg: 'image/svg+xml', + bmp: 'image/bmp', + heic: 'image/heic', + heif: 'image/heif', +} + +function mimeTypeFromExt(filePath: string): string { + const ext = filePath.toLowerCase().split('.').pop() || '' + return IMAGE_MIME_BY_EXT[ext] || 'application/octet-stream' +} + /** * Buffers text chunks from the AI stream and flushes them on a timer (~80ms) * or immediately before any non-text event. This coalesces many small token-level @@ -1727,6 +1747,10 @@ export class AgentServer { void this.handleSessionDestroy(msg) break + case 'session_rename': + this.handleSessionRename(msg) + break + case 'session_provider_switch': void this.handleSessionProviderSwitch(msg) break @@ -1735,6 +1759,10 @@ export class AgentServer { this.handleSessionHistory(msg) break + case 'request_attachment': + void this.handleRequestAttachment(msg) + break + case 'session_set_thinking_level': this.handleSessionSetThinkingLevel(msg) break @@ -3184,6 +3212,73 @@ export class AgentServer { log.info({ sessionId: msg.id, idReused }, 'Session destroyed') } + /** + * User-initiated rename. Persists the new title to meta.json, + * refreshes the global sync index for cross-restart durability, and + * pushes a `title_update` so live clients update immediately. + * + * Mirrors the auto-titling flow used by `set_session_title`: in-memory + * `setTitle` (when the session is loaded), `writeHarnessSessionTitle` + * for the on-disk write, and a broadcast `title_update` event. + */ + private handleSessionRename(msg: { id: string; title: string }) { + const trimmed = (msg.title || '').trim().slice(0, 200) + if (!trimmed) return + + // Resolve project scope before touching any state. peek() avoids + // resurrecting a torn-down session. + const session = this.sessions.peek(msg.id) + const projectId = + (session && !isHarnessSession(session) ? session.contextInfo?.projectId : undefined) ?? + this.extractProjectId(msg.id) + + // Update the live session's in-memory title so the next turn's + // prompts and persisted mirror lines see the user's chosen value. + // Both HarnessSession and CodexHarnessSession expose setTitle. + if (session) { + const s = session as { setTitle?: (t: string) => void } + if (typeof s.setTitle === 'function') { + try { + s.setTitle(trimmed) + } catch (err) { + log.warn({ err, sessionId: msg.id }, 'live setTitle failed during rename') + } + } + } + + // Persist to meta.json. Same writer as set_session_title — handles + // both project-scoped and global session paths, no-op if meta is + // missing. + try { + writeHarnessSessionTitle({ + sessionId: msg.id, + projectId, + title: trimmed, + }) + } catch (err) { + log.warn({ err, sessionId: msg.id }, 'failed to persist user rename') + return + } + + // Bump the global sync index so reconnecting clients receive the + // new title via the regular session_sync delta path. No-op for + // project-scoped sessions (they don't live in this index). + if (!projectId) { + try { + refreshSessionIndex(msg.id) + } catch (err) { + log.warn({ err, sessionId: msg.id }, 'failed to refresh sync index after rename') + } + } + + // Broadcast to every connected client (matches the auto-title push). + this.sendToClient(Channel.AI, { + type: 'title_update', + sessionId: msg.id, + title: trimmed, + }) + } + private handleSessionHistory(msg: { id: string before?: number @@ -3317,6 +3412,61 @@ export class AgentServer { } } + /** + * Stream image attachment bytes back to the client on demand. + * + * History payloads carry only metadata (id, name, mimeType, storagePath, + * sizeBytes); the renderer fetches the actual bytes through this handler + * when it needs to render a chip thumbnail / hover preview / full viewer. + * Keeps WS payloads small and lets the client cache blob URLs locally. + */ + private async handleRequestAttachment(msg: { + id: string + sessionId: string + storagePath: string + }): Promise { + const sendError = (error: string) => { + this.sendToClient(Channel.AI, { + type: 'attachment_data', + id: msg.id, + error, + }) + } + + const absolutePath = resolveSessionImagePath(msg.sessionId, msg.storagePath) + if (!absolutePath) { + sendError('invalid_path') + return + } + if (!existsSync(absolutePath)) { + sendError('not_found') + return + } + + try { + // Async read so a burst of chip mounts in a long history doesn't + // serialize the event loop on readFileSync calls. + const buffer = await readFile(absolutePath) + this.sendToClient(Channel.AI, { + type: 'attachment_data', + id: msg.id, + mimeType: mimeTypeFromExt(absolutePath), + data: buffer.toString('base64'), + sizeBytes: buffer.byteLength, + }) + } catch (err: unknown) { + log.warn( + { + sessionId: msg.sessionId, + storagePath: msg.storagePath, + error: (err as Error).message, + }, + 'Failed to read attachment from disk', + ) + sendError('read_failed') + } + } + /** * If the session on disk belongs to a harness provider, read its * mirrored messages.jsonl, flatten into SessionHistoryEntry[], and diff --git a/packages/desktop/src/components/Sidebar.tsx b/packages/desktop/src/components/Sidebar.tsx index 7731f378..b3f71267 100644 --- a/packages/desktop/src/components/Sidebar.tsx +++ b/packages/desktop/src/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { Monitor, Network, PanelLeft, + Pencil, Plus, RefreshCw, Search, @@ -57,6 +58,7 @@ export function Sidebar({ onViewChange, onOpenSettings }: Props) { const switchConversation = useStore((s) => s.switchConversation) const newConversation = useStore((s) => s.newConversation) const deleteConversation = useStore((s) => s.deleteConversation) + const renameConversation = useStore((s) => s.renameConversation) const conversations = useStore((s) => s.conversations) const activeConversationId = useStore((s) => s.activeConversationId) const sidebarCollapsed = uiStore((s) => s.sidebarCollapsed) @@ -73,6 +75,11 @@ export function Sidebar({ onViewChange, onOpenSettings }: Props) { const projectMenuRef = useRef(null) const [projectMenuPos, setProjectMenuPos] = useState<{ top: number; left: number } | null>(null) + const [editingTaskId, setEditingTaskId] = useState(null) + const [editingTaskValue, setEditingTaskValue] = useState('') + const editInputRef = useRef(null) + const cancelEditRef = useRef(false) + useLayoutEffect(() => { if (!projectMenuOpen) { setProjectMenuPos(null) @@ -210,6 +217,42 @@ export function Sidebar({ onViewChange, onOpenSettings }: Props) { onViewChange('agent') } + useEffect(() => { + if (editingTaskId && editInputRef.current) { + editInputRef.current.focus() + editInputRef.current.select() + } + }, [editingTaskId]) + + const startEditTask = (id: string, currentTitle: string) => { + cancelEditRef.current = false + setEditingTaskId(id) + setEditingTaskValue(currentTitle) + } + + const cancelEditTask = () => { + cancelEditRef.current = true + setEditingTaskId(null) + setEditingTaskValue('') + } + + const commitEditTask = () => { + if (cancelEditRef.current) { + cancelEditRef.current = false + return + } + const id = editingTaskId + if (!id) return + const trimmed = editingTaskValue.trim() + const original = recentTasks.find((t) => t.id === id)?.title + if (trimmed && trimmed !== original) { + // Optimistic local update + WebSocket persist to the server. + renameConversation(id, trimmed) + } + setEditingTaskId(null) + setEditingTaskValue('') + } + const sidebarWidth = sidebarCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)' return ( @@ -426,34 +469,82 @@ export function Sidebar({ onViewChange, onOpenSettings }: Props) { {recentTasks.length === 0 ? (
No tasks yet
) : ( - recentTasks.map((t) => ( -
- - -
- )) + {isEditing ? ( +
+ + setEditingTaskValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + editInputRef.current?.blur() + } else if (e.key === 'Escape') { + e.preventDefault() + cancelEditTask() + } + }} + onBlur={commitEditTask} + maxLength={120} + aria-label="Rename task" + /> +
+ ) : ( + + )} + {!isEditing && ( + <> + + + + )} + + ) + }) )} diff --git a/packages/desktop/src/components/chat/MessageBubble.tsx b/packages/desktop/src/components/chat/MessageBubble.tsx index a85511dd..60d86a81 100644 --- a/packages/desktop/src/components/chat/MessageBubble.tsx +++ b/packages/desktop/src/components/chat/MessageBubble.tsx @@ -1,4 +1,4 @@ -import { motion } from 'framer-motion' +import { AnimatePresence, motion } from 'framer-motion' import { AlertTriangle, File as FileIcon, @@ -7,9 +7,12 @@ import { FileText, Folder, Image as ImageIcon, + ImageOff, + Loader2, } from 'lucide-react' import { useMemo, useState } from 'react' import { classifyUpload } from '../../lib/artifacts.js' +import { useAttachmentBlobUrl } from '../../lib/attachments.js' import type { ChatImageAttachment, CitationSource } from '../../lib/store.js' import { type ChatMessage, useStore } from '../../lib/store.js' import { artifactStore } from '../../lib/store/artifactStore.js' @@ -23,11 +26,81 @@ import { ToolCallBlock } from './ToolCallBlock.js' interface Props { message: ChatMessage + sessionId?: string isLastThinking?: boolean } -function attachmentSrc(attachment: ChatImageAttachment): string | undefined { - return attachment.data ? `data:${attachment.mimeType};base64,${attachment.data}` : undefined +interface ImageAttachmentChipProps { + attachment: ChatImageAttachment + sessionId: string | undefined + onOpen: (src: string, alt: string) => void +} + +function ImageAttachmentChip({ attachment, sessionId, onOpen }: ImageAttachmentChipProps) { + const [hover, setHover] = useState(false) + const { url, loading, error } = useAttachmentBlobUrl( + sessionId, + attachment.storagePath, + attachment.mimeType, + attachment.data, + ) + + const leadingIcon = url ? ( + {attachment.name} + ) : loading ? ( + + ) : error ? ( + + ) : ( + + ) + + const title = error ? `Image unavailable (${error})` : attachment.name + const buttonClass = error + ? 'message__image-chip message__image-chip--error' + : 'message__image-chip' + + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + + + {hover && url && ( + + {attachment.name} + + )} + + + ) } // Matches [img:id], [file:path], [dir:path] — three marker kinds the composer @@ -92,15 +165,20 @@ function navigateToFolder(relPath: string) { } /** Render user message content with inline chips for image / file / dir markers. */ -function UserMessageContent({ message }: { message: ChatMessage }) { +function UserMessageContent({ + message, + sessionId, +}: { + message: ChatMessage + sessionId: string | undefined +}) { const [viewerImage, setViewerImage] = useState<{ src: string; alt: string } | null>(null) + const openViewer = (src: string, alt: string) => setViewerImage({ src, alt }) const attachments = message.attachments const content = message.content - const attachmentMap = new Map( - (attachments ?? []).map((a) => [a.id, { attachment: a, src: attachmentSrc(a) }]), - ) + const attachmentMap = new Map((attachments ?? []).map((a) => [a.id, a])) // Scan for any marker kind. If there are none AND no attachments, it's // pure text. If there are legacy attachments without markers, the old @@ -118,20 +196,14 @@ function UserMessageContent({ message }: { message: ChatMessage }) { <> {content &&
{content}
}
- {attachments.map((attachment) => { - const src = attachmentMap.get(attachment.id)?.src - return ( - - ) - })} + {attachments.map((attachment) => ( + + ))}
{viewerImage && ( - entry.src && setViewerImage({ src: entry.src, alt: entry.attachment.name }) - } - > - - {entry.attachment.name} - , + attachment={attachment} + sessionId={sessionId} + onOpen={openViewer} + />, ) } } else if (kind === 'file') { @@ -230,7 +297,7 @@ function UserMessageContent({ message }: { message: ChatMessage }) { ) } -export function MessageBubble({ message, isLastThinking }: Props) { +export function MessageBubble({ message, sessionId, isLastThinking }: Props) { const citations = useStore((s) => s.citations.get(message.id)) // For assistant final answers, the model may reference citations across // multiple web_search calls in the turn — but only the most-recent batch @@ -267,7 +334,7 @@ export function MessageBubble({ message, isLastThinking }: Props) { > {message.role === 'user' && (
- +
)} diff --git a/packages/desktop/src/components/chat/MessageList.tsx b/packages/desktop/src/components/chat/MessageList.tsx index 910be211..36c0f8fa 100644 --- a/packages/desktop/src/components/chat/MessageList.tsx +++ b/packages/desktop/src/components/chat/MessageList.tsx @@ -269,7 +269,13 @@ export function MessageList({ messages }: Props) { } const { item } = entry if (item.type === 'message') { - return + return ( + + ) } if (item.type === 'task_section') { return ( diff --git a/packages/desktop/src/components/chat/ProviderSettingsModal.tsx b/packages/desktop/src/components/chat/ProviderSettingsModal.tsx index 26e72d0e..80f90839 100644 --- a/packages/desktop/src/components/chat/ProviderSettingsModal.tsx +++ b/packages/desktop/src/components/chat/ProviderSettingsModal.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, Check, ChevronDown, Plus, RotateCcw, X } from 'lucide-react' +import { ArrowRight, Check, ChevronDown, RotateCcw, X } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ProviderInfo } from '../../lib/store.js' import { sessionStore } from '../../lib/store/sessionStore.js' @@ -19,9 +19,7 @@ export function ProviderSettingsModal({ provider, onClose }: Props) { const [apiKey, setApiKey] = useState('') const [keySaved, setKeySaved] = useState(false) const [models, setModels] = useState([]) - const [newModel, setNewModel] = useState('') const [modelsOpen, setModelsOpen] = useState(false) - const addInputRef = useRef(null) const relistTimer = useRef | null>(null) const savedFlagTimer = useRef | null>(null) @@ -43,7 +41,6 @@ export function ProviderSettingsModal({ provider, onClose }: Props) { setModels([...provider.models]) setApiKey('') setKeySaved(false) - setNewModel('') setModelsOpen(false) } }, [providerName]) @@ -85,8 +82,6 @@ export function ProviderSettingsModal({ provider, onClose }: Props) { const icon = providerIcons[live.name] const label = providerDisplayName(live.name) const connected = live.hasApiKey || keySaved - const trimmedNew = newModel.trim() - const canAdd = trimmedNew.length > 0 && !models.includes(trimmedNew) const saveKey = (e: React.FormEvent) => { e.preventDefault() @@ -103,14 +98,6 @@ export function ProviderSettingsModal({ provider, onClose }: Props) { }, 1800) } - const addModel = (e: React.FormEvent) => { - e.preventDefault() - if (!canAdd) return - commitModels([...models, trimmedNew]) - setNewModel('') - addInputRef.current?.focus() - } - const removeModel = (id: string) => { commitModels(models.filter((m) => m !== id)) } @@ -201,9 +188,7 @@ export function ProviderSettingsModal({ provider, onClose }: Props) {
    {models.length === 0 && ( -
  • - No models yet. Add one below or reset to defaults. -
  • +
  • No models yet. Reset to defaults to restore them.
  • )} {models.map((m) => { const tag = classifyModelTag(m) @@ -229,23 +214,6 @@ export function ProviderSettingsModal({ provider, onClose }: Props) { })}
-
-
) } diff --git a/packages/desktop/src/components/home/TasksListView.tsx b/packages/desktop/src/components/home/TasksListView.tsx index 8c5c734e..a71ca281 100644 --- a/packages/desktop/src/components/home/TasksListView.tsx +++ b/packages/desktop/src/components/home/TasksListView.tsx @@ -1,5 +1,5 @@ -import { Pause, Search, Trash2 } from 'lucide-react' -import { useMemo, useState } from 'react' +import { Pause, Pencil, Search, Trash2 } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' import { sanitizeTitle } from '../../lib/conversations.js' import type { ChatImageAttachment } from '../../lib/store.js' import { useStore } from '../../lib/store.js' @@ -26,12 +26,17 @@ export function TasksListView() { const activeProjectId = projectStore((s) => s.activeProjectId) const switchConversation = useStore((s) => s.switchConversation) const deleteConversation = useStore((s) => s.deleteConversation) + const renameConversation = useStore((s) => s.renameConversation) const setActiveView = uiStore((s) => s.setActiveView) const sessionStates = sessionStore((s) => s.sessionStates) const sendCancelTurn = sessionStore((s) => s.sendCancelTurn) const newConversation = useStore((s) => s.newConversation) const [query, setQuery] = useState('') const [showSearch, setShowSearch] = useState(false) + const [editingId, setEditingId] = useState(null) + const [editingValue, setEditingValue] = useState('') + const editInputRef = useRef(null) + const cancelEditRef = useRef(false) const rows = useMemo(() => { const inProject = allConversations.filter( @@ -70,6 +75,42 @@ export function TasksListView() { setActiveView('chat') } + useEffect(() => { + if (editingId && editInputRef.current) { + editInputRef.current.focus() + editInputRef.current.select() + } + }, [editingId]) + + const startEdit = (id: string, currentTitle: string) => { + cancelEditRef.current = false + setEditingId(id) + setEditingValue(currentTitle) + } + + const cancelEdit = () => { + cancelEditRef.current = true + setEditingId(null) + setEditingValue('') + } + + const commitEdit = () => { + if (cancelEditRef.current) { + cancelEditRef.current = false + return + } + const id = editingId + if (!id) return + const trimmed = editingValue.trim() + const original = rows.find((r) => r.id === id)?.title + if (trimmed && trimmed !== original) { + // Optimistic local update + WebSocket persist to the server. + renameConversation(id, trimmed) + } + setEditingId(null) + setEditingValue('') + } + const startNewTask = (text: string, attachments?: ChatImageAttachment[]) => { const sessionId = `sess_${Date.now().toString(36)}` const ps = projectStore.getState() @@ -144,49 +185,102 @@ export function TasksListView() { ) : ( rows.map((r) => { const working = r.status === 'working' + const isEditing = editingId === r.id return ( -
- - {formatRelative(r.updatedAt)} -
- {working && r.sessionId && ( +
+ {isEditing ? ( +
+ + setEditingValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + editInputRef.current?.blur() + } else if (e.key === 'Escape') { + e.preventDefault() + cancelEdit() + } + }} + onBlur={commitEdit} + maxLength={120} + aria-label="Rename task" + /> +
+ ) : ( + + )} + {!isEditing && ( + {formatRelative(r.updatedAt)} + )} + {!isEditing && ( +
+ {working && r.sessionId && ( + + )} - )} - -
+ +
+ )}
) }) diff --git a/packages/desktop/src/index.css b/packages/desktop/src/index.css index f74cac72..e7729623 100644 --- a/packages/desktop/src/index.css +++ b/packages/desktop/src/index.css @@ -796,12 +796,12 @@ button { min-width: 0; flex: 1; } -.sb-history-close { +.sb-history-close, +.sb-history-edit { display: flex; align-items: center; justify-content: center; width: 22px; - margin-right: 4px; border: 0; background: transparent; border-radius: 4px; @@ -810,14 +810,46 @@ button { opacity: 0; transition: opacity 0.1s, background 0.1s, color 0.1s; } +.sb-history-edit { + margin-right: 1px; +} +.sb-history-close { + margin-right: 4px; +} .sb-history-item:hover .sb-history-close, -.sb-history-item:focus-within .sb-history-close { +.sb-history-item:hover .sb-history-edit, +.sb-history-item:focus-within .sb-history-close, +.sb-history-item:focus-within .sb-history-edit { opacity: 1; } -.sb-history-close:hover { +.sb-history-close:hover, +.sb-history-edit:hover { background: var(--bg-elev-3); color: var(--text); } +.sb-history-row--editing { + cursor: text; +} +.sb-history-edit-input { + flex: 1; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--text); + font: inherit; + padding: 0; + margin: 0; + caret-color: var(--accent); +} +.sb-history-edit-input::selection { + background: var(--accent-dim); + color: var(--text); +} +.sb-history-item.editing { + background: var(--bg-elev-2); + box-shadow: inset 0 0 0 1px var(--accent-line); +} .sb-history-dot { width: 6px; height: 6px; @@ -3302,6 +3334,36 @@ button { background: var(--bg-elev-2); color: var(--text); } +.tasks-row--editing { + background: var(--bg-elev-1); + box-shadow: inset 0 0 0 1px var(--accent-line); +} +.tasks-row--editing:hover { + background: var(--bg-elev-1); +} +.tasks-row__main--editing { + cursor: text; +} +.tasks-row__edit-input { + grid-column: 2 / -1; + min-width: 0; + width: 100%; + border: 0; + outline: 0; + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 400; + letter-spacing: -0.005em; + padding: 0; + margin: 0; + caret-color: var(--accent); +} +.tasks-row__edit-input::selection { + background: var(--accent-dim); + color: var(--text); +} .tasks-empty { padding: 40px 20px; text-align: center; @@ -3552,6 +3614,70 @@ button { line-height: 1; } +.message__image-chip-thumb { + width: 18px; + height: 18px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} + +.message__image-chip-icon--spin { + color: var(--text-secondary, rgba(255, 255, 255, 0.55)); + animation: message-chip-spin 0.9s linear infinite; +} + +.message__image-chip-icon--error { + color: #d05656; +} + +.message__image-chip--error { + background: rgba(208, 86, 86, 0.08); + border-color: rgba(208, 86, 86, 0.24); + cursor: not-allowed; +} + +.message__image-chip:disabled { + cursor: default; +} + +@keyframes message-chip-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.message__image-chip-wrap { + position: relative; + display: inline-flex; + vertical-align: middle; +} + +.message__image-chip-preview { + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + z-index: 50; + pointer-events: none; + background: var(--bg-elevated); + border: 1px solid rgba(var(--overlay), 0.18); + border-radius: 10px; + padding: 6px; + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.34); +} + +.message__image-chip-preview img { + display: block; + max-width: 256px; + max-height: 256px; + border-radius: 6px; + object-fit: contain; +} + /* ── File / folder chip inside user message bubble ─────────────────── */ .message__file-chip { all: unset; diff --git a/packages/desktop/src/lib/attachmentDiskCache.ts b/packages/desktop/src/lib/attachmentDiskCache.ts new file mode 100644 index 00000000..0e3cbc24 --- /dev/null +++ b/packages/desktop/src/lib/attachmentDiskCache.ts @@ -0,0 +1,195 @@ +/** + * IndexedDB-backed disk cache for attachment bytes. + * + * Survives app restarts so we don't refetch every visible image from the + * agent-server on each launch — important for users running with a remote + * server over a slow link. Keyed by `${sessionId}:${storagePath}`, which + * is content-stable so a hit guarantees the bytes are still correct. + * + * Stores ArrayBuffer + mimeType (not Blob directly) — older WebKit / + * WKWebView builds had quirks with Blob round-trips through IndexedDB, + * and ArrayBuffer is universally well-supported. + * + * Size accounting is kept in memory and seeded once on first open. We + * never re-scan the full store on each put — that turned the cache into + * an O(N²) hot path under sustained image fetches. + */ + +const DB_NAME = 'anton-attachments' +const STORE = 'blobs' +const DB_VERSION = 1 +const SOFT_CAP_BYTES = 256 * 1024 * 1024 +const ACCESS_INDEX = 'lastAccess' + +export type DiskRecord = { + key: string + buffer: ArrayBuffer + mimeType: string + sizeBytes: number + lastAccess: number +} + +let dbPromise: Promise | null = null + +/** Running total of bytes in the store. Seeded once on first open from + * the existing records, then maintained incrementally on put/delete. + * Approximate during the seed window; converges within milliseconds. */ +let totalBytesOnDisk = 0 +let totalSeeded = false +let seedPromise: Promise | null = null + +function openDb(): Promise { + if (dbPromise) return dbPromise + if (typeof indexedDB === 'undefined') { + dbPromise = Promise.resolve(null) + return dbPromise + } + dbPromise = new Promise((resolve) => { + let req: IDBOpenDBRequest + try { + req = indexedDB.open(DB_NAME, DB_VERSION) + } catch { + resolve(null) + return + } + req.onupgradeneeded = () => { + const db = req.result + if (!db.objectStoreNames.contains(STORE)) { + const store = db.createObjectStore(STORE, { keyPath: 'key' }) + store.createIndex(ACCESS_INDEX, 'lastAccess') + } + } + req.onsuccess = () => resolve(req.result) + req.onerror = () => resolve(null) + req.onblocked = () => resolve(null) + }) + return dbPromise +} + +function seedTotalBytes(): Promise { + if (totalSeeded) return Promise.resolve() + if (seedPromise) return seedPromise + seedPromise = openDb().then( + (db) => + new Promise((resolve) => { + if (!db) { + totalSeeded = true + resolve() + return + } + try { + const transaction = db.transaction(STORE, 'readonly') + const store = transaction.objectStore(STORE) + const cursorReq = store.openCursor() + let total = 0 + cursorReq.onsuccess = () => { + const cursor = cursorReq.result + if (!cursor) return + const v = cursor.value as DiskRecord + total += v.sizeBytes + cursor.continue() + } + transaction.oncomplete = () => { + totalBytesOnDisk = total + totalSeeded = true + resolve() + } + transaction.onerror = () => { + totalSeeded = true + resolve() + } + transaction.onabort = () => { + totalSeeded = true + resolve() + } + } catch { + totalSeeded = true + resolve() + } + }), + ) + return seedPromise +} + +function tx( + mode: IDBTransactionMode, + work: (store: IDBObjectStore) => IDBRequest, +): Promise { + return openDb().then((db) => { + if (!db) return null + return new Promise((resolve) => { + let result: T | null = null + try { + const transaction = db.transaction(STORE, mode) + const store = transaction.objectStore(STORE) + const request = work(store) + request.onsuccess = () => { + result = request.result + } + transaction.oncomplete = () => resolve(result) + transaction.onerror = () => resolve(null) + transaction.onabort = () => resolve(null) + } catch { + resolve(null) + } + }) + }) +} + +export async function diskGet(key: string): Promise { + const record = await tx( + 'readonly', + (store) => store.get(key) as IDBRequest, + ) + if (!record) return null + // Touch lastAccess in the background so disk-side LRU reflects reality. + void tx( + 'readwrite', + (store) => store.put({ ...record, lastAccess: Date.now() }) as IDBRequest, + ) + return record +} + +export async function diskPut(record: DiskRecord): Promise { + await seedTotalBytes() + // diskPut may overwrite an existing record. We don't bother subtracting + // the prior size — overwrites are rare (same key = same content for + // content-stable storagePath) and a small over-count just triggers + // eviction slightly earlier than strictly necessary. + await tx('readwrite', (store) => store.put(record) as IDBRequest) + totalBytesOnDisk += record.sizeBytes + if (totalBytesOnDisk > SOFT_CAP_BYTES) { + void evictOldestUntilUnderCap() + } +} + +async function evictOldestUntilUnderCap(): Promise { + const db = await openDb() + if (!db) return + await new Promise((resolve) => { + try { + const transaction = db.transaction(STORE, 'readwrite') + const store = transaction.objectStore(STORE) + const index = store.index(ACCESS_INDEX) + // Walk in oldest-first order. Stop once we're back under the soft cap. + const cursorReq = index.openCursor() + cursorReq.onsuccess = () => { + const cursor = cursorReq.result + if (!cursor) return + if (totalBytesOnDisk <= SOFT_CAP_BYTES * 0.8) { + // Done; let the transaction commit. + return + } + const v = cursor.value as DiskRecord + cursor.delete() + totalBytesOnDisk = Math.max(0, totalBytesOnDisk - v.sizeBytes) + cursor.continue() + } + transaction.oncomplete = () => resolve() + transaction.onerror = () => resolve() + transaction.onabort = () => resolve() + } catch { + resolve() + } + }) +} diff --git a/packages/desktop/src/lib/attachments.ts b/packages/desktop/src/lib/attachments.ts new file mode 100644 index 00000000..7207803c --- /dev/null +++ b/packages/desktop/src/lib/attachments.ts @@ -0,0 +1,309 @@ +/** + * Lazy attachment loader. + * + * History payloads carry only metadata for image attachments — the renderer + * fetches bytes through this module on demand via a `request_attachment` + * WS round-trip. Bytes get wrapped in a Blob URL once and cached, so chip + * thumbnail / hover preview / full viewer all reuse the same object URL. + * + * Three-tier cache: + * 1. In-memory blob URL cache (this module) — fastest, refCounted, LRU. + * 2. IndexedDB disk cache — survives app restart. + * 3. WS request to the agent-server — source of truth. + * + * Pending requests survive WS reconnects: status transitions back to + * 'connected' replay every still-unresolved request with a fresh timeout. + * A page reload was previously the only fix for a mid-flight disconnect. + */ + +import { Channel } from '@anton/protocol' +import { useEffect, useMemo, useState } from 'react' +import { type DiskRecord, diskGet, diskPut } from './attachmentDiskCache.js' +import { connection } from './connection.js' + +type CacheEntry = { + blobUrl: string + sizeBytes: number + lastAccess: number + refCount: number +} + +type PendingRequest = { + requestId: string + sessionId: string + storagePath: string + resolve: (result: { blobUrl: string; sizeBytes: number }) => void + reject: (err: Error) => void + timer: number +} + +const SOFT_CAP_BYTES = 64 * 1024 * 1024 +const REQUEST_TIMEOUT_MS = 30_000 + +// `${sessionId}:${storagePath}` — sessionIds are UUID-shaped (no `:`), +// storagePaths always start with `images/`, so this is collision-free +// in practice. If those invariants ever loosen, switch to a `\0` separator. +const cache = new Map() +const inflight = new Map>() +const pendingByRequestId = new Map() +let totalBytes = 0 +let nextSeq = 0 + +function cacheKey(sessionId: string, storagePath: string): string { + return `${sessionId}:${storagePath}` +} + +function nextRequestId(): string { + nextSeq += 1 + return `att-${nextSeq}-${Date.now()}` +} + +function decodeBase64ToArrayBuffer(s: string): ArrayBuffer { + const bin = atob(s) + const buf = new ArrayBuffer(bin.length) + const arr = new Uint8Array(buf) + for (let i = 0; i < bin.length; i += 1) arr[i] = bin.charCodeAt(i) + return buf +} + +function evictIfNeeded() { + if (totalBytes <= SOFT_CAP_BYTES) return + const entries = [...cache.entries()].sort((a, b) => a[1].lastAccess - b[1].lastAccess) + for (const [key, entry] of entries) { + if (totalBytes <= SOFT_CAP_BYTES * 0.8) break + if (entry.refCount > 0) continue + URL.revokeObjectURL(entry.blobUrl) + totalBytes -= entry.sizeBytes + cache.delete(key) + } +} + +function bufferToBlobUrl(buffer: ArrayBuffer, mimeType: string): string { + const blob = new Blob([buffer], { type: mimeType || 'application/octet-stream' }) + return URL.createObjectURL(blob) +} + +function armTimeout(pending: PendingRequest) { + pending.timer = window.setTimeout(() => { + if (pendingByRequestId.delete(pending.requestId)) { + pending.reject(new Error('timeout')) + } + }, REQUEST_TIMEOUT_MS) +} + +function sendRequest(pending: PendingRequest) { + connection.send(Channel.AI, { + type: 'request_attachment', + id: pending.requestId, + sessionId: pending.sessionId, + storagePath: pending.storagePath, + }) +} + +connection.onMessage((channel, msg) => { + if (channel !== Channel.AI || msg.type !== 'attachment_data') return + const pending = pendingByRequestId.get(msg.id) + if (!pending) return + pendingByRequestId.delete(msg.id) + window.clearTimeout(pending.timer) + if (msg.error || typeof msg.data !== 'string') { + pending.reject(new Error(msg.error || 'no_data')) + return + } + const buffer = decodeBase64ToArrayBuffer(msg.data) + const mimeType = msg.mimeType || 'application/octet-stream' + pending.resolve({ + blobUrl: bufferToBlobUrl(buffer, mimeType), + sizeBytes: buffer.byteLength, + }) + // Persist to disk cache so the next app launch finds it without a fetch. + // Failures here are non-fatal (next launch just refetches); swallow so + // we don't leak an unhandled rejection. + diskPut({ + key: cacheKey(pending.sessionId, pending.storagePath), + buffer, + mimeType, + sizeBytes: buffer.byteLength, + lastAccess: Date.now(), + }).catch(() => {}) +}) + +// Replay every unresolved request when the WS comes back. Without this, a +// disconnect mid-fetch leaves the chip stuck on the icon for 30s until +// the per-request timeout fires. +// +// The clearTimeout below is load-bearing: armTimeout() reassigns +// pending.timer, but the prior timer handle is still scheduled. If we +// don't cancel it, that earlier timer will fire later and incorrectly +// reject a request whose response is now in flight again. +connection.onStatusChange((status) => { + if (status !== 'connected') return + for (const pending of pendingByRequestId.values()) { + window.clearTimeout(pending.timer) + armTimeout(pending) + sendRequest(pending) + } +}) + +function fetchAttachmentBlobUrl(sessionId: string, storagePath: string): Promise { + const key = cacheKey(sessionId, storagePath) + const cached = cache.get(key) + if (cached) { + cached.lastAccess = Date.now() + return Promise.resolve(cached.blobUrl) + } + const inFlight = inflight.get(key) + if (inFlight) return inFlight + + // Register the inflight entry BEFORE starting the work, so any + // re-entrant call within this microtask sees the same promise. + let resolveOuter!: (url: string) => void + let rejectOuter!: (err: Error) => void + const outer = new Promise((res, rej) => { + resolveOuter = res + rejectOuter = rej + }) + inflight.set(key, outer) + + loadFromDiskOrServer(sessionId, storagePath).then( + ({ blobUrl, sizeBytes }) => { + const entry: CacheEntry = { + blobUrl, + sizeBytes, + lastAccess: Date.now(), + refCount: 0, + } + cache.set(key, entry) + totalBytes += entry.sizeBytes + inflight.delete(key) + resolveOuter(blobUrl) + // Defer eviction one microtask so any awaiting consumer's .then + // callback runs first and bumps refCount before we consider + // evicting this entry. Without this, a near-full cache pinned by + // other components could revoke the URL we just resolved. + queueMicrotask(evictIfNeeded) + }, + (err) => { + inflight.delete(key) + rejectOuter(err instanceof Error ? err : new Error(String(err))) + }, + ) + return outer +} + +async function loadFromDiskOrServer( + sessionId: string, + storagePath: string, +): Promise<{ blobUrl: string; sizeBytes: number }> { + const key = cacheKey(sessionId, storagePath) + let diskHit: DiskRecord | null = null + try { + diskHit = await diskGet(key) + } catch { + diskHit = null + } + if (diskHit) { + return { + blobUrl: bufferToBlobUrl(diskHit.buffer, diskHit.mimeType), + sizeBytes: diskHit.sizeBytes, + } + } + return new Promise<{ blobUrl: string; sizeBytes: number }>((resolve, reject) => { + const pending: PendingRequest = { + requestId: nextRequestId(), + sessionId, + storagePath, + resolve, + reject, + timer: 0, + } + pendingByRequestId.set(pending.requestId, pending) + armTimeout(pending) + sendRequest(pending) + }) +} + +function acquire(key: string) { + const entry = cache.get(key) + if (entry) entry.refCount += 1 +} + +function release(key: string) { + const entry = cache.get(key) + if (!entry) return + entry.refCount = Math.max(0, entry.refCount - 1) +} + +export type AttachmentBlobState = { + url: string | undefined + loading: boolean + error: string | undefined +} + +/** + * Resolve image bytes to a renderable URL. + * + * - When `inlineData` is provided (live just-sent message), returns a + * data: URL synchronously — no WS round-trip. + * - Otherwise checks the in-memory cache, then IndexedDB, then asks the + * server. The same URL is shared across chip thumbnail / hover preview + * / full viewer for the rest of the session lifetime. + */ +export function useAttachmentBlobUrl( + sessionId: string | undefined, + storagePath: string | undefined, + mimeType: string | undefined, + inlineData?: string, +): AttachmentBlobState { + // Memoize so multi-MB base64 doesn't re-concat into a fresh string on + // every render — and so the effect's dependency reference stays stable. + const inlineUrl = useMemo( + () => (inlineData && mimeType ? `data:${mimeType};base64,${inlineData}` : undefined), + [inlineData, mimeType], + ) + + const [state, setState] = useState(() => + inlineUrl + ? { url: inlineUrl, loading: false, error: undefined } + : { url: undefined, loading: !!sessionId && !!storagePath, error: undefined }, + ) + + useEffect(() => { + if (inlineUrl) { + setState({ url: inlineUrl, loading: false, error: undefined }) + return + } + if (!sessionId || !storagePath) { + setState({ url: undefined, loading: false, error: undefined }) + return + } + const key = cacheKey(sessionId, storagePath) + let cancelled = false + let acquired = false + setState((prev) => ({ url: prev.url, loading: true, error: undefined })) + fetchAttachmentBlobUrl(sessionId, storagePath) + .then((url) => { + if (cancelled) return + acquire(key) + acquired = true + setState({ url, loading: false, error: undefined }) + }) + .catch((err: unknown) => { + if (cancelled) return + setState({ + url: undefined, + loading: false, + error: err instanceof Error ? err.message : 'fetch_failed', + }) + }) + return () => { + cancelled = true + // Pair release with acquire — if the fetch was cancelled before + // we acquired, releasing here would decrement another component's + // refCount and let eviction revoke a URL still in use. + if (acquired) release(key) + } + }, [sessionId, storagePath, inlineUrl]) + + return state +} diff --git a/packages/desktop/src/lib/connection.ts b/packages/desktop/src/lib/connection.ts index 159c9307..be300311 100644 --- a/packages/desktop/src/lib/connection.ts +++ b/packages/desktop/src/lib/connection.ts @@ -219,6 +219,16 @@ export class Connection { this.send(Channel.AI, { type: 'session_destroy', id }) } + /** + * User-initiated rename of a session. Server persists to meta.json, + * refreshes the sync index, and broadcasts `title_update` to all + * connected clients (this client included — the echo is harmless and + * keeps server and optimistic local state aligned). + */ + sendSessionRename(id: string, title: string) { + this.send(Channel.AI, { type: 'session_rename', id, title }) + } + /** * Swap the provider/model of an existing harness (BYOS) session. * Server tears down the running CLI, rebuilds with the new provider, diff --git a/packages/desktop/src/lib/store.ts b/packages/desktop/src/lib/store.ts index 51f6b849..cf092879 100644 --- a/packages/desktop/src/lib/store.ts +++ b/packages/desktop/src/lib/store.ts @@ -180,6 +180,7 @@ interface AppState { requestSessionHistory: (sessionId: string) => void loadOlderMessages: (sessionId: string) => void updateConversationTitle: (sessionId: string, title: string) => void + renameConversation: (id: string, title: string) => void // Session readiness (delegates to sessionStore) registerPendingSession: (id: string) => Promise @@ -839,6 +840,21 @@ export const useStore = create((set, get) => { }) }, + renameConversation: (id, title) => { + const trimmed = title.trim().slice(0, 200) + if (!trimmed) return + const conv = get().conversations.find((c) => c.id === id) + const sessionId = conv?.sessionId ?? id + // Optimistic local update — keeps the UI responsive while the + // server round-trips. The server's `title_update` echo will pass + // through this same path again (idempotent: same value, no + // visible change). + get().updateConversationTitle(sessionId, trimmed) + // Persist on the server: writes meta.json + refreshes the sync + // index so reconnecting clients pick up the rename. + sessionStore.getState().renameSession(sessionId, trimmed) + }, + registerPendingSession: (id) => { return sessionStore.getState().registerPendingSession(id) }, diff --git a/packages/desktop/src/lib/store/sessionStore.ts b/packages/desktop/src/lib/store/sessionStore.ts index 1b36af67..b1773aed 100644 --- a/packages/desktop/src/lib/store/sessionStore.ts +++ b/packages/desktop/src/lib/store/sessionStore.ts @@ -333,6 +333,7 @@ interface SessionStoreState { }, ) => void destroySession: (sessionId: string) => void + renameSession: (sessionId: string, title: string) => void switchSessionProvider: (sessionId: string, provider: string, model: string) => void requestSessionHistory: (sessionId: string, opts?: { before?: number; limit?: number }) => void sendConfirmResponse: (id: string, approved: boolean) => void @@ -617,6 +618,7 @@ export const sessionStore = create((set, get) => { get().updateSessionState(sessionId, { thinkingLevel }) }, destroySession: (sessionId) => connection.sendSessionDestroy(sessionId), + renameSession: (sessionId, title) => connection.sendSessionRename(sessionId, title), switchSessionProvider: (sessionId, provider, model) => connection.sendSessionProviderSwitch(sessionId, provider, model), requestSessionHistory: (sessionId, opts) => { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 99992e67..bd29cb02 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -253,6 +253,21 @@ export interface SessionDestroyMessage { id: string } +/** + * Client → Server: user-initiated rename of a session. + * + * Server persists the new title to the session's meta.json, refreshes + * the global sync index (so reconnecting clients get the update via + * the regular session_sync delta path), and broadcasts a `title_update` + * push so live clients update immediately. Empty / whitespace-only + * titles are ignored. Server side normalizes (trim + length cap). + */ +export interface SessionRenameMessage { + type: 'session_rename' + id: string + title: string +} + /** * Client asks the server to swap the provider/model of an existing * harness session without losing its conversation history. Server @@ -348,6 +363,26 @@ export interface SessionImageAttachment { data?: string } +export interface RequestAttachmentMessage { + type: 'request_attachment' + /** Correlation id echoed in attachment_data response. */ + id: string + sessionId: string + /** Path relative to the session dir, must start with `images/`. */ + storagePath: string +} + +export interface AttachmentDataMessage { + type: 'attachment_data' + /** Echoes RequestAttachmentMessage.id. */ + id: string + mimeType?: string + /** Base64-encoded bytes. Absent when an error occurred. */ + data?: string + sizeBytes?: number + error?: string +} + // Provider management export interface ProvidersListMessage { type: 'providers_list' @@ -1417,10 +1452,13 @@ export type AiMessage = | SessionSyncPush | SessionDestroyMessage | SessionDestroyedMessage + | SessionRenameMessage | SessionProviderSwitchMessage | SessionProviderSwitchedMessage | SessionHistoryMessage | SessionHistoryResponse + | RequestAttachmentMessage + | AttachmentDataMessage | ContextInfoMessage // Provider management | ProvidersListMessage diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62eaf3fe..c3fc2542 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: esbuild: specifier: ^0.27.4 version: 0.27.4 + vitest: + specifier: ^3 + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2) packages/agent: dependencies: @@ -2851,9 +2854,15 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/diff-match-patch@1.0.36': resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} @@ -2959,6 +2968,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vue/compiler-core@3.5.31': resolution: {integrity: sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==} @@ -3130,6 +3168,10 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.13.4: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} @@ -3332,6 +3374,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -3357,6 +3403,10 @@ packages: resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} engines: {node: '>=0.8'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -3381,6 +3431,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + cheminfo-types@1.14.0: resolution: {integrity: sha512-OqGJQN0AqS1Gw3IEZ3GxP8WL8sI+usK//StPyKcvE8fR5MZPxnGZWMGXYtQHFOYE16iUo/54AJOlzC5NnLw+wg==} @@ -3617,6 +3671,10 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -3754,6 +3812,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -3829,6 +3890,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3849,6 +3913,10 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + expo-asset@55.0.14: resolution: {integrity: sha512-8jeWHW39/UOQytGoXXFIrpE+DhK72RhMu09iuTxYuGluqGzGgs+DgcaP9jTvCPwkAXxSfWZdsTttuKXE5nDUCQ==} peerDependencies: @@ -4502,6 +4570,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -4683,6 +4754,9 @@ packages: lop@0.4.2: resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -5322,6 +5396,13 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5820,6 +5901,9 @@ packages: resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} engines: {node: '>=20'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -5918,6 +6002,9 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -5933,6 +6020,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stream-buffers@2.2.0: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} @@ -5978,6 +6068,9 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.2.1: resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==} @@ -6067,10 +6160,28 @@ packages: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -6270,6 +6381,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6310,6 +6426,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} @@ -6355,6 +6499,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + widest-line@5.0.0: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} engines: {node: '>=18'} @@ -9422,10 +9571,17 @@ snapshots: dependencies: '@types/node': 22.19.15 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/diff-match-patch@1.0.36': {} '@types/estree-jsx@1.0.5': @@ -9530,6 +9686,48 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vue/compiler-core@3.5.31': dependencies: '@babel/parser': 7.29.2 @@ -9705,6 +9903,8 @@ snapshots: asap@2.0.6: {} + assertion-error@2.0.1: {} + ast-types@0.13.4: dependencies: tslib: 2.8.1 @@ -9996,6 +10196,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -10016,6 +10218,14 @@ snapshots: adler-32: 1.3.1 crc-32: 1.2.2 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -10037,6 +10247,8 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} + cheminfo-types@1.14.0: {} chokidar@4.0.3: @@ -10271,6 +10483,8 @@ snapshots: dependencies: mimic-response: 3.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} deepmerge@4.3.1: {} @@ -10382,6 +10596,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -10513,6 +10729,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} @@ -10523,6 +10743,8 @@ snapshots: expand-template@2.0.3: {} + expect-type@1.3.0: {} + expo-asset@55.0.14(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): dependencies: '@expo/image-utils': 0.8.13(typescript@5.9.3) @@ -11289,6 +11511,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -11453,6 +11677,8 @@ snapshots: option: 0.2.4 underscore: 1.13.8 + loupe@3.2.1: {} + lru-cache@10.4.3: {} lru-cache@11.3.3: {} @@ -12514,6 +12740,10 @@ snapshots: lru-cache: 11.3.3 minipass: 7.1.3 + pathe@2.0.3: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -13170,6 +13400,8 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} simple-concat@1.0.1: {} @@ -13267,6 +13499,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + stackframe@1.3.4: {} stacktrace-parser@0.1.11: @@ -13277,6 +13511,8 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + stream-buffers@2.2.0: {} strict-uri-encode@2.0.0: {} @@ -13322,6 +13558,10 @@ snapshots: strip-json-comments@5.0.3: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + strnum@2.2.1: {} structured-headers@0.4.1: {} @@ -13431,11 +13671,21 @@ snapshots: throttleit@2.1.0: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -13609,6 +13859,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -13626,6 +13897,48 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 22.19.15 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vlq@1.0.1: {} vue@3.5.31(typescript@5.9.3): @@ -13667,6 +13980,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + widest-line@5.0.0: dependencies: string-width: 7.2.0 diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..e6835b41 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['packages/*/src/**/*.test.ts'], + environment: 'node', + testTimeout: 10_000, + }, +})