summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-21 19:52:35 +0900
committerAdam Malczewski <[email protected]>2026-05-21 19:52:35 +0900
commit4ba2673557c7cfd0bc31b03e2c35ab5e1efe60e7 (patch)
treedddae8fc37a12157f4c8e840475e9e5d6203464a /packages/frontend/src/lib/components
parent55633c90c0d96e62153a4b6655f3f833a3b46ad4 (diff)
downloaddispatch-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')
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte6
-rw-r--r--packages/frontend/src/lib/components/ChatPanel.svelte33
-rw-r--r--packages/frontend/src/lib/components/Header.svelte12
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte128
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte5
-rw-r--r--packages/frontend/src/lib/components/TabBar.svelte47
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>