diff options
| author | Adam Malczewski <[email protected]> | 2026-05-21 22:47:35 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-21 22:47:35 +0900 |
| commit | 7884709e3b2adb1b65c1c086257e0300eed51cee (patch) | |
| tree | de27a07d78dcfca23761b9cfb9d2c85e520d6498 | |
| parent | 1e13f79899622dd8a5c268b5b8e854b14f82d87f (diff) | |
| download | dispatch-7884709e3b2adb1b65c1c086257e0300eed51cee.tar.gz dispatch-7884709e3b2adb1b65c1c086257e0300eed51cee.zip | |
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
| -rw-r--r-- | biome.json | 2 | ||||
| -rw-r--r-- | packages/api/src/agent-manager.ts | 2 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SkillsBrowser.svelte | 290 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/TabBar.svelte | 43 | ||||
| -rw-r--r-- | packages/frontend/src/lib/settings.svelte.ts | 64 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 169 | ||||
| -rw-r--r-- | packages/frontend/src/lib/ws.svelte.ts | 9 |
7 files changed, 348 insertions, 231 deletions
@@ -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<string, string> = { 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 @@ <script lang="ts"> +import { appSettings } from "../settings.svelte.js"; +import { tabStore } from "../tabs.svelte.js"; + interface Skill { name: string; description: string; @@ -7,16 +10,9 @@ interface Skill { directory: "default" | "agents" | "project"; } -interface SkillMapping { - agentType: string; - isOrchestrator: boolean; - skills: string[]; - scope: string; -} - interface SkillsResponse { skills: Skill[]; - mappings: SkillMapping[]; + mappings: unknown[]; } interface SkillDetail extends Skill { @@ -27,11 +23,11 @@ interface SkillDetail extends Skill { 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>>({}); +let expandedSkill = $state<string | null>(null); +let expandedDetail = $state<SkillDetail | null>(null); +let loadingDetail = $state(false); async function fetchSkills() { loading = true; @@ -41,7 +37,6 @@ async function fetchSkills() { 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 { @@ -49,27 +44,47 @@ async function fetchSkills() { } } -async function toggleSkill(skill: Skill) { - const key = `${skill.scope}:${skill.name}`; - if (expandedSkills[key] !== undefined) { - const updated = { ...expandedSkills }; - delete updated[key]; - expandedSkills = updated; +function skillKey(skill: Skill): string { + return `${skill.scope}:${skill.name}`; +} + +function isChecked(skill: Skill): boolean { + return appSettings.skillChecks[skillKey(skill)] === true; +} + +function isInjected(skill: Skill): boolean { + return tabStore.activeTab?.injectedSkills.includes(skillKey(skill)) ?? false; +} + +function toggleCheck(skill: Skill): void { + const key = skillKey(skill); + appSettings.skillChecks = { ...appSettings.skillChecks, [key]: !isChecked(skill) }; +} + +function resetChecks(): void { + appSettings.skillChecks = {}; +} + +async function toggleExpand(skill: Skill) { + const key = skillKey(skill); + if (expandedSkill === key) { + expandedSkill = null; + expandedDetail = null; return; } - if (loadingSkill[key]) return; - loadingSkill = { ...loadingSkill, [key]: true }; + expandedSkill = key; + expandedDetail = null; + loadingDetail = true; try { - const res = await fetch(`${apiBase}/skills/${encodeURIComponent(skill.name)}?scope=${skill.scope}`); + 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 }; + expandedDetail = await res.json(); + } catch { + expandedDetail = null; } finally { - const updated = { ...loadingSkill }; - delete updated[key]; - loadingSkill = updated; + loadingDetail = false; } } @@ -77,158 +92,99 @@ $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); -} +const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v) => v).length); </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> +<div class="flex flex-col gap-3"> + <div class="flex items-center gap-2"> + <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Skills</div> {#if !loading} <span class="badge badge-sm badge-neutral">{skills.length}</span> {/if} + {#if checkedCount > 0} + <span class="badge badge-sm badge-primary">{checkedCount} queued</span> + {/if} <button class="btn btn-xs btn-ghost ml-auto" - onclick={(e) => { e.stopPropagation(); fetchSkills(); }} + onclick={fetchSkills} title="Refresh skills" > - ↺ Refresh + 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> + </div> + + <p class="text-xs text-base-content/40">Check skills to inject with your next message.</p> + + {#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 <code class="font-mono">.skills/</code> directories to get started. + </p> + {:else} + <div class="flex flex-col gap-0.5"> + {#each skills as skill (skillKey(skill))} + {@const key = skillKey(skill)} + {@const checked = isChecked(skill)} + {@const injected = isInjected(skill)} + <div + class="rounded p-1.5 transition-colors {injected ? 'bg-primary/10 border border-primary/20' : 'hover:bg-base-200'}" + > + <label class="flex items-start gap-2 cursor-pointer"> + <input + type="checkbox" + class="checkbox checkbox-sm checkbox-primary rounded-sm mt-0.5" + checked={checked} + onchange={() => toggleCheck(skill)} + /> + <div class="flex-1 min-w-0"> + <div class="flex items-center gap-1.5 flex-wrap"> + <button + class="font-mono text-xs text-left hover:underline {injected ? 'text-primary font-semibold' : 'text-base-content'}" + onclick={() => toggleExpand(skill)} + > + {skill.name} + </button> + <span class="badge badge-xs {skill.scope === 'global' ? 'badge-info' : 'badge-warning'}">{skill.scope}</span> + {#if injected} + <span class="badge badge-xs badge-primary">active</span> + {/if} + {#each skill.tags as tag} + <span class="badge badge-xs badge-outline">{tag}</span> + {/each} + </div> + {#if skill.description} + <p class="text-xs text-base-content/50 truncate">{skill.description}</p> + {/if} + </div> + </label> + + {#if expandedSkill === key} + <div class="mt-2 ml-6 bg-base-300 rounded p-2"> + {#if loadingDetail} + <span class="loading loading-spinner loading-xs text-base-content/40"></span> + {:else if expandedDetail} + <pre class="whitespace-pre-wrap font-mono text-xs overflow-x-auto max-h-60 overflow-y-auto">{expandedDetail.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> + {/each} + </div> + {/if} + + <button + class="btn btn-sm btn-ghost w-full" + disabled={!appSettings.skillChecksDirty} + onclick={resetChecks} + > + Reset + </button> +</div> 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 @@ <script lang="ts"> - import { tabStore } from "../tabs.svelte.js"; +import { tabStore } from "../tabs.svelte.js"; - function statusColor(status: string): string { - if (status === "running") return "bg-warning"; - if (status === "error") return "bg-error"; - return "bg-success"; - } +function statusColor(status: string): string { + if (status === "running") return "bg-warning"; + if (status === "error") return "bg-error"; + return "bg-success"; +} </script> -<div class="overflow-x-auto bg-base-200 flex-shrink-0"> - <div role="tablist" class="tabs tabs-lift min-w-max"> +<!-- svelte-ignore a11y_no_static_element_interactions --> +<div + class="overflow-x-auto bg-base-200 flex-shrink-0" + ondblclick={(e) => { if (e.target === e.currentTarget) tabStore.createNewTab(); }} +> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + role="tablist" + class="tabs tabs-lift min-w-max" + ondblclick={(e) => { if (e.target === e.currentTarget) tabStore.createNewTab(); }} + > <!-- New tab button — always first --> <button type="button" @@ -24,7 +33,7 @@ <!-- svelte-ignore a11y_no_static_element_interactions --> <div role="tab" - class="tab {tab.id === tabStore.activeTabId ? 'tab-active' : ''}" + class="tab !flex items-stretch gap-1.5 {tab.id === tabStore.activeTabId ? 'tab-active' : ''}" onclick={() => tabStore.switchTab(tab.id)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tabStore.switchTab(tab.id); }} tabindex="0" @@ -32,15 +41,15 @@ <span class="flex items-center gap-1.5"> <span class="w-1.5 h-1.5 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span> <span class="max-w-32 truncate text-xs">{tab.title}</span> - <button - type="button" - class="ml-0.5 text-base-content/30 hover:text-error transition-colors text-xs leading-none" - onclick={(e) => { e.stopPropagation(); tabStore.closeTab(tab.id); }} - aria-label="Close tab" - > - x - </button> </span> + <button + type="button" + class="flex items-center justify-center px-3 my-1 leading-none text-base-content/30 hover:text-error hover:bg-base-300 rounded transition-colors text-xs" + onclick={(e) => { e.stopPropagation(); tabStore.closeTab(tab.id); }} + aria-label="Close tab" + > + ✕ + </button> </div> {/each} </div> 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<Record<string, boolean>>({ read: true, edit: false, bash: false, external_directory: false }); -let savedToolPerms = $state<Record<string, boolean>>({ read: true, edit: false, bash: false, external_directory: false }); +let toolPerms = $state<Record<string, boolean>>({ + read: true, + edit: false, + bash: false, + external_directory: false, +}); +let savedToolPerms = $state<Record<string, boolean>>({ + read: true, + edit: false, + bash: false, + external_directory: false, +}); +let skillChecks = $state<Record<string, boolean>>({}); 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<string, boolean>) { toolPerms = v; }, - get savedToolPerms() { return savedToolPerms; }, - set savedToolPerms(v: Record<string, boolean>) { 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<string, boolean>) { + toolPerms = v; + }, + get savedToolPerms() { + return savedToolPerms; + }, + set savedToolPerms(v: Record<string, boolean>) { + savedToolPerms = v; + }, get toolPermsDirty() { return Object.keys(toolPerms).some((k) => toolPerms[k] !== savedToolPerms[k]); }, + get skillChecks() { + return skillChecks; + }, + set skillChecks(v: Record<string, boolean>) { + 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<void> { + 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<string, boolean> = { ...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<string | null> { + 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<void> { 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(`<skill name="${name}">\n${content}\n</skill>`); + } + } + 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<typeof setTimeout> | null = null; let manualDisconnect = false; - const callbacks: EventCallback[] = []; + const callbacks = new Set<EventCallback>(); 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 { |
