summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/frontend/src/lib
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz
dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/frontend/src/lib')
-rw-r--r--packages/frontend/src/lib/attachment-tokens.ts234
-rw-r--r--packages/frontend/src/lib/cache-warm-storage.ts77
-rw-r--r--packages/frontend/src/lib/cache-warming.svelte.ts318
-rw-r--r--packages/frontend/src/lib/components/AgentBuilder.svelte806
-rw-r--r--packages/frontend/src/lib/components/CacheRatePanel.svelte124
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte396
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte148
-rw-r--r--packages/frontend/src/lib/components/ChatPanel.svelte181
-rw-r--r--packages/frontend/src/lib/components/ClaudeReset.svelte375
-rw-r--r--packages/frontend/src/lib/components/ConfigPanel.svelte251
-rw-r--r--packages/frontend/src/lib/components/ContextWindowPanel.svelte85
-rw-r--r--packages/frontend/src/lib/components/DebugPanel.svelte35
-rw-r--r--packages/frontend/src/lib/components/Header.svelte27
-rw-r--r--packages/frontend/src/lib/components/HotReloadIndicator.svelte35
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte477
-rw-r--r--packages/frontend/src/lib/components/MarkdownRenderer.svelte174
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte636
-rw-r--r--packages/frontend/src/lib/components/ModelStatus.svelte356
-rw-r--r--packages/frontend/src/lib/components/PermissionPrompt.svelte100
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte643
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte220
-rw-r--r--packages/frontend/src/lib/components/SkillsBrowser.svelte317
-rw-r--r--packages/frontend/src/lib/components/SystemPromptPanel.svelte61
-rw-r--r--packages/frontend/src/lib/components/TabBar.svelte234
-rw-r--r--packages/frontend/src/lib/components/TaskListPanel.svelte80
-rw-r--r--packages/frontend/src/lib/components/ToolCallDisplay.svelte140
-rw-r--r--packages/frontend/src/lib/components/ToolPermissions.svelte185
-rw-r--r--packages/frontend/src/lib/config.ts44
-rw-r--r--packages/frontend/src/lib/context-window.ts37
-rw-r--r--packages/frontend/src/lib/router.svelte.ts12
-rw-r--r--packages/frontend/src/lib/settings.svelte.ts88
-rw-r--r--packages/frontend/src/lib/sidebar-storage.ts84
-rw-r--r--packages/frontend/src/lib/snapshot-sequencer.ts47
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts2441
-rw-r--r--packages/frontend/src/lib/theme.ts92
-rw-r--r--packages/frontend/src/lib/types.ts337
-rw-r--r--packages/frontend/src/lib/ws.svelte.ts127
37 files changed, 0 insertions, 10024 deletions
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: `【<kind>:<id>】` 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<string> {
- 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<string, StagedAttachment>): 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<string, boolean>;
-
-/** 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<string, unknown>)) {
- 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<Record<string, WarmState>>({});
- // 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<string, ReturnType<typeof setTimeout>>();
- const fireTokens = new Map<string, number>();
- const runningTabs = new Set<string>();
- let ticker: ReturnType<typeof setInterval> | 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<void> {
- 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 @@
-<script module>
-// Session-level cache for models per key; avoids refetching across component instances
-const modelCache = new Map();
-</script>
-
-<script lang="ts">
- import {
- DEFAULT_REASONING_EFFORT,
- REASONING_EFFORTS,
- REASONING_EFFORT_LABELS,
- } from "@dispatch/core/src/types/index.js";
- import { config } from "../config.js";
- import { router } from "../router.svelte.js";
- import type { KeyInfo } from "../types.js";
- import SkillsBrowser from "./SkillsBrowser.svelte";
- import ToolPermissions from "./ToolPermissions.svelte";
-
- interface AgentModelEntry {
- key_id: string;
- model_id: string;
- effort?: string;
- }
-
- interface AgentDefinition {
- name: string;
- description: string;
- skills: string[];
- tools: string[];
- models: AgentModelEntry[];
- scope: string;
- slug: string;
- cwd?: string;
- is_subagent?: boolean;
- }
-
- interface DirEntry {
- label: string;
- path: string;
- scope: string;
- }
-
- const { keys = [] }: { keys?: KeyInfo[] } = $props();
-
-
-
- // Portal action (escape stacking context)
- function portal(node: HTMLElement) {
- document.body.appendChild(node);
- return {
- destroy() {
- node.remove();
- },
- };
- }
-
- // State
- let agents = $state<AgentDefinition[]>([]);
- let dirs = $state<DirEntry[]>([]);
- let loading = $state(false);
- let error = $state<string | null>(null);
-
- // Editor state
- let editing = $state(false);
- let editingSlug = $state<string | null>(null); // null = new agent
-
- let formName = $state("");
- let formDescription = $state("");
- let formScope = $state("global");
- let formCwd = $state("");
- let cwdExists = $state<boolean | null>(null);
- let cwdResolved = $state<string | null>(null);
- let cwdCheckTimer: ReturnType<typeof setTimeout> | null = null;
- let formSkills = $state<Set<string>>(new Set());
- let formTools = $state<Set<string>>(new Set());
- let formModels = $state<AgentModelEntry[]>([]);
- let formIsSubagent = $state(false);
-
- // Model selection modal state
- let modelModalIndex = $state<number | null>(null);
- let modelModalType = $state<"key" | "model">("key");
- let modalAvailableModels = $state<string[]>([]);
- let modalLoadingModels = $state(false);
- let modalModelError = $state<string | null>(null);
-
- // Drag-and-drop reorder state
- let dragIndex = $state<number | null>(null);
- let dragOverIndex = $state<number | null>(null);
-
- // Delete confirm
- let deletingAgent = $state<AgentDefinition | null>(null);
-
- function slugify(name: string): string {
- return name
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-+|-+$/g, "")
- || "agent";
- }
-
- async function fetchAgents() {
- loading = true;
- error = null;
- try {
- const res = await fetch(`${config.apiBase}/agents`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data = await res.json();
- agents = data.agents ?? [];
- dirs = data.dirs ?? [];
- } catch (e) {
- error = e instanceof Error ? e.message : "Failed to fetch agents";
- } finally {
- loading = false;
- }
- }
-
- function handleSkillToggle(key: string, checked: boolean) {
- const next = new Set(formSkills);
- if (checked) next.add(key);
- else next.delete(key);
- formSkills = next;
- }
-
- function startNewAgent() {
- formReady = false;
- editingSlug = null;
- formName = "";
- formDescription = "";
- formScope = dirs[0]?.scope ?? "global";
- formCwd = "";
- formSkills = new Set();
- formTools = new Set();
- formModels = [];
- formIsSubagent = true;
- editing = true;
- // Allow the effect to skip the initial population
- setTimeout(() => { formReady = true; }, 0);
- }
-
- function startEdit(agent: AgentDefinition) {
- formReady = false;
- editingSlug = agent.slug;
- formName = agent.name;
- formDescription = agent.description;
- formScope = agent.scope;
- formCwd = agent.cwd ?? "";
- formSkills = new Set(agent.skills);
- formTools = new Set(agent.tools);
- formModels = agent.models.map((m) => ({ ...m }));
- formIsSubagent = agent.is_subagent ?? false;
- editing = true;
- // Allow the effect to skip the initial population
- setTimeout(() => { formReady = true; }, 0);
- }
-
- function cancelEdit() {
- // Flush any pending debounced save before leaving
- if (debounceTimer) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- saveAgent();
- }
- formReady = false;
- editing = false;
- editingSlug = null;
- }
-
- function handleToolToggle(id: string, checked: boolean) {
- const next = new Set(formTools);
- if (checked) next.add(id);
- else next.delete(id);
- formTools = next;
- }
-
- function addModelEntry() {
- formModels = [...formModels, { key_id: "", model_id: "" }];
- }
-
- function removeModelEntry(i: number) {
- formModels = formModels.filter((_, idx) => idx !== i);
- }
-
- function setEffortEntry(i: number, effort: string) {
- formModels = formModels.map((m, idx) => {
- if (idx !== i) return m;
- // Empty string = "inherit" (no per-model override). Strip the key so
- // the saved TOML omits `effort` and the call site falls back to the
- // per-tab selector / default.
- if (!effort) {
- const { effort: _dropped, ...rest } = m;
- return rest;
- }
- return { ...m, effort };
- });
- }
-
- async function openKeyModal(i: number) {
- modelModalIndex = i;
- modelModalType = "key";
- }
-
- async function openModelModal(i: number) {
- const entry = formModels[i];
- if (!entry || !entry.key_id) return;
- modelModalIndex = i;
- modelModalType = "model";
- modalModelError = null;
- modalAvailableModels = [];
-
- if (modelCache.has(entry.key_id)) {
- modalAvailableModels = modelCache.get(entry.key_id)!;
- return;
- }
-
- modalLoadingModels = true;
- try {
- const res = await fetch(
- `${config.apiBase}/models/available?keyId=${encodeURIComponent(entry.key_id)}`,
- );
- if (!res.ok) {
- const d = await res.json().catch(() => ({}));
- modalModelError = d.error ?? `HTTP ${res.status}`;
- return;
- }
- const d = await res.json();
- modalAvailableModels = d.models ?? [];
- modelCache.set(entry.key_id, modalAvailableModels);
- } catch (e) {
- modalModelError = e instanceof Error ? e.message : "Failed";
- } finally {
- modalLoadingModels = false;
- }
- }
-
- function selectKey(keyId: string) {
- if (modelModalIndex === null) return;
- const idx = modelModalIndex;
- formModels = formModels.map((m, i) =>
- i === idx ? { key_id: keyId, model_id: "" } : m,
- );
- modelModalIndex = null;
- // Immediately open model selection for the same row
- openModelModal(idx);
- }
-
- function selectModel(model: string) {
- if (modelModalIndex === null) return;
- formModels = formModels.map((m, i) =>
- i === modelModalIndex ? { ...m, model_id: model } : m,
- );
- modelModalIndex = null;
- }
-
- function closeModal() {
- modelModalIndex = null;
- }
-
- let formError = $state<string | null>(null);
- let saving = $state(false);
- let formReady = false;
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
- let saveAbort: AbortController | null = null;
-
- function isFormValid(): boolean {
- if (!formName.trim()) return false;
- if (formModels.length === 0) return false;
- if (formModels.some((m) => !m.key_id || !m.model_id)) return false;
- return true;
- }
-
- async function saveAgent() {
- if (!isFormValid()) return;
- formError = null;
-
- // Cancel any in-flight save
- saveAbort?.abort();
- const controller = new AbortController();
- saveAbort = controller;
-
- const payload: AgentDefinition = {
- name: formName.trim(),
- description: formDescription.trim(),
- skills: Array.from(formSkills),
- tools: Array.from(formTools),
- models: formModels,
- scope: formScope,
- slug: editingSlug ?? slugify(formName.trim()),
- ...(formCwd.trim() ? { cwd: formCwd.trim() } : {}),
- ...(formIsSubagent ? { is_subagent: true } : {}),
- };
-
- saving = true;
- try {
- const res = await fetch(`${config.apiBase}/agents`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- signal: controller.signal,
- });
- if (!res.ok) {
- const d = await res.json().catch(() => ({}));
- formError = d.error ?? `HTTP ${res.status}`;
- return;
- }
- // If this was a new agent, lock in the slug for future saves
- if (!editingSlug) {
- editingSlug = payload.slug;
- }
- await fetchAgents();
- } catch (e) {
- if (e instanceof DOMException && e.name === "AbortError") return;
- formError = e instanceof Error ? e.message : "Failed to save";
- } finally {
- if (saveAbort === controller) {
- saving = false;
- saveAbort = null;
- }
- }
- }
-
- // Check if CWD directory exists (debounced)
- $effect(() => {
- const cwd = formCwd;
- if (!cwd.trim()) {
- cwdExists = null;
- cwdResolved = null;
- return;
- }
- if (cwdCheckTimer) clearTimeout(cwdCheckTimer);
- cwdCheckTimer = setTimeout(async () => {
- try {
- const res = await fetch(
- `${config.apiBase}/agents/check-dir?path=${encodeURIComponent(cwd.trim())}`,
- );
- if (res.ok) {
- const data = await res.json();
- cwdExists = data.exists ?? false;
- cwdResolved = data.resolved ?? null;
- }
- } catch {
- cwdExists = null;
- cwdResolved = null;
- }
- }, 300);
- });
-
- // Auto-save with debounce whenever form fields change
- $effect(() => {
- // Read all reactive form fields to subscribe
- // noinspection: intentionally unused — reading these values subscribes the effect to them
- void [formName, formDescription, formScope, formCwd, formSkills, formTools, formModels, formIsSubagent];
- if (!formReady || !editing) return;
- if (debounceTimer) clearTimeout(debounceTimer);
- debounceTimer = setTimeout(() => {
- saveAgent();
- }, 600);
- });
-
- async function deleteAgent(agent: AgentDefinition) {
- try {
- // Prevent auto-save from re-creating the deleted agent
- formReady = false;
- if (debounceTimer) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- }
- saveAbort?.abort();
-
- const res = await fetch(
- `${config.apiBase}/agents/${encodeURIComponent(agent.slug)}?scope=${encodeURIComponent(agent.scope)}`,
- { method: "DELETE" },
- );
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- deletingAgent = null;
- editing = false;
- editingSlug = null;
- await fetchAgents();
- } catch (e) {
- error = e instanceof Error ? e.message : "Failed to delete";
- }
- }
-
- const hasName = $derived(formName.trim().length > 0);
-
- const globalAgents = $derived(agents.filter((a) => a.scope === "global"));
- const projectAgents = $derived(agents.filter((a) => a.scope !== "global"));
-
- // Fetch on mount
- $effect(() => {
- fetchAgents();
- });
-</script>
-
-<div class="flex flex-col flex-1 overflow-hidden">
- <!-- Page header -->
- <div class="flex items-center gap-4 px-6 py-4 border-b border-base-300 bg-base-200 shrink-0">
- <h1 class="text-xl font-bold flex-1">Agent Settings</h1>
- <button
- type="button"
- class="btn btn-ghost btn-sm"
- onclick={() => { if (editing) { cancelEdit(); } else { router.navigate("dashboard"); } }}
- >
- {editing ? '← Back to Agent Settings' : '← Back to Dashboard'}
- </button>
- </div>
-
- <div class="flex-1 overflow-y-auto px-6 py-6">
- {#if error}
- <div class="alert alert-error mb-4">{error}</div>
- {/if}
-
- {#if editing}
- <!-- Agent Editor Form -->
- <div class="card bg-base-200 shadow-md">
- <div class="card-body gap-4">
- <h2 class="card-title">{editingSlug ? "Edit Agent" : "New Agent"}</h2>
-
- {#if formError}
- <div class="alert alert-error text-sm">{formError}</div>
- {/if}
-
- <!-- Name -->
- <div class="form-control gap-1">
- <label class="label py-0" for="agent-name">
- <span class="label-text font-semibold">Name *</span>
- </label>
- <input
- id="agent-name"
- type="text"
- class="input input-bordered"
- placeholder="my-agent"
- bind:value={formName}
- />
- {#if formName}
- <span class="text-xs text-base-content/50">slug: {slugify(formName)}</span>
- {/if}
- </div>
-
- <fieldset disabled={!hasName} class="contents">
- <!-- Description -->
- <div class="form-control gap-1">
- <label class="label py-0" for="agent-desc">
- <span class="label-text font-semibold">Description</span>
- </label>
- <input
- id="agent-desc"
- type="text"
- class="input input-bordered"
- placeholder="What does this agent do?"
- bind:value={formDescription}
- />
- </div>
-
- <!-- Is Subagent -->
- <div class="form-control">
- <label class="label cursor-pointer justify-start gap-3 py-1">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- bind:checked={formIsSubagent}
- />
- <div>
- <span class="label-text font-semibold">Is Subagent</span>
- <p class="text-xs text-base-content/50">Subagents are hidden from Chat Settings and can only be used by other agents.</p>
- </div>
- </label>
- </div>
-
- <!-- Scope -->
- <div class="form-control gap-1">
- <label class="label py-0" for="agent-scope">
- <span class="label-text font-semibold">Scope</span>
- </label>
- <select id="agent-scope" class="select select-bordered" bind:value={formScope}>
- <option value="global">Global</option>
- {#each dirs.filter((d) => d.scope !== "global") as dir}
- <option value={dir.scope}>{dir.label} ({dir.path})</option>
- {/each}
- </select>
- </div>
-
- <!-- Working Directory -->
- <div class="form-control gap-1">
- <label class="label py-0" for="agent-cwd">
- <span class="label-text font-semibold">Working Directory</span>
- </label>
- <div class="flex items-center gap-2">
- <input
- id="agent-cwd"
- type="text"
- class="input input-bordered font-mono text-sm flex-1"
- placeholder="default (project root)"
- bind:value={formCwd}
- />
- {#if formCwd.trim()}
- {#if cwdExists === true}
- <span class="text-success text-lg" title="Directory exists">&#x2714;</span>
- {:else if cwdExists === false}
- <span class="text-warning text-lg" title="Directory does not exist (will be created)">&#x2716;</span>
- {:else}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- {/if}
- </div>
- {#if cwdExists === false && formCwd.trim()}
- <span class="text-xs text-warning">Directory will be created automatically on first message.</span>
- {/if}
- {#if cwdResolved && formCwd.trim() && cwdResolved !== formCwd.trim()}
- <span class="text-xs text-base-content/50 font-mono">{cwdResolved}</span>
- {/if}
- {#if !formCwd.trim()}
- <span class="text-xs text-base-content/50">Absolute or relative path. Relative paths resolve against the parent agent's working directory, or the project root.</span>
- {/if}
- </div>
-
- <!-- Models -->
- <div class="form-control gap-2">
- <div class="label py-0">
- <span class="label-text font-semibold">Models *</span>
- </div>
- <div class="flex flex-col gap-2">
- {#each formModels as entry, i (i)}
- <div
- class="flex items-center gap-2 rounded p-1 transition-colors {dragOverIndex === i ? 'bg-primary/10 border border-primary/30' : ''}"
- draggable="true"
- role="listitem"
- ondragstart={(e) => {
- 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; }}
- >
- <span class="badge badge-sm badge-neutral font-mono w-6 shrink-0 cursor-grab">{i + 1}</span>
- <button
- type="button"
- class="btn btn-sm btn-outline flex-1 font-mono truncate"
- onclick={() => openKeyModal(i)}
- >
- {entry.key_id || "Select Key"}
- </button>
- <button
- type="button"
- class="btn btn-sm btn-outline flex-1 font-mono truncate"
- onclick={() => openModelModal(i)}
- disabled={!entry.key_id}
- >
- {entry.model_id || "Select Model"}
- </button>
- <select
- class="select select-bordered select-sm shrink-0 w-36"
- title="Reasoning effort for this model. 'Inherit' uses the per-tab selector / default."
- value={entry.effort ?? ""}
- onchange={(e) => setEffortEntry(i, e.currentTarget.value)}
- >
- <option value="">Inherit ({REASONING_EFFORT_LABELS[DEFAULT_REASONING_EFFORT]})</option>
- {#each REASONING_EFFORTS as effort}
- <option value={effort}>{REASONING_EFFORT_LABELS[effort]}</option>
- {/each}
- </select>
- <button
- type="button"
- class="btn btn-sm btn-ghost text-error"
- onclick={() => removeModelEntry(i)}
- aria-label="Remove model entry"
- >
- ✕
- </button>
- </div>
- {/each}
- </div>
- <button
- type="button"
- class="btn btn-sm btn-ghost w-fit"
- onclick={addModelEntry}
- >
- + Add Model
- </button>
- </div>
-
- <!-- Tools -->
- <div class="form-control gap-2">
- <div class="label py-0">
- <span class="label-text font-semibold">Tools</span>
- </div>
- <ToolPermissions
- checkedTools={formTools}
- onToolToggle={handleToolToggle}
- />
- </div>
-
- <!-- Skills -->
- <div class="form-control gap-2">
- <div class="label py-0">
- <span class="label-text font-semibold">Skills</span>
- </div>
- <SkillsBrowser
- apiBase={config.apiBase}
- checkedSkills={formSkills}
- onSkillToggle={handleSkillToggle}
- />
- </div>
-
- <!-- Actions -->
- <div class="card-actions justify-between items-center pt-2">
- {#if editingSlug && !(editingSlug === "default" && formScope === "global")}
- <button
- type="button"
- class="btn btn-error btn-ghost"
- onclick={() => {
- const agent = agents.find((a) => a.slug === editingSlug && a.scope === formScope);
- if (agent) deletingAgent = agent;
- }}
- >Delete</button>
- {:else}
- <div></div>
- {/if}
- <div class="flex items-center gap-2">
- {#if saving}
- <span class="text-xs text-base-content/50">Saving...</span>
- {/if}
- </div>
- </div>
- </fieldset>
- </div>
- </div>
- {:else}
- <!-- Agent List -->
- {#if loading}
- <div class="flex justify-center py-16">
- <span class="loading loading-spinner loading-lg"></span>
- </div>
- {:else}
- <!-- Global Agents -->
- <section class="mb-8">
- <h2 class="text-lg font-semibold mb-3">Global Agents</h2>
- <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
- {#each globalAgents as agent}
- <button
- type="button"
- class="card bg-base-200 shadow-sm hover:bg-base-300 transition-colors cursor-pointer text-left"
- onclick={() => startEdit(agent)}
- >
- <div class="card-body p-4 gap-2">
- <h3 class="font-semibold font-mono truncate">{agent.name}</h3>
- {#if agent.description}
- <p class="text-sm text-base-content/60 truncate">{agent.description}</p>
- {/if}
- <div class="flex gap-2 flex-wrap">
- <span class="badge badge-sm badge-neutral">{agent.models.length} model{agent.models.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-sm badge-neutral">{agent.skills.length} skill{agent.skills.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-sm badge-outline">{agent.tools.length} tool{agent.tools.length !== 1 ? "s" : ""}</span>
- </div>
- </div>
- </button>
- {/each}
- <button
- type="button"
- class="card bg-base-200 shadow-sm hover:bg-base-300 transition-colors cursor-pointer border-2 border-dashed border-base-content/20"
- onclick={startNewAgent}
- >
- <div class="card-body p-4 items-center justify-center">
- <span class="text-2xl text-base-content/30">+</span>
- <span class="text-sm text-base-content/50">New Agent</span>
- </div>
- </button>
- </div>
- </section>
-
- <!-- Project Agents -->
- {#if projectAgents.length > 0 || dirs.some((d) => d.scope !== "global")}
- <section>
- <h2 class="text-lg font-semibold mb-3">Project Agents</h2>
- {#if projectAgents.length === 0}
- <p class="text-base-content/50 italic text-sm">No project agents yet.</p>
- {:else}
- <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
- {#each projectAgents as agent}
- <button
- type="button"
- class="card bg-base-200 shadow-sm hover:bg-base-300 transition-colors cursor-pointer text-left"
- onclick={() => startEdit(agent)}
- >
- <div class="card-body p-4 gap-2">
- <h3 class="font-semibold font-mono truncate">{agent.name}</h3>
- {#if agent.description}
- <p class="text-sm text-base-content/60 truncate">{agent.description}</p>
- {/if}
- <p class="text-xs text-base-content/40 font-mono truncate">{agent.scope}</p>
- <div class="flex gap-2 flex-wrap">
- <span class="badge badge-sm badge-neutral">{agent.models.length} model{agent.models.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-sm badge-neutral">{agent.skills.length} skill{agent.skills.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-sm badge-outline">{agent.tools.length} tool{agent.tools.length !== 1 ? "s" : ""}</span>
- </div>
- </div>
- </button>
- {/each}
- </div>
- {/if}
- </section>
- {/if}
- {/if}
- {/if}
- </div>
-</div>
-
-<!-- Key selection modal -->
-{#if modelModalIndex !== null && modelModalType === "key"}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-xl">Select Key</h3>
- <div class="mt-4 flex flex-col gap-2">
- {#each keys as key}
- <button
- type="button"
- class="btn {formModels[modelModalIndex ?? 0]?.key_id === key.id ? 'btn-primary' : 'btn-ghost'} justify-start text-base"
- onclick={() => selectKey(key.id)}
- >
- <span class="font-mono">{key.id}</span>
- <span class="badge ml-auto">{key.provider}</span>
- <span class="badge {key.status === 'active' ? 'badge-success' : 'badge-error'}">{key.status}</span>
- </button>
- {/each}
- </div>
- <div class="modal-action">
- <button type="button" class="btn" onclick={closeModal}>Cancel</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Close modal"></button>
- </div>
-{/if}
-
-<!-- Model selection modal -->
-{#if modelModalIndex !== null && modelModalType === "model"}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-xl">Select Model</h3>
- {#if modalLoadingModels}
- <div class="flex justify-center py-8">
- <span class="loading loading-spinner loading-lg"></span>
- </div>
- {:else if modalModelError}
- <div class="alert alert-error mt-4 text-base">
- <span>{modalModelError}</span>
- </div>
- {:else}
- <div class="mt-4 flex flex-col gap-1 max-h-96 overflow-y-auto">
- {#each modalAvailableModels as model}
- <button
- type="button"
- class="btn {formModels[modelModalIndex ?? 0]?.model_id === model ? 'btn-primary' : 'btn-ghost'} justify-start font-mono text-base"
- onclick={() => selectModel(model)}
- >
- {model}
- </button>
- {/each}
- </div>
- {/if}
- <div class="modal-action">
- <button type="button" class="btn" onclick={closeModal}>Cancel</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Close modal"></button>
- </div>
-{/if}
-
-<!-- Delete confirm modal -->
-{#if deletingAgent !== null}
- {@const agentToDelete = deletingAgent}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-lg">Delete Agent</h3>
- <p class="py-4">
- Are you sure you want to delete <span class="font-mono font-semibold">{agentToDelete.name}</span>? This cannot be undone.
- </p>
- <div class="modal-action">
- <button type="button" class="btn btn-ghost" onclick={() => { deletingAgent = null; }}>Cancel</button>
- <button
- type="button"
- class="btn btn-error"
- onclick={() => deleteAgent(agentToDelete)}
- >Delete</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={() => { deletingAgent = null; }} aria-label="Close modal"></button>
- </div>
-{/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 @@
-<script lang="ts">
-import type { CacheStats } from "../types.js";
-
-const {
- cacheStats = null,
- tabTitle = null,
-}: {
- cacheStats?: CacheStats | null;
- tabTitle?: string | null;
-} = $props();
-
-// Cache hit rate = cached-read tokens / total prompt tokens. `inputTokens` is
-// the TOTAL prompt (fresh + cache read + cache write), so this is the share of
-// the prompt that was served from Anthropic's prompt cache.
-function rate(read: number, totalInput: number): number {
- if (totalInput <= 0) return 0;
- return Math.max(0, Math.min(1, read / totalInput));
-}
-
-// For caching, a HIGH hit rate is GOOD — invert the usual color thresholds.
-function rateClass(r: number): string {
- if (r >= 0.7) return "progress-success";
- if (r >= 0.3) return "progress-warning";
- return "progress-error";
-}
-
-function fmt(n: number): string {
- return n.toLocaleString();
-}
-
-const hitRate = $derived(cacheStats ? rate(cacheStats.cacheReadTokens, cacheStats.inputTokens) : 0);
-const hitPct = $derived(Math.round(hitRate * 100));
-const uncached = $derived(
- cacheStats
- ? Math.max(0, cacheStats.inputTokens - cacheStats.cacheReadTokens - cacheStats.cacheWriteTokens)
- : 0,
-);
-const lastHitPct = $derived(
- cacheStats?.last
- ? Math.round(rate(cacheStats.last.cacheReadTokens, cacheStats.last.inputTokens) * 100)
- : 0,
-);
-</script>
-
-<div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
- {#if !cacheStats || cacheStats.requests === 0}
- <p class="text-xs text-base-content/50">
- No cache data yet. Send a message to a Claude model — prompt-cache usage
- appears here after the first response.
- </p>
- {:else}
- <div class="bg-base-200 rounded-lg p-2">
- <div class="flex items-center gap-1.5 mb-2">
- <span class="text-xs font-semibold">Cache Hit Rate</span>
- {#if tabTitle}
- <span class="badge badge-xs badge-ghost">{tabTitle}</span>
- {/if}
- <span class="badge badge-xs ml-auto whitespace-nowrap">{cacheStats.requests} req</span>
- </div>
-
- <!-- Headline cumulative hit rate -->
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Session (this tab)</span>
- <span class="text-xs font-mono">{hitPct}%</span>
- </div>
- <progress
- class="progress w-full h-2 {rateClass(hitRate)}"
- value={hitPct}
- max="100"
- ></progress>
- </div>
-
- <!-- Most recent request -->
- {#if cacheStats.last}
- <div class="flex flex-col gap-0.5 mt-2">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Last request</span>
- <span class="text-xs font-mono">{lastHitPct}%</span>
- </div>
- <progress
- class="progress w-full h-2 {rateClass(lastHitPct / 100)}"
- value={lastHitPct}
- max="100"
- ></progress>
- </div>
- {/if}
- </div>
-
- <!-- Token breakdown (cumulative, this tab) -->
- <div class="bg-base-200 rounded-lg p-2">
- <div class="text-xs font-semibold mb-1.5">Tokens (cumulative)</div>
- <div class="flex flex-col gap-1 pl-1">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">
- <span class="badge badge-xs badge-success badge-soft mr-1">read</span>Cache hits
- </span>
- <span class="text-xs font-mono">{fmt(cacheStats.cacheReadTokens)}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">
- <span class="badge badge-xs badge-warning badge-soft mr-1">write</span>Cache writes
- </span>
- <span class="text-xs font-mono">{fmt(cacheStats.cacheWriteTokens)}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">
- <span class="badge badge-xs badge-error badge-soft mr-1">fresh</span>Uncached input
- </span>
- <span class="text-xs font-mono">{fmt(uncached)}</span>
- </div>
- <div class="border-t border-base-300 my-0.5"></div>
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Total input</span>
- <span class="text-xs font-mono">{fmt(cacheStats.inputTokens)}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Output</span>
- <span class="text-xs font-mono">{fmt(cacheStats.outputTokens)}</span>
- </div>
- </div>
- </div>
- {/if}
-</div>
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 @@
-<script lang="ts">
-import {
- ACCEPTED_PDF_MEDIA_TYPE,
- isImageMediaType,
- isPdfMediaType,
- MAX_ATTACHMENTS,
- MAX_IMAGE_BYTES,
- MAX_PDF_BYTES,
-} from "@dispatch/core/src/models/attachments.js";
-import {
- type AttachmentKind,
- computeTokenDeletion,
- generateTokenId,
- makeAttachmentToken,
- parseDraft,
- type StagedAttachment,
-} from "../attachment-tokens.js";
-import { computeContextUsage } from "../context-window.js";
-import { tabStore } from "../tabs.svelte.js";
-
-const {
- contextLimit = null,
- imageSupport = null,
-}: {
- contextLimit?: number | null;
- // Image/PDF INPUT capability for the active model, or `null` when unknown
- // (catalog offline / unsupported provider) — null means "can't verify"
- // (optimistic allow), not a hard no.
- imageSupport?: { image: boolean; pdf: boolean } | null;
-} = $props();
-
-const MAX_LINES = 7;
-
-let inputEl: HTMLTextAreaElement | undefined;
-// Transient error shown when a paste is rejected (bad type / too large / too
-// many). Cleared on the next successful paste or any keystroke.
-let pasteError = $state<string | null>(null);
-
-const agentStatus = $derived(tabStore.activeTab?.agentStatus ?? "idle");
-const tabId = $derived(tabStore.activeTab?.id ?? "");
-// The current input text lives on the active tab (in-memory draft), so
-// switching tabs saves the current draft and restores the target tab's text
-// automatically — drafts are never lost or clobbered by tab switching.
-const inputValue = $derived(tabStore.activeTab?.draft ?? "");
-const attachments = $derived(tabStore.activeTab?.attachments ?? []);
-const cacheStats = $derived(tabStore.activeTab?.cacheStats ?? null);
-
-const isRunning = $derived(agentStatus === "running");
-// Lock input while this tab is mid-compaction: either it's the source
-// conversation being summarized, or it's the transient placeholder tab.
-const compactLocked = $derived(
- (tabStore.activeTab?.isCompacting ?? false) ||
- (tabStore.activeTab?.compactingSource ?? null) !== null ||
- (tabStore.activeTab?.compactionError ?? null) !== null,
-);
-const hasText = $derived(inputValue.trim().length > 0);
-const hasAttachments = $derived(attachments.length > 0);
-// While generating with an empty box, the primary action is "stop". With text
-// in the box, it stays "send" (the message is queued behind the live turn).
-const showStop = $derived(isRunning && !hasText && !hasAttachments);
-
-// ─── Attachment capability gating ──────────────────────────────
-// A definitive "no" from the catalog (imageSupport.image === false with an
-// image staged, or .pdf === false with a pdf staged) blocks the send so no
-// tokens are spent. Unknown capability (imageSupport === null) is permissive.
-const hasImageAttachment = $derived(attachments.some((a) => a.kind === "image"));
-const hasPdfAttachment = $derived(attachments.some((a) => a.kind === "pdf"));
-const imageBlocked = $derived(
- hasImageAttachment && imageSupport !== null && imageSupport.image === false,
-);
-const pdfBlocked = $derived(
- hasPdfAttachment && imageSupport !== null && imageSupport.pdf === false,
-);
-// Attachments require a fresh turn — they can't ride the queue path (which is
-// text-only), so block sending an attachment while the agent is generating.
-const attachmentsWhileRunning = $derived(hasAttachments && isRunning);
-
-const attachmentWarning = $derived.by(() => {
- if (pasteError) return pasteError;
- if (attachmentsWhileRunning)
- return "Wait for the current response to finish before sending images.";
- if (imageBlocked && pdfBlocked)
- return "The selected model doesn't support image or PDF input. Remove the attachments to send.";
- if (imageBlocked)
- return "The selected model doesn't support image input. Remove the image to send.";
- if (pdfBlocked) return "The selected model doesn't support PDF input. Remove the PDF to send.";
- return null;
-});
-
-// Send is blocked (but not the box) when an attachment is definitively
-// unsupported or when attachments are staged mid-generation.
-const sendBlocked = $derived(imageBlocked || pdfBlocked || attachmentsWhileRunning);
-
-const usage = $derived(computeContextUsage(cacheStats, contextLimit));
-const hasUsage = $derived((cacheStats?.last ?? null) !== null);
-
-// As the window fills, escalate color: calm → warning → danger. Mirrors the
-// Context Window sidebar view so the two displays agree.
-function fillClass(pct: number): string {
- if (pct >= 90) return "progress-error";
- if (pct >= 70) return "progress-warning";
- return "progress-success";
-}
-
-// Compact token count for the slim bar (e.g. 12.3k, 1.2M). Full numbers live
-// in the sidebar's Context Window panel.
-function fmtCompact(n: number): string {
- if (n < 1000) return `${n}`;
- if (n < 1_000_000) {
- const k = n / 1000;
- return `${k >= 100 ? Math.round(k) : k.toFixed(1)}k`;
- }
- const m = n / 1_000_000;
- return `${m >= 100 ? Math.round(m) : m.toFixed(1)}M`;
-}
-
-$effect(() => {
- // Re-focus when switching tabs.
- void tabId;
- inputEl?.focus();
-});
-
-function resize() {
- const el = inputEl;
- if (!el) return;
- // Reset height so scrollHeight reflects the content's natural height.
- el.style.height = "auto";
- const style = getComputedStyle(el);
- const lineHeight = Number.parseFloat(style.lineHeight) || 20;
- const paddingY = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
- const borderY =
- Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
- const maxHeight = lineHeight * MAX_LINES + paddingY + borderY;
- const next = Math.min(el.scrollHeight, maxHeight);
- el.style.height = `${next}px`;
- el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
-}
-
-// Re-run resize whenever the value changes (covers tab switches and
-// programmatic clears too).
-$effect(() => {
- // Touch inputValue so this effect tracks it.
- void inputValue;
- resize();
-});
-
-function handleInput(e: Event) {
- if (!tabId) return;
- pasteError = null;
- // setDraft also reconciles staged attachments against the surviving tokens,
- // so deleting a token (by any means) detaches its attachment.
- tabStore.setDraft(tabId, (e.currentTarget as HTMLTextAreaElement).value);
-}
-
-function kindForMediaType(mediaType: string): AttachmentKind | null {
- if (isImageMediaType(mediaType)) return "image";
- if (isPdfMediaType(mediaType)) return "pdf";
- return null;
-}
-
-function readAsBase64(file: File): Promise<string> {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => {
- const result = reader.result;
- if (typeof result !== "string") {
- reject(new Error("unexpected reader result"));
- return;
- }
- // Strip the `data:<mediaType>;base64,` prefix → bare base64.
- const comma = result.indexOf(",");
- resolve(comma === -1 ? result : result.slice(comma + 1));
- };
- reader.onerror = () => reject(reader.error ?? new Error("read failed"));
- reader.readAsDataURL(file);
- });
-}
-
-/** Insert `insert` at the textarea's caret, returning the new caret offset. */
-function insertAtCaret(insert: string): number {
- const el = inputEl;
- const text = inputValue;
- const start = el?.selectionStart ?? text.length;
- const end = el?.selectionEnd ?? text.length;
- const next = text.slice(0, start) + insert + text.slice(end);
- if (tabId) tabStore.setDraft(tabId, next);
- return start + insert.length;
-}
-
-async function handlePaste(e: ClipboardEvent) {
- if (!tabId) return;
- const items = e.clipboardData?.items;
- if (!items) return;
- const files: File[] = [];
- for (const item of items) {
- if (item.kind === "file") {
- const file = item.getAsFile();
- if (file) files.push(file);
- }
- }
- // No files in the clipboard → let the default text paste happen.
- if (files.length === 0) return;
- // We're handling at least one file; stop the browser from also pasting a
- // filename / image fallback into the textarea.
- e.preventDefault();
- pasteError = null;
-
- for (const file of files) {
- const kind = kindForMediaType(file.type);
- if (!kind) {
- pasteError = `Unsupported file type: ${file.type || "unknown"}. Allowed: PNG, JPEG, WebP, GIF, PDF.`;
- continue;
- }
- const current = tabStore.activeTab?.attachments ?? [];
- if (current.length >= MAX_ATTACHMENTS) {
- pasteError = `You can attach at most ${MAX_ATTACHMENTS} files per message.`;
- break;
- }
- const limit = kind === "pdf" ? MAX_PDF_BYTES : MAX_IMAGE_BYTES;
- if (file.size > limit) {
- const mb = Math.round(limit / (1024 * 1024));
- pasteError = `${kind === "pdf" ? "PDF" : "Image"} is too large (max ${mb} MB).`;
- continue;
- }
- try {
- const data = await readAsBase64(file);
- const id = generateTokenId();
- const mediaType = kind === "pdf" ? ACCEPTED_PDF_MEDIA_TYPE : file.type;
- const staged: StagedAttachment = {
- id,
- kind,
- mediaType,
- data,
- ...(file.name ? { name: file.name } : {}),
- };
- // Stage first, then insert the token — `setDraft` reconciles against
- // staged attachments, so the attachment must exist before its token
- // appears in the draft.
- tabStore.addAttachment(tabId, staged);
- const caret = insertAtCaret(makeAttachmentToken(kind, id));
- // Restore the caret after the value updates.
- requestAnimationFrame(() => {
- const el = inputEl;
- if (el) {
- el.focus();
- el.setSelectionRange(caret, caret);
- }
- });
- } catch {
- pasteError = "Failed to read the pasted file.";
- }
- }
-}
-
-function handleKeydown(e: KeyboardEvent) {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- submit();
- return;
- }
- if ((e.key === "Backspace" || e.key === "Delete") && inputEl && tabId) {
- // Atomic token delete: a single Backspace/Delete next to (or a selection
- // overlapping) a `【…】` token removes the whole token in one stroke.
- const result = computeTokenDeletion(
- inputValue,
- inputEl.selectionStart ?? 0,
- inputEl.selectionEnd ?? 0,
- e.key,
- );
- if (result) {
- e.preventDefault();
- tabStore.setDraft(tabId, result.text);
- requestAnimationFrame(() => {
- const el = inputEl;
- if (el) {
- el.focus();
- el.setSelectionRange(result.caret, result.caret);
- }
- });
- }
- }
-}
-
-function submit() {
- if (!tabId) return;
- // Block sending while this tab is mid-compaction (source or placeholder).
- if (compactLocked) return;
- const map = new Map(attachments.map((a) => [a.id, a] as const));
- const { displayText, content } = parseDraft(inputValue, map);
- const trimmed = displayText.trim();
- // Nothing to send (no text and no usable attachment).
- if (!trimmed && !content) return;
- // Don't send when a staged attachment is unsupported / mid-generation.
- if (sendBlocked) return;
- const text = trimmed || displayText;
- tabStore.setDraft(tabId, "");
- void tabStore.sendMessage(text, content ?? undefined);
-}
-
-function primaryAction() {
- if (showStop) {
- tabStore.stopGeneration(tabId);
- return;
- }
- submit();
-}
-</script>
-
-<div class="flex flex-col">
- {#if attachmentWarning}
- <div class="px-3 pt-2 text-xs text-warning flex items-start gap-1">
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5 mt-0.5 shrink-0" aria-hidden="true">
- <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
- <line x1="12" y1="9" x2="12" y2="13"></line>
- <line x1="12" y1="17" x2="12.01" y2="17"></line>
- </svg>
- <span>{attachmentWarning}</span>
- </div>
- {/if}
- <!-- Top bar: expanding textarea + send/stop action -->
- <div class="flex items-end gap-2 px-3 pt-3 pb-2">
- <textarea
- bind:this={inputEl}
- value={inputValue}
- rows="1"
- placeholder={compactLocked
- ? "Compaction in progress…"
- : "Type a message... (paste an image or PDF to attach)"}
- disabled={compactLocked}
- class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto"
- onkeydown={handleKeydown}
- oninput={handleInput}
- onpaste={handlePaste}
- ></textarea>
- <!-- Single fixed-width button across all states so the layout never
- shifts when it morphs between Send and Stop. -->
- <button
- type="button"
- class="btn w-20 shrink-0 {showStop ? 'btn-error btn-outline' : 'btn-primary'}"
- disabled={compactLocked || (!showStop && !hasText && !hasAttachments) || sendBlocked}
- onclick={primaryAction}
- title={showStop ? "Stop generation" : sendBlocked ? (attachmentWarning ?? "Cannot send") : "Send message"}
- >
- {#if showStop}
- <span class="loading loading-spinner loading-sm"></span>
- Stop
- {:else}
- Send
- {/if}
- </button>
- </div>
-
- <!-- Bottom bar: status icon · context progress · token count -->
- <div class="flex items-center gap-2 px-3 pb-2 text-xs text-base-content/50">
- <!-- Status icon -->
- <span class="shrink-0">
- {#if agentStatus === "running"}
- <span class="loading loading-spinner loading-xs text-primary"></span>
- {:else if agentStatus === "error"}
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 text-error" aria-label="Error">
- <circle cx="12" cy="12" r="10"></circle>
- <line x1="12" y1="8" x2="12" y2="12"></line>
- <line x1="12" y1="16" x2="12.01" y2="16"></line>
- </svg>
- {:else}
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 text-success" aria-label="Idle">
- <polyline points="20 6 9 17 4 12"></polyline>
- </svg>
- {/if}
- </span>
-
- <!-- Context-window fill bar -->
- {#if usage.percent !== null}
- <progress
- class="progress flex-1 h-2 {fillClass(usage.percent)}"
- value={usage.percent}
- max="100"
- ></progress>
- {:else}
- <!-- Model's max context is unknown → inert, disabled bar. -->
- <progress class="progress flex-1 h-2 opacity-40" value="0" max="100"></progress>
- {/if}
-
- <!-- Context size + percent -->
- <span class="shrink-0 font-mono whitespace-nowrap">
- {#if hasUsage}
- {fmtCompact(usage.current)}{#if usage.max !== null}<span class="text-base-content/40"> / {fmtCompact(usage.max)}</span>{/if}
- {#if usage.percent !== null}
- <span class="ml-1">· {usage.percent.toFixed(1)}%</span>
- {/if}
- {:else}
- <span class="text-base-content/40">— tokens</span>
- {/if}
- </span>
- </div>
-</div>
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 @@
-<script lang="ts">
-import { appSettings } from "../settings.svelte.js";
-import { tabStore } from "../tabs.svelte.js";
-import type { ChatMessage, Chunk, SystemChunkKind } from "../types.js";
-import MarkdownRenderer from "./MarkdownRenderer.svelte";
-import ToolCallDisplay from "./ToolCallDisplay.svelte";
-
-const { message, tabId }: { message: ChatMessage; tabId?: string } = $props();
-
-const isUser = $derived(message.role === "user");
-const isSystem = $derived(message.role === "system");
-
-// Check if this message is queued: its id starts with "queued-"
-const queuedMessageId = $derived(
- isUser && message.id.startsWith("queued-") ? message.id.slice("queued-".length) : null,
-);
-const isQueued = $derived(queuedMessageId !== null);
-
-function cancelQueued() {
- if (tabId && queuedMessageId) {
- void tabStore.cancelQueuedMessage(tabId, queuedMessageId);
- }
-}
-
-function chunkKey(chunk: Chunk, i: number): string {
- if (chunk.type === "tool-batch") {
- // Stable-ish: first call id + count keeps re-renders sane while streaming.
- return `tb-${chunk.calls[0]?.id ?? i}-${chunk.calls.length}`;
- }
- return `${chunk.type}-${i}`;
-}
-
-const SYSTEM_KIND_LABEL: Record<SystemChunkKind, string> = {
- notice: "Notice",
- "model-changed": "Model changed",
- "config-reload": "Config reload",
- cancelled: "Cancelled",
-};
-
-/**
- * Returns true if the given chunk has visible content worth rendering.
- * Used by `hasRenderableContent` to suppress empty assistant bubbles.
- *
- * Note: `ThinkingChunk.metadata` is intentionally excluded — it is
- * internal wire data (Anthropic's providerMetadata / signature) and
- * must never appear in the UI.
- */
-function chunkHasRenderableContent(chunk: Chunk): boolean {
- switch (chunk.type) {
- case "text":
- return chunk.text.length > 0;
- case "thinking":
- return chunk.text.length > 0;
- case "tool-batch":
- return chunk.calls.length > 0;
- case "error":
- return true;
- case "system":
- return true;
- }
-}
-
-/**
- * True when the assistant bubble has something worth showing.
- * Guards the assistant render path so we don't emit an empty box
- * (e.g. a message that only had empty/signature-only thinking blocks
- * from Anthropic adaptive thinking mode).
- *
- * Streaming messages always have renderable content — the cursor
- * needs somewhere to live.
- */
-const hasRenderableContent = $derived(
- message.isStreaming === true || message.chunks.some(chunkHasRenderableContent),
-);
-</script>
-
-{#snippet renderChunks(chunks: Chunk[], streaming: boolean | undefined)}
- {#each chunks as chunk, i (chunkKey(chunk, i))}
- {#if chunk.type === "text"}
- <MarkdownRenderer text={chunk.text} {streaming} />
- {:else if chunk.type === "thinking"}
- <!-- Skip empty thinking chunks: Anthropic adaptive thinking can emit
- a reasoning-end with a signature but no thinking_delta content.
- The metadata is internal wire data — never displayed. -->
- {#if chunk.text.length > 0}
- <div class="collapse collapse-arrow mb-2 p-1">
- <input type="checkbox" checked={appSettings.autoExpandThinking} />
- <div class="collapse-title text-sm opacity-60 italic py-0 pl-0 pr-8 min-h-0">Thinking...</div>
- <div class="collapse-content text-sm opacity-60 italic p-0">
- <p class="whitespace-pre-wrap mt-1">{chunk.text}</p>
- </div>
- </div>
- {/if}
- {:else if chunk.type === "tool-batch"}
- {#each chunk.calls as call (call.id)}
- <ToolCallDisplay toolCall={call} />
- {/each}
- {:else if chunk.type === "error"}
- <div class="alert alert-error my-2 py-2 px-3 text-sm rounded border border-error/60 bg-error/10 text-error">
- <div class="flex flex-col gap-0.5 w-full">
- <span class="break-words">{chunk.message}</span>
- {#if chunk.statusCode !== undefined}
- <span class="text-xs opacity-70">status {chunk.statusCode}</span>
- {/if}
- </div>
- </div>
- {:else if chunk.type === "system"}
- <div class="my-1 text-xs italic opacity-50 flex gap-1 items-baseline">
- <span class="font-semibold not-italic">{SYSTEM_KIND_LABEL[chunk.kind]}:</span>
- <span class="break-words">{chunk.text}</span>
- </div>
- {/if}
- {/each}
-{/snippet}
-
-{#if isSystem}
- <div class="flex justify-center my-2 px-4">
- <div class="max-w-full text-center">
- {@render renderChunks(message.chunks, false)}
- </div>
- </div>
-{:else if !isUser && !hasRenderableContent}
- <!-- Empty assistant message — no renderable chunks and not streaming.
- Suppressed to avoid an empty bubble (e.g. a turn that produced
- only empty/signature-only thinking blocks from Anthropic adaptive
- thinking mode, or a done event with no content). -->
-{:else}
-<div class="chat chat-start mb-2 [&>.chat-bubble]:max-w-full {isQueued ? 'opacity-60' : ''}">
- <div class="chat-bubble break-words {isUser ? 'chat-bubble-primary w-fit' : 'bg-transparent w-full'}">
- {@render renderChunks(message.chunks, message.isStreaming)}
- {#if message.isStreaming}
- <span class="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5 align-middle rounded-sm"></span>
- {/if}
- </div>
- {#if isQueued}
- <div class="flex items-center gap-1 mt-0.5 ml-1">
- <span class="badge badge-ghost badge-xs text-base-content/40">queued</span>
- <button
- class="btn btn-xs btn-ghost text-base-content/40 hover:text-error px-1 min-h-0 h-auto"
- onclick={cancelQueued}
- title="Cancel queued message"
- >
- ✕
- </button>
- </div>
- {/if}
-</div>
-{/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 @@
-<script lang="ts">
-import { tick, untrack } from "svelte";
-import { tabStore } from "../tabs.svelte.js";
-import ChatMessageComponent from "./ChatMessage.svelte";
-
-let messagesEl: HTMLDivElement | undefined;
-let userScrolledUp = $state(false);
-let isAutoScrolling = false;
-let isLoadingMore = $state(false);
-
-const renderGroups = $derived(tabStore.activeTab?.renderGroups ?? []);
-const activeTabId = $derived(tabStore.activeTab?.id);
-// Compaction placeholder state for the active tab. `compactingSource` is set on
-// a transient placeholder tab while a conversation is being compacted;
-// `compactionError` is set if it failed.
-const compactingSource = $derived(tabStore.activeTab?.compactingSource ?? null);
-const compactionError = $derived(tabStore.activeTab?.compactionError ?? null);
-
-// Stable, turn-scoped render keys. A bubble's identity is `${turnId}:${role}:${n}`
-// (n = its index among same-(turn,role) messages) rather than the underlying
-// row/client id, so when the live turn reconciles into sealed chunk rows the
-// bubble keeps its identity and does NOT remount (no flash). Falls back to the
-// message id for anything without a turnId (e.g. optimistic/queued messages
-// before turn-start, standalone system notices).
-const keyedMessages = $derived.by(() => {
- const counts = new Map<string, number>();
- return renderGroups.map((m) => {
- if (!m.turnId) return { m, key: m.id };
- const base = `${m.turnId}:${m.role}`;
- const n = counts.get(base) ?? 0;
- counts.set(base, n + 1);
- return { m, key: `${base}:${n}` };
- });
-});
-
-function isNearBottom(el: HTMLElement): boolean {
- return el.scrollHeight - el.scrollTop - el.clientHeight < 64;
-}
-
-function scrollToBottom(animate = false) {
- if (!messagesEl) return;
- messagesEl.scrollTo({
- top: messagesEl.scrollHeight,
- behavior: animate ? "smooth" : "instant",
- });
-}
-
-async function onNearTop() {
- if (isLoadingMore) return;
- const tab = tabStore.activeTab;
- if (!tab) return;
- // Nothing older to load if we're already at the very first message, or if
- // we already hold every message the backend has for this tab.
- if (tab.oldestLoadedSeq !== null && tab.oldestLoadedSeq <= 0) return;
-
- isLoadingMore = true;
- const prevScrollHeight = messagesEl?.scrollHeight ?? 0;
- const prevScrollTop = messagesEl?.scrollTop ?? 0;
- try {
- await tabStore.loadOlderChunks(tab.id);
- // Wait for Svelte to flush the prepended messages into the DOM.
- // Reading `scrollHeight` synchronously after the await would observe
- // the OLD layout (reactive updates are batched), so the scroll
- // correction would be computed against a stale height and the
- // viewport would jump. `tick()` resolves once the DOM reflects the
- // new message list.
- await tick();
- if (messagesEl) {
- const newScrollHeight = messagesEl.scrollHeight;
- const delta = newScrollHeight - prevScrollHeight;
- // Only adjust when content was actually prepended above the
- // viewport. If nothing was added (all duplicates / nothing older),
- // `delta` is 0 and we leave the user where they are instead of
- // snapping to the top.
- if (delta > 0) {
- messagesEl.scrollTop = prevScrollTop + delta;
- }
- }
- } finally {
- isLoadingMore = false;
- }
-}
-
-function handleScroll() {
- if (!messagesEl || isAutoScrolling) return;
- const wasScrolledUp = userScrolledUp;
- userScrolledUp = !isNearBottom(messagesEl);
- if (activeTabId) tabStore.setScrolledUp(activeTabId, userScrolledUp);
- // User just scrolled back to the bottom manually — safe to evict now.
- if (wasScrolledUp && !userScrolledUp && activeTabId) {
- tabStore.evictChunks(activeTabId);
- }
- // Near the top — pull in older history.
- if (userScrolledUp && messagesEl.scrollTop < 200) {
- void onNearTop();
- }
-}
-
-function resumeAutoScroll() {
- userScrolledUp = false;
- isAutoScrolling = true;
- if (activeTabId) {
- tabStore.setScrolledUp(activeTabId, false);
- tabStore.evictChunks(activeTabId);
- }
- scrollToBottom(true);
-}
-
-$effect(() => {
- const count = renderGroups.length;
- void count;
- if (messagesEl) {
- untrack(() => {
- if (!userScrolledUp) scrollToBottom(false);
- });
- }
-});
-
-$effect(() => {
- const prevTabId = activeTabId;
- // Reset scroll state when switching tabs
- userScrolledUp = false;
- isAutoScrolling = false;
- return () => {
- if (prevTabId) {
- tabStore.setScrolledUp(prevTabId, false);
- }
- };
-});
-</script>
-
-<div class="flex flex-col h-full">
- <!-- Messages -->
- <div class="relative flex-1 min-h-0">
- <div
- bind:this={messagesEl}
- class="h-full overflow-y-auto p-4"
- onscroll={handleScroll}
- onscrollend={() => {
- isAutoScrolling = false;
- }}
- >
- {#if isLoadingMore}
- <div class="text-center text-xs text-base-content/40 py-2">Loading earlier messages...</div>
- {/if}
- {#if compactingSource || compactionError}
- <div class="flex flex-col items-center justify-center h-full gap-4 px-6 text-center">
- {#if compactionError}
- <div class="text-2xl font-semibold text-error">Compaction failed</div>
- <div class="text-base text-base-content/70 max-w-md">{compactionError}</div>
- <div class="text-sm text-base-content/50">Close this tab to dismiss — your conversation was not changed.</div>
- {:else}
- <span class="loading loading-spinner loading-lg text-primary"></span>
- <div class="text-2xl font-semibold text-base-content">Please wait, compacting conversation…</div>
- <div class="text-sm text-base-content/50">You can cancel by closing this tab.</div>
- {/if}
- </div>
- {:else if renderGroups.length === 0}
- <div class="flex items-center justify-center h-full text-base-content/40 text-sm">
- Send a message to start a conversation
- </div>
- {/if}
- {#if !compactingSource && !compactionError}
- {#each keyedMessages as { m, key } (key)}
- <ChatMessageComponent message={m} tabId={activeTabId} />
- {/each}
- {/if}
- </div>
-
- <!-- Scroll-to-bottom button -->
- <button
- type="button"
- class="absolute bottom-0 left-1/2 -translate-x-1/2 mb-4 btn btn-sm px-8 rounded-lg shadow-lg transition-opacity duration-200 {userScrolledUp ? 'opacity-100' : 'opacity-0 pointer-events-none'}"
- onclick={resumeAutoScroll}
- aria-label="Scroll to bottom"
- tabindex={userScrolledUp ? 0 : -1}
- >
- &#x25BC;
- </button>
- </div>
-</div>
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 @@
-<script lang="ts">
-import { onDestroy } from "svelte";
-import { SnapshotSequencer } from "../snapshot-sequencer.js";
-
-const { apiBase = "" }: { apiBase?: string } = $props();
-
-/** Fixed offset (hours) from a wake to the "Claude session reset" display.
- * Mirrors the backend constant — kept in sync via the GET response. */
-const DEFAULT_RESET_OFFSET_HOURS = 5;
-const DEFAULT_PROBE_SLOT_MINUTES = [0, 15, 30, 45] as const;
-
-type ProbeMinute = number; // 0 | 15 | 30 | 45 in practice
-type HourSlots = Record<ProbeMinute, number>; // slot minute → next fire ms
-
-interface WakeResult {
- label: string;
- ok: boolean;
- error?: string;
-}
-
-interface LastWake {
- firedAt: number;
- ok: boolean;
- results: WakeResult[];
-}
-
-interface PendingRetry {
- retriesLeft: number;
- nextRetryAt: number;
- reason: string;
-}
-
-interface ScheduleSnapshot {
- /** hour (0-23) → { slotMinute → next fire ms }. */
- schedule: Record<string, Record<string, number>>;
- resetOffsetHours?: number;
- probeSlotMinutes?: number[];
- lastWake?: LastWake | null;
- pendingRetry?: PendingRetry | null;
-}
-
-// Marked hours: hour → { slotMinute → next fire ms }. Empty inner record = not marked.
-let schedule = $state<Record<number, HourSlots>>({});
-let resetOffsetHours = $state<number>(DEFAULT_RESET_OFFSET_HOURS);
-let probeSlotMinutes = $state<readonly number[]>(DEFAULT_PROBE_SLOT_MINUTES);
-let lastWake = $state<LastWake | null>(null);
-let pendingRetry = $state<PendingRetry | null>(null);
-
-/**
- * Global mutation lock: the hour whose toggle POST is currently in flight,
- * or null if none. Disables ALL toggle buttons (not just this hour's) while
- * any mutation is pending.
- *
- * Why global, not per-hour: snapshot responses can be reordered on the wire,
- * but worse, requests themselves can be reordered. If two POSTs are in
- * flight and the SERVER processes them out of order, the snapshot the
- * SnapshotSequencer picks as "winner" (highest client-send seq) may not be
- * the snapshot reflecting the truest server state — the UI desyncs from
- * the server permanently. Serializing mutations on the client eliminates
- * the reorder window entirely. (The sequencer is still useful for the
- * GET-on-mount vs first-click race.)
- */
-let pendingHour = $state<number | null>(null);
-
-/**
- * Single global sequencer for ALL /models/wake-schedule responses (initial
- * GET + every toggle POST). Each response is dropped if a newer request has
- * already won. This protects against three races:
- * 1. Two toggles on different hours land out of order — older snapshot
- * blindly overwrites the newer one, and the most-recent click vanishes
- * from the UI.
- * 2. The initial loadFromServer is still in flight when the user clicks.
- * 3. Any future fan-out (e.g. polling) racing a user action.
- * A per-hour counter was insufficient because applySnapshot replaces the
- * whole `schedule` object, not just one hour's slot. See snapshot-sequencer.ts.
- */
-const sequencer = new SnapshotSequencer();
-
-/** Live "now" used for the current-hour ring + relative timestamps. */
-let nowMs = $state<number>(Date.now());
-
-const nowTimer = setInterval(() => {
- nowMs = Date.now();
-}, 30_000);
-
-onDestroy(() => {
- clearInterval(nowTimer);
-});
-
-function formatHour(h: number): string {
- const display = h % 12;
- return display === 0 ? "12" : String(display);
-}
-
-/**
- * Compute the next occurrence of HH:MM in the user's local timezone.
- * Today if still future, else tomorrow.
- */
-function nextOccurrenceAt(hour: number, minute: number): number {
- const target = new Date();
- target.setHours(hour, minute, 0, 0);
- if (target.getTime() <= Date.now()) {
- target.setDate(target.getDate() + 1);
- }
- return target.getTime();
-}
-
-function applySnapshot(data: ScheduleSnapshot): void {
- const parsed: Record<number, HourSlots> = {};
- for (const [hourStr, slots] of Object.entries(data.schedule ?? {})) {
- const inner: HourSlots = {};
- for (const [slotStr, ts] of Object.entries(slots ?? {})) {
- inner[Number(slotStr)] = ts;
- }
- parsed[Number(hourStr)] = inner;
- }
- schedule = parsed;
- if (typeof data.resetOffsetHours === "number") {
- resetOffsetHours = data.resetOffsetHours;
- }
- if (Array.isArray(data.probeSlotMinutes) && data.probeSlotMinutes.length > 0) {
- probeSlotMinutes = data.probeSlotMinutes;
- }
- lastWake = data.lastWake ?? null;
- pendingRetry = data.pendingRetry ?? null;
-}
-
-async function loadFromServer(): Promise<void> {
- const mySeq = sequencer.begin();
- try {
- const res = await fetch(`${apiBase}/models/wake-schedule`);
- if (!res.ok) return;
- const data = (await res.json()) as ScheduleSnapshot;
- if (!sequencer.accept(mySeq)) return; // a newer response already won
- applySnapshot(data);
- } catch {
- // Network error — leave existing state
- }
-}
-
-function setPending(hour: number | null): void {
- pendingHour = hour;
-}
-
-async function postToggle(
- hour: number,
- action: "on" | "off",
- timestamps?: Record<number, number>,
-): Promise<void> {
- const mySeq = sequencer.begin();
- setPending(hour);
-
- try {
- const body: {
- hour: number;
- action: "on" | "off";
- timestamps?: Record<string, number>;
- } = { hour, action };
- if (timestamps) {
- const stringKeyed: Record<string, number> = {};
- for (const [k, v] of Object.entries(timestamps)) stringKeyed[k] = v;
- body.timestamps = stringKeyed;
- }
- const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) return;
- const data = (await res.json()) as ScheduleSnapshot;
- // Drop stale snapshots — only the most-recent request wins for ALL
- // shared state (schedule, lastWake, pendingRetry). Even with the
- // global mutation lock, this still catches the GET-on-mount vs
- // first-click race.
- if (!sequencer.accept(mySeq)) return;
- applySnapshot(data);
- } catch {
- // Network error — leave local state alone; user can re-toggle.
- } finally {
- setPending(null);
- }
-}
-
-function toggleHour(hour: number): void {
- // Global lock: any pending mutation on ANY hour blocks new clicks. This
- // serializes POSTs on the wire so the server never has to choose between
- // two concurrent requests — eliminating the request-reorder failure mode
- // where the sequencer's "highest client seq wins" rule would discard the
- // snapshot reflecting the true server state.
- if (pendingHour !== null) return;
- if (schedule[hour] !== undefined) {
- // User intent: turn this hour OFF.
- void postToggle(hour, "off");
- } else {
- // User intent: turn this hour ON — compute first occurrence of HH:MM
- // for each probe slot, in the user's local timezone.
- const timestamps: Record<number, number> = {};
- for (const minute of probeSlotMinutes) {
- timestamps[minute] = nextOccurrenceAt(hour, minute);
- }
- void postToggle(hour, "on", timestamps);
- }
-}
-
-$effect(() => {
- void loadFromServer();
-});
-
-/**
- * Faded hours: the `resetOffsetHours - 1` hours immediately after each
- * marked hour (the "active session window"). Excludes hours that are
- * themselves marked.
- */
-const fadedHours = $derived.by((): Set<number> => {
- const result = new Set<number>();
- const window = Math.max(0, resetOffsetHours - 1);
- for (const h of Object.keys(schedule).map(Number)) {
- for (let i = 1; i <= window; i++) {
- const faded = (h + i) % 24;
- if (schedule[faded] === undefined) {
- result.add(faded);
- }
- }
- }
- return result;
-});
-
-const currentHour = $derived(new Date(nowMs).getHours());
-
-function blockClass(hour: number, faded: Set<number>): string {
- const isMarked = schedule[hour] !== undefined;
- const isCurrent = hour === currentHour;
- const isFaded = faded.has(hour);
- // Only the hour whose request is in flight shows the "wait" cursor;
- // other buttons are merely disabled (via the template `disabled={...}`).
- const isPending = pendingHour === hour;
-
- let base =
- "flex items-center justify-center rounded select-none text-[10px] font-mono transition-colors";
-
- if (isPending) {
- base += " opacity-60 cursor-wait";
- } else {
- base += " cursor-pointer";
- }
-
- if (isMarked) {
- base += " bg-primary text-primary-content";
- } else if (isFaded) {
- base += " bg-primary/25 text-base-content";
- } else {
- base += " bg-base-300 text-base-content/60 hover:bg-base-content/10";
- }
-
- if (isCurrent) {
- base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
- }
-
- return base;
-}
-
-function formatAmPm(hour24: number): string {
- const h = hour24 % 12;
- const ampm = hour24 < 12 ? "AM" : "PM";
- return `${h === 0 ? "12" : String(h)}:00 ${ampm}`;
-}
-
-function resetHour(wakeHour: number): number {
- return (wakeHour + resetOffsetHours) % 24;
-}
-
-function formatRelative(ts: number, now: number): string {
- const diff = now - ts;
- if (diff < 0) {
- const ahead = -diff;
- if (ahead < 60_000) return "in <1 min";
- if (ahead < 3600_000) return `in ${Math.round(ahead / 60_000)} min`;
- return `in ${Math.round(ahead / 3600_000)} h`;
- }
- if (diff < 60_000) return "just now";
- if (diff < 3600_000) return `${Math.round(diff / 60_000)} min ago`;
- if (diff < 86_400_000) return `${Math.round(diff / 3600_000)} h ago`;
- return `${Math.round(diff / 86_400_000)} d ago`;
-}
-
-const markedHours = $derived(
- Object.keys(schedule)
- .map(Number)
- .sort((a, b) => a - b),
-);
-
-function probeLabel(minute: number): string {
- return `:${String(minute).padStart(2, "0")}`;
-}
-
-const probeLabels = $derived(probeSlotMinutes.map(probeLabel).join(" "));
-
-const amRow1 = Array.from({ length: 6 }, (_, i) => i); // 0–5
-const amRow2 = Array.from({ length: 6 }, (_, i) => i + 6); // 6–11
-const pmRow1 = Array.from({ length: 6 }, (_, i) => i + 12); // 12–17
-const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23
-</script>
-
-<div class="flex flex-col gap-2">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Claude Wake Schedule</div>
-
- <!-- AM rows -->
- <div class="flex items-center gap-1">
- <span class="text-[10px] font-semibold text-base-content/40 w-5 shrink-0">AM</span>
- <div class="flex flex-col gap-0.5">
- <div class="flex gap-0.5">
- {#each amRow1 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} AM — probes at {probeLabels}">
- {formatHour(hour)}
- </button>
- {/each}
- </div>
- <div class="flex gap-0.5">
- {#each amRow2 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} AM — probes at {probeLabels}">
- {formatHour(hour)}
- </button>
- {/each}
- </div>
- </div>
- </div>
-
- <!-- PM rows -->
- <div class="flex items-center gap-1">
- <span class="text-[10px] font-semibold text-base-content/40 w-5 shrink-0">PM</span>
- <div class="flex flex-col gap-0.5">
- <div class="flex gap-0.5">
- {#each pmRow1 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} PM — probes at {probeLabels}">
- {formatHour(hour)}
- </button>
- {/each}
- </div>
- <div class="flex gap-0.5">
- {#each pmRow2 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} PM — probes at {probeLabels}">
- {formatHour(hour)}
- </button>
- {/each}
- </div>
- </div>
- </div>
-
- <!-- Marked hours summary -->
- {#if markedHours.length > 0}
- <div class="flex flex-col gap-0.5 mt-1">
- {#each markedHours as hour}
- <div class="flex items-center gap-1.5 text-xs text-base-content/70">
- <span class="badge badge-xs badge-primary whitespace-nowrap shrink-0">{formatHour(hour)} {hour < 12 ? "AM" : "PM"}</span>
- <span>Probes {probeLabels} → reset by {formatAmPm(resetHour(hour))}</span>
- </div>
- {/each}
- </div>
- {:else}
- <p class="text-xs text-base-content/40 italic">No wake hours marked. Click a block to probe at {probeLabels} that hour.</p>
- {/if}
-
- <!-- Status: last wake / pending retry -->
- {#if lastWake}
- <div class="flex items-center gap-1.5 text-xs mt-1" class:text-success={lastWake.ok} class:text-error={!lastWake.ok}>
- <span class="font-semibold">{lastWake.ok ? "✓" : "✗"}</span>
- <span>Last wake {formatRelative(lastWake.firedAt, nowMs)}{lastWake.ok ? "" : ` — ${lastWake.results.find((r) => !r.ok)?.error ?? "failed"}`}</span>
- </div>
- {/if}
- {#if pendingRetry}
- <div class="text-xs text-warning">
- Retrying ({pendingRetry.retriesLeft} left, next {formatRelative(pendingRetry.nextRetryAt, nowMs)})
- </div>
- {/if}
-</div>
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 @@
-<script lang="ts">
-interface AgentTemplate {
- name: string;
- description?: string;
- model_tag?: string;
- tools?: string[];
-}
-
-interface ModelEntry {
- id: string;
- provider?: string;
- tags?: string[];
-}
-
-interface KeyEntry {
- id: string;
- provider?: string;
- status?: string;
- lastError?: string;
- exhaustedAt?: string;
-}
-
-interface ConfigData {
- agents?: Record<string, AgentTemplate>;
- models?: Record<string, { provider?: string; tags?: string[] }>;
- keys?: Record<string, { env?: string }>;
- fallback?: string[];
- permissions?: Record<string, unknown>;
-}
-
-interface ModelsData {
- models?: ModelEntry[];
- tags?: Record<string, string[]>;
- keys?: KeyEntry[];
-}
-
-const { apiBase }: { apiBase: string } = $props();
-
-let configData = $state<ConfigData | null>(null);
-let modelsData = $state<ModelsData | null>(null);
-let error = $state<string | null>(null);
-let loading = $state(false);
-
-async function fetchData() {
- loading = true;
- error = null;
- try {
- const [configRes, modelsRes] = await Promise.all([
- fetch(`${apiBase}/config`),
- fetch(`${apiBase}/models`),
- ]);
- if (!configRes.ok) throw new Error(`/config returned ${configRes.status}`);
- if (!modelsRes.ok) throw new Error(`/models returned ${modelsRes.status}`);
- const configJson = await configRes.json();
- const modelsJson = await modelsRes.json();
- configData = configJson.config ?? configJson;
- modelsData = modelsJson;
- } catch (e) {
- error = e instanceof Error ? e.message : String(e);
- } finally {
- loading = false;
- }
-}
-
-$effect(() => {
- fetchData();
-});
-
-const modelCount = $derived(modelsData?.models?.length ?? 0);
-const keyCount = $derived(modelsData?.keys?.length ?? 0);
-
-function formatDate(iso: string | undefined): string {
- if (!iso) return "";
- try {
- return new Date(iso).toLocaleString();
- } catch {
- return iso;
- }
-}
-
-function permissionEntries(
- permissions: Record<string, unknown>,
-): Array<{ name: string; value: unknown }> {
- return Object.entries(permissions).map(([name, value]) => ({ name, value }));
-}
-
-function isSimpleRule(value: unknown): value is { action: string } {
- return (
- typeof value === "object" &&
- value !== null &&
- "action" in value &&
- Object.keys(value).length === 1
- );
-}
-
-function isPatternRule(value: unknown): value is Record<string, { action: string }> {
- return typeof value === "object" && value !== null && !("action" in value);
-}
-</script>
-
-<details class="collapse collapse-arrow bg-base-200 mt-2">
- <summary class="collapse-title text-sm font-medium flex items-center gap-2">
- <span>Configuration</span>
- {#if modelCount > 0 || keyCount > 0}
- <span class="badge badge-sm badge-neutral">{modelCount} models</span>
- <span class="badge badge-sm badge-neutral">{keyCount} keys</span>
- {/if}
- {#if loading}
- <span class="loading loading-spinner loading-xs ml-auto"></span>
- {/if}
- </summary>
-
- <div class="collapse-content text-xs">
- {#if error}
- <div class="alert alert-error alert-sm py-1 mb-2 text-xs">
- <span>Failed to load config: {error}</span>
- </div>
- {/if}
-
- <div class="flex justify-end mb-2">
- <button
- type="button"
- class="btn btn-xs btn-ghost"
- onclick={fetchData}
- disabled={loading}
- >
- {loading ? "Loading…" : "Refresh"}
- </button>
- </div>
-
- <!-- Agent Templates -->
- {#if configData?.agents && Object.keys(configData.agents).length > 0}
- <div class="mb-3">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Agent Templates</div>
- {#each Object.entries(configData.agents) as [name, template]}
- <div class="bg-base-100 rounded p-2 mb-1">
- <div class="flex items-center gap-2 flex-wrap">
- <span class="font-medium">{name}</span>
- {#if template.model_tag}
- <span class="badge badge-xs badge-info">{template.model_tag}</span>
- {/if}
- </div>
- {#if template.description}
- <p class="text-base-content/60 mt-0.5">{template.description}</p>
- {/if}
- {#if template.tools && template.tools.length > 0}
- <div class="flex flex-wrap gap-1 mt-1">
- {#each template.tools as tool}
- <span class="badge badge-xs badge-ghost">{tool}</span>
- {/each}
- </div>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- <!-- Models -->
- {#if modelsData?.models && modelsData.models.length > 0}
- <div class="mb-3">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Models</div>
- {#each modelsData.models as model}
- <div class="flex items-center gap-2 flex-wrap py-0.5 border-b border-base-300">
- <span class="font-mono">{model.id}</span>
- {#if model.provider}
- <span class="badge badge-xs badge-primary">{model.provider}</span>
- {/if}
- {#if model.tags && model.tags.length > 0}
- {#each model.tags as tag}
- <span class="badge badge-xs badge-ghost">{tag}</span>
- {/each}
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- <!-- Keys -->
- {#if modelsData?.keys && modelsData.keys.length > 0}
- <div class="mb-3">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">API Keys</div>
- {#each modelsData.keys as key}
- <div class="bg-base-100 rounded p-1.5 mb-1">
- <div class="flex items-center gap-2 flex-wrap">
- <span class="font-mono">{key.id}</span>
- {#if key.provider}
- <span class="badge badge-xs badge-secondary">{key.provider}</span>
- {/if}
- {#if key.status === "exhausted"}
- <span class="badge badge-xs badge-error">exhausted</span>
- {:else}
- <span class="badge badge-xs badge-success">active</span>
- {/if}
- </div>
- {#if key.status === "exhausted" && key.lastError}
- <p class="text-error/80 mt-0.5 truncate">{key.lastError}</p>
- {/if}
- {#if key.exhaustedAt}
- <p class="text-base-content/40 mt-0.5">Since {formatDate(key.exhaustedAt)}</p>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- <!-- Fallback Order -->
- {#if configData?.fallback && configData.fallback.length > 0}
- <div class="mb-3">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Fallback Order</div>
- <ol class="list-none space-y-0.5">
- {#each configData.fallback as keyId, i}
- <li class="flex items-center gap-2">
- <span class="badge badge-xs badge-outline">{i + 1}</span>
- <span class="font-mono">{keyId}</span>
- </li>
- {/each}
- </ol>
- </div>
- {/if}
-
- <!-- Permissions -->
- {#if configData?.permissions && Object.keys(configData.permissions).length > 0}
- <div class="mb-1">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Permissions</div>
- {#each permissionEntries(configData.permissions) as entry}
- <div class="py-0.5 border-b border-base-300">
- <span class="font-medium text-base-content/80">{entry.name}</span>
- {#if isSimpleRule(entry.value)}
- <span class="ml-2 badge badge-xs {entry.value.action === 'allow' ? 'badge-success' : 'badge-error'}">{entry.value.action}</span>
- {:else if isPatternRule(entry.value)}
- {#each Object.entries(entry.value) as [pattern, rule]}
- <div class="pl-2 flex items-center gap-2">
- <span class="text-base-content/50 font-mono truncate max-w-32">{pattern}</span>
- {#if typeof rule === "object" && rule !== null && "action" in rule}
- <span class="badge badge-xs {(rule as { action: string }).action === 'allow' ? 'badge-success' : 'badge-error'}">{(rule as { action: string }).action}</span>
- {/if}
- </div>
- {/each}
- {:else}
- <span class="text-base-content/40 ml-2">{JSON.stringify(entry.value)}</span>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- {#if !loading && !error && !configData && !modelsData}
- <p class="text-base-content/40 italic">No configuration loaded.</p>
- {/if}
- </div>
-</details>
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 @@
-<script lang="ts">
-import { computeContextUsage } from "../context-window.js";
-import type { CacheStats } from "../types.js";
-
-const {
- cacheStats = null,
- contextLimit = null,
- tabTitle = null,
- modelId = null,
-}: {
- cacheStats?: CacheStats | null;
- contextLimit?: number | null;
- tabTitle?: string | null;
- modelId?: string | null;
-} = $props();
-
-const usage = $derived(computeContextUsage(cacheStats, contextLimit));
-
-// As the window fills, escalate color: calm → warning → danger.
-function fillClass(pct: number): string {
- if (pct >= 90) return "progress-error";
- if (pct >= 70) return "progress-warning";
- return "progress-success";
-}
-
-function fmt(n: number): string {
- return n.toLocaleString();
-}
-
-const hasUsage = $derived((cacheStats?.last ?? null) !== null);
-</script>
-
-<div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
- {#if !hasUsage}
- <p class="text-xs text-base-content/50">
- No context data yet. Send a message — the current context size appears
- here after the first response.
- </p>
- {:else}
- <div class="bg-base-200 rounded-lg p-2">
- <div class="flex items-center gap-1.5 mb-2">
- <span class="text-xs font-semibold">Context Window</span>
- {#if tabTitle}
- <span class="badge badge-xs badge-ghost">{tabTitle}</span>
- {/if}
- {#if usage.percent !== null}
- <span class="badge badge-xs ml-auto">{usage.percent.toFixed(2)}%</span>
- {/if}
- </div>
-
- <!-- Headline: current / max (or just current when max is unknown) -->
- <div class="flex items-baseline gap-1.5">
- <span class="text-lg font-mono font-semibold">{fmt(usage.current)}</span>
- {#if usage.max !== null}
- <span class="text-xs text-base-content/50 font-mono">/ {fmt(usage.max)}</span>
- {/if}
- <span class="text-xs text-base-content/40 ml-1">tokens</span>
- </div>
-
- {#if usage.percent !== null}
- <progress
- class="progress w-full h-2 mt-1.5 {fillClass(usage.percent)}"
- value={usage.percent}
- max="100"
- ></progress>
- {:else}
- <p class="text-xs text-base-content/40 mt-1.5">
- Max context size unknown for this model.
- </p>
- {/if}
-
- {#if modelId}
- <div class="text-xs text-base-content/40 mt-1.5 truncate" title={modelId}>
- {modelId}
- </div>
- {/if}
- </div>
-
- <p class="text-xs text-base-content/40">
- 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.
- </p>
- {/if}
-</div>
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 @@
-<script lang="ts">
-import { tabStore } from "../tabs.svelte.js";
-
-let copyLabel = $state("Copy conversation");
-
-function resetCopyLabel(): void {
- copyLabel = "Copy conversation";
-}
-
-async function handleCopy(): Promise<void> {
- const text = tabStore.copyConversation();
- try {
- await navigator.clipboard.writeText(text);
- copyLabel = "Copied";
- } catch {
- copyLabel = "Failed";
- }
- setTimeout(resetCopyLabel, 1500);
-}
-</script>
-
-<div class="flex flex-col gap-3">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Debug</div>
-
- <div class="flex flex-col gap-2">
- <p class="text-xs text-base-content/70">Conversation</p>
- <p class="text-xs text-base-content/40">
- Copy a structured plain-text dump of the active tab's conversation
- (chunk shape included) for bug reports.
- </p>
- <button type="button" class="btn btn-sm btn-primary w-full" onclick={handleCopy}>
- {copyLabel}
- </button>
- </div>
-</div>
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 @@
-<script lang="ts">
-import ListIcon from "phosphor-svelte/lib/ListIcon";
-import { router } from "../router.svelte.js";
-import { wsClient } from "../ws.svelte.js";
-
-const { onToggleSidebar }: { onToggleSidebar: () => void } = $props();
-</script>
-
-<header class="navbar bg-base-200 px-4 min-h-14 flex-shrink-0">
- <div class="navbar-start">
- <button class="text-xl font-bold tracking-tight btn btn-ghost px-0" onclick={() => router.navigate("dashboard")}>Dispatch</button>
- </div>
- <div class="navbar-end flex items-center gap-3">
- <span class="flex items-center gap-1.5 text-xs text-base-content/60">
- <span class="status status-sm {wsClient.connectionStatus === 'connected' ? 'status-success' : wsClient.connectionStatus === 'connecting' ? 'status-warning' : 'status-error'}"></span>
- <span class="capitalize">{wsClient.connectionStatus}</span>
- </span>
- <button
- type="button"
- class="btn btn-square btn-sm btn-neutral"
- onclick={onToggleSidebar}
- aria-label="Toggle sidebar"
- >
- <ListIcon size={20} weight="bold" aria-hidden="true" />
- </button>
- </div>
-</header>
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 @@
-<script lang="ts">
-const { active }: { active: boolean } = $props();
-
-let visible = $state(false);
-let timer: ReturnType<typeof setTimeout> | null = null;
-
-$effect(() => {
- if (active) {
- visible = true;
- if (timer !== null) clearTimeout(timer);
- timer = setTimeout(() => {
- visible = false;
- timer = null;
- }, 2000);
- }
- return () => {
- if (timer !== null) {
- clearTimeout(timer);
- timer = null;
- }
- visible = false;
- };
-});
-</script>
-
-{#if visible}
- <div
- class="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-info/20 text-info text-xs font-medium animate-pulse"
- role="status"
- aria-live="polite"
- >
- <span class="status status-xs status-info"></span>
- <span>Config reloaded</span>
- </div>
-{/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 @@
-<script lang="ts">
-import type { KeyInfo, KeyUsageData, UsageBucket } from "../types.js";
-
-const {
- keys = [],
- apiBase = "",
-}: {
- keys?: KeyInfo[];
- apiBase?: string;
-} = $props();
-
-interface KeyUsageEntry {
- keyId: string;
- provider: string;
- data: KeyUsageData | null;
- error: string | null;
- loading: boolean;
-}
-
-// In-memory cache for the current session (backend DB is the persistent cache)
-const usageCache = new Map<string, KeyUsageData>();
-
-function buildEntries(keyList: KeyInfo[]): KeyUsageEntry[] {
- return keyList.map((k) => {
- const cached = usageCache.get(k.id);
- return {
- keyId: k.id,
- provider: k.provider,
- data: cached ?? null,
- error: null,
- loading: true, // always show spinner during refresh
- };
- });
-}
-
-let entries = $state<KeyUsageEntry[]>([]);
-
-async function fetchOne(key: KeyInfo) {
- try {
- const res = await fetch(`${apiBase}/models/key-usage?keyId=${encodeURIComponent(key.id)}`);
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- updateEntry(key.id, {
- error: data.error ?? `HTTP ${res.status}`,
- loading: false,
- });
- } else {
- const fresh = (await res.json()) as KeyUsageData;
- usageCache.set(key.id, fresh);
- updateEntry(key.id, {
- data: fresh,
- error: null,
- loading: false,
- });
- }
- } catch (e) {
- updateEntry(key.id, {
- error: e instanceof Error ? e.message : "Failed to fetch",
- loading: false,
- });
- }
-}
-
-function updateEntry(
- keyId: string,
- patch: { data?: KeyUsageData | null; error?: string | null; loading?: boolean },
-) {
- entries = entries.map((e) => (e.keyId === keyId ? { ...e, ...patch } : e));
-}
-
-// Sync entries with keys reactively — runs before DOM update so
-// cached data renders on first paint without a flash of empty state.
-$effect.pre(() => {
- entries = buildEntries(keys);
-});
-
-// Fetch data and set up 90s auto-refresh
-$effect(() => {
- const currentKeys = keys;
- // Fire all fetches in parallel
- for (const key of currentKeys) {
- fetchOne(key);
- }
-
- // Refresh every 90s
- const interval = setInterval(() => {
- for (const key of currentKeys) {
- updateEntry(key.id, { loading: true });
- fetchOne(key);
- }
- }, 90_000);
-
- return () => clearInterval(interval);
-});
-
-// Merge duplicate Claude entries — all anthropic keys return the same
-// set of accounts, so collect and deduplicate under one "Claude" card.
-const claudeAccounts = $derived.by(() => {
- const seen = new Set<string>();
- const accounts: Array<{
- label: string;
- source: string;
- subscriptionType?: string;
- fiveHour?: UsageBucket;
- sevenDay?: UsageBucket;
- error?: string;
- }> = [];
- const claudeEntries = entries.filter((e) => e.provider === "anthropic");
- for (const e of claudeEntries) {
- if (!e.data || e.data.provider !== "anthropic" || !e.data.accounts) continue;
- for (const acct of e.data.accounts) {
- if (!seen.has(acct.source)) {
- seen.add(acct.source);
- accounts.push(acct);
- }
- }
- }
- return accounts;
-});
-
-const claudeLoading = $derived(entries.some((e) => e.provider === "anthropic" && e.loading));
-const claudeError = $derived(
- entries.find((e) => e.provider === "anthropic" && e.error)?.error ?? null,
-);
-
-const nonClaudeEntries = $derived(entries.filter((e) => e.provider !== "anthropic"));
-
-function progressClass(utilization: number): string {
- if (utilization > 0.8) return "progress-error";
- if (utilization >= 0.5) return "progress-warning";
- return "progress-success";
-}
-
-// Pace-aware coloring for cycle bars that show a "time dot" (elapsed % of the
-// reset window). Red once usage hits 90%, otherwise green when usage is at or
-// behind the dot and orange when it has run ahead of it. Falls back to the
-// plain threshold coloring when no dot is present (elapsedPct < 0).
-function pacedProgressClass(percentUsed: number, elapsedPct: number): string {
- if (percentUsed >= 90) return "progress-error";
- if (elapsedPct < 0) return progressClass(percentUsed / 100);
- if (percentUsed <= elapsedPct) return "progress-success";
- return "progress-warning";
-}
-
-function formatDate(ts: number): string {
- const diff = ts - Date.now();
- const days = Math.floor(diff / 86400000);
- const d = new Date(ts);
- const dateStr =
- d.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit" }) +
- " " +
- d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
-
- if (diff <= 0) {
- return d.toLocaleString();
- }
- if (diff < 48 * 60 * 60 * 1000) {
- const hours = Math.floor(diff / 3600000);
- const minutes = Math.floor((diff % 3600000) / 60000);
- return `in ${hours}:${String(minutes).padStart(2, "0")}`;
- }
- if (days <= 30) {
- const weeks = Math.floor(days / 7);
- const remDays = days % 7;
- if (weeks > 0 && remDays > 0) {
- return `in ${weeks}w ${remDays}d (${dateStr})`;
- }
- if (weeks > 0) {
- return `in ${weeks} week${weeks > 1 ? "s" : ""} (${dateStr})`;
- }
- return `in ${days} day${days > 1 ? "s" : ""} (${dateStr})`;
- }
- return d.toLocaleDateString("en-US", {
- month: "2-digit",
- day: "2-digit",
- year: "numeric",
- });
-}
-
-function formatKeyId(id: string): string {
- if (/^github-copilot/i.test(id)) return "Copilot";
- return id
- .split("-")
- .map((part) => {
- if (part.toLowerCase() === "opencode") return "OpenCode";
- return part.charAt(0).toUpperCase() + part.slice(1);
- })
- .join(" ");
-}
-
-// Cycle durations in ms
-const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
-const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
-const THIRTY_DAY_MS = 30 * 24 * 60 * 60 * 1000;
-
-function cycleElapsedPct(resetsAt: number | undefined, cycleDurationMs: number): number {
- if (!resetsAt) return -1;
- const timeRemaining = resetsAt - Date.now();
- const elapsed = cycleDurationMs - timeRemaining;
- return Math.max(0, Math.min(100, Math.round((elapsed / cycleDurationMs) * 100)));
-}
-
-function hasBucketData(bucket: UsageBucket | undefined): boolean {
- return bucket !== undefined && bucket.utilization !== undefined;
-}
-</script>
-
-<div class="flex flex-col gap-3 flex-1 min-h-0">
- {#if keys.length === 0}
- <p class="text-xs text-base-content/50">No keys available.</p>
- {:else}
- <div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
- <!-- Claude (all accounts merged under one card) -->
- {#if claudeAccounts.length > 0 || claudeLoading || claudeError}
- <div class="bg-base-200 rounded-lg p-2">
- <div class="flex items-center gap-1.5 mb-1.5">
- <span class="text-xs font-semibold">Claude</span>
- <span class="badge badge-xs badge-ghost">anthropic</span>
- {#if claudeLoading}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
-
- {#if claudeAccounts.length === 0 && claudeLoading}
- <div class="flex items-center gap-1.5 py-1">
- <span class="text-xs text-base-content/50">Loading...</span>
- </div>
- {:else if claudeAccounts.length === 0 && claudeError}
- <div role="alert" class="text-xs text-error/80">{claudeError}</div>
- {:else}
- {#if claudeError}
- <div role="alert" class="text-xs text-error/80 mb-1">{claudeError}</div>
- {/if}
- {#each claudeAccounts as acct, idx (acct.source)}
- {#if idx > 0}
- <div class="border-t border-base-300 my-1.5"></div>
- {/if}
- <div class="flex flex-col gap-1 pl-1">
- <div class="flex items-center gap-1">
- <span class="text-xs font-medium">{acct.label}</span>
- {#if acct.subscriptionType}
- <span class="badge badge-xs">{acct.subscriptionType}</span>
- {/if}
- </div>
- {#if acct.error}
- <p class="text-xs text-error/70">{acct.error}</p>
- {/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)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">5-Hour</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/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)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Weekly</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- </div>
- {/each}
- {/if}
- </div>
- {/if}
-
- <!-- Non-Claude keys -->
- {#each nonClaudeEntries as entry (entry.keyId)}
- <div class="bg-base-200 rounded-lg p-2">
- <div class="flex items-center gap-1.5 mb-1.5">
- <span class="text-xs font-semibold">{formatKeyId(entry.keyId)}</span>
- <span class="badge badge-xs badge-ghost">{entry.provider}</span>
- {#if entry.loading}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
-
- {#if entry.loading && !entry.data}
- <div class="flex items-center gap-1.5 py-1">
- <span class="loading loading-spinner loading-xs"></span>
- <span class="text-xs text-base-content/50">Loading...</span>
- </div>
- {:else}
- {#if entry.error}
- <div role="alert" class="text-xs text-error/80 mb-1">{entry.error}</div>
- {/if}
- {#if !entry.data}
- <p class="text-xs text-base-content/50">No data.</p>
-
- {:else if entry.data.provider === "opencode-go"}
- {#if entry.data.unavailable}
- <p class="text-xs text-base-content/70">Usage data not available. Set OPENCODE_COOKIE env var to enable.</p>
- {#if entry.data.limits}
- <div class="text-xs text-base-content/50 mt-1">
- Limits: {entry.data.limits.fiveHour}/5h &middot; {entry.data.limits.weekly}/wk &middot; {entry.data.limits.monthly}/mo
- </div>
- {/if}
- {#if entry.data.consoleUrl}
- <a href={entry.data.consoleUrl} target="_blank" rel="noopener noreferrer" class="link link-primary text-xs mt-1">
- View usage on opencode.ai
- </a>
- {/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)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">5-Hour</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/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)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Weekly</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/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)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Monthly</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {/if}
-
- {:else if entry.data.provider === "github-copilot"}
- {@const p = Math.round(entry.data.percentUsed ?? 0)}
- <div class="flex flex-col gap-0.5 pl-1">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">
- {#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}
- </span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <progress class="progress w-full h-2 {progressClass(p / 100)}" value={p} max="100"></progress>
- {#if entry.data.resetAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(entry.data.resetAt)}</span>
- {/if}
- </div>
- {:else if entry.data.provider === "google"}
- <div class="flex flex-col gap-0.5 pl-1">
- <!-- Cookie-scraped usage from gemini.google.com -->
- {#if entry.data.currentUsage}
- {@const u = entry.data.currentUsage}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Current</span>
- <span class="text-xs font-mono">{u.percentUsed}%</span>
- </div>
- <progress class="progress w-full h-2 {progressClass(u.percentUsed / 100)}" value={u.percentUsed} max="100"></progress>
- {#if u.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {u.resetsAt}</span>
- {/if}
- </div>
- {/if}
- {#if entry.data.weeklyUsage}
- {@const w = entry.data.weeklyUsage}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Weekly</span>
- <span class="text-xs font-mono">{w.percentUsed}%</span>
- </div>
- <progress class="progress w-full h-2 {progressClass(w.percentUsed / 100)}" value={w.percentUsed} max="100"></progress>
- {#if w.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {w.resetsAt}</span>
- {/if}
- </div>
- {/if}
- <!-- API key rate limits -->
- {#if !entry.data.currentUsage && entry.data.models && entry.data.models.length > 0}
- {@const m = entry.data.models[0]}
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Models</span>
- <span class="text-xs font-mono">{entry.data.models.length} available</span>
- </div>
- {#if m && m.rpm > 0}
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">RPM</span>
- <span class="text-xs font-mono">{m.rpm}</span>
- </div>
- {/if}
- {#if m && m.requestsPerDay > 0}
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">RPD</span>
- <span class="text-xs font-mono">{m.requestsPerDay.toLocaleString()}</span>
- </div>
- {/if}
- {#if !entry.data.currentUsage}
- <p class="text-xs text-base-content/40 mt-0.5">Set GEMINI_COOKIE (__Secure-1PSID) for usage %</p>
- {/if}
- {/if}
- </div>
- {/if}
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-</div>
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 @@
-<script lang="ts">
-import DOMPurify from "dompurify";
-import hljs from "highlight.js/lib/core";
-// Hot set — matches roughly what ChatGPT preloads. Registered eagerly so
-// common code blocks highlight on first paint without a network roundtrip.
-import bash from "highlight.js/lib/languages/bash";
-import c from "highlight.js/lib/languages/c";
-import cpp from "highlight.js/lib/languages/cpp";
-import csharp from "highlight.js/lib/languages/csharp";
-import css from "highlight.js/lib/languages/css";
-import go from "highlight.js/lib/languages/go";
-import java from "highlight.js/lib/languages/java";
-import javascript from "highlight.js/lib/languages/javascript";
-import json from "highlight.js/lib/languages/json";
-import markdown from "highlight.js/lib/languages/markdown";
-import php from "highlight.js/lib/languages/php";
-import plaintext from "highlight.js/lib/languages/plaintext";
-import python from "highlight.js/lib/languages/python";
-import ruby from "highlight.js/lib/languages/ruby";
-import rust from "highlight.js/lib/languages/rust";
-import shell from "highlight.js/lib/languages/shell";
-import sql from "highlight.js/lib/languages/sql";
-import typescript from "highlight.js/lib/languages/typescript";
-import xml from "highlight.js/lib/languages/xml";
-import yaml from "highlight.js/lib/languages/yaml";
-import { Marked } from "marked";
-import { markedHighlight } from "marked-highlight";
-
-hljs.registerLanguage("bash", bash);
-hljs.registerLanguage("c", c);
-hljs.registerLanguage("cpp", cpp);
-hljs.registerLanguage("csharp", csharp);
-hljs.registerLanguage("css", css);
-hljs.registerLanguage("go", go);
-hljs.registerLanguage("java", java);
-hljs.registerLanguage("javascript", javascript);
-hljs.registerLanguage("json", json);
-hljs.registerLanguage("markdown", markdown);
-hljs.registerLanguage("php", php);
-hljs.registerLanguage("plaintext", plaintext);
-hljs.registerLanguage("python", python);
-hljs.registerLanguage("ruby", ruby);
-hljs.registerLanguage("rust", rust);
-hljs.registerLanguage("shell", shell);
-hljs.registerLanguage("sql", sql);
-hljs.registerLanguage("typescript", typescript);
-hljs.registerLanguage("xml", xml);
-hljs.registerLanguage("yaml", yaml);
-
-// Normalize common aliases to their canonical highlight.js language names.
-// The canonical name is what we'll attempt to dynamically import.
-const ALIASES: Record<string, string> = {
- js: "javascript",
- jsx: "javascript",
- mjs: "javascript",
- cjs: "javascript",
- ts: "typescript",
- tsx: "typescript",
- py: "python",
- py3: "python",
- rb: "ruby",
- sh: "bash",
- shell: "bash",
- zsh: "bash",
- yml: "yaml",
- "c++": "cpp",
- cxx: "cpp",
- "c#": "csharp",
- cs: "csharp",
- htm: "xml",
- html: "xml",
- svg: "xml",
- md: "markdown",
- mdx: "markdown",
- golang: "go",
- rs: "rust",
- kt: "kotlin",
- ps1: "powershell",
-};
-
-function normalizeLang(lang: string): string {
- const lower = lang.toLowerCase().trim();
- return ALIASES[lower] ?? lower;
-}
-
-const loadCache = new Map<string, Promise<boolean>>();
-
-async function ensureLanguage(lang: string): Promise<boolean> {
- const name = normalizeLang(lang);
- if (hljs.getLanguage(name)) return true;
- if (loadCache.has(name)) return loadCache.get(name) ?? false;
-
- const promise = (async () => {
- try {
- // Dynamic import for languages not in the hot set above.
- // @vite-ignore: the variable `name` is intentionally dynamic;
- // missing modules are caught by the try/catch below.
- const mod = await import(/* @vite-ignore */ `highlight.js/lib/languages/${name}`);
- hljs.registerLanguage(name, mod.default);
- return true;
- } catch {
- return false;
- }
- })();
-
- loadCache.set(name, promise);
- return promise;
-}
-
-function escapeHtml(s: string): string {
- return s
- .replace(/&/g, "&amp;")
- .replace(/</g, "&lt;")
- .replace(/>/g, "&gt;")
- .replace(/"/g, "&quot;")
- .replace(/'/g, "&#39;");
-}
-
-const md = new Marked(
- markedHighlight({
- emptyLangClass: "hljs",
- langPrefix: "hljs language-",
- async: true,
- async highlight(code: string, lang: string): Promise<string> {
- if (!lang) return escapeHtml(code);
- const name = normalizeLang(lang);
- const loaded = await ensureLanguage(name);
- if (!loaded) return escapeHtml(code);
- try {
- return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
- } catch {
- return escapeHtml(code);
- }
- },
- }),
- {
- gfm: true,
- breaks: true,
- },
-);
-
-const { text = "", streaming = false }: { text?: string; streaming?: boolean } = $props();
-
-function closeOpenDelimiters(src: string): string {
- let out = src;
- const fenceCount = (out.match(/^```/gm) || []).length;
- if (fenceCount % 2 !== 0) out += "\n```";
- const boldCount = (out.match(/\*\*/g) || []).length;
- if (boldCount % 2 !== 0) out += "**";
- const inlineCode = (out.match(/(?<!`)`(?!`)/g) || []).length;
- if (inlineCode % 2 !== 0) out += "`";
- return out;
-}
-
-let html = $state("");
-let renderToken = 0;
-
-$effect(() => {
- const src = streaming ? closeOpenDelimiters(text) : text;
- const myToken = ++renderToken;
- (async () => {
- try {
- const raw = (await md.parse(src)) as string;
- if (myToken === renderToken) html = DOMPurify.sanitize(raw);
- } catch {
- // swallow — keeps last successful render visible
- }
- })();
-});
-</script>
-
-<div class="markdown-body">
- {@html html}
-</div>
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 @@
-<script module lang="ts">
-const modelCache = new Map<string, string[]>();
-</script>
-
-<script lang="ts">
- import {
- DEFAULT_REASONING_EFFORT,
- isReasoningEffort,
- REASONING_EFFORTS,
- REASONING_EFFORT_LABELS,
- } from "@dispatch/core/src/types/index.js";
- import type { KeyInfo } from "../types.js";
- import {
- cacheWarming,
- WARM_INTERVAL_MS,
- } from "../cache-warming.svelte.js";
- import { config } from "../config.js";
- import { router } from "../router.svelte.js";
- import { tabStore } from "../tabs.svelte.js";
-
- interface AgentInfo {
- name: string;
- slug: string;
- scope: string;
- description: string;
- skills: string[];
- tools: string[];
- models: Array<{ key_id: string; model_id: string; effort?: string }>;
- cwd?: string;
- is_subagent?: boolean;
- }
-
- // Moves an element to document.body so modals escape the sidebar's
- // transform stacking context and cover the full viewport.
- function portal(node: HTMLElement) {
- document.body.appendChild(node);
- return {
- destroy() {
- node.remove();
- },
- };
- }
-
- /**
- * Human-readable effort label for a (possibly-unset) per-model effort. When
- * the model has no explicit override, the badge reflects what will ACTUALLY
- * run: the per-tab selector if valid, else the system default. This mirrors
- * the backend resolution order (per-model → per-tab → default) so the UI
- * never misrepresents the effective effort.
- */
- function effortLabel(effort: string | undefined): string {
- if (isReasoningEffort(effort)) return REASONING_EFFORT_LABELS[effort];
- const tab = isReasoningEffort(reasoningEffort) ? reasoningEffort : DEFAULT_REASONING_EFFORT;
- return REASONING_EFFORT_LABELS[tab];
- }
-
- const {
- keys = [],
- activeTabId = null,
- activeKeyId = null,
- activeModelId = null,
- reasoningEffort = "max",
- activeAgentSlug = null,
- activeTabParentId = null as string | null,
- activeAgentModels = null as Array<{ key_id: string; model_id: string; effort?: string }> | null,
- workingDirectory = null,
- onKeyChange,
- onModelChange,
- onReasoningChange,
- onAgentChange = (_agent: AgentInfo | null) => {},
- onWorkingDirectoryChange = (_dir: string | null) => {},
- onCompact = () => {},
- canCompact = false,
- compacting = false,
- }: {
- keys?: KeyInfo[];
- activeTabId?: string | null;
- activeKeyId?: string | null;
- activeModelId?: string | null;
- reasoningEffort?: string;
- activeAgentSlug?: string | null;
- activeTabParentId?: string | null;
- activeAgentModels?: Array<{ key_id: string; model_id: string; effort?: string }> | null;
- workingDirectory?: string | null;
- onKeyChange: (keyId: string) => void;
- onModelChange: (keyId: string, modelId: string) => void;
- onReasoningChange: (effort: string) => void;
- onAgentChange?: (agent: AgentInfo | null) => void;
- onWorkingDirectoryChange?: (dir: string | null) => void;
- onCompact?: () => void;
- canCompact?: boolean;
- compacting?: boolean;
- } = $props();
-
- let showKeyModal = $state(false);
- let showModelModal = $state(false);
- let availableModels = $state<string[]>([]);
- let loadingModels = $state(false);
- let modelError = $state<string | null>(null);
- let sliderDragging = $state<number | null>(null);
- let modelSearch = $state("");
-
- // ─── Prompt-cache warming (debug strip lives at the bottom) ──────
- // Reactive per-tab warming state from the singleton store. `warm.now` is a
- // 1s ticking clock so the countdown re-renders while a fire is pending.
- const warm = $derived(cacheWarming.stateFor(activeTabId));
- const warmCountdown = $derived.by(() => {
- const next = warm.nextFireAt;
- if (next === null) return null;
- const ms = Math.max(0, next - cacheWarming.now);
- const total = Math.round(ms / 1000);
- const m = Math.floor(total / 60);
- const s = total % 60;
- return `${m}:${s.toString().padStart(2, "0")}`;
- });
- const warmIntervalLabel = `${Math.round(WARM_INTERVAL_MS / 60000)} min`;
-
- function toggleCacheWarming(enabled: boolean): void {
- if (!activeTabId) return;
- tabStore.setCacheWarmingEnabled(activeTabId, enabled);
- }
-
- let cwdExists = $state<boolean | null>(null);
- let cwdCheckTimer: ReturnType<typeof setTimeout> | null = null;
-
- $effect(() => {
- const cwd = workingDirectory;
- if (!cwd) {
- cwdExists = null;
- return;
- }
- cwdExists = null;
- if (cwdCheckTimer) clearTimeout(cwdCheckTimer);
- cwdCheckTimer = setTimeout(async () => {
- try {
- const res = await fetch(
- `${config.apiBase}/agents/check-dir?path=${encodeURIComponent(cwd)}`,
- );
- if (res.ok) {
- const data = await res.json();
- cwdExists = data.exists ?? false;
- }
- } catch {
- cwdExists = null;
- }
- }, 300);
- });
-
- let modeOverride = $state<"manual" | "agent" | null>(null);
- let mode = $derived(
- activeTabParentId && activeAgentSlug
- ? "subagent"
- : modeOverride ?? (activeAgentSlug ? "agent" : "manual"),
- );
- let agents = $state<AgentInfo[]>([]);
- let visibleAgents = $derived(agents.filter((a) => !a.is_subagent));
- let loadingAgents = $state(false);
-
- $effect(() => {
- fetchAgents();
- });
-
- async function fetchAgents() {
- loadingAgents = true;
- try {
- const res = await fetch(`${config.apiBase}/agents`);
- if (res.ok) {
- const data = await res.json();
- agents = data.agents ?? [];
- }
- } catch {
- /* ignore */
- } finally {
- loadingAgents = false;
- }
- }
-
- function selectKey(keyId: string) {
- showKeyModal = false;
- onKeyChange(keyId);
- // Immediately open model selection for the new key
- openModelModal(keyId);
- }
-
- async function openModelModal(keyIdOverride?: string) {
- const keyId = keyIdOverride ?? activeKeyId;
- if (!keyId) return;
- showModelModal = true;
- modelError = null;
- modelSearch = "";
-
- // Check session cache
- if (modelCache.has(keyId)) {
- availableModels = modelCache.get(keyId)!;
- loadingModels = false;
- return;
- }
-
- loadingModels = true;
- availableModels = [];
-
- try {
- const res = await fetch(
- `${config.apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`,
- );
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- modelError = data.error ?? `Failed to fetch models (HTTP ${res.status})`;
- return;
- }
- const data = await res.json();
- availableModels = data.models ?? [];
- // Cache for session
- modelCache.set(keyId, availableModels);
- } catch (err) {
- modelError = err instanceof Error ? err.message : "Failed to fetch models";
- } finally {
- loadingModels = false;
- }
- }
-
- function selectModel(model: string) {
- showModelModal = false;
- if (activeKeyId) {
- onModelChange(activeKeyId, model);
- }
- }
-</script>
-
-<div class="bg-base-200 rounded-lg p-3">
- <!-- Working Directory -->
- <div class="form-control mb-3">
- <label class="label py-0" for="cwd-input">
- <span class="label-text text-xs font-semibold">Working Directory</span>
- </label>
- <div class="flex items-center gap-1.5 mt-1">
- <input
- id="cwd-input"
- type="text"
- class="input input-bordered input-sm font-mono text-xs flex-1"
- placeholder="default (project root)"
- value={workingDirectory ?? ""}
- onchange={(e) => {
- const val = e.currentTarget.value.trim();
- onWorkingDirectoryChange(val || null);
- }}
- />
- {#if workingDirectory}
- {#if cwdExists === true}
- <span class="text-success text-sm" title="Directory exists">&#x2714;</span>
- {:else if cwdExists === false}
- <span class="text-warning text-sm" title="Will be created">&#x2716;</span>
- {:else}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- {/if}
- </div>
- </div>
-
- <!-- Compact conversation -->
- <div class="mb-3">
- <button
- type="button"
- class="btn btn-sm btn-outline w-full"
- disabled={!canCompact || compacting}
- onclick={onCompact}
- title="Summarize older turns into a compact anchor, preserving the most recent turns. Opens a new tab while it works; the conversation continues here once done."
- >
- {#if compacting}
- <span class="loading loading-spinner loading-xs"></span>
- Compacting…
- {:else}
- Compact conversation
- {/if}
- </button>
- </div>
-
- <!-- Toggle -->
- <div class="flex items-center gap-2 mb-3">
- <button
- class="btn btn-xs {mode === 'manual' ? 'btn-primary' : 'btn-ghost'}"
- onclick={() => { modeOverride = "manual"; onAgentChange(null); }}
- disabled={mode === 'subagent'}
- >
- Manual
- </button>
- <button
- class="btn btn-xs {mode === 'agent' ? 'btn-primary' : 'btn-ghost'}"
- onclick={async () => {
- modeOverride = "agent";
- await fetchAgents();
- // Re-apply the active agent's settings (including cwd)
- const current = visibleAgents.find(a => a.slug === activeAgentSlug);
- const agentToApply = current ?? visibleAgents[0] ?? null;
- if (agentToApply) {
- onAgentChange(agentToApply);
- // Force-update the input since the prop may not change (already set)
- const cwdEl = document.getElementById("cwd-input") as HTMLInputElement | null;
- if (cwdEl) cwdEl.value = agentToApply.cwd ?? "";
- }
- }}
- disabled={mode === 'subagent'}
- >
- Agent
- </button>
- <button
- class="btn btn-xs {mode === 'subagent' ? 'btn-primary' : 'btn-ghost'}"
- disabled={true}
- >
- SubAgent
- </button>
- </div>
-
- {#if mode === "manual"}
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">Key</span>
- <button class="btn btn-sm btn-outline" onclick={() => (showKeyModal = true)}>
- {activeKeyId ?? "Select Key"}
- </button>
- </div>
-
- <div class="flex items-center justify-between mt-2">
- <span class="text-sm font-medium">Model</span>
- <button class="btn btn-sm btn-outline" onclick={() => openModelModal()} disabled={!activeKeyId}>
- {activeModelId ?? "Select Model"}
- </button>
- </div>
-
- {#if activeModelId}
- <div class="flex items-center justify-between mt-2">
- <span class="text-sm font-medium">Thinking</span>
- <select
- class="select select-bordered select-sm"
- value={reasoningEffort}
- onchange={(e) => onReasoningChange(e.currentTarget.value)}
- >
- {#each REASONING_EFFORTS as effort}
- <option value={effort}>{REASONING_EFFORT_LABELS[effort]}</option>
- {/each}
- </select>
- </div>
- {/if}
- {:else if mode === "subagent"}
- <!-- SubAgent read-only info -->
- {@const subModels = activeAgentModels ?? []}
- {@const hasSubModels = subModels.length > 1}
- {@const subActiveIdx = subModels.findIndex(
- (m) => m.key_id === activeKeyId && m.model_id === activeModelId,
- )}
- <div class="flex flex-col gap-2">
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">SubAgent</span>
- <span class="badge badge-sm">{activeAgentSlug}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">Key</span>
- <span class="font-mono text-xs">{activeKeyId ?? "default"}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">Model</span>
- <span class="font-mono text-xs">{activeModelId ?? "default"}</span>
- </div>
- {#if hasSubModels}
- {@const displayIdx = subActiveIdx >= 0 ? subActiveIdx : 0}
- <div class="mt-1 pt-2 border-t border-base-content/20">
- <div class="text-xs font-semibold mb-1">Model fallback chain</div>
- <input
- type="range"
- min="0"
- max={subModels.length - 1}
- value={displayIdx}
- class="range range-xs"
- step="1"
- disabled
- />
- <div class="flex w-full justify-between px-0.5 text-xs opacity-50 mt-0.5">
- {#each subModels as _, i}
- <span>{i + 1}</span>
- {/each}
- </div>
- <div class="mt-1 flex flex-col gap-0.5">
- {#each subModels as m, i}
- <div class="text-xs font-mono truncate flex items-center gap-1 {i === displayIdx ? 'opacity-100 font-semibold' : 'opacity-50'}">
- <span class="truncate">{i + 1}. {m.key_id} / {m.model_id}</span>
- <span class="badge badge-xs badge-ghost shrink-0">{effortLabel(m.effort)}</span>
- </div>
- {/each}
- </div>
- </div>
- {/if}
- <p class="text-xs text-base-content/50 mt-1">This tab was spawned by a parent agent. Settings cannot be changed.</p>
- </div>
- {:else}
- <!-- Agent selection UI -->
- {#if loadingAgents}
- <div class="flex items-center gap-2 py-2 text-base-content/60">
- <span class="loading loading-spinner loading-xs"></span>
- Loading agents...
- </div>
- {:else if visibleAgents.length === 0}
- <p class="text-base-content/50 text-sm py-2">No agents configured.</p>
- {:else}
- <div class="flex flex-col gap-1.5">
- {#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}
- <div
- role="button"
- tabindex="0"
- class="w-full text-left rounded-lg px-3 py-2 transition-colors {isActive ? 'bg-primary text-primary-content' : 'bg-base-300 hover:bg-base-200'}"
- onclick={() => {
- // 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 ?? "";
- }
- }}
- >
- <div class="flex items-center justify-between gap-2">
- <span class="font-medium text-sm">{agent.name}</span>
- <div class="flex gap-1 shrink-0">
- <span class="badge badge-xs">{agent.models.length} model{agent.models.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-xs badge-outline">{agent.scope === "global" ? "global" : "project"}</span>
- </div>
- </div>
- {#if agent.description}
- <p class="text-xs opacity-60 mt-0.5">{agent.description}</p>
- {/if}
- {#if isActive && hasMultipleModels}
- {@const displayIdx = sliderDragging !== null ? sliderDragging : (currentIdx >= 0 ? currentIdx : 0)}
- {@const displayModel = agent.models[displayIdx]}
- <div class="mt-2 pt-2 border-t border-primary-content/20">
- <div class="text-xs font-semibold mb-1 truncate flex items-center gap-1">
- <span class="truncate">{displayModel ? `${displayModel.key_id} / ${displayModel.model_id}` : `${activeKeyId} / ${activeModelId}`}</span>
- <span class="badge badge-xs shrink-0">{effortLabel(displayModel?.effort)}</span>
- </div>
- <input
- type="range"
- min="0"
- max={agent.models.length - 1}
- value={currentIdx >= 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()}
- />
- <div class="flex w-full justify-between px-0.5 text-xs opacity-50 mt-0.5">
- {#each agent.models as _, i}
- <span>{i + 1}</span>
- {/each}
- </div>
- </div>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- <button
- type="button"
- class="btn btn-outline btn-sm w-full mt-2 hover:bg-base-300 hover:border-base-300 text-base-content/60"
- onclick={() => router.navigate("agent-builder")}
- >
- Agent Settings
- </button>
- {/if}
-
- <!-- Prompt-cache warming (bottom of the Chat Settings panel) -->
- <div class="mt-3 pt-3 border-t border-base-300">
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- checked={warm.enabled}
- disabled={!activeTabId}
- onchange={(e) => toggleCacheWarming(e.currentTarget.checked)}
- />
- <span class="text-xs font-semibold">Keep prompt cache warm</span>
- </label>
- <p class="text-[10px] text-base-content/40 mt-1 leading-snug">
- 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.
- </p>
-
- {#if warm.enabled}
- <div class="mt-2 flex flex-col gap-2 bg-base-300/40 rounded-lg p-2">
- <!-- Warming "last request" cache rate (separate from the real metric) -->
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Last request (warming)</span>
- <span class="text-xs font-mono">{warm.lastPct === null ? "-%" : `${warm.lastPct}%`}</span>
- </div>
- <progress
- class="progress w-full h-2 {warm.lastPct === null
- ? ''
- : warm.lastPct >= 70
- ? 'progress-success'
- : warm.lastPct >= 30
- ? 'progress-warning'
- : 'progress-error'}"
- value={warm.lastPct ?? 0}
- max="100"
- ></progress>
- </div>
-
- <!-- Countdown to the next warming fire -->
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Next warm in</span>
- <span class="text-xs font-mono">
- {#if warm.firing}
- warming…
- {:else if warmCountdown !== null}
- {warmCountdown}
- {:else}
- —
- {/if}
- </span>
- </div>
-
- {#if warm.error}
- <div class="text-[10px] text-error break-words">
- {warm.error}
- </div>
- {/if}
- </div>
- {/if}
- </div>
-</div>
-
-{#if showKeyModal}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-xl">Select Key</h3>
- <div class="mt-4 flex flex-col gap-2">
- {#each keys as key}
- <button
- class="btn {key.id === activeKeyId
- ? 'btn-primary'
- : 'btn-ghost'} justify-start text-base"
- onclick={() => selectKey(key.id)}
- >
- <span class="font-mono">{key.id}</span>
- <span class="badge ml-auto">{key.provider}</span>
- <span
- class="badge {key.status === 'active'
- ? 'badge-success'
- : 'badge-error'}">{key.status}</span
- >
- </button>
- {/each}
- </div>
- <div class="modal-action">
- <button class="btn" onclick={() => (showKeyModal = false)}>Cancel</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={() => (showKeyModal = false)} aria-label="Close modal"></button>
- </div>
-{/if}
-
-{#if showModelModal}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-xl">Select Model</h3>
- {#if loadingModels}
- <div class="flex justify-center py-8">
- <span class="loading loading-spinner loading-lg"></span>
- </div>
- {:else if modelError}
- <div class="alert alert-error mt-4 text-base">
- <span>{modelError}</span>
- </div>
- {:else}
- {@const search = modelSearch.toLowerCase().trim()}
- {@const searchRegex = search
- ? new RegExp(
- search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/ /g, "[ _-]"),
- )
- : null}
- {@const filteredModels = searchRegex
- ? availableModels.filter((m) => searchRegex.test(m.toLowerCase()))
- : availableModels}
- <div class="mt-4 flex flex-col gap-1">
- <input
- type="text"
- class="input input-bordered input-sm w-full"
- placeholder="Filter models..."
- bind:value={modelSearch}
- />
- <div class="mt-2 max-h-96 overflow-y-auto flex flex-col gap-1">
- {#each filteredModels as model}
- <button
- class="btn {model === activeModelId
- ? 'btn-primary'
- : 'btn-ghost'} justify-start font-mono text-base"
- onclick={() => selectModel(model)}
- >
- {model}
- </button>
- {/each}
- {#if filteredModels.length === 0}
- <p class="text-xs text-base-content/50 py-2 text-center">
- {search ? 'No models match your search.' : 'No models available.'}
- </p>
- {/if}
- </div>
- </div>
- {/if}
- <div class="modal-action">
- <button class="btn" onclick={() => (showModelModal = false)}>Cancel</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={() => (showModelModal = false)} aria-label="Close modal"></button>
- </div>
-{/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 @@
-<script lang="ts">
-interface KeyInfo {
- id: string;
- provider: string;
- status: "active" | "exhausted";
- lastError: string | null;
- exhaustedAt: number | null;
-}
-
-interface CredentialStatus {
- keyId: string;
- provider: string;
- subscriptionType: string | null;
- importedAt: number;
- updatedAt: number;
- expired: boolean;
-}
-
-const {
- keys = [],
- currentModel,
- apiBase = "",
- onAddKey = () => {},
-}: {
- keys?: KeyInfo[];
- currentModel?: string;
- apiBase?: string;
- onAddKey?: () => void;
-} = $props();
-
-const activeKeys = $derived(keys.filter((k) => k.status === "active").length);
-const totalKeys = $derived(keys.length);
-const allActive = $derived(totalKeys > 0 && activeKeys === totalKeys);
-const allExhausted = $derived(totalKeys > 0 && activeKeys === 0);
-const someExhausted = $derived(totalKeys > 0 && activeKeys < totalKeys && activeKeys > 0);
-
-let credentialStatus = $state<Record<string, CredentialStatus>>({});
-let importingKey = $state<string | null>(null);
-let importError = $state<string | null>(null);
-let importSuccess = $state<string | null>(null);
-
-let apiKeyStatus = $state<
- Record<string, { keyId: string; provider: string; importedAt: number; updatedAt: number }>
->({});
-let showKeyModal = $state<string | null>(null); // keyId or null
-let keyModalValue = $state("");
-let keyModalError = $state<string | null>(null);
-let keyModalSaving = $state(false);
-let removingKey = $state<string | null>(null);
-
-async function loadCredentialStatus(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/credentials-status`);
- if (!res.ok) return;
- const data = (await res.json()) as { credentials: CredentialStatus[] };
- const map: Record<string, CredentialStatus> = {};
- for (const cred of data.credentials) {
- map[cred.keyId] = cred;
- }
- credentialStatus = map;
- } catch {
- // ignore
- }
-}
-
-async function importCredentials(keyId: string): Promise<void> {
- importingKey = keyId;
- importError = null;
- importSuccess = null;
- try {
- const res = await fetch(`${apiBase}/models/import-credentials`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId }),
- });
- const data = (await res.json()) as { success?: boolean; error?: string };
- if (!res.ok || !data.success) {
- importError = data.error ?? "Import failed";
- } else {
- importSuccess = keyId;
- await loadCredentialStatus();
- setTimeout(() => {
- importSuccess = null;
- }, 3000);
- }
- } catch (e) {
- importError = e instanceof Error ? e.message : "Network error";
- } finally {
- importingKey = null;
- }
-}
-
-async function loadApiKeyStatus(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/api-keys-status`);
- if (!res.ok) return;
- const data = (await res.json()) as {
- keys: Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }>;
- };
- const map: Record<string, (typeof data.keys)[0]> = {};
- for (const k of data.keys) {
- map[k.keyId] = k;
- }
- apiKeyStatus = map;
- } catch {
- // ignore
- }
-}
-
-async function saveApiKey(): Promise<void> {
- if (!showKeyModal || !keyModalValue.trim()) return;
- keyModalSaving = true;
- keyModalError = null;
- try {
- const res = await fetch(`${apiBase}/models/set-api-key`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: showKeyModal, apiKey: keyModalValue.trim() }),
- });
- const data = (await res.json()) as { success?: boolean; error?: string };
- if (!res.ok || !data.success) {
- keyModalError = data.error ?? "Failed to save";
- } else {
- showKeyModal = null;
- keyModalValue = "";
- await loadApiKeyStatus();
- }
- } catch (e) {
- keyModalError = e instanceof Error ? e.message : "Network error";
- } finally {
- keyModalSaving = false;
- }
-}
-
-async function removeKey(keyId: string): Promise<void> {
- if (!confirm(`Remove key "${keyId}" from config?`)) return;
- removingKey = keyId;
- try {
- const res = await fetch(`${apiBase}/models/remove-key`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ id: keyId }),
- });
- const data = (await res.json()) as { success?: boolean; error?: string };
- if (res.ok && data.success) {
- await new Promise((r) => setTimeout(r, 500));
- window.location.reload();
- }
- } catch {
- // ignore
- } finally {
- removingKey = null;
- }
-}
-
-$effect(() => {
- void loadCredentialStatus();
- void loadApiKeyStatus();
-});
-
-function timeAgo(ts: number | null): string {
- if (ts === null) return "";
- const diffMs = Date.now() - ts;
- const diffSec = Math.floor(diffMs / 1000);
- if (diffSec < 60) return `${diffSec}s ago`;
- const diffMin = Math.floor(diffSec / 60);
- if (diffMin < 60) return `${diffMin}m ago`;
- const diffHr = Math.floor(diffMin / 60);
- return `${diffHr}h ago`;
-}
-
-function truncate(str: string | null, max: number): string {
- if (!str) return "";
- return str.length > max ? `${str.slice(0, max)}...` : str;
-}
-</script>
-
-<div class="flex flex-col gap-3">
- {#if keys.length === 0}
- <p class="text-xs text-base-content/50">
- No models configured. Using environment defaults.
- </p>
- {:else}
- <!-- Overall status -->
- {#if allActive}
- <div class="flex items-center gap-1.5">
- <span class="badge badge-success badge-xs">●</span>
- <span class="text-xs text-success">All keys available</span>
- </div>
- {:else if allExhausted}
- <div class="flex items-center gap-1.5">
- <span class="badge badge-error badge-xs">●</span>
- <span class="text-xs text-error">All keys exhausted — waiting for refresh</span>
- </div>
- {:else if someExhausted}
- <div class="flex items-center gap-1.5">
- <span class="badge badge-warning badge-xs">●</span>
- <span class="text-xs text-warning">
- Fallback active ({activeKeys}/{totalKeys} keys available)
- </span>
- </div>
- {/if}
-
- <!-- Current model -->
- {#if currentModel}
- <div class="flex flex-col gap-0.5">
- <p class="text-xs text-base-content/50 uppercase tracking-wide">Current Model</p>
- <p class="text-sm font-mono font-semibold text-primary">{currentModel}</p>
- </div>
- {/if}
-
- <!-- Import error/success banners -->
- {#if importError}
- <div role="alert" class="text-xs text-error/80">{importError}</div>
- {/if}
- {#if importSuccess}
- <div class="text-xs text-success/80">Imported credentials for {importSuccess}</div>
- {/if}
-
- <!-- Keys -->
- {#if keys.length > 0}
- <div class="flex flex-col gap-1">
- <p class="text-xs text-base-content/50 uppercase tracking-wide">API Keys</p>
- <ul class="flex flex-col gap-1">
- {#each keys as key (key.id)}
- {@const cred = credentialStatus[key.id]}
- <li class="flex flex-col gap-0.5 rounded p-1 hover:bg-base-200 transition-colors">
- <div class="flex items-center gap-1.5">
- <span
- class="badge badge-xs {key.status === 'active'
- ? 'badge-success'
- : 'badge-error'}"
- >
- {key.status}
- </span>
- <span class="text-xs font-mono flex-1">{key.id}</span>
- <span class="text-xs text-base-content/40">{key.provider}</span>
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-square text-base-content/30 hover:text-error"
- disabled={removingKey === key.id}
- onclick={() => removeKey(key.id)}
- title="Remove key"
- >
- {#if removingKey === key.id}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- ✕
- {/if}
- </button>
- </div>
- {#if key.status === "exhausted"}
- <div class="pl-2 flex flex-col gap-0.5">
- {#if key.lastError}
- <p class="text-xs text-error/70 line-clamp-1">
- {truncate(key.lastError, 80)}
- </p>
- {/if}
- {#if key.exhaustedAt !== null}
- <p class="text-xs text-base-content/40">{timeAgo(key.exhaustedAt)}</p>
- {/if}
- </div>
- {/if}
- <!-- Credential import for anthropic keys -->
- {#if key.provider === "anthropic"}
- <div class="pl-2 flex items-center gap-1.5 mt-0.5">
- {#if cred}
- <span class="badge badge-xs {cred.expired ? 'badge-warning' : 'badge-info'}">
- {cred.expired ? "expired" : "imported"}
- </span>
- {#if cred.subscriptionType}
- <span class="text-xs text-base-content/40">{cred.subscriptionType}</span>
- {/if}
- {/if}
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-outline"
- disabled={importingKey === key.id}
- onclick={() => importCredentials(key.id)}
- >
- {#if importingKey === key.id}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- {cred ? "Re-import" : "Import Credentials"}
- {/if}
- </button>
- </div>
- {/if}
- <!-- API key import for env-based keys (opencode, copilot, etc) -->
- {#if key.provider !== "anthropic"}
- <div class="pl-2 flex items-center gap-1.5 mt-0.5">
- {#if apiKeyStatus[key.id]}
- <span class="badge badge-xs badge-info">imported</span>
- {/if}
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-outline"
- onclick={() => { showKeyModal = key.id; keyModalValue = ""; keyModalError = null; }}
- >
- {apiKeyStatus[key.id] ? "Update Key" : "Import Key"}
- </button>
- </div>
- {/if}
- </li>
- {/each}
- </ul>
- </div>
- {/if}
- {/if}
- <button
- type="button"
- class="btn btn-sm btn-primary btn-outline w-full mt-2"
- onclick={onAddKey}
- >
- + Add New Key
- </button>
-<!-- Import Key Modal -->
-{#if showKeyModal}
- <div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" role="dialog">
- <div class="bg-base-100 rounded-lg p-4 w-80 flex flex-col gap-3 shadow-xl">
- <h3 class="text-sm font-semibold">Import Key: {showKeyModal}</h3>
- <input
- type="password"
- class="input input-bordered input-sm w-full"
- placeholder="Paste API key..."
- bind:value={keyModalValue}
- />
- {#if keyModalError}
- <p class="text-xs text-error">{keyModalError}</p>
- {/if}
- <div class="flex justify-end gap-2">
- <button
- type="button"
- class="btn btn-sm btn-ghost"
- onclick={() => { showKeyModal = null; keyModalValue = ""; keyModalError = null; }}
- >
- Cancel
- </button>
- <button
- type="button"
- class="btn btn-sm btn-primary"
- disabled={!keyModalValue.trim() || keyModalSaving}
- onclick={saveApiKey}
- >
- {#if keyModalSaving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Save
- {/if}
- </button>
- </div>
- </div>
- </div>
-{/if}
-
-</div>
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 @@
-<script lang="ts">
-import type { PermissionPrompt } from "../types.js";
-
-const {
- pending,
- onReply,
-}: {
- pending: PermissionPrompt[];
- onReply: (id: string, reply: "once" | "always" | "reject") => void;
-} = $props();
-
-let current = $derived(pending[0]);
-
-let showAlwaysConfirmation = $state(false);
-
-let dialogEl: HTMLDialogElement | undefined = $state();
-
-$effect(() => {
- if (!dialogEl) return;
- if (current) {
- if (!dialogEl.open) dialogEl.showModal();
- } else {
- if (dialogEl.open) dialogEl.close();
- }
-});
-
-function handleAlways() {
- showAlwaysConfirmation = true;
-}
-
-function confirmAlways() {
- if (current) onReply(current.id, "always");
- showAlwaysConfirmation = false;
-}
-
-function handleOnce() {
- if (current) onReply(current.id, "once");
-}
-
-function handleReject() {
- showAlwaysConfirmation = false;
- if (current) onReply(current.id, "reject");
-}
-</script>
-
-<dialog class="modal" bind:this={dialogEl} oncancel={handleReject}>
- {#if current}
- {#if !showAlwaysConfirmation}
- <div class="modal-box">
- <h3 class="text-lg font-bold">
- {#if current.permission === "bash"}
- Run command
- {:else if current.permission === "external_directory"}
- Access external directory
- {:else if current.permission === "read"}
- Read file
- {:else if current.permission === "edit"}
- Edit file
- {:else}
- Permission required
- {/if}
- </h3>
-
- <p class="py-2 text-sm opacity-70">{current.description}</p>
-
- {#if current.permission === "bash" && current.metadata.command}
- <div class="mockup-code my-2">
- <pre><code>$ {current.metadata.command as string}</code></pre>
- </div>
- {/if}
-
- {#if current.metadata.filepath}
- <p class="text-sm font-mono">{current.metadata.filepath as string}</p>
- {/if}
-
- <div class="modal-action gap-2">
- <button class="btn btn-sm btn-ghost" aria-label="Deny permission" onclick={handleReject}>Deny</button>
- <button class="btn btn-sm" aria-label="Allow once" onclick={handleOnce}>Allow once</button>
- <button class="btn btn-sm btn-primary" aria-label="Always allow" onclick={handleAlways}>Always allow</button>
- </div>
- </div>
- {:else}
- <div class="modal-box">
- <h3 class="text-lg font-bold">Always allow?</h3>
- <p class="py-2 text-sm">
- The following patterns will be permanently allowed until you restart Dispatch:
- </p>
- <div class="mockup-code my-2">
- {#each current.always as pattern}
- <pre><code>{pattern}</code></pre>
- {/each}
- </div>
- <div class="modal-action gap-2">
- <button class="btn btn-sm btn-ghost" aria-label="Go back" onclick={() => showAlwaysConfirmation = false}>Back</button>
- <button class="btn btn-sm btn-primary" aria-label="Confirm always allow" onclick={confirmAlways}>Confirm</button>
- </div>
- </div>
- {/if}
- {/if}
-</dialog>
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 @@
-<script lang="ts">
-import { config } from "../config.js";
-import { appSettings } from "../settings.svelte.js";
-import { applyTheme, loadStoredTheme, THEMES, type Theme } from "../theme.js";
-import type { KeyInfo } from "../types.js";
-
-const {
- keys = [],
- apiBase = "",
-}: {
- keys?: KeyInfo[];
- apiBase?: string;
-} = $props();
-
-// Theme picker — was a header-triggered modal (`ThemeSwitcher.svelte`);
-// inlined here so theme picking lives in Settings alongside other UI
-// preferences. Theme constants and apply/persist live in `../theme.ts`
-// so the boot-time apply in `App.svelte` and this picker can't drift.
-let currentTheme = $state<Theme>(loadStoredTheme());
-
-function selectTheme(theme: Theme): void {
- currentTheme = theme;
- applyTheme(theme);
-}
-
-let titleKeyId = $state<string | null>(null);
-let titleModelId = $state<string | null>(null);
-let availableModels = $state<string[]>([]);
-let compactionKeyId = $state<string | null>(null);
-let compactionModelId = $state<string | null>(null);
-let compactionModels = $state<string[]>([]);
-let loadingCompactionModels = $state(false);
-let loadingModels = $state(false);
-let autoExpandThinking = $state(appSettings.autoExpandThinking);
-let localChunkLimit = $state(appSettings.chunkLimit);
-let backendUrl = $state(config.apiBase);
-let backendUrlSaved = $state(false);
-
-// ─── ntfy.sh push notifications ──────────────────────────────────
-// Server-side schema mirror — kept inline rather than imported to avoid
-// pulling a node-only barrel into the browser bundle (frontend already
-// hand-mirrors a few core types in lib/types.ts for the same reason).
-type NotificationEventType =
- | "turn-completed"
- | "turn-error"
- | "permission-required"
- | "agent-spawned";
-
-interface NtfyConfigView {
- enabled: boolean;
- topic: string;
- authToken: string;
- hasAuthToken?: boolean;
- events: Record<NotificationEventType, boolean>;
- notifySubagents: boolean;
-}
-
-const NTFY_EVENT_LABELS: Record<NotificationEventType, string> = {
- "turn-completed": "Turn completed",
- "turn-error": "Turn error",
- "permission-required": "Permission requested",
- "agent-spawned": "User agent spawned",
-};
-
-const DEFAULT_NTFY: NtfyConfigView = {
- enabled: false,
- topic: "",
- authToken: "",
- hasAuthToken: false,
- events: {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
- },
- notifySubagents: false,
-};
-
-let ntfy = $state<NtfyConfigView>({ ...DEFAULT_NTFY, events: { ...DEFAULT_NTFY.events } });
-let ntfyAuthTokenInput = $state(""); // empty == leave unchanged on save
-let ntfyEventOrder = $state<NotificationEventType[]>([
- "turn-completed",
- "turn-error",
- "permission-required",
- "agent-spawned",
-]);
-let ntfySaving = $state(false);
-let ntfySaveError = $state<string | null>(null);
-let ntfySaveOk = $state(false);
-let ntfyTesting = $state(false);
-let ntfyTestResult = $state<string | null>(null);
-let ntfyTestOk = $state(false);
-let ntfyClearingToken = $state(false);
-
-function onChunkLimitChange(e: Event): void {
- const input = e.target as HTMLInputElement;
- const val = parseInt(input.value, 10);
- if (val >= 10 && val <= 2000) {
- appSettings.chunkLimit = val;
- localChunkLimit = val;
- }
-}
-
-function saveBackendUrl(): void {
- const trimmed = backendUrl.trim().replace(/\/+$/, "");
- if (!trimmed) return;
- config.setApiBase(trimmed);
- backendUrl = trimmed;
- backendUrlSaved = true;
- setTimeout(() => {
- backendUrlSaved = false;
- }, 2000);
-}
-
-function resetBackendUrl(): void {
- config.setApiBase(config.defaultApiBase);
- backendUrl = config.defaultApiBase;
- backendUrlSaved = true;
- setTimeout(() => {
- backendUrlSaved = false;
- }, 2000);
-}
-
-async function loadSettings(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/tabs/settings/title-model`);
- if (res.ok) {
- const data = (await res.json()) as { keyId: string | null; modelId: string | null };
- titleKeyId = data.keyId;
- if (titleKeyId) {
- await loadModelsForKey(titleKeyId);
- }
- titleModelId = data.modelId;
- }
- } catch {
- // ignore
- }
- try {
- const res = await fetch(`${apiBase}/tabs/settings/compaction-model`);
- if (res.ok) {
- const data = (await res.json()) as { keyId: string | null; modelId: string | null };
- compactionKeyId = data.keyId;
- if (compactionKeyId) {
- await loadCompactionModelsForKey(compactionKeyId);
- }
- compactionModelId = data.modelId;
- }
- } catch {
- // ignore
- }
- try {
- const res = await fetch(`${apiBase}/tabs/settings/auto-expand-thinking`);
- if (res.ok) {
- const data = (await res.json()) as { value: string | null };
- autoExpandThinking = data.value === "true";
- appSettings.autoExpandThinking = autoExpandThinking;
- }
- } catch {
- // ignore
- }
- await loadNtfy();
-}
-
-async function loadNtfy(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/notifications`);
- if (!res.ok) return;
- const data = (await res.json()) as {
- config: NtfyConfigView;
- eventTypes?: NotificationEventType[];
- };
- ntfy = {
- ...DEFAULT_NTFY,
- ...data.config,
- events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) },
- };
- if (Array.isArray(data.eventTypes) && data.eventTypes.length > 0) {
- ntfyEventOrder = data.eventTypes;
- }
- } catch {
- // ignore
- }
-}
-
-async function saveNtfy(): Promise<void> {
- ntfySaving = true;
- ntfySaveError = null;
- ntfySaveOk = false;
- try {
- // `authToken: undefined` ⇒ server keeps the existing token.
- // `authToken: ""` ⇒ explicit clear (the user typed and cleared).
- const payload: Partial<NtfyConfigView> & { authToken?: string } = {
- enabled: ntfy.enabled,
- topic: ntfy.topic,
- events: ntfy.events,
- notifySubagents: ntfy.notifySubagents,
- };
- if (ntfyAuthTokenInput !== "") payload.authToken = ntfyAuthTokenInput;
- const res = await fetch(`${apiBase}/notifications`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
- const data = (await res.json()) as { config?: NtfyConfigView; error?: string };
- if (!res.ok) {
- ntfySaveError = data.error ?? `Save failed (HTTP ${res.status})`;
- return;
- }
- if (data.config) {
- ntfy = {
- ...DEFAULT_NTFY,
- ...data.config,
- events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) },
- };
- }
- ntfyAuthTokenInput = "";
- ntfySaveOk = true;
- setTimeout(() => {
- ntfySaveOk = false;
- }, 2000);
- } catch (e) {
- ntfySaveError = e instanceof Error ? e.message : "Network error";
- } finally {
- ntfySaving = false;
- }
-}
-
-async function sendNtfyTest(): Promise<void> {
- ntfyTesting = true;
- ntfyTestResult = null;
- ntfyTestOk = false;
- try {
- const res = await fetch(`${apiBase}/notifications/test`, { method: "POST" });
- const data = (await res.json()) as { ok?: boolean; error?: string; status?: number };
- if (!res.ok || !data.ok) {
- ntfyTestResult = data.error ?? `Test failed (HTTP ${res.status})`;
- return;
- }
- ntfyTestOk = true;
- ntfyTestResult = "Sent — check your ntfy client.";
- } catch (e) {
- ntfyTestResult = e instanceof Error ? e.message : "Network error";
- } finally {
- ntfyTesting = false;
- }
-}
-
-async function clearNtfyAuthToken(): Promise<void> {
- // `""` ⇒ explicit clear on save (vs. `undefined` which keeps existing).
- // Optimistic local state on failure caused a real bug pre-review: UI showed
- // the token cleared while the server still held it, then "Save" treated the
- // blank input as "keep existing" and silently re-armed the old token. Await
- // the response and only flip local state on success.
- ntfyClearingToken = true;
- ntfySaveError = null;
- try {
- const res = await fetch(`${apiBase}/notifications`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ authToken: "" }),
- });
- const data = (await res.json().catch(() => ({}))) as {
- config?: NtfyConfigView;
- error?: string;
- };
- if (!res.ok) {
- ntfySaveError = data.error ?? `Clear failed (HTTP ${res.status})`;
- return;
- }
- ntfyAuthTokenInput = "";
- if (data.config) {
- ntfy = {
- ...DEFAULT_NTFY,
- ...data.config,
- events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) },
- };
- } else {
- ntfy = { ...ntfy, hasAuthToken: false };
- }
- } catch (e) {
- ntfySaveError = e instanceof Error ? e.message : "Network error";
- } finally {
- ntfyClearingToken = false;
- }
-}
-
-async function toggleAutoExpand(): Promise<void> {
- autoExpandThinking = !autoExpandThinking;
- appSettings.autoExpandThinking = autoExpandThinking;
- fetch(`${apiBase}/tabs/settings/auto-expand-thinking`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ value: String(autoExpandThinking) }),
- }).catch(() => {});
-}
-
-async function loadModelsForKey(keyId: string): Promise<void> {
- loadingModels = true;
- try {
- const res = await fetch(`${apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`);
- if (!res.ok) {
- availableModels = [];
- return;
- }
- const data = (await res.json()) as { models: string[] };
- availableModels = data.models ?? [];
- } catch {
- availableModels = [];
- } finally {
- loadingModels = false;
- }
-}
-
-function saveTitleModel(): void {
- fetch(`${apiBase}/tabs/settings/title-model`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: titleKeyId, modelId: titleModelId }),
- }).catch(() => {});
-}
-
-async function onKeyChange(e: Event): Promise<void> {
- const select = e.target as HTMLSelectElement;
- titleKeyId = select.value || null;
- titleModelId = null;
- availableModels = [];
- if (titleKeyId) {
- await loadModelsForKey(titleKeyId);
- }
- saveTitleModel();
-}
-
-async function onModelChange(e: Event): Promise<void> {
- const select = e.target as HTMLSelectElement;
- titleModelId = select.value || null;
- saveTitleModel();
-}
-
-async function loadCompactionModelsForKey(keyId: string): Promise<void> {
- loadingCompactionModels = true;
- try {
- const res = await fetch(`${apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`);
- if (!res.ok) {
- compactionModels = [];
- return;
- }
- const data = (await res.json()) as { models: string[] };
- compactionModels = data.models ?? [];
- } catch {
- compactionModels = [];
- } finally {
- loadingCompactionModels = false;
- }
-}
-
-function saveCompactionModel(): void {
- fetch(`${apiBase}/tabs/settings/compaction-model`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: compactionKeyId, modelId: compactionModelId }),
- }).catch(() => {});
-}
-
-async function onCompactionKeyChange(e: Event): Promise<void> {
- const select = e.target as HTMLSelectElement;
- compactionKeyId = select.value || null;
- compactionModelId = null;
- compactionModels = [];
- if (compactionKeyId) {
- await loadCompactionModelsForKey(compactionKeyId);
- }
- saveCompactionModel();
-}
-
-function onCompactionModelChange(e: Event): void {
- const select = e.target as HTMLSelectElement;
- compactionModelId = select.value || null;
- saveCompactionModel();
-}
-
-$effect(() => {
- void loadSettings();
-});
-</script>
-
-<div class="flex flex-col gap-3">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Settings</div>
-
- <div class="flex flex-col gap-2">
- <p class="text-xs text-base-content/70">Theme</p>
- <label class="text-xs text-base-content/60">
- Appearance
- <select
- class="select select-bordered select-sm w-full capitalize"
- value={currentTheme}
- onchange={(e) => selectTheme(e.currentTarget.value as Theme)}
- >
- {#each THEMES as theme (theme)}
- <option value={theme} class="capitalize">{theme}</option>
- {/each}
- </select>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Title Generation Model</p>
- <p class="text-xs text-base-content/40">Used to generate short titles for new tabs after the first message.</p>
-
- <label class="text-xs text-base-content/60">
- Key
- <select class="select select-bordered select-sm w-full" onchange={onKeyChange} value={titleKeyId ?? ""}>
- <option value="">Select a key...</option>
- {#each keys as key (key.id)}
- <option value={key.id}>{key.id} ({key.provider})</option>
- {/each}
- </select>
- </label>
-
- <label class="text-xs text-base-content/60">
- Model
- <select
- class="select select-bordered select-sm w-full"
- onchange={onModelChange}
- value={titleModelId ?? ""}
- disabled={!titleKeyId || loadingModels}
- >
- <option value="">{loadingModels ? "Loading models..." : "Select a model..."}</option>
- {#each availableModels as model (model)}
- <option value={model}>{model}</option>
- {/each}
- </select>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Conversation Compaction Model</p>
- <p class="text-xs text-base-content/40">Used to summarize a conversation when you compact it. If unset, the tab's own key/model is used.</p>
-
- <label class="text-xs text-base-content/60">
- Key
- <select class="select select-bordered select-sm w-full" onchange={onCompactionKeyChange} value={compactionKeyId ?? ""}>
- <option value="">Select a key...</option>
- {#each keys as key (key.id)}
- <option value={key.id}>{key.id} ({key.provider})</option>
- {/each}
- </select>
- </label>
-
- <label class="text-xs text-base-content/60">
- Model
- <select
- class="select select-bordered select-sm w-full"
- onchange={onCompactionModelChange}
- value={compactionModelId ?? ""}
- disabled={!compactionKeyId || loadingCompactionModels}
- >
- <option value="">{loadingCompactionModels ? "Loading models..." : "Select a model..."}</option>
- {#each compactionModels as model (model)}
- <option value={model}>{model}</option>
- {/each}
- </select>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Chat</p>
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- checked={autoExpandThinking}
- onchange={toggleAutoExpand}
- />
- <span class="text-xs text-base-content/70">Auto-expand thinking</span>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Memory</p>
- <label class="flex flex-col gap-1">
- <span class="text-xs text-base-content/70">
- Max chunks in memory: <span class="font-semibold">{localChunkLimit}</span>
- </span>
- <input
- type="range"
- min="20"
- max="1000"
- step="10"
- class="range range-xs"
- value={localChunkLimit}
- oninput={onChunkLimitChange}
- />
- <span class="text-[10px] text-base-content/40">Lower = less RAM. Higher = less re-fetching.</span>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Backend URL</p>
- <p class="text-xs text-base-content/40">API server address. Default: {config.defaultApiBase}</p>
- <div class="flex gap-1">
- <input
- type="text"
- class="input input-bordered input-sm flex-1"
- bind:value={backendUrl}
- placeholder={config.defaultApiBase}
- />
- <button type="button" class="btn btn-sm btn-primary" onclick={saveBackendUrl}>
- Save
- </button>
- </div>
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-outline w-full"
- disabled={config.apiBase === config.defaultApiBase}
- onclick={resetBackendUrl}
- >
- Reset to default
- </button>
- {#if backendUrlSaved}
- <p class="text-xs text-success">Saved. Reload the page to apply.</p>
- {/if}
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Notifications (ntfy.sh)</p>
- <p class="text-xs text-base-content/40">
- Push notifications to your phone when things happen here. Subscribe to your topic in the
- <a href="https://ntfy.sh/" target="_blank" rel="noopener" class="link">ntfy.sh</a> app to receive them.
- </p>
-
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- bind:checked={ntfy.enabled}
- />
- <span class="text-xs text-base-content/70">Enable notifications</span>
- </label>
-
- <label class="text-xs text-base-content/60 flex flex-col gap-1">
- Topic
- <input
- type="text"
- class="input input-bordered input-sm w-full"
- placeholder="your-secret-topic"
- bind:value={ntfy.topic}
- />
- <span class="text-[10px] text-base-content/40">
- Any string — pick something unguessable, since anyone with the topic name can read your notifications. Subscribe to the same topic in the ntfy app.
- </span>
- </label>
-
- <label class="text-xs text-base-content/60 flex flex-col gap-1">
- Auth token (optional, for private ntfy servers)
- <input
- type="password"
- class="input input-bordered input-sm w-full"
- placeholder={ntfy.hasAuthToken ? "•••• (stored — type to replace)" : "Leave blank for public ntfy.sh"}
- bind:value={ntfyAuthTokenInput}
- autocomplete="off"
- />
- {#if ntfy.hasAuthToken}
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-outline self-start"
- disabled={ntfyClearingToken}
- onclick={clearNtfyAuthToken}
- >
- {#if ntfyClearingToken}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Clear stored token
- {/if}
- </button>
- {/if}
- </label>
-
- <div class="flex flex-col gap-1 mt-1">
- <span class="text-xs text-base-content/60">Notify me on:</span>
- {#each ntfyEventOrder as evType (evType)}
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- bind:checked={ntfy.events[evType]}
- />
- <span class="text-xs text-base-content/70">{NTFY_EVENT_LABELS[evType] ?? evType}</span>
- </label>
- {/each}
- </div>
-
- <div class="flex flex-col gap-1 mt-1">
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- bind:checked={ntfy.notifySubagents}
- />
- <span class="text-xs text-base-content/70">Include subagent tabs</span>
- </label>
- <span class="text-[10px] text-base-content/40 pl-6">
- Off (default): turn-completed/turn-error from subagents are suppressed. Permission prompts still fire so subagents don't silently hang.
- </span>
- </div>
-
- <div class="flex gap-1 mt-1">
- <button
- type="button"
- class="btn btn-sm btn-primary flex-1"
- disabled={ntfySaving}
- onclick={saveNtfy}
- >
- {#if ntfySaving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Save
- {/if}
- </button>
- <button
- type="button"
- class="btn btn-sm btn-outline"
- disabled={ntfyTesting || !ntfy.enabled}
- onclick={sendNtfyTest}
- title={ntfy.enabled ? "Send a test notification with current settings" : "Enable notifications first"}
- >
- {#if ntfyTesting}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Send test
- {/if}
- </button>
- </div>
- {#if ntfySaveOk}
- <p class="text-xs text-success">Saved.</p>
- {/if}
- {#if ntfySaveError}
- <p class="text-xs text-error">{ntfySaveError}</p>
- {/if}
- {#if ntfyTestResult}
- <p class="text-xs {ntfyTestOk ? 'text-success' : 'text-error'}">{ntfyTestResult}</p>
- {/if}
- </div>
-</div>
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 @@
-<script lang="ts">
-import { loadSidebarPanels, saveSidebarPanels } from "../sidebar-storage.js";
-import type { CacheStats, KeyInfo, LogEntry, TaskItem } from "../types.js";
-import CacheRatePanel from "./CacheRatePanel.svelte";
-import ClaudeReset from "./ClaudeReset.svelte";
-import ConfigPanel from "./ConfigPanel.svelte";
-import ContextWindowPanel from "./ContextWindowPanel.svelte";
-import DebugPanel from "./DebugPanel.svelte";
-import KeyUsage from "./KeyUsage.svelte";
-import ModelSelector from "./ModelSelector.svelte";
-import ModelStatus from "./ModelStatus.svelte";
-import SettingsPanel from "./SettingsPanel.svelte";
-import SkillsBrowser from "./SkillsBrowser.svelte";
-import TaskListPanel from "./TaskListPanel.svelte";
-import ToolPermissions from "./ToolPermissions.svelte";
-
-interface AgentInfo {
- slug: string;
- scope: string;
- skills: string[];
- tools: string[];
- models: Array<{ key_id: string; model_id: string }>;
- cwd?: string;
-}
-
-const {
- keys = [],
- tasks = [],
- cacheStats = null,
- cacheTabTitle = null,
- contextLimit = null,
- permissionLog = [],
- apiBase = "",
- activeTabId = null as string | null,
- activeKeyId = null,
- activeModelId = null,
- reasoningEffort = "max",
- activeAgentSlug = null as string | null,
- activeTabParentId = null as string | null,
- activeAgentModels = null as Array<{ key_id: string; model_id: string; effort?: string }> | null,
- workingDirectory = null as string | null,
- onKeyChange,
- onModelChange,
- onReasoningChange,
- onAgentChange = (_agent: AgentInfo | null) => {},
- onWorkingDirectoryChange = (_dir: string | null) => {},
- onCompact = () => {},
- canCompact = false,
- compacting = false,
- onAddKey = () => {},
-}: {
- keys?: KeyInfo[];
- tasks?: TaskItem[];
- cacheStats?: CacheStats | null;
- cacheTabTitle?: string | null;
- contextLimit?: number | null;
- permissionLog?: LogEntry[];
- apiBase?: string;
- activeTabId?: string | null;
- activeKeyId?: string | null;
- activeModelId?: string | null;
- reasoningEffort?: string;
- activeAgentSlug?: string | null;
- activeTabParentId?: string | null;
- activeAgentModels?: Array<{ key_id: string; model_id: string; effort?: string }> | null;
- workingDirectory?: string | null;
- onKeyChange: (keyId: string) => void;
- onModelChange: (keyId: string, modelId: string) => void;
- onReasoningChange: (effort: string) => void;
- onAgentChange?: (agent: AgentInfo | null) => void;
- onWorkingDirectoryChange?: (dir: string | null) => void;
- onCompact?: () => void;
- canCompact?: boolean;
- compacting?: boolean;
- onAddKey?: () => void;
-} = $props();
-
-interface Panel {
- id: number;
- selected: string;
-}
-
-// The `id` field is purely a stable key for Svelte's `{#each ... (panel.id)}`
-// block within a single session — it is NEVER persisted. Only the ordered
-// list of `selected` strings is round-tripped through localStorage; ids are
-// regenerated fresh from `nextId` on every mount.
-let nextId = 0;
-let panels = $state<Panel[]>(loadSidebarPanels().map((selected) => ({ id: nextId++, selected })));
-
-// Persist the layout whenever it changes. `$effect` re-runs whenever any
-// reactive read inside it changes; we read `panels` (the whole array) via
-// `.map`, which Svelte 5 tracks. Save errors are swallowed inside
-// `saveSidebarPanels` — best-effort.
-$effect(() => {
- saveSidebarPanels(panels.map((p) => p.selected));
-});
-
-const viewOptions = [
- "Select a view",
- "Chat Settings",
- "Key Usage",
- "Cache Rate",
- "Context Window",
- "Claude Reset",
- "Model Status",
- "Tasks",
- "Config",
- "Skills",
- "Tools",
- "Settings",
- "Debug",
-];
-
-function addPanel() {
- panels = [...panels, { id: nextId++, selected: "Select a view" }];
-}
-
-// Every panel sizes to its content; the sidebar itself (in App.svelte) is the
-// scroll container. We deliberately do NOT use `flex-1` fill here: a filled
-// panel combined with `min-h-0` lets flex shrink the panel below its content's
-// natural height, and since the content wrapper is a plain block the inner
-// scroll regions never receive a bounded height — so their bars/lists spill
-// out of the panel into neighbours or past the window edge.
-function panelClass(_selected: string): string {
- return "bg-base-200 rounded-lg p-3 flex flex-col";
-}
-
-function contentClass(_selected: string): string {
- return "mt-2";
-}
-</script>
-
-<div class="flex flex-col gap-2 min-h-0">
- {#each panels as panel, idx (panel.id)}
- <div class={panelClass(panel.selected)}>
- <div class="flex items-center gap-1">
- <select
- class="select select-bordered select-sm flex-1"
- value={panel.selected}
- onchange={(e) => {
- panels = panels.map((p) =>
- p.id === panel.id ? { ...p, selected: e.currentTarget.value } : p,
- );
- }}
- >
- {#each viewOptions as option}
- <option value={option} disabled={option === "Select a view"}>{option}</option>
- {/each}
- </select>
- {#if idx > 0}
- <button
- type="button"
- class="btn btn-sm btn-ghost btn-square shrink-0"
- aria-label="Remove panel"
- onclick={() => {
- panels = panels.filter((p) => p.id !== panel.id);
- }}
- >
- ✕
- </button>
- {/if}
- </div>
-
- <div class={contentClass(panel.selected)}>
- {#if panel.selected === "Chat Settings"}
- <ModelSelector
- {keys}
- {activeTabId}
- {activeKeyId}
- {activeModelId}
- {reasoningEffort}
- {onKeyChange}
- {onModelChange}
- {onReasoningChange}
- {activeAgentSlug}
- {activeTabParentId}
- {activeAgentModels}
- {onAgentChange}
- {workingDirectory}
- {onWorkingDirectoryChange}
- {onCompact}
- {canCompact}
- {compacting}
- />
- {:else if panel.selected === "Key Usage"}
- <KeyUsage {keys} {apiBase} />
- {:else if panel.selected === "Cache Rate"}
- <CacheRatePanel {cacheStats} tabTitle={cacheTabTitle} />
- {:else if panel.selected === "Context Window"}
- <ContextWindowPanel
- {cacheStats}
- {contextLimit}
- tabTitle={cacheTabTitle}
- modelId={activeModelId}
- />
- {:else if panel.selected === "Claude Reset"}
- <ClaudeReset {apiBase} />
- {:else if panel.selected === "Model Status"}
- <ModelStatus {keys} {apiBase} {onAddKey} />
- {:else if panel.selected === "Tasks"}
- <TaskListPanel {tasks} />
- {:else if panel.selected === "Config"}
- <ConfigPanel {apiBase} />
- {:else if panel.selected === "Skills"}
- <SkillsBrowser {apiBase} />
- {:else if panel.selected === "Tools"}
- <ToolPermissions entries={permissionLog} {apiBase} />
- {:else if panel.selected === "Settings"}
- <SettingsPanel {keys} {apiBase} />
- {:else if panel.selected === "Debug"}
- <DebugPanel />
- {/if}
- </div>
- </div>
- {/each}
-
- <button type="button" class="btn bg-base-200 hover:bg-base-300 border-none w-full text-lg" onclick={addPanel}>
- +
- </button>
-</div>
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 @@
-<script lang="ts">
-import { appSettings } from "../settings.svelte.js";
-import { tabStore } from "../tabs.svelte.js";
-
-interface Skill {
- name: string;
- description: string;
- tags: string[];
- scope: "global" | "project";
- directory: string;
-}
-
-interface SkillsResponse {
- skills: Skill[];
- mappings: unknown[];
-}
-
-interface SkillDetail extends Skill {
- content: string;
- source: string;
-}
-
-interface DirGroup {
- path: string;
- label: string;
- scope: "global" | "project";
- skills: Skill[];
-}
-
-const {
- apiBase,
- checkedSkills = null,
- onSkillToggle = null,
-}: {
- apiBase: string;
- /** External checked set (agent builder mode). When null, uses appSettings. */
- checkedSkills?: Set<string> | null;
- /** Callback when a skill is toggled in external mode. */
- onSkillToggle?: ((key: string, checked: boolean) => void) | null;
-} = $props();
-
-/** Whether we're in external (agent builder) mode */
-const externalMode = $derived(checkedSkills !== null && onSkillToggle !== null);
-
-let skills = $state<Skill[]>([]);
-let loading = $state(false);
-let error = $state<string | null>(null);
-let expandedSkill = $state<string | null>(null);
-let expandedDetail = $state<SkillDetail | null>(null);
-let loadingDetail = $state(false);
-let collapsedDirs = $state<Set<string>>(new Set());
-
-async function fetchSkills() {
- loading = true;
- error = null;
- try {
- const res = await fetch(`${apiBase}/skills`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data: SkillsResponse = await res.json();
- skills = data.skills ?? [];
- } catch (e) {
- error = e instanceof Error ? e.message : "Failed to fetch skills";
- } finally {
- loading = false;
- }
-}
-
-function skillKey(skill: Skill): string {
- return `${skill.scope}:${skill.name}`;
-}
-
-/** Build a unique key for a directory group (scope + path) */
-function dirKey(group: DirGroup): string {
- return `${group.scope}:${group.path}`;
-}
-
-function isChecked(skill: Skill): boolean {
- const key = skillKey(skill);
- if (externalMode) {
- return checkedSkills?.has(key) ?? false;
- }
- return appSettings.skillChecks[key] === true;
-}
-
-function isInjected(skill: Skill): boolean {
- if (externalMode) return false;
- return tabStore.activeTab?.injectedSkills.includes(skillKey(skill)) ?? false;
-}
-
-function toggleCheck(skill: Skill): void {
- const key = skillKey(skill);
- if (externalMode) {
- onSkillToggle?.(key, !checkedSkills?.has(key));
- return;
- }
- appSettings.skillChecks = { ...appSettings.skillChecks, [key]: !isChecked(skill) };
-}
-
-function resetChecks(): void {
- if (externalMode) return;
- appSettings.skillChecks = {};
-}
-
-function toggleDir(key: string): void {
- const next = new Set(collapsedDirs);
- if (next.has(key)) next.delete(key);
- else next.add(key);
- collapsedDirs = next;
-}
-
-/** Check if a group is hidden because an ancestor directory is collapsed */
-function isHiddenByParent(group: DirGroup): boolean {
- if (!group.path.includes("/")) return false;
- // Check each ancestor path segment
- const parts = group.path.split("/");
- for (let i = 1; i < parts.length; i++) {
- const ancestorPath = parts.slice(0, i).join("/");
- const ancestorKey = `${group.scope}:${ancestorPath}`;
- if (collapsedDirs.has(ancestorKey)) return true;
- }
- return false;
-}
-
-/** Get the top-level directory for grouping spacing */
-function topLevelDir(group: DirGroup): string {
- const slash = group.path.indexOf("/");
- return slash === -1 ? group.path : group.path.slice(0, slash);
-}
-
-async function toggleExpand(skill: Skill) {
- const key = skillKey(skill);
- if (expandedSkill === key) {
- expandedSkill = null;
- expandedDetail = null;
- return;
- }
- expandedSkill = key;
- expandedDetail = null;
- loadingDetail = true;
- try {
- const res = await fetch(
- `${apiBase}/skills/${encodeURIComponent(skill.name)}?scope=${skill.scope}`,
- );
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- expandedDetail = await res.json();
- } catch {
- expandedDetail = null;
- } finally {
- loadingDetail = false;
- }
-}
-
-$effect(() => {
- fetchSkills();
-});
-
-const checkedCount = $derived(
- externalMode
- ? (checkedSkills?.size ?? 0)
- : Object.values(appSettings.skillChecks).filter((v) => v).length,
-);
-
-/** Group skills by scope + directory, sorted */
-const dirGroups = $derived.by((): DirGroup[] => {
- const map = new Map<string, DirGroup>();
- for (const skill of skills) {
- const key = `${skill.scope}:${skill.directory}`;
- let group = map.get(key);
- if (!group) {
- const label = skill.directory || "(root)";
- group = { path: skill.directory, label, scope: skill.scope, skills: [] };
- map.set(key, group);
- }
- group.skills.push(skill);
- }
- // Sort: global before project, then alphabetically by path
- const groups = Array.from(map.values());
- groups.sort((a, b) => {
- if (a.scope !== b.scope) return a.scope === "global" ? -1 : 1;
- return a.path.localeCompare(b.path);
- });
- // Sort skills within each group alphabetically
- for (const g of groups) {
- g.skills.sort((a, b) => a.name.localeCompare(b.name));
- }
- return groups;
-});
-</script>
-
-<div class="flex flex-col gap-3">
- <div class="flex items-center gap-2">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Skills</div>
- {#if !loading}
- <span class="badge badge-sm badge-neutral">{skills.length}</span>
- {/if}
- {#if checkedCount > 0}
- <span class="badge badge-sm badge-primary">{checkedCount} {externalMode ? 'selected' : 'queued'}</span>
- {/if}
- <button
- class="btn btn-xs btn-ghost ml-auto"
- onclick={fetchSkills}
- title="Refresh skills"
- >
- Refresh
- </button>
- </div>
-
- {#if !externalMode}
- <p class="text-xs text-base-content/40">Check skills to inject with your next message.</p>
- {/if}
-
- {#if loading}
- <div class="flex items-center gap-2 py-2 text-base-content/60">
- <span class="loading loading-spinner loading-xs"></span>
- Loading skills...
- </div>
- {:else if error}
- <div class="alert alert-error text-xs py-2">{error}</div>
- {:else if skills.length === 0}
- <p class="text-base-content/50 italic py-2">
- No skills found. Create <code class="font-mono">.skills/</code> directories to get started.
- </p>
- {:else}
- <div class="flex flex-col">
- {#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}
- <div class="divider my-1"></div>
- {/if}
- <div class="{isNewTopLevel ? '' : 'mt-0.5'}">
- <div class="rounded border border-base-content/20">
- <!-- Directory header -->
- <button
- type="button"
- class="flex items-center gap-1.5 w-full px-2 py-1.5 text-left hover:bg-base-200 transition-colors rounded-t"
- onclick={() => toggleDir(dirKey(group))}
- >
- <span class="text-xs text-base-content/40 w-3 inline-block transition-transform {collapsed ? '-rotate-90' : ''}">▼</span>
- <span class="font-mono text-xs font-semibold text-base-content/70">{group.label}</span>
- <span class="badge badge-xs {group.scope === 'global' ? 'badge-info' : 'badge-warning'}">{group.scope}</span>
- <span class="badge badge-xs badge-neutral ml-auto">{group.skills.length}</span>
- </button>
-
- <!-- Skills in this directory -->
- {#if !collapsed}
- <div class="flex flex-col gap-0.5 px-1 pb-1">
- {#each group.skills as skill (skillKey(skill))}
- {@const key = skillKey(skill)}
- {@const checked = isChecked(skill)}
- {@const injected = isInjected(skill)}
- <div
- class="rounded p-1.5 transition-colors {injected ? 'bg-primary/10 border border-primary/20' : 'hover:bg-base-200'}"
- >
- <label class="flex items-start gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm checkbox-primary rounded-sm mt-0.5"
- checked={checked}
- onchange={() => toggleCheck(skill)}
- />
- <div class="flex-1 min-w-0">
- <div class="flex items-center gap-1.5 flex-wrap">
- <button
- class="font-mono text-xs text-left hover:underline {injected ? 'text-primary font-semibold' : 'text-base-content'}"
- onclick={() => toggleExpand(skill)}
- >
- {skill.name}
- </button>
- {#if injected}
- <span class="badge badge-xs badge-primary">active</span>
- {/if}
- {#each skill.tags as tag}
- <span class="badge badge-xs badge-outline">{tag}</span>
- {/each}
- </div>
- {#if skill.description}
- <p class="text-xs text-base-content/50 truncate">{skill.description}</p>
- {/if}
- </div>
- </label>
-
- {#if expandedSkill === key}
- <div class="mt-2 ml-6 bg-base-300 rounded p-2">
- {#if loadingDetail}
- <span class="loading loading-spinner loading-xs text-base-content/40"></span>
- {:else if expandedDetail}
- <pre class="whitespace-pre-wrap font-mono text-xs overflow-x-auto max-h-60 overflow-y-auto">{expandedDetail.content}</pre>
- {:else}
- <p class="text-error text-xs">Failed to load skill content.</p>
- {/if}
- </div>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
- </div>
- </div>
- {/if}
- {/each}
- </div>
- {/if}
-
- {#if !externalMode}
- <button
- class="btn btn-sm btn-ghost w-full"
- disabled={!appSettings.skillChecksDirty}
- onclick={resetChecks}
- >
- Reset
- </button>
- {/if}
-</div>
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 @@
-<script lang="ts">
-import { onMount } from "svelte";
-import { appSettings } from "../settings.svelte.js";
-
-const {
- apiBase = "",
-}: {
- apiBase?: string;
-} = $props();
-
-const DEFAULT_PROMPT = "You are Dispatch, a helpful AI coding assistant. Be concise and helpful.";
-
-async function loadPrompt(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/tabs/settings/system_prompt`);
- if (res.ok) {
- const data = (await res.json()) as { value: string | null };
- const value = data.value ?? DEFAULT_PROMPT;
- appSettings.systemPrompt = value;
- appSettings.savedSystemPrompt = value;
- }
- } catch {
- // ignore
- }
-}
-
-function resetPrompt(): void {
- appSettings.systemPrompt = appSettings.savedSystemPrompt;
-}
-
-const isDirty = $derived(appSettings.systemPrompt !== appSettings.savedSystemPrompt);
-
-onMount(() => {
- if (!appSettings.systemPrompt) {
- appSettings.systemPrompt = DEFAULT_PROMPT;
- appSettings.savedSystemPrompt = DEFAULT_PROMPT;
- }
- loadPrompt();
-});
-</script>
-
-<div class="flex flex-col gap-3 flex-1 min-h-0">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">System Prompt</div>
- <p class="text-xs text-base-content/40">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.</p>
-
- <textarea
- class="textarea textarea-bordered w-full flex-1 min-h-32 text-xs font-mono leading-relaxed"
- bind:value={appSettings.systemPrompt}
- placeholder="Enter system prompt..."
- ></textarea>
-
- <button
- class="btn btn-sm btn-ghost w-full"
- disabled={!isDirty}
- onclick={resetPrompt}
- >
- Reset
- </button>
-
- <p class="text-xs text-base-content/40">Warning: changing the system prompt will reset the AI's prompt cache for active conversations, which may increase usage costs.</p>
-</div>
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 @@
-<script lang="ts">
-import { tick } from "svelte";
-import type { Tab } from "../tabs.svelte.js";
-import { tabStore } from "../tabs.svelte.js";
-
-function statusColor(status: string): string {
- if (status === "running") return "bg-warning";
- if (status === "error") return "bg-error";
- return "bg-success";
-}
-
-/**
- * A tab "needs attention" — and should ping to grab the user's eye — when the
- * agent has stopped and is likely waiting on the user:
- * (a) the turn ended (idle) but the task list still has incomplete tasks
- * (pending / in_progress) — the agent probably expects a response; or
- * (b) the turn stopped due to an error of any kind.
- */
-function needsAttention(tab: Tab): boolean {
- if (tab.agentStatus === "error") return true;
- if (tab.agentStatus === "idle") {
- return tab.tasks.some((t) => t.status === "pending" || t.status === "in_progress");
- }
- return false;
-}
-
-const userTabs = $derived(tabStore.tabs.filter((t) => t.parentTabId === null));
-const subagentTabs = $derived(
- tabStore.tabs.filter((t) => t.parentTabId !== null && t.parentTabId === activeUserTabId),
-);
-const hasSubagentTabs = $derived(subagentTabs.length > 0);
-
-// When a subagent tab is active, its parent user tab should still appear selected
-const activeTab = $derived(tabStore.tabs.find((t) => t.id === tabStore.activeTabId));
-const activeUserTabId = $derived(
- activeTab?.parentTabId !== null && activeTab?.parentTabId !== undefined
- ? activeTab.parentTabId
- : tabStore.activeTabId,
-);
-
-// ── Drag-and-drop reorder (user tabs only) ──
-// Mirrors the native HTML5 DnD pattern used in AgentBuilder.svelte.
-let dragIndex = $state<number | null>(null);
-let dragOverIndex = $state<number | null>(null);
-
-function dropReorder(targetIndex: number): void {
- if (dragIndex !== null && dragIndex !== targetIndex) {
- const ids = userTabs.map((t) => t.id);
- const moved = ids.splice(dragIndex, 1)[0];
- if (moved) {
- ids.splice(targetIndex, 0, moved);
- tabStore.reorderTabs(ids);
- }
- }
- dragIndex = null;
- dragOverIndex = null;
-}
-
-// ── Double-click rename (user tabs only) ──
-let editingTabId = $state<string | null>(null);
-let editValue = $state("");
-let editInputEl = $state<HTMLInputElement | undefined>(undefined);
-
-async function startRename(tab: { id: string; title: string }): Promise<void> {
- editingTabId = tab.id;
- editValue = tab.title;
- await tick();
- editInputEl?.focus();
- editInputEl?.select();
-}
-
-function commitRename(): void {
- if (editingTabId === null) return;
- const id = editingTabId;
- editingTabId = null;
- const next = editValue.trim();
- if (next) tabStore.renameTab(id, next);
-}
-
-function cancelRename(): void {
- editingTabId = null;
-}
-
-function handleRenameKeydown(e: KeyboardEvent): void {
- if (e.key === "Enter") {
- e.preventDefault();
- commitRename();
- } else if (e.key === "Escape") {
- e.preventDefault();
- cancelRename();
- }
-}
-</script>
-
-<!-- Top row: user tabs -->
-<!-- svelte-ignore a11y_no_static_element_interactions -->
-<div
- class="overflow-x-auto bg-base-200 flex-shrink-0 {hasSubagentTabs ? '' : 'rounded-br-lg'}"
- ondblclick={(e) => { if (e.target === e.currentTarget) tabStore.createNewTab(); }}
->
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- role="tablist"
- tabindex="0"
- class="tabs tabs-lift min-w-max"
- ondblclick={(e) => { if (e.target === e.currentTarget) tabStore.createNewTab(); }}
- >
- <!-- New tab button — sticky-pinned to the left edge so it stays reachable
- at any horizontal scroll; opaque bg + right-side shadow as a floating cue. -->
- <button
- type="button"
- class="tab tab-active !sticky left-0 z-10 !rounded-ss-none !border-l-0 shadow-[2px_0_4px_-1px_rgba(0,0,0,0.2)]"
- onclick={() => tabStore.createNewTab()}
- aria-label="New tab"
- >
- +
- </button>
-
- {#each userTabs as tab, i (tab.id)}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- role="tab"
- class="tab !flex items-stretch gap-1.5 {tab.id === activeUserTabId ? 'tab-active' : ''} {dragOverIndex === i ? 'bg-primary/10' : ''} {dragIndex === i ? 'opacity-50' : ''}"
- draggable={editingTabId === tab.id ? "false" : "true"}
- onclick={() => tabStore.switchTab(tab.id)}
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tabStore.switchTab(tab.id); }}
- ondragstart={(e) => {
- 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(); dropReorder(i); }}
- ondragend={() => { dragIndex = null; dragOverIndex = null; }}
- tabindex="0"
- >
- <span class="flex items-center gap-1.5">
- {#if needsAttention(tab)}
- <span class="relative inline-grid shrink-0 *:[grid-area:1/1]">
- <span class="w-1.5 h-1.5 rounded-full animate-ping {statusColor(tab.agentStatus)}"></span>
- <span class="w-1.5 h-1.5 rounded-full {statusColor(tab.agentStatus)}"></span>
- </span>
- {:else}
- <span class="w-1.5 h-1.5 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span>
- {/if}
- <span class="font-mono text-[10px] px-1 py-0.5 rounded bg-base-300 text-base-content/60 shrink-0" title="Tab ID — agents address this tab by this handle">{tabStore.shortHandleFor(tab.id)}</span>
- {#if editingTabId === tab.id}
- <input
- bind:this={editInputEl}
- bind:value={editValue}
- class="max-w-32 text-xs bg-base-100 rounded px-1 outline-none ring-1 ring-primary/40"
- onclick={(e) => e.stopPropagation()}
- ondblclick={(e) => e.stopPropagation()}
- onkeydown={handleRenameKeydown}
- onblur={commitRename}
- />
- {:else}
- <span
- class="max-w-32 truncate text-xs"
- ondblclick={(e) => { e.stopPropagation(); startRename(tab); }}
- title="Double-click to rename"
- >{tab.title}</span>
- {/if}
- </span>
- <button
- type="button"
- class="flex items-center justify-center px-3 my-1 leading-none text-base-content/30 hover:text-error hover:bg-base-300 rounded transition-colors text-xs"
- onclick={(e) => { e.stopPropagation(); tabStore.closeTab(tab.id); }}
- aria-label="Close tab"
- >
- &#x2715;
- </button>
- </div>
- {/each}
-
- <!-- Trailing padding after the last tab. Fills remaining space (big target),
- shrinks to a small minimum when the bar overflows and scrolls.
- Double-click anywhere in it to open a new tab. -->
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="flex-1 min-w-12 self-stretch cursor-default"
- ondblclick={() => tabStore.createNewTab()}
- title="Double-click to open a new tab"
- ></div>
- </div>
-</div>
-
-<!-- Bottom row: subagent tabs (hidden when empty) -->
-{#if hasSubagentTabs}
- <div class="overflow-x-auto bg-base-200 flex-shrink-0 border-t border-base-300 rounded-br-lg">
- <div
- role="tablist"
- class="tabs tabs-lift tabs-xs min-w-max"
- >
- {#each subagentTabs as tab (tab.id)}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- role="tab"
- class="tab !flex items-stretch gap-1 {tab.id === tabStore.activeTabId ? 'tab-active' : ''} {!tab.persistent ? 'opacity-70 italic' : ''}"
- onclick={() => tab.persistent ? tabStore.switchTab(tab.id) : tabStore.promoteTab(tab.id)}
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tab.persistent ? tabStore.switchTab(tab.id) : tabStore.promoteTab(tab.id); }}
- tabindex="0"
- >
- <span class="flex items-center gap-1">
- {#if needsAttention(tab)}
- <span class="relative inline-grid shrink-0 *:[grid-area:1/1]">
- <span class="w-1 h-1 rounded-full animate-ping {statusColor(tab.agentStatus)}"></span>
- <span class="w-1 h-1 rounded-full {statusColor(tab.agentStatus)}"></span>
- </span>
- {:else}
- <span class="w-1 h-1 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span>
- {/if}
- <span class="font-mono text-[10px] px-1 rounded bg-base-300 text-base-content/60 shrink-0" title="Tab ID — agents address this tab by this handle">{tabStore.shortHandleFor(tab.id)}</span>
- <span class="max-w-28 truncate text-xs">{tab.title}</span>
- </span>
- {#if tab.persistent}
- <button
- type="button"
- class="flex items-center justify-center px-2 my-0.5 leading-none text-base-content/30 hover:text-error hover:bg-base-300 rounded transition-colors text-xs"
- onclick={(e) => { e.stopPropagation(); tabStore.closeTab(tab.id); }}
- aria-label="Close tab"
- >
- &#x2715;
- </button>
- {/if}
- </div>
- {/each}
- </div>
- </div>
-{/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 @@
-<script lang="ts">
-import type { TaskItem } from "../types.js";
-
-const { tasks }: { tasks: TaskItem[] } = $props();
-
-type Status = TaskItem["status"];
-
-const completedCount = $derived(tasks.filter((t) => t.status === "completed").length);
-const inProgressCount = $derived(tasks.filter((t) => t.status === "in_progress").length);
-const cancelledCount = $derived(tasks.filter((t) => t.status === "cancelled").length);
-// "Active" total excludes cancelled items, so progress reads as work that still counts.
-const activeTotal = $derived(tasks.length - cancelledCount);
-
-function checkboxClass(status: Status): string {
- switch (status) {
- case "pending":
- return "checkbox checkbox-sm rounded-sm checkbox-secondary";
- case "in_progress":
- return "checkbox checkbox-sm rounded-sm checkbox-info";
- case "completed":
- return "checkbox checkbox-sm rounded-sm checkbox-success";
- case "cancelled":
- return "checkbox checkbox-sm rounded-sm checkbox-neutral";
- }
-}
-
-function isChecked(status: Status): boolean {
- return status === "completed";
-}
-
-function isIndeterminate(status: Status): boolean {
- return status === "in_progress";
-}
-
-function rowClass(status: Status): string {
- if (status === "completed") return "opacity-60";
- if (status === "cancelled") return "opacity-40";
- return "";
-}
-
-function textClass(status: Status): string {
- switch (status) {
- case "completed":
- return "line-through text-base-content/50";
- case "cancelled":
- return "line-through text-base-content/40";
- case "in_progress":
- return "font-semibold";
- default:
- return "";
- }
-}
-</script>
-
-<div class="flex flex-col gap-2">
- {#if tasks.length === 0}
- <p class="text-xs text-base-content/50">No tasks yet.</p>
- {:else}
- <p class="text-xs text-base-content/60">
- {completedCount}/{activeTotal} completed{#if inProgressCount > 0}, {inProgressCount} in progress{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if}
- </p>
- <ul class="flex flex-col gap-0.5">
- {#each tasks as task (task.id)}
- <li class="flex items-start gap-2 rounded p-1.5 transition-colors {rowClass(task.status)}">
- <input
- type="checkbox"
- class={checkboxClass(task.status)}
- checked={isChecked(task.status)}
- indeterminate={isIndeterminate(task.status)}
- disabled
- tabindex="-1"
- />
- <span class="text-xs leading-tight min-w-0 {textClass(task.status)}">
- {task.content}
- </span>
- </li>
- {/each}
- </ul>
- {/if}
-</div>
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 @@
-<script lang="ts">
-import { tabStore } from "../tabs.svelte.js";
-import type { ToolBatchEntry } from "../types.js";
-
-const { toolCall }: { toolCall: ToolBatchEntry } = $props();
-
-let isExpanded = $state(false);
-
-function toggle() {
- isExpanded = !isExpanded;
-}
-
-interface ShellResult {
- stdout: string;
- stderr: string;
- exitCode: number;
-}
-
-function parseShellResult(result: string): ShellResult | null {
- try {
- const parsed = JSON.parse(result) as unknown;
- if (
- parsed !== null &&
- typeof parsed === "object" &&
- "stdout" in parsed &&
- "stderr" in parsed &&
- "exitCode" in parsed
- ) {
- return {
- stdout: String((parsed as Record<string, unknown>).stdout ?? ""),
- stderr: String((parsed as Record<string, unknown>).stderr ?? ""),
- exitCode: Number((parsed as Record<string, unknown>).exitCode ?? 0),
- };
- }
- return null;
- } catch {
- return null;
- }
-}
-
-const isShell = $derived(toolCall.name === "run_shell");
-const shellResult = $derived(
- isShell && toolCall.result !== undefined ? parseShellResult(toolCall.result) : null,
-);
-
-const summonAgentId = $derived.by(() => {
- if (toolCall.name !== "summon" || !toolCall.result) return null;
- const match = toolCall.result.match(/agent_id:\s*([a-f0-9-]+)/);
- return match ? match[1] : null;
-});
-</script>
-
-<div class="collapse collapse-arrow mb-2 p-1 opacity-60 {isExpanded ? 'collapse-open' : ''}">
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="collapse-title flex items-center gap-2 text-sm italic cursor-pointer w-full text-left"
- onclick={toggle}
- role="button"
- tabindex="0"
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') toggle(); }}
- aria-expanded={isExpanded}
- >
- <span class="badge badge-neutral badge-sm">tool</span>
- <span class="font-mono">{toolCall.name}</span>
- {#if summonAgentId !== null}
- <button
- type="button"
- class="btn btn-xs btn-ghost"
- onclick={(e) => { e.stopPropagation(); tabStore.openAgentTab(summonAgentId!); }}
- >Open Tab</button>
- {/if}
- {#if toolCall.result !== undefined}
- {#if toolCall.result.includes("[USER INTERRUPT]")}
- <span class="badge badge-info badge-sm ml-auto">interrupted</span>
- {:else if isShell && shellResult !== null}
- <span class="badge badge-sm ml-auto {shellResult.exitCode === 0 ? 'badge-success' : 'badge-error'}">
- exit {shellResult.exitCode}
- </span>
- {:else if toolCall.isError}
- <span class="badge badge-error badge-sm ml-auto">error</span>
- {:else}
- <span class="badge badge-success badge-sm ml-auto">done</span>
- {/if}
- {:else}
- <span class="badge badge-warning badge-sm ml-auto">pending</span>
- {/if}
- </div>
-
- <div class="collapse-content text-xs">
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">Arguments</p>
- <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all">{JSON.stringify(toolCall.arguments, null, 2)}</pre>
- </div>
- {#if isShell && toolCall.result !== undefined}
- {#if shellResult !== null}
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">stdout:</p>
- <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{shellResult.stdout || "(empty)"}</pre>
- </div>
- {#if shellResult.stderr}
- <div class="mt-2">
- <p class="font-semibold text-error/80 mb-1">stderr:</p>
- <pre class="bg-error/10 text-error rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{shellResult.stderr}</pre>
- </div>
- {/if}
- <div class="mt-2 flex items-center gap-2">
- <span class="font-semibold text-base-content/70">exit code:</span>
- <span class="badge badge-sm {shellResult.exitCode === 0 ? 'badge-success' : 'badge-error'}">{shellResult.exitCode}</span>
- </div>
- {:else}
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">Result</p>
- <pre class="rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all {toolCall.isError ? 'bg-error/20 text-error' : 'bg-base-300'}">{toolCall.result}</pre>
- </div>
- {/if}
- {:else if isShell && toolCall.shellOutput}
- {#if toolCall.shellOutput.stdout}
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">stdout</p>
- <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all text-xs">{toolCall.shellOutput.stdout}</pre>
- </div>
- {/if}
- {#if toolCall.shellOutput.stderr}
- <div class="mt-2">
- <p class="font-semibold text-error/70 mb-1">stderr</p>
- <pre class="bg-error/10 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all text-xs text-error">{toolCall.shellOutput.stderr}</pre>
- </div>
- {/if}
- <span class="text-xs text-base-content/50 italic">Running...</span>
- {:else if toolCall.result !== undefined}
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">Result</p>
- <pre
- class="rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all {toolCall.isError
- ? 'bg-error/20 text-error'
- : 'bg-base-300'}">{toolCall.result}</pre>
- </div>
- {/if}
- </div>
-</div>
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 @@
-<script lang="ts">
-import { onMount } from "svelte";
-import { appSettings } from "../settings.svelte.js";
-import type { LogEntry } from "../types.js";
-
-interface ToolPermission {
- id: string;
- label: string;
- description: string;
-}
-
-const toolPermissions: ToolPermission[] = [
- { id: "read", label: "Read files", description: "Allow the AI to read files in the workspace" },
- {
- id: "edit",
- label: "Edit files",
- description: "Allow the AI to write/edit files in the workspace",
- },
- { id: "bash", label: "Run commands", description: "Allow the AI to execute shell commands" },
- {
- id: "summon",
- label: "Summon agents",
- description: "Allow the AI to spawn child agents to work on tasks",
- },
- {
- id: "user_agent",
- label: "Spawn user agents",
- description: "Allow the AI to open new independent top-level tabs",
- },
- {
- id: "send_to_tab",
- label: "Message other tabs",
- description: "Allow the AI to send messages to other tabs by their ID",
- },
- {
- id: "read_tab",
- label: "Read other tabs",
- description: "Allow the AI to read other tabs' latest responses by their ID",
- },
- {
- id: "web_search",
- label: "Web search",
- description: "Allow the AI to search the web via Firecrawl",
- },
- {
- id: "youtube_transcribe",
- label: "YouTube transcripts",
- description: "Allow the AI to fetch YouTube video transcripts",
- },
- {
- id: "search_code",
- label: "Search code",
- description: "Allow the AI to search the codebase with the cs ranked code-search engine",
- },
- {
- id: "key_usage",
- label: "Key usage",
- description:
- "Allow the AI to read current API-key usage levels, rate-limit headroom, and reset times",
- },
- {
- id: "lsp",
- label: "LSP queries",
- description:
- "Allow the AI to query a language server for hover, go-to-definition, references, and more",
- },
-];
-
-const {
- entries = [],
- apiBase = "",
- checkedTools = null,
- onToolToggle = null,
-}: {
- entries?: LogEntry[];
- apiBase?: string;
- /** External checked set (agent builder mode). When null, uses appSettings. */
- checkedTools?: Set<string> | null;
- /** Callback when a tool is toggled in external mode. */
- onToolToggle?: ((id: string, checked: boolean) => void) | null;
-} = $props();
-
-/** Whether we're in external (agent builder) mode */
-const externalMode = $derived(checkedTools !== null && onToolToggle !== null);
-
-function isChecked(id: string): boolean {
- if (externalMode) return checkedTools?.has(id) ?? false;
- return appSettings.toolPerms[id] === true;
-}
-
-async function loadPermissions(): Promise<void> {
- const loaded: Record<string, boolean> = { ...appSettings.toolPerms };
- for (const perm of toolPermissions) {
- try {
- const res = await fetch(`${apiBase}/tabs/settings/perm_${perm.id}`);
- if (res.ok) {
- const data = (await res.json()) as { value: string | null };
- if (data.value !== null) {
- loaded[perm.id] = data.value === "allow";
- }
- }
- } catch {
- // ignore
- }
- }
- appSettings.toolPerms = { ...loaded };
- appSettings.savedToolPerms = { ...loaded };
-}
-
-function togglePermission(id: string): void {
- if (externalMode) {
- onToolToggle?.(id, !checkedTools?.has(id));
- return;
- }
- appSettings.toolPerms = { ...appSettings.toolPerms, [id]: !appSettings.toolPerms[id] };
-}
-
-function resetPermissions(): void {
- appSettings.toolPerms = { ...appSettings.savedToolPerms };
-}
-
-onMount(() => {
- if (!externalMode) {
- loadPermissions();
- }
-});
-</script>
-
-<div class="flex flex-col gap-3">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Tool Permissions</div>
- {#if !externalMode}
- <p class="text-xs text-base-content/40">Changes are applied when you send your next message.</p>
- {/if}
-
- <div class="flex flex-col gap-1.5">
- {#each toolPermissions as perm (perm.id)}
- <label class="flex items-start gap-2 cursor-pointer p-1 rounded hover:bg-base-200 transition-colors">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm mt-0.5"
- checked={isChecked(perm.id)}
- onchange={() => togglePermission(perm.id)}
- />
- <div class="flex flex-col">
- <span class="text-xs font-medium text-base-content">{perm.label}</span>
- <span class="text-xs text-base-content/40">{perm.description}</span>
- </div>
- </label>
- {/each}
- </div>
-
- {#if !externalMode}
- <button
- class="btn btn-sm btn-ghost w-full"
- disabled={!appSettings.toolPermsDirty}
- onclick={resetPermissions}
- >
- Reset
- </button>
-
- <p class="text-xs text-base-content/40">Warning: changing tool access will reset the AI's prompt cache for active conversations, which may increase usage costs.</p>
-
- <!-- Permission Log -->
- {#if entries.length > 0}
- <div class="collapse collapse-arrow bg-base-200 mt-2">
- <input type="checkbox" />
- <div class="collapse-title text-sm font-medium py-2 min-h-0">
- Log ({entries.length})
- </div>
- <div class="collapse-content text-xs max-h-40 overflow-y-auto">
- {#each entries as entry (entry.id)}
- <div class="flex items-center gap-2 py-1 border-b border-base-300">
- <span class="badge badge-sm {entry.action === 'reject' ? 'badge-error' : 'badge-success'}">
- {entry.action}
- </span>
- <span class="text-base-content/70">{entry.permission}</span>
- <span class="text-base-content/50 ml-auto text-xs">{entry.timestamp}</span>
- </div>
- <p class="text-base-content/60 pl-2 pb-1">{entry.description}</p>
- {/each}
- </div>
- </div>
- {/if}
- {/if}
-</div>
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<Page>("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<Record<string, boolean>>({
- 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<Record<string, boolean>>({
- 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<Record<string, boolean>>({});
-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<string, boolean>) {
- toolPerms = v;
- },
- get savedToolPerms() {
- return savedToolPerms;
- },
- set savedToolPerms(v: Record<string, boolean>) {
- savedToolPerms = v;
- },
- get toolPermsDirty() {
- return Object.keys(toolPerms).some((k) => toolPerms[k] !== savedToolPerms[k]);
- },
- get skillChecks() {
- return skillChecks;
- },
- set skillChecks(v: Record<string, boolean>) {
- 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<string> = ["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> = {}): 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<number, ChunkRow>();
- 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<string>();
-
- // 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<string>();
-
- // 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<string, string>();
-
- // 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<Tab> {
- 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<void> {
- 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<void> {
- 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<Tab>): 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<void> {
- 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<typeof appendEventToChunks>[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<void> {
- 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<number> {
- 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<string, TabStatusSnapshot> = {};
- try {
- const res = await fetch(`${config.apiBase}/status`);
- if (res.ok) {
- const data = (await res.json()) as { statuses?: Record<string, TabStatusSnapshot> };
- 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<string, Win>();
- 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<void> {
- 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<void> {
- // 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<void> {
- 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<string, boolean> = { ...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<void> {
- 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<Tab> = {
- 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<string, boolean> = { ...appSettings.skillChecks };
- for (const skillKey of defaultAgent.skills) {
- checks[skillKey] = true;
- }
- appSettings.skillChecks = checks;
- }
-
- // Apply tool permissions
- const perms: Record<string, boolean> = {};
- 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<void> {
- 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<Tab> = {
- 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<string | null> {
- 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<void> {
- 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(`<skill name="${name}">\n${skillContent}\n</skill>`);
- }
- }
- 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<unknown>[] = [];
-
- 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<Tab> = {
- 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<string, boolean> = {};
- 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<string, boolean> = {};
- 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<void> {
- 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<void> {
- 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 `<select>`, which lets the user
- * change theme at runtime.
- *
- * Both used to hand-roll their own `localStorage` key, default value,
- * and DOM-attribute write. That drift produced a real bug: `App.svelte`
- * left the DOM untouched on first load (so daisyUI fell back to the
- * first theme in `app.css`, `light`), while `SettingsPanel` showed
- * `"dark"` as the selected value — UI and reality disagreed until the
- * user manually picked a theme. Centralizing here closes that gap.
- *
- * The theme list also intentionally mirrors the `@plugin "daisyui"`
- * block in `app.css`. Drift between the two is harmless (a theme name
- * present here but not in CSS just falls back to the default daisyUI
- * theme at render time), but they should be kept in sync by hand —
- * daisyUI's plugin config is a CSS-time concern and can't be imported
- * from TS.
- */
-
-export const THEMES = [
- "light",
- "dark",
- "dracula",
- "night",
- "nord",
- "sunset",
- "cyberpunk",
- "forest",
- "cmyk",
- "coffee",
- "caramellatte",
- "garden",
- "luxury",
-] as const;
-
-export type Theme = (typeof THEMES)[number];
-
-export const THEME_STORAGE_KEY = "dispatch-theme";
-
-/**
- * The fallback theme used both at first-boot apply (when nothing is
- * persisted) and as the UI's default-selected value. They MUST match —
- * if they ever diverged again, the UI would show one theme and the
- * page would render another.
- */
-export const DEFAULT_THEME: Theme = "dark";
-
-/**
- * Read the persisted theme. Returns `DEFAULT_THEME` when nothing is
- * stored, when access throws (private mode / SecurityError), or when
- * the stored value isn't one of the known themes. Never throws.
- *
- * SSR-safe: if `localStorage` is undefined (e.g. `vite build` during
- * prerender, if that's ever wired up), returns the default.
- */
-export function loadStoredTheme(): Theme {
- try {
- if (typeof localStorage === "undefined") return DEFAULT_THEME;
- const raw = localStorage.getItem(THEME_STORAGE_KEY);
- if (raw && (THEMES as readonly string[]).includes(raw)) {
- return raw as Theme;
- }
- return DEFAULT_THEME;
- } catch {
- return DEFAULT_THEME;
- }
-}
-
-/**
- * Apply a theme to the live document and persist it. The DOM write is
- * unconditional (daisyUI keys off `data-theme` on the `<html>`
- * element); the storage write is best-effort and swallows quota /
- * SecurityError failures because the session continues to work either
- * way — only cross-reload persistence degrades.
- */
-export function applyTheme(theme: Theme): void {
- if (typeof document !== "undefined") {
- document.documentElement.setAttribute("data-theme", theme);
- }
- try {
- if (typeof localStorage !== "undefined") {
- localStorage.setItem(THEME_STORAGE_KEY, theme);
- }
- } catch {
- // Best-effort.
- }
-}
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
deleted file mode 100644
index bb7c169..0000000
--- a/packages/frontend/src/lib/types.ts
+++ /dev/null
@@ -1,337 +0,0 @@
-export interface DebugInfo {
- timestamp: string;
- error?: string;
- notice?: string;
- model?: string;
- apiBase?: string;
- connectionStatus?: string;
- agentStatus?: string;
- rawEvent?: unknown;
- httpStatus?: number;
- httpBody?: string;
-}
-
-/**
- * Per-tab prompt-cache telemetry, accumulated from the `usage` AgentEvent
- * (one per LLM round-trip). Token counts are cumulative across the session
- * since the page loaded; `last` holds the most recent request's split. Powers
- * the "Cache Rate" sidebar view. The cache hit rate is
- * `cacheReadTokens / inputTokens` (inputTokens is the TOTAL prompt, including
- * cached tokens).
- */
-export interface CacheStats {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- /** Number of LLM requests (usage events) counted. */
- requests: number;
- last: {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- } | null;
-}
-
-/**
- * Mirror of the core `Chunk` union (see packages/core/src/types/index.ts).
- *
- * Wire-format symmetry MUST be kept with core. If you change one, change
- * the other. The frontend store calls into the shared
- * `appendEventToChunks` helper from core so the two stay in lockstep.
- */
-export type Chunk = TextChunk | ThinkingChunk | ToolBatchChunk | ErrorChunk | SystemChunk;
-
-export interface TextChunk {
- type: "text";
- text: string;
-}
-
-export interface ThinkingChunk {
- type: "thinking";
- text: string;
- /**
- * Mirror of core. Anthropic's `providerMetadata` blob captured from
- * the v6 `reasoning-end` stream event. Present once the backend has
- * sealed the chunk; absent for in-flight thinking or for non-Anthropic
- * models. The UI doesn't render this — it lives here for wire-format
- * symmetry with the persisted chunk shape.
- */
- metadata?: Record<string, unknown>;
-}
-
-export interface ToolBatchChunk {
- type: "tool-batch";
- calls: ToolBatchEntry[];
-}
-
-export interface ToolBatchEntry {
- id: string;
- name: string;
- arguments: Record<string, unknown>;
- result?: string;
- isError?: boolean;
- shellOutput?: { stdout: string; stderr: string };
-}
-
-export interface ErrorChunk {
- type: "error";
- message: string;
- statusCode?: number;
-}
-
-export type SystemChunkKind = "notice" | "model-changed" | "config-reload" | "cancelled";
-
-export interface SystemChunk {
- type: "system";
- kind: SystemChunkKind;
- text: string;
-}
-
-/**
- * A render bubble. NOT stored state — it's a derived projection of the flat
- * chunk log (`groupRowsToMessages(tab.chunks)`) concatenated with the transient
- * live tail. The store's source of truth for history is `tab.chunks: ChunkRow[]`.
- */
-export interface ChatMessage {
- id: string;
- role: "user" | "assistant" | "system";
- chunks: Chunk[];
- isStreaming?: boolean;
- debugInfo?: DebugInfo;
- seq?: number;
- /**
- * turn_id of the chunk rows this message was grouped from (or the in-flight
- * turn for a live message). Gives a stable, turn-scoped render key so the
- * bubble doesn't remount when the live turn reconciles into sealed chunks.
- */
- turnId?: string;
-}
-
-export type ConnectionStatus = "connecting" | "connected" | "disconnected";
-
-/**
- * Mirror of core's `TabStatusSnapshot` (see packages/core/src/types/index.ts).
- *
- * Sent on every WS (re)connect and via `GET /status`. The frontend uses
- * this to:
- * - reconcile its in-memory `agentStatus` with the backend's truth
- * after a disconnect window;
- * - reconstruct the in-flight assistant message for any tab the
- * backend is currently streaming, so the user sees the partial
- * thinking / text without waiting for the next delta.
- *
- * Wire-format symmetry MUST be kept with core. If you change one,
- * change the other.
- */
-export interface TabStatusSnapshot {
- status: "idle" | "running" | "error";
- currentChunks?: Chunk[];
- currentAssistantId?: string;
- /** turn_id of the in-flight turn; present iff status === "running". */
- currentTurnId?: string;
- /** The tab's todo list, for rehydrating the Tasks panel on reload. */
- tasks?: TaskItem[];
-}
-
-export type AgentEvent =
- | { type: "status"; status: "idle" | "running" | "error" }
- // Opens a turn before any content delta; carries the turn_id used to tag
- // the live chunks so they reconcile cleanly when the turn seals.
- | { type: "turn-start"; turnId: string }
- // Fires after the turn settled AND its chunks were persisted (after the DB
- // write, post status:idle). Triggers the frontend's reconcile-from-DB.
- // `usageStats` carries the tab's authoritative usage aggregate (read after the
- // usage rows were persisted); the store REPLACES `cacheStats` with it,
- // reconciling the live accumulator to the DB truth (self-heals the live
- // overshoot from a discarded rate-limited fallback attempt). null ⇒ no usage
- // rows; absent ⇒ leave cacheStats untouched.
- | { type: "turn-sealed"; turnId: string; usageStats?: CacheStats | null }
- // Sent on every WS (re)connect: a snapshot of every tab the backend is
- // currently tracking and its live status. The frontend uses this to
- // detect desync after a reconnect (e.g. bun --watch restart killed the
- // in-flight agent state, frontend missed `done` / `status:idle` events).
- | { type: "statuses"; statuses: Record<string, TabStatusSnapshot> }
- | { type: "text-delta"; delta: string }
- | { type: "reasoning-delta"; delta: string }
- | { type: "reasoning-end"; metadata?: Record<string, unknown> }
- | {
- type: "tool-call";
- toolCall: {
- id: string;
- name: string;
- arguments: Record<string, unknown>;
- };
- }
- | {
- type: "tool-result";
- toolResult: { toolCallId: string; result: string; isError: boolean };
- }
- | {
- type: "usage";
- usage: {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- };
- }
- | { type: "error"; error: string }
- | { type: "notice"; message: string }
- | { type: "model-changed"; keyId: string; modelId: string }
- | { type: "task-list-update"; tasks: TaskItem[] }
- | { type: "config-reload" }
- | {
- type: "done";
- message: {
- role: string;
- content: string;
- toolCalls?: unknown[];
- toolResults?: unknown[];
- };
- }
- | { type: "permission-prompt"; pending: PermissionPrompt[] }
- | { type: "shell-output"; data: string; stream: "stdout" | "stderr" }
- | {
- type: "tab-created";
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- agentSlug?: string | null;
- workingDirectory?: string | null;
- agentModels?: Array<{ key_id: string; model_id: string }> | null;
- }
- | { type: "message-queued"; tabId: string; messageId: string; message: string }
- | {
- type: "message-consumed";
- tabId: string;
- messageIds: string[];
- /**
- * Why the queue was drained:
- * - "interrupt": consumed mid-turn, folded into a running turn's tool
- * result as a [USER INTERRUPT]. The optimistic bubble collapses into
- * that sealed turn.
- * - "continuation": consumed between turns to START a new turn. The
- * optimistic bubble becomes that new turn's initiating user row.
- * Absent ⇒ treat as "interrupt" (back-compat).
- */
- reason?: "interrupt" | "continuation";
- }
- | { type: "message-cancelled"; tabId: string; messageId: string }
- | { type: "compaction-started"; tempTabId: string; sourceTabId: string }
- | {
- type: "compaction-complete";
- tempTabId: string;
- sourceTabId: string;
- backupTabId: string;
- backupTitle: string;
- }
- | { type: "compaction-error"; tempTabId: string; sourceTabId: string; error: string };
-
-export interface TaskItem {
- /** Stable positional id for Svelte keying only; never shown to the model. */
- id: string;
- content: string;
- status: "pending" | "in_progress" | "completed" | "cancelled";
-}
-
-export interface PermissionPrompt {
- id: string;
- permission: string;
- patterns: string[];
- always: string[];
- description: string;
- metadata: Record<string, unknown>;
-}
-
-export interface ModelOverride {
- keyId: string;
- modelId: string;
-}
-
-export interface KeyInfo {
- id: string;
- provider: string;
- status: "active" | "exhausted";
- lastError: string | null;
- exhaustedAt: number | null;
-}
-
-export interface QueuedMessage {
- id: string;
- message: string;
- timestamp: number;
-}
-
-export interface LogEntry {
- id: string;
- permission: string;
- patterns: string[];
- action: "once" | "always" | "reject";
- timestamp: string;
- description: string;
-}
-
-export interface UsageBucket {
- utilization?: number;
- resetsAt?: number;
-}
-
-export interface ClaudeAccountUsage {
- label: string;
- source: string;
- subscriptionType?: string;
- fiveHour?: UsageBucket;
- sevenDay?: UsageBucket;
- error?: string;
-}
-
-export interface ClaudeUsageData {
- provider: "anthropic";
- accounts?: ClaudeAccountUsage[];
- fiveHour?: UsageBucket;
- sevenDay?: UsageBucket;
-}
-
-export interface OpencodeUsageData {
- provider: "opencode-go";
- unavailable?: boolean;
- consoleUrl?: string;
- limits?: { fiveHour?: string; weekly?: string; monthly?: string };
- fiveHour?: UsageBucket;
- weekly?: UsageBucket;
- monthly?: UsageBucket;
-}
-
-export interface CopilotUsageData {
- provider: "github-copilot";
- tokensConsumed?: number;
- tokensRemaining?: number;
- percentUsed?: number;
- resetAt?: number;
- plan?: string;
-}
-
-export interface GoogleUsageData {
- provider: "google";
- models?: Array<{
- name: string;
- inputTokenLimit: number;
- outputTokenLimit: number;
- rpm: number;
- requestsPerDay: number;
- }>;
- currentUsage?: {
- percentUsed: number;
- resetsAt?: string;
- };
- weeklyUsage?: {
- percentUsed: number;
- resetsAt?: string;
- };
-}
-
-export type KeyUsageData = ClaudeUsageData | OpencodeUsageData | CopilotUsageData | GoogleUsageData;
diff --git a/packages/frontend/src/lib/ws.svelte.ts b/packages/frontend/src/lib/ws.svelte.ts
deleted file mode 100644
index ff142ad..0000000
--- a/packages/frontend/src/lib/ws.svelte.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { config } from "./config.js";
-import type { AgentEvent, ConnectionStatus } from "./types.js";
-
-type EventCallback = (event: AgentEvent) => void;
-
-// Close any stale WebSocket from HMR reloads
-if (import.meta.hot) {
- import.meta.hot.dispose((data: Record<string, unknown>) => {
- const old = data._ws as WebSocket | undefined;
- if (old && old.readyState === WebSocket.OPEN) {
- old.close();
- }
- });
-}
-
-function createWebSocketClient(url: string) {
- let connectionStatus: ConnectionStatus = $state("disconnected");
- let ws: WebSocket | null = null;
- let reconnectDelay = 1000;
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
- let manualDisconnect = false;
- const callbacks = new Set<EventCallback>();
-
- function connect() {
- if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
- return;
- }
- manualDisconnect = false;
- connectionStatus = "connecting";
- ws = new WebSocket(url);
-
- // Store ref for HMR cleanup
- if (import.meta.hot) {
- import.meta.hot.data._ws = ws;
- }
-
- ws.onopen = () => {
- connectionStatus = "connected";
- reconnectDelay = 1000;
- };
-
- ws.onmessage = (event: MessageEvent) => {
- let data: AgentEvent;
- try {
- data = JSON.parse(event.data as string) as AgentEvent;
- } catch (err) {
- // Genuinely malformed WS payload — these are rare and harmless.
- console.warn("[ws] ignored malformed message:", err);
- return;
- }
- // Run callbacks OUTSIDE the parse try so callback throws are visible.
- // The previous catch-everything wrapper silently swallowed bugs in
- // downstream handlers (notably the `structuredClone` of a Svelte 5
- // $state proxy throwing DataCloneError), making them undiagnosable.
- for (const cb of callbacks) {
- try {
- cb(data);
- } catch (err) {
- console.error("[ws] callback threw for event:", data, err);
- }
- }
- };
-
- ws.onclose = () => {
- connectionStatus = "disconnected";
- ws = null;
- if (!manualDisconnect) {
- scheduleReconnect();
- }
- };
-
- ws.onerror = () => {
- ws?.close();
- };
- }
-
- function scheduleReconnect() {
- if (reconnectTimer) clearTimeout(reconnectTimer);
- reconnectTimer = setTimeout(() => {
- reconnectTimer = null;
- connect();
- }, reconnectDelay);
- reconnectDelay = Math.min(reconnectDelay * 2, 10000);
- }
-
- function disconnect() {
- manualDisconnect = true;
- if (reconnectTimer) {
- clearTimeout(reconnectTimer);
- reconnectTimer = null;
- }
- ws?.close();
- ws = null;
- connectionStatus = "disconnected";
- }
-
- function onEvent(callback: EventCallback) {
- callbacks.add(callback);
- return () => {
- callbacks.delete(callback);
- };
- }
-
- /** Remove all registered event callbacks (used for HMR safety). */
- function clearCallbacks() {
- callbacks.clear();
- }
-
- function send(data: unknown): void {
- if (ws && ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify(data));
- }
- }
-
- return {
- get connectionStatus() {
- return connectionStatus;
- },
- connect,
- disconnect,
- onEvent,
- clearCallbacks,
- send,
- };
-}
-
-export const wsClient = createWebSocketClient(config.wsUrl);