summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-21 20:59:24 +0900
committerAdam Malczewski <[email protected]>2026-05-21 20:59:24 +0900
commit1e13f79899622dd8a5c268b5b8e854b14f82d87f (patch)
treeb15c3002c26a74e34e909e8d00e1d47e532634e0
parent13b670e5e93dc0243f970832673385e9855a1df6 (diff)
downloaddispatch-1e13f79899622dd8a5c268b5b8e854b14f82d87f.tar.gz
dispatch-1e13f79899622dd8a5c268b5b8e854b14f82d87f.zip
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
-rw-r--r--packages/api/src/agent-manager.ts55
-rw-r--r--packages/frontend/src/App.svelte17
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte4
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte17
-rw-r--r--packages/frontend/src/lib/components/PermissionLog.svelte43
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte9
-rw-r--r--packages/frontend/src/lib/components/SystemPromptPanel.svelte61
-rw-r--r--packages/frontend/src/lib/settings.svelte.ts15
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts33
9 files changed, 203 insertions, 51 deletions
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<string, string> = {
+ 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<typeof createReadFileTool> }> = [];
+ 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(() => {
<div class="flex flex-col h-screen overflow-hidden">
<Header onToggleSidebar={() => sidebarOpen = !sidebarOpen} />
- <div class="flex flex-1 overflow-hidden">
+ <div class="flex flex-1 overflow-hidden relative">
<!-- Main chat area -->
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
<TabBar />
@@ -76,9 +76,11 @@ onMount(() => {
<ChatInput />
</div>
- <!-- Right sidebar -->
+ <!-- Right sidebar: overlay on small screens, inline on large -->
<div
- class="shrink-0 overflow-x-hidden flex flex-col transition-[width] duration-300 ease-out relative"
+ class="shrink-0 overflow-x-hidden flex flex-col transition-[width] duration-300 ease-out
+ sm:relative sm:z-auto
+ absolute right-0 top-0 bottom-0 z-30"
class:w-80={sidebarOpen}
class:w-0={!sidebarOpen}
>
@@ -109,6 +111,15 @@ onMount(() => {
</div>
</div>
+<!-- Backdrop for sidebar on small screens -->
+{#if sidebarOpen}
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <div
+ class="fixed inset-0 bg-black/30 z-20 sm:hidden"
+ onclick={() => sidebarOpen = false}
+ ></div>
+{/if}
+
<!-- Fixed overlay elements -->
<PermissionPrompt
pending={tabStore.pendingPermissions}
diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte
index 4db57c3..398bee8 100644
--- a/packages/frontend/src/lib/components/ChatMessage.svelte
+++ b/packages/frontend/src/lib/components/ChatMessage.svelte
@@ -21,8 +21,8 @@ const isSystem = $derived(message.role === "system");
</div>
</div>
{:else}
-<div class="chat chat-start mb-2">
- <div class="chat-bubble max-w-[80%] break-words {isUser ? 'chat-bubble-primary' : 'bg-transparent'}">
+<div class="chat chat-start mb-2 [&>.chat-bubble]:max-w-full">
+ <div class="chat-bubble break-words {isUser ? 'chat-bubble-primary w-fit' : 'bg-transparent w-full'}">
{#if message.thinking}
<div class="collapse collapse-arrow mb-2 p-1">
<input type="checkbox" checked={appSettings.autoExpandThinking} />
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 @@
<div class="flex items-center justify-between mt-2">
<span class="text-sm font-medium">Model</span>
- <button class="btn btn-sm btn-outline" onclick={openModelModal} disabled={!activeKeyId}>
+ <button class="btn btn-sm btn-outline" onclick={() => openModelModal()} disabled={!activeKeyId}>
{activeModelId ?? "Select Model"}
</button>
</div>
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 @@
<script lang="ts">
+import { onMount } from "svelte";
import type { LogEntry } from "../types.js";
+import { appSettings } from "../settings.svelte.js";
const { entries, apiBase = "" }: { entries: LogEntry[]; apiBase?: string } = $props();
@@ -16,46 +18,41 @@ const toolPermissions: ToolPermission[] = [
{ id: "external_directory", label: "External directories", description: "Allow access to files outside the workspace" },
];
-let permStates = $state<Record<string, boolean>>({
- read: true,
- edit: false,
- bash: false,
- external_directory: false,
-});
-
async function loadPermissions(): Promise<void> {
+ const loaded: Record<string, boolean> = { ...appSettings.toolPerms };
for (const perm of toolPermissions) {
try {
const res = await fetch(`${apiBase}/tabs/settings/perm_${perm.id}`);
if (res.ok) {
const data = await res.json() as { value: string | null };
if (data.value !== null) {
- permStates[perm.id] = data.value === "allow";
+ loaded[perm.id] = data.value === "allow";
}
}
} catch {
// ignore
}
}
+ appSettings.toolPerms = { ...loaded };
+ appSettings.savedToolPerms = { ...loaded };
}
-async function togglePermission(id: string): Promise<void> {
- permStates[id] = !permStates[id];
- const value = permStates[id] ? "allow" : "ask";
- fetch(`${apiBase}/tabs/settings/perm_${id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ value }),
- }).catch(() => {});
+function togglePermission(id: string): void {
+ appSettings.toolPerms = { ...appSettings.toolPerms, [id]: !appSettings.toolPerms[id] };
}
-$effect(() => {
- void loadPermissions();
+function resetPermissions(): void {
+ appSettings.toolPerms = { ...appSettings.savedToolPerms };
+}
+
+onMount(() => {
+ loadPermissions();
});
</script>
<div class="flex flex-col gap-3">
<div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Tool Permissions</div>
+ <p class="text-xs text-base-content/40">Changes are applied when you send your next message.</p>
<div class="flex flex-col gap-1.5">
{#each toolPermissions as perm (perm.id)}
@@ -63,7 +60,7 @@ $effect(() => {
<input
type="checkbox"
class="checkbox checkbox-sm rounded-sm mt-0.5"
- checked={permStates[perm.id]}
+ checked={appSettings.toolPerms[perm.id]}
onchange={() => togglePermission(perm.id)}
/>
<div class="flex flex-col">
@@ -74,6 +71,14 @@ $effect(() => {
{/each}
</div>
+ <button
+ class="btn btn-sm btn-ghost w-full"
+ disabled={!appSettings.toolPermsDirty}
+ onclick={resetPermissions}
+ >
+ Reset
+ </button>
+
<p class="text-xs text-base-content/40">Warning: changing tool access will reset the AI's prompt cache for active conversations, which may increase usage costs.</p>
<!-- Permission Log -->
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<Panel[]>([{ 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 @@
<ConfigPanel {apiBase} />
{:else if panel.selected === "Skills"}
<SkillsBrowser {apiBase} />
- {:else if panel.selected === "Permissions"}
+ {:else if panel.selected === "Tools"}
<PermissionLog entries={permissionLog} {apiBase} />
- {:else if panel.selected === "Settings"}
+ {:else if panel.selected === "System Prompt"}
+ <SystemPromptPanel {apiBase} />
+ {:else if panel.selected === "Settings"}
<SettingsPanel {keys} {apiBase} />
{/if}
</div>
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 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import { appSettings } from "../settings.svelte.js";
+
+ const {
+ apiBase = "",
+ }: {
+ apiBase?: string;
+ } = $props();
+
+ const DEFAULT_PROMPT = "You are Dispatch, a helpful AI coding assistant. Be concise and helpful.";
+
+ async function loadPrompt(): Promise<void> {
+ try {
+ const res = await fetch(`${apiBase}/tabs/settings/system_prompt`);
+ if (res.ok) {
+ const data = await res.json() as { value: string | null };
+ const value = data.value ?? DEFAULT_PROMPT;
+ appSettings.systemPrompt = value;
+ appSettings.savedSystemPrompt = value;
+ }
+ } catch {
+ // ignore
+ }
+ }
+
+ function resetPrompt(): void {
+ appSettings.systemPrompt = appSettings.savedSystemPrompt;
+ }
+
+ const isDirty = $derived(appSettings.systemPrompt !== appSettings.savedSystemPrompt);
+
+ onMount(() => {
+ if (!appSettings.systemPrompt) {
+ appSettings.systemPrompt = DEFAULT_PROMPT;
+ appSettings.savedSystemPrompt = DEFAULT_PROMPT;
+ }
+ loadPrompt();
+ });
+</script>
+
+<div class="flex flex-col gap-3 flex-1 min-h-0">
+ <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">System Prompt</div>
+ <p class="text-xs text-base-content/40">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.</p>
+
+ <textarea
+ class="textarea textarea-bordered w-full flex-1 min-h-32 text-xs font-mono leading-relaxed"
+ bind:value={appSettings.systemPrompt}
+ placeholder="Enter system prompt..."
+ ></textarea>
+
+ <button
+ class="btn btn-sm btn-ghost w-full"
+ disabled={!isDirty}
+ onclick={resetPrompt}
+ >
+ Reset
+ </button>
+
+ <p class="text-xs text-base-content/40">Warning: changing the system prompt will reset the AI's prompt cache for active conversations, which may increase usage costs.</p>
+</div>
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<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 });
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 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<unknown>[] = [];
+
+ 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",