summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-21 22:47:35 +0900
committerAdam Malczewski <[email protected]>2026-05-21 22:47:35 +0900
commit7884709e3b2adb1b65c1c086257e0300eed51cee (patch)
treede27a07d78dcfca23761b9cfb9d2c85e520d6498
parent1e13f79899622dd8a5c268b5b8e854b14f82d87f (diff)
downloaddispatch-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.json2
-rw-r--r--packages/api/src/agent-manager.ts2
-rw-r--r--packages/frontend/src/lib/components/SkillsBrowser.svelte290
-rw-r--r--packages/frontend/src/lib/components/TabBar.svelte43
-rw-r--r--packages/frontend/src/lib/settings.svelte.ts64
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts169
-rw-r--r--packages/frontend/src/lib/ws.svelte.ts9
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<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"
+ >
+ &#x2715;
+ </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 {