diff options
| author | Adam Malczewski <[email protected]> | 2026-05-21 20:26:07 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-21 20:26:07 +0900 |
| commit | 13b670e5e93dc0243f970832673385e9855a1df6 (patch) | |
| tree | 0d252be4410b04e28867efa569064bf9608840d3 | |
| parent | 4ba2673557c7cfd0bc31b03e2c35ab5e1efe60e7 (diff) | |
| download | dispatch-13b670e5e93dc0243f970832673385e9855a1df6.tar.gz dispatch-13b670e5e93dc0243f970832673385e9855a1df6.zip | |
feat: tool permission toggles, settings improvements, UI polish
- Tool permissions (read, edit, bash) stored in DB and control Agent tool registration
- Agent invalidated when permissions change; cache warning in Permissions panel
- Default: read=allow, edit=ask, bash=ask (matches UI checkboxes)
- Settings: auto-save title model on selection (removed save button)
- Settings: auto-expand thinking checkbox with shared reactive store
- Permissions panel: renamed from Permission Log, shows tool toggles + collapsible log
- Sidebar: renamed Current Model to Model Choice
- Chat input: allow typing while agent runs (just disable send)
- Chat cursor: fix doubled rectangle (removed unicode char)
- Generic GET/PUT /tabs/settings/:key endpoints for key-value settings
| -rw-r--r-- | packages/api/src/agent-manager.ts | 21 | ||||
| -rw-r--r-- | packages/api/src/routes/tabs.ts | 31 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatInput.svelte | 3 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatMessage.svelte | 5 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/PermissionLog.svelte | 116 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SettingsPanel.svelte | 90 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 10 | ||||
| -rw-r--r-- | packages/frontend/src/lib/settings.svelte.ts | 8 |
8 files changed, 205 insertions, 79 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 07af249..9d222fa 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -24,6 +24,7 @@ import { refreshAccountCredentials, refreshAccountCredentialsAsync, resolveApiKey, + getSetting, } from "@dispatch/core"; import type { PermissionManager } from "./permission-manager.js"; import { setConfigGetter } from "./routes/config.js"; @@ -47,6 +48,7 @@ interface TabAgent { keyId: string | null; modelId: string | null; taskList: TaskList; + _lastPermKey?: string; } export class AgentManager { @@ -193,10 +195,16 @@ export class AgentManager { const effectiveKeyId = keyId ?? tabAgent.keyId; const effectiveModelId = modelId ?? tabAgent.modelId; - // If the override differs from what the current agent was built with, invalidate the cache + // Read tool permission settings from DB (default: read=allow, edit=ask, bash=ask) + const permRead = getSetting("perm_read") !== "ask"; + const permEdit = getSetting("perm_edit") === "allow"; + const permBash = getSetting("perm_bash") === "allow"; + const permKey = `${permRead}:${permEdit}:${permBash}`; + + // If the override differs or permissions changed, invalidate the cached agent if ( tabAgent.agent && - (effectiveKeyId !== tabAgent.keyId || effectiveModelId !== tabAgent.modelId) + (effectiveKeyId !== tabAgent.keyId || effectiveModelId !== tabAgent.modelId || permKey !== tabAgent._lastPermKey) ) { tabAgent.agent = null; } @@ -204,13 +212,14 @@ export class AgentManager { if (!tabAgent.agent) { const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); + // Build tools list based on permission settings const tools = [ - createReadFileTool(workingDirectory), - createWriteFileTool(workingDirectory), - createListFilesTool(workingDirectory), - createRunShellTool(workingDirectory), + ...(permRead ? [createReadFileTool(workingDirectory), createListFilesTool(workingDirectory)] : []), + ...(permEdit ? [createWriteFileTool(workingDirectory)] : []), + ...(permBash ? [createRunShellTool(workingDirectory)] : []), createTaskListTool(tabAgent.taskList), ]; + tabAgent._lastPermKey = permKey; const ruleset = configToRuleset(this.config); diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts index 7fe05d9..288cd51 100644 --- a/packages/api/src/routes/tabs.ts +++ b/packages/api/src/routes/tabs.ts @@ -10,6 +10,7 @@ import { getMessagesForTab, getSetting, setSetting, + deleteSetting, } from "@dispatch/core"; export const tabsRoutes = new Hono(); @@ -41,9 +42,15 @@ tabsRoutes.get("/settings/title-model", (c) => { }); tabsRoutes.put("/settings/title-model", async (c) => { - const body = await c.req.json<{ keyId?: string; modelId?: string }>(); - if (body.keyId) setSetting("title_model_key_id", body.keyId); - if (body.modelId) setSetting("title_model_id", body.modelId); + const body = await c.req.json<{ keyId?: string | null; modelId?: string | null }>(); + if (body.keyId !== undefined) { + if (body.keyId) setSetting("title_model_key_id", body.keyId); + else deleteSetting("title_model_key_id"); + } + if (body.modelId !== undefined) { + if (body.modelId) setSetting("title_model_id", body.modelId); + else deleteSetting("title_model_id"); + } return c.json({ success: true }); }); @@ -72,6 +79,24 @@ tabsRoutes.patch("/:id", async (c) => { return c.json(tab); }); +// ─── Settings ───────────────────────────────────────────────── + +tabsRoutes.get("/settings/:key", (c) => { + const key = c.req.param("key"); + const value = getSetting(key); + return c.json({ value }); +}); + +tabsRoutes.put("/settings/:key", async (c) => { + const key = c.req.param("key"); + const body = await c.req.json<{ value?: string }>(); + if (typeof body.value !== "string") { + return c.json({ error: "value is required" }, 400); + } + setSetting(key, body.value); + return c.json({ success: true }); +}); + tabsRoutes.delete("/:id", (c) => { const id = c.req.param("id"); const mgr = getAgentManager(); diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte index 9563a9f..8910bb4 100644 --- a/packages/frontend/src/lib/components/ChatInput.svelte +++ b/packages/frontend/src/lib/components/ChatInput.svelte @@ -29,9 +29,8 @@ function submit() { bind:this={inputEl} bind:value={inputValue} type="text" - placeholder={isDisabled ? "Agent is running..." : "Type a message..."} + placeholder="Type a message..." class="input flex-1" - disabled={isDisabled} onkeydown={handleKeydown} /> <button diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte index c8b3f6e..4db57c3 100644 --- a/packages/frontend/src/lib/components/ChatMessage.svelte +++ b/packages/frontend/src/lib/components/ChatMessage.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import type { ChatMessage } from "../types.js"; +import { appSettings } from "../settings.svelte.js"; import MarkdownRenderer from "./MarkdownRenderer.svelte"; import ToolCallDisplay from "./ToolCallDisplay.svelte"; @@ -24,7 +25,7 @@ const isSystem = $derived(message.role === "system"); <div class="chat-bubble max-w-[80%] break-words {isUser ? 'chat-bubble-primary' : 'bg-transparent'}"> {#if message.thinking} <div class="collapse collapse-arrow mb-2 p-1"> - <input type="checkbox" /> + <input type="checkbox" checked={appSettings.autoExpandThinking} /> <div class="collapse-title text-sm opacity-60 italic py-0 pl-0 pr-8 min-h-0">Thinking...</div> <div class="collapse-content text-sm opacity-60 italic p-0"> <p class="whitespace-pre-wrap mt-1">{message.thinking}</p> @@ -39,7 +40,7 @@ const isSystem = $derived(message.role === "system"); {/if} {/each} {#if message.isStreaming} - <span class="inline-block w-2 h-4 bg-current animate-pulse ml-0.5 align-middle">▌</span> + <span class="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5 align-middle rounded-sm"></span> {/if} </div> </div> diff --git a/packages/frontend/src/lib/components/PermissionLog.svelte b/packages/frontend/src/lib/components/PermissionLog.svelte index f6e07f6..06607de 100644 --- a/packages/frontend/src/lib/components/PermissionLog.svelte +++ b/packages/frontend/src/lib/components/PermissionLog.svelte @@ -1,28 +1,100 @@ <script lang="ts"> import type { LogEntry } from "../types.js"; -const { entries }: { entries: LogEntry[] } = $props(); +const { entries, apiBase = "" }: { entries: LogEntry[]; apiBase?: string } = $props(); + +interface ToolPermission { + id: string; + label: string; + description: string; +} + +const toolPermissions: ToolPermission[] = [ + { id: "read", label: "Read files", description: "Allow the AI to read files in the workspace" }, + { id: "edit", label: "Edit files", description: "Allow the AI to write/edit files in the workspace" }, + { id: "bash", label: "Run commands", description: "Allow the AI to execute shell commands" }, + { 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> { + 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"; + } + } + } catch { + // ignore + } + } +} + +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(() => {}); +} + +$effect(() => { + void loadPermissions(); +}); </script> -<div class="collapse collapse-arrow bg-base-200 mt-4"> - <input type="checkbox" /> - <div class="collapse-title text-sm font-medium"> - Permission Log ({entries.length}) - </div> - <div class="collapse-content text-xs max-h-40 overflow-y-auto"> - {#if entries.length === 0} - <p class="text-base-content/50 italic">No permissions granted or denied yet.</p> - {:else} - {#each entries as entry (entry.id)} - <div class="flex items-center gap-2 py-1 border-b border-base-300"> - <span class="badge badge-sm {entry.action === 'reject' ? 'badge-error' : 'badge-success'}"> - {entry.action} - </span> - <span class="text-base-content/70">{entry.permission}</span> - <span class="text-base-content/50 ml-auto text-xs">{entry.timestamp}</span> - </div> - <p class="text-base-content/60 pl-2 pb-1">{entry.description}</p> - {/each} - {/if} - </div> +<div class="flex flex-col gap-3"> + <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Tool Permissions</div> + + <div class="flex flex-col gap-1.5"> + {#each toolPermissions as perm (perm.id)} + <label class="flex items-start gap-2 cursor-pointer p-1 rounded hover:bg-base-200 transition-colors"> + <input + type="checkbox" + class="checkbox checkbox-sm rounded-sm mt-0.5" + checked={permStates[perm.id]} + onchange={() => togglePermission(perm.id)} + /> + <div class="flex flex-col"> + <span class="text-xs font-medium text-base-content">{perm.label}</span> + <span class="text-xs text-base-content/40">{perm.description}</span> + </div> + </label> + {/each} + </div> + + <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 --> + {#if entries.length > 0} + <div class="collapse collapse-arrow bg-base-200 mt-2"> + <input type="checkbox" /> + <div class="collapse-title text-sm font-medium py-2 min-h-0"> + Log ({entries.length}) + </div> + <div class="collapse-content text-xs max-h-40 overflow-y-auto"> + {#each entries as entry (entry.id)} + <div class="flex items-center gap-2 py-1 border-b border-base-300"> + <span class="badge badge-sm {entry.action === 'reject' ? 'badge-error' : 'badge-success'}"> + {entry.action} + </span> + <span class="text-base-content/70">{entry.permission}</span> + <span class="text-base-content/50 ml-auto text-xs">{entry.timestamp}</span> + </div> + <p class="text-base-content/60 pl-2 pb-1">{entry.description}</p> + {/each} + </div> + </div> + {/if} </div> diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index 8449aa4..6580308 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import type { KeyInfo } from "../types.js"; + import { appSettings } from "../settings.svelte.js"; const { keys = [], @@ -13,25 +14,44 @@ let titleModelId = $state<string | null>(null); let availableModels = $state<string[]>([]); let loadingModels = $state(false); - let saving = $state(false); - let saved = $state(false); + let autoExpandThinking = $state(appSettings.autoExpandThinking); async function loadSettings(): Promise<void> { try { const res = await fetch(`${apiBase}/tabs/settings/title-model`); - if (!res.ok) return; - const data = await res.json() as { keyId: string | null; modelId: string | null }; - titleKeyId = data.keyId; - if (titleKeyId) { - await loadModelsForKey(titleKeyId); + if (res.ok) { + const data = await res.json() as { keyId: string | null; modelId: string | null }; + titleKeyId = data.keyId; + if (titleKeyId) { + await loadModelsForKey(titleKeyId); + } + titleModelId = data.modelId; + } + } catch { + // ignore + } + try { + const res = await fetch(`${apiBase}/tabs/settings/auto-expand-thinking`); + if (res.ok) { + const data = await res.json() as { value: string | null }; + autoExpandThinking = data.value === "true"; + appSettings.autoExpandThinking = autoExpandThinking; } - // Set model AFTER options are loaded so the select can match the value - titleModelId = data.modelId; } catch { // ignore } } + async function toggleAutoExpand(): Promise<void> { + autoExpandThinking = !autoExpandThinking; + appSettings.autoExpandThinking = autoExpandThinking; + fetch(`${apiBase}/tabs/settings/auto-expand-thinking`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: String(autoExpandThinking) }), + }).catch(() => {}); + } + async function loadModelsForKey(keyId: string): Promise<void> { loadingModels = true; try { @@ -46,6 +66,14 @@ } } + function saveTitleModel(): void { + fetch(`${apiBase}/tabs/settings/title-model`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keyId: titleKeyId, modelId: titleModelId }), + }).catch(() => {}); + } + async function onKeyChange(e: Event): Promise<void> { const select = e.target as HTMLSelectElement; titleKeyId = select.value || null; @@ -54,28 +82,13 @@ if (titleKeyId) { await loadModelsForKey(titleKeyId); } + saveTitleModel(); } async function onModelChange(e: Event): Promise<void> { const select = e.target as HTMLSelectElement; titleModelId = select.value || null; - } - - async function saveSettings(): Promise<void> { - saving = true; - try { - await fetch(`${apiBase}/tabs/settings/title-model`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ keyId: titleKeyId, modelId: titleModelId }), - }); - saved = true; - setTimeout(() => { saved = false; }, 2000); - } catch { - // ignore - } finally { - saving = false; - } + saveTitleModel(); } $effect(() => { @@ -111,18 +124,17 @@ {/each} </select> - <button - class="btn btn-sm btn-primary w-full mt-1" - disabled={saving || !titleKeyId || !titleModelId} - onclick={saveSettings} - > - {#if saving} - <span class="loading loading-spinner loading-xs"></span> - {:else if saved} - Saved - {:else} - Save - {/if} - </button> + <div class="divider my-0"></div> + + <p class="text-xs text-base-content/70">Chat</p> + <label class="flex items-center gap-2 cursor-pointer"> + <input + type="checkbox" + class="checkbox checkbox-sm rounded-sm" + checked={autoExpandThinking} + onchange={toggleAutoExpand} + /> + <span class="text-xs text-base-content/70">Auto-expand thinking</span> + </label> </div> </div> diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index 7e71bd5..9aca408 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -40,9 +40,9 @@ } let nextId = 0; - let panels = $state<Panel[]>([{ id: nextId++, selected: "Current Model" }]); + let panels = $state<Panel[]>([{ id: nextId++, selected: "Model Choice" }]); - const viewOptions = ["Select a view", "Current Model", "Key Usage", "Claude Reset", "Model Status", "Tasks", "Config", "Skills", "Permission Log", "Settings"]; + const viewOptions = ["Select a view", "Model Choice", "Key Usage", "Claude Reset", "Model Status", "Tasks", "Config", "Skills", "Permissions", "Settings"]; function addPanel() { panels = [...panels, { id: nextId++, selected: "Select a view" }]; @@ -91,7 +91,7 @@ </div> <div class={contentClass(panel.selected)}> - {#if panel.selected === "Current Model"} + {#if panel.selected === "Model Choice"} <ModelSelector {keys} {activeKeyId} @@ -113,8 +113,8 @@ <ConfigPanel {apiBase} /> {:else if panel.selected === "Skills"} <SkillsBrowser {apiBase} /> - {:else if panel.selected === "Permission Log"} - <PermissionLog entries={permissionLog} /> + {:else if panel.selected === "Permissions"} + <PermissionLog entries={permissionLog} {apiBase} /> {:else if panel.selected === "Settings"} <SettingsPanel {keys} {apiBase} /> {/if} diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts new file mode 100644 index 0000000..7acc55a --- /dev/null +++ b/packages/frontend/src/lib/settings.svelte.ts @@ -0,0 +1,8 @@ +/** Shared reactive app settings. */ + +let autoExpandThinking = $state(false); + +export const appSettings = { + get autoExpandThinking() { return autoExpandThinking; }, + set autoExpandThinking(v: boolean) { autoExpandThinking = v; }, +}; |
