From 394f1ed37ce860da6fdc385769bf29f9737105cd Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 4 Jun 2026 21:21:20 +0900 Subject: chore: genesis — remove all files to rebuild from scratch (arch rewrite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/frontend/src/App.svelte | 347 --- packages/frontend/src/app.css | 111 - packages/frontend/src/lib/attachment-tokens.ts | 234 -- packages/frontend/src/lib/cache-warm-storage.ts | 77 - packages/frontend/src/lib/cache-warming.svelte.ts | 318 --- .../src/lib/components/AgentBuilder.svelte | 806 ------- .../src/lib/components/CacheRatePanel.svelte | 124 - .../frontend/src/lib/components/ChatInput.svelte | 396 ---- .../frontend/src/lib/components/ChatMessage.svelte | 148 -- .../frontend/src/lib/components/ChatPanel.svelte | 181 -- .../frontend/src/lib/components/ClaudeReset.svelte | 375 --- .../frontend/src/lib/components/ConfigPanel.svelte | 251 -- .../src/lib/components/ContextWindowPanel.svelte | 85 - .../frontend/src/lib/components/DebugPanel.svelte | 35 - packages/frontend/src/lib/components/Header.svelte | 27 - .../src/lib/components/HotReloadIndicator.svelte | 35 - .../frontend/src/lib/components/KeyUsage.svelte | 477 ---- .../src/lib/components/MarkdownRenderer.svelte | 174 -- .../src/lib/components/ModelSelector.svelte | 636 ----- .../frontend/src/lib/components/ModelStatus.svelte | 356 --- .../src/lib/components/PermissionPrompt.svelte | 100 - .../src/lib/components/SettingsPanel.svelte | 643 ------ .../src/lib/components/SidebarPanel.svelte | 220 -- .../src/lib/components/SkillsBrowser.svelte | 317 --- .../src/lib/components/SystemPromptPanel.svelte | 61 - packages/frontend/src/lib/components/TabBar.svelte | 234 -- .../src/lib/components/TaskListPanel.svelte | 80 - .../src/lib/components/ToolCallDisplay.svelte | 140 -- .../src/lib/components/ToolPermissions.svelte | 185 -- packages/frontend/src/lib/config.ts | 44 - packages/frontend/src/lib/context-window.ts | 37 - packages/frontend/src/lib/router.svelte.ts | 12 - packages/frontend/src/lib/settings.svelte.ts | 88 - packages/frontend/src/lib/sidebar-storage.ts | 84 - packages/frontend/src/lib/snapshot-sequencer.ts | 47 - packages/frontend/src/lib/tabs.svelte.ts | 2441 -------------------- packages/frontend/src/lib/theme.ts | 92 - packages/frontend/src/lib/types.ts | 337 --- packages/frontend/src/lib/ws.svelte.ts | 127 - packages/frontend/src/main.ts | 9 - packages/frontend/src/vite-env.d.ts | 2 - 41 files changed, 10493 deletions(-) delete mode 100644 packages/frontend/src/App.svelte delete mode 100644 packages/frontend/src/app.css delete mode 100644 packages/frontend/src/lib/attachment-tokens.ts delete mode 100644 packages/frontend/src/lib/cache-warm-storage.ts delete mode 100644 packages/frontend/src/lib/cache-warming.svelte.ts delete mode 100644 packages/frontend/src/lib/components/AgentBuilder.svelte delete mode 100644 packages/frontend/src/lib/components/CacheRatePanel.svelte delete mode 100644 packages/frontend/src/lib/components/ChatInput.svelte delete mode 100644 packages/frontend/src/lib/components/ChatMessage.svelte delete mode 100644 packages/frontend/src/lib/components/ChatPanel.svelte delete mode 100644 packages/frontend/src/lib/components/ClaudeReset.svelte delete mode 100644 packages/frontend/src/lib/components/ConfigPanel.svelte delete mode 100644 packages/frontend/src/lib/components/ContextWindowPanel.svelte delete mode 100644 packages/frontend/src/lib/components/DebugPanel.svelte delete mode 100644 packages/frontend/src/lib/components/Header.svelte delete mode 100644 packages/frontend/src/lib/components/HotReloadIndicator.svelte delete mode 100644 packages/frontend/src/lib/components/KeyUsage.svelte delete mode 100644 packages/frontend/src/lib/components/MarkdownRenderer.svelte delete mode 100644 packages/frontend/src/lib/components/ModelSelector.svelte delete mode 100644 packages/frontend/src/lib/components/ModelStatus.svelte delete mode 100644 packages/frontend/src/lib/components/PermissionPrompt.svelte delete mode 100644 packages/frontend/src/lib/components/SettingsPanel.svelte delete mode 100644 packages/frontend/src/lib/components/SidebarPanel.svelte delete mode 100644 packages/frontend/src/lib/components/SkillsBrowser.svelte delete mode 100644 packages/frontend/src/lib/components/SystemPromptPanel.svelte delete mode 100644 packages/frontend/src/lib/components/TabBar.svelte delete mode 100644 packages/frontend/src/lib/components/TaskListPanel.svelte delete mode 100644 packages/frontend/src/lib/components/ToolCallDisplay.svelte delete mode 100644 packages/frontend/src/lib/components/ToolPermissions.svelte delete mode 100644 packages/frontend/src/lib/config.ts delete mode 100644 packages/frontend/src/lib/context-window.ts delete mode 100644 packages/frontend/src/lib/router.svelte.ts delete mode 100644 packages/frontend/src/lib/settings.svelte.ts delete mode 100644 packages/frontend/src/lib/sidebar-storage.ts delete mode 100644 packages/frontend/src/lib/snapshot-sequencer.ts delete mode 100644 packages/frontend/src/lib/tabs.svelte.ts delete mode 100644 packages/frontend/src/lib/theme.ts delete mode 100644 packages/frontend/src/lib/types.ts delete mode 100644 packages/frontend/src/lib/ws.svelte.ts delete mode 100644 packages/frontend/src/main.ts delete mode 100644 packages/frontend/src/vite-env.d.ts (limited to 'packages/frontend/src') diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte deleted file mode 100644 index f5317ba..0000000 --- a/packages/frontend/src/App.svelte +++ /dev/null @@ -1,347 +0,0 @@ - - -
-
sidebarOpen = !sidebarOpen} /> - - {#if router.page === "dashboard"} -
- -
- -
- -
- -
- - -
-
- tabStore.setKey(keyId)} - onModelChange={(keyId, modelId) => tabStore.changeModel(keyId, modelId)} - onReasoningChange={(effort) => tabStore.setReasoningEffort(effort)} - onAgentChange={(agent) => tabStore.setAgent(agent)} - onWorkingDirectoryChange={(dir) => tabStore.setWorkingDirectory(dir)} - onCompact={() => { const id = tabStore.activeTab?.id; if (id) void tabStore.startCompaction(id); }} - canCompact={(tabStore.activeTab?.agentStatus ?? "idle") === "idle" && (tabStore.activeTab?.chunks.length ?? 0) > 0 && !(tabStore.activeTab?.compactingSource) && !(tabStore.activeTab?.isCompacting)} - compacting={tabStore.activeTab?.isCompacting ?? false} - onAddKey={() => { showAddKeyModal = true; addKeyId = ""; addKeyProvider = "anthropic"; addKeyError = null; }} - /> -
-
-
- {:else if router.page === "agent-builder"} - - {/if} -
- - -{#if sidebarOpen} - -
sidebarOpen = false} - onkeydown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') sidebarOpen = false; }} - aria-label="Close sidebar" - >
-{/if} - - - tabStore.replyPermission(id, reply)} -/> - - -
- -
- - -{#if showAddKeyModal} - -{/if} diff --git a/packages/frontend/src/app.css b/packages/frontend/src/app.css deleted file mode 100644 index 169f7e4..0000000 --- a/packages/frontend/src/app.css +++ /dev/null @@ -1,111 +0,0 @@ -@import "tailwindcss"; -@import "highlight.js/styles/atom-one-dark.min.css"; -@plugin "daisyui" { - themes: - light, dark, dracula, night, nord, sunset, cyberpunk, forest, cmyk, coffee, caramellatte, - garden, luxury; -} - -.markdown-body { - & p { - margin-block: 0.5em; - &:first-child { - margin-block-start: 0; - } - &:last-child { - margin-block-end: 0; - } - } - & h1, - & h2, - & h3, - & h4, - & h5, - & h6 { - font-weight: 600; - line-height: 1.25; - margin-block: 0.75em 0.25em; - &:first-child { - margin-block-start: 0; - } - } - & h1 { - font-size: 1.4em; - } - & h2 { - font-size: 1.2em; - } - & h3 { - font-size: 1.1em; - } - & ul, - & ol { - padding-inline-start: 1.5em; - margin-block: 0.5em; - } - & ul { - list-style-type: disc; - } - & ol { - list-style-type: decimal; - } - & li { - margin-block: 0.15em; - } - & pre { - overflow-x: auto; - border-radius: var(--radius-box); - margin-block: 0.5em; - background-color: oklch(var(--color-neutral)); - color: oklch(var(--color-neutral-content)); - } - & pre code { - display: block; - padding: 0.75em 1em; - font-size: 0.8125em; - line-height: 1.5; - } - & :not(pre) > code { - font-size: 0.875em; - padding: 0.15em 0.4em; - border-radius: var(--radius-selector); - background-color: oklch(var(--color-base-content) / 0.1); - } - & blockquote { - border-inline-start: 3px solid oklch(var(--color-base-content) / 0.2); - padding-inline-start: 0.75em; - margin-block: 0.5em; - opacity: 0.8; - } - & a { - color: oklch(var(--color-primary)); - text-decoration: underline; - &:hover { - opacity: 0.8; - } - } - & strong { - font-weight: 600; - } - & table { - width: 100%; - border-collapse: collapse; - margin-block: 0.5em; - font-size: 0.875em; - } - & th, - & td { - border: 1px solid oklch(var(--color-base-content) / 0.15); - padding: 0.4em 0.75em; - text-align: start; - } - & th { - font-weight: 600; - background-color: oklch(var(--color-base-200)); - } - & hr { - border: none; - border-top: 1px solid oklch(var(--color-base-content) / 0.2); - margin-block: 0.75em; - } -} diff --git a/packages/frontend/src/lib/attachment-tokens.ts b/packages/frontend/src/lib/attachment-tokens.ts deleted file mode 100644 index 79d4cbc..0000000 --- a/packages/frontend/src/lib/attachment-tokens.ts +++ /dev/null @@ -1,234 +0,0 @@ -// Inline attachment tokens for the chat input. -// -// A pasted image/PDF is represented in the textarea draft as an inline TOKEN -// (e.g. `【image:a1b2c3】`). The token is ordinary text living inside the draft, -// so attachments have ORDER relative to typed text and to each other, and the -// user can reference them positionally ("here is image A: 【image:…】"). The -// token is also the ONLY handle on an attachment — deleting it (atomic delete, -// below) detaches the underlying file. There is no separate preview strip. -// -// This module is pure (no DOM, no Svelte) so it can be unit-tested directly. - -import type { UserContentPart } from "@dispatch/core/src/types/index.js"; - -export type AttachmentKind = "image" | "pdf"; - -/** A staged attachment, keyed by its short token id. */ -export interface StagedAttachment { - id: string; - kind: AttachmentKind; - /** IANA media type, e.g. `image/png`, `application/pdf`. */ - mediaType: string; - /** Base64 payload WITHOUT a `data:` URI prefix. */ - data: string; - /** Optional original filename (used for PDFs). */ - name?: string; -} - -/** - * Token grammar: `【:】` where kind ∈ {image,pdf} and id is 6 - * lowercase alphanumerics. The CJK corner brackets (U+3010/U+3011) are used as - * delimiters because they're visually distinct and virtually never typed by - * hand, so a token won't collide with normal prose. - */ -export const ATTACHMENT_TOKEN_RE = /【(image|pdf):([a-z0-9]{6})】/g; - -/** Build the inline token string for a staged attachment id + kind. */ -export function makeAttachmentToken(kind: AttachmentKind, id: string): string { - return `【${kind}:${id}】`; -} - -/** Generate a short, URL-safe token id (6 lowercase alphanumerics). */ -export function generateTokenId(): string { - let out = ""; - const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; - // crypto.getRandomValues is available in browsers and modern Node/Bun. - const cryptoObj = (globalThis as { crypto?: Crypto }).crypto; - if (cryptoObj?.getRandomValues) { - const buf = new Uint32Array(6); - cryptoObj.getRandomValues(buf); - for (let i = 0; i < 6; i++) out += alphabet[(buf[i] ?? 0) % alphabet.length]; - return out; - } - for (let i = 0; i < 6; i++) out += alphabet[Math.floor(Math.random() * alphabet.length)]; - return out; -} - -export interface FoundToken { - id: string; - kind: AttachmentKind; - /** Inclusive start index of the token within the text. */ - start: number; - /** Exclusive end index of the token within the text. */ - end: number; -} - -/** Find all attachment tokens in `text`, in order of appearance. */ -export function findTokens(text: string): FoundToken[] { - const out: FoundToken[] = []; - // Fresh regex per call so `lastIndex` state never leaks between calls. - const re = new RegExp(ATTACHMENT_TOKEN_RE.source, "g"); - let m: RegExpExecArray | null = re.exec(text); - while (m !== null) { - out.push({ - kind: m[1] as AttachmentKind, - id: m[2] ?? "", - start: m.index, - end: m.index + m[0].length, - }); - m = re.exec(text); - } - return out; -} - -/** The set of attachment ids whose token is still intact in `text`. */ -export function intactTokenIds(text: string): Set { - return new Set(findTokens(text).map((t) => t.id)); -} - -export interface DeletionResult { - /** Text after the deletion. */ - text: string; - /** New caret position (collapsed) after the deletion. */ - caret: number; - /** Ids of attachments whose tokens were removed by this deletion. */ - removedIds: string[]; -} - -/** - * Compute the result of a Backspace/Delete keystroke when it interacts with an - * attachment token, so a token deletes ATOMICALLY (one keystroke removes the - * whole `【…】`, never a single bracket). Returns `null` when the keystroke does - * NOT touch a token — the caller should then let the browser's default editing - * behaviour run. - * - * Rules: - * - Range selection (`selStart !== selEnd`): expand the range to fully cover - * any token it overlaps, then delete the expanded range. Only acts when at - * least one token actually overlaps (otherwise returns null). - * - Collapsed + Backspace: if a token ends exactly at the caret, delete it. - * - Collapsed + Delete: if a token starts exactly at the caret, delete it. - */ -export function computeTokenDeletion( - text: string, - selStart: number, - selEnd: number, - key: "Backspace" | "Delete", -): DeletionResult | null { - const tokens = findTokens(text); - if (tokens.length === 0) return null; - - if (selStart !== selEnd) { - const lo = Math.min(selStart, selEnd); - const hi = Math.max(selStart, selEnd); - const overlapping = tokens.filter((t) => t.start < hi && t.end > lo); - if (overlapping.length === 0) return null; - const delStart = Math.min(lo, ...overlapping.map((t) => t.start)); - const delEnd = Math.max(hi, ...overlapping.map((t) => t.end)); - return { - text: text.slice(0, delStart) + text.slice(delEnd), - caret: delStart, - removedIds: overlapping.map((t) => t.id), - }; - } - - // Collapsed caret. - if (key === "Backspace") { - const tok = tokens.find((t) => t.end === selStart); - if (!tok) return null; - return { - text: text.slice(0, tok.start) + text.slice(tok.end), - caret: tok.start, - removedIds: [tok.id], - }; - } - // Delete (forward). - const tok = tokens.find((t) => t.start === selStart); - if (!tok) return null; - return { - text: text.slice(0, tok.start) + text.slice(tok.end), - caret: tok.start, - removedIds: [tok.id], - }; -} - -/** Human-readable marker that replaces a token in persisted/display text. */ -export function markerFor(kind: AttachmentKind): string { - return kind === "pdf" ? "[pdf]" : "[image]"; -} - -export interface ParsedDraft { - /** - * Text-only projection of the draft with each attachment token replaced by a - * `[image]` / `[pdf]` marker. This is what gets persisted and rendered in the - * chat history (the raw bytes are never stored). - */ - displayText: string; - /** - * Ordered multimodal content (interleaved text + attachment parts) to send to - * the model, or `null` when the draft has no intact attachment token (the - * caller then sends plain text). - */ - content: UserContentPart[] | null; -} - -/** - * Split a draft (text containing attachment tokens) plus the staged-attachment - * map into: - * - `displayText`: tokens swapped for `[image]`/`[pdf]` markers, and - * - `content`: an ordered `UserContentPart[]` interleaving the surrounding text - * with the matching attachment parts. - * - * A token whose id has no matching staged attachment (e.g. a stray paste of the - * token text, or a detached attachment) is treated as plain text in BOTH - * outputs — its marker still appears in `displayText`, but it contributes no - * attachment part. `content` is `null` when no attachment part is produced. - */ -export function parseDraft(draft: string, attachments: Map): ParsedDraft { - const tokens = findTokens(draft); - let displayText = ""; - const content: UserContentPart[] = []; - let textBuf = ""; - let cursor = 0; - let producedAttachment = false; - - const flushText = () => { - if (textBuf.length > 0) { - content.push({ type: "text", text: textBuf }); - textBuf = ""; - } - }; - - for (const tok of tokens) { - const between = draft.slice(cursor, tok.start); - textBuf += between; - displayText += between; - const att = attachments.get(tok.id); - if (att) { - // displayText (persisted/rendered) gets a `[image]`/`[pdf]` marker; - // the multimodal content gets the ACTUAL attachment part instead — no - // marker text, since the part itself represents the file to the model. - displayText += markerFor(tok.kind); - flushText(); - content.push({ - type: "attachment", - mediaType: att.mediaType, - data: att.data, - ...(att.name ? { name: att.name } : {}), - }); - producedAttachment = true; - } else { - // Orphan token (no staged attachment) → keep the marker as plain text - // in BOTH outputs; it contributes no attachment part. - displayText += markerFor(tok.kind); - textBuf += markerFor(tok.kind); - } - cursor = tok.end; - } - const tail = draft.slice(cursor); - textBuf += tail; - displayText += tail; - flushText(); - - return { displayText, content: producedAttachment ? content : null }; -} diff --git a/packages/frontend/src/lib/cache-warm-storage.ts b/packages/frontend/src/lib/cache-warm-storage.ts deleted file mode 100644 index 66a8c31..0000000 --- a/packages/frontend/src/lib/cache-warm-storage.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * LocalStorage persistence for the per-tab "prompt cache warming" toggle. - * - * Cache warming keeps a tab's provider prompt-cache warm while the tab is idle - * by periodically replaying its exact cached conversation plus a trivial - * throwaway turn (see `cache-warming.svelte.ts`). Whether warming is enabled is - * a per-tab preference that must survive a browser reload. - * - * Why localStorage and not the backend `settings` table: - * - It's a per-device UI preference, not domain state — the same precedent as - * `dispatch-sidebar-panels`, `dispatch-theme`, and `dispatch-api-url`. - * - No backend round-trip on every toggle. - * - Warming itself is a frontend-driven timer; keeping its on/off flag on the - * frontend keeps the whole feature self-contained. - * - * Shape: a single JSON object mapping `tabId -> boolean` under one key, so a - * closed tab's stale entry is cheap and easy to prune. - */ - -const LS_KEY = "dispatch-cache-warming"; - -type WarmMap = Record; - -/** Read the whole tab→enabled map. Never throws; returns {} on any failure. */ -function readMap(): WarmMap { - try { - const raw = localStorage.getItem(LS_KEY); - if (!raw) return {}; - const parsed: unknown = JSON.parse(raw); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; - const out: WarmMap = {}; - for (const [k, v] of Object.entries(parsed as Record)) { - if (typeof v === "boolean") out[k] = v; - } - return out; - } catch { - // localStorage unavailable (private mode / restricted context) or corrupt - // write from a prior session. Degrade to "nothing persisted". - return {}; - } -} - -/** Persist the whole map. Best-effort — swallows quota / access errors. */ -function writeMap(map: WarmMap): void { - try { - localStorage.setItem(LS_KEY, JSON.stringify(map)); - } catch { - // Best-effort: the in-memory store remains the source of truth for the - // current session even if persistence fails. - } -} - -/** Whether cache warming is enabled for `tabId` (default: false). */ -export function loadCacheWarmEnabled(tabId: string): boolean { - return readMap()[tabId] === true; -} - -/** - * Persist the warming toggle for one tab. Writing `false` removes the entry - * (default is off, so absence is the natural "disabled" representation and - * keeps stale tabs from accumulating). - */ -export function saveCacheWarmEnabled(tabId: string, enabled: boolean): void { - const map = readMap(); - if (enabled) map[tabId] = true; - else delete map[tabId]; - writeMap(map); -} - -/** Drop a tab's persisted toggle entirely (called when the tab is closed). */ -export function clearCacheWarmEnabled(tabId: string): void { - const map = readMap(); - if (tabId in map) { - delete map[tabId]; - writeMap(map); - } -} diff --git a/packages/frontend/src/lib/cache-warming.svelte.ts b/packages/frontend/src/lib/cache-warming.svelte.ts deleted file mode 100644 index 0253c08..0000000 --- a/packages/frontend/src/lib/cache-warming.svelte.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * Prompt-cache WARMING — frontend timer/orchestration store. - * - * Keeps a tab's provider prompt-cache warm while the tab is IDLE by firing a - * cheap "warm" request (`POST /chat/warm`) on a repeating ~4-minute cadence. - * The backend replays the tab's EXACT cached prefix plus one trivial throwaway - * turn (see `Agent.warmCache`), which registers a cache READ and refreshes the - * provider's ~5-min prompt-cache TTL so the user's next real message lands on a - * warm cache. - * - * Lifecycle (driven by the tab store via the `onTurn*` / `onUserMessage` hooks): - * - A turn ENDS (tab goes idle) → arm: schedule a fire in 4 minutes. - * - The timer fires → warm, then re-arm 4 minutes out - * (repeats; resets the countdown each - * cycle). - * - A turn is ONGOING (generation active) → never fires; the pending timer is - * cancelled. - * - The user sends a real message → disable+reset the timer immediately; - * the turn it starts re-arms warming - * once it ends. - * - * CRITICAL: the warming request is debug-only. Its cache data is surfaced ONLY - * as a warming-specific "Last request" percentage here — it is NEVER folded - * into the real Cache Rate metric, never persisted, never counted toward - * context. The backend route returns just the request's `usage`; nothing else. - */ - -import type { AgentModelEntry } from "@dispatch/core/src/types/index.js"; -import { - clearCacheWarmEnabled, - loadCacheWarmEnabled, - saveCacheWarmEnabled, -} from "./cache-warm-storage.js"; -import { config } from "./config.js"; - -/** Re-warm cadence. Comfortably under Claude's ~5-min prompt-cache expiry. */ -export const WARM_INTERVAL_MS = 4 * 60 * 1000; - -/** Per-tab request parameters the warm POST needs (resolved from the tab). */ -export interface WarmRequestParams { - keyId: string | null; - modelId: string | null; - agentModels: AgentModelEntry[] | null; - /** - * The SAME reasoning effort the next real turn would use. It drives the - * Anthropic thinking providerOptions, which is a message-cache key — warming - * must match it so it refreshes the bucket the real message reads. - */ - reasoningEffort: string | null; -} - -/** Reactive, per-tab warming UI state (read by the Chat Settings debug strip). */ -export interface WarmState { - /** User toggle (persisted per-tab in localStorage). */ - enabled: boolean; - /** Epoch ms of the next scheduled fire, or null when not armed. */ - nextFireAt: number | null; - /** - * Cache-read % of the most recent warming request (0–100), or null if it - * has never fired this session. Drives the "-%" → number display. - */ - lastPct: number | null; - /** Last warming error (provider/network), surfaced in the debug strip. */ - error: string | null; - /** True while a warm request is in flight. */ - firing: boolean; -} - -function defaultState(enabled: boolean): WarmState { - return { enabled, nextFireAt: null, lastPct: null, error: null, firing: false }; -} - -function computeCachePct(inputTokens: number, cacheReadTokens: number): number { - if (inputTokens <= 0) return 0; - return Math.round(Math.max(0, Math.min(1, cacheReadTokens / inputTokens)) * 100); -} - -export function createCacheWarmingStore() { - // Reactive per-tab state. Nested mutation is reactive via Svelte 5 proxies; - // new keys are assigned wholesale (also reactive). - const states = $state>({}); - // Ticking clock so the countdown display refreshes once per second. Only - // ticked while at least one tab is armed (see (re)startTicker). - let now = $state(Date.now()); - - // Non-reactive bookkeeping (timers, in-flight tokens, running set, resolver). - const fireTimers = new Map>(); - const fireTokens = new Map(); - const runningTabs = new Set(); - let ticker: ReturnType | null = null; - let resolveParams: ((tabId: string) => WarmRequestParams | null) | null = null; - - function ensure(tabId: string): WarmState { - let s = states[tabId]; - if (!s) { - s = defaultState(loadCacheWarmEnabled(tabId)); - states[tabId] = s; - } - return s; - } - - function anyArmed(): boolean { - for (const s of Object.values(states)) { - if (s.nextFireAt !== null) return true; - } - return false; - } - - function startTickerIfNeeded(): void { - if (ticker !== null) return; - if (typeof setInterval !== "function") return; - ticker = setInterval(() => { - now = Date.now(); - // Self-stop once nothing is armed, so we don't tick forever. - if (!anyArmed()) stopTicker(); - }, 1000); - } - - function stopTicker(): void { - if (ticker !== null) { - clearInterval(ticker); - ticker = null; - } - } - - function clearFireTimer(tabId: string): void { - const t = fireTimers.get(tabId); - if (t !== undefined) { - clearTimeout(t); - fireTimers.delete(tabId); - } - } - - /** Cancel any pending fire / in-flight request and clear the countdown. */ - function cancel(tabId: string): void { - clearFireTimer(tabId); - // Invalidate any in-flight warm so its late result is ignored. - fireTokens.set(tabId, (fireTokens.get(tabId) ?? 0) + 1); - const s = states[tabId]; - if (s) s.nextFireAt = null; - if (!anyArmed()) stopTicker(); - } - - /** Schedule the next fire 4 minutes out — only when enabled AND idle. */ - function arm(tabId: string): void { - const s = ensure(tabId); - if (!s.enabled) return; - if (runningTabs.has(tabId)) return; - clearFireTimer(tabId); - s.nextFireAt = Date.now() + WARM_INTERVAL_MS; - startTickerIfNeeded(); - if (typeof setTimeout === "function") { - fireTimers.set( - tabId, - setTimeout(() => { - fireTimers.delete(tabId); - void fire(tabId); - }, WARM_INTERVAL_MS), - ); - } - } - - /** Perform one warming request, then (if still eligible) re-arm. */ - async function fire(tabId: string): Promise { - const s = ensure(tabId); - if (!s.enabled || runningTabs.has(tabId) || s.firing) { - return; - } - const token = (fireTokens.get(tabId) ?? 0) + 1; - fireTokens.set(tabId, token); - const params = resolveParams?.(tabId) ?? null; - - s.firing = true; - s.error = null; - // Clear the countdown while the request is in flight. - s.nextFireAt = null; - try { - const res = await fetch(`${config.apiBase}/chat/warm`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - tabId, - ...(params?.keyId ? { keyId: params.keyId } : {}), - ...(params?.modelId ? { modelId: params.modelId } : {}), - ...(params?.agentModels ? { agentModels: params.agentModels } : {}), - ...(params?.reasoningEffort ? { reasoningEffort: params.reasoningEffort } : {}), - }), - }); - // A newer cancel/fire superseded this request — drop its result so it - // can't clobber fresher state (e.g. user sent a real message meanwhile). - if (fireTokens.get(tabId) !== token) return; - - if (!res.ok) { - let msg = `warm failed (HTTP ${res.status})`; - try { - const body = (await res.json()) as { error?: string }; - if (body?.error) msg = body.error; - } catch { - /* non-JSON error body — keep the HTTP status message */ - } - s.error = msg; - } else { - const data = (await res.json()) as { - usage?: { inputTokens?: number; cacheReadTokens?: number }; - }; - const u = data.usage ?? {}; - s.lastPct = computeCachePct(u.inputTokens ?? 0, u.cacheReadTokens ?? 0); - s.error = null; - } - } catch (err) { - if (fireTokens.get(tabId) !== token) return; - s.error = err instanceof Error ? err.message : String(err); - } finally { - if (fireTokens.get(tabId) === token) { - s.firing = false; - // Re-arm for the next cycle (resets the 4-min countdown), but only - // if still enabled and the tab is still idle. - if (s.enabled && !runningTabs.has(tabId)) arm(tabId); - else if (!anyArmed()) stopTicker(); - } - } - } - - // ─── Public lifecycle hooks (called by the tab store) ──────────── - - /** - * Register the resolver the store uses to fetch a tab's request params - * (key/model/agentModels) at fire time. Called once by the tab store. - */ - function setRequestResolver(fn: (tabId: string) => WarmRequestParams | null): void { - resolveParams = fn; - } - - /** Seed a tab's state from persistence. Arms immediately if enabled+idle. */ - function initTab(tabId: string): void { - const s = ensure(tabId); - if (s.enabled && !runningTabs.has(tabId) && s.nextFireAt === null) { - arm(tabId); - } - } - - /** Toggle warming for a tab (persisted). Arms or cancels accordingly. */ - function setEnabled(tabId: string, enabled: boolean): void { - const s = ensure(tabId); - s.enabled = enabled; - saveCacheWarmEnabled(tabId, enabled); - if (enabled) arm(tabId); - else cancel(tabId); - } - - /** A turn started / generation is active — never warm during a turn. */ - function onTurnActive(tabId: string): void { - runningTabs.add(tabId); - cancel(tabId); - } - - /** A turn ended (tab idle) — re-arm the 4-minute countdown if enabled. */ - function onTurnEnded(tabId: string): void { - runningTabs.delete(tabId); - const s = ensure(tabId); - if (s.enabled) arm(tabId); - } - - /** - * The user sent a real message — disable+reset the timer immediately. The - * turn this message starts will re-arm warming via `onTurnEnded` once it - * settles, so the real message lands on a cache with no throwaway turns. - */ - function onUserMessage(tabId: string): void { - cancel(tabId); - } - - /** Forget a closed tab's timers/state. */ - function removeTab(tabId: string): void { - cancel(tabId); - fireTimers.delete(tabId); - fireTokens.delete(tabId); - runningTabs.delete(tabId); - delete states[tabId]; - if (!anyArmed()) stopTicker(); - } - - /** - * Forget a tab AND drop its persisted preference — for an explicit user - * close/archive. (`removeTab` keeps the persisted flag so an ephemeral - * idle-cleanup or a later reopen restores the user's choice.) - */ - function forgetTab(tabId: string): void { - removeTab(tabId); - clearCacheWarmEnabled(tabId); - } - - /** Reactive state for a tab (creates a default-off entry if absent). */ - function stateFor(tabId: string | null | undefined): WarmState { - if (!tabId) return defaultState(false); - return ensure(tabId); - } - - return { - setRequestResolver, - initTab, - setEnabled, - onTurnActive, - onTurnEnded, - onUserMessage, - removeTab, - forgetTab, - stateFor, - /** Reactive ticking clock (epoch ms) for countdown rendering. */ - get now() { - return now; - }, - // Exposed for tests to drive a fire without waiting 4 minutes. - fireNow: fire, - }; -} - -export const cacheWarming = createCacheWarmingStore(); diff --git a/packages/frontend/src/lib/components/AgentBuilder.svelte b/packages/frontend/src/lib/components/AgentBuilder.svelte deleted file mode 100644 index f1c9cdf..0000000 --- a/packages/frontend/src/lib/components/AgentBuilder.svelte +++ /dev/null @@ -1,806 +0,0 @@ - - - - -
- -
-

Agent Settings

- -
- -
- {#if error} -
{error}
- {/if} - - {#if editing} - -
-
-

{editingSlug ? "Edit Agent" : "New Agent"}

- - {#if formError} -
{formError}
- {/if} - - -
- - - {#if formName} - slug: {slugify(formName)} - {/if} -
- -
- -
- - -
- - -
- -
- - -
- - -
- - -
- -
- - {#if formCwd.trim()} - {#if cwdExists === true} - - {:else if cwdExists === false} - - {:else} - - {/if} - {/if} -
- {#if cwdExists === false && formCwd.trim()} - Directory will be created automatically on first message. - {/if} - {#if cwdResolved && formCwd.trim() && cwdResolved !== formCwd.trim()} - {cwdResolved} - {/if} - {#if !formCwd.trim()} - Absolute or relative path. Relative paths resolve against the parent agent's working directory, or the project root. - {/if} -
- - -
-
- Models * -
-
- {#each formModels as entry, i (i)} -
{ - dragIndex = i; - if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; - }} - ondragover={(e) => { - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; - dragOverIndex = i; - }} - ondragleave={() => { - if (dragOverIndex === i) dragOverIndex = null; - }} - ondrop={(e) => { - e.preventDefault(); - if (dragIndex !== null && dragIndex !== i) { - const reordered = [...formModels]; - const moved = reordered.splice(dragIndex, 1)[0]; - if (moved) { - reordered.splice(i, 0, moved); - formModels = reordered; - } - } - dragIndex = null; - dragOverIndex = null; - }} - ondragend={() => { dragIndex = null; dragOverIndex = null; }} - > - {i + 1} - - - - -
- {/each} -
- -
- - -
-
- Tools -
- -
- - -
-
- Skills -
- -
- - -
- {#if editingSlug && !(editingSlug === "default" && formScope === "global")} - - {:else} -
- {/if} -
- {#if saving} - Saving... - {/if} -
-
-
-
-
- {:else} - - {#if loading} -
- -
- {:else} - -
-

Global Agents

-
- {#each globalAgents as agent} - - {/each} - -
-
- - - {#if projectAgents.length > 0 || dirs.some((d) => d.scope !== "global")} -
-

Project Agents

- {#if projectAgents.length === 0} -

No project agents yet.

- {:else} -
- {#each projectAgents as agent} - - {/each} -
- {/if} -
- {/if} - {/if} - {/if} -
-
- - -{#if modelModalIndex !== null && modelModalType === "key"} - -{/if} - - -{#if modelModalIndex !== null && modelModalType === "model"} - -{/if} - - -{#if deletingAgent !== null} - {@const agentToDelete = deletingAgent} - -{/if} diff --git a/packages/frontend/src/lib/components/CacheRatePanel.svelte b/packages/frontend/src/lib/components/CacheRatePanel.svelte deleted file mode 100644 index 88985a0..0000000 --- a/packages/frontend/src/lib/components/CacheRatePanel.svelte +++ /dev/null @@ -1,124 +0,0 @@ - - -
- {#if !cacheStats || cacheStats.requests === 0} -

- No cache data yet. Send a message to a Claude model — prompt-cache usage - appears here after the first response. -

- {:else} -
-
- Cache Hit Rate - {#if tabTitle} - {tabTitle} - {/if} - {cacheStats.requests} req -
- - -
-
- Session (this tab) - {hitPct}% -
- -
- - - {#if cacheStats.last} -
-
- Last request - {lastHitPct}% -
- -
- {/if} -
- - -
-
Tokens (cumulative)
-
-
- - readCache hits - - {fmt(cacheStats.cacheReadTokens)} -
-
- - writeCache writes - - {fmt(cacheStats.cacheWriteTokens)} -
-
- - freshUncached input - - {fmt(uncached)} -
-
-
- Total input - {fmt(cacheStats.inputTokens)} -
-
- Output - {fmt(cacheStats.outputTokens)} -
-
-
- {/if} -
diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte deleted file mode 100644 index f3eadf7..0000000 --- a/packages/frontend/src/lib/components/ChatInput.svelte +++ /dev/null @@ -1,396 +0,0 @@ - - -
- {#if attachmentWarning} -
- - {attachmentWarning} -
- {/if} - -
- - - -
- - -
- - - {#if agentStatus === "running"} - - {:else if agentStatus === "error"} - - - - - - {:else} - - - - {/if} - - - - {#if usage.percent !== null} - - {:else} - - - {/if} - - - - {#if hasUsage} - {fmtCompact(usage.current)}{#if usage.max !== null} / {fmtCompact(usage.max)}{/if} - {#if usage.percent !== null} - · {usage.percent.toFixed(1)}% - {/if} - {:else} - — tokens - {/if} - -
-
diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte deleted file mode 100644 index 54e99c8..0000000 --- a/packages/frontend/src/lib/components/ChatMessage.svelte +++ /dev/null @@ -1,148 +0,0 @@ - - -{#snippet renderChunks(chunks: Chunk[], streaming: boolean | undefined)} - {#each chunks as chunk, i (chunkKey(chunk, i))} - {#if chunk.type === "text"} - - {:else if chunk.type === "thinking"} - - {#if chunk.text.length > 0} -
- -
Thinking...
-
-

{chunk.text}

-
-
- {/if} - {:else if chunk.type === "tool-batch"} - {#each chunk.calls as call (call.id)} - - {/each} - {:else if chunk.type === "error"} -
-
- {chunk.message} - {#if chunk.statusCode !== undefined} - status {chunk.statusCode} - {/if} -
-
- {:else if chunk.type === "system"} -
- {SYSTEM_KIND_LABEL[chunk.kind]}: - {chunk.text} -
- {/if} - {/each} -{/snippet} - -{#if isSystem} -
-
- {@render renderChunks(message.chunks, false)} -
-
-{:else if !isUser && !hasRenderableContent} - -{:else} -
-
- {@render renderChunks(message.chunks, message.isStreaming)} - {#if message.isStreaming} - - {/if} -
- {#if isQueued} -
- queued - -
- {/if} -
-{/if} diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte deleted file mode 100644 index ee1b8ef..0000000 --- a/packages/frontend/src/lib/components/ChatPanel.svelte +++ /dev/null @@ -1,181 +0,0 @@ - - -
- -
-
{ - isAutoScrolling = false; - }} - > - {#if isLoadingMore} -
Loading earlier messages...
- {/if} - {#if compactingSource || compactionError} -
- {#if compactionError} -
Compaction failed
-
{compactionError}
-
Close this tab to dismiss — your conversation was not changed.
- {:else} - -
Please wait, compacting conversation…
-
You can cancel by closing this tab.
- {/if} -
- {:else if renderGroups.length === 0} -
- Send a message to start a conversation -
- {/if} - {#if !compactingSource && !compactionError} - {#each keyedMessages as { m, key } (key)} - - {/each} - {/if} -
- - - -
-
diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte deleted file mode 100644 index eea8744..0000000 --- a/packages/frontend/src/lib/components/ClaudeReset.svelte +++ /dev/null @@ -1,375 +0,0 @@ - - -
-
Claude Wake Schedule
- - -
- AM -
-
- {#each amRow1 as hour} - - {/each} -
-
- {#each amRow2 as hour} - - {/each} -
-
-
- - -
- PM -
-
- {#each pmRow1 as hour} - - {/each} -
-
- {#each pmRow2 as hour} - - {/each} -
-
-
- - - {#if markedHours.length > 0} -
- {#each markedHours as hour} -
- {formatHour(hour)} {hour < 12 ? "AM" : "PM"} - Probes {probeLabels} → reset by {formatAmPm(resetHour(hour))} -
- {/each} -
- {:else} -

No wake hours marked. Click a block to probe at {probeLabels} that hour.

- {/if} - - - {#if lastWake} -
- {lastWake.ok ? "✓" : "✗"} - Last wake {formatRelative(lastWake.firedAt, nowMs)}{lastWake.ok ? "" : ` — ${lastWake.results.find((r) => !r.ok)?.error ?? "failed"}`} -
- {/if} - {#if pendingRetry} -
- Retrying ({pendingRetry.retriesLeft} left, next {formatRelative(pendingRetry.nextRetryAt, nowMs)}) -
- {/if} -
diff --git a/packages/frontend/src/lib/components/ConfigPanel.svelte b/packages/frontend/src/lib/components/ConfigPanel.svelte deleted file mode 100644 index f517d28..0000000 --- a/packages/frontend/src/lib/components/ConfigPanel.svelte +++ /dev/null @@ -1,251 +0,0 @@ - - -
- - Configuration - {#if modelCount > 0 || keyCount > 0} - {modelCount} models - {keyCount} keys - {/if} - {#if loading} - - {/if} - - -
- {#if error} -
- Failed to load config: {error} -
- {/if} - -
- -
- - - {#if configData?.agents && Object.keys(configData.agents).length > 0} -
-
Agent Templates
- {#each Object.entries(configData.agents) as [name, template]} -
-
- {name} - {#if template.model_tag} - {template.model_tag} - {/if} -
- {#if template.description} -

{template.description}

- {/if} - {#if template.tools && template.tools.length > 0} -
- {#each template.tools as tool} - {tool} - {/each} -
- {/if} -
- {/each} -
- {/if} - - - {#if modelsData?.models && modelsData.models.length > 0} -
-
Models
- {#each modelsData.models as model} -
- {model.id} - {#if model.provider} - {model.provider} - {/if} - {#if model.tags && model.tags.length > 0} - {#each model.tags as tag} - {tag} - {/each} - {/if} -
- {/each} -
- {/if} - - - {#if modelsData?.keys && modelsData.keys.length > 0} -
-
API Keys
- {#each modelsData.keys as key} -
-
- {key.id} - {#if key.provider} - {key.provider} - {/if} - {#if key.status === "exhausted"} - exhausted - {:else} - active - {/if} -
- {#if key.status === "exhausted" && key.lastError} -

{key.lastError}

- {/if} - {#if key.exhaustedAt} -

Since {formatDate(key.exhaustedAt)}

- {/if} -
- {/each} -
- {/if} - - - {#if configData?.fallback && configData.fallback.length > 0} -
-
Fallback Order
-
    - {#each configData.fallback as keyId, i} -
  1. - {i + 1} - {keyId} -
  2. - {/each} -
-
- {/if} - - - {#if configData?.permissions && Object.keys(configData.permissions).length > 0} -
-
Permissions
- {#each permissionEntries(configData.permissions) as entry} -
- {entry.name} - {#if isSimpleRule(entry.value)} - {entry.value.action} - {:else if isPatternRule(entry.value)} - {#each Object.entries(entry.value) as [pattern, rule]} -
- {pattern} - {#if typeof rule === "object" && rule !== null && "action" in rule} - {(rule as { action: string }).action} - {/if} -
- {/each} - {:else} - {JSON.stringify(entry.value)} - {/if} -
- {/each} -
- {/if} - - {#if !loading && !error && !configData && !modelsData} -

No configuration loaded.

- {/if} -
-
diff --git a/packages/frontend/src/lib/components/ContextWindowPanel.svelte b/packages/frontend/src/lib/components/ContextWindowPanel.svelte deleted file mode 100644 index 6c7de05..0000000 --- a/packages/frontend/src/lib/components/ContextWindowPanel.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - -
- {#if !hasUsage} -

- No context data yet. Send a message — the current context size appears - here after the first response. -

- {:else} -
-
- Context Window - {#if tabTitle} - {tabTitle} - {/if} - {#if usage.percent !== null} - {usage.percent.toFixed(2)}% - {/if} -
- - -
- {fmt(usage.current)} - {#if usage.max !== null} - / {fmt(usage.max)} - {/if} - tokens -
- - {#if usage.percent !== null} - - {:else} -

- Max context size unknown for this model. -

- {/if} - - {#if modelId} -
- {modelId} -
- {/if} -
- -

- Current context = the most recent request's prompt + output (what the - model actually held in its window that turn). Grows as the conversation - gets longer. Resets on reload. -

- {/if} -
diff --git a/packages/frontend/src/lib/components/DebugPanel.svelte b/packages/frontend/src/lib/components/DebugPanel.svelte deleted file mode 100644 index aea1ccb..0000000 --- a/packages/frontend/src/lib/components/DebugPanel.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -
-
Debug
- -
-

Conversation

-

- Copy a structured plain-text dump of the active tab's conversation - (chunk shape included) for bug reports. -

- -
-
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte deleted file mode 100644 index 72a6ebf..0000000 --- a/packages/frontend/src/lib/components/Header.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/packages/frontend/src/lib/components/HotReloadIndicator.svelte b/packages/frontend/src/lib/components/HotReloadIndicator.svelte deleted file mode 100644 index 8d37578..0000000 --- a/packages/frontend/src/lib/components/HotReloadIndicator.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -{#if visible} -
- - Config reloaded -
-{/if} diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte deleted file mode 100644 index 7c0cadc..0000000 --- a/packages/frontend/src/lib/components/KeyUsage.svelte +++ /dev/null @@ -1,477 +0,0 @@ - - -
- {#if keys.length === 0} -

No keys available.

- {:else} -
- - {#if claudeAccounts.length > 0 || claudeLoading || claudeError} -
-
- Claude - anthropic - {#if claudeLoading} - - {/if} -
- - {#if claudeAccounts.length === 0 && claudeLoading} -
- Loading... -
- {:else if claudeAccounts.length === 0 && claudeError} - - {:else} - {#if claudeError} - - {/if} - {#each claudeAccounts as acct, idx (acct.source)} - {#if idx > 0} -
- {/if} -
-
- {acct.label} - {#if acct.subscriptionType} - {acct.subscriptionType} - {/if} -
- {#if acct.error} -

{acct.error}

- {/if} - {#if hasBucketData(acct.fiveHour)} - {@const b = acct.fiveHour!} - {@const u = b.utilization ?? 0} - {@const p = Math.round(u * 100)} - {@const tp = cycleElapsedPct(b.resetsAt, FIVE_HOUR_MS)} -
-
- 5-Hour - {p}% -
-
- - {#if tp >= 0} -
- {/if} -
- {#if b.resetsAt} - Resets: {formatDate(b.resetsAt)} - {/if} -
- {/if} - {#if hasBucketData(acct.sevenDay)} - {@const b = acct.sevenDay!} - {@const u = b.utilization ?? 0} - {@const p = Math.round(u * 100)} - {@const tp = cycleElapsedPct(b.resetsAt, SEVEN_DAY_MS)} -
-
- Weekly - {p}% -
-
- - {#if tp >= 0} -
- {/if} -
- {#if b.resetsAt} - Resets: {formatDate(b.resetsAt)} - {/if} -
- {/if} -
- {/each} - {/if} -
- {/if} - - - {#each nonClaudeEntries as entry (entry.keyId)} -
-
- {formatKeyId(entry.keyId)} - {entry.provider} - {#if entry.loading} - - {/if} -
- - {#if entry.loading && !entry.data} -
- - Loading... -
- {:else} - {#if entry.error} - - {/if} - {#if !entry.data} -

No data.

- - {:else if entry.data.provider === "opencode-go"} - {#if entry.data.unavailable} -

Usage data not available. Set OPENCODE_COOKIE env var to enable.

- {#if entry.data.limits} -
- Limits: {entry.data.limits.fiveHour}/5h · {entry.data.limits.weekly}/wk · {entry.data.limits.monthly}/mo -
- {/if} - {#if entry.data.consoleUrl} - - View usage on opencode.ai - - {/if} - {:else} - {#if hasBucketData(entry.data.fiveHour)} - {@const b = entry.data.fiveHour!} - {@const u = b.utilization ?? 0} - {@const p = Math.round(u * 100)} - {@const tp = cycleElapsedPct(b.resetsAt, FIVE_HOUR_MS)} -
-
- 5-Hour - {p}% -
-
- - {#if tp >= 0} -
- {/if} -
- {#if b.resetsAt} - Resets: {formatDate(b.resetsAt)} - {/if} -
- {/if} - {#if hasBucketData(entry.data.weekly)} - {@const b = entry.data.weekly!} - {@const u = b.utilization ?? 0} - {@const p = Math.round(u * 100)} - {@const tp = cycleElapsedPct(b.resetsAt, SEVEN_DAY_MS)} -
-
- Weekly - {p}% -
-
- - {#if tp >= 0} -
- {/if} -
- {#if b.resetsAt} - Resets: {formatDate(b.resetsAt)} - {/if} -
- {/if} - {#if hasBucketData(entry.data.monthly)} - {@const b = entry.data.monthly!} - {@const u = b.utilization ?? 0} - {@const p = Math.round(u * 100)} - {@const tp = cycleElapsedPct(b.resetsAt, THIRTY_DAY_MS)} -
-
- Monthly - {p}% -
-
- - {#if tp >= 0} -
- {/if} -
- {#if b.resetsAt} - Resets: {formatDate(b.resetsAt)} - {/if} -
- {/if} - {/if} - - {:else if entry.data.provider === "github-copilot"} - {@const p = Math.round(entry.data.percentUsed ?? 0)} -
-
- - {#if entry.data.tokensConsumed !== undefined && entry.data.tokensRemaining !== undefined} - {entry.data.tokensConsumed.toLocaleString()} / {(entry.data.tokensConsumed + entry.data.tokensRemaining).toLocaleString()} tokens - {:else if entry.data.plan} - {entry.data.plan} - {:else} - Usage - {/if} - - {p}% -
- - {#if entry.data.resetAt} - Resets: {formatDate(entry.data.resetAt)} - {/if} -
- {:else if entry.data.provider === "google"} -
- - {#if entry.data.currentUsage} - {@const u = entry.data.currentUsage} -
-
- Current - {u.percentUsed}% -
- - {#if u.resetsAt} - Resets: {u.resetsAt} - {/if} -
- {/if} - {#if entry.data.weeklyUsage} - {@const w = entry.data.weeklyUsage} -
-
- Weekly - {w.percentUsed}% -
- - {#if w.resetsAt} - Resets: {w.resetsAt} - {/if} -
- {/if} - - {#if !entry.data.currentUsage && entry.data.models && entry.data.models.length > 0} - {@const m = entry.data.models[0]} -
- Models - {entry.data.models.length} available -
- {#if m && m.rpm > 0} -
- RPM - {m.rpm} -
- {/if} - {#if m && m.requestsPerDay > 0} -
- RPD - {m.requestsPerDay.toLocaleString()} -
- {/if} - {#if !entry.data.currentUsage} -

Set GEMINI_COOKIE (__Secure-1PSID) for usage %

- {/if} - {/if} -
- {/if} - {/if} -
- {/each} -
- {/if} -
diff --git a/packages/frontend/src/lib/components/MarkdownRenderer.svelte b/packages/frontend/src/lib/components/MarkdownRenderer.svelte deleted file mode 100644 index de202b6..0000000 --- a/packages/frontend/src/lib/components/MarkdownRenderer.svelte +++ /dev/null @@ -1,174 +0,0 @@ - - -
- {@html html} -
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte deleted file mode 100644 index 64036d4..0000000 --- a/packages/frontend/src/lib/components/ModelSelector.svelte +++ /dev/null @@ -1,636 +0,0 @@ - - - - -
- -
- -
- { - const val = e.currentTarget.value.trim(); - onWorkingDirectoryChange(val || null); - }} - /> - {#if workingDirectory} - {#if cwdExists === true} - - {:else if cwdExists === false} - - {:else} - - {/if} - {/if} -
-
- - -
- -
- - -
- - - -
- - {#if mode === "manual"} -
- Key - -
- -
- Model - -
- - {#if activeModelId} -
- Thinking - -
- {/if} - {:else if mode === "subagent"} - - {@const subModels = activeAgentModels ?? []} - {@const hasSubModels = subModels.length > 1} - {@const subActiveIdx = subModels.findIndex( - (m) => m.key_id === activeKeyId && m.model_id === activeModelId, - )} -
-
- SubAgent - {activeAgentSlug} -
-
- Key - {activeKeyId ?? "default"} -
-
- Model - {activeModelId ?? "default"} -
- {#if hasSubModels} - {@const displayIdx = subActiveIdx >= 0 ? subActiveIdx : 0} -
-
Model fallback chain
- -
- {#each subModels as _, i} - {i + 1} - {/each} -
-
- {#each subModels as m, i} -
- {i + 1}. {m.key_id} / {m.model_id} - {effortLabel(m.effort)} -
- {/each} -
-
- {/if} -

This tab was spawned by a parent agent. Settings cannot be changed.

-
- {:else} - - {#if loadingAgents} -
- - Loading agents... -
- {:else if visibleAgents.length === 0} -

No agents configured.

- {:else} -
- {#each visibleAgents as agent (agent.slug + ":" + agent.scope)} - {@const isActive = activeAgentSlug === agent.slug} - {@const hasMultipleModels = agent.models.length > 1} - {@const currentIdx = isActive - ? agent.models.findIndex( - (m) => m.key_id === activeKeyId && m.model_id === activeModelId, - ) - : -1} -
{ - // Only switch agent — don't reset the slider position - onAgentChange(agent); - const cwdEl = document.getElementById("cwd-input") as HTMLInputElement | null; - if (cwdEl) cwdEl.value = agent.cwd ?? ""; - }} - onkeydown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onAgentChange(agent); - const cwdEl = document.getElementById("cwd-input") as HTMLInputElement | null; - if (cwdEl) cwdEl.value = agent.cwd ?? ""; - } - }} - > -
- {agent.name} -
- {agent.models.length} model{agent.models.length !== 1 ? "s" : ""} - {agent.scope === "global" ? "global" : "project"} -
-
- {#if agent.description} -

{agent.description}

- {/if} - {#if isActive && hasMultipleModels} - {@const displayIdx = sliderDragging !== null ? sliderDragging : (currentIdx >= 0 ? currentIdx : 0)} - {@const displayModel = agent.models[displayIdx]} -
-
- {displayModel ? `${displayModel.key_id} / ${displayModel.model_id}` : `${activeKeyId} / ${activeModelId}`} - {effortLabel(displayModel?.effort)} -
- = 0 ? currentIdx : 0} - class="range range-xs" - step="1" - oninput={(e) => { - sliderDragging = Number(e.currentTarget.value); - }} - onchange={(e) => { - const idx = Number(e.currentTarget.value); - const m = agent.models[idx]; - if (m) onModelChange(m.key_id, m.model_id); - sliderDragging = null; - }} - onclick={(e) => e.stopPropagation()} - onkeydown={(e) => e.stopPropagation()} - /> -
- {#each agent.models as _, i} - {i + 1} - {/each} -
-
- {/if} -
- {/each} -
- {/if} - - - {/if} - - -
- -

- While this tab is idle, replays the cached conversation every {warmIntervalLabel} - so the provider cache stays warm for your next message. Warming traffic is - debug-only — it never touches history, the Cache Rate metric, or context size. -

- - {#if warm.enabled} -
- -
-
- Last request (warming) - {warm.lastPct === null ? "-%" : `${warm.lastPct}%`} -
- -
- - -
- Next warm in - - {#if warm.firing} - warming… - {:else if warmCountdown !== null} - {warmCountdown} - {:else} - — - {/if} - -
- - {#if warm.error} -
- {warm.error} -
- {/if} -
- {/if} -
-
- -{#if showKeyModal} - -{/if} - -{#if showModelModal} - -{/if} diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte deleted file mode 100644 index 57c5efd..0000000 --- a/packages/frontend/src/lib/components/ModelStatus.svelte +++ /dev/null @@ -1,356 +0,0 @@ - - -
- {#if keys.length === 0} -

- No models configured. Using environment defaults. -

- {:else} - - {#if allActive} -
- - All keys available -
- {:else if allExhausted} -
- - All keys exhausted — waiting for refresh -
- {:else if someExhausted} -
- - - Fallback active ({activeKeys}/{totalKeys} keys available) - -
- {/if} - - - {#if currentModel} -
-

Current Model

-

{currentModel}

-
- {/if} - - - {#if importError} - - {/if} - {#if importSuccess} -
Imported credentials for {importSuccess}
- {/if} - - - {#if keys.length > 0} -
-

API Keys

-
    - {#each keys as key (key.id)} - {@const cred = credentialStatus[key.id]} -
  • -
    - - {key.status} - - {key.id} - {key.provider} - -
    - {#if key.status === "exhausted"} -
    - {#if key.lastError} -

    - {truncate(key.lastError, 80)} -

    - {/if} - {#if key.exhaustedAt !== null} -

    {timeAgo(key.exhaustedAt)}

    - {/if} -
    - {/if} - - {#if key.provider === "anthropic"} -
    - {#if cred} - - {cred.expired ? "expired" : "imported"} - - {#if cred.subscriptionType} - {cred.subscriptionType} - {/if} - {/if} - -
    - {/if} - - {#if key.provider !== "anthropic"} -
    - {#if apiKeyStatus[key.id]} - imported - {/if} - -
    - {/if} -
  • - {/each} -
-
- {/if} - {/if} - - -{#if showKeyModal} - -{/if} - -
diff --git a/packages/frontend/src/lib/components/PermissionPrompt.svelte b/packages/frontend/src/lib/components/PermissionPrompt.svelte deleted file mode 100644 index 3a67b30..0000000 --- a/packages/frontend/src/lib/components/PermissionPrompt.svelte +++ /dev/null @@ -1,100 +0,0 @@ - - - - {#if current} - {#if !showAlwaysConfirmation} - - {:else} - - {/if} - {/if} - diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte deleted file mode 100644 index 8b28957..0000000 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ /dev/null @@ -1,643 +0,0 @@ - - -
-
Settings
- -
-

Theme

- - -
- -

Title Generation Model

-

Used to generate short titles for new tabs after the first message.

- - - - - -
- -

Conversation Compaction Model

-

Used to summarize a conversation when you compact it. If unset, the tab's own key/model is used.

- - - - - -
- -

Chat

- - -
- -

Memory

- - -
- -

Backend URL

-

API server address. Default: {config.defaultApiBase}

-
- - -
- - {#if backendUrlSaved} -

Saved. Reload the page to apply.

- {/if} - -
- -

Notifications (ntfy.sh)

-

- Push notifications to your phone when things happen here. Subscribe to your topic in the - ntfy.sh app to receive them. -

- - - - - - - -
- Notify me on: - {#each ntfyEventOrder as evType (evType)} - - {/each} -
- -
- - - Off (default): turn-completed/turn-error from subagents are suppressed. Permission prompts still fire so subagents don't silently hang. - -
- -
- - -
- {#if ntfySaveOk} -

Saved.

- {/if} - {#if ntfySaveError} -

{ntfySaveError}

- {/if} - {#if ntfyTestResult} -

{ntfyTestResult}

- {/if} -
-
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte deleted file mode 100644 index 2003856..0000000 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ /dev/null @@ -1,220 +0,0 @@ - - -
- {#each panels as panel, idx (panel.id)} -
-
- - {#if idx > 0} - - {/if} -
- -
- {#if panel.selected === "Chat Settings"} - - {:else if panel.selected === "Key Usage"} - - {:else if panel.selected === "Cache Rate"} - - {:else if panel.selected === "Context Window"} - - {:else if panel.selected === "Claude Reset"} - - {:else if panel.selected === "Model Status"} - - {:else if panel.selected === "Tasks"} - - {:else if panel.selected === "Config"} - - {:else if panel.selected === "Skills"} - - {:else if panel.selected === "Tools"} - - {:else if panel.selected === "Settings"} - - {:else if panel.selected === "Debug"} - - {/if} -
-
- {/each} - - -
diff --git a/packages/frontend/src/lib/components/SkillsBrowser.svelte b/packages/frontend/src/lib/components/SkillsBrowser.svelte deleted file mode 100644 index e697732..0000000 --- a/packages/frontend/src/lib/components/SkillsBrowser.svelte +++ /dev/null @@ -1,317 +0,0 @@ - - -
-
-
Skills
- {#if !loading} - {skills.length} - {/if} - {#if checkedCount > 0} - {checkedCount} {externalMode ? 'selected' : 'queued'} - {/if} - -
- - {#if !externalMode} -

Check skills to inject with your next message.

- {/if} - - {#if loading} -
- - Loading skills... -
- {:else if error} -
{error}
- {:else if skills.length === 0} -

- No skills found. Create .skills/ directories to get started. -

- {:else} -
- {#each dirGroups as group, idx (dirKey(group))} - {@const collapsed = collapsedDirs.has(dirKey(group))} - {@const hidden = isHiddenByParent(group)} - {@const prevGroup = dirGroups[idx - 1]} - {@const isNewTopLevel = idx === 0 || !prevGroup || topLevelDir(group) !== topLevelDir(prevGroup) || group.scope !== prevGroup.scope} - {#if !hidden} - {#if isNewTopLevel && idx > 0} -
- {/if} -
-
- - - - - {#if !collapsed} -
- {#each group.skills as skill (skillKey(skill))} - {@const key = skillKey(skill)} - {@const checked = isChecked(skill)} - {@const injected = isInjected(skill)} -
- - - {#if expandedSkill === key} -
- {#if loadingDetail} - - {:else if expandedDetail} -
{expandedDetail.content}
- {:else} -

Failed to load skill content.

- {/if} -
- {/if} -
- {/each} -
- {/if} -
-
- {/if} - {/each} -
- {/if} - - {#if !externalMode} - - {/if} -
diff --git a/packages/frontend/src/lib/components/SystemPromptPanel.svelte b/packages/frontend/src/lib/components/SystemPromptPanel.svelte deleted file mode 100644 index d9039f4..0000000 --- a/packages/frontend/src/lib/components/SystemPromptPanel.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - -
-
System Prompt
-

The base instructions sent to the AI at the start of every conversation. Tool descriptions are appended automatically. Changes are applied when you send your next message.

- - - - - -

Warning: changing the system prompt will reset the AI's prompt cache for active conversations, which may increase usage costs.

-
diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte deleted file mode 100644 index 7371f7b..0000000 --- a/packages/frontend/src/lib/components/TabBar.svelte +++ /dev/null @@ -1,234 +0,0 @@ - - - - -
{ if (e.target === e.currentTarget) tabStore.createNewTab(); }} -> - -
{ if (e.target === e.currentTarget) tabStore.createNewTab(); }} - > - - - - {#each userTabs as tab, i (tab.id)} - - - {/each} - - - -
tabStore.createNewTab()} - title="Double-click to open a new tab" - >
-
-
- - -{#if hasSubagentTabs} -
-
- {#each subagentTabs as tab (tab.id)} - - - {/each} -
-
-{/if} diff --git a/packages/frontend/src/lib/components/TaskListPanel.svelte b/packages/frontend/src/lib/components/TaskListPanel.svelte deleted file mode 100644 index 1f84bb8..0000000 --- a/packages/frontend/src/lib/components/TaskListPanel.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - -
- {#if tasks.length === 0} -

No tasks yet.

- {:else} -

- {completedCount}/{activeTotal} completed{#if inProgressCount > 0}, {inProgressCount} in progress{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if} -

-
    - {#each tasks as task (task.id)} -
  • - - - {task.content} - -
  • - {/each} -
- {/if} -
diff --git a/packages/frontend/src/lib/components/ToolCallDisplay.svelte b/packages/frontend/src/lib/components/ToolCallDisplay.svelte deleted file mode 100644 index 1b4ebca..0000000 --- a/packages/frontend/src/lib/components/ToolCallDisplay.svelte +++ /dev/null @@ -1,140 +0,0 @@ - - -
- -
{ if (e.key === 'Enter' || e.key === ' ') toggle(); }} - aria-expanded={isExpanded} - > - tool - {toolCall.name} - {#if summonAgentId !== null} - - {/if} - {#if toolCall.result !== undefined} - {#if toolCall.result.includes("[USER INTERRUPT]")} - interrupted - {:else if isShell && shellResult !== null} - - exit {shellResult.exitCode} - - {:else if toolCall.isError} - error - {:else} - done - {/if} - {:else} - pending - {/if} -
- -
-
-

Arguments

-
{JSON.stringify(toolCall.arguments, null, 2)}
-
- {#if isShell && toolCall.result !== undefined} - {#if shellResult !== null} -
-

stdout:

-
{shellResult.stdout || "(empty)"}
-
- {#if shellResult.stderr} -
-

stderr:

-
{shellResult.stderr}
-
- {/if} -
- exit code: - {shellResult.exitCode} -
- {:else} -
-

Result

-
{toolCall.result}
-
- {/if} - {:else if isShell && toolCall.shellOutput} - {#if toolCall.shellOutput.stdout} -
-

stdout

-
{toolCall.shellOutput.stdout}
-
- {/if} - {#if toolCall.shellOutput.stderr} -
-

stderr

-
{toolCall.shellOutput.stderr}
-
- {/if} - Running... - {:else if toolCall.result !== undefined} -
-

Result

-
{toolCall.result}
-
- {/if} -
-
diff --git a/packages/frontend/src/lib/components/ToolPermissions.svelte b/packages/frontend/src/lib/components/ToolPermissions.svelte deleted file mode 100644 index 4298724..0000000 --- a/packages/frontend/src/lib/components/ToolPermissions.svelte +++ /dev/null @@ -1,185 +0,0 @@ - - -
-
Tool Permissions
- {#if !externalMode} -

Changes are applied when you send your next message.

- {/if} - -
- {#each toolPermissions as perm (perm.id)} - - {/each} -
- - {#if !externalMode} - - -

Warning: changing tool access will reset the AI's prompt cache for active conversations, which may increase usage costs.

- - - {#if entries.length > 0} -
- -
- Log ({entries.length}) -
-
- {#each entries as entry (entry.id)} -
- - {entry.action} - - {entry.permission} - {entry.timestamp} -
-

{entry.description}

- {/each} -
-
- {/if} - {/if} -
diff --git a/packages/frontend/src/lib/config.ts b/packages/frontend/src/lib/config.ts deleted file mode 100644 index 0565367..0000000 --- a/packages/frontend/src/lib/config.ts +++ /dev/null @@ -1,44 +0,0 @@ -const STORAGE_KEY = "dispatch-api-url"; - -function getDefaultApiBase(): string { - if (import.meta.env.VITE_API_URL) return import.meta.env.VITE_API_URL; - // Derive from current page hostname so it works over Tailscale/LAN - if (typeof window !== "undefined" && window.location.hostname !== "localhost") { - return `http://${window.location.hostname}:3000`; - } - return "http://localhost:3000"; -} - -const DEFAULT_API_BASE = getDefaultApiBase(); - -function loadApiBase(): string { - if (typeof localStorage !== "undefined") { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) return saved; - } - return DEFAULT_API_BASE; -} - -let _apiBase = loadApiBase(); - -export const config = { - get apiBase() { - return _apiBase; - }, - get wsUrl() { - return `${_apiBase.replace(/^http/, "ws")}/ws`; - }, - get defaultApiBase() { - return DEFAULT_API_BASE; - }, - setApiBase(url: string) { - _apiBase = url; - if (typeof localStorage !== "undefined") { - if (url === DEFAULT_API_BASE) { - localStorage.removeItem(STORAGE_KEY); - } else { - localStorage.setItem(STORAGE_KEY, url); - } - } - }, -}; diff --git a/packages/frontend/src/lib/context-window.ts b/packages/frontend/src/lib/context-window.ts deleted file mode 100644 index c4321f8..0000000 --- a/packages/frontend/src/lib/context-window.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { CacheStats } from "./types.js"; - -/** - * Context-window occupancy for the current tab/model. - * - * `current` is the size of the model's context on the MOST RECENT request — - * the last turn's full prompt (`inputTokens`, which already includes cached - * tokens for Anthropic) plus what the model generated that turn - * (`outputTokens`). This mirrors how opencode derives context fullness from - * the last assistant message, and reflects what actually occupies the model's - * window — NOT the session-cumulative totals shown by the Cache Rate view. - * - * `max` is the model's maximum context window from models.dev (or `null` when - * unknown). `percent` is `current / max * 100` clamped to [0, 100] (unrounded; - * the UI decides the displayed precision), or `null` when - * `max` is unknown — in which case the UI shows the bare token count with no - * denominator or progress bar. - */ -export interface ContextUsage { - current: number; - max: number | null; - percent: number | null; -} - -export function computeContextUsage( - cacheStats: CacheStats | null | undefined, - contextLimit: number | null | undefined, -): ContextUsage { - const last = cacheStats?.last ?? null; - const current = last ? last.inputTokens + last.outputTokens : 0; - const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null; - // Precise (unrounded) percentage clamped to [0, 100]; the UI formats the - // decimal places. Kept unrounded so small contexts against huge windows - // (e.g. a few thousand tokens vs. 1,000,000) still read non-zero. - const percent = max ? Math.max(0, Math.min(100, (current / max) * 100)) : null; - return { current, max, percent }; -} diff --git a/packages/frontend/src/lib/router.svelte.ts b/packages/frontend/src/lib/router.svelte.ts deleted file mode 100644 index 8bed880..0000000 --- a/packages/frontend/src/lib/router.svelte.ts +++ /dev/null @@ -1,12 +0,0 @@ -type Page = "dashboard" | "agent-builder"; - -let currentPage = $state("dashboard"); - -export const router = { - get page() { - return currentPage; - }, - navigate(page: Page) { - currentPage = page; - }, -}; diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts deleted file mode 100644 index 1b93804..0000000 --- a/packages/frontend/src/lib/settings.svelte.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** Shared reactive app settings. */ - -let autoExpandThinking = $state(false); -let systemPrompt = $state(""); -let savedSystemPrompt = $state(""); -let toolPerms = $state>({ - read: true, - edit: false, - bash: false, - summon: false, - user_agent: false, - send_to_tab: false, - read_tab: false, - external_directory: false, - web_search: false, - youtube_transcribe: false, - search_code: false, - key_usage: false, - lsp: false, -}); -let savedToolPerms = $state>({ - read: true, - edit: false, - bash: false, - summon: false, - user_agent: false, - send_to_tab: false, - read_tab: false, - external_directory: false, - web_search: false, - youtube_transcribe: false, - search_code: false, - key_usage: false, - lsp: false, -}); -let skillChecks = $state>({}); -let chunkLimit = $state(100); - -export const appSettings = { - get chunkLimit() { - return chunkLimit; - }, - set chunkLimit(v: number) { - chunkLimit = v; - }, - get autoExpandThinking() { - return autoExpandThinking; - }, - set autoExpandThinking(v: boolean) { - autoExpandThinking = v; - }, - get systemPrompt() { - return systemPrompt; - }, - set systemPrompt(v: string) { - systemPrompt = v; - }, - get savedSystemPrompt() { - return savedSystemPrompt; - }, - set savedSystemPrompt(v: string) { - savedSystemPrompt = v; - }, - get toolPerms() { - return toolPerms; - }, - set toolPerms(v: Record) { - toolPerms = v; - }, - get savedToolPerms() { - return savedToolPerms; - }, - set savedToolPerms(v: Record) { - savedToolPerms = v; - }, - get toolPermsDirty() { - return Object.keys(toolPerms).some((k) => toolPerms[k] !== savedToolPerms[k]); - }, - get skillChecks() { - return skillChecks; - }, - set skillChecks(v: Record) { - skillChecks = v; - }, - get skillChecksDirty() { - return Object.values(skillChecks).some((v) => v); - }, -}; diff --git a/packages/frontend/src/lib/sidebar-storage.ts b/packages/frontend/src/lib/sidebar-storage.ts deleted file mode 100644 index 9d9928f..0000000 --- a/packages/frontend/src/lib/sidebar-storage.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * LocalStorage persistence for the sidebar panel layout. - * - * The sidebar (`SidebarPanel.svelte`) is a variable-length stack of - * "slot" components; each slot has a dropdown that picks one of N view - * components to render (Chat Settings, Tasks, Skills, etc.). The - * source-of-truth `panels` array in the component records the order - * and selection of each slot. This module persists that array's - * `selected` field across browser refreshes/loads. - * - * Why localStorage and not the backend `settings` table: - * - The sidebar layout is a UI preference, not domain state. It - * matches the precedent set by `dispatch-theme` and - * `dispatch-api-url`, both of which are localStorage. - * - Per-device layout is reasonable (a phone may want different - * panels than a desktop). - * - No backend round-trip on every layout change. - * - * Why only the `selected` strings and not the full `Panel` objects: - * - The `id` field is a session-ephemeral counter - * (`SidebarPanel.svelte:61`) that exists only to keep Svelte's - * `{#each ... (panel.id)}` block keyed across reorders. Restoring - * ids verbatim would have no benefit, and would collide with the - * module-scoped `nextId` counter on remount. - * - The `selected` string is everything we need to reconstruct the - * visible layout. - */ - -const LS_KEY = "dispatch-sidebar-panels"; - -/** - * The fallback layout when nothing is stored or the stored value is - * unusable. Matches the hardcoded initial state at - * `SidebarPanel.svelte:62` so first-ever load is unchanged: a single - * "Chat Settings" panel. - */ -const DEFAULT_LAYOUT: ReadonlyArray = ["Chat Settings"]; - -/** - * Read the persisted sidebar layout. Returns an array of - * `panel.selected` strings in render order (top-to-bottom). - * - * Falls back to `DEFAULT_LAYOUT` on any of: - * - localStorage key absent (first-ever load) - * - JSON.parse throws (corrupt write from a prior session) - * - parsed value is not an array (someone hand-edited storage) - * - parsed array, after filtering non-string entries, is empty - * (preserves the "minimum one panel" invariant the UI enforces - * via `{#if idx > 0}` on the remove button) - * - * Never throws. - */ -export function loadSidebarPanels(): string[] { - try { - const raw = localStorage.getItem(LS_KEY); - if (!raw) return [...DEFAULT_LAYOUT]; - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return [...DEFAULT_LAYOUT]; - // Discard any non-string entries — they could only come from a - // hand-edited localStorage or a future schema mismatch. Either - // way, we don't want to render `undefined` as a panel name. - const cleaned = parsed.filter((x): x is string => typeof x === "string"); - return cleaned.length > 0 ? cleaned : [...DEFAULT_LAYOUT]; - } catch { - // localStorage access threw (SecurityError in restricted browser - // contexts, etc.). Fall through to default. - return [...DEFAULT_LAYOUT]; - } -} - -/** - * Persist the current sidebar layout. Best-effort: errors are - * swallowed (quota exceeded, localStorage disabled in private mode, - * SecurityError, etc.). The next save attempt may succeed; even if - * none do, the current session continues to work — only the cross- - * reload restore is degraded. - */ -export function saveSidebarPanels(selected: string[]): void { - try { - localStorage.setItem(LS_KEY, JSON.stringify(selected)); - } catch { - // Best-effort. - } -} diff --git a/packages/frontend/src/lib/snapshot-sequencer.ts b/packages/frontend/src/lib/snapshot-sequencer.ts deleted file mode 100644 index fccc9ef..0000000 --- a/packages/frontend/src/lib/snapshot-sequencer.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Tiny race guard for "the most-recent request wins" semantics. - * - * When a frontend component fans out multiple HTTP calls that each return a - * full snapshot of shared state — and applying an older snapshot would clobber - * a newer one — wrap each call with `seq = sequencer.begin()` before send and - * `sequencer.accept(seq)` before applying the response. Older sequences are - * rejected. - * - * Why: the Claude Wake Schedule's POST /toggle and GET /wake-schedule both - * return the *whole* schedule. If a user toggles hour 9 (request A) and then - * hour 10 (request B), and B's response arrives before A's, the older A - * response — which doesn't know about hour 10 yet — would otherwise overwrite - * hour 10 right out of the UI. A per-hour counter is NOT enough because the - * race spans different hours (and also covers the initial-load vs first-click - * race). - * - * `>=` on accept is intentional: if seq equals the latest applied seq, the - * response is a redundant arrival of the most-recent winner — accepting it - * (idempotently) is fine. The discriminator is *strictly less than*. - */ -export class SnapshotSequencer { - private nextSeq = 0; - private latestApplied = 0; - - /** Tag a new request. Call before sending; pass the returned seq to accept(). */ - begin(): number { - this.nextSeq += 1; - return this.nextSeq; - } - - /** - * Decide whether to apply a response. Returns true if this seq is the - * newest seen so far (and updates the watermark); false if a newer - * response has already won. - */ - accept(seq: number): boolean { - if (seq < this.latestApplied) return false; - this.latestApplied = seq; - return true; - } - - /** Inspect (for tests / debugging). */ - get state(): { nextSeq: number; latestApplied: number } { - return { nextSeq: this.nextSeq, latestApplied: this.latestApplied }; - } -} diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts deleted file mode 100644 index 16805df..0000000 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ /dev/null @@ -1,2441 +0,0 @@ -// Import the chunk-builder helpers directly from core so the frontend store -// and the backend agent share the exact same wire-format logic. Deep import -// is intentional: the core barrel pulls in node-only deps (chokidar, etc.) -// that don't belong in the browser bundle. -import { - appendEventToChunks, - applySystemEvent, - type IdentifiedMessage, - type SystemEventLike, -} from "@dispatch/core/src/chunks/append.js"; -// DB-free; safe in the browser bundle. The flat chunk log is the frontend's -// source of truth for HISTORY; `groupRowsToMessages` derives render bubbles. -import { groupRowsToMessages, type MessageRow } from "@dispatch/core/src/chunks/transform.js"; -import type { ChunkRow, UserContentPart } from "@dispatch/core/src/types/index.js"; -import { - type AgentModelEntry, - DEFAULT_REASONING_EFFORT, - isReasoningEffort, - type ReasoningEffort, -} from "@dispatch/core/src/types/index.js"; -import { intactTokenIds, type StagedAttachment } from "./attachment-tokens.js"; -import { cacheWarming } from "./cache-warming.svelte.js"; -import { config } from "./config.js"; -import { appSettings } from "./settings.svelte.js"; -import type { - AgentEvent, - CacheStats, - ChatMessage, - Chunk, - DebugInfo, - LogEntry, - PermissionPrompt, - QueuedMessage, - TabStatusSnapshot, - TaskItem, -} from "./types.js"; -import { wsClient } from "./ws.svelte.js"; - -function generateId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - // Fallback for non-secure contexts (HTTP over Tailscale/LAN) - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - return (c === "x" ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -function makeDebugInfo(overrides: Partial = {}): DebugInfo { - return { - timestamp: new Date().toISOString(), - connectionStatus: wsClient.connectionStatus, - ...overrides, - }; -} - -// ─── Chunk-log → render projection ─────────────────────────────── -// -// History lives as a flat `ChunkRow[]` (sealed, real seq). For rendering we -// group it into bubbles with `groupRowsToMessages` (pairs tool_call+tool_result -// by callId, wraps a turn's assistant chunks) — a pure, ephemeral view, never -// stored as the source of truth. - -/** Map a grouped chunk-row message to a render `ChatMessage`. */ -function rowGroupToMessage(m: MessageRow): ChatMessage { - return { - id: m.id, - role: m.role, - chunks: m.chunks, - isStreaming: false, - seq: m.seq, - turnId: m.turnId, - }; -} - -/** - * The render view for a tab: grouped sealed chunks followed by the transient - * live tail (current unsealed turn). This is what the chat panel renders. - */ -function deriveRenderGroups(chunks: ChunkRow[], live: ChatMessage[]): ChatMessage[] { - const sealed = groupRowsToMessages(chunks).map(rowGroupToMessage); - return live.length > 0 ? [...sealed, ...live] : sealed; -} - -/** Total chunk count of the live tail (for the eviction budget). */ -function countLiveChunks(live: ChatMessage[]): number { - return live.reduce((sum, m) => sum + m.chunks.length, 0); -} - -/** Smallest `seq` among sealed chunk rows, or null when empty. */ -function minSeqOf(chunks: ChunkRow[]): number | null { - let min: number | null = null; - for (const c of chunks) { - if (typeof c.seq === "number" && (min === null || c.seq < min)) min = c.seq; - } - return min; -} - -/** Merge older chunk rows into a window, dedupe by `seq`, keep ascending. */ -function mergeChunksBySeq(existing: ChunkRow[], incoming: ChunkRow[]): ChunkRow[] { - const bySeq = new Map(); - for (const c of existing) bySeq.set(c.seq, c); - for (const c of incoming) bySeq.set(c.seq, c); - return [...bySeq.values()].sort((a, b) => a.seq - b.seq); -} - -/** Fetch a raw chunk window from the backend (the chunk-native load source). */ -async function fetchChunkWindow( - tabId: string, - params: { limit?: number; before?: number } = {}, -): Promise<{ ok: boolean; chunks: ChunkRow[]; total: number; oldestSeq: number | null }> { - const qs = new URLSearchParams(); - if (params.limit !== undefined) qs.set("limit", String(params.limit)); - if (params.before !== undefined) qs.set("before", String(params.before)); - const q = qs.toString(); - try { - const res = await fetch(`${config.apiBase}/tabs/${tabId}/chunks${q ? `?${q}` : ""}`); - if (!res.ok) return { ok: false, chunks: [], total: 0, oldestSeq: null }; - const data = (await res.json()) as { - chunks?: ChunkRow[]; - total?: number; - oldestSeq?: number | null; - }; - const chunks = Array.isArray(data.chunks) ? data.chunks : []; - return { - ok: true, - chunks, - total: data.total ?? chunks.length, - oldestSeq: data.oldestSeq ?? minSeqOf(chunks), - }; - } catch { - return { ok: false, chunks: [], total: 0, oldestSeq: null }; - } -} - -export interface Tab { - id: string; - title: string; - /** - * SEALED conversation history as a flat chunk log (real per-tab `seq`). - * The source of truth for history and the unit of eviction + pagination. - */ - chunks: ChunkRow[]; - /** - * Transient render buffer for the CURRENT (unsealed) turn only: the - * optimistic user message, the in-flight assistant turn (folded from - * stream deltas), queued/consumed user messages, interrupt splits. Tiny and - * short-lived — cleared and folded into `chunks` (via refetch) the moment - * the turn seals. NOT stored history. - */ - live: ChatMessage[]; - /** - * Materialized render projection = groupRowsToMessages(chunks) ++ live, - * recomputed by `updateTab` after any change to `chunks`/`live`. A derived - * cache for the view layer — NOT the source of truth, never the - * eviction/pagination unit. - */ - renderGroups: ChatMessage[]; - /** turn_id of the in-flight turn (stable render keys + reconcile). */ - liveTurnId: string | null; - agentStatus: "idle" | "running" | "error"; - keyId: string | null; - modelId: string | null; - reasoningEffort: ReasoningEffort; - currentAssistantId: string | null; - tasks: TaskItem[]; - injectedSkills: string[]; - parentTabId: string | null; - persistent: boolean; - agentSlug: string | null; - agentScope: string | null; - agentModels: AgentModelEntry[] | null; - workingDirectory: string | null; - queuedMessages: QueuedMessage[]; - chunkLimit: number; - /** Smallest `seq` currently in `chunks` — the backward-pagination cursor. */ - oldestLoadedSeq: number | null; - /** Total chunk count for this tab on the backend (drives "more to load?"). */ - totalChunks: number; - /** - * Unsent chat-input text for THIS tab (in-memory only — never persisted). - * Saved/restored on tab switch so a draft is never lost or clobbered by - * switching tabs. Cleared on send. - */ - draft: string; - /** - * Staged image/PDF attachments for THIS tab's unsent draft (in-memory only — - * never persisted). Each corresponds to an inline `【image:…】`/`【pdf:…】` - * token in `draft`; removing the token detaches the attachment (reconciled on - * every keystroke). Ephemeral: sent to the model for one turn, then cleared. - */ - attachments: StagedAttachment[]; - /** - * True once the user has manually renamed this tab (double-click rename). - * Suppresses the first-message auto-title so a chosen name is never - * clobbered. In-memory only — a renamed tab is no longer "New Tab" on - * reload, so the auto-title guard already won't fire for it. - */ - manualTitle: boolean; - /** - * Cumulative prompt-cache token telemetry for this tab since the page - * loaded (in-memory only — resets on reload). Undefined until the first - * `usage` event arrives. Drives the "Cache Rate" sidebar view. - */ - cacheStats?: CacheStats; - /** - * Compaction UI state. `compactingSource` is set on a TRANSIENT placeholder - * tab while it hosts the "compacting…" screen, naming the conversation being - * compacted. `isCompacting` is set on the SOURCE tab while its compaction is - * in flight (input locked). Both clear when compaction settles. - */ - compactingSource?: string | null; - isCompacting?: boolean; - /** Error message shown on a placeholder tab when compaction fails. */ - compactionError?: string | null; -} - -/** - * Build a fresh tab store. Exported so tests can construct a real - * `$state`-backed instance per test — the production singleton is - * exported below as `tabStore`. The previous test harness duplicated - * the store logic against POJO arrays, which made the - * `structuredClone(svelteProxy)` bug undetectable: native `structuredClone` - * works on plain arrays and throws on Svelte reactive proxies. See the - * `chat-store.test.ts` rewrite for the proper integration tests that - * drive the actual reactive code path. - */ -export function createTabStore() { - let tabs: Tab[] = $state([]); - let activeTabId: string | null = $state(null); - let pendingPermissions: PermissionPrompt[] = $state([]); - let permissionLog: LogEntry[] = $state([]); - let configReloaded = $state(false); - let isConnected = $state(false); - - // Track message IDs that were consumed before the POST /chat response arrived. - // Keyed by queueId — if consumed before we process the response, we skip the queued state. - const recentlyConsumedIds = new Set(); - - // Tabs whose UI is currently scrolled up (viewing older history). While a - // tab is in this set, automatic eviction is suppressed so messages don't - // vanish out from under the user's viewport. ChatPanel toggles this via - // `setScrolledUp`. A `force` eviction ignores this set entirely. - const scrolledUpTabs = new Set(); - - // tabId → the turn_id whose reconcile was deferred because the user was - // scrolled up. A Map (not a Set) so the deferred flush knows which turn - // sealed and can preserve a newer turn that started streaming meanwhile. - // Flushed when they return to the bottom so we don't yank their viewport. - const pendingReconcileTabs = new Map(); - - // Clear any stale listeners from HMR reloads, then register - wsClient.clearCallbacks(); - wsClient.onEvent((event) => { - handleEvent(event as AgentEvent & { tabId?: string }); - }); - - // Let the cache-warming store resolve a tab's provider request params - // (key/model/fallback chain) at fire time, straight from live tab state. - cacheWarming.setRequestResolver((tabId) => { - const t = getTabById(tabId); - if (!t) return null; - return { - keyId: t.keyId, - modelId: t.modelId, - agentModels: t.agentModels, - reasoningEffort: t.reasoningEffort, - }; - }); - - $effect.root(() => { - $effect(() => { - isConnected = wsClient.connectionStatus === "connected"; - }); - }); - - function getActiveTab(): Tab | undefined { - return tabs.find((t) => t.id === activeTabId); - } - - function getTabById(id: string): Tab | undefined { - return tabs.find((t) => t.id === id); - } - - /** - * Minimum display length of a tab handle (git-style short id). Mirrors - * `MIN_TAB_PREFIX_LENGTH` in core's `db/tabs.ts` so the handle the user sees - * is always resolvable by the backend's `resolveTabPrefix`. - */ - const MIN_HANDLE_LENGTH = 4; - - /** - * Compute the shortest unique prefix (≥ MIN_HANDLE_LENGTH chars) of `tabId` - * among all currently-open tabs — the displayed "handle" agents use to - * address each other. Purely DERIVED from the UUIDs already in `tabs`; never - * stored. Grows by one char only when another open tab shares the prefix, and - * shrinks back when that sibling closes. - */ - function shortHandleFor(tabId: string): string { - const others = tabs.map((t) => t.id).filter((id) => id !== tabId); - for (let len = MIN_HANDLE_LENGTH; len < tabId.length; len++) { - const candidate = tabId.slice(0, len); - if (!others.some((id) => id.startsWith(candidate))) return candidate; - } - return tabId; - } - - async function createNewTab(): Promise { - const id = generateId(); - const title = "New Tab"; - - // Create on backend - try { - await fetch(`${config.apiBase}/tabs`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id, title }), - }); - } catch { - // Continue even if backend fails — tab works locally - } - - const tab: Tab = { - id, - title, - chunks: [], - live: [], - renderGroups: [], - liveTurnId: null, - agentStatus: "idle", - keyId: null, - modelId: null, - reasoningEffort: DEFAULT_REASONING_EFFORT, - currentAssistantId: null, - tasks: [], - injectedSkills: [], - parentTabId: null, - persistent: true, - agentSlug: null, - agentScope: null, - agentModels: null, - workingDirectory: null, - queuedMessages: [], - chunkLimit: appSettings.chunkLimit, - draft: "", - attachments: [], - manualTitle: false, - oldestLoadedSeq: null, - totalChunks: 0, - compactingSource: null, - isCompacting: false, - compactionError: null, - }; - tabs = [...tabs, tab]; - activeTabId = id; - cacheWarming.initTab(id); - - // Auto-check default skills then apply default agent (sequential to avoid race) - void (async () => { - await autoCheckDefaultSkills(); - await autoSelectDefaultAgent(id); - })(); - - return tab; - } - - function switchTab(id: string): void { - if (tabs.some((t) => t.id === id)) { - activeTabId = id; - } - } - - function promoteTab(id: string): void { - const tab = getTabById(id); - if (!tab) return; - updateTab(id, { persistent: true }); - switchTab(id); - } - - async function openAgentTab(agentId: string): Promise { - const tab = getTabById(agentId); - if (tab) { - updateTab(agentId, { persistent: true }); - switchTab(agentId); - return; - } - - // Tab not found locally — try to fetch from backend - try { - const tabRes = await fetch(`${config.apiBase}/tabs/${agentId}`); - if (!tabRes.ok) return; // 404 or other error — tab doesn't exist - const tabData = (await tabRes.json()) as { - id: string; - title: string; - keyId?: string | null; - modelId?: string | null; - status?: string; - parentTabId?: string | null; - }; - - // Load the tail of the flat chunk log (raw rows — the frontend groups - // for render and evicts/paginates on the flat list). - const win = await fetchChunkWindow(agentId, { limit: 100 }); - - const newTab: Tab = { - id: agentId, - title: tabData.title, - chunks: win.chunks, - live: [], - renderGroups: deriveRenderGroups(win.chunks, []), - liveTurnId: null, - agentStatus: "idle", - keyId: tabData.keyId ?? null, - modelId: tabData.modelId ?? null, - reasoningEffort: DEFAULT_REASONING_EFFORT, - currentAssistantId: null, - tasks: [], - injectedSkills: [], - parentTabId: tabData.parentTabId ?? null, - persistent: true, - agentSlug: null, - agentScope: null, - agentModels: null, - workingDirectory: null, - queuedMessages: [], - chunkLimit: appSettings.chunkLimit, - draft: "", - attachments: [], - manualTitle: false, - oldestLoadedSeq: win.oldestSeq, - totalChunks: win.total, - }; - tabs = [...tabs, newTab]; - activeTabId = agentId; - cacheWarming.initTab(agentId); - evictChunks(agentId); - } catch (err) { - console.error("openAgentTab failed:", err); - } - } - - async function closeTab(id: string): Promise { - const tab = getTabById(id); - if (!tab) return; - - cacheWarming.forgetTab(id); - - // Archive on backend (also stops any running agent) - try { - await fetch(`${config.apiBase}/tabs/${id}`, { method: "DELETE" }); - } catch { - // Continue with local removal - } - - tabs = tabs.filter((t) => t.id !== id); - - // If we closed the active tab, switch to the last remaining or create a new one - if (activeTabId === id) { - if (tabs.length > 0) { - const fallback = tabs[tabs.length - 1]; - if (fallback && !fallback.persistent) { - updateTab(fallback.id, { persistent: true }); - } - activeTabId = fallback?.id ?? null; - } else { - await createNewTab(); - } - } - } - - function updateTab(id: string, patch: Partial): void { - tabs = tabs.map((t) => { - if (t.id !== id) return t; - const next = { ...t, ...patch }; - // `renderGroups` is a derived cache: recompute it whenever its inputs - // (`chunks` / `live`) change so the view layer never reads a stale - // projection. Callers only ever mutate `chunks`/`live`. - if ("chunks" in patch || "live" in patch) { - next.renderGroups = deriveRenderGroups(next.chunks, next.live); - } - return next; - }); - } - - /** - * Rename a tab. Records `manualTitle` so the first-message auto-title never - * clobbers the user's chosen name, and persists the new title to the DB - * (fire-and-forget — the optimistic local update is the source of truth for - * the open session). - */ - function renameTab(id: string, title: string): void { - const trimmed = title.trim(); - if (!trimmed) return; - updateTab(id, { title: trimmed, manualTitle: true }); - fetch(`${config.apiBase}/tabs/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: trimmed }), - }).catch(() => {}); - } - - /** - * Reorder the top-row USER tabs to match `orderedUserTabIds`. Subagent tabs - * (those with a `parentTabId`) keep their relative order untouched — they - * live in a separate row and aren't draggable. The new left-to-right user - * order is persisted via `PATCH /tabs/reorder`, which rewrites each open - * tab's `position` (fire-and-forget, matching the title-persist style). - */ - function reorderTabs(orderedUserTabIds: string[]): void { - const byId = new Map(tabs.map((t) => [t.id, t])); - const ordered = orderedUserTabIds - .map((id) => byId.get(id)) - .filter((t): t is Tab => t !== undefined && t.parentTabId === null); - // Bail if the requested order doesn't cover exactly the current user tabs - // (stale drag against a since-changed tab set) — never drop tabs. - const currentUserCount = tabs.filter((t) => t.parentTabId === null).length; - if (ordered.length !== currentUserCount) return; - const subagentTabs = tabs.filter((t) => t.parentTabId !== null); - tabs = [...ordered, ...subagentTabs]; - // Persist the full open-tab order (user tabs first, then subagents) so the - // backend `position` column matches what the user sees on reload. - const persistOrder = [...ordered, ...subagentTabs].map((t) => t.id); - fetch(`${config.apiBase}/tabs/reorder`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ids: persistOrder }), - }).catch(() => {}); - } - - /** - * Persist the unsent chat-input text for a tab (in-memory only). Saved on - * every keystroke so switching tabs preserves the draft and restoring the - * target tab shows its own text. No-op if the tab is gone. - */ - function setDraft(id: string, text: string): void { - const tab = getTabById(id); - if (!tab) return; - // Detach any staged attachment whose inline token is no longer intact in - // the new draft text (covers atomic-delete, manual mid-token edits, cut, - // select-all-delete, etc.). The token in the textarea is the ONLY handle - // on an attachment, so reconciling here keeps the two in lockstep. - const intact = intactTokenIds(text); - const keep = tab.attachments.filter((a) => intact.has(a.id)); - if (keep.length !== tab.attachments.length) { - updateTab(id, { draft: text, attachments: keep }); - } else { - updateTab(id, { draft: text }); - } - } - - /** - * Stage a pasted attachment on a tab. The caller is responsible for also - * inserting the matching `【image:…】`/`【pdf:…】` token into the draft (the - * token is what keeps the attachment alive through reconciliation). No-op if - * the tab is gone. - */ - function addAttachment(id: string, attachment: StagedAttachment): void { - const tab = getTabById(id); - if (!tab) return; - updateTab(id, { attachments: [...tab.attachments, attachment] }); - } - - /** - * Record whether a tab's chat view is scrolled up (viewing older history). - * Used to suppress automatic eviction while the user is reading old - * messages — we don't want to delete what they're currently looking at. - */ - function setScrolledUp(tabId: string, scrolledUp: boolean): void { - if (scrolledUp) { - scrolledUpTabs.add(tabId); - } else { - scrolledUpTabs.delete(tabId); - // Returned to the bottom — run any reconcile we deferred while reading. - const deferredTurnId = pendingReconcileTabs.get(tabId); - if (deferredTurnId !== undefined) { - pendingReconcileTabs.delete(tabId); - reconcileSealedTurn(tabId, deferredTurnId); - } - } - } - - /** - * Drop up to `n` of the oldest chunks from the live tail (front-to-back - * across its messages), never removing the chunk currently being streamed - * (the last chunk of the in-flight assistant message). Emptied messages are - * dropped. Used only when a single in-flight turn alone exceeds the budget. - */ - function trimLiveChunks( - live: ChatMessage[], - n: number, - streamingId: string | null, - ): ChatMessage[] { - let remaining = n; - const out = live.map((m) => ({ ...m, chunks: [...m.chunks] })); - for (const m of out) { - if (remaining <= 0) break; - const isStreamingMsg = m.id === streamingId || m.isStreaming === true; - while (m.chunks.length > 0 && remaining > 0) { - // Keep the last (open) chunk of the actively streaming message. - if (isStreamingMsg && m.chunks.length === 1) break; - m.chunks.shift(); - remaining--; - } - } - return out.filter((m) => m.chunks.length > 0); - } - - /** - * Bound a tab's in-memory footprint to `chunkLimit` by rolling eviction of - * the OLDEST chunks. Sealed history (`tab.chunks`) is trimmed from the front - * first; if a single in-flight turn alone still exceeds the budget, the - * oldest chunks of the live tail are trimmed too (never the chunk currently - * being streamed). Evicted sealed chunks are re-fetched on scroll-up via - * `loadOlderChunks`; live chunks that haven't sealed yet are recovered by - * the turn-completion reconcile once their write lands. Suppressed while - * scrolled up unless `force` is set. - */ - function evictChunks(tabId: string, force = false): void { - const tab = getTabById(tabId); - if (!tab) return; - if (!force && scrolledUpTabs.has(tabId)) return; - - const limit = appSettings.chunkLimit; - if (!Number.isFinite(limit) || limit <= 0) return; - - let sealed = tab.chunks; - let live = tab.live; - let total = sealed.length + countLiveChunks(live); - if (total <= limit) return; - - // 1. Drop oldest sealed chunk rows from the front. - if (sealed.length > 0) { - let dropTo = 0; - while (total > limit && dropTo < sealed.length) { - dropTo++; - total--; - } - if (dropTo > 0) sealed = sealed.slice(dropTo); - } - - // 2. Still over budget → one live turn exceeds the limit on its own. - if (total > limit && live.length > 0) { - live = trimLiveChunks(live, total - limit, tab.currentAssistantId); - } - - updateTab(tabId, { - chunks: sealed, - live, - oldestLoadedSeq: minSeqOf(sealed) ?? tab.oldestLoadedSeq, - }); - } - - /** - * Fetch and prepend the next older page of CHUNKS (raw rows). Called when - * the user scrolls toward the top. Pages backward by the oldest loaded - * `seq` (`?before=`), dedupes by `seq`, and keeps the window seq-sorted — - * so a turn split across the window boundary regroups into one bubble with - * no special-casing. Does NOT evict (the user is reading history). - */ - async function loadOlderChunks(tabId: string): Promise { - const tab = getTabById(tabId); - if (!tab) return; - const before = tab.oldestLoadedSeq; - const win = await fetchChunkWindow(tabId, { - limit: 50, - ...(before !== null ? { before } : {}), - }); - const current = getTabById(tabId); - if (!current) return; - if (win.chunks.length === 0) { - // Nothing older; refresh the total if the backend reported a real one. - if (win.total > 0) updateTab(tabId, { totalChunks: win.total }); - return; - } - const merged = mergeChunksBySeq(current.chunks, win.chunks); - updateTab(tabId, { - chunks: merged, - oldestLoadedSeq: minSeqOf(merged), - totalChunks: win.total, - }); - } - - function ensureAssistantMessage(tabId: string): ChatMessage | null { - const tab = getTabById(tabId); - if (!tab) return null; - - if (tab.currentAssistantId) { - const existing = tab.live.find((m) => m.id === tab.currentAssistantId); - if (existing) return existing; - } - - const id = generateId(); - const newMsg: ChatMessage = { - id, - role: "assistant", - chunks: [], - isStreaming: true, - ...(tab.liveTurnId !== null ? { turnId: tab.liveTurnId } : {}), - }; - updateTab(tabId, { - currentAssistantId: id, - live: [...tab.live, newMsg], - }); - evictChunks(tabId); - return newMsg; - } - - /** - * Update the live tail (the current unsealed turn). All streaming handlers - * operate here; sealed history (`tab.chunks`) is never touched by streaming. - */ - function updateLive(tabId: string, updater: (live: ChatMessage[]) => ChatMessage[]): void { - const tab = getTabById(tabId); - if (!tab) return; - updateTab(tabId, { live: updater(tab.live) }); - } - - /** - * Apply a content-producing event to the in-flight assistant message via the - * shared core helper. - * - * Reactivity contract: `appendEventToChunks` mutates the chunks array in - * place, but Svelte 5 `$state` only triggers updates when we reassign at the - * `tabs` array level. We snapshot the message's chunks via - * `$state.snapshot` (Svelte's own safe clone — strips reactive proxies and - * falls back gracefully where native `structuredClone` would throw - * `DataCloneError` on a `$state` proxy), mutate the snapshot, then write - * it back through `updateLive`. The previous use of `structuredClone` - * here threw silently and was swallowed by the WS try/catch — left chunks - * empty for every streaming turn. - */ - function applyChunkEvent(tabId: string, event: AgentEvent): void { - ensureAssistantMessage(tabId); - const tab = getTabById(tabId); - if (!tab) return; - const currentId = tab.currentAssistantId; - if (!currentId) return; - updateLive(tabId, (msgs) => - msgs.map((m) => { - if (m.id !== currentId) return m; - const cloned = $state.snapshot(m.chunks) as Chunk[]; - // The frontend's local AgentEvent is structurally compatible with - // core's for every variant the helper cares about; the variants - // where shapes differ (tab-created, done, status, message-*) are - // all in the helper's no-op branch. - appendEventToChunks(cloned, event as unknown as Parameters[1]); - return { ...m, chunks: cloned, isStreaming: true }; - }), - ); - // A chunk may have just completed — keep the in-memory footprint bounded. - evictChunks(tabId); - } - - /** - * Route a system event when there's no in-flight assistant turn. Wraps - * `applySystemEvent` from core, which either appends a `system` chunk to - * the most recent `role: "system"` message or creates a new one. - */ - function routeSystemEvent(tabId: string, sysEvent: SystemEventLike): void { - const tab = getTabById(tabId); - if (!tab) return; - // Operate on the live tail (applySystemEvent appends a system chunk to - // the trailing system message or creates one). Build a shallow-cloned - // IdentifiedMessage[] view via `$state.snapshot` (safe against Svelte 5 - // reactive proxies; native `structuredClone` would throw), run the - // helper, then write it back. The backend persists this system row too, - // so it reconciles into `chunks` on the next turn/load. - const view: IdentifiedMessage[] = tab.live.map((m) => ({ - id: m.id, - role: m.role, - chunks: $state.snapshot(m.chunks) as Chunk[], - })); - applySystemEvent(view, sysEvent, generateId); - - // Reconcile: rebuild the live array from the view, preserving existing - // message metadata (isStreaming, debugInfo) where IDs match. - const byId = new Map(tab.live.map((m) => [m.id, m])); - const rebuilt: ChatMessage[] = view.map((v) => { - const existing = byId.get(v.id); - if (existing) { - return { ...existing, role: v.role, chunks: v.chunks as Chunk[] }; - } - return { - id: v.id, - role: v.role, - chunks: v.chunks as Chunk[], - isStreaming: false, - }; - }); - updateTab(tabId, { live: rebuilt }); - } - - /** - * Reload a tab's chunk window from the API and fold the sealed turn out of - * the live tail. The persisted chunk log is the source of truth. Two modes: - * - turn-completion reconcile (`preserveActiveTurn=true`, `sealedTurnId` - * set): the just-sealed turn's rows now carry real seqs. Drop that turn - * from `live`, but PRESERVE (a) a newer turn that began streaming while a - * reconcile was deferred — the queued-message race — and (b) optimistic - * user messages not yet bound to a turn, so neither is wiped. - * - WS-reconnect desync (`preserveActiveTurn=false`): the backend has moved - * on and is idle, so trust the DB fully and clear the live tail. - * A failed fetch is a no-op (never wipes a populated tab). - */ - async function reloadChunksFromApi( - tabId: string, - preserveActiveTurn = false, - sealedTurnId?: string, - ): Promise { - const win = await fetchChunkWindow(tabId, { limit: 100 }); - if (!win.ok) return; - const current = getTabById(tabId); - if (!current) return; - // A turn that started streaming AFTER the one being reconciled must not be - // wiped — only the sealed turn folds into `chunks`. - const preserveTurnId = - preserveActiveTurn && current.liveTurnId !== null && current.liveTurnId !== sealedTurnId - ? current.liveTurnId - : null; - const keptLive = preserveActiveTurn - ? current.live.filter( - (m) => - (preserveTurnId !== null && m.turnId === preserveTurnId) || - // Optimistic / queued user messages not yet bound to a turn. - (m.turnId === undefined && m.role === "user"), - ) - : []; - const stillActive = preserveTurnId !== null; - updateTab(tabId, { - chunks: win.chunks, - live: keptLive, - liveTurnId: stillActive ? current.liveTurnId : null, - currentAssistantId: stillActive ? current.currentAssistantId : null, - oldestLoadedSeq: win.oldestSeq, - totalChunks: win.total, - }); - evictChunks(tabId); - } - - /** - * Turn-completion reconcile. On `turn-sealed`, fold the just-finished turn - * (`sealedTurnId`) into the sealed log by reloading the chunk window (real - * seqs) and dropping that turn from the live tail — while preserving any - * newer in-flight turn and not-yet-sealed optimistic user messages. Deferred - * while the user is scrolled up so the viewport isn't disturbed; re-attempted - * (with the same `sealedTurnId`) when they return to the bottom. - */ - function reconcileSealedTurn(tabId: string, sealedTurnId: string): void { - const tab = getTabById(tabId); - if (!tab) return; - if (tab.live.length === 0 && tab.liveTurnId === null) return; - if (scrolledUpTabs.has(tabId)) { - pendingReconcileTabs.set(tabId, sealedTurnId); - return; - } - pendingReconcileTabs.delete(tabId); - void reloadChunksFromApi(tabId, true, sealedTurnId); - } - - /** - * Hydrate the tab store from the backend on app mount. Restores the - * full list of open tabs (every row with `is_open = 1` in the DB), - * loads each tab's persisted message history, and seeds the in-flight - * assistant message for any tab the backend is currently streaming. - * - * Wire calls: - * - GET /tabs → list of open tabs in `position` order - * - GET /tabs/:id/messages → persisted ChatMessage[] for each - * - GET /status → in-flight TabStatusSnapshot map - * - * Failure modes (all log + continue with whatever was successfully - * hydrated; callers fall back to creating a fresh tab if the final - * `tabs` array is empty): - * - /tabs request fails → no tabs restored - * - /tabs/:id/messages fails → that tab restored with empty messages - * - /status fails → tabs restored, in-flight streaming will be - * lost (will surface as a static "running" status until the next - * event arrives); harmless because the WS will broadcast `statuses` - * on reconnect anyway. - * - * Returns the number of tabs hydrated (0 on total failure, ≥1 on - * partial or full success). Caller uses this to decide whether to - * create a fresh tab. - * - * Idempotency: if `tabs.length > 0` when called, returns 0 without - * touching state — the caller already has tabs from elsewhere (e.g. - * a hot-reload that preserved Svelte state). - */ - async function hydrateFromBackend(): Promise { - if (tabs.length > 0) return 0; - - // 1. Fetch the list of open tabs from the DB. - let tabRows: Array<{ - id: string; - title: string; - keyId?: string | null; - modelId?: string | null; - parentTabId?: string | null; - // Backend usage aggregate (GET /tabs). Structurally identical to - // CacheStats, so it seeds `cacheStats` directly on reload. This is the - // initial seed (hydrate runs only when tabs.length === 0, i.e. a true - // reload); thereafter `turn-sealed` REPLACES cacheStats with the same - // aggregate each turn, keeping the live accumulator reconciled to the DB - // truth. Neither path ADDS to live events, so there is no double-count. - usageStats?: CacheStats | null; - }> = []; - try { - const res = await fetch(`${config.apiBase}/tabs`); - if (!res.ok) return 0; - const data = (await res.json()) as { tabs?: typeof tabRows }; - tabRows = Array.isArray(data.tabs) ? data.tabs : []; - } catch { - return 0; - } - - if (tabRows.length === 0) return 0; - - // 2. Fetch the in-flight snapshot. Failure is non-fatal. - let statusMap: Record = {}; - try { - const res = await fetch(`${config.apiBase}/status`); - if (res.ok) { - const data = (await res.json()) as { statuses?: Record }; - if (data.statuses && typeof data.statuses === "object") { - statusMap = data.statuses; - } - } - } catch { - // Non-fatal: tabs still restore with idle status. - } - - // 3. For each tab, fetch its chunk window (raw rows) in parallel. - type Win = { ok: boolean; chunks: ChunkRow[]; total: number; oldestSeq: number | null }; - const winByTab = new Map(); - for (const { id, win } of await Promise.all( - tabRows.map(async (row) => ({ - id: row.id, - win: await fetchChunkWindow(row.id, { limit: 100 }), - })), - )) { - winByTab.set(id, win); - } - - // 4. Build the Tab objects, seeding the in-flight live turn for running - // tabs from the status snapshot (the unsealed turn isn't in the DB - // yet; it reconciles into `chunks` when `turn-sealed` arrives). - const restored: Tab[] = tabRows.map((row) => { - const snap = statusMap[row.id]; - const win: Win = winByTab.get(row.id) ?? { ok: true, chunks: [], total: 0, oldestSeq: null }; - const agentStatus: Tab["agentStatus"] = snap?.status ?? "idle"; - - let currentAssistantId: string | null = null; - let liveTurnId: string | null = null; - let live: ChatMessage[] = []; - - if (agentStatus === "running" && snap?.currentAssistantId) { - currentAssistantId = snap.currentAssistantId; - liveTurnId = snap.currentTurnId ?? null; - live = [ - { - id: snap.currentAssistantId, - role: "assistant", - chunks: snap.currentChunks ? [...snap.currentChunks] : [], - isStreaming: true, - ...(liveTurnId !== null ? { turnId: liveTurnId } : {}), - }, - ]; - } - - return { - id: row.id, - title: row.title, - chunks: win.chunks, - live, - renderGroups: deriveRenderGroups(win.chunks, live), - liveTurnId, - agentStatus, - keyId: row.keyId ?? null, - modelId: row.modelId ?? null, - reasoningEffort: DEFAULT_REASONING_EFFORT, - currentAssistantId, - // Rehydrate the todo list from the backend snapshot so a reload - // doesn't blank the Tasks panel mid-task. - tasks: snap?.tasks ?? [], - injectedSkills: [], - parentTabId: row.parentTabId ?? null, - persistent: true, - agentSlug: null, - agentScope: null, - agentModels: null, - workingDirectory: null, - queuedMessages: [], - chunkLimit: appSettings.chunkLimit, - draft: "", - attachments: [], - manualTitle: false, - oldestLoadedSeq: win.oldestSeq, - totalChunks: win.total, - cacheStats: row.usageStats ?? undefined, - }; - }); - - tabs = restored; - // Trim each restored tab down to the chunk limit (user starts at bottom). - for (const t of restored) { - evictChunks(t.id); - // Seed warming from persisted per-tab preference. Arms the 4-minute - // countdown for idle+enabled tabs; running tabs stay paused until - // their next `status`/`statuses` reconcile flips them idle. - cacheWarming.initTab(t.id); - if (t.agentStatus === "running") cacheWarming.onTurnActive(t.id); - } - // Activate the first restored tab (the list is already ordered by - // `position` from the backend). - activeTabId = restored[0]?.id ?? null; - return restored.length; - } - - /** - * Start a conversation compaction (UI-driven). Creates a TRANSIENT - * placeholder tab that shows the "compacting…" screen, switches to it, and - * kicks off the backend compaction of `sourceTabId`. Outcome arrives via the - * `compaction-*` WS events (see handleEvent). Closing the placeholder tab - * before completion cancels it (DELETE aborts the in-flight summary). - */ - async function startCompaction(sourceTabId: string): Promise { - const source = getTabById(sourceTabId); - if (!source) return; - if (source.isCompacting) return; - - const tempId = generateId(); - // Create the placeholder tab on the backend (so DELETE-on-close can - // abort the run) and locally (so we can switch to it and show the UI). - try { - await fetch(`${config.apiBase}/tabs`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id: tempId, title: "Compacting…" }), - }); - } catch { - // Continue — the run is driven server-side via the compact endpoint. - } - - const placeholder: Tab = { - id: tempId, - title: "Compacting…", - chunks: [], - live: [], - renderGroups: [], - liveTurnId: null, - agentStatus: "idle", - keyId: null, - modelId: null, - reasoningEffort: DEFAULT_REASONING_EFFORT, - currentAssistantId: null, - tasks: [], - injectedSkills: [], - parentTabId: null, - persistent: true, - agentSlug: null, - agentScope: null, - agentModels: null, - workingDirectory: null, - queuedMessages: [], - chunkLimit: appSettings.chunkLimit, - draft: "", - manualTitle: true, - oldestLoadedSeq: null, - totalChunks: 0, - attachments: [], - compactingSource: sourceTabId, - isCompacting: false, - compactionError: null, - }; - tabs = [...tabs, placeholder]; - activeTabId = tempId; - updateTab(sourceTabId, { isCompacting: true }); - - try { - const res = await fetch(`${config.apiBase}/tabs/${tempId}/compact`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sourceTabId }), - }); - if (!res.ok) { - const msg = `Compaction request failed (HTTP ${res.status}).`; - updateTab(sourceTabId, { isCompacting: false }); - if (getTabById(tempId)) updateTab(tempId, { compactionError: msg, compactingSource: null }); - } - } catch { - const msg = "Could not reach the server to start compaction."; - updateTab(sourceTabId, { isCompacting: false }); - if (getTabById(tempId)) updateTab(tempId, { compactionError: msg, compactingSource: null }); - } - } - - /** - * Finish a completed compaction: the canonical conversation now lives on - * `sourceTabId` (re-seeded with summary + preserved tail), the full prior - * history was relocated to `backupTabId`. Reload the source tab's chunks, - * insert the backup tab into the sidebar, switch focus back to the source, - * and discard the transient placeholder. - */ - async function finishCompaction(ev: { - tempTabId: string; - sourceTabId: string; - backupTabId: string; - backupTitle: string; - }): Promise { - // Reload the re-seeded source conversation from the backend. - updateTab(ev.sourceTabId, { isCompacting: false }); - await reloadChunksFromApi(ev.sourceTabId); - - // Insert the backup tab (full pre-compaction history) if not present. - if (!getTabById(ev.backupTabId)) { - const win = await fetchChunkWindow(ev.backupTabId, { limit: 100 }); - const src = getTabById(ev.sourceTabId); - const backup: Tab = { - id: ev.backupTabId, - title: ev.backupTitle, - chunks: win.chunks, - live: [], - renderGroups: deriveRenderGroups(win.chunks, []), - liveTurnId: null, - agentStatus: "idle", - keyId: src?.keyId ?? null, - modelId: src?.modelId ?? null, - reasoningEffort: src?.reasoningEffort ?? DEFAULT_REASONING_EFFORT, - currentAssistantId: null, - tasks: [], - injectedSkills: [], - parentTabId: null, - persistent: true, - agentSlug: src?.agentSlug ?? null, - agentScope: src?.agentScope ?? null, - agentModels: src?.agentModels ?? null, - workingDirectory: src?.workingDirectory ?? null, - queuedMessages: [], - chunkLimit: appSettings.chunkLimit, - draft: "", - manualTitle: true, - oldestLoadedSeq: win.oldestSeq, - totalChunks: win.total, - attachments: [], - compactingSource: null, - isCompacting: false, - compactionError: null, - }; - tabs = [...tabs, backup]; - evictChunks(ev.backupTabId); - } - - // Switch focus back to the (compacted) source tab and drop the - // placeholder. If the placeholder was the active tab, focus moves to - // the source conversation. - if (activeTabId === ev.tempTabId) activeTabId = ev.sourceTabId; - tabs = tabs.filter((t) => t.id !== ev.tempTabId); - } - - function handleEvent(event: AgentEvent & { tabId?: string }): void { - const tabId = event.tabId; - - switch (event.type) { - case "status": { - if (!tabId) break; - updateTab(tabId, { agentStatus: event.status }); - // Cache warming never fires mid-turn: pause it while running, and - // re-arm the 4-minute countdown once the turn ends (idle/error). - if (event.status === "running") { - cacheWarming.onTurnActive(tabId); - } - if (event.status === "idle" || event.status === "error") { - // Stop the streaming cursor immediately; the fold of the live - // tail into the sealed chunk log happens on `turn-sealed` - // (after the DB write lands — status fires before it). - updateLive(tabId, (msgs) => - msgs.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)), - ); - updateTab(tabId, { currentAssistantId: null }); - const tab = getTabById(tabId); - if (tab && !tab.persistent && tabId !== activeTabId) { - cacheWarming.removeTab(tabId); - tabs = tabs.filter((t) => t.id !== tabId); - } else { - cacheWarming.onTurnEnded(tabId); - } - } - break; - } - case "turn-start": { - if (!tabId) break; - const tsTab = getTabById(tabId); - // Tag the in-flight turn. Also backfill the turn_id onto THIS - // turn's initiating optimistic user message — it was created on - // send before the turn_id was known — so it key-matches the sealed - // user row after reconcile (flicker-free; no remount). - // - // A turn-start corresponds to exactly one persisted user row - // (processMessage → explodeUserText), and a queued message never - // gets its own turn-start (it is drained into a running turn via - // message-consumed). So the initiator is the single most-recent - // NON-queued untagged user row. We must NOT tag pending `queued-` - // rows: they belong to future turns, and tagging them here would - // wipe them from the UI when THIS turn seals (reconcile drops live - // rows bound to the sealed turn). - const taggedLive = tsTab - ? (() => { - const live = [...tsTab.live]; - for (let i = live.length - 1; i >= 0; i--) { - const m = live[i]; - // Stop at the first non-user row (assistant/system - // boundary): earlier user rows belong to prior turns. - if (!m || m.role !== "user") break; - // Skip past pending queued messages (future turns). - if (m.id.startsWith("queued-")) continue; - // Most-recent non-queued user row = this turn's - // initiator. Tag it once (if untagged), then stop. - if (m.turnId === undefined) { - live[i] = { ...m, turnId: event.turnId }; - } - break; - } - return live; - })() - : undefined; - updateTab(tabId, { - liveTurnId: event.turnId, - ...(taggedLive ? { live: taggedLive } : {}), - }); - break; - } - case "turn-sealed": { - if (!tabId) break; - // The turn's rows are now durable — fold THIS turn out of the live - // tail into the sealed chunk log (refetch real seqs), preserving any - // newer in-flight turn. Deferred while scrolled up. - reconcileSealedTurn(tabId, event.turnId); - // Reconcile cacheStats to the DB source-of-truth carried on the event. - // REPLACE (not add): the aggregate already includes every persisted - // usage row for this tab, so this both lands the just-sealed turn's - // usage AND self-heals any live overshoot (e.g. a rate-limited - // fallback attempt streamed usage live but was discarded server-side). - // `usageStats === undefined` (older backend) leaves cacheStats as-is. - if (event.usageStats !== undefined) { - updateTab(tabId, { cacheStats: event.usageStats ?? undefined }); - } - break; - } - case "statuses": { - // WS (re)connect snapshot. The shape was widened to - // TabStatusSnapshot (status + optional currentChunks + - // optional currentAssistantId) so the frontend can seed - // in-flight assistant messages on browser reopen. - const backend = event.statuses; - for (const t of tabs) { - const snap = backend[t.id]; - const backendStatus = snap?.status ?? "idle"; - - // Desync case: frontend thought it was streaming, backend - // has already moved on. The turn is persisted now — reload - // the chunk window so the final answer shows up. - if (t.agentStatus === "running" && backendStatus !== "running") { - void reloadChunksFromApi(t.id); - } - - // Status alignment. - if (t.agentStatus !== backendStatus) { - updateTab(t.id, { agentStatus: backendStatus }); - } - - // Sync cache warming to the reconciled status: pause it while a - // tab is (still) running, otherwise (re-)arm the idle countdown. - if (backendStatus === "running") { - cacheWarming.onTurnActive(t.id); - } else { - cacheWarming.onTurnEnded(t.id); - } - - // Rehydrate the todo list from the snapshot (backend truth) - // so a reconnect/reload doesn't blank the Tasks panel. - updateTab(t.id, { tasks: snap?.tasks ?? [] }); - - if (backendStatus === "running") { - // Seed the in-flight assistant message from the snapshot. - // This handles the "browser just reopened mid-stream" - // path: the DB only has chunks up to the last - // flushAssistant call, but the snapshot has the live - // in-memory currentChunks. - if (snap?.currentAssistantId) { - const targetId = snap.currentAssistantId; - updateTab(t.id, { - currentAssistantId: targetId, - ...(snap.currentTurnId ? { liveTurnId: snap.currentTurnId } : {}), - }); - updateLive(t.id, (msgs) => { - const idx = msgs.findIndex((m) => m.id === targetId); - if (idx >= 0) { - return msgs.map((m, i) => - i === idx - ? { - ...m, - chunks: snap.currentChunks ? [...snap.currentChunks] : m.chunks, - isStreaming: true, - } - : m, - ); - } - return [ - ...msgs, - { - id: targetId, - role: "assistant", - chunks: snap.currentChunks ? [...snap.currentChunks] : [], - isStreaming: true, - ...(snap.currentTurnId ? { turnId: snap.currentTurnId } : {}), - }, - ]; - }); - } - } else if (t.currentAssistantId) { - // Not running: clear streaming flags. - updateLive(t.id, (msgs) => - msgs.map((m) => (m.id === t.currentAssistantId ? { ...m, isStreaming: false } : m)), - ); - updateTab(t.id, { currentAssistantId: null }); - } - } - break; - } - case "reasoning-delta": - case "reasoning-end": - case "text-delta": - case "tool-call": - case "tool-result": - case "shell-output": { - if (!tabId) break; - applyChunkEvent(tabId, event); - break; - } - case "usage": { - if (!tabId) break; - const tab = getTabById(tabId); - if (!tab) break; - const u = event.usage; - const prev = tab.cacheStats; - updateTab(tabId, { - cacheStats: { - inputTokens: (prev?.inputTokens ?? 0) + u.inputTokens, - outputTokens: (prev?.outputTokens ?? 0) + u.outputTokens, - cacheReadTokens: (prev?.cacheReadTokens ?? 0) + u.cacheReadTokens, - cacheWriteTokens: (prev?.cacheWriteTokens ?? 0) + u.cacheWriteTokens, - requests: (prev?.requests ?? 0) + 1, - last: { - inputTokens: u.inputTokens, - outputTokens: u.outputTokens, - cacheReadTokens: u.cacheReadTokens, - cacheWriteTokens: u.cacheWriteTokens, - }, - }, - }); - break; - } - case "done": { - if (!tabId) break; - const tab5 = getTabById(tabId); - if (!tab5) break; - updateLive(tabId, (msgs) => - msgs.map((m) => (m.id === tab5.currentAssistantId ? { ...m, isStreaming: false } : m)), - ); - updateTab(tabId, { currentAssistantId: null }); - break; - } - case "error": { - if (!tabId) break; - const errTab = getTabById(tabId); - if (!errTab) break; - if (errTab.currentAssistantId) { - // In-flight turn: append the error as a chunk on the - // assistant message via the shared helper. Mark debug info - // on the message for parity with the previous behavior. - applyChunkEvent(tabId, event); - updateLive(tabId, (msgs) => - msgs.map((m) => - m.id === errTab.currentAssistantId - ? { - ...m, - isStreaming: false, - debugInfo: makeDebugInfo({ error: event.error }), - } - : m, - ), - ); - } else { - // No turn in flight: open a new assistant message holding - // only the error chunk. We do this by ensuring an assistant - // message then funneling through applyChunkEvent, which - // guarantees the chunk shape matches the helper's output. - ensureAssistantMessage(tabId); - applyChunkEvent(tabId, event); - const afterTab = getTabById(tabId); - if (afterTab?.currentAssistantId) { - const newId = afterTab.currentAssistantId; - updateLive(tabId, (msgs) => - msgs.map((m) => - m.id === newId - ? { - ...m, - isStreaming: false, - debugInfo: makeDebugInfo({ error: event.error }), - } - : m, - ), - ); - } - } - updateTab(tabId, { currentAssistantId: null, agentStatus: "error" }); - break; - } - case "notice": { - if (!tabId) break; - const noticeTab = getTabById(tabId); - if (!noticeTab) break; - if (noticeTab.currentAssistantId) { - applyChunkEvent(tabId, event); - } else { - routeSystemEvent(tabId, { kind: "notice", text: event.message }); - } - break; - } - case "model-changed": { - if (!tabId) break; - const mcTab2 = getTabById(tabId); - if (!mcTab2) break; - // Always update the tab's active key/model. Additionally emit - // a `system` chunk to record the switch at its temporal - // position (in the assistant turn if one is in flight; else - // in a standalone system message). - updateTab(tabId, { keyId: event.keyId, modelId: event.modelId }); - if (mcTab2.currentAssistantId) { - applyChunkEvent(tabId, event); - } else { - routeSystemEvent(tabId, { - kind: "model-changed", - text: `Switched to ${event.modelId} (${event.keyId})`, - }); - } - break; - } - case "permission-prompt": { - pendingPermissions = event.pending; - break; - } - case "task-list-update": { - if (tabId) { - updateTab(tabId, { tasks: event.tasks }); - } - break; - } - case "config-reload": { - configReloaded = true; - setTimeout(() => { - configReloaded = false; - }, 2500); - // If a tab + turn is in flight, also record the reload as a - // system chunk for honest history. If no turn is in flight we - // could route to a system message, but config-reload is a - // global signal not scoped to any tab — only the active tab, - // if any, gets the chunk. - if (tabId) { - const crTab = getTabById(tabId); - if (crTab?.currentAssistantId) { - applyChunkEvent(tabId, event); - } - } - break; - } - case "tab-created": { - const newTabEvent = event as AgentEvent & { - id: string; - title: string; - keyId: string | null; - modelId: string | null; - parentTabId: string | null; - agentSlug?: string | null; - workingDirectory: string | null; - agentModels?: AgentModelEntry[] | null; - }; - // Only add if we don't already have this tab - if (!getTabById(newTabEvent.id)) { - const tab: Tab = { - id: newTabEvent.id, - title: newTabEvent.title, - chunks: [], - live: [], - renderGroups: [], - liveTurnId: null, - agentStatus: "running", - keyId: newTabEvent.keyId ?? null, - modelId: newTabEvent.modelId ?? null, - reasoningEffort: DEFAULT_REASONING_EFFORT, - currentAssistantId: null, - tasks: [], - injectedSkills: [], - parentTabId: newTabEvent.parentTabId ?? null, - persistent: newTabEvent.parentTabId == null, - agentSlug: newTabEvent.agentSlug ?? null, - agentScope: null, - agentModels: newTabEvent.agentModels ?? null, - workingDirectory: newTabEvent.workingDirectory ?? null, - queuedMessages: [], - chunkLimit: appSettings.chunkLimit, - draft: "", - attachments: [], - manualTitle: false, - oldestLoadedSeq: null, - totalChunks: 0, - }; - tabs = [...tabs, tab]; - } - break; - } - case "message-queued": { - if (!tabId) break; - const mqEvent = event as AgentEvent & { tabId: string; messageId: string; message: string }; - const mqTab = getTabById(tabId); - if (!mqTab) break; - // Only add to queuedMessages if not already tracked (might have been added - // optimistically by sendMessage using the same queueId) - const alreadyQueued = mqTab.queuedMessages.some((m) => m.id === mqEvent.messageId); - if (!alreadyQueued) { - // Message came from another client/session — add it fresh - const qm: QueuedMessage = { - id: mqEvent.messageId, - message: mqEvent.message, - timestamp: Date.now(), - }; - updateTab(tabId, { queuedMessages: [...mqTab.queuedMessages, qm] }); - // Also add as a user message in the live tail if not present. - const tabAfterQm = getTabById(tabId); - const existingMsg = tabAfterQm?.live.find( - (m) => m.id === `queued-${mqEvent.messageId}` || m.id === mqEvent.messageId, - ); - if (!existingMsg) { - const userMsg: ChatMessage = { - id: `queued-${mqEvent.messageId}`, - role: "user", - chunks: [{ type: "text", text: mqEvent.message }], - }; - updateTab(tabId, { live: [...(tabAfterQm?.live ?? []), userMsg] }); - } - } - // If alreadyQueued, the optimistic update already put everything in place with the - // correct `queued-${messageId}` id — nothing more to do. - break; - } - case "message-consumed": { - if (!tabId) break; - const mcEvent = event as AgentEvent & { - tabId: string; - messageIds: string[]; - reason?: "interrupt" | "continuation"; - }; - const mcTab = getTabById(tabId); - if (!mcTab) break; - // Track recently consumed IDs so sendMessage can detect early consumption - for (const id of mcEvent.messageIds) { - recentlyConsumedIds.add(id); - setTimeout(() => recentlyConsumedIds.delete(id), 10000); - } - updateTab(tabId, { - queuedMessages: mcTab.queuedMessages.filter((m) => !mcEvent.messageIds.includes(m.id)), - }); - - // "continuation" — these queued messages are draining BETWEEN turns - // to START a fresh turn (the "queue consumed after turn ends" path), - // not folding into a running turn's tool result. The backend joins - // them into ONE initiating user row, so we collapse the matching - // optimistic `queued-` bubbles into a single UNTAGGED user row. It - // stays untagged on purpose: the imminent `turn-start` tags it as - // this new turn's initiator (exactly like a normal send), and - // reconcile then folds it into the sealed turn. Leaving N separate - // untagged rows would strand all but the most-recent one (turn-start - // only tags one), so collapsing is required. - if (mcEvent.reason === "continuation") { - updateLive(tabId, (msgs) => { - const consumedTexts: string[] = []; - const rest: ChatMessage[] = []; - let firstConsumedIdx = -1; - for (const m of msgs) { - if (m.role === "user" && m.id.startsWith("queued-")) { - const queuedId = m.id.slice(7); - if (mcEvent.messageIds.includes(queuedId)) { - if (firstConsumedIdx === -1) firstConsumedIdx = rest.length; - const textChunk = m.chunks.find((c) => c.type === "text"); - consumedTexts.push(textChunk && textChunk.type === "text" ? textChunk.text : ""); - continue; - } - } - rest.push(m); - } - if (consumedTexts.length === 0) return msgs; - const initiator: ChatMessage = { - id: generateId(), - role: "user", - chunks: [{ type: "text", text: consumedTexts.join("\n---\n") }], - }; - rest.splice(firstConsumedIdx === -1 ? rest.length : firstConsumedIdx, 0, initiator); - return rest; - }); - break; - } - - // Split the current assistant message: finalize it, then insert - // the consumed user messages after it. Subsequent streaming events - // will create a NEW assistant message block below. - const currentAssistantId = mcTab.currentAssistantId; - updateLive(tabId, (msgs) => { - // Extract consumed messages - const consumed: ChatMessage[] = []; - const rest: ChatMessage[] = []; - for (const m of msgs) { - if (m.id.startsWith("queued-")) { - const queuedId = m.id.slice(7); - if (mcEvent.messageIds.includes(queuedId)) { - // Bind the consumed message to the in-flight turn that is - // consuming it. Stripping the `queued-` prefix alone leaves - // it an UNTAGGED user row, which reconcileSealedTurn KEEPS — - // so the interrupt bubble would linger in the live tail - // forever AND duplicate the `[USER INTERRUPT]` text the - // backend folds into the sealed tool-result chunk. Tagging - // it lets reconcile drop it on seal, collapsing to the - // persisted shape. (liveTurnId is set for the duration of a - // running turn, which is the only time a consume happens.) - consumed.push({ - ...m, - id: queuedId, - ...(mcTab.liveTurnId !== null ? { turnId: mcTab.liveTurnId } : {}), - }); - continue; - } - } - rest.push(m); - } - if (consumed.length === 0) return msgs; - - // Mark the current assistant message as done streaming - const result = rest.map((m) => - m.id === currentAssistantId ? { ...m, isStreaming: false } : m, - ); - - // Insert consumed messages right after the current assistant message - let insertIdx = result.length; - for (let i = result.length - 1; i >= 0; i--) { - if (result[i]?.id === currentAssistantId) { - insertIdx = i + 1; - break; - } - } - result.splice(insertIdx, 0, ...consumed); - return result; - }); - // Clear currentAssistantId so the next streaming event creates - // a new assistant message block after the user's message - updateTab(tabId, { currentAssistantId: null }); - break; - } - case "message-cancelled": { - if (!tabId) break; - const cancelEvent = event as AgentEvent & { tabId: string; messageId: string }; - const cancelTab = getTabById(tabId); - if (!cancelTab) break; - updateTab(tabId, { - queuedMessages: cancelTab.queuedMessages.filter((m) => m.id !== cancelEvent.messageId), - live: cancelTab.live.filter( - (m) => !(m.role === "user" && m.id === `queued-${cancelEvent.messageId}`), - ), - }); - break; - } - case "compaction-started": { - const ev = event as AgentEvent & { tempTabId: string; sourceTabId: string }; - // Lock the source tab's input while compaction runs. - if (getTabById(ev.sourceTabId)) { - updateTab(ev.sourceTabId, { isCompacting: true }); - } - // Mark the placeholder tab so it shows the "compacting…" screen. - if (getTabById(ev.tempTabId)) { - updateTab(ev.tempTabId, { compactingSource: ev.sourceTabId }); - } - break; - } - case "compaction-complete": { - const ev = event as AgentEvent & { - tempTabId: string; - sourceTabId: string; - backupTabId: string; - backupTitle: string; - }; - void finishCompaction(ev); - break; - } - case "compaction-error": { - const ev = event as AgentEvent & { - tempTabId: string; - sourceTabId: string; - error: string; - }; - if (getTabById(ev.sourceTabId)) { - updateTab(ev.sourceTabId, { isCompacting: false }); - } - // Surface the error on the placeholder tab (if still open) so the - // user sees why it failed; the source conversation is untouched. - const tmp = getTabById(ev.tempTabId); - if (tmp) { - updateTab(ev.tempTabId, { - compactingSource: null, - compactionError: ev.error, - }); - } - break; - } - } - } - - async function autoCheckDefaultSkills(): Promise { - try { - const res = await fetch(`${config.apiBase}/skills`); - if (!res.ok) return; - const data = (await res.json()) as { - skills?: Array<{ - name: string; - scope: string; - directory: string; - }>; - }; - const defaultSkills = (data.skills ?? []).filter((s) => s.directory === "default"); - if (defaultSkills.length === 0) return; - const checks: Record = { ...appSettings.skillChecks }; - for (const skill of defaultSkills) { - checks[`${skill.scope}:${skill.name}`] = true; - } - appSettings.skillChecks = checks; - } catch { - // Silently ignore — skills will still be available for manual checking - } - } - - async function autoSelectDefaultAgent(tabId: string): Promise { - try { - const res = await fetch(`${config.apiBase}/agents`); - if (!res.ok) return; - const data = (await res.json()) as { - agents?: Array<{ - slug: string; - scope: string; - name: string; - skills: string[]; - tools: string[]; - models: AgentModelEntry[]; - cwd?: string; - }>; - }; - const agents = data.agents ?? []; - const defaultAgent = agents.find( - (a: { slug: string; scope: string }) => a.slug === "default" && a.scope === "global", - ); - if (!defaultAgent) return; - - const tab = getTabById(tabId); - if (!tab) return; - - // Apply the default agent - const firstModel = defaultAgent.models[0]; - const patch: Partial = { - agentSlug: defaultAgent.slug, - agentScope: defaultAgent.scope, - agentModels: defaultAgent.models, - workingDirectory: defaultAgent.cwd || null, - }; - if (firstModel) { - patch.keyId = firstModel.key_id; - patch.modelId = firstModel.model_id; - } - updateTab(tabId, patch); - - // Merge the agent's skills into existing checked skills - if (defaultAgent.skills.length > 0) { - const checks: Record = { ...appSettings.skillChecks }; - for (const skillKey of defaultAgent.skills) { - checks[skillKey] = true; - } - appSettings.skillChecks = checks; - } - - // Apply tool permissions - const perms: Record = {}; - for (const key of Object.keys(appSettings.toolPerms)) { - perms[key] = false; - } - for (const tool of defaultAgent.tools) { - perms[tool] = true; - } - appSettings.toolPerms = perms; - - // Persist to backend - if (firstModel) { - fetch(`${config.apiBase}/tabs/${tabId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ keyId: firstModel.key_id, modelId: firstModel.model_id }), - }).catch(() => {}); - } - } catch { - // Silently ignore - } - } - - async function refreshAgentConfig(tabId: string): Promise { - const tab = getTabById(tabId); - if (!tab?.agentSlug || !tab.agentScope) return; - try { - const res = await fetch(`${config.apiBase}/agents`); - if (!res.ok) return; - const data = (await res.json()) as { - agents?: Array<{ - slug: string; - scope: string; - models: AgentModelEntry[]; - cwd?: string; - }>; - }; - const agents = data.agents ?? []; - const freshAgent = agents.find((a) => a.slug === tab.agentSlug && a.scope === tab.agentScope); - if (!freshAgent) return; - const patch: Partial = { - agentModels: freshAgent.models, - // NOTE: do not reset workingDirectory here. It is a per-tab user - // setting (see setWorkingDirectory); refreshing agent config on - // send must not clobber the directory the user chose for this tab. - }; - // Preserve the user's current selection if it still exists in the - // refreshed models list. Only fall back to the first model when the - // current selection is no longer valid. - const currentStillValid = freshAgent.models.some( - (m) => m.key_id === tab.keyId && m.model_id === tab.modelId, - ); - if (!currentStillValid) { - const firstModel = freshAgent.models[0]; - if (firstModel) { - patch.keyId = firstModel.key_id; - patch.modelId = firstModel.model_id; - } else { - patch.keyId = null; - patch.modelId = null; - } - } - updateTab(tabId, patch); - } catch { - // Silently fall back to stale values - } - } - - async function fetchSkillContent(scope: string, name: string): Promise { - try { - const res = await fetch( - `${config.apiBase}/skills/${encodeURIComponent(name)}?scope=${scope}`, - ); - if (!res.ok) return null; - const data = (await res.json()) as { content?: string }; - return data.content ?? null; - } catch { - return null; - } - } - - async function sendMessage(text: string, content?: UserContentPart[]): Promise { - let tab = getActiveTab(); - if (!tab) return; - - // A real user message disables+resets the warming timer immediately, so - // the genuine turn appends to the real history with NO throwaway turns - // present (it lands on the warm cache). Warming re-arms when this turn - // ends (see the `status` handler → cacheWarming.onTurnEnded). - cacheWarming.onUserMessage(tab.id); - - // Refresh agent config to pick up any changes made in AgentBuilder - if (tab.agentSlug && tab.agentScope) { - await refreshAgentConfig(tab.id); - tab = getActiveTab(); - if (!tab) return; - } - - // Fetch content for checked skills and build the message to send. - // `skillPrefix` (when non-empty) is prepended to BOTH the text projection - // that gets persisted/rendered AND the multimodal content array, so an - // image turn still carries the activated skills to the model. - let skillPrefix = ""; - const checkedKeys = Object.entries(appSettings.skillChecks) - .filter(([, v]) => v) - .map(([k]) => k); - - if (checkedKeys.length > 0) { - const skillSections: string[] = []; - for (const key of checkedKeys) { - const [scope, ...nameParts] = key.split(":"); - const name = nameParts.join(":"); - if (!scope || !name) continue; - const skillContent = await fetchSkillContent(scope, name); - if (skillContent) { - skillSections.push(`\n${skillContent}\n`); - } - } - if (skillSections.length > 0) { - skillPrefix = `[The following skills have been activated for this message]\n\n${skillSections.join("\n\n")}\n\n---\n\n`; - } - - // Track injected skills on the tab - const newInjected = [...new Set([...tab.injectedSkills, ...checkedKeys])]; - updateTab(tab.id, { injectedSkills: newInjected }); - - // Clear all checks - appSettings.skillChecks = {}; - } - - const messageToSend = `${skillPrefix}${text}`; - // Prepend the skill prefix to the multimodal content as a leading text - // part so the model sees the activated skills before the attachments. - const contentToSend = - content && skillPrefix ? [{ type: "text" as const, text: skillPrefix }, ...content] : content; - - const userMsg: ChatMessage = { - id: generateId(), - role: "user", - chunks: [{ type: "text", text }], - }; - - // If the agent is currently running, we expect the POST to be queued. - // Optimistically assign the queued- prefix and add to queuedMessages BEFORE - // the POST so that the WS "message-queued" event (which may arrive before - // the HTTP response) can match the existing chat message instead of creating - // a duplicate. - const isRunning = tab.agentStatus === "running"; - let queueId: string | null = null; - if (isRunning) { - queueId = generateId(); - userMsg.id = `queued-${queueId}`; - // Pre-populate queuedMessages so WS event finds it immediately - tab.queuedMessages = [ - ...tab.queuedMessages, - { id: queueId, message: text, timestamp: Date.now() }, - ]; - } - - // Optimistically show the user's message in the live tail. - updateTab(tab.id, { live: [...tab.live, userMsg] }); - // Generate a title from the first user message of an empty tab. - const isFirstMessage = tab.chunks.length === 0 && tab.live.length === 0; - if (!tab.manualTitle && (isFirstMessage || tab.title === "New Tab")) { - const titleText = text.length > 50 ? `${text.slice(0, 47)}...` : text; - updateTab(tab.id, { title: titleText }); - fetch(`${config.apiBase}/tabs/${tab.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: titleText }), - }).catch(() => {}); - } - - // Save settings to DB before sending (bakes in on send) - const settingsSaves: Promise[] = []; - - if (appSettings.systemPrompt !== appSettings.savedSystemPrompt) { - appSettings.savedSystemPrompt = appSettings.systemPrompt; - settingsSaves.push( - fetch(`${config.apiBase}/tabs/settings/system_prompt`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ value: appSettings.systemPrompt }), - }).catch(() => {}), - ); - } - - if (appSettings.toolPermsDirty) { - const perms = appSettings.toolPerms; - appSettings.savedToolPerms = { ...perms }; - for (const [id, enabled] of Object.entries(perms)) { - settingsSaves.push( - fetch(`${config.apiBase}/tabs/settings/perm_${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ value: enabled ? "allow" : "ask" }), - }).catch(() => {}), - ); - } - } - - if (settingsSaves.length > 0) { - await Promise.all(settingsSaves); - } - - try { - const res = await fetch(`${config.apiBase}/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - tabId: tab.id, - message: messageToSend, - ...(contentToSend ? { content: contentToSend } : {}), - ...(tab.keyId ? { keyId: tab.keyId } : {}), - ...(tab.modelId ? { modelId: tab.modelId } : {}), - ...(tab.agentModels ? { agentModels: tab.agentModels } : {}), - reasoningEffort: tab.reasoningEffort, - ...(tab.workingDirectory ? { workingDirectory: tab.workingDirectory } : {}), - ...(queueId ? { queueId } : {}), - }), - }); - if (!res.ok) { - const body = await res.text(); - // Rollback optimistic queued state on error - if (queueId) { - const currentTab = getTabById(tab.id); - if (currentTab) { - updateTab(tab.id, { - queuedMessages: currentTab.queuedMessages.filter((m) => m.id !== queueId), - }); - } - updateLive(tab.id, (msgs) => - msgs.map((m) => (m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m)), - ); - } - const errMsg: ChatMessage = { - id: generateId(), - role: "assistant", - chunks: [ - { - type: "error", - message: `Failed to send message (HTTP ${res.status})`, - statusCode: res.status, - }, - ], - isStreaming: false, - debugInfo: makeDebugInfo({ - error: `HTTP ${res.status}`, - httpStatus: res.status, - httpBody: body, - }), - }; - updateTab(tab.id, { live: [...(getTabById(tab.id)?.live ?? []), errMsg] }); - } else { - const responseData = (await res.json()) as { status: string; messageId?: string }; - if (responseData.status === "queued" && responseData.messageId) { - // The backend confirmed the message was queued with the ID we sent (queueId). - // If the message was already consumed before we got here (super-fast agent), - // clean up the optimistic queued state. - if (queueId && recentlyConsumedIds.has(queueId)) { - recentlyConsumedIds.delete(queueId); - // queuedMessages and the queued- prefix have already been cleaned up by - // the message-consumed handler. Nothing more to do. - } - // Otherwise everything is already in place from the optimistic update above. - } else if (responseData.status === "ok") { - // Agent wasn't running after all — undo the optimistic queued state if we set it. - if (queueId) { - const currentTab = getTabById(tab.id); - if (currentTab) { - updateTab(tab.id, { - queuedMessages: currentTab.queuedMessages.filter((m) => m.id !== queueId), - }); - } - // Restore the message to a normal (non-queued) ID - updateLive(tab.id, (msgs) => - msgs.map((m) => (m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m)), - ); - } - } - } - } catch (err) { - // Rollback optimistic queued state on network error - if (queueId) { - const currentTab = getTabById(tab.id); - if (currentTab) { - updateTab(tab.id, { - queuedMessages: currentTab.queuedMessages.filter((m) => m.id !== queueId), - }); - } - updateLive(tab.id, (msgs) => - msgs.map((m) => (m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m)), - ); - } - const errMsg: ChatMessage = { - id: generateId(), - role: "assistant", - chunks: [{ type: "error", message: "Could not reach the server" }], - isStreaming: false, - debugInfo: makeDebugInfo({ error: err instanceof Error ? err.message : String(err) }), - }; - updateTab(tab.id, { live: [...(getTabById(tab.id)?.live ?? []), errMsg] }); - } - } - - function changeModel(keyId: string, modelId: string): void { - const tab = getActiveTab(); - if (!tab) return; - updateTab(tab.id, { keyId, modelId }); - - // Persist to backend - fetch(`${config.apiBase}/tabs/${tab.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ keyId, modelId }), - }).catch(() => {}); - } - - function setKey(keyId: string): void { - const tab = getActiveTab(); - if (!tab) return; - updateTab(tab.id, { keyId, modelId: null }); - - // Persist to backend - fetch(`${config.apiBase}/tabs/${tab.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ keyId, modelId: null }), - }).catch(() => {}); - } - - /** - * Update the per-tab reasoning-effort selector. Ignores unrecognised - * values so an out-of-range string from the UI can't corrupt the tab - * state. This is the per-tab effort in the per-model → per-tab → default - * resolution chain. - */ - function setReasoningEffort(effort: string): void { - if (!isReasoningEffort(effort)) return; - const tab = getActiveTab(); - if (!tab) return; - updateTab(tab.id, { reasoningEffort: effort }); - } - - function setWorkingDirectory(dir: string | null): void { - const tab = getActiveTab(); - if (!tab) return; - updateTab(tab.id, { workingDirectory: dir || null }); - } - - /** - * Enable/disable prompt-cache warming for a tab (persisted per-tab). The - * warming store arms or cancels its 4-minute idle timer accordingly. - */ - function setCacheWarmingEnabled(tabId: string, enabled: boolean): void { - cacheWarming.setEnabled(tabId, enabled); - } - - function setAgent( - agent: { - slug: string; - scope: string; - skills: string[]; - tools: string[]; - models: AgentModelEntry[]; - cwd?: string; - } | null, - ): void { - const tab = getActiveTab(); - if (!tab) return; - - if (!agent) { - // Switch back to manual mode — clear agent and reset working directory - updateTab(tab.id, { - agentSlug: null, - agentScope: null, - agentModels: null, - workingDirectory: null, - }); - return; - } - - // Apply agent's first model as the active key+model - const firstModel = agent.models[0]; - const patch: Partial = { - agentSlug: agent.slug, - agentScope: agent.scope, - agentModels: agent.models, - workingDirectory: agent.cwd || null, - }; - if (firstModel) { - patch.keyId = firstModel.key_id; - patch.modelId = firstModel.model_id; - } - updateTab(tab.id, patch); - - // Reset and apply the agent's skills (don't accumulate from previous agents) - const checks: Record = {}; - for (const skillKey of agent.skills) { - checks[skillKey] = true; - } - appSettings.skillChecks = checks; - - // Always reset tool permissions to agent's allowlist (even if empty) - const perms: Record = {}; - for (const key of Object.keys(appSettings.toolPerms)) { - perms[key] = false; - } - for (const tool of agent.tools) { - perms[tool] = true; - } - appSettings.toolPerms = perms; - - // Persist to backend - fetch(`${config.apiBase}/tabs/${tab.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ...(firstModel ? { keyId: firstModel.key_id, modelId: firstModel.model_id } : {}), - }), - }).catch(() => {}); - } - - function replyPermission(id: string, reply: "once" | "always" | "reject"): void { - if (wsClient.connectionStatus !== "connected") return; - const prompt = pendingPermissions.find((p) => p.id === id); - wsClient.send({ type: "permission-reply", id, reply }); - pendingPermissions = pendingPermissions.filter((p) => p.id !== id); - if (prompt) { - permissionLog = [ - ...permissionLog, - { - id: generateId(), - permission: prompt.permission, - patterns: prompt.patterns, - action: reply, - timestamp: new Date().toISOString(), - description: prompt.description, - }, - ]; - } - } - - async function cancelQueuedMessage(tabId: string, messageId: string): Promise { - const tab = getTabById(tabId); - if (tab) { - updateTab(tabId, { - queuedMessages: tab.queuedMessages.filter((m) => m.id !== messageId), - live: tab.live.filter((m) => !(m.role === "user" && m.id === `queued-${messageId}`)), - }); - } - try { - await fetch(`${config.apiBase}/chat/cancel`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tabId, messageId }), - }); - } catch { - // ignore - } - } - - async function stopGeneration(tabId: string): Promise { - try { - await fetch(`${config.apiBase}/chat/stop`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tabId }), - }); - } catch { - // ignore - } - } - - function copyConversation(): string { - const tab = getActiveTab(); - if (!tab) return ""; - - const enabledTools = Object.entries(appSettings.savedToolPerms) - .filter(([, v]) => v) - .map(([k]) => k); - - // Short, distinguishable chunk descriptor — lets us see the shape of - // each message at a glance without dumping the full content. - // E.g. `chunks=6: thinking, tool-batch[2], thinking, tool-batch[1], thinking, text`. - // If a message reports `chunks=0`, the in-memory store has no content - // for it — which is the canonical symptom of a wire-format / load - // failure. Always include this so bug reports are diagnosable from - // the paste alone, without DB access. - const summarizeChunks = (chunks: (typeof tab.renderGroups)[number]["chunks"]) => { - if (chunks.length === 0) return "chunks=0"; - const parts = chunks.map((c) => { - if (c.type === "tool-batch") return `tool-batch[${c.calls.length}]`; - if (c.type === "system") return `system:${c.kind}`; - return c.type; // text | thinking | error - }); - return `chunks=${chunks.length}: ${parts.join(", ")}`; - }; - - const shortId = (id: string | undefined) => (id ? `${id.slice(0, 8)}…` : "?"); - - const lines: string[] = [ - "=== Dispatch Conversation ===", - `Tab ID: ${tab.id}`, - `Tab: ${tab.title}`, - `Model: ${tab.modelId ?? "default"}`, - `Tools: ${enabledTools.length > 0 ? enabledTools.join(", ") : "none"}`, - `Injected Skills: ${tab.injectedSkills.length > 0 ? tab.injectedSkills.join(", ") : "none"}`, - `Total tabs: ${tabs.length}`, - `All tab IDs: ${tabs.map((t) => t.id).join(", ")}`, - "", - // Store-level state — distinguishes "store empty (load/parse - // failure)" from "store populated, agent stuck mid-stream" from - // "agent finished cleanly" etc. - "--- Frontend store state ---", - `Connected to backend: ${isConnected}`, - `Tab agentStatus: ${tab.agentStatus}`, - `Tab currentAssistantId: ${tab.currentAssistantId ?? "null"}`, - `Render groups in store: ${tab.renderGroups.length}`, - `Queued messages: ${tab.queuedMessages.length}`, - `Persistent: ${tab.persistent}`, - `Working directory: ${tab.workingDirectory ?? "default"}`, - `Reasoning effort: ${tab.reasoningEffort}`, - `Todos: ${tab.tasks.length} (${tab.tasks.filter((t) => t.status === "completed").length} completed)`, - "", - ]; - const TOOL_RESULT_MAX = 300; - - for (const msg of tab.renderGroups) { - const role = msg.role === "user" ? "User" : msg.role === "system" ? "System" : "Assistant"; - const streamingFlag = msg.isStreaming ? ", streaming=true" : ""; - // Inline message diagnostics — id, streaming, chunk summary — - // makes wire-format / store-shape bugs immediately visible. - lines.push( - `--- ${role} --- (id=${shortId(msg.id)}${streamingFlag}, ${summarizeChunks(msg.chunks)})`, - ); - - // Surface non-trivial debugInfo (errors, HTTP failures). Skip - // when there's nothing interesting — keeps the output readable - // for happy-path conversations. - const dbg = msg.debugInfo; - if (dbg && (dbg.error || dbg.httpStatus !== undefined || dbg.httpBody)) { - const dbgBits: string[] = []; - if (dbg.error) dbgBits.push(`error="${dbg.error}"`); - if (dbg.httpStatus !== undefined) dbgBits.push(`httpStatus=${dbg.httpStatus}`); - if (dbg.httpBody) { - const body = dbg.httpBody.length > 200 ? `${dbg.httpBody.slice(0, 200)}…` : dbg.httpBody; - dbgBits.push(`httpBody="${body}"`); - } - lines.push(` [debug]: ${dbgBits.join(" ")}`); - } - - for (const chunk of msg.chunks) { - switch (chunk.type) { - case "text": - lines.push(chunk.text); - break; - case "thinking": - lines.push(` [Thinking]: ${chunk.text}`); - break; - case "tool-batch": - for (const call of chunk.calls) { - lines.push(` [Tool: ${call.name}]`); - if (call.result !== undefined) { - const result = String(call.result); - if (result.length > TOOL_RESULT_MAX) { - lines.push( - ` Result: ${result.slice(0, TOOL_RESULT_MAX)}... [truncated, ${result.length} chars total]`, - ); - } else { - lines.push(` Result: ${result}`); - } - } - } - break; - case "error": { - const code = chunk.statusCode !== undefined ? ` (HTTP ${chunk.statusCode})` : ""; - lines.push(` [Error${code}]: ${chunk.message}`); - break; - } - case "system": - lines.push(` [${chunk.kind}]: ${chunk.text}`); - break; - } - } - lines.push(""); - } - return lines.join("\n"); - } - - return { - get tabs() { - return tabs; - }, - get activeTabId() { - return activeTabId; - }, - get activeTab() { - return getActiveTab(); - }, - get isConnected() { - return isConnected; - }, - get pendingPermissions() { - return pendingPermissions; - }, - get permissionLog() { - return permissionLog; - }, - get configReloaded() { - return configReloaded; - }, - shortHandleFor, - createNewTab, - switchTab, - closeTab, - renameTab, - reorderTabs, - setDraft, - addAttachment, - sendMessage, - cancelQueuedMessage, - stopGeneration, - changeModel, - setKey, - setReasoningEffort, - setAgent, - replyPermission, - copyConversation, - promoteTab, - openAgentTab, - setWorkingDirectory, - setCacheWarmingEnabled, - startCompaction, - // Exposed so tests can drive the real reactive code path that the - // WS callback uses in production. Not intended for use in - // components — they should rely on the WS subscription instead. - handleEvent, - hydrateFromBackend, - loadOlderChunks, - evictChunks, - setScrolledUp, - }; -} - -export const tabStore = createTabStore(); diff --git a/packages/frontend/src/lib/theme.ts b/packages/frontend/src/lib/theme.ts deleted file mode 100644 index 2b4bad0..0000000 --- a/packages/frontend/src/lib/theme.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Single source of truth for the app's theme picker. - * - * Two callers care about themes: - * - `App.svelte`'s `onMount`, which applies the persisted theme on boot - * so the first paint is the right color. - * - `SettingsPanel.svelte`'s theme `