summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-21 20:26:07 +0900
committerAdam Malczewski <[email protected]>2026-05-21 20:26:07 +0900
commit13b670e5e93dc0243f970832673385e9855a1df6 (patch)
tree0d252be4410b04e28867efa569064bf9608840d3
parent4ba2673557c7cfd0bc31b03e2c35ab5e1efe60e7 (diff)
downloaddispatch-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.ts21
-rw-r--r--packages/api/src/routes/tabs.ts31
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte3
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte5
-rw-r--r--packages/frontend/src/lib/components/PermissionLog.svelte116
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte90
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte10
-rw-r--r--packages/frontend/src/lib/settings.svelte.ts8
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; },
+};