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/lib/components | |
| 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/lib/components')
6 files changed, 195 insertions, 36 deletions
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> |
