From 7884709e3b2adb1b65c1c086257e0300eed51cee Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 21 May 2026 22:47:35 +0900 Subject: feat: skills system with toggle/inject, tab bar UX, streaming dedup fix - Add skills toggle system: check skills in sidebar to inject with next message - Auto-check default skills on new tab creation for first-message injection - Track injected skills per tab with visual highlights in skills browser - Redesign tab bar: double-click background for new tab, larger close button - Update default system prompt - Fix streaming text duplication: change WS callbacks from array to Set - Fix biome config: exclude references/ directory - Auto-format with biome --- biome.json | 2 +- packages/api/src/agent-manager.ts | 2 +- .../src/lib/components/SkillsBrowser.svelte | 290 +++++++++------------ packages/frontend/src/lib/components/TabBar.svelte | 43 +-- packages/frontend/src/lib/settings.svelte.ts | 64 ++++- packages/frontend/src/lib/tabs.svelte.ts | 169 ++++++++++-- packages/frontend/src/lib/ws.svelte.ts | 9 +- 7 files changed, 348 insertions(+), 231 deletions(-) diff --git a/biome.json b/biome.json index 8943de3..4fbaeda 100644 --- a/biome.json +++ b/biome.json @@ -47,6 +47,6 @@ } ], "files": { - "includes": ["**", "!**/node_modules", "!**/dist", "!**/build"] + "includes": ["**", "!**/node_modules", "!**/dist", "!**/build", "!references/**"] } } diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 0c95200..889142d 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -40,7 +40,7 @@ const TOOL_DESCRIPTIONS: Record = { task_list: "Manage a task list for tracking work items.", }; -const DEFAULT_SYSTEM_PROMPT = "You are Dispatch, a helpful AI coding assistant. Be concise and helpful."; +const DEFAULT_SYSTEM_PROMPT = "You are Dispatch, an agent designed to help with any task that the user asks for. Be helpful and concise."; function buildSystemPrompt(toolNames: string[], basePrompt?: string): string { const base = basePrompt || DEFAULT_SYSTEM_PROMPT; diff --git a/packages/frontend/src/lib/components/SkillsBrowser.svelte b/packages/frontend/src/lib/components/SkillsBrowser.svelte index be1ad29..685f890 100644 --- a/packages/frontend/src/lib/components/SkillsBrowser.svelte +++ b/packages/frontend/src/lib/components/SkillsBrowser.svelte @@ -1,4 +1,7 @@ -
- - Skills +
+
+
Skills
{#if !loading} {skills.length} {/if} + {#if checkedCount > 0} + {checkedCount} queued + {/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}
+
+ +

Check skills to inject with your next message.

+ + {#if loading} +
+ + Loading skills... +
+ {:else if error} +
{error}
+ {:else if skills.length === 0} +

+ No skills found. Create .skills/ directories to get started. +

+ {:else} +
+ {#each skills as skill (skillKey(skill))} + {@const key = skillKey(skill)} + {@const checked = isChecked(skill)} + {@const injected = isInjected(skill)} +
+ + + {#if expandedSkill === key} +
+ {#if loadingDetail} + + {:else if expandedDetail} +
{expandedDetail.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} -
-
+ {/each} + + {/if} + + + diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte index d1a3936..b5597e1 100644 --- a/packages/frontend/src/lib/components/TabBar.svelte +++ b/packages/frontend/src/lib/components/TabBar.svelte @@ -1,15 +1,24 @@ -
-
+ +
{ if (e.target === e.currentTarget) tabStore.createNewTab(); }} +> + +
{ if (e.target === e.currentTarget) tabStore.createNewTab(); }} + > +
{/each}
diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts index 6c35efd..2c8e2f5 100644 --- a/packages/frontend/src/lib/settings.svelte.ts +++ b/packages/frontend/src/lib/settings.svelte.ts @@ -3,21 +3,61 @@ let autoExpandThinking = $state(false); let systemPrompt = $state(""); let savedSystemPrompt = $state(""); -let toolPerms = $state>({ read: true, edit: false, bash: false, external_directory: false }); -let savedToolPerms = $state>({ read: true, edit: false, bash: false, external_directory: false }); +let toolPerms = $state>({ + read: true, + edit: false, + bash: false, + external_directory: false, +}); +let savedToolPerms = $state>({ + read: true, + edit: false, + bash: false, + external_directory: false, +}); +let skillChecks = $state>({}); export const appSettings = { - get autoExpandThinking() { return autoExpandThinking; }, - set autoExpandThinking(v: boolean) { autoExpandThinking = v; }, - get systemPrompt() { return systemPrompt; }, - set systemPrompt(v: string) { systemPrompt = v; }, - get savedSystemPrompt() { return savedSystemPrompt; }, - set savedSystemPrompt(v: string) { savedSystemPrompt = v; }, - get toolPerms() { return toolPerms; }, - set toolPerms(v: Record) { toolPerms = v; }, - get savedToolPerms() { return savedToolPerms; }, - set savedToolPerms(v: Record) { savedToolPerms = v; }, + get autoExpandThinking() { + return autoExpandThinking; + }, + set autoExpandThinking(v: boolean) { + autoExpandThinking = v; + }, + get systemPrompt() { + return systemPrompt; + }, + set systemPrompt(v: string) { + systemPrompt = v; + }, + get savedSystemPrompt() { + return savedSystemPrompt; + }, + set savedSystemPrompt(v: string) { + savedSystemPrompt = v; + }, + get toolPerms() { + return toolPerms; + }, + set toolPerms(v: Record) { + toolPerms = v; + }, + get savedToolPerms() { + return savedToolPerms; + }, + set savedToolPerms(v: Record) { + savedToolPerms = v; + }, get toolPermsDirty() { return Object.keys(toolPerms).some((k) => toolPerms[k] !== savedToolPerms[k]); }, + get skillChecks() { + return skillChecks; + }, + set skillChecks(v: Record) { + skillChecks = v; + }, + get skillChecksDirty() { + return Object.values(skillChecks).some((v) => v); + }, }; diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index e38a6e3..55aa161 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -1,6 +1,14 @@ import { config } from "./config.js"; import { appSettings } from "./settings.svelte.js"; -import type { AgentEvent, ChatMessage, ContentSegment, DebugInfo, LogEntry, PermissionPrompt, TaskItem } from "./types.js"; +import type { + AgentEvent, + ChatMessage, + ContentSegment, + DebugInfo, + LogEntry, + PermissionPrompt, + TaskItem, +} from "./types.js"; import { wsClient } from "./ws.svelte.js"; function generateId() { @@ -25,6 +33,7 @@ export interface Tab { reasoningEffort: string; currentAssistantId: string | null; tasks: TaskItem[]; + injectedSkills: string[]; } function createTabStore() { @@ -80,9 +89,14 @@ function createTabStore() { reasoningEffort: "max", currentAssistantId: null, tasks: [], + injectedSkills: [], }; tabs = [...tabs, tab]; activeTabId = id; + + // Auto-check default skills for injection with the first message + autoCheckDefaultSkills(); + return tab; } @@ -108,7 +122,7 @@ function createTabStore() { // If we closed the active tab, switch to the last remaining or create a new one if (activeTabId === id) { if (tabs.length > 0) { - activeTabId = tabs[tabs.length - 1]!.id; + activeTabId = tabs[tabs.length - 1]?.id; } else { await createNewTab(); } @@ -229,7 +243,11 @@ function createTabStore() { ...m, content: m.content.map((seg) => { if (seg.type === "tool-call" && seg.id === event.toolResult.toolCallId) { - return { ...seg, result: event.toolResult.result, isError: event.toolResult.isError }; + return { + ...seg, + result: event.toolResult.result, + isError: event.toolResult.isError, + }; } return seg; }), @@ -243,9 +261,7 @@ function createTabStore() { const tab5 = getTabById(tabId); if (!tab5) break; updateMessages(tabId, (msgs) => - msgs.map((m) => - m.id === tab5.currentAssistantId ? { ...m, isStreaming: false } : m, - ), + msgs.map((m) => (m.id === tab5.currentAssistantId ? { ...m, isStreaming: false } : m)), ); updateTab(tabId, { currentAssistantId: null }); break; @@ -282,7 +298,9 @@ function createTabStore() { } case "config-reload": { configReloaded = true; - setTimeout(() => { configReloaded = false; }, 2500); + setTimeout(() => { + configReloaded = false; + }, 2500); break; } case "shell-output": { @@ -299,8 +317,12 @@ function createTabStore() { segments[i] = { ...seg, shellOutput: { - stdout: (seg.shellOutput?.stdout ?? "") + (event.stream === "stdout" ? event.data : ""), - stderr: (seg.shellOutput?.stderr ?? "") + (event.stream === "stderr" ? event.data : ""), + stdout: + (seg.shellOutput?.stdout ?? "") + + (event.stream === "stdout" ? event.data : ""), + stderr: + (seg.shellOutput?.stderr ?? "") + + (event.stream === "stderr" ? event.data : ""), }, }; break; @@ -314,10 +336,75 @@ function createTabStore() { } } + async function autoCheckDefaultSkills(): Promise { + try { + const res = await fetch(`${config.apiBase}/skills`); + if (!res.ok) return; + const data = (await res.json()) as { + skills?: Array<{ + name: string; + scope: string; + directory: string; + }>; + }; + const defaultSkills = (data.skills ?? []).filter((s) => s.directory === "default"); + if (defaultSkills.length === 0) return; + const checks: Record = { ...appSettings.skillChecks }; + for (const skill of defaultSkills) { + checks[`${skill.scope}:${skill.name}`] = true; + } + appSettings.skillChecks = checks; + } catch { + // Silently ignore — skills will still be available for manual checking + } + } + + async function fetchSkillContent(scope: string, name: string): Promise { + try { + const res = await fetch( + `${config.apiBase}/skills/${encodeURIComponent(name)}?scope=${scope}`, + ); + if (!res.ok) return null; + const data = (await res.json()) as { content?: string }; + return data.content ?? null; + } catch { + return null; + } + } + async function sendMessage(text: string): Promise { const tab = getActiveTab(); if (!tab) return; + // Fetch content for checked skills and build the message to send + let messageToSend = text; + const checkedKeys = Object.entries(appSettings.skillChecks) + .filter(([, v]) => v) + .map(([k]) => k); + + if (checkedKeys.length > 0) { + const skillSections: string[] = []; + for (const key of checkedKeys) { + const [scope, ...nameParts] = key.split(":"); + const name = nameParts.join(":"); + if (!scope || !name) continue; + const content = await fetchSkillContent(scope, name); + if (content) { + skillSections.push(`\n${content}\n`); + } + } + if (skillSections.length > 0) { + messageToSend = `[The following skills have been activated for this message]\n\n${skillSections.join("\n\n")}\n\n---\n\n${text}`; + } + + // Track injected skills on the tab + const newInjected = [...new Set([...tab.injectedSkills, ...checkedKeys])]; + updateTab(tab.id, { injectedSkills: newInjected }); + + // Clear all checks + appSettings.skillChecks = {}; + } + const userMsg: ChatMessage = { id: generateId(), role: "user", @@ -327,7 +414,7 @@ function createTabStore() { // Generate title from first user message if (tab.messages.length === 0 || (tab.messages.length === 1 && tab.title === "New Tab")) { - const titleText = text.length > 50 ? text.slice(0, 47) + "..." : text; + const titleText = text.length > 50 ? `${text.slice(0, 47)}...` : text; updateTab(tab.id, { title: titleText }); fetch(`${config.apiBase}/tabs/${tab.id}`, { method: "PATCH", @@ -374,7 +461,7 @@ function createTabStore() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tabId: tab.id, - message: text, + message: messageToSend, ...(tab.keyId ? { keyId: tab.keyId } : {}), ...(tab.modelId ? { modelId: tab.modelId } : {}), reasoningEffort: tab.reasoningEffort, @@ -387,7 +474,11 @@ function createTabStore() { role: "assistant", content: [{ type: "text", text: `Error: Failed to send message (HTTP ${res.status})` }], isStreaming: false, - debugInfo: makeDebugInfo({ error: `HTTP ${res.status}`, httpStatus: res.status, httpBody: body }), + debugInfo: makeDebugInfo({ + error: `HTTP ${res.status}`, + httpStatus: res.status, + httpBody: body, + }), }; updateTab(tab.id, { messages: [...(getTabById(tab.id)?.messages ?? []), errMsg] }); } @@ -435,21 +526,29 @@ function createTabStore() { wsClient.send({ type: "permission-reply", id, reply }); pendingPermissions = pendingPermissions.filter((p) => p.id !== id); if (prompt) { - permissionLog = [...permissionLog, { - id: generateId(), - permission: prompt.permission, - patterns: prompt.patterns, - action: reply, - timestamp: new Date().toISOString(), - description: prompt.description, - }]; + permissionLog = [ + ...permissionLog, + { + id: generateId(), + permission: prompt.permission, + patterns: prompt.patterns, + action: reply, + timestamp: new Date().toISOString(), + description: prompt.description, + }, + ]; } } function copyConversation(): string { const tab = getActiveTab(); if (!tab) return ""; - const lines: string[] = ["=== Dispatch Conversation ===", `Tab: ${tab.title}`, `Model: ${tab.modelId ?? "default"}`, ""]; + const lines: string[] = [ + "=== Dispatch Conversation ===", + `Tab: ${tab.title}`, + `Model: ${tab.modelId ?? "default"}`, + "", + ]; for (const msg of tab.messages) { const role = msg.role === "user" ? "User" : msg.role === "system" ? "System" : "Assistant"; lines.push(`--- ${role} ---`); @@ -467,13 +566,27 @@ function createTabStore() { } return { - get tabs() { return tabs; }, - get activeTabId() { return activeTabId; }, - get activeTab() { return getActiveTab(); }, - get isConnected() { return isConnected; }, - get pendingPermissions() { return pendingPermissions; }, - get permissionLog() { return permissionLog; }, - get configReloaded() { return configReloaded; }, + get tabs() { + return tabs; + }, + get activeTabId() { + return activeTabId; + }, + get activeTab() { + return getActiveTab(); + }, + get isConnected() { + return isConnected; + }, + get pendingPermissions() { + return pendingPermissions; + }, + get permissionLog() { + return permissionLog; + }, + get configReloaded() { + return configReloaded; + }, createNewTab, switchTab, closeTab, diff --git a/packages/frontend/src/lib/ws.svelte.ts b/packages/frontend/src/lib/ws.svelte.ts index 26f9077..95243d5 100644 --- a/packages/frontend/src/lib/ws.svelte.ts +++ b/packages/frontend/src/lib/ws.svelte.ts @@ -9,7 +9,7 @@ function createWebSocketClient(url: string) { let reconnectDelay = 1000; let reconnectTimer: ReturnType | null = null; let manualDisconnect = false; - const callbacks: EventCallback[] = []; + const callbacks = new Set(); function connect() { if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { @@ -69,16 +69,15 @@ function createWebSocketClient(url: string) { } function onEvent(callback: EventCallback) { - callbacks.push(callback); + callbacks.add(callback); return () => { - const idx = callbacks.indexOf(callback); - if (idx !== -1) callbacks.splice(idx, 1); + callbacks.delete(callback); }; } /** Remove all registered event callbacks (used for HMR safety). */ function clearCallbacks() { - callbacks.length = 0; + callbacks.clear(); } function send(data: unknown): void { -- cgit v1.2.3