diff options
| author | Adam Malczewski <[email protected]> | 2026-05-21 19:52:35 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-21 19:52:35 +0900 |
| commit | 4ba2673557c7cfd0bc31b03e2c35ab5e1efe60e7 (patch) | |
| tree | dddae8fc37a12157f4c8e840475e9e5d6203464a /packages/frontend/src | |
| parent | 55633c90c0d96e62153a4b6655f3f833a3b46ad4 (diff) | |
| download | dispatch-4ba2673557c7cfd0bc31b03e2c35ab5e1efe60e7.tar.gz dispatch-4ba2673557c7cfd0bc31b03e2c35ab5e1efe60e7.zip | |
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<tabId, TabAgent>
- 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)
Diffstat (limited to 'packages/frontend/src')
| -rw-r--r-- | packages/frontend/src/App.svelte | 41 | ||||
| -rw-r--r-- | packages/frontend/src/lib/chat.svelte.ts | 414 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatInput.svelte | 6 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatPanel.svelte | 33 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/Header.svelte | 12 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SettingsPanel.svelte | 128 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 5 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/TabBar.svelte | 47 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 455 | ||||
| -rw-r--r-- | packages/frontend/src/lib/ws.svelte.ts | 6 |
10 files changed, 683 insertions, 464 deletions
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(() => { <div class="flex flex-1 overflow-hidden"> <!-- Main chat area --> <div class="flex flex-col flex-1 min-w-0 overflow-hidden"> + <TabBar /> <div class="flex-1 overflow-hidden"> <ChatPanel /> </div> <ChatInput /> </div> - <!-- Right sidebar — slides in/out while chat smoothly resizes --> + <!-- Right sidebar --> <div class="shrink-0 overflow-x-hidden flex flex-col transition-[width] duration-300 ease-out relative" class:w-80={sidebarOpen} @@ -81,15 +88,21 @@ onMount(() => { > <SidebarPanel keys={modelsData.keys} - tasks={chatStore.tasks} - permissionLog={chatStore.permissionLog} + tasks={tabStore.activeTab?.tasks ?? []} + permissionLog={tabStore.permissionLog} apiBase={config.apiBase} - activeKeyId={chatStore.activeKeyId} - activeModelId={chatStore.activeModelId} - reasoningEffort={chatStore.reasoningEffort} - onKeyChange={(keyId) => 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; + } + }} /> </div> </div> @@ -98,11 +111,11 @@ onMount(() => { <!-- Fixed overlay elements --> <PermissionPrompt - pending={chatStore.pendingPermissions} - onReply={(id, reply) => chatStore.replyPermission(id, reply)} + pending={tabStore.pendingPermissions} + onReply={(id, reply) => tabStore.replyPermission(id, reply)} /> <!-- Hot reload indicator fixed top-right --> <div class="fixed top-4 right-4 z-50"> - <HotReloadIndicator active={chatStore.configReloaded} /> + <HotReloadIndicator active={tabStore.configReloaded} /> </div> 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> = {}): 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 @@ <script lang="ts"> -import { chatStore } from "../chat.svelte.js"; +import { tabStore } from "../tabs.svelte.js"; let inputEl: HTMLInputElement | undefined; let inputValue = $state(""); -const isDisabled = $derived(chatStore.agentStatus === "running"); +const isDisabled = $derived((tabStore.activeTab?.agentStatus ?? "idle") === "running"); $effect(() => { inputEl?.focus(); @@ -20,7 +20,7 @@ function submit() { const text = inputValue.trim(); if (!text || isDisabled) return; inputValue = ""; - chatStore.sendMessage(text); + tabStore.sendMessage(text); } </script> 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 @@ <script lang="ts"> import { untrack } from "svelte"; - import { chatStore } from "../chat.svelte.js"; - import { wsClient } from "../ws.svelte.js"; + import { tabStore } from "../tabs.svelte.js"; import ChatMessageComponent from "./ChatMessage.svelte"; let messagesEl: HTMLDivElement | undefined; let userScrolledUp = $state(false); let isAutoScrolling = false; + const messages = $derived(tabStore.activeTab?.messages ?? []); + function isNearBottom(el: HTMLElement): boolean { return el.scrollHeight - el.scrollTop - el.clientHeight < 64; } @@ -32,8 +33,7 @@ } $effect(() => { - // Trigger on any message appended; track length explicitly - const count = chatStore.messages.length; + const count = messages.length; void count; if (messagesEl) { untrack(() => { @@ -44,27 +44,6 @@ </script> <div class="flex flex-col h-full"> - <!-- Status bar --> - <div class="flex items-center gap-3 px-4 py-2 bg-base-200 border-b border-base-300 text-xs"> - <span class="flex items-center gap-1.5"> - <span class="status status-sm {wsClient.connectionStatus === 'connected' ? 'status-success' : wsClient.connectionStatus === 'connecting' ? 'status-warning' : 'status-error'}"></span> - <span class="capitalize text-base-content/70">{wsClient.connectionStatus}</span> - </span> - <span class="text-base-content/50">|</span> - <span class="text-base-content/70"> - Agent: - <span - class="font-semibold {chatStore.agentStatus === 'running' - ? 'text-warning' - : chatStore.agentStatus === 'error' - ? 'text-error' - : 'text-success'}" - > - {chatStore.agentStatus === "running" ? "running..." : chatStore.agentStatus} - </span> - </span> - </div> - <!-- Messages --> <div class="relative flex-1 min-h-0"> <div @@ -75,12 +54,12 @@ isAutoScrolling = false; }} > - {#if chatStore.messages.length === 0} + {#if messages.length === 0} <div class="flex items-center justify-center h-full text-base-content/40 text-sm"> Send a message to start a conversation </div> {/if} - {#each chatStore.messages as message (message.id)} + {#each messages as message (message.id)} <ChatMessageComponent {message} /> {/each} </div> 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 @@ <script lang="ts"> -import { chatStore } from "../chat.svelte.js"; +import { tabStore } from "../tabs.svelte.js"; +import { wsClient } from "../ws.svelte.js"; import ThemeSwitcher from "./ThemeSwitcher.svelte"; const { onToggleSidebar }: { onToggleSidebar: () => void } = $props(); @@ -12,7 +13,7 @@ function resetCopyLabel() { } async function handleCopy() { - const text = chatStore.copyConversation(); + const text = tabStore.copyConversation(); try { await navigator.clipboard.writeText(text); copyLabel = "Copied"; @@ -29,8 +30,9 @@ async function handleCopy() { <span class="text-xl font-bold tracking-tight">Dispatch</span> </div> <div class="navbar-end flex items-center gap-3"> - <span class="text-xs text-base-content/60 hidden sm:block"> - {chatStore.activeModelId ?? "Default Model"} + <span class="flex items-center gap-1.5 text-xs text-base-content/60"> + <span class="status status-sm {wsClient.connectionStatus === 'connected' ? 'status-success' : wsClient.connectionStatus === 'connecting' ? 'status-warning' : 'status-error'}"></span> + <span class="capitalize">{wsClient.connectionStatus}</span> </span> <button type="button" @@ -54,7 +56,7 @@ async function handleCopy() { onclick={onToggleSidebar} aria-label="Toggle sidebar" > - ☰ Sidebar + Sidebar </button> </div> </header> diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte new file mode 100644 index 0000000..8449aa4 --- /dev/null +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -0,0 +1,128 @@ +<script lang="ts"> + import type { KeyInfo } from "../types.js"; + + const { + keys = [], + apiBase = "", + }: { + keys?: KeyInfo[]; + apiBase?: string; + } = $props(); + + let titleKeyId = $state<string | null>(null); + let titleModelId = $state<string | null>(null); + let availableModels = $state<string[]>([]); + let loadingModels = $state(false); + let saving = $state(false); + let saved = $state(false); + + 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); + } + // Set model AFTER options are loaded so the select can match the value + titleModelId = data.modelId; + } catch { + // ignore + } + } + + async function loadModelsForKey(keyId: string): Promise<void> { + loadingModels = true; + try { + const res = await fetch(`${apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`); + if (!res.ok) { availableModels = []; return; } + const data = await res.json() as { models: string[] }; + availableModels = data.models ?? []; + } catch { + availableModels = []; + } finally { + loadingModels = false; + } + } + + async function onKeyChange(e: Event): Promise<void> { + const select = e.target as HTMLSelectElement; + titleKeyId = select.value || null; + titleModelId = null; + availableModels = []; + if (titleKeyId) { + await loadModelsForKey(titleKeyId); + } + } + + 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; + } + } + + $effect(() => { + void loadSettings(); + }); +</script> + +<div class="flex flex-col gap-3"> + <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Settings</div> + + <div class="flex flex-col gap-2"> + <p class="text-xs text-base-content/70">Title Generation Model</p> + <p class="text-xs text-base-content/40">Used to generate short titles for new tabs after the first message.</p> + + <label class="text-xs text-base-content/60">Key</label> + <select class="select select-bordered select-sm w-full" onchange={onKeyChange} value={titleKeyId ?? ""}> + <option value="">Select a key...</option> + {#each keys as key (key.id)} + <option value={key.id}>{key.id} ({key.provider})</option> + {/each} + </select> + + <label class="text-xs text-base-content/60">Model</label> + <select + class="select select-bordered select-sm w-full" + onchange={onModelChange} + value={titleModelId ?? ""} + disabled={!titleKeyId || loadingModels} + > + <option value="">{loadingModels ? "Loading models..." : "Select a model..."}</option> + {#each availableModels as model (model)} + <option value={model}>{model}</option> + {/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> +</div> 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<Panel[]>([{ 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 @@ <SkillsBrowser {apiBase} /> {:else if panel.selected === "Permission Log"} <PermissionLog entries={permissionLog} /> + {:else if panel.selected === "Settings"} + <SettingsPanel {keys} {apiBase} /> {/if} </div> </div> 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 @@ +<script lang="ts"> + import { tabStore } from "../tabs.svelte.js"; + + function statusColor(status: string): string { + if (status === "running") return "bg-warning"; + if (status === "error") return "bg-error"; + return "bg-success"; + } +</script> + +<div class="overflow-x-auto bg-base-200 flex-shrink-0"> + <div role="tablist" class="tabs tabs-lift min-w-max"> + <!-- New tab button — always first --> + <button + type="button" + class="tab" + onclick={() => tabStore.createNewTab()} + aria-label="New tab" + > + + + </button> + + {#each tabStore.tabs as tab (tab.id)} + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + role="tab" + class="tab {tab.id === tabStore.activeTabId ? 'tab-active' : ''}" + onclick={() => tabStore.switchTab(tab.id)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tabStore.switchTab(tab.id); }} + tabindex="0" + > + <span class="flex items-center gap-1.5"> + <span class="w-1.5 h-1.5 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span> + <span class="max-w-32 truncate text-xs">{tab.title}</span> + <button + type="button" + class="ml-0.5 text-base-content/30 hover:text-error transition-colors text-xs leading-none" + onclick={(e) => { e.stopPropagation(); tabStore.closeTab(tab.id); }} + aria-label="Close tab" + > + x + </button> + </span> + </div> + {/each} + </div> +</div> 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> = {}): 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<Tab> { + 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<void> { + 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<Tab>): 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<void> { + 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, }; } |
