From 1e13f79899622dd8a5c268b5b8e854b14f82d87f Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 21 May 2026 20:59:24 +0900 Subject: feat: system prompt editor, tool permissions save-on-send, responsive sidebar, UI polish - System Prompt sidebar view: editable textarea, save-on-send, reset button - Tool permissions: save-on-send pattern (not immediate), reset button - Dynamic system prompt: buildSystemPrompt reads from DB, tool list auto-generated - Responsive sidebar: overlay on small screens with backdrop - Chat bubbles: user=fit-width, assistant=full-width - Fix infinite loops (use onMount for data fetching) - Fix sendMessage race condition (await settings saves before chat POST) - Model selector: auto-open model modal after key selection - Rename views: Permissions->Tools, Tab Settings->Model Choice - Shared appSettings store for cross-component reactive state - Delete old chat.svelte.ts --- packages/api/src/agent-manager.ts | 55 +++++++++++++------ packages/frontend/src/App.svelte | 17 ++++-- .../frontend/src/lib/components/ChatMessage.svelte | 4 +- .../src/lib/components/ModelSelector.svelte | 17 +++--- .../src/lib/components/PermissionLog.svelte | 43 ++++++++------- .../src/lib/components/SidebarPanel.svelte | 9 ++-- .../src/lib/components/SystemPromptPanel.svelte | 61 ++++++++++++++++++++++ packages/frontend/src/lib/settings.svelte.ts | 15 ++++++ packages/frontend/src/lib/tabs.svelte.ts | 33 ++++++++++++ 9 files changed, 203 insertions(+), 51 deletions(-) create mode 100644 packages/frontend/src/lib/components/SystemPromptPanel.svelte diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 9d222fa..0c95200 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -32,15 +32,26 @@ import { setSkillsGetter } from "./routes/skills.js"; import { setModelsGetter, setAccountsGetter } from "./routes/models.js"; import { setTabsAgentManager } from "./routes/tabs.js"; -const SYSTEM_PROMPT = `You are Dispatch, a helpful AI coding assistant. You have access to the following tools for working with files in the current working directory: - -- read_file: Read the contents of a file -- write_file: Write content to a file (creates parent directories if needed) -- list_files: List files and directories -- run_shell: Execute shell commands in the working directory (bash). Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. Do NOT run destructive or irreversible commands unless the user explicitly requests them. -- task_list: Manage a task list for tracking work items. - -When asked to work with files, use these tools. Always confirm what you did after completing an action. Be concise and helpful.`; +const TOOL_DESCRIPTIONS: Record = { + read_file: "Read the contents of a file", + list_files: "List files and directories", + write_file: "Write content to a file (creates parent directories if needed)", + run_shell: "Execute shell commands in the working directory (bash). Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. Do NOT run destructive or irreversible commands unless the user explicitly requests them.", + 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."; + +function buildSystemPrompt(toolNames: string[], basePrompt?: string): string { + const base = basePrompt || DEFAULT_SYSTEM_PROMPT; + const toolList = toolNames + .filter((name) => TOOL_DESCRIPTIONS[name]) + .map((name) => `- ${name}: ${TOOL_DESCRIPTIONS[name]}`) + .join("\n"); + + if (!toolList) return base; + return `${base}\n\nYou have access to the following tools:\n\n${toolList}\n\nWhen asked to work with files, use these tools. Always confirm what you did after completing an action.`; +} interface TabAgent { agent: Agent | null; @@ -199,7 +210,8 @@ export class AgentManager { const permRead = getSetting("perm_read") !== "ask"; const permEdit = getSetting("perm_edit") === "allow"; const permBash = getSetting("perm_bash") === "allow"; - const permKey = `${permRead}:${permEdit}:${permBash}`; + const sysPrompt = getSetting("system_prompt") ?? ""; + const permKey = `${permRead}:${permEdit}:${permBash}:${sysPrompt}`; // If the override differs or permissions changed, invalidate the cached agent if ( @@ -213,12 +225,20 @@ export class AgentManager { const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); // Build tools list based on permission settings - const tools = [ - ...(permRead ? [createReadFileTool(workingDirectory), createListFilesTool(workingDirectory)] : []), - ...(permEdit ? [createWriteFileTool(workingDirectory)] : []), - ...(permBash ? [createRunShellTool(workingDirectory)] : []), - createTaskListTool(tabAgent.taskList), - ]; + const toolEntries: Array<{ name: string; tool: ReturnType }> = []; + if (permRead) { + toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) }); + toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) }); + } + if (permEdit) { + toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); + } + if (permBash) { + toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory) }); + } + toolEntries.push({ name: "task_list", tool: createTaskListTool(tabAgent.taskList) }); + const tools = toolEntries.map((e) => e.tool); + const toolNames = toolEntries.map((e) => e.name); tabAgent._lastPermKey = permKey; const ruleset = configToRuleset(this.config); @@ -309,11 +329,12 @@ export class AgentManager { tabAgent.modelId = null; } + const customSystemPrompt = getSetting("system_prompt") || undefined; tabAgent.agent = new Agent({ model, apiKey, baseURL, - systemPrompt: SYSTEM_PROMPT, + systemPrompt: buildSystemPrompt(toolNames, customSystemPrompt), tools, workingDirectory, permissionChecker: this.permissionManager ?? undefined, diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index 4d4200a..8288dfd 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -66,7 +66,7 @@ onMount(() => {
sidebarOpen = !sidebarOpen} /> -
+
@@ -76,9 +76,11 @@ onMount(() => {
- +
@@ -109,6 +111,15 @@ onMount(() => {
+ +{#if sidebarOpen} + +
sidebarOpen = false} + >
+{/if} +
{:else} -
-
+
+
{#if message.thinking}
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte index b88b2e3..0af5ae6 100644 --- a/packages/frontend/src/lib/components/ModelSelector.svelte +++ b/packages/frontend/src/lib/components/ModelSelector.svelte @@ -44,16 +44,19 @@ function selectKey(keyId: string) { showKeyModal = false; onKeyChange(keyId); + // Immediately open model selection for the new key + openModelModal(keyId); } - async function openModelModal() { - if (!activeKeyId) return; + async function openModelModal(keyIdOverride?: string) { + const keyId = keyIdOverride ?? activeKeyId; + if (!keyId) return; showModelModal = true; modelError = null; // Check session cache - if (modelCache.has(activeKeyId)) { - availableModels = modelCache.get(activeKeyId)!; + if (modelCache.has(keyId)) { + availableModels = modelCache.get(keyId)!; loadingModels = false; return; } @@ -63,7 +66,7 @@ try { const res = await fetch( - `${config.apiBase}/models/available?keyId=${encodeURIComponent(activeKeyId)}`, + `${config.apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`, ); if (!res.ok) { const data = await res.json().catch(() => ({})); @@ -73,7 +76,7 @@ const data = await res.json(); availableModels = data.models ?? []; // Cache for session - modelCache.set(activeKeyId, availableModels); + modelCache.set(keyId, availableModels); } catch (err) { modelError = err instanceof Error ? err.message : "Failed to fetch models"; } finally { @@ -99,7 +102,7 @@
Model -
diff --git a/packages/frontend/src/lib/components/PermissionLog.svelte b/packages/frontend/src/lib/components/PermissionLog.svelte index 06607de..7733dcf 100644 --- a/packages/frontend/src/lib/components/PermissionLog.svelte +++ b/packages/frontend/src/lib/components/PermissionLog.svelte @@ -1,5 +1,7 @@
Tool Permissions
+

Changes are applied when you send your next message.

{#each toolPermissions as perm (perm.id)} @@ -63,7 +60,7 @@ $effect(() => { togglePermission(perm.id)} />
@@ -74,6 +71,14 @@ $effect(() => { {/each}
+ +

Warning: changing tool access will reset the AI's prompt cache for active conversations, which may increase usage costs.

diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index 9aca408..93d528e 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -8,6 +8,7 @@ import KeyUsage from "./KeyUsage.svelte"; import ClaudeReset from "./ClaudeReset.svelte"; import SettingsPanel from "./SettingsPanel.svelte"; + import SystemPromptPanel from "./SystemPromptPanel.svelte"; import type { TaskItem, LogEntry, KeyInfo } from "../types.js"; const { @@ -42,7 +43,7 @@ let nextId = 0; let panels = $state([{ id: nextId++, selected: "Model Choice" }]); - const viewOptions = ["Select a view", "Model Choice", "Key Usage", "Claude Reset", "Model Status", "Tasks", "Config", "Skills", "Permissions", "Settings"]; + const viewOptions = ["Select a view", "Model Choice", "Key Usage", "Claude Reset", "Model Status", "Tasks", "Config", "Skills", "Tools", "System Prompt", "Settings"]; function addPanel() { panels = [...panels, { id: nextId++, selected: "Select a view" }]; @@ -113,9 +114,11 @@ {:else if panel.selected === "Skills"} - {:else if panel.selected === "Permissions"} + {:else if panel.selected === "Tools"} - {:else if panel.selected === "Settings"} + {:else if panel.selected === "System Prompt"} + + {:else if panel.selected === "Settings"} {/if}
diff --git a/packages/frontend/src/lib/components/SystemPromptPanel.svelte b/packages/frontend/src/lib/components/SystemPromptPanel.svelte new file mode 100644 index 0000000..6b91ebe --- /dev/null +++ b/packages/frontend/src/lib/components/SystemPromptPanel.svelte @@ -0,0 +1,61 @@ + + +
+
System Prompt
+

The base instructions sent to the AI at the start of every conversation. Tool descriptions are appended automatically. Changes are applied when you send your next message.

+ + + + + +

Warning: changing the system prompt will reset the AI's prompt cache for active conversations, which may increase usage costs.

+
diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts index 7acc55a..6c35efd 100644 --- a/packages/frontend/src/lib/settings.svelte.ts +++ b/packages/frontend/src/lib/settings.svelte.ts @@ -1,8 +1,23 @@ /** Shared reactive app settings. */ 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 }); 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 toolPermsDirty() { + return Object.keys(toolPerms).some((k) => toolPerms[k] !== savedToolPerms[k]); + }, }; diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index 9ebfaed..e38a6e3 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -1,4 +1,5 @@ import { config } from "./config.js"; +import { appSettings } from "./settings.svelte.js"; import type { AgentEvent, ChatMessage, ContentSegment, DebugInfo, LogEntry, PermissionPrompt, TaskItem } from "./types.js"; import { wsClient } from "./ws.svelte.js"; @@ -335,6 +336,38 @@ function createTabStore() { }).catch(() => {}); } + // Save settings to DB before sending (bakes in on send) + const settingsSaves: Promise[] = []; + + if (appSettings.systemPrompt !== appSettings.savedSystemPrompt) { + appSettings.savedSystemPrompt = appSettings.systemPrompt; + settingsSaves.push( + fetch(`${config.apiBase}/tabs/settings/system_prompt`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: appSettings.systemPrompt }), + }).catch(() => {}), + ); + } + + if (appSettings.toolPermsDirty) { + const perms = appSettings.toolPerms; + appSettings.savedToolPerms = { ...perms }; + for (const [id, enabled] of Object.entries(perms)) { + settingsSaves.push( + fetch(`${config.apiBase}/tabs/settings/perm_${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: enabled ? "allow" : "ask" }), + }).catch(() => {}), + ); + } + } + + if (settingsSaves.length > 0) { + await Promise.all(settingsSaves); + } + try { const res = await fetch(`${config.apiBase}/chat`, { method: "POST", -- cgit v1.2.3