summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 15:04:26 +0900
committerAdam Malczewski <[email protected]>2026-05-20 15:04:26 +0900
commitadc8bd185b54935e7a31aae04da3175b7989927a (patch)
tree031fa53009a4708ce0cc15c0e3dcb2f2a73cf2d9 /packages/frontend/src/lib
parent52af4ca33b1f83b8f863a6267d447944f55e729d (diff)
downloaddispatch-adc8bd185b54935e7a31aae04da3175b7989927a.tar.gz
dispatch-adc8bd185b54935e7a31aae04da3175b7989927a.zip
feat: phase 3 — config, skills, model groups, task list, and sidebar UI
- 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)
Diffstat (limited to 'packages/frontend/src/lib')
-rw-r--r--packages/frontend/src/lib/chat.svelte.ts21
-rw-r--r--packages/frontend/src/lib/components/ConfigPanel.svelte244
-rw-r--r--packages/frontend/src/lib/components/Header.svelte10
-rw-r--r--packages/frontend/src/lib/components/HotReloadIndicator.svelte35
-rw-r--r--packages/frontend/src/lib/components/ModelStatus.svelte135
-rw-r--r--packages/frontend/src/lib/components/SkillsBrowser.svelte234
-rw-r--r--packages/frontend/src/lib/components/TaskListPanel.svelte72
-rw-r--r--packages/frontend/src/lib/types.ts9
8 files changed, 759 insertions, 1 deletions
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 @@
+<script lang="ts">
+ interface AgentTemplate {
+ name: string;
+ description?: string;
+ model_tag?: string;
+ tools?: string[];
+ }
+
+ interface ModelEntry {
+ id: string;
+ provider?: string;
+ tags?: string[];
+ }
+
+ interface KeyEntry {
+ id: string;
+ provider?: string;
+ status?: string;
+ lastError?: string;
+ exhaustedAt?: string;
+ }
+
+ interface ConfigData {
+ agents?: Record<string, AgentTemplate>;
+ models?: Record<string, { provider?: string; tags?: string[] }>;
+ keys?: Record<string, { env?: string }>;
+ fallback?: string[];
+ permissions?: Record<string, unknown>;
+ }
+
+ interface ModelsData {
+ models?: ModelEntry[];
+ tags?: Record<string, string[]>;
+ keys?: KeyEntry[];
+ }
+
+ const { apiBase }: { apiBase: string } = $props();
+
+ let configData = $state<ConfigData | null>(null);
+ let modelsData = $state<ModelsData | null>(null);
+ let error = $state<string | null>(null);
+ let loading = $state(false);
+
+ async function fetchData() {
+ loading = true;
+ error = null;
+ try {
+ const [configRes, modelsRes] = await Promise.all([
+ fetch(`${apiBase}/config`),
+ fetch(`${apiBase}/models`),
+ ]);
+ if (!configRes.ok) throw new Error(`/config returned ${configRes.status}`);
+ if (!modelsRes.ok) throw new Error(`/models returned ${modelsRes.status}`);
+ const configJson = await configRes.json();
+ const modelsJson = await modelsRes.json();
+ configData = configJson.config ?? configJson;
+ modelsData = modelsJson;
+ } catch (e) {
+ error = e instanceof Error ? e.message : String(e);
+ } finally {
+ loading = false;
+ }
+ }
+
+ $effect(() => {
+ fetchData();
+ });
+
+ const modelCount = $derived(modelsData?.models?.length ?? 0);
+ const keyCount = $derived(modelsData?.keys?.length ?? 0);
+
+ function formatDate(iso: string | undefined): string {
+ if (!iso) return "";
+ try {
+ return new Date(iso).toLocaleString();
+ } catch {
+ return iso;
+ }
+ }
+
+ function permissionEntries(permissions: Record<string, unknown>): Array<{ name: string; value: unknown }> {
+ return Object.entries(permissions).map(([name, value]) => ({ name, value }));
+ }
+
+ function isSimpleRule(value: unknown): value is { action: string } {
+ return typeof value === "object" && value !== null && "action" in value && Object.keys(value).length === 1;
+ }
+
+ function isPatternRule(value: unknown): value is Record<string, { action: string }> {
+ return typeof value === "object" && value !== null && !("action" in value);
+ }
+</script>
+
+<details class="collapse collapse-arrow bg-base-200 mt-2">
+ <summary class="collapse-title text-sm font-medium flex items-center gap-2">
+ <span>Configuration</span>
+ {#if modelCount > 0 || keyCount > 0}
+ <span class="badge badge-sm badge-neutral">{modelCount} models</span>
+ <span class="badge badge-sm badge-neutral">{keyCount} keys</span>
+ {/if}
+ {#if loading}
+ <span class="loading loading-spinner loading-xs ml-auto"></span>
+ {/if}
+ </summary>
+
+ <div class="collapse-content text-xs">
+ {#if error}
+ <div class="alert alert-error alert-sm py-1 mb-2 text-xs">
+ <span>Failed to load config: {error}</span>
+ </div>
+ {/if}
+
+ <div class="flex justify-end mb-2">
+ <button
+ type="button"
+ class="btn btn-xs btn-ghost"
+ onclick={fetchData}
+ disabled={loading}
+ >
+ {loading ? "Loading…" : "Refresh"}
+ </button>
+ </div>
+
+ <!-- Agent Templates -->
+ {#if configData?.agents && Object.keys(configData.agents).length > 0}
+ <div class="mb-3">
+ <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Agent Templates</div>
+ {#each Object.entries(configData.agents) as [name, template]}
+ <div class="bg-base-100 rounded p-2 mb-1">
+ <div class="flex items-center gap-2 flex-wrap">
+ <span class="font-medium">{name}</span>
+ {#if template.model_tag}
+ <span class="badge badge-xs badge-info">{template.model_tag}</span>
+ {/if}
+ </div>
+ {#if template.description}
+ <p class="text-base-content/60 mt-0.5">{template.description}</p>
+ {/if}
+ {#if template.tools && template.tools.length > 0}
+ <div class="flex flex-wrap gap-1 mt-1">
+ {#each template.tools as tool}
+ <span class="badge badge-xs badge-ghost">{tool}</span>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- Models -->
+ {#if modelsData?.models && modelsData.models.length > 0}
+ <div class="mb-3">
+ <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Models</div>
+ {#each modelsData.models as model}
+ <div class="flex items-center gap-2 flex-wrap py-0.5 border-b border-base-300">
+ <span class="font-mono">{model.id}</span>
+ {#if model.provider}
+ <span class="badge badge-xs badge-primary">{model.provider}</span>
+ {/if}
+ {#if model.tags && model.tags.length > 0}
+ {#each model.tags as tag}
+ <span class="badge badge-xs badge-ghost">{tag}</span>
+ {/each}
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- Keys -->
+ {#if modelsData?.keys && modelsData.keys.length > 0}
+ <div class="mb-3">
+ <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">API Keys</div>
+ {#each modelsData.keys as key}
+ <div class="bg-base-100 rounded p-1.5 mb-1">
+ <div class="flex items-center gap-2 flex-wrap">
+ <span class="font-mono">{key.id}</span>
+ {#if key.provider}
+ <span class="badge badge-xs badge-secondary">{key.provider}</span>
+ {/if}
+ {#if key.status === "exhausted"}
+ <span class="badge badge-xs badge-error">exhausted</span>
+ {:else}
+ <span class="badge badge-xs badge-success">active</span>
+ {/if}
+ </div>
+ {#if key.status === "exhausted" && key.lastError}
+ <p class="text-error/80 mt-0.5 truncate">{key.lastError}</p>
+ {/if}
+ {#if key.exhaustedAt}
+ <p class="text-base-content/40 mt-0.5">Since {formatDate(key.exhaustedAt)}</p>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- Fallback Order -->
+ {#if configData?.fallback && configData.fallback.length > 0}
+ <div class="mb-3">
+ <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Fallback Order</div>
+ <ol class="list-none space-y-0.5">
+ {#each configData.fallback as keyId, i}
+ <li class="flex items-center gap-2">
+ <span class="badge badge-xs badge-outline">{i + 1}</span>
+ <span class="font-mono">{keyId}</span>
+ </li>
+ {/each}
+ </ol>
+ </div>
+ {/if}
+
+ <!-- Permissions -->
+ {#if configData?.permissions && Object.keys(configData.permissions).length > 0}
+ <div class="mb-1">
+ <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Permissions</div>
+ {#each permissionEntries(configData.permissions) as entry}
+ <div class="py-0.5 border-b border-base-300">
+ <span class="font-medium text-base-content/80">{entry.name}</span>
+ {#if isSimpleRule(entry.value)}
+ <span class="ml-2 badge badge-xs {entry.value.action === 'allow' ? 'badge-success' : 'badge-error'}">{entry.value.action}</span>
+ {:else if isPatternRule(entry.value)}
+ {#each Object.entries(entry.value) as [pattern, rule]}
+ <div class="pl-2 flex items-center gap-2">
+ <span class="text-base-content/50 font-mono truncate max-w-32">{pattern}</span>
+ {#if typeof rule === "object" && rule !== null && "action" in rule}
+ <span class="badge badge-xs {(rule as { action: string }).action === 'allow' ? 'badge-success' : 'badge-error'}">{(rule as { action: string }).action}</span>
+ {/if}
+ </div>
+ {/each}
+ {:else}
+ <span class="text-base-content/40 ml-2">{JSON.stringify(entry.value)}</span>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ {#if !loading && !error && !configData && !modelsData}
+ <p class="text-base-content/40 italic">No configuration loaded.</p>
+ {/if}
+ </div>
+</details>
diff --git a/packages/frontend/src/lib/components/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
</button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ onclick={onToggleSidebar}
+ aria-label="Toggle sidebar"
+ >
+ ☰ Sidebar
+ </button>
</div>
</header>
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 @@
+<script lang="ts">
+ const { active }: { active: boolean } = $props();
+
+ let visible = $state(false);
+ let timer: ReturnType<typeof setTimeout> | null = null;
+
+ $effect(() => {
+ if (active) {
+ visible = true;
+ if (timer !== null) clearTimeout(timer);
+ timer = setTimeout(() => {
+ visible = false;
+ timer = null;
+ }, 2000);
+ }
+ return () => {
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ visible = false;
+ };
+ });
+</script>
+
+{#if visible}
+ <div
+ class="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-info/20 text-info text-xs font-medium animate-pulse"
+ role="status"
+ aria-live="polite"
+ >
+ <span class="status status-xs status-info"></span>
+ <span>Config reloaded</span>
+ </div>
+{/if}
diff --git a/packages/frontend/src/lib/components/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 @@
+<script lang="ts">
+ interface KeyInfo {
+ id: string;
+ provider: string;
+ status: "active" | "exhausted";
+ lastError: string | null;
+ exhaustedAt: number | null;
+ }
+
+ interface ModelInfo {
+ id: string;
+ provider: string;
+ tags: string[];
+ }
+
+ const {
+ models = [],
+ keys = [],
+ tags = [],
+ currentModel,
+ }: {
+ models?: ModelInfo[];
+ keys?: KeyInfo[];
+ tags?: string[];
+ currentModel?: 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 uniqueTags = $derived([...new Set(tags)]);
+
+ function timeAgo(ts: number | null): string {
+ if (ts === null) return "";
+ const diffMs = Date.now() - ts;
+ const diffSec = Math.floor(diffMs / 1000);
+ if (diffSec < 60) return `${diffSec}s ago`;
+ const diffMin = Math.floor(diffSec / 60);
+ if (diffMin < 60) return `${diffMin}m ago`;
+ const diffHr = Math.floor(diffMin / 60);
+ return `${diffHr}h ago`;
+ }
+
+ function truncate(str: string | null, max: number): string {
+ if (!str) return "";
+ return str.length > max ? str.slice(0, max) + "…" : str;
+ }
+</script>
+
+<div class="flex flex-col gap-3">
+ {#if models.length === 0 && keys.length === 0}
+ <p class="text-xs text-base-content/50">
+ No models configured. Using environment defaults.
+ </p>
+ {:else}
+ <!-- Overall status -->
+ {#if allActive}
+ <div class="flex items-center gap-1.5">
+ <span class="badge badge-success badge-xs">●</span>
+ <span class="text-xs text-success">All keys available</span>
+ </div>
+ {:else if allExhausted}
+ <div class="flex items-center gap-1.5">
+ <span class="badge badge-error badge-xs">●</span>
+ <span class="text-xs text-error">All keys exhausted — waiting for refresh</span>
+ </div>
+ {:else if someExhausted}
+ <div class="flex items-center gap-1.5">
+ <span class="badge badge-warning badge-xs">●</span>
+ <span class="text-xs text-warning">
+ Fallback active ({activeKeys}/{totalKeys} keys available)
+ </span>
+ </div>
+ {/if}
+
+ <!-- Current model -->
+ {#if currentModel}
+ <div class="flex flex-col gap-0.5">
+ <p class="text-xs text-base-content/50 uppercase tracking-wide">Current Model</p>
+ <p class="text-sm font-mono font-semibold text-primary">{currentModel}</p>
+ </div>
+ {/if}
+
+ <!-- Tags -->
+ {#if uniqueTags.length > 0}
+ <div class="flex flex-col gap-1">
+ <p class="text-xs text-base-content/50 uppercase tracking-wide">Tags</p>
+ <div class="flex flex-wrap gap-1">
+ {#each uniqueTags as tag (tag)}
+ <span class="badge badge-outline badge-xs">{tag}</span>
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ <!-- Keys -->
+ {#if keys.length > 0}
+ <div class="flex flex-col gap-1">
+ <p class="text-xs text-base-content/50 uppercase tracking-wide">API Keys</p>
+ <ul class="flex flex-col gap-1">
+ {#each keys as key (key.id)}
+ <li class="flex flex-col gap-0.5 rounded p-1 hover:bg-base-200 transition-colors">
+ <div class="flex items-center gap-1.5">
+ <span
+ class="badge badge-xs {key.status === 'active'
+ ? 'badge-success'
+ : 'badge-error'}"
+ >
+ {key.status}
+ </span>
+ <span class="text-xs font-mono">{key.id}</span>
+ <span class="text-xs text-base-content/40">{key.provider}</span>
+ </div>
+ {#if key.status === "exhausted"}
+ <div class="pl-2 flex flex-col gap-0.5">
+ {#if key.lastError}
+ <p class="text-xs text-error/70 line-clamp-1">
+ {truncate(key.lastError, 80)}
+ </p>
+ {/if}
+ {#if key.exhaustedAt !== null}
+ <p class="text-xs text-base-content/40">{timeAgo(key.exhaustedAt)}</p>
+ {/if}
+ </div>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+ </div>
+ {/if}
+ {/if}
+</div>
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 @@
+<script lang="ts">
+interface Skill {
+ name: string;
+ description: string;
+ tags: string[];
+ scope: "global" | "project";
+ directory: "default" | "agents" | "project";
+}
+
+interface SkillMapping {
+ agentType: string;
+ isOrchestrator: boolean;
+ skills: string[];
+ scope: string;
+}
+
+interface SkillsResponse {
+ skills: Skill[];
+ mappings: SkillMapping[];
+}
+
+interface SkillDetail extends Skill {
+ content: string;
+ source: string;
+}
+
+const { apiBase }: { apiBase: string } = $props();
+
+let skills = $state<Skill[]>([]);
+let mappings = $state<SkillMapping[]>([]);
+let loading = $state(false);
+let error = $state<string | null>(null);
+let expandedSkills = $state<Record<string, SkillDetail | null>>({});
+let loadingSkill = $state<Record<string, boolean>>({});
+
+async function fetchSkills() {
+ loading = true;
+ error = null;
+ try {
+ const res = await fetch(`${apiBase}/skills`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data: SkillsResponse = await res.json();
+ skills = data.skills ?? [];
+ mappings = data.mappings ?? [];
+ } catch (e) {
+ error = e instanceof Error ? e.message : "Failed to fetch skills";
+ } finally {
+ loading = false;
+ }
+}
+
+async function toggleSkill(skill: Skill) {
+ const key = `${skill.scope}:${skill.name}`;
+ if (expandedSkills[key] !== undefined) {
+ const updated = { ...expandedSkills };
+ delete updated[key];
+ expandedSkills = updated;
+ return;
+ }
+ if (loadingSkill[key]) return;
+ loadingSkill = { ...loadingSkill, [key]: true };
+ try {
+ const res = await fetch(`${apiBase}/skills/${encodeURIComponent(skill.name)}?scope=${skill.scope}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data: SkillDetail = await res.json();
+ expandedSkills = { ...expandedSkills, [key]: data };
+ } catch (_e) {
+ expandedSkills = { ...expandedSkills, [key]: null };
+ } finally {
+ const updated = { ...loadingSkill };
+ delete updated[key];
+ loadingSkill = updated;
+ }
+}
+
+$effect(() => {
+ fetchSkills();
+});
+
+const globalSkills = $derived(skills.filter((s) => s.scope === "global"));
+const projectSkills = $derived(skills.filter((s) => s.scope === "project"));
+
+function skillsByDirectory(list: Skill[], dir: "default" | "agents" | "project") {
+ return list.filter((s) => s.directory === dir);
+}
+
+function getMappingsForScope(scope: string) {
+ return mappings.filter((m) => m.scope === scope);
+}
+</script>
+
+<details class="collapse collapse-arrow bg-base-200 mt-4">
+ <summary class="collapse-title text-sm font-medium flex items-center gap-2">
+ <span>Skills</span>
+ {#if !loading}
+ <span class="badge badge-sm badge-neutral">{skills.length}</span>
+ {/if}
+ <button
+ class="btn btn-xs btn-ghost ml-auto"
+ onclick={(e) => { e.stopPropagation(); fetchSkills(); }}
+ title="Refresh skills"
+ >
+ ↺ Refresh
+ </button>
+ </summary>
+ <div class="collapse-content text-xs">
+ {#if loading}
+ <div class="flex items-center gap-2 py-2 text-base-content/60">
+ <span class="loading loading-spinner loading-xs"></span>
+ Loading skills...
+ </div>
+ {:else if error}
+ <div class="alert alert-error text-xs py-2">{error}</div>
+ {:else if skills.length === 0}
+ <p class="text-base-content/50 italic py-2">
+ No skills found. Create a <code class="font-mono">.skills/default/</code> directory to get started.
+ </p>
+ {:else}
+ {#snippet skillItem(skill: Skill)}
+ {@const key = `${skill.scope}:${skill.name}`}
+ {@const isExpanded = key in expandedSkills}
+ {@const detail = expandedSkills[key]}
+ {@const isLoading = loadingSkill[key]}
+ <div class="border-b border-base-300 last:border-0 py-1">
+ <div class="flex items-start gap-1 flex-wrap">
+ <button
+ class="font-mono text-primary hover:underline text-left"
+ onclick={() => toggleSkill(skill)}
+ >
+ {skill.name}
+ </button>
+ {#if isLoading}
+ <span class="loading loading-spinner loading-xs text-base-content/40"></span>
+ {/if}
+ {#each skill.tags as tag}
+ <span class="badge badge-xs badge-outline">{tag}</span>
+ {/each}
+ </div>
+ {#if skill.description}
+ <p class="text-base-content/60 truncate max-w-xs">{skill.description}</p>
+ {/if}
+ {#if isExpanded}
+ <div class="mt-2 bg-base-300 rounded p-2">
+ {#if detail}
+ <pre class="whitespace-pre-wrap font-mono text-xs overflow-x-auto max-h-60 overflow-y-auto">{detail.content}</pre>
+ {:else}
+ <p class="text-error text-xs">Failed to load skill content.</p>
+ {/if}
+ <button
+ class="btn btn-xs btn-ghost mt-1"
+ onclick={() => toggleSkill(skill)}
+ >
+ Close
+ </button>
+ </div>
+ {/if}
+ </div>
+ {/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)}
+ <div class="mb-3">
+ <div class="flex items-center gap-1 mb-1">
+ <span class="font-semibold text-base-content/80">{label}</span>
+ <span class="badge badge-xs {scope === 'global' ? 'badge-info' : 'badge-warning'}">{scope}</span>
+ </div>
+
+ {#if defaultSkills.length > 0}
+ <div class="ml-2 mb-2">
+ <div class="text-base-content/50 mb-1">default/</div>
+ <div class="ml-2">
+ {#each defaultSkills as skill}
+ {@render skillItem(skill)}
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ {#if agentSkills.length > 0 || scopeMappings.length > 0}
+ <div class="ml-2 mb-2">
+ <div class="text-base-content/50 mb-1">agents/</div>
+ <div class="ml-2">
+ {#if scopeMappings.length > 0}
+ {#each scopeMappings as mapping}
+ <div class="py-1 border-b border-base-300 last:border-0">
+ <div class="flex items-center gap-1 flex-wrap">
+ <span class="font-mono text-secondary">{mapping.agentType}</span>
+ {#if mapping.isOrchestrator}
+ <span class="badge badge-xs badge-accent">(orchestrator)</span>
+ {/if}
+ <span class="text-base-content/40">→</span>
+ {#each mapping.skills as skillName}
+ {@const mappedSkill = agentSkills.find((s) => s.name === skillName)}
+ {#if mappedSkill}
+ {@render skillItem(mappedSkill)}
+ {:else}
+ <span class="font-mono text-base-content/60">{skillName}</span>
+ {/if}
+ {/each}
+ </div>
+ </div>
+ {/each}
+ {:else}
+ {#each agentSkills as skill}
+ {@render skillItem(skill)}
+ {/each}
+ {/if}
+ </div>
+ </div>
+ {/if}
+
+ {#if projectDirSkills.length > 0}
+ <div class="ml-2 mb-2">
+ <div class="text-base-content/50 mb-1">project/</div>
+ <div class="ml-2">
+ {#each projectDirSkills as skill}
+ {@render skillItem(skill)}
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+ {/if}
+ {/snippet}
+
+ {@render scopeSection("Global", globalSkills, "global")}
+ {@render scopeSection("Project", projectSkills, "project")}
+ {/if}
+ </div>
+</details>
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 @@
+<script lang="ts">
+ interface TaskItem {
+ id: string;
+ title: string;
+ description: string;
+ status: "pending" | "in_progress" | "done" | "blocked";
+ }
+
+ const { tasks }: { tasks: TaskItem[] } = $props();
+
+ const doneCount = $derived(tasks.filter((t) => t.status === "done").length);
+ const inProgressCount = $derived(tasks.filter((t) => t.status === "in_progress").length);
+
+ function badgeClass(status: TaskItem["status"]): string {
+ switch (status) {
+ case "pending":
+ return "badge badge-ghost badge-xs";
+ case "in_progress":
+ return "badge badge-info badge-xs";
+ case "done":
+ return "badge badge-success badge-xs";
+ case "blocked":
+ return "badge badge-warning badge-xs";
+ }
+ }
+
+ function statusIcon(status: TaskItem["status"]): string {
+ switch (status) {
+ case "pending":
+ return "⏳";
+ case "in_progress":
+ return "▶";
+ case "done":
+ return "✓";
+ case "blocked":
+ return "⚠";
+ }
+ }
+</script>
+
+<div class="flex flex-col gap-2">
+ {#if tasks.length === 0}
+ <p class="text-xs text-base-content/50">No tasks yet.</p>
+ {:else}
+ <p class="text-xs text-base-content/60">
+ {tasks.length} task{tasks.length !== 1 ? "s" : ""}
+ ({doneCount} done, {inProgressCount} in progress)
+ </p>
+ <ul class="flex flex-col gap-1">
+ {#each tasks as task (task.id)}
+ <li class="flex flex-col gap-0.5 rounded p-1.5 hover:bg-base-200 transition-colors">
+ <div class="flex items-center gap-1.5">
+ <span class={badgeClass(task.status)}>
+ {statusIcon(task.status)}
+ </span>
+ <span
+ class="text-sm leading-tight {task.status === 'in_progress'
+ ? 'font-bold'
+ : 'font-medium'}"
+ >
+ {task.title}
+ </span>
+ </div>
+ {#if task.description}
+ <p class="text-xs text-base-content/60 line-clamp-2 pl-5">{task.description}</p>
+ {/if}
+ <p class="text-xs text-base-content/30 pl-5 font-mono">{task.id}</p>
+ </li>
+ {/each}
+ </ul>
+ {/if}
+</div>
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;