From adc8bd185b54935e7a31aae04da3175b7989927a Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 20 May 2026 15:04:26 +0900 Subject: feat: phase 3 — config, skills, model groups, task list, and sidebar UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Config system: TOML-based dispatch.toml with hot-reload via chokidar - Model/key resolution: tag-based model selection, key fallback chains - Skills system: directory loader with TOML frontmatter, agent mappings - Task list tool: add/update/list/get operations with WebSocket events - API routes: GET /config, /skills, /skills/:name, /models, /models/resolve - Frontend: sidebar with model status, task list, config viewer, skills browser, permission log - Sliding sidebar animation using CSS transitions (not Svelte transitions) --- packages/frontend/src/App.svelte | 110 +++++++++- packages/frontend/src/lib/chat.svelte.ts | 21 +- .../frontend/src/lib/components/ConfigPanel.svelte | 244 +++++++++++++++++++++ packages/frontend/src/lib/components/Header.svelte | 10 + .../src/lib/components/HotReloadIndicator.svelte | 35 +++ .../frontend/src/lib/components/ModelStatus.svelte | 135 ++++++++++++ .../src/lib/components/SkillsBrowser.svelte | 234 ++++++++++++++++++++ .../src/lib/components/TaskListPanel.svelte | 72 ++++++ packages/frontend/src/lib/types.ts | 9 + 9 files changed, 863 insertions(+), 7 deletions(-) create mode 100644 packages/frontend/src/lib/components/ConfigPanel.svelte create mode 100644 packages/frontend/src/lib/components/HotReloadIndicator.svelte create mode 100644 packages/frontend/src/lib/components/ModelStatus.svelte create mode 100644 packages/frontend/src/lib/components/SkillsBrowser.svelte create mode 100644 packages/frontend/src/lib/components/TaskListPanel.svelte (limited to 'packages/frontend/src') diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index b980abf..02a80ba 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -5,11 +5,60 @@ import ChatPanel from "./lib/components/ChatPanel.svelte"; import Header from "./lib/components/Header.svelte"; import PermissionPrompt from "./lib/components/PermissionPrompt.svelte"; import PermissionLog from "./lib/components/PermissionLog.svelte"; +import ConfigPanel from "./lib/components/ConfigPanel.svelte"; +import SkillsBrowser from "./lib/components/SkillsBrowser.svelte"; +import TaskListPanel from "./lib/components/TaskListPanel.svelte"; +import ModelStatus from "./lib/components/ModelStatus.svelte"; +import HotReloadIndicator from "./lib/components/HotReloadIndicator.svelte"; import { chatStore } from "./lib/chat.svelte.js"; import { wsClient } from "./lib/ws.svelte.js"; +import { config } from "./lib/config.js"; const STORAGE_KEY = "dispatch-theme"; +interface KeyInfo { + id: string; + provider: string; + status: "active" | "exhausted"; + lastError: string | null; + exhaustedAt: number | null; +} + +interface ModelInfo { + id: string; + provider: string; + tags: string[]; +} + +let modelsData = $state<{ models: ModelInfo[]; keys: KeyInfo[]; tags: string[] }>({ + models: [], + keys: [], + tags: [], +}); + +let sidebarOpen = $state(true); + +async function fetchModels() { + try { + const res = await fetch(`${config.apiBase}/models`); + if (!res.ok) return; + const data = await res.json(); + modelsData = { + models: data.models ?? [], + keys: data.keys ?? [], + tags: data.tags ? (Array.isArray(data.tags) ? data.tags : Object.keys(data.tags)) : [], + }; + } catch { + // ignore fetch errors + } +} + +$effect(() => { + if (chatStore.configReloaded) { + fetchModels(); + } +}); + onMount(() => { // Apply saved theme const saved = localStorage.getItem(STORAGE_KEY); @@ -20,6 +69,9 @@ onMount(() => { // Connect WebSocket wsClient.connect(); + // Initial models fetch + fetchModels(); + return () => { wsClient.disconnect(); }; @@ -27,18 +79,64 @@ onMount(() => {
-
-
- +
sidebarOpen = !sidebarOpen} /> + +
+ +
+
+ +
+ +
+ + +
+
+
+ +
Model Status
+
+ +
+
+ +
+ +
Tasks
+
+ +
+
+ + + + + + +
+
-
+ chatStore.replyPermission(id, reply)} /> -
- + +
+
diff --git a/packages/frontend/src/lib/chat.svelte.ts b/packages/frontend/src/lib/chat.svelte.ts index c0f0a98..9559f20 100644 --- a/packages/frontend/src/lib/chat.svelte.ts +++ b/packages/frontend/src/lib/chat.svelte.ts @@ -1,5 +1,5 @@ import { config } from "./config.js"; -import type { AgentEvent, ChatMessage, ContentSegment, DebugInfo, LogEntry, PermissionPrompt } from "./types.js"; +import type { AgentEvent, ChatMessage, ContentSegment, DebugInfo, LogEntry, PermissionPrompt, TaskItem } from "./types.js"; import { wsClient } from "./ws.svelte.js"; function generateId() { @@ -71,6 +71,8 @@ function createChatStore() { let currentAssistantId: string | null = null; let pendingPermissions: PermissionPrompt[] = $state([]); let permissionLog: LogEntry[] = $state([]); + let tasks: TaskItem[] = $state([]); + let configReloaded = $state(false); wsClient.onEvent((event) => { handleEvent(event); @@ -209,6 +211,17 @@ function createChatStore() { pendingPermissions = event.pending; break; } + case "task-list-update": { + tasks = event.tasks; + break; + } + case "config-reload": { + configReloaded = true; + setTimeout(() => { + configReloaded = false; + }, 2500); + break; + } case "shell-output": { messages = messages.map((m) => { if (m.id === currentAssistantId) { @@ -334,6 +347,12 @@ function createChatStore() { get permissionLog() { return permissionLog; }, + get tasks() { + return tasks; + }, + get configReloaded() { + return configReloaded; + }, sendMessage, handleEvent, replyPermission, diff --git a/packages/frontend/src/lib/components/ConfigPanel.svelte b/packages/frontend/src/lib/components/ConfigPanel.svelte new file mode 100644 index 0000000..25cbe98 --- /dev/null +++ b/packages/frontend/src/lib/components/ConfigPanel.svelte @@ -0,0 +1,244 @@ + + +
+ + Configuration + {#if modelCount > 0 || keyCount > 0} + {modelCount} models + {keyCount} keys + {/if} + {#if loading} + + {/if} + + +
+ {#if error} +
+ Failed to load config: {error} +
+ {/if} + +
+ +
+ + + {#if configData?.agents && Object.keys(configData.agents).length > 0} +
+
Agent Templates
+ {#each Object.entries(configData.agents) as [name, template]} +
+
+ {name} + {#if template.model_tag} + {template.model_tag} + {/if} +
+ {#if template.description} +

{template.description}

+ {/if} + {#if template.tools && template.tools.length > 0} +
+ {#each template.tools as tool} + {tool} + {/each} +
+ {/if} +
+ {/each} +
+ {/if} + + + {#if modelsData?.models && modelsData.models.length > 0} +
+
Models
+ {#each modelsData.models as model} +
+ {model.id} + {#if model.provider} + {model.provider} + {/if} + {#if model.tags && model.tags.length > 0} + {#each model.tags as tag} + {tag} + {/each} + {/if} +
+ {/each} +
+ {/if} + + + {#if modelsData?.keys && modelsData.keys.length > 0} +
+
API Keys
+ {#each modelsData.keys as key} +
+
+ {key.id} + {#if key.provider} + {key.provider} + {/if} + {#if key.status === "exhausted"} + exhausted + {:else} + active + {/if} +
+ {#if key.status === "exhausted" && key.lastError} +

{key.lastError}

+ {/if} + {#if key.exhaustedAt} +

Since {formatDate(key.exhaustedAt)}

+ {/if} +
+ {/each} +
+ {/if} + + + {#if configData?.fallback && configData.fallback.length > 0} +
+
Fallback Order
+
    + {#each configData.fallback as keyId, i} +
  1. + {i + 1} + {keyId} +
  2. + {/each} +
+
+ {/if} + + + {#if configData?.permissions && Object.keys(configData.permissions).length > 0} +
+
Permissions
+ {#each permissionEntries(configData.permissions) as entry} +
+ {entry.name} + {#if isSimpleRule(entry.value)} + {entry.value.action} + {:else if isPatternRule(entry.value)} + {#each Object.entries(entry.value) as [pattern, rule]} +
+ {pattern} + {#if typeof rule === "object" && rule !== null && "action" in rule} + {(rule as { action: string }).action} + {/if} +
+ {/each} + {:else} + {JSON.stringify(entry.value)} + {/if} +
+ {/each} +
+ {/if} + + {#if !loading && !error && !configData && !modelsData} +

No configuration loaded.

+ {/if} +
+
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte index cf466fe..5d14593 100644 --- a/packages/frontend/src/lib/components/Header.svelte +++ b/packages/frontend/src/lib/components/Header.svelte @@ -2,6 +2,8 @@ import { chatStore } from "../chat.svelte.js"; import ThemeSwitcher from "./ThemeSwitcher.svelte"; +const { onToggleSidebar }: { onToggleSidebar: () => void } = $props(); + let showThemeSwitcher = $state(false); let copyLabel = $state("Copy"); @@ -46,6 +48,14 @@ async function handleCopy() { > Theme +
diff --git a/packages/frontend/src/lib/components/HotReloadIndicator.svelte b/packages/frontend/src/lib/components/HotReloadIndicator.svelte new file mode 100644 index 0000000..3e34d3b --- /dev/null +++ b/packages/frontend/src/lib/components/HotReloadIndicator.svelte @@ -0,0 +1,35 @@ + + +{#if visible} +
+ + Config reloaded +
+{/if} diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte new file mode 100644 index 0000000..34a0563 --- /dev/null +++ b/packages/frontend/src/lib/components/ModelStatus.svelte @@ -0,0 +1,135 @@ + + +
+ {#if models.length === 0 && keys.length === 0} +

+ No models configured. Using environment defaults. +

+ {:else} + + {#if allActive} +
+ + All keys available +
+ {:else if allExhausted} +
+ + All keys exhausted — waiting for refresh +
+ {:else if someExhausted} +
+ + + Fallback active ({activeKeys}/{totalKeys} keys available) + +
+ {/if} + + + {#if currentModel} +
+

Current Model

+

{currentModel}

+
+ {/if} + + + {#if uniqueTags.length > 0} +
+

Tags

+
+ {#each uniqueTags as tag (tag)} + {tag} + {/each} +
+
+ {/if} + + + {#if keys.length > 0} +
+

API Keys

+
    + {#each keys as key (key.id)} +
  • +
    + + {key.status} + + {key.id} + {key.provider} +
    + {#if key.status === "exhausted"} +
    + {#if key.lastError} +

    + {truncate(key.lastError, 80)} +

    + {/if} + {#if key.exhaustedAt !== null} +

    {timeAgo(key.exhaustedAt)}

    + {/if} +
    + {/if} +
  • + {/each} +
+
+ {/if} + {/if} +
diff --git a/packages/frontend/src/lib/components/SkillsBrowser.svelte b/packages/frontend/src/lib/components/SkillsBrowser.svelte new file mode 100644 index 0000000..be1ad29 --- /dev/null +++ b/packages/frontend/src/lib/components/SkillsBrowser.svelte @@ -0,0 +1,234 @@ + + +
+ + Skills + {#if !loading} + {skills.length} + {/if} + + +
+ {#if loading} +
+ + Loading skills... +
+ {:else if error} +
{error}
+ {:else if skills.length === 0} +

+ No skills found. Create a .skills/default/ directory to get started. +

+ {:else} + {#snippet skillItem(skill: Skill)} + {@const key = `${skill.scope}:${skill.name}`} + {@const isExpanded = key in expandedSkills} + {@const detail = expandedSkills[key]} + {@const isLoading = loadingSkill[key]} +
+
+ + {#if isLoading} + + {/if} + {#each skill.tags as tag} + {tag} + {/each} +
+ {#if skill.description} +

{skill.description}

+ {/if} + {#if isExpanded} +
+ {#if detail} +
{detail.content}
+ {:else} +

Failed to load skill content.

+ {/if} + +
+ {/if} +
+ {/snippet} + + {#snippet scopeSection(label: string, scopeSkills: Skill[], scope: string)} + {#if scopeSkills.length > 0} + {@const defaultSkills = skillsByDirectory(scopeSkills, "default")} + {@const agentSkills = skillsByDirectory(scopeSkills, "agents")} + {@const projectDirSkills = skillsByDirectory(scopeSkills, "project")} + {@const scopeMappings = getMappingsForScope(scope)} +
+
+ {label} + {scope} +
+ + {#if defaultSkills.length > 0} +
+
default/
+
+ {#each defaultSkills as skill} + {@render skillItem(skill)} + {/each} +
+
+ {/if} + + {#if agentSkills.length > 0 || scopeMappings.length > 0} +
+
agents/
+
+ {#if scopeMappings.length > 0} + {#each scopeMappings as mapping} +
+
+ {mapping.agentType} + {#if mapping.isOrchestrator} + (orchestrator) + {/if} + + {#each mapping.skills as skillName} + {@const mappedSkill = agentSkills.find((s) => s.name === skillName)} + {#if mappedSkill} + {@render skillItem(mappedSkill)} + {:else} + {skillName} + {/if} + {/each} +
+
+ {/each} + {:else} + {#each agentSkills as skill} + {@render skillItem(skill)} + {/each} + {/if} +
+
+ {/if} + + {#if projectDirSkills.length > 0} +
+
project/
+
+ {#each projectDirSkills as skill} + {@render skillItem(skill)} + {/each} +
+
+ {/if} +
+ {/if} + {/snippet} + + {@render scopeSection("Global", globalSkills, "global")} + {@render scopeSection("Project", projectSkills, "project")} + {/if} +
+
diff --git a/packages/frontend/src/lib/components/TaskListPanel.svelte b/packages/frontend/src/lib/components/TaskListPanel.svelte new file mode 100644 index 0000000..5f2ffe2 --- /dev/null +++ b/packages/frontend/src/lib/components/TaskListPanel.svelte @@ -0,0 +1,72 @@ + + +
+ {#if tasks.length === 0} +

No tasks yet.

+ {:else} +

+ {tasks.length} task{tasks.length !== 1 ? "s" : ""} + ({doneCount} done, {inProgressCount} in progress) +

+
    + {#each tasks as task (task.id)} +
  • +
    + + {statusIcon(task.status)} + + + {task.title} + +
    + {#if task.description} +

    {task.description}

    + {/if} +

    {task.id}

    +
  • + {/each} +
+ {/if} +
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 3626bbe..fcf6f92 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -52,6 +52,8 @@ export type AgentEvent = toolResult: { toolCallId: string; result: string; isError: boolean }; } | { type: "error"; error: string } + | { type: "task-list-update"; tasks: TaskItem[] } + | { type: "config-reload" } | { type: "done"; message: { @@ -64,6 +66,13 @@ export type AgentEvent = | { type: "permission-prompt"; pending: PermissionPrompt[] } | { type: "shell-output"; data: string; stream: "stdout" | "stderr" }; +export interface TaskItem { + id: string; + title: string; + description: string; + status: "pending" | "in_progress" | "done" | "blocked"; +} + export interface PermissionPrompt { id: string; permission: string; -- cgit v1.2.3