diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 15:04:26 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 15:04:26 +0900 |
| commit | adc8bd185b54935e7a31aae04da3175b7989927a (patch) | |
| tree | 031fa53009a4708ce0cc15c0e3dcb2f2a73cf2d9 /packages/frontend/src/lib | |
| parent | 52af4ca33b1f83b8f863a6267d447944f55e729d (diff) | |
| download | dispatch-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.ts | 21 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ConfigPanel.svelte | 244 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/Header.svelte | 10 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/HotReloadIndicator.svelte | 35 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ModelStatus.svelte | 135 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SkillsBrowser.svelte | 234 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/TaskListPanel.svelte | 72 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 9 |
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; |
