From 8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 22 May 2026 04:09:00 +0900 Subject: feat: two-row tab bar with temp/persistent subagent tabs, tab persistence - Split tab bar into user tabs (top) and subagent tabs (bottom row) - Bottom row only visible when active user tab has child agents - Parent user tab stays highlighted when viewing a subagent tab - Temp tabs auto-disappear when agent completes, click to promote to persistent - 'Open Tab' button on summon tool calls to view/reopen subagent history - Reopening archived tabs fetches full chat history from backend - Add parent_tab_id column to DB with safe ALTER TABLE migration - Persist keyId, modelId, parentTabId on child tab creation - Flush partial assistant messages on abort/error (finally block) - Defensive JSON.parse in message restoration - Allow all Vite hosts for local development - Fix nested button in ToolCallDisplay (button inside button) --- packages/api/src/agent-manager.ts | 72 ++++++++++++---- packages/core/src/db/index.ts | 27 +++--- packages/core/src/db/tabs.ts | 77 ++++++++++++++--- packages/core/src/types/index.ts | 1 + packages/frontend/src/lib/components/TabBar.svelte | 55 +++++++++++- .../src/lib/components/ToolCallDisplay.svelte | 23 ++++- packages/frontend/src/lib/tabs.svelte.ts | 98 +++++++++++++++++++++- packages/frontend/src/lib/types.ts | 1 + packages/frontend/vite.config.ts | 1 + 9 files changed, 309 insertions(+), 46 deletions(-) (limited to 'packages') diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index e4f284d..7e814af 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -338,6 +338,7 @@ export class AgentManager { parentKeyId: tabAgent.keyId, parentModelId: tabAgent.modelId, parentAllowedTools: childParentAllowedTools, + parentTabId: tabId, }), }), }); @@ -373,6 +374,7 @@ export class AgentManager { parentKeyId: tabAgent.keyId, parentModelId: tabAgent.modelId, parentAllowedTools, + parentTabId: tabId, }), }), }); @@ -566,6 +568,7 @@ export class AgentManager { parentKeyId?: string | null; parentModelId?: string | null; parentAllowedTools?: Set; + parentTabId?: string; }): Promise { const tabId = crypto.randomUUID(); const title = options.task.length > 50 ? `${options.task.slice(0, 47)}...` : options.task; @@ -605,14 +608,25 @@ export class AgentManager { // Create tab in DB try { const { createTab } = await import("@dispatch/core"); - createTab(tabId, title); + createTab(tabId, title, { + keyId: tabAgent.keyId, + modelId: tabAgent.modelId, + parentTabId: options.parentTabId, + }); } catch { // Continue even if DB fails } // Notify the frontend about the new tab this.emit( - { type: "tab-created", id: tabId, title, keyId: tabAgent.keyId, modelId: tabAgent.modelId }, + { + type: "tab-created", + id: tabId, + title, + keyId: tabAgent.keyId, + modelId: tabAgent.modelId, + parentTabId: options.parentTabId ?? null, + }, tabId, ); @@ -668,6 +682,18 @@ export class AgentManager { tabAgent.status = "running"; this.messageCount += 1; + let allOutput = ""; + let assistantText = ""; + let assistantThinking = ""; + const assistantToolCalls: Array<{ + id: string; + name: string; + arguments: Record; + result?: string; + isError?: boolean; + }> = []; + let processError: string | null = null; + try { const agent = await this.getOrCreateAgentForTab(tabId, keyId, modelId); @@ -679,17 +705,6 @@ export class AgentManager { JSON.stringify([{ type: "text", text: message }]), ); - let allOutput = ""; - let assistantText = ""; - let assistantThinking = ""; - const assistantToolCalls: Array<{ - id: string; - name: string; - arguments: Record; - result?: string; - isError?: boolean; - }> = []; - for await (const event of agent.run( message, reasoningEffort ? { reasoningEffort } : undefined, @@ -742,15 +757,38 @@ export class AgentManager { assistantToolCalls.length = 0; } } - // Resolve completion promise for child agents - tabAgent.finalOutput = allOutput; - tabAgent.completionResolve?.({ status: "done", result: allOutput || "(no output)" }); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); + processError = errorMsg; tabAgent.status = "error"; this.emit({ type: "error", error: errorMsg }, tabId); this.emit({ type: "status", status: "error" }, tabId); - tabAgent.completionResolve?.({ status: "error", error: errorMsg }); + } finally { + // Flush any accumulated assistant content that wasn't saved by a done event + // (happens when the agent is aborted mid-stream or throws an error) + if (assistantText || assistantToolCalls.length > 0) { + const contentSegments: Array> = []; + if (assistantText) contentSegments.push({ type: "text", text: assistantText }); + for (const tc of assistantToolCalls) { + contentSegments.push({ type: "tool-call", ...tc }); + } + if (contentSegments.length > 0) { + appendMessage( + tabId, + crypto.randomUUID(), + "assistant", + JSON.stringify(contentSegments), + assistantThinking || undefined, + ); + } + } + // Resolve completion promise for child agents + if (processError === null) { + tabAgent.finalOutput = allOutput; + tabAgent.completionResolve?.({ status: "done", result: allOutput || "(no output)" }); + } else { + tabAgent.completionResolve?.({ status: "error", error: processError }); + } } } diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts index 2992708..81b474e 100644 --- a/packages/core/src/db/index.ts +++ b/packages/core/src/db/index.ts @@ -1,7 +1,7 @@ import { Database } from "bun:sqlite"; import { existsSync, mkdirSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; /** * Returns the directory for persistent Dispatch data, following XDG Base @@ -75,17 +75,24 @@ export function getDatabase(): Database { )`); _db.run(`CREATE TABLE IF NOT EXISTS tabs ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - key_id TEXT, - model_id TEXT, - status TEXT NOT NULL DEFAULT 'idle', - is_open INTEGER NOT NULL DEFAULT 1, - position INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + key_id TEXT, + model_id TEXT, + parent_tab_id TEXT, + status TEXT NOT NULL DEFAULT 'idle', + is_open INTEGER NOT NULL DEFAULT 1, + position INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL )`); + try { + _db.run("ALTER TABLE tabs ADD COLUMN parent_tab_id TEXT"); + } catch { + // Column already exists — ignore + } + _db.run(`CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, tab_id TEXT NOT NULL REFERENCES tabs(id), diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts index 0f4511f..c0ce809 100644 --- a/packages/core/src/db/tabs.ts +++ b/packages/core/src/db/tabs.ts @@ -5,6 +5,7 @@ export interface TabRow { title: string; keyId: string | null; modelId: string | null; + parentTabId: string | null; status: string; isOpen: boolean; position: number; @@ -18,6 +19,7 @@ function rowToTab(row: Record): TabRow { title: row.title as string, keyId: row.key_id as string | null, modelId: row.model_id as string | null, + parentTabId: (row.parent_tab_id as string) ?? null, status: row.status as string, isOpen: (row.is_open as number) === 1, position: row.position as number, @@ -26,48 +28,97 @@ function rowToTab(row: Record): TabRow { }; } -export function createTab(id: string, title: string): TabRow { +export function createTab( + id: string, + title: string, + options?: { keyId?: string | null; modelId?: string | null; parentTabId?: string | null }, +): TabRow { const db = getDatabase(); const now = Date.now(); - const maxPos = db.query("SELECT COALESCE(MAX(position), -1) as max_pos FROM tabs WHERE is_open = 1").get() as { max_pos: number }; + const maxPos = db + .query("SELECT COALESCE(MAX(position), -1) as max_pos FROM tabs WHERE is_open = 1") + .get() as { max_pos: number }; const position = (maxPos?.max_pos ?? -1) + 1; + const keyId = options?.keyId ?? null; + const modelId = options?.modelId ?? null; + const parentTabId = options?.parentTabId ?? null; db.query( - `INSERT INTO tabs (id, title, key_id, model_id, status, is_open, position, created_at, updated_at) - VALUES ($id, $title, NULL, NULL, 'idle', 1, $position, $now, $now)`, - ).run({ $id: id, $title: title, $position: position, $now: now }); - return { id, title, keyId: null, modelId: null, status: "idle", isOpen: true, position, createdAt: now, updatedAt: now }; + `INSERT INTO tabs (id, title, key_id, model_id, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ($id, $title, $keyId, $modelId, $parentTabId, 'idle', 1, $position, $now, $now)`, + ).run({ + $id: id, + $title: title, + $keyId: keyId, + $modelId: modelId, + $parentTabId: parentTabId, + $position: position, + $now: now, + }); + return { + id, + title, + keyId, + modelId, + parentTabId, + status: "idle", + isOpen: true, + position, + createdAt: now, + updatedAt: now, + }; } export function getTab(id: string): TabRow | null { const db = getDatabase(); - const row = db.query("SELECT * FROM tabs WHERE id = $id").get({ $id: id }) as Record | null; + const row = db.query("SELECT * FROM tabs WHERE id = $id").get({ $id: id }) as Record< + string, + unknown + > | null; return row ? rowToTab(row) : null; } export function listOpenTabs(): TabRow[] { const db = getDatabase(); - const rows = db.query("SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC").all() as Array>; + const rows = db + .query("SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC") + .all() as Array>; return rows.map(rowToTab); } export function updateTabTitle(id: string, title: string): void { const db = getDatabase(); - db.query("UPDATE tabs SET title = $title, updated_at = $now WHERE id = $id").run({ $id: id, $title: title, $now: Date.now() }); + db.query("UPDATE tabs SET title = $title, updated_at = $now WHERE id = $id").run({ + $id: id, + $title: title, + $now: Date.now(), + }); } export function updateTabModel(id: string, keyId: string | null, modelId: string | null): void { const db = getDatabase(); - db.query("UPDATE tabs SET key_id = $keyId, model_id = $modelId, updated_at = $now WHERE id = $id").run({ - $id: id, $keyId: keyId, $modelId: modelId, $now: Date.now(), + db.query( + "UPDATE tabs SET key_id = $keyId, model_id = $modelId, updated_at = $now WHERE id = $id", + ).run({ + $id: id, + $keyId: keyId, + $modelId: modelId, + $now: Date.now(), }); } export function updateTabStatus(id: string, status: string): void { const db = getDatabase(); - db.query("UPDATE tabs SET status = $status, updated_at = $now WHERE id = $id").run({ $id: id, $status: status, $now: Date.now() }); + db.query("UPDATE tabs SET status = $status, updated_at = $now WHERE id = $id").run({ + $id: id, + $status: status, + $now: Date.now(), + }); } export function archiveTab(id: string): void { const db = getDatabase(); - db.query("UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id").run({ $id: id, $now: Date.now() }); + db.query("UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id").run({ + $id: id, + $now: Date.now(), + }); } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 8745bc7..eaf8669 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -46,6 +46,7 @@ export type AgentEvent = title: string; keyId: string | null; modelId: string | null; + parentTabId: string | null; }; // ─── Tool Types ────────────────────────────────────────────────── diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte index b5597e1..cfa5887 100644 --- a/packages/frontend/src/lib/components/TabBar.svelte +++ b/packages/frontend/src/lib/components/TabBar.svelte @@ -6,8 +6,23 @@ function statusColor(status: string): string { if (status === "error") return "bg-error"; return "bg-success"; } + +const userTabs = $derived(tabStore.tabs.filter((t) => t.parentTabId === null)); +const subagentTabs = $derived( + tabStore.tabs.filter((t) => t.parentTabId !== null && t.parentTabId === activeUserTabId), +); +const hasSubagentTabs = $derived(subagentTabs.length > 0); + +// When a subagent tab is active, its parent user tab should still appear selected +const activeTab = $derived(tabStore.tabs.find((t) => t.id === tabStore.activeTabId)); +const activeUserTabId = $derived( + activeTab?.parentTabId !== null && activeTab?.parentTabId !== undefined + ? activeTab.parentTabId + : tabStore.activeTabId, +); +
- {#each tabStore.tabs as tab (tab.id)} + {#each userTabs as tab (tab.id)}
+ + +{#if hasSubagentTabs} +
+
+ {#each subagentTabs as tab (tab.id)} + + + {/each} +
+
+{/if} diff --git a/packages/frontend/src/lib/components/ToolCallDisplay.svelte b/packages/frontend/src/lib/components/ToolCallDisplay.svelte index 805de6d..213ba17 100644 --- a/packages/frontend/src/lib/components/ToolCallDisplay.svelte +++ b/packages/frontend/src/lib/components/ToolCallDisplay.svelte @@ -1,4 +1,5 @@
- + {/if} {#if toolCall.result !== undefined} {#if isShell && shellResult !== null} @@ -65,7 +82,7 @@ const shellResult = $derived( {:else} pending {/if} - +
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index 9615c3d..15a0736 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -34,6 +34,10 @@ export interface Tab { currentAssistantId: string | null; tasks: TaskItem[]; injectedSkills: string[]; + /** null = user-owned tab, string = spawned by that tab */ + parentTabId: string | null; + /** Persistent tabs stay until manually closed. Temp tabs disappear when agent finishes. */ + persistent: boolean; } function createTabStore() { @@ -90,6 +94,8 @@ function createTabStore() { currentAssistantId: null, tasks: [], injectedSkills: [], + parentTabId: null, + persistent: true, }; tabs = [...tabs, tab]; activeTabId = id; @@ -106,6 +112,83 @@ function createTabStore() { } } + function promoteTab(id: string): void { + const tab = getTabById(id); + if (!tab) return; + updateTab(id, { persistent: true }); + switchTab(id); + } + + async function openAgentTab(agentId: string): Promise { + const tab = getTabById(agentId); + if (tab) { + updateTab(agentId, { persistent: true }); + switchTab(agentId); + return; + } + + // Tab not found locally — try to fetch from backend + try { + const tabRes = await fetch(`${config.apiBase}/tabs/${agentId}`); + if (!tabRes.ok) return; // 404 or other error — tab doesn't exist + const tabData = (await tabRes.json()) as { + id: string; + title: string; + keyId?: string | null; + modelId?: string | null; + status?: string; + parentTabId?: string | null; + }; + + const messagesRes = await fetch(`${config.apiBase}/tabs/${agentId}/messages`); + const messagesData = messagesRes.ok + ? ((await messagesRes.json()) as { + messages: Array<{ + id?: string; + role: string; + contentJson: string; + thinking: string | null; + }>; + }) + : { messages: [] }; + + const chatMessages: ChatMessage[] = messagesData.messages.flatMap((m) => { + try { + return [ + { + id: m.id ?? generateId(), + role: m.role as ChatMessage["role"], + content: JSON.parse(m.contentJson) as ContentSegment[], + thinking: m.thinking ?? undefined, + isStreaming: false, + }, + ]; + } catch { + return []; + } + }); + + const newTab: Tab = { + id: agentId, + title: tabData.title, + messages: chatMessages, + agentStatus: "idle", + keyId: tabData.keyId ?? null, + modelId: tabData.modelId ?? null, + reasoningEffort: "max", + currentAssistantId: null, + tasks: [], + injectedSkills: [], + parentTabId: tabData.parentTabId ?? null, + persistent: true, + }; + tabs = [...tabs, newTab]; + activeTabId = agentId; + } catch (err) { + console.error("openAgentTab failed:", err); + } + } + async function closeTab(id: string): Promise { const tab = getTabById(id); if (!tab) return; @@ -122,7 +205,11 @@ 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; + const fallback = tabs[tabs.length - 1]; + if (fallback && !fallback.persistent) { + updateTab(fallback.id, { persistent: true }); + } + activeTabId = fallback?.id; } else { await createNewTab(); } @@ -172,6 +259,10 @@ function createTabStore() { updateTab(tabId, { agentStatus: event.status }); if (event.status === "idle" || event.status === "error") { updateTab(tabId, { currentAssistantId: null }); + const tab = getTabById(tabId); + if (tab && !tab.persistent && tabId !== activeTabId) { + tabs = tabs.filter((t) => t.id !== tabId); + } } } break; @@ -339,6 +430,7 @@ function createTabStore() { title: string; keyId: string | null; modelId: string | null; + parentTabId: string | null; }; // Only add if we don't already have this tab if (!getTabById(newTabEvent.id)) { @@ -353,6 +445,8 @@ function createTabStore() { currentAssistantId: null, tasks: [], injectedSkills: [], + parentTabId: newTabEvent.parentTabId ?? null, + persistent: newTabEvent.parentTabId == null, }; tabs = [...tabs, tab]; } @@ -630,6 +724,8 @@ function createTabStore() { setKey, replyPermission, copyConversation, + promoteTab, + openAgentTab, }; } diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index e810864..4523828 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -70,6 +70,7 @@ export type AgentEvent = title: string; keyId: string | null; modelId: string | null; + parentTabId: string | null; }; export interface TaskItem { diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 3604d5e..ca947d0 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ plugins: [tailwindcss(), svelte()], server: { port: 5173, + allowedHosts: true, }, test: { include: ["tests/**/*.test.ts"], -- cgit v1.2.3