summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components/ModelSelector.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
committerAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
commit9ecaabd87c0e51b8a7408dabb0133a9344586859 (patch)
tree4b5809ab23948a5e3f558f3aa34c52d5038f4e19 /packages/frontend/src/lib/components/ModelSelector.svelte
parent8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 (diff)
downloaddispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.tar.gz
dispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.zip
feat: agent builder, CWD support, auto-save, UI polish, unavailable tool handling
- Agent Builder: full CRUD with card grid, drag-and-drop model reorder, edit/delete - Auto-save on edit with 600ms debounce, AbortController for concurrency, fieldset disabled until name entered - Agent definitions stored as TOML with cwd field, loaded from global/project dirs - Working directory: per-tab CWD override in Chat Settings, agent default CWD, auto-create on first message - CWD validation: check-dir endpoint with ~ expansion, real-time validity indicator - Subagent CWD validated against parent's effective CWD using path.relative - Unavailable tool calls: caught gracefully, shown as tool call with error badge, model retries - UI: tab bar border radius, sidebar border removed, chat input ghost style, scroll-to-bottom rectangle - Skills dir collapse uses CSS rotation, Model Choice renamed to Chat Settings, System Prompt view removed - Reusable SkillsBrowser/ToolPermissions with external mode for Agent Builder - ModelSelector: Agent/Manual toggle, agent list, Agent Settings link - Page router, skills recursive scanning, bin/up gopass removed, docker volume mounts
Diffstat (limited to 'packages/frontend/src/lib/components/ModelSelector.svelte')
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte202
1 files changed, 178 insertions, 24 deletions
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte
index 0af5ae6..21364f9 100644
--- a/packages/frontend/src/lib/components/ModelSelector.svelte
+++ b/packages/frontend/src/lib/components/ModelSelector.svelte
@@ -1,10 +1,22 @@
-<script module>
- const modelCache = new Map<string, string[]>();
+<script module lang="ts">
+const modelCache = new Map<string, string[]>();
</script>
<script lang="ts">
import type { KeyInfo } from "../types.js";
import { config } from "../config.js";
+ import { router } from "../router.svelte.js";
+
+ interface AgentInfo {
+ name: string;
+ slug: string;
+ scope: string;
+ description: string;
+ skills: string[];
+ tools: string[];
+ models: Array<{ key_id: string; model_id: string }>;
+ cwd?: string;
+ }
// Moves an element to document.body so modals escape the sidebar's
// transform stacking context and cover the full viewport.
@@ -22,17 +34,25 @@
activeKeyId = null,
activeModelId = null,
reasoningEffort = "max",
+ activeAgentSlug = null,
+ workingDirectory = null,
onKeyChange,
onModelChange,
onReasoningChange,
+ onAgentChange = (_agent: AgentInfo | null) => {},
+ onWorkingDirectoryChange = (_dir: string | null) => {},
}: {
keys?: KeyInfo[];
activeKeyId?: string | null;
activeModelId?: string | null;
reasoningEffort?: string;
+ activeAgentSlug?: string | null;
+ workingDirectory?: string | null;
onKeyChange: (keyId: string) => void;
onModelChange: (keyId: string, modelId: string) => void;
onReasoningChange: (effort: string) => void;
+ onAgentChange?: (agent: AgentInfo | null) => void;
+ onWorkingDirectoryChange?: (dir: string | null) => void;
} = $props();
let showKeyModal = $state(false);
@@ -41,6 +61,56 @@
let loadingModels = $state(false);
let modelError = $state<string | null>(null);
+ let cwdExists = $state<boolean | null>(null);
+ let cwdCheckTimer: ReturnType<typeof setTimeout> | null = null;
+
+ $effect(() => {
+ const cwd = workingDirectory;
+ if (!cwd) {
+ cwdExists = null;
+ return;
+ }
+ cwdExists = null;
+ if (cwdCheckTimer) clearTimeout(cwdCheckTimer);
+ cwdCheckTimer = setTimeout(async () => {
+ try {
+ const res = await fetch(
+ `${config.apiBase}/agents/check-dir?path=${encodeURIComponent(cwd)}`,
+ );
+ if (res.ok) {
+ const data = await res.json();
+ cwdExists = data.exists ?? false;
+ }
+ } catch {
+ cwdExists = null;
+ }
+ }, 300);
+ });
+
+ let modeOverride = $state<"manual" | "agent" | null>(null);
+ let mode = $derived(modeOverride ?? (activeAgentSlug ? "agent" : "manual"));
+ let agents = $state<AgentInfo[]>([]);
+ let loadingAgents = $state(false);
+
+ $effect(() => {
+ fetchAgents();
+ });
+
+ async function fetchAgents() {
+ loadingAgents = true;
+ try {
+ const res = await fetch(`${config.apiBase}/agents`);
+ if (res.ok) {
+ const data = await res.json();
+ agents = data.agents ?? [];
+ }
+ } catch {
+ /* ignore */
+ } finally {
+ loadingAgents = false;
+ }
+ }
+
function selectKey(keyId: string) {
showKeyModal = false;
onKeyChange(keyId);
@@ -93,35 +163,119 @@
</script>
<div class="bg-base-200 rounded-lg p-3">
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">Key</span>
- <button class="btn btn-sm btn-outline" onclick={() => (showKeyModal = true)}>
- {activeKeyId ?? "Select Key"}
- </button>
+ <!-- Working Directory -->
+ <div class="form-control mb-3">
+ <label class="label py-0" for="cwd-input">
+ <span class="label-text text-xs font-semibold">Working Directory</span>
+ </label>
+ <div class="flex items-center gap-1.5 mt-1">
+ <input
+ id="cwd-input"
+ type="text"
+ class="input input-bordered input-sm font-mono text-xs flex-1"
+ placeholder="default (project root)"
+ value={workingDirectory ?? ""}
+ oninput={(e) => {
+ const val = e.currentTarget.value.trim();
+ onWorkingDirectoryChange(val || null);
+ }}
+ />
+ {#if workingDirectory}
+ {#if cwdExists === true}
+ <span class="text-success text-sm" title="Directory exists">&#x2714;</span>
+ {:else if cwdExists === false}
+ <span class="text-warning text-sm" title="Will be created">&#x2716;</span>
+ {:else}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {/if}
+ </div>
</div>
- <div class="flex items-center justify-between mt-2">
- <span class="text-sm font-medium">Model</span>
- <button class="btn btn-sm btn-outline" onclick={() => openModelModal()} disabled={!activeKeyId}>
- {activeModelId ?? "Select Model"}
+ <!-- Toggle -->
+ <div class="flex items-center gap-2 mb-3">
+ <button
+ class="btn btn-xs {mode === 'manual' ? 'btn-primary' : 'btn-ghost'}"
+ onclick={() => { modeOverride = "manual"; onAgentChange(null); }}
+ >
+ Manual
+ </button>
+ <button
+ class="btn btn-xs {mode === 'agent' ? 'btn-primary' : 'btn-ghost'}"
+ onclick={() => { modeOverride = "agent"; fetchAgents(); }}
+ >
+ Agent
</button>
</div>
- {#if activeModelId}
+ {#if mode === "manual"}
+ <div class="flex items-center justify-between">
+ <span class="text-sm font-medium">Key</span>
+ <button class="btn btn-sm btn-outline" onclick={() => (showKeyModal = true)}>
+ {activeKeyId ?? "Select Key"}
+ </button>
+ </div>
+
<div class="flex items-center justify-between mt-2">
- <span class="text-sm font-medium">Thinking</span>
- <select
- class="select select-bordered select-sm"
- value={reasoningEffort}
- onchange={(e) => onReasoningChange(e.currentTarget.value)}
- >
- <option value="none">Off</option>
- <option value="low">Low</option>
- <option value="medium">Medium</option>
- <option value="high">High</option>
- <option value="max">Max</option>
- </select>
+ <span class="text-sm font-medium">Model</span>
+ <button class="btn btn-sm btn-outline" onclick={() => openModelModal()} disabled={!activeKeyId}>
+ {activeModelId ?? "Select Model"}
+ </button>
</div>
+
+ {#if activeModelId}
+ <div class="flex items-center justify-between mt-2">
+ <span class="text-sm font-medium">Thinking</span>
+ <select
+ class="select select-bordered select-sm"
+ value={reasoningEffort}
+ onchange={(e) => onReasoningChange(e.currentTarget.value)}
+ >
+ <option value="none">Off</option>
+ <option value="low">Low</option>
+ <option value="medium">Medium</option>
+ <option value="high">High</option>
+ <option value="max">Max</option>
+ </select>
+ </div>
+ {/if}
+ {:else}
+ <!-- Agent selection UI -->
+ {#if loadingAgents}
+ <div class="flex items-center gap-2 py-2 text-base-content/60">
+ <span class="loading loading-spinner loading-xs"></span>
+ Loading agents...
+ </div>
+ {:else if agents.length === 0}
+ <p class="text-base-content/50 text-sm py-2">No agents configured.</p>
+ {:else}
+ <div class="flex flex-col gap-1.5">
+ {#each agents as agent (agent.slug + ":" + agent.scope)}
+ <button
+ class="w-full text-left rounded-lg px-3 py-2 transition-colors {activeAgentSlug === agent.slug ? 'bg-primary text-primary-content' : 'bg-base-300 hover:bg-base-200'}"
+ onclick={() => onAgentChange(agent)}
+ >
+ <div class="flex items-center justify-between gap-2">
+ <span class="font-medium text-sm">{agent.name}</span>
+ <div class="flex gap-1 shrink-0">
+ <span class="badge badge-xs">{agent.models.length} model{agent.models.length !== 1 ? 's' : ''}</span>
+ <span class="badge badge-xs badge-outline">{agent.scope === "global" ? "global" : "project"}</span>
+ </div>
+ </div>
+ {#if agent.description}
+ <p class="text-xs opacity-60 mt-0.5">{agent.description}</p>
+ {/if}
+ </button>
+ {/each}
+ </div>
+ {/if}
+ <button
+ type="button"
+ class="btn btn-outline btn-sm w-full mt-2 hover:bg-base-300 hover:border-base-300 text-base-content/60"
+ onclick={() => router.navigate("agent-builder")}
+ >
+ Agent Settings
+ </button>
{/if}
</div>