diff options
| author | Adam Malczewski <[email protected]> | 2026-05-22 04:09:00 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-22 04:09:00 +0900 |
| commit | 8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 (patch) | |
| tree | 296a5641d8e84365875ecc0819282cfcf5502be0 /packages/frontend/src/lib | |
| parent | e43df9a50ed9ad25dc07a7d5ee90c66d14e8a16f (diff) | |
| download | dispatch-8f1dd855f0c4c877bff8d3a4ba193432b268b1c2.tar.gz dispatch-8f1dd855f0c4c877bff8d3a4ba193432b268b1c2.zip | |
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)
Diffstat (limited to 'packages/frontend/src/lib')
| -rw-r--r-- | packages/frontend/src/lib/components/TabBar.svelte | 55 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ToolCallDisplay.svelte | 23 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 98 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 1 |
4 files changed, 171 insertions, 6 deletions
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, +); </script> +<!-- Top row: user tabs --> <!-- svelte-ignore a11y_no_static_element_interactions --> <div class="overflow-x-auto bg-base-200 flex-shrink-0" @@ -29,11 +44,11 @@ function statusColor(status: string): string { + </button> - {#each tabStore.tabs as tab (tab.id)} + {#each userTabs as tab (tab.id)} <!-- svelte-ignore a11y_no_static_element_interactions --> <div role="tab" - class="tab !flex items-stretch gap-1.5 {tab.id === tabStore.activeTabId ? 'tab-active' : ''}" + class="tab !flex items-stretch gap-1.5 {tab.id === activeUserTabId ? 'tab-active' : ''}" onclick={() => tabStore.switchTab(tab.id)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tabStore.switchTab(tab.id); }} tabindex="0" @@ -54,3 +69,39 @@ function statusColor(status: string): string { {/each} </div> </div> + +<!-- Bottom row: subagent tabs (hidden when empty) --> +{#if hasSubagentTabs} + <div class="overflow-x-auto bg-base-200 flex-shrink-0 border-t border-base-300"> + <div + role="tablist" + class="tabs tabs-lift tabs-xs min-w-max" + > + {#each subagentTabs as tab (tab.id)} + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + role="tab" + class="tab !flex items-stretch gap-1 {tab.id === tabStore.activeTabId ? 'tab-active' : ''} {!tab.persistent ? 'opacity-70 italic' : ''}" + onclick={() => tab.persistent ? tabStore.switchTab(tab.id) : tabStore.promoteTab(tab.id)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tab.persistent ? tabStore.switchTab(tab.id) : tabStore.promoteTab(tab.id); }} + tabindex="0" + > + <span class="flex items-center gap-1"> + <span class="w-1 h-1 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span> + <span class="max-w-28 truncate text-xs">{tab.title}</span> + </span> + {#if tab.persistent} + <button + type="button" + class="flex items-center justify-center px-2 my-0.5 leading-none text-base-content/30 hover:text-error hover:bg-base-300 rounded transition-colors text-xs" + onclick={(e) => { e.stopPropagation(); tabStore.closeTab(tab.id); }} + aria-label="Close tab" + > + ✕ + </button> + {/if} + </div> + {/each} + </div> + </div> +{/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 @@ <script lang="ts"> +import { tabStore } from "../tabs.svelte.js"; import type { ToolCallDisplay } from "../types.js"; const { toolCall }: { toolCall: ToolCallDisplay } = $props(); @@ -41,17 +42,33 @@ const isShell = $derived(toolCall.name === "run_shell"); const shellResult = $derived( isShell && toolCall.result !== undefined ? parseShellResult(toolCall.result) : null, ); + +const summonAgentId = $derived.by(() => { + if (toolCall.name !== "summon" || !toolCall.result) return null; + const match = toolCall.result.match(/agent_id:\s*([a-f0-9-]+)/); + return match ? match[1] : null; +}); </script> <div class="collapse collapse-arrow mb-2 p-1 opacity-60 {isExpanded ? 'collapse-open' : ''}"> - <button - type="button" + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div class="collapse-title flex items-center gap-2 text-sm italic cursor-pointer w-full text-left" onclick={toggle} + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') toggle(); }} aria-expanded={isExpanded} > <span class="badge badge-neutral badge-sm">tool</span> <span class="font-mono">{toolCall.name}</span> + {#if summonAgentId !== null} + <button + type="button" + class="btn btn-xs btn-ghost" + onclick={(e) => { e.stopPropagation(); tabStore.openAgentTab(summonAgentId!); }} + >Open Tab</button> + {/if} {#if toolCall.result !== undefined} {#if isShell && shellResult !== null} <span class="badge badge-sm ml-auto {shellResult.exitCode === 0 ? 'badge-success' : 'badge-error'}"> @@ -65,7 +82,7 @@ const shellResult = $derived( {:else} <span class="badge badge-warning badge-sm ml-auto">pending</span> {/if} - </button> + </div> <div class="collapse-content text-xs"> <div class="mt-2"> 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<void> { + 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<void> { 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 { |
