From 4ba2673557c7cfd0bc31b03e2c35ab5e1efe60e7 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 21 May 2026 19:52:35 +0900 Subject: feat: tab system with per-tab agents, DB persistence, and DaisyUI tabs-lift UI - Add tabs, messages, and settings tables to SQLite database - Backend: refactor AgentManager to manage per-tab Agent instances via Map - Backend: WebSocket events tagged with tabId for multiplexing - Backend: tab CRUD routes (create, list, update, archive, messages) - Backend: persist user and assistant messages to DB during chat - Frontend: new tabStore replaces single chatStore with multi-tab reactive state - Frontend: TabBar component using DaisyUI tabs-lift style with status dots - Frontend: Settings sidebar panel for title generation model selection - Frontend: wire ChatPanel, ChatInput, Header to use tabStore - Fix HMR listener accumulation via wsClient.clearCallbacks() - Delete old single-chat chatStore (chat.svelte.ts) --- packages/frontend/src/App.svelte | 41 +- packages/frontend/src/lib/chat.svelte.ts | 414 ------------------- .../frontend/src/lib/components/ChatInput.svelte | 6 +- .../frontend/src/lib/components/ChatPanel.svelte | 33 +- packages/frontend/src/lib/components/Header.svelte | 12 +- .../src/lib/components/SettingsPanel.svelte | 128 ++++++ .../src/lib/components/SidebarPanel.svelte | 5 +- packages/frontend/src/lib/components/TabBar.svelte | 47 +++ packages/frontend/src/lib/tabs.svelte.ts | 455 +++++++++++++++++++++ packages/frontend/src/lib/ws.svelte.ts | 6 + 10 files changed, 683 insertions(+), 464 deletions(-) delete mode 100644 packages/frontend/src/lib/chat.svelte.ts create mode 100644 packages/frontend/src/lib/components/SettingsPanel.svelte create mode 100644 packages/frontend/src/lib/components/TabBar.svelte create mode 100644 packages/frontend/src/lib/tabs.svelte.ts (limited to 'packages/frontend') diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index 4ea968c..4d4200a 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -3,10 +3,11 @@ import { onMount } from "svelte"; import ChatInput from "./lib/components/ChatInput.svelte"; import ChatPanel from "./lib/components/ChatPanel.svelte"; import Header from "./lib/components/Header.svelte"; +import TabBar from "./lib/components/TabBar.svelte"; import PermissionPrompt from "./lib/components/PermissionPrompt.svelte"; import SidebarPanel from "./lib/components/SidebarPanel.svelte"; import HotReloadIndicator from "./lib/components/HotReloadIndicator.svelte"; -import { chatStore } from "./lib/chat.svelte.js"; +import { tabStore } from "./lib/tabs.svelte.js"; import { wsClient } from "./lib/ws.svelte.js"; import { config } from "./lib/config.js"; import type { KeyInfo } from "./lib/types.js"; @@ -33,7 +34,7 @@ async function fetchModels() { } $effect(() => { - if (chatStore.configReloaded) { + if (tabStore.configReloaded) { fetchModels(); } }); @@ -51,6 +52,11 @@ onMount(() => { // Initial models fetch fetchModels(); + // Create initial tab + if (tabStore.tabs.length === 0) { + tabStore.createNewTab(); + } + return () => { wsClient.disconnect(); }; @@ -63,13 +69,14 @@ onMount(() => {
+
- +
{ > chatStore.setKey(keyId)} - onModelChange={(keyId, modelId) => chatStore.changeModel(keyId, modelId)} - onReasoningChange={(effort) => { chatStore.reasoningEffort = effort; }} + activeKeyId={tabStore.activeTab?.keyId ?? null} + activeModelId={tabStore.activeTab?.modelId ?? null} + reasoningEffort={tabStore.activeTab?.reasoningEffort ?? "max"} + onKeyChange={(keyId) => tabStore.setKey(keyId)} + onModelChange={(keyId, modelId) => tabStore.changeModel(keyId, modelId)} + onReasoningChange={(effort) => { + const tab = tabStore.activeTab; + if (tab) { + // Update reasoning effort for active tab + tabStore.tabs.find(t => t.id === tab.id)!.reasoningEffort = effort; + } + }} />
@@ -98,11 +111,11 @@ onMount(() => { chatStore.replyPermission(id, reply)} + pending={tabStore.pendingPermissions} + onReply={(id, reply) => tabStore.replyPermission(id, reply)} />
- +
diff --git a/packages/frontend/src/lib/chat.svelte.ts b/packages/frontend/src/lib/chat.svelte.ts deleted file mode 100644 index e211203..0000000 --- a/packages/frontend/src/lib/chat.svelte.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { config } from "./config.js"; -import type { AgentEvent, ChatMessage, ContentSegment, DebugInfo, LogEntry, PermissionPrompt, TaskItem } from "./types.js"; -import { wsClient } from "./ws.svelte.js"; - -function generateId() { - return Math.random().toString(36).slice(2, 11); -} - -function makeDebugInfo(overrides: Partial = {}): DebugInfo { - return { - timestamp: new Date().toISOString(), - model: "deepseek-v4-flash", - apiBase: config.apiBase, - connectionStatus: wsClient.connectionStatus, - ...overrides, - }; -} - -function formatConversation(msgs: ChatMessage[], activeModelId: string | null): string { - const lines: string[] = []; - lines.push("=== Dispatch Conversation ==="); - lines.push(`Exported: ${new Date().toISOString()}`); - lines.push(`Active Model: ${activeModelId ?? "default"}`); - lines.push(""); - - for (const msg of msgs) { - if (msg.role === "system") { - lines.push(`--- System ---`); - for (const seg of msg.content) { - if (seg.type === "text") lines.push(seg.text); - } - lines.push(""); - continue; - } - - const role = msg.role === "user" ? "User" : "Assistant"; - lines.push(`--- ${role} ---`); - - if (msg.thinking) { - lines.push(` [Thinking]: ${msg.thinking}`); - } - - for (const seg of msg.content) { - if (seg.type === "text") { - lines.push(seg.text); - } else if (seg.type === "tool-call") { - lines.push(` [Tool: ${seg.name}]`); - lines.push(` Args: ${JSON.stringify(seg.arguments)}`); - if (seg.result !== undefined) { - const prefix = seg.isError ? " Error: " : " Result: "; - lines.push(`${prefix}${seg.result}`); - } - } - } - - if (msg.debugInfo) { - lines.push(" [Debug Info]"); - lines.push(` Timestamp: ${msg.debugInfo.timestamp}`); - if (msg.debugInfo.error) lines.push(` Error: ${msg.debugInfo.error}`); - if (msg.debugInfo.model) lines.push(` Model: ${msg.debugInfo.model}`); - if (msg.debugInfo.apiBase) lines.push(` API Base: ${msg.debugInfo.apiBase}`); - if (msg.debugInfo.connectionStatus) - lines.push(` Connection: ${msg.debugInfo.connectionStatus}`); - if (msg.debugInfo.agentStatus) lines.push(` Agent Status: ${msg.debugInfo.agentStatus}`); - if (msg.debugInfo.httpStatus) lines.push(` HTTP Status: ${msg.debugInfo.httpStatus}`); - if (msg.debugInfo.httpBody) lines.push(` HTTP Body: ${msg.debugInfo.httpBody}`); - if (msg.debugInfo.rawEvent) - lines.push(` Raw Event: ${JSON.stringify(msg.debugInfo.rawEvent)}`); - } - - lines.push(""); - } - - return lines.join("\n"); -} - -function createChatStore() { - let messages: ChatMessage[] = $state([]); - let agentStatus: "idle" | "running" | "error" = $state("idle"); - let activeKeyId: string | null = $state(null); - let activeModelId: string | null = $state(null); - let reasoningEffort: string = $state("max"); - let isConnected = $state(false); - let currentAssistantId: string | null = null; - let pendingPermissions: PermissionPrompt[] = $state([]); - let permissionLog: LogEntry[] = $state([]); - let tasks: TaskItem[] = $state([]); - let configReloaded = $state(false); - - wsClient.onEvent((event) => { - handleEvent(event); - }); - - $effect.root(() => { - $effect(() => { - isConnected = wsClient.connectionStatus === "connected"; - }); - }); - - function getCurrentAssistantMessage(): ChatMessage | null { - if (!currentAssistantId) return null; - return messages.find((m) => m.id === currentAssistantId) ?? null; - } - - function ensureCurrentAssistantMessage(): ChatMessage { - let msg = getCurrentAssistantMessage(); - if (!msg) { - const id = generateId(); - currentAssistantId = id; - const newMsg: ChatMessage = { - id, - role: "assistant", - content: [], - thinking: "", - isStreaming: true, - }; - messages = [...messages, newMsg]; - msg = newMsg; - } - return msg; - } - - function handleEvent(event: AgentEvent) { - switch (event.type) { - case "status": { - agentStatus = event.status; - if (event.status === "idle" || event.status === "error") { - currentAssistantId = null; - } - break; - } - case "reasoning-delta": { - ensureCurrentAssistantMessage(); - messages = messages.map((m) => { - if (m.id === currentAssistantId) { - return { ...m, thinking: (m.thinking ?? "") + event.delta }; - } - return m; - }); - break; - } - case "text-delta": { - ensureCurrentAssistantMessage(); - messages = messages.map((m) => { - if (m.id === currentAssistantId) { - const segments = [...m.content]; - const last = segments[segments.length - 1]; - if (last && last.type === "text") { - segments[segments.length - 1] = { ...last, text: last.text + event.delta }; - } else { - segments.push({ type: "text", text: event.delta }); - } - return { ...m, content: segments, isStreaming: true }; - } - return m; - }); - break; - } - case "tool-call": { - ensureCurrentAssistantMessage(); - messages = messages.map((m) => { - if (m.id === currentAssistantId) { - const segments: ContentSegment[] = [ - ...m.content, - { - type: "tool-call", - id: event.toolCall.id, - name: event.toolCall.name, - arguments: event.toolCall.arguments, - }, - ]; - return { ...m, content: segments }; - } - return m; - }); - break; - } - case "tool-result": { - messages = messages.map((m) => { - if (m.id === currentAssistantId) { - return { - ...m, - content: m.content.map((seg) => { - if (seg.type === "tool-call" && seg.id === event.toolResult.toolCallId) { - return { ...seg, result: event.toolResult.result, isError: event.toolResult.isError }; - } - return seg; - }), - }; - } - return m; - }); - break; - } - case "done": { - messages = messages.map((m) => { - if (m.id === currentAssistantId) { - return { ...m, isStreaming: false }; - } - return m; - }); - currentAssistantId = null; - break; - } - case "error": { - const errMsg: ChatMessage = { - id: generateId(), - role: "assistant", - content: [{ type: "text", text: `Error: ${event.error}` }], - isStreaming: false, - debugInfo: makeDebugInfo({ - error: event.error, - agentStatus, - rawEvent: event, - }), - }; - messages = [...messages, errMsg]; - currentAssistantId = null; - agentStatus = "error"; - break; - } - case "permission-prompt": { - pendingPermissions = event.pending; - break; - } - case "task-list-update": { - tasks = event.tasks; - break; - } - case "config-reload": { - configReloaded = true; - setTimeout(() => { - configReloaded = false; - }, 2500); - break; - } - case "shell-output": { - messages = messages.map((m) => { - if (m.id === currentAssistantId) { - // Find the last tool-call segment - const segments = [...m.content]; - let found = false; - for (let i = segments.length - 1; i >= 0; i--) { - const seg = segments[i]; - if (seg && seg.type === "tool-call") { - segments[i] = { - ...seg, - shellOutput: { - stdout: (seg.shellOutput?.stdout ?? "") + (event.stream === "stdout" ? event.data : ""), - stderr: (seg.shellOutput?.stderr ?? "") + (event.stream === "stderr" ? event.data : ""), - }, - }; - found = true; - break; - } - } - if (!found) return m; // no tool-call segment yet - return { ...m, content: segments }; - } - return m; - }); - break; - } - } - } - - async function sendMessage(text: string) { - const userMsg: ChatMessage = { - id: generateId(), - role: "user", - content: [{ type: "text", text }], - }; - messages = [...messages, userMsg]; - currentAssistantId = null; - - const url = `${config.apiBase}/chat`; - try { - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: text, - ...(activeKeyId && activeModelId ? { keyId: activeKeyId, modelId: activeModelId } : {}), - reasoningEffort, - }), - }); - if (!res.ok) { - const body = await res.text(); - const errMsg: ChatMessage = { - id: generateId(), - role: "assistant", - content: [{ type: "text", text: `Error: Failed to send message (HTTP ${res.status})` }], - isStreaming: false, - debugInfo: makeDebugInfo({ - error: `POST ${url} returned ${res.status}`, - agentStatus, - httpStatus: res.status, - httpBody: body, - }), - }; - messages = [...messages, errMsg]; - } - } catch (err) { - const errorText = err instanceof Error ? err.message : String(err); - const errMsg: ChatMessage = { - id: generateId(), - role: "assistant", - content: [{ type: "text", text: "Error: Could not reach the server" }], - isStreaming: false, - debugInfo: makeDebugInfo({ - error: `POST ${url} failed: ${errorText}`, - agentStatus, - }), - }; - messages = [...messages, errMsg]; - } - } - - function copyConversation(): string { - return formatConversation(messages, activeModelId); - } - - function changeModel(keyId: string, modelId: string) { - const previousModel = activeModelId; - activeKeyId = keyId; - activeModelId = modelId; - - if (previousModel && previousModel !== modelId) { - const systemMsg: ChatMessage = { - id: generateId(), - role: "system", - content: [{ type: "text", text: `Model changed from ${previousModel} to ${modelId}` }], - }; - messages = [...messages, systemMsg]; - } else if (!previousModel) { - const systemMsg: ChatMessage = { - id: generateId(), - role: "system", - content: [{ type: "text", text: `Model set to ${modelId}` }], - }; - messages = [...messages, systemMsg]; - } - } - - function setKey(keyId: string) { - activeKeyId = keyId; - // Clear model when key changes since available models depend on the key - activeModelId = null; - } - - function replyPermission(id: string, reply: "once" | "always" | "reject") { - if (wsClient.connectionStatus !== "connected") { - // WebSocket is not connected; skip optimistic removal to avoid losing the prompt - return; - } - const prompt = pendingPermissions.find((p) => p.id === id); - wsClient.send({ type: "permission-reply", id, reply }); - pendingPermissions = pendingPermissions.filter((p) => p.id !== id); - if (prompt) { - const entry: LogEntry = { - id: generateId(), - permission: prompt.permission, - patterns: prompt.patterns, - action: reply, - timestamp: new Date().toISOString(), - description: prompt.description, - }; - permissionLog = [...permissionLog, entry]; - } - } - - function clear() { - messages = []; - currentAssistantId = null; - agentStatus = "idle"; - } - - return { - get messages() { - return messages; - }, - get agentStatus() { - return agentStatus; - }, - get isConnected() { - return isConnected; - }, - get pendingPermissions() { - return pendingPermissions; - }, - get permissionLog() { - return permissionLog; - }, - get tasks() { - return tasks; - }, - get configReloaded() { - return configReloaded; - }, - sendMessage, - handleEvent, - replyPermission, - copyConversation, - clear, - changeModel, - setKey, - get activeKeyId() { return activeKeyId; }, - get activeModelId() { return activeModelId; }, - get reasoningEffort() { return reasoningEffort; }, - set reasoningEffort(value: string) { reasoningEffort = value; }, - }; -} - -export const chatStore = createChatStore(); diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte index 92c78b9..9563a9f 100644 --- a/packages/frontend/src/lib/components/ChatInput.svelte +++ b/packages/frontend/src/lib/components/ChatInput.svelte @@ -1,9 +1,9 @@ diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte index 11c3b5d..24d8c7f 100644 --- a/packages/frontend/src/lib/components/ChatPanel.svelte +++ b/packages/frontend/src/lib/components/ChatPanel.svelte @@ -1,13 +1,14 @@
- -
- - - {wsClient.connectionStatus} - - | - - Agent: - - {chatStore.agentStatus === "running" ? "running..." : chatStore.agentStatus} - - -
-
- {#if chatStore.messages.length === 0} + {#if messages.length === 0}
Send a message to start a conversation
{/if} - {#each chatStore.messages as message (message.id)} + {#each messages as message (message.id)} {/each}
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte index 721a773..ada5500 100644 --- a/packages/frontend/src/lib/components/Header.svelte +++ b/packages/frontend/src/lib/components/Header.svelte @@ -1,5 +1,6 @@ + +
+
Settings
+ +
+

Title Generation Model

+

Used to generate short titles for new tabs after the first message.

+ + + + + + + + +
+
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index e5536de..7e71bd5 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -7,6 +7,7 @@ import PermissionLog from "./PermissionLog.svelte"; import KeyUsage from "./KeyUsage.svelte"; import ClaudeReset from "./ClaudeReset.svelte"; + import SettingsPanel from "./SettingsPanel.svelte"; import type { TaskItem, LogEntry, KeyInfo } from "../types.js"; const { @@ -41,7 +42,7 @@ let nextId = 0; let panels = $state([{ id: nextId++, selected: "Current Model" }]); - const viewOptions = ["Select a view", "Current Model", "Key Usage", "Claude Reset", "Model Status", "Tasks", "Config", "Skills", "Permission Log"]; + const viewOptions = ["Select a view", "Current Model", "Key Usage", "Claude Reset", "Model Status", "Tasks", "Config", "Skills", "Permission Log", "Settings"]; function addPanel() { panels = [...panels, { id: nextId++, selected: "Select a view" }]; @@ -114,6 +115,8 @@ {:else if panel.selected === "Permission Log"} + {:else if panel.selected === "Settings"} + {/if}
diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte new file mode 100644 index 0000000..d1a3936 --- /dev/null +++ b/packages/frontend/src/lib/components/TabBar.svelte @@ -0,0 +1,47 @@ + + +
+
+ + + + {#each tabStore.tabs as tab (tab.id)} + + + {/each} +
+
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts new file mode 100644 index 0000000..9ebfaed --- /dev/null +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -0,0 +1,455 @@ +import { config } from "./config.js"; +import type { AgentEvent, ChatMessage, ContentSegment, DebugInfo, LogEntry, PermissionPrompt, TaskItem } from "./types.js"; +import { wsClient } from "./ws.svelte.js"; + +function generateId() { + return crypto.randomUUID(); +} + +function makeDebugInfo(overrides: Partial = {}): DebugInfo { + return { + timestamp: new Date().toISOString(), + connectionStatus: wsClient.connectionStatus, + ...overrides, + }; +} + +export interface Tab { + id: string; + title: string; + messages: ChatMessage[]; + agentStatus: "idle" | "running" | "error"; + keyId: string | null; + modelId: string | null; + reasoningEffort: string; + currentAssistantId: string | null; + tasks: TaskItem[]; +} + +function createTabStore() { + let tabs: Tab[] = $state([]); + let activeTabId: string | null = $state(null); + let pendingPermissions: PermissionPrompt[] = $state([]); + let permissionLog: LogEntry[] = $state([]); + let configReloaded = $state(false); + let isConnected = $state(false); + + // Clear any stale listeners from HMR reloads, then register + wsClient.clearCallbacks(); + wsClient.onEvent((event) => { + handleEvent(event as AgentEvent & { tabId?: string }); + }); + + $effect.root(() => { + $effect(() => { + isConnected = wsClient.connectionStatus === "connected"; + }); + }); + + function getActiveTab(): Tab | undefined { + return tabs.find((t) => t.id === activeTabId); + } + + function getTabById(id: string): Tab | undefined { + return tabs.find((t) => t.id === id); + } + + async function createNewTab(): Promise { + const id = generateId(); + const title = "New Tab"; + + // Create on backend + try { + await fetch(`${config.apiBase}/tabs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, title }), + }); + } catch { + // Continue even if backend fails — tab works locally + } + + const tab: Tab = { + id, + title, + messages: [], + agentStatus: "idle", + keyId: null, + modelId: null, + reasoningEffort: "max", + currentAssistantId: null, + tasks: [], + }; + tabs = [...tabs, tab]; + activeTabId = id; + return tab; + } + + function switchTab(id: string): void { + if (tabs.some((t) => t.id === id)) { + activeTabId = id; + } + } + + async function closeTab(id: string): Promise { + const tab = getTabById(id); + if (!tab) return; + + // Archive on backend (also stops any running agent) + try { + await fetch(`${config.apiBase}/tabs/${id}`, { method: "DELETE" }); + } catch { + // Continue with local removal + } + + tabs = tabs.filter((t) => t.id !== id); + + // 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; + } else { + await createNewTab(); + } + } + } + + function updateTab(id: string, patch: Partial): void { + tabs = tabs.map((t) => (t.id === id ? { ...t, ...patch } : t)); + } + + function ensureAssistantMessage(tabId: string): ChatMessage { + const tab = getTabById(tabId); + if (!tab) throw new Error(`Tab not found: ${tabId}`); + + if (tab.currentAssistantId) { + const existing = tab.messages.find((m) => m.id === tab.currentAssistantId); + if (existing) return existing; + } + + const id = generateId(); + const newMsg: ChatMessage = { + id, + role: "assistant", + content: [], + thinking: "", + isStreaming: true, + }; + updateTab(tabId, { + currentAssistantId: id, + messages: [...tab.messages, newMsg], + }); + return newMsg; + } + + function updateMessages(tabId: string, updater: (msgs: ChatMessage[]) => ChatMessage[]): void { + const tab = getTabById(tabId); + if (!tab) return; + updateTab(tabId, { messages: updater(tab.messages) }); + } + + function handleEvent(event: AgentEvent & { tabId?: string }): void { + const tabId = event.tabId; + + switch (event.type) { + case "status": { + if (tabId) { + updateTab(tabId, { agentStatus: event.status }); + if (event.status === "idle" || event.status === "error") { + updateTab(tabId, { currentAssistantId: null }); + } + } + break; + } + case "reasoning-delta": { + if (!tabId) break; + ensureAssistantMessage(tabId); + const tab = getTabById(tabId); + if (!tab) break; + updateMessages(tabId, (msgs) => + msgs.map((m) => + m.id === tab.currentAssistantId + ? { ...m, thinking: (m.thinking ?? "") + event.delta } + : m, + ), + ); + break; + } + case "text-delta": { + if (!tabId) break; + ensureAssistantMessage(tabId); + const tab2 = getTabById(tabId); + if (!tab2) break; + updateMessages(tabId, (msgs) => + msgs.map((m) => { + if (m.id !== tab2.currentAssistantId) return m; + const segments = [...m.content]; + const last = segments[segments.length - 1]; + if (last && last.type === "text") { + segments[segments.length - 1] = { ...last, text: last.text + event.delta }; + } else { + segments.push({ type: "text", text: event.delta }); + } + return { ...m, content: segments, isStreaming: true }; + }), + ); + break; + } + case "tool-call": { + if (!tabId) break; + ensureAssistantMessage(tabId); + const tab3 = getTabById(tabId); + if (!tab3) break; + updateMessages(tabId, (msgs) => + msgs.map((m) => { + if (m.id !== tab3.currentAssistantId) return m; + const segments: ContentSegment[] = [ + ...m.content, + { + type: "tool-call", + id: event.toolCall.id, + name: event.toolCall.name, + arguments: event.toolCall.arguments, + }, + ]; + return { ...m, content: segments }; + }), + ); + break; + } + case "tool-result": { + if (!tabId) break; + const tab4 = getTabById(tabId); + if (!tab4) break; + updateMessages(tabId, (msgs) => + msgs.map((m) => { + if (m.id !== tab4.currentAssistantId) return m; + return { + ...m, + content: m.content.map((seg) => { + if (seg.type === "tool-call" && seg.id === event.toolResult.toolCallId) { + return { ...seg, result: event.toolResult.result, isError: event.toolResult.isError }; + } + return seg; + }), + }; + }), + ); + break; + } + case "done": { + if (!tabId) break; + const tab5 = getTabById(tabId); + if (!tab5) break; + updateMessages(tabId, (msgs) => + msgs.map((m) => + m.id === tab5.currentAssistantId ? { ...m, isStreaming: false } : m, + ), + ); + updateTab(tabId, { currentAssistantId: null }); + break; + } + case "error": { + if (tabId) { + const errMsg: ChatMessage = { + id: generateId(), + role: "assistant", + content: [{ type: "text", text: `Error: ${event.error}` }], + isStreaming: false, + debugInfo: makeDebugInfo({ error: event.error }), + }; + const tab6 = getTabById(tabId); + if (tab6) { + updateTab(tabId, { + messages: [...tab6.messages, errMsg], + currentAssistantId: null, + agentStatus: "error", + }); + } + } + break; + } + case "permission-prompt": { + pendingPermissions = event.pending; + break; + } + case "task-list-update": { + if (tabId) { + updateTab(tabId, { tasks: event.tasks }); + } + break; + } + case "config-reload": { + configReloaded = true; + setTimeout(() => { configReloaded = false; }, 2500); + break; + } + case "shell-output": { + if (!tabId) break; + const tab7 = getTabById(tabId); + if (!tab7) break; + updateMessages(tabId, (msgs) => + msgs.map((m) => { + if (m.id !== tab7.currentAssistantId) return m; + const segments = [...m.content]; + for (let i = segments.length - 1; i >= 0; i--) { + const seg = segments[i]; + if (seg && seg.type === "tool-call") { + segments[i] = { + ...seg, + shellOutput: { + stdout: (seg.shellOutput?.stdout ?? "") + (event.stream === "stdout" ? event.data : ""), + stderr: (seg.shellOutput?.stderr ?? "") + (event.stream === "stderr" ? event.data : ""), + }, + }; + break; + } + } + return { ...m, content: segments }; + }), + ); + break; + } + } + } + + async function sendMessage(text: string): Promise { + const tab = getActiveTab(); + if (!tab) return; + + const userMsg: ChatMessage = { + id: generateId(), + role: "user", + content: [{ type: "text", text }], + }; + updateTab(tab.id, { messages: [...tab.messages, userMsg] }); + + // Generate title from first user message + if (tab.messages.length === 0 || (tab.messages.length === 1 && tab.title === "New Tab")) { + const titleText = text.length > 50 ? text.slice(0, 47) + "..." : text; + updateTab(tab.id, { title: titleText }); + fetch(`${config.apiBase}/tabs/${tab.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: titleText }), + }).catch(() => {}); + } + + try { + const res = await fetch(`${config.apiBase}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tabId: tab.id, + message: text, + ...(tab.keyId ? { keyId: tab.keyId } : {}), + ...(tab.modelId ? { modelId: tab.modelId } : {}), + reasoningEffort: tab.reasoningEffort, + }), + }); + if (!res.ok) { + const body = await res.text(); + const errMsg: ChatMessage = { + id: generateId(), + role: "assistant", + content: [{ type: "text", text: `Error: Failed to send message (HTTP ${res.status})` }], + isStreaming: false, + debugInfo: makeDebugInfo({ error: `HTTP ${res.status}`, httpStatus: res.status, httpBody: body }), + }; + updateTab(tab.id, { messages: [...(getTabById(tab.id)?.messages ?? []), errMsg] }); + } + } catch (err) { + const errMsg: ChatMessage = { + id: generateId(), + role: "assistant", + content: [{ type: "text", text: "Error: Could not reach the server" }], + isStreaming: false, + debugInfo: makeDebugInfo({ error: err instanceof Error ? err.message : String(err) }), + }; + updateTab(tab.id, { messages: [...(getTabById(tab.id)?.messages ?? []), errMsg] }); + } + } + + function changeModel(keyId: string, modelId: string): void { + const tab = getActiveTab(); + if (!tab) return; + updateTab(tab.id, { keyId, modelId }); + + // Persist to backend + fetch(`${config.apiBase}/tabs/${tab.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keyId, modelId }), + }).catch(() => {}); + } + + function setKey(keyId: string): void { + const tab = getActiveTab(); + if (!tab) return; + updateTab(tab.id, { keyId, modelId: null }); + + // Persist to backend + fetch(`${config.apiBase}/tabs/${tab.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keyId, modelId: null }), + }).catch(() => {}); + } + + function replyPermission(id: string, reply: "once" | "always" | "reject"): void { + if (wsClient.connectionStatus !== "connected") return; + const prompt = pendingPermissions.find((p) => p.id === id); + wsClient.send({ type: "permission-reply", id, reply }); + pendingPermissions = pendingPermissions.filter((p) => p.id !== id); + if (prompt) { + permissionLog = [...permissionLog, { + id: generateId(), + permission: prompt.permission, + patterns: prompt.patterns, + action: reply, + timestamp: new Date().toISOString(), + description: prompt.description, + }]; + } + } + + function copyConversation(): string { + const tab = getActiveTab(); + if (!tab) return ""; + const lines: string[] = ["=== Dispatch Conversation ===", `Tab: ${tab.title}`, `Model: ${tab.modelId ?? "default"}`, ""]; + for (const msg of tab.messages) { + const role = msg.role === "user" ? "User" : msg.role === "system" ? "System" : "Assistant"; + lines.push(`--- ${role} ---`); + if (msg.thinking) lines.push(` [Thinking]: ${msg.thinking}`); + for (const seg of msg.content) { + if (seg.type === "text") lines.push(seg.text); + else if (seg.type === "tool-call") { + lines.push(` [Tool: ${seg.name}]`); + if (seg.result !== undefined) lines.push(` Result: ${seg.result}`); + } + } + lines.push(""); + } + return lines.join("\n"); + } + + return { + get tabs() { return tabs; }, + get activeTabId() { return activeTabId; }, + get activeTab() { return getActiveTab(); }, + get isConnected() { return isConnected; }, + get pendingPermissions() { return pendingPermissions; }, + get permissionLog() { return permissionLog; }, + get configReloaded() { return configReloaded; }, + createNewTab, + switchTab, + closeTab, + sendMessage, + changeModel, + setKey, + replyPermission, + copyConversation, + }; +} + +export const tabStore = createTabStore(); diff --git a/packages/frontend/src/lib/ws.svelte.ts b/packages/frontend/src/lib/ws.svelte.ts index 2ca97be..26f9077 100644 --- a/packages/frontend/src/lib/ws.svelte.ts +++ b/packages/frontend/src/lib/ws.svelte.ts @@ -76,6 +76,11 @@ function createWebSocketClient(url: string) { }; } + /** Remove all registered event callbacks (used for HMR safety). */ + function clearCallbacks() { + callbacks.length = 0; + } + function send(data: unknown): void { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); @@ -89,6 +94,7 @@ function createWebSocketClient(url: string) { connect, disconnect, onEvent, + clearCallbacks, send, }; } -- cgit v1.2.3