diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/frontend/src/lib/components | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-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')
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">✔</span> - {:else if cwdExists === false} - <span class="text-warning text-lg" title="Directory does not exist (will be created)">✖</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} - > - ▼ - </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 · {entry.data.limits.weekly}/wk · {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, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -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">✔</span> - {:else if cwdExists === false} - <span class="text-warning text-sm" title="Will be created">✖</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" - > - ✕ - </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" - > - ✕ - </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> |
