summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components
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/components
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/components')
-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
24 files changed, 0 insertions, 6086 deletions
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>