summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
committerAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
commit9ecaabd87c0e51b8a7408dabb0133a9344586859 (patch)
tree4b5809ab23948a5e3f558f3aa34c52d5038f4e19 /packages/frontend/src/lib/components
parent8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 (diff)
downloaddispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.tar.gz
dispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.zip
feat: agent builder, CWD support, auto-save, UI polish, unavailable tool handling
- Agent Builder: full CRUD with card grid, drag-and-drop model reorder, edit/delete - Auto-save on edit with 600ms debounce, AbortController for concurrency, fieldset disabled until name entered - Agent definitions stored as TOML with cwd field, loaded from global/project dirs - Working directory: per-tab CWD override in Chat Settings, agent default CWD, auto-create on first message - CWD validation: check-dir endpoint with ~ expansion, real-time validity indicator - Subagent CWD validated against parent's effective CWD using path.relative - Unavailable tool calls: caught gracefully, shown as tool call with error badge, model retries - UI: tab bar border radius, sidebar border removed, chat input ghost style, scroll-to-bottom rectangle - Skills dir collapse uses CSS rotation, Model Choice renamed to Chat Settings, System Prompt view removed - Reusable SkillsBrowser/ToolPermissions with external mode for Agent Builder - ModelSelector: Agent/Manual toggle, agent list, Agent Settings link - Page router, skills recursive scanning, bin/up gopass removed, docker volume mounts
Diffstat (limited to 'packages/frontend/src/lib/components')
-rw-r--r--packages/frontend/src/lib/components/AgentBuilder.svelte746
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte4
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte2
-rw-r--r--packages/frontend/src/lib/components/ChatPanel.svelte74
-rw-r--r--packages/frontend/src/lib/components/ClaudeReset.svelte247
-rw-r--r--packages/frontend/src/lib/components/ConfigPanel.svelte177
-rw-r--r--packages/frontend/src/lib/components/Header.svelte3
-rw-r--r--packages/frontend/src/lib/components/HotReloadIndicator.svelte38
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte320
-rw-r--r--packages/frontend/src/lib/components/MarkdownRenderer.svelte308
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte202
-rw-r--r--packages/frontend/src/lib/components/ModelStatus.svelte254
-rw-r--r--packages/frontend/src/lib/components/PermissionPrompt.svelte5
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte201
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte44
-rw-r--r--packages/frontend/src/lib/components/SkillsBrowser.svelte247
-rw-r--r--packages/frontend/src/lib/components/SystemPromptPanel.svelte66
-rw-r--r--packages/frontend/src/lib/components/TabBar.svelte5
-rw-r--r--packages/frontend/src/lib/components/TaskListPanel.svelte11
-rw-r--r--packages/frontend/src/lib/components/ThemeSwitcher.svelte16
-rw-r--r--packages/frontend/src/lib/components/ToolPermissions.svelte99
21 files changed, 2075 insertions, 994 deletions
diff --git a/packages/frontend/src/lib/components/AgentBuilder.svelte b/packages/frontend/src/lib/components/AgentBuilder.svelte
new file mode 100644
index 0000000..4a2d5d8
--- /dev/null
+++ b/packages/frontend/src/lib/components/AgentBuilder.svelte
@@ -0,0 +1,746 @@
+<script module>
+// Session-level cache for models per key; avoids refetching across component instances
+const modelCache = new Map();
+</script>
+
+<script lang="ts">
+ 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;
+ }
+
+ interface AgentDefinition {
+ name: string;
+ description: string;
+ skills: string[];
+ tools: string[];
+ models: AgentModelEntry[];
+ scope: string;
+ slug: string;
+ cwd?: string;
+ }
+
+ 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 cwdCheckTimer: ReturnType<typeof setTimeout> | null = null;
+ let formSkills = $state<Set<string>>(new Set());
+ let formTools = $state<Set<string>>(new Set());
+ let formModels = $state<AgentModelEntry[]>([]);
+
+ // 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 = [];
+ 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 }));
+ 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);
+ }
+
+ 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() } : {}),
+ };
+
+ 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;
+ 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;
+ }
+ } catch {
+ cwdExists = null;
+ }
+ }, 300);
+ });
+
+ // Auto-save with debounce whenever form fields change
+ $effect(() => {
+ // Read all reactive form fields to subscribe
+ const _ = [formName, formDescription, formScope, formCwd, formSkills, formTools, formModels];
+ 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>
+
+ <!-- Scope -->
+ <div class="form-control gap-1">
+ <label class="label py-0" for="agent-scope">
+ <span class="label-text font-semibold">Scope</span>
+ </label>
+ <select id="agent-scope" class="select select-bordered" bind:value={formScope}>
+ <option value="global">Global</option>
+ {#each dirs.filter((d) => d.scope !== "global") as dir}
+ <option value={dir.scope}>{dir.label} ({dir.path})</option>
+ {/each}
+ </select>
+ </div>
+
+ <!-- Working Directory -->
+ <div class="form-control gap-1">
+ <label class="label py-0" for="agent-cwd">
+ <span class="label-text font-semibold">Working Directory</span>
+ </label>
+ <div class="flex items-center gap-2">
+ <input
+ id="agent-cwd"
+ type="text"
+ class="input input-bordered font-mono text-sm flex-1"
+ placeholder="default (project root)"
+ bind:value={formCwd}
+ />
+ {#if formCwd.trim()}
+ {#if cwdExists === true}
+ <span class="text-success text-lg" title="Directory exists">&#x2714;</span>
+ {:else if cwdExists === false}
+ <span class="text-warning text-lg" title="Directory does not exist (will be created)">&#x2716;</span>
+ {:else}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {/if}
+ </div>
+ {#if cwdExists === false && formCwd.trim()}
+ <span class="text-xs text-warning">Directory will be created automatically on first message.</span>
+ {:else}
+ <span class="text-xs text-base-content/50">Absolute path. Leave empty to use 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>
+ <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/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte
index 8910bb4..21c85fe 100644
--- a/packages/frontend/src/lib/components/ChatInput.svelte
+++ b/packages/frontend/src/lib/components/ChatInput.svelte
@@ -24,13 +24,13 @@ function submit() {
}
</script>
-<div class="flex items-center gap-2 p-3 border-t border-base-300 bg-base-100">
+<div class="flex items-center gap-2 p-3">
<input
bind:this={inputEl}
bind:value={inputValue}
type="text"
placeholder="Type a message..."
- class="input flex-1"
+ class="input input-ghost flex-1"
onkeydown={handleKeydown}
/>
<button
diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte
index 398bee8..308e6ba 100644
--- a/packages/frontend/src/lib/components/ChatMessage.svelte
+++ b/packages/frontend/src/lib/components/ChatMessage.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
-import type { ChatMessage } from "../types.js";
import { appSettings } from "../settings.svelte.js";
+import type { ChatMessage } from "../types.js";
import MarkdownRenderer from "./MarkdownRenderer.svelte";
import ToolCallDisplay from "./ToolCallDisplay.svelte";
diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte
index 24d8c7f..8c92e4f 100644
--- a/packages/frontend/src/lib/components/ChatPanel.svelte
+++ b/packages/frontend/src/lib/components/ChatPanel.svelte
@@ -1,46 +1,46 @@
<script lang="ts">
- import { untrack } from "svelte";
- import { tabStore } from "../tabs.svelte.js";
- import ChatMessageComponent from "./ChatMessage.svelte";
+import { 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 messagesEl: HTMLDivElement | undefined;
+let userScrolledUp = $state(false);
+let isAutoScrolling = false;
- const messages = $derived(tabStore.activeTab?.messages ?? []);
+const messages = $derived(tabStore.activeTab?.messages ?? []);
- function isNearBottom(el: HTMLElement): boolean {
- return el.scrollHeight - el.scrollTop - el.clientHeight < 64;
- }
+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",
- });
- }
+function scrollToBottom(animate = false) {
+ if (!messagesEl) return;
+ messagesEl.scrollTo({
+ top: messagesEl.scrollHeight,
+ behavior: animate ? "smooth" : "instant",
+ });
+}
- function handleScroll() {
- if (!messagesEl || isAutoScrolling) return;
- userScrolledUp = !isNearBottom(messagesEl);
- }
+function handleScroll() {
+ if (!messagesEl || isAutoScrolling) return;
+ userScrolledUp = !isNearBottom(messagesEl);
+}
- function resumeAutoScroll() {
- userScrolledUp = false;
- isAutoScrolling = true;
- scrollToBottom(true);
- }
+function resumeAutoScroll() {
+ userScrolledUp = false;
+ isAutoScrolling = true;
+ scrollToBottom(true);
+}
- $effect(() => {
- const count = messages.length;
- void count;
- if (messagesEl) {
- untrack(() => {
- if (!userScrolledUp) scrollToBottom(false);
- });
- }
- });
+$effect(() => {
+ const count = messages.length;
+ void count;
+ if (messagesEl) {
+ untrack(() => {
+ if (!userScrolledUp) scrollToBottom(false);
+ });
+ }
+});
</script>
<div class="flex flex-col h-full">
@@ -67,12 +67,12 @@
<!-- Scroll-to-bottom button -->
<button
type="button"
- class="absolute bottom-0 left-1/2 -translate-x-1/2 mb-4 btn btn-circle btn-sm shadow-lg transition-opacity duration-200 {userScrolledUp ? 'opacity-100' : 'opacity-0 pointer-events-none'}"
+ class="absolute bottom-0 left-1/2 -translate-x-1/2 mb-4 btn btn-sm px-8 rounded-lg shadow-lg transition-opacity duration-200 {userScrolledUp ? 'opacity-100' : 'opacity-0 pointer-events-none'}"
onclick={resumeAutoScroll}
aria-label="Scroll to bottom"
tabindex={userScrolledUp ? 0 : -1}
>
- ↓
+ &#x25BC;
</button>
</div>
</div>
diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte
index bcefbcf..baddb73 100644
--- a/packages/frontend/src/lib/components/ClaudeReset.svelte
+++ b/packages/frontend/src/lib/components/ClaudeReset.svelte
@@ -1,151 +1,152 @@
<script lang="ts">
- const { apiBase = "" }: { apiBase?: string } = $props();
-
- // Map of hour (0-23) → scheduled wake timestamp (ms)
- let schedule = $state<Record<number, number>>({});
-
- function formatHour(h: number): string {
- const display = h % 12;
- return display === 0 ? "12" : String(display);
- }
-
- function nextOccurrenceAt15(hour: number): number {
- const now = new Date();
- const target = new Date(now);
- target.setHours(hour, 15, 0, 0);
- if (target.getTime() <= Date.now()) {
- target.setDate(target.getDate() + 1);
- }
- return target.getTime();
+const { apiBase = "" }: { apiBase?: string } = $props();
+
+// Map of hour (0-23) → scheduled wake timestamp (ms)
+let schedule = $state<Record<number, number>>({});
+
+function formatHour(h: number): string {
+ const display = h % 12;
+ return display === 0 ? "12" : String(display);
+}
+
+function nextOccurrenceAt15(hour: number): number {
+ const now = new Date();
+ const target = new Date(now);
+ target.setHours(hour, 15, 0, 0);
+ if (target.getTime() <= Date.now()) {
+ target.setDate(target.getDate() + 1);
}
+ return target.getTime();
+}
- async function loadFromServer(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/wake-schedule`);
- if (!res.ok) return;
- const data = (await res.json()) as { schedule: Record<string, number> };
- const parsed: Record<number, number> = {};
- for (const [k, v] of Object.entries(data.schedule)) {
- parsed[Number(k)] = v;
- }
- schedule = parsed;
- } catch {
- // Network error — leave schedule empty
- }
- }
-
- async function parseScheduleResponse(res: Response): Promise<void> {
+async function loadFromServer(): Promise<void> {
+ try {
+ const res = await fetch(`${apiBase}/models/wake-schedule`);
+ if (!res.ok) return;
const data = (await res.json()) as { schedule: Record<string, number> };
const parsed: Record<number, number> = {};
for (const [k, v] of Object.entries(data.schedule)) {
parsed[Number(k)] = v;
}
schedule = parsed;
+ } catch {
+ // Network error — leave schedule empty
}
+}
- async function toggleOnServer(hour: number, ts: number): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ hour, timestamp: ts }),
- });
- if (!res.ok) return;
- await parseScheduleResponse(res);
- } catch {
- // Network error — keep local state
- }
+async function parseScheduleResponse(res: Response): Promise<void> {
+ const data = (await res.json()) as { schedule: Record<string, number> };
+ const parsed: Record<number, number> = {};
+ for (const [k, v] of Object.entries(data.schedule)) {
+ parsed[Number(k)] = v;
}
-
- async function removeFromServer(hour: number): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ hour }),
- });
- if (!res.ok) return;
- await parseScheduleResponse(res);
- } catch {
- // Network error — keep local state
- }
+ schedule = parsed;
+}
+
+async function toggleOnServer(hour: number, ts: number): Promise<void> {
+ try {
+ const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ hour, timestamp: ts }),
+ });
+ if (!res.ok) return;
+ await parseScheduleResponse(res);
+ } catch {
+ // Network error — keep local state
}
-
- function toggleHour(hour: number): void {
- if (schedule[hour] !== undefined) {
- void removeFromServer(hour);
- } else {
- const ts = nextOccurrenceAt15(hour);
- void toggleOnServer(hour, ts);
- }
+}
+
+async function removeFromServer(hour: number): Promise<void> {
+ try {
+ const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ hour }),
+ });
+ if (!res.ok) return;
+ await parseScheduleResponse(res);
+ } catch {
+ // Network error — keep local state
}
-
- $effect(() => {
- void loadFromServer();
- });
-
- // Compute "faded" hours: the 4 hours after each scheduled block
- const fadedHours = $derived((): Set<number> => {
- const result = new Set<number>();
- for (const h of Object.keys(schedule).map(Number)) {
- for (let i = 1; i <= 4; i++) {
- const faded = (h + i) % 24;
- if (schedule[faded] === undefined) {
- result.add(faded);
- }
+}
+
+function toggleHour(hour: number): void {
+ if (schedule[hour] !== undefined) {
+ void removeFromServer(hour);
+ } else {
+ const ts = nextOccurrenceAt15(hour);
+ void toggleOnServer(hour, ts);
+ }
+}
+
+$effect(() => {
+ void loadFromServer();
+});
+
+// Compute "faded" hours: the 4 hours after each scheduled block
+const fadedHours = $derived((): Set<number> => {
+ const result = new Set<number>();
+ for (const h of Object.keys(schedule).map(Number)) {
+ for (let i = 1; i <= 4; i++) {
+ const faded = (h + i) % 24;
+ if (schedule[faded] === undefined) {
+ result.add(faded);
}
}
- return result;
- });
+ }
+ return result;
+});
- const currentHour = $derived(new Date().getHours());
+const currentHour = $derived(new Date().getHours());
- function blockClass(hour: number): string {
- const isScheduled = schedule[hour] !== undefined;
- const isCurrent = hour === currentHour;
- const isFaded = fadedHours().has(hour);
+function blockClass(hour: number): string {
+ const isScheduled = schedule[hour] !== undefined;
+ const isCurrent = hour === currentHour;
+ const isFaded = fadedHours().has(hour);
- let base = "flex items-center justify-center rounded cursor-pointer select-none text-[10px] font-mono transition-colors";
+ let base =
+ "flex items-center justify-center rounded cursor-pointer select-none text-[10px] font-mono transition-colors";
+ if (isScheduled) {
+ 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) {
if (isScheduled) {
- base += " bg-primary text-primary-content";
- } else if (isFaded) {
- base += " bg-primary/25 text-base-content";
+ base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
} else {
- base += " bg-base-300 text-base-content/60 hover:bg-base-content/10";
- }
-
- if (isCurrent) {
- if (isScheduled) {
- base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
- } else {
- base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
- }
+ 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 + 5) % 24;
- }
-
- const scheduledHours = $derived(
- Object.keys(schedule)
- .map(Number)
- .sort((a, b) => (schedule[a] ?? 0) - (schedule[b] ?? 0))
- );
-
- 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
+ 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 + 5) % 24;
+}
+
+const scheduledHours = $derived(
+ Object.keys(schedule)
+ .map(Number)
+ .sort((a, b) => (schedule[a] ?? 0) - (schedule[b] ?? 0)),
+);
+
+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">
diff --git a/packages/frontend/src/lib/components/ConfigPanel.svelte b/packages/frontend/src/lib/components/ConfigPanel.svelte
index 25cbe98..f517d28 100644
--- a/packages/frontend/src/lib/components/ConfigPanel.svelte
+++ b/packages/frontend/src/lib/components/ConfigPanel.svelte
@@ -1,94 +1,101 @@
<script lang="ts">
- interface AgentTemplate {
- name: string;
- description?: string;
- model_tag?: string;
- tools?: string[];
+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;
}
+}
- 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 }));
- }
+$effect(() => {
+ fetchData();
+});
- function isSimpleRule(value: unknown): value is { action: string } {
- return typeof value === "object" && value !== null && "action" in value && Object.keys(value).length === 1;
- }
+const modelCount = $derived(modelsData?.models?.length ?? 0);
+const keyCount = $derived(modelsData?.keys?.length ?? 0);
- function isPatternRule(value: unknown): value is Record<string, { action: string }> {
- return typeof value === "object" && value !== null && !("action" in value);
+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">
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte
index ada5500..06b8ac1 100644
--- a/packages/frontend/src/lib/components/Header.svelte
+++ b/packages/frontend/src/lib/components/Header.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+import { router } from "../router.svelte.js";
import { tabStore } from "../tabs.svelte.js";
import { wsClient } from "../ws.svelte.js";
import ThemeSwitcher from "./ThemeSwitcher.svelte";
@@ -27,7 +28,7 @@ async function handleCopy() {
<header class="navbar bg-base-200 border-b border-base-300 px-4 min-h-14 flex-shrink-0">
<div class="navbar-start">
- <span class="text-xl font-bold tracking-tight">Dispatch</span>
+ <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">
diff --git a/packages/frontend/src/lib/components/HotReloadIndicator.svelte b/packages/frontend/src/lib/components/HotReloadIndicator.svelte
index 3e34d3b..8d37578 100644
--- a/packages/frontend/src/lib/components/HotReloadIndicator.svelte
+++ b/packages/frontend/src/lib/components/HotReloadIndicator.svelte
@@ -1,26 +1,26 @@
<script lang="ts">
- const { active }: { active: boolean } = $props();
+const { active }: { active: boolean } = $props();
- let visible = $state(false);
- let timer: ReturnType<typeof setTimeout> | null = null;
+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;
- }
+$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}
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte
index f5f7d6d..fc85739 100644
--- a/packages/frontend/src/lib/components/KeyUsage.svelte
+++ b/packages/frontend/src/lib/components/KeyUsage.svelte
@@ -1,201 +1,197 @@
<script lang="ts">
- import type { KeyInfo, KeyUsageData, UsageBucket } from "../types.js";
+import type { KeyInfo, KeyUsageData, UsageBucket } from "../types.js";
- const {
- keys = [],
- apiBase = "",
- }: {
- keys?: KeyInfo[];
- apiBase?: string;
- } = $props();
+const {
+ keys = [],
+ apiBase = "",
+}: {
+ keys?: KeyInfo[];
+ apiBase?: string;
+} = $props();
- interface KeyUsageEntry {
- keyId: string;
- provider: string;
- data: KeyUsageData | null;
- error: string | null;
- loading: boolean;
- }
+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>();
+// 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
- };
- });
- }
+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[]>([]);
+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) {
+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, {
- error: e instanceof Error ? e.message : "Failed to fetch",
+ 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,
- );
- }
+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);
- });
+// 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);
+ }
- // Fetch data and set up 90s auto-refresh
- $effect(() => {
- const currentKeys = keys;
- // Fire all fetches in parallel
+ // Refresh every 90s
+ const interval = setInterval(() => {
for (const key of currentKeys) {
+ updateEntry(key.id, { loading: true });
fetchOne(key);
}
+ }, 90_000);
- // Refresh every 90s
- const interval = setInterval(() => {
- for (const key of currentKeys) {
- updateEntry(key.id, { loading: true });
- fetchOne(key);
- }
- }, 90_000);
+ return () => clearInterval(interval);
+});
- 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);
- }
+// 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;
- });
+ }
+ 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 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"),
- );
+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";
- }
+function progressClass(utilization: number): string {
+ if (utilization > 0.8) return "progress-error";
+ if (utilization >= 0.5) return "progress-warning";
+ return "progress-success";
+}
- 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 });
+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 <= 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 (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 (weeks > 0) {
+ return `in ${weeks} week${weeks > 1 ? "s" : ""} (${dateStr})`;
}
- 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",
- });
+ 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) => {
+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(" ");
- }
+ })
+ .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;
+// 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 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;
- }
+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">
diff --git a/packages/frontend/src/lib/components/MarkdownRenderer.svelte b/packages/frontend/src/lib/components/MarkdownRenderer.svelte
index f73140f..0fbf314 100644
--- a/packages/frontend/src/lib/components/MarkdownRenderer.svelte
+++ b/packages/frontend/src/lib/components/MarkdownRenderer.svelte
@@ -1,172 +1,172 @@
<script lang="ts">
- import { Marked } from "marked";
- import { markedHighlight } from "marked-highlight";
- 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 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);
+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",
- };
+// 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;
- }
+function normalizeLang(lang: string): string {
+ const lower = lang.toLowerCase().trim();
+ return ALIASES[lower] ?? lower;
+}
- const loadCache = new Map<string, Promise<boolean>>();
+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)!;
+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)!;
- 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;
- }
- })();
+ 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;
- }
+ loadCache.set(name, promise);
+ return promise;
+}
- function escapeHtml(s: string): string {
- return s
- .replace(/&/g, "&amp;")
- .replace(/</g, "&lt;")
- .replace(/>/g, "&gt;")
- .replace(/"/g, "&quot;")
- .replace(/'/g, "&#39;");
- }
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#39;");
+}
- const md = new Marked(
- markedHighlight({
- emptyLangClass: "hljs",
- langPrefix: "hljs language-",
- async: true,
- async highlight(code: string, lang: string): Promise<string> {
- if (!lang) return escapeHtml(code);
- const name = normalizeLang(lang);
- const loaded = await ensureLanguage(name);
- if (!loaded) return escapeHtml(code);
- try {
- return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
- } catch {
- return escapeHtml(code);
- }
- },
- }),
- {
- gfm: true,
- breaks: true,
+const 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();
+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;
- }
+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;
+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
- }
- })();
- });
+$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">
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte
index 0af5ae6..21364f9 100644
--- a/packages/frontend/src/lib/components/ModelSelector.svelte
+++ b/packages/frontend/src/lib/components/ModelSelector.svelte
@@ -1,10 +1,22 @@
-<script module>
- const modelCache = new Map<string, string[]>();
+<script module lang="ts">
+const modelCache = new Map<string, string[]>();
</script>
<script lang="ts">
import type { KeyInfo } from "../types.js";
import { config } from "../config.js";
+ import { router } from "../router.svelte.js";
+
+ interface AgentInfo {
+ name: string;
+ slug: string;
+ scope: string;
+ description: string;
+ skills: string[];
+ tools: string[];
+ models: Array<{ key_id: string; model_id: string }>;
+ cwd?: string;
+ }
// Moves an element to document.body so modals escape the sidebar's
// transform stacking context and cover the full viewport.
@@ -22,17 +34,25 @@
activeKeyId = null,
activeModelId = null,
reasoningEffort = "max",
+ activeAgentSlug = null,
+ workingDirectory = null,
onKeyChange,
onModelChange,
onReasoningChange,
+ onAgentChange = (_agent: AgentInfo | null) => {},
+ onWorkingDirectoryChange = (_dir: string | null) => {},
}: {
keys?: KeyInfo[];
activeKeyId?: string | null;
activeModelId?: string | null;
reasoningEffort?: string;
+ activeAgentSlug?: 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;
} = $props();
let showKeyModal = $state(false);
@@ -41,6 +61,56 @@
let loadingModels = $state(false);
let modelError = $state<string | null>(null);
+ 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(modeOverride ?? (activeAgentSlug ? "agent" : "manual"));
+ let agents = $state<AgentInfo[]>([]);
+ 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);
@@ -93,35 +163,119 @@
</script>
<div class="bg-base-200 rounded-lg p-3">
- <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>
+ <!-- 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 ?? ""}
+ oninput={(e) => {
+ const val = e.currentTarget.value.trim();
+ onWorkingDirectoryChange(val || null);
+ }}
+ />
+ {#if workingDirectory}
+ {#if cwdExists === true}
+ <span class="text-success text-sm" title="Directory exists">&#x2714;</span>
+ {:else if cwdExists === false}
+ <span class="text-warning text-sm" title="Will be created">&#x2716;</span>
+ {:else}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {/if}
+ </div>
</div>
- <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"}
+ <!-- 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); }}
+ >
+ Manual
+ </button>
+ <button
+ class="btn btn-xs {mode === 'agent' ? 'btn-primary' : 'btn-ghost'}"
+ onclick={() => { modeOverride = "agent"; fetchAgents(); }}
+ >
+ Agent
</button>
</div>
- {#if activeModelId}
+ {#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">Thinking</span>
- <select
- class="select select-bordered select-sm"
- value={reasoningEffort}
- onchange={(e) => onReasoningChange(e.currentTarget.value)}
- >
- <option value="none">Off</option>
- <option value="low">Low</option>
- <option value="medium">Medium</option>
- <option value="high">High</option>
- <option value="max">Max</option>
- </select>
+ <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)}
+ >
+ <option value="none">Off</option>
+ <option value="low">Low</option>
+ <option value="medium">Medium</option>
+ <option value="high">High</option>
+ <option value="max">Max</option>
+ </select>
+ </div>
+ {/if}
+ {: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 agents.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 agents as agent (agent.slug + ":" + agent.scope)}
+ <button
+ class="w-full text-left rounded-lg px-3 py-2 transition-colors {activeAgentSlug === agent.slug ? 'bg-primary text-primary-content' : 'bg-base-300 hover:bg-base-200'}"
+ onclick={() => onAgentChange(agent)}
+ >
+ <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}
+ </button>
+ {/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}
</div>
diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte
index a627de8..1270fcc 100644
--- a/packages/frontend/src/lib/components/ModelStatus.svelte
+++ b/packages/frontend/src/lib/components/ModelStatus.svelte
@@ -1,148 +1,154 @@
<script lang="ts">
- interface KeyInfo {
- id: string;
- provider: string;
- status: "active" | "exhausted";
- lastError: string | null;
- exhaustedAt: number | null;
- }
+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;
- }
+interface CredentialStatus {
+ keyId: string;
+ provider: string;
+ subscriptionType: string | null;
+ importedAt: number;
+ updatedAt: number;
+ expired: boolean;
+}
- const {
- keys = [],
- currentModel,
- apiBase = "",
- }: {
- keys?: KeyInfo[];
- currentModel?: string;
- apiBase?: string;
- } = $props();
+const {
+ keys = [],
+ currentModel,
+ apiBase = "",
+}: {
+ keys?: KeyInfo[];
+ currentModel?: string;
+ apiBase?: string;
+} = $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);
+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 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 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);
- 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 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 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 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 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;
}
+}
- $effect(() => {
- void loadCredentialStatus();
- void loadApiKeyStatus();
- });
+$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 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;
- }
+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">
diff --git a/packages/frontend/src/lib/components/PermissionPrompt.svelte b/packages/frontend/src/lib/components/PermissionPrompt.svelte
index ce7afd7..3a67b30 100644
--- a/packages/frontend/src/lib/components/PermissionPrompt.svelte
+++ b/packages/frontend/src/lib/components/PermissionPrompt.svelte
@@ -1,7 +1,10 @@
<script lang="ts">
import type { PermissionPrompt } from "../types.js";
-const { pending, onReply }: {
+const {
+ pending,
+ onReply,
+}: {
pending: PermissionPrompt[];
onReply: (id: string, reply: "once" | "always" | "reject") => void;
} = $props();
diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte
index 6580308..79574d2 100644
--- a/packages/frontend/src/lib/components/SettingsPanel.svelte
+++ b/packages/frontend/src/lib/components/SettingsPanel.svelte
@@ -1,99 +1,102 @@
<script lang="ts">
- import type { KeyInfo } from "../types.js";
- import { appSettings } from "../settings.svelte.js";
+import { appSettings } from "../settings.svelte.js";
+import type { KeyInfo } from "../types.js";
- const {
- keys = [],
- apiBase = "",
- }: {
- keys?: KeyInfo[];
- apiBase?: string;
- } = $props();
+const {
+ keys = [],
+ apiBase = "",
+}: {
+ keys?: KeyInfo[];
+ apiBase?: string;
+} = $props();
- let titleKeyId = $state<string | null>(null);
- let titleModelId = $state<string | null>(null);
- let availableModels = $state<string[]>([]);
- let loadingModels = $state(false);
- let autoExpandThinking = $state(appSettings.autoExpandThinking);
+let titleKeyId = $state<string | null>(null);
+let titleModelId = $state<string | null>(null);
+let availableModels = $state<string[]>([]);
+let loadingModels = $state(false);
+let autoExpandThinking = $state(appSettings.autoExpandThinking);
- 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;
+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);
}
- } catch {
- // ignore
+ titleModelId = data.modelId;
}
- 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
+ } 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
}
+}
- 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 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 {
+async function loadModelsForKey(keyId: string): Promise<void> {
+ loadingModels = true;
+ try {
+ const res = await fetch(`${apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`);
+ if (!res.ok) {
availableModels = [];
- } finally {
- loadingModels = false;
+ 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(() => {});
- }
+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 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 onModelChange(e: Event): Promise<void> {
+ const select = e.target as HTMLSelectElement;
+ titleModelId = select.value || null;
+ saveTitleModel();
+}
- $effect(() => {
- void loadSettings();
- });
+$effect(() => {
+ void loadSettings();
+});
</script>
<div class="flex flex-col gap-3">
@@ -103,26 +106,30 @@
<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</label>
- <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 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</label>
- <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 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>
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index a0aeb1d..041679a 100644
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -7,7 +7,6 @@ import ModelSelector from "./ModelSelector.svelte";
import ModelStatus from "./ModelStatus.svelte";
import SettingsPanel from "./SettingsPanel.svelte";
import SkillsBrowser from "./SkillsBrowser.svelte";
-import SystemPromptPanel from "./SystemPromptPanel.svelte";
import TaskListPanel from "./TaskListPanel.svelte";
import ToolPermissions from "./ToolPermissions.svelte";
@@ -19,9 +18,13 @@ const {
activeKeyId = null,
activeModelId = null,
reasoningEffort = "max",
+ activeAgentSlug = null as string | null,
+ workingDirectory = null as string | null,
onKeyChange,
onModelChange,
onReasoningChange,
+ onAgentChange = (_agent: any) => {},
+ onWorkingDirectoryChange = (_dir: string | null) => {},
}: {
keys?: KeyInfo[];
tasks?: TaskItem[];
@@ -30,9 +33,13 @@ const {
activeKeyId?: string | null;
activeModelId?: string | null;
reasoningEffort?: string;
+ activeAgentSlug?: string | null;
+ workingDirectory?: string | null;
onKeyChange: (keyId: string) => void;
onModelChange: (keyId: string, modelId: string) => void;
onReasoningChange: (effort: string) => void;
+ onAgentChange?: (agent: any) => void;
+ onWorkingDirectoryChange?: (dir: string | null) => void;
} = $props();
interface Panel {
@@ -41,11 +48,11 @@ interface Panel {
}
let nextId = 0;
-let panels = $state<Panel[]>([{ id: nextId++, selected: "Model Choice" }]);
+let panels = $state<Panel[]>([{ id: nextId++, selected: "Chat Settings" }]);
const viewOptions = [
"Select a view",
- "Model Choice",
+ "Chat Settings",
"Key Usage",
"Claude Reset",
"Model Status",
@@ -53,7 +60,6 @@ const viewOptions = [
"Config",
"Skills",
"Tools",
- "System Prompt",
"Settings",
];
@@ -104,16 +110,20 @@ function contentClass(selected: string): string {
</div>
<div class={contentClass(panel.selected)}>
- {#if panel.selected === "Model Choice"}
- <ModelSelector
- {keys}
- {activeKeyId}
- {activeModelId}
- {reasoningEffort}
- {onKeyChange}
- {onModelChange}
- {onReasoningChange}
- />
+ {#if panel.selected === "Chat Settings"}
+ <ModelSelector
+ {keys}
+ {activeKeyId}
+ {activeModelId}
+ {reasoningEffort}
+ {onKeyChange}
+ {onModelChange}
+ {onReasoningChange}
+ {activeAgentSlug}
+ {onAgentChange}
+ {workingDirectory}
+ {onWorkingDirectoryChange}
+ />
{:else if panel.selected === "Key Usage"}
<KeyUsage {keys} {apiBase} />
{:else if panel.selected === "Claude Reset"}
@@ -128,16 +138,14 @@ function contentClass(selected: string): string {
<SkillsBrowser {apiBase} />
{:else if panel.selected === "Tools"}
<ToolPermissions entries={permissionLog} {apiBase} />
- {:else if panel.selected === "System Prompt"}
- <SystemPromptPanel {apiBase} />
- {:else if panel.selected === "Settings"}
+ {:else if panel.selected === "Settings"}
<SettingsPanel {keys} {apiBase} />
{/if}
</div>
</div>
{/each}
- <button type="button" class="btn btn-sm btn-ghost w-full" onclick={addPanel}>
+ <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
index 685f890..add43e4 100644
--- a/packages/frontend/src/lib/components/SkillsBrowser.svelte
+++ b/packages/frontend/src/lib/components/SkillsBrowser.svelte
@@ -7,7 +7,7 @@ interface Skill {
description: string;
tags: string[];
scope: "global" | "project";
- directory: "default" | "agents" | "project";
+ directory: string;
}
interface SkillsResponse {
@@ -20,7 +20,27 @@ interface SkillDetail extends Skill {
source: string;
}
-const { apiBase }: { apiBase: string } = $props();
+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);
@@ -28,6 +48,7 @@ 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;
@@ -48,23 +69,64 @@ 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 {
- return appSettings.skillChecks[skillKey(skill)] === true;
+ const key = skillKey(skill);
+ if (externalMode) {
+ return checkedSkills!.has(key);
+ }
+ 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) {
@@ -92,7 +154,37 @@ $effect(() => {
fetchSkills();
});
-const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v) => v).length);
+const checkedCount = $derived(
+ externalMode
+ ? checkedSkills!.size
+ : 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">
@@ -102,7 +194,7 @@ const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v)
<span class="badge badge-sm badge-neutral">{skills.length}</span>
{/if}
{#if checkedCount > 0}
- <span class="badge badge-sm badge-primary">{checkedCount} queued</span>
+ <span class="badge badge-sm badge-primary">{checkedCount} {externalMode ? 'selected' : 'queued'}</span>
{/if}
<button
class="btn btn-xs btn-ghost ml-auto"
@@ -113,7 +205,9 @@ const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v)
</button>
</div>
- <p class="text-xs text-base-content/40">Check skills to inject with your next message.</p>
+ {#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">
@@ -127,64 +221,97 @@ const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v)
No skills found. Create <code class="font-mono">.skills/</code> directories to get started.
</p>
{:else}
- <div class="flex flex-col gap-0.5">
- {#each 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>
- <span class="badge badge-xs {skill.scope === 'global' ? 'badge-info' : 'badge-warning'}">{skill.scope}</span>
- {#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>
+ <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>
- {/if}
- </div>
+ </div>
+ {/if}
{/each}
</div>
{/if}
- <button
- class="btn btn-sm btn-ghost w-full"
- disabled={!appSettings.skillChecksDirty}
- onclick={resetChecks}
- >
- Reset
- </button>
+ {#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
index 6b91ebe..d9039f4 100644
--- a/packages/frontend/src/lib/components/SystemPromptPanel.svelte
+++ b/packages/frontend/src/lib/components/SystemPromptPanel.svelte
@@ -1,42 +1,42 @@
<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
+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;
- }
+function resetPrompt(): void {
+ appSettings.systemPrompt = appSettings.savedSystemPrompt;
+}
- const isDirty = $derived(appSettings.systemPrompt !== appSettings.savedSystemPrompt);
+const isDirty = $derived(appSettings.systemPrompt !== appSettings.savedSystemPrompt);
- onMount(() => {
- if (!appSettings.systemPrompt) {
- appSettings.systemPrompt = DEFAULT_PROMPT;
- appSettings.savedSystemPrompt = DEFAULT_PROMPT;
- }
- loadPrompt();
- });
+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">
diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte
index cfa5887..feb1e5b 100644
--- a/packages/frontend/src/lib/components/TabBar.svelte
+++ b/packages/frontend/src/lib/components/TabBar.svelte
@@ -25,12 +25,13 @@ const activeUserTabId = $derived(
<!-- Top row: user tabs -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
- class="overflow-x-auto bg-base-200 flex-shrink-0"
+ 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(); }}
>
@@ -72,7 +73,7 @@ const activeUserTabId = $derived(
<!-- 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">
+ <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"
diff --git a/packages/frontend/src/lib/components/TaskListPanel.svelte b/packages/frontend/src/lib/components/TaskListPanel.svelte
index d70373f..17ade55 100644
--- a/packages/frontend/src/lib/components/TaskListPanel.svelte
+++ b/packages/frontend/src/lib/components/TaskListPanel.svelte
@@ -29,17 +29,6 @@ function isChecked(status: TaskItem["status"]): boolean {
function isIndeterminate(status: TaskItem["status"]): boolean {
return status === "in_progress";
}
-
-function statusLabel(status: TaskItem["status"]): string {
- switch (status) {
- case "pending":
- return "Pending";
- case "in_progress":
- return "In progress";
- case "done":
- return "Done";
- }
-}
</script>
<div class="flex flex-col gap-2">
diff --git a/packages/frontend/src/lib/components/ThemeSwitcher.svelte b/packages/frontend/src/lib/components/ThemeSwitcher.svelte
index fe12cc5..418fcea 100644
--- a/packages/frontend/src/lib/components/ThemeSwitcher.svelte
+++ b/packages/frontend/src/lib/components/ThemeSwitcher.svelte
@@ -1,8 +1,18 @@
<script lang="ts">
const THEMES = [
- "light", "dark", "dracula", "night", "nord", "sunset",
- "cyberpunk", "forest", "cmyk", "coffee", "caramellatte",
- "garden", "luxury",
+ "light",
+ "dark",
+ "dracula",
+ "night",
+ "nord",
+ "sunset",
+ "cyberpunk",
+ "forest",
+ "cmyk",
+ "coffee",
+ "caramellatte",
+ "garden",
+ "luxury",
] as const;
const STORAGE_KEY = "dispatch-theme";
diff --git a/packages/frontend/src/lib/components/ToolPermissions.svelte b/packages/frontend/src/lib/components/ToolPermissions.svelte
index c6af47f..6533c7b 100644
--- a/packages/frontend/src/lib/components/ToolPermissions.svelte
+++ b/packages/frontend/src/lib/components/ToolPermissions.svelte
@@ -3,8 +3,6 @@ import { onMount } from "svelte";
import { appSettings } from "../settings.svelte.js";
import type { LogEntry } from "../types.js";
-const { entries, apiBase = "" }: { entries: LogEntry[]; apiBase?: string } = $props();
-
interface ToolPermission {
id: string;
label: string;
@@ -24,13 +22,30 @@ const toolPermissions: ToolPermission[] = [
label: "Summon agents",
description: "Allow the AI to spawn child agents to work on tasks",
},
- {
- id: "external_directory",
- label: "External directories",
- description: "Allow access to files outside the workspace",
- },
];
+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);
+ return appSettings.toolPerms[id] === true;
+}
+
async function loadPermissions(): Promise<void> {
const loaded: Record<string, boolean> = { ...appSettings.toolPerms };
for (const perm of toolPermissions) {
@@ -51,6 +66,10 @@ async function loadPermissions(): Promise<void> {
}
function togglePermission(id: string): void {
+ if (externalMode) {
+ onToolToggle!(id, !checkedTools!.has(id));
+ return;
+ }
appSettings.toolPerms = { ...appSettings.toolPerms, [id]: !appSettings.toolPerms[id] };
}
@@ -59,13 +78,17 @@ function resetPermissions(): void {
}
onMount(() => {
- loadPermissions();
+ 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>
- <p class="text-xs text-base-content/40">Changes are applied when you send your next message.</p>
+ {#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)}
@@ -73,7 +96,7 @@ onMount(() => {
<input
type="checkbox"
class="checkbox checkbox-sm rounded-sm mt-0.5"
- checked={appSettings.toolPerms[perm.id]}
+ checked={isChecked(perm.id)}
onchange={() => togglePermission(perm.id)}
/>
<div class="flex flex-col">
@@ -84,35 +107,37 @@ onMount(() => {
{/each}
</div>
- <button
- class="btn btn-sm btn-ghost w-full"
- disabled={!appSettings.toolPermsDirty}
- onclick={resetPermissions}
- >
- Reset
- </button>
+ {#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>
+ <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}
+ <!-- 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>
- </div>
+ {/if}
{/if}
</div>