diff options
| author | Adam Malczewski <[email protected]> | 2026-05-22 15:24:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-22 15:24:13 +0900 |
| commit | 9ecaabd87c0e51b8a7408dabb0133a9344586859 (patch) | |
| tree | 4b5809ab23948a5e3f558f3aa34c52d5038f4e19 /packages/frontend/src/lib/components | |
| parent | 8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 (diff) | |
| download | dispatch-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')
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">✔</span> + {:else if cwdExists === false} + <span class="text-warning text-lg" title="Directory does not exist (will be created)">✖</span> + {:else} + <span class="loading loading-spinner loading-xs"></span> + {/if} + {/if} + </div> + {#if cwdExists === false && formCwd.trim()} + <span class="text-xs text-warning">Directory will be created automatically on first message.</span> + {: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} > - ↓ + ▼ </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, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} - const md = new Marked( - markedHighlight({ - emptyLangClass: "hljs", - langPrefix: "hljs language-", - async: true, - async highlight(code: string, lang: string): Promise<string> { - if (!lang) return escapeHtml(code); - const name = normalizeLang(lang); - const loaded = await ensureLanguage(name); - if (!loaded) return escapeHtml(code); - try { - return hljs.highlight(code, { language: name, ignoreIllegals: true }).value; - } catch { - return escapeHtml(code); - } - }, - }), - { - gfm: true, - breaks: true, +const 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">✔</span> + {:else if cwdExists === false} + <span class="text-warning text-sm" title="Will be created">✖</span> + {:else} + <span class="loading loading-spinner loading-xs"></span> + {/if} + {/if} + </div> </div> - <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> |
