summaryrefslogtreecommitdiffhomepage
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
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)
-rw-r--r--packages/api/src/agent-manager.ts212
-rw-r--r--packages/api/src/app.ts19
-rw-r--r--packages/api/src/index.ts8
-rw-r--r--packages/api/src/routes/tabs.ts81
-rw-r--r--packages/core/src/db/index.ts29
-rw-r--r--packages/core/src/db/messages.ts47
-rw-r--r--packages/core/src/db/settings.ts20
-rw-r--r--packages/core/src/db/tabs.ts73
-rw-r--r--packages/core/src/index.ts5
-rw-r--r--packages/frontend/src/App.svelte41
-rw-r--r--packages/frontend/src/lib/chat.svelte.ts414
-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
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts455
-rw-r--r--packages/frontend/src/lib/ws.svelte.ts6
19 files changed, 1109 insertions, 532 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 6e78ad7..07af249 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -19,6 +19,7 @@ import {
TaskList,
createTaskListTool,
type ClaudeAccount,
+ appendMessage,
getClaudeAccountsFromDB,
refreshAccountCredentials,
refreshAccountCredentialsAsync,
@@ -28,6 +29,7 @@ import type { PermissionManager } from "./permission-manager.js";
import { setConfigGetter } from "./routes/config.js";
import { setSkillsGetter } from "./routes/skills.js";
import { setModelsGetter, setAccountsGetter } from "./routes/models.js";
+import { setTabsAgentManager } from "./routes/tabs.js";
const SYSTEM_PROMPT = `You are Dispatch, a helpful AI coding assistant. You have access to the following tools for working with files in the current working directory:
@@ -39,20 +41,23 @@ const SYSTEM_PROMPT = `You are Dispatch, a helpful AI coding assistant. You have
When asked to work with files, use these tools. Always confirm what you did after completing an action. Be concise and helpful.`;
+interface TabAgent {
+ agent: Agent | null;
+ status: AgentStatus;
+ keyId: string | null;
+ modelId: string | null;
+ taskList: TaskList;
+}
+
export class AgentManager {
- private agent: Agent | null = null;
- private status: AgentStatus = "idle";
+ private tabAgents: Map<string, TabAgent> = new Map();
private messageCount = 0;
- private eventListeners: Set<(event: AgentEvent) => void> = new Set();
+ private eventListeners: Set<(event: AgentEvent & { tabId: string }) => void> = new Set();
private permissionManager: PermissionManager | undefined;
- activeModelId: string | null = null;
- activeKeyId: string | null = null;
-
private config: DispatchConfig;
private skillsData: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] };
private modelRegistry: ModelRegistry | null = null;
- private taskList: TaskList;
private configWatcher: { close(): void } | null = null;
private skillsWatcher: { close(): void } | null = null;
@@ -89,12 +94,7 @@ export class AgentManager {
() => this.modelRegistry,
);
setAccountsGetter(() => this.claudeAccounts);
-
- // Set up task list
- this.taskList = new TaskList();
- this.taskList.onChange((tasks) => {
- this.emit({ type: "task-list-update", tasks });
- });
+ setTabsAgentManager(() => this);
// Set up hot-reload watchers
this.configWatcher = createConfigWatcher(workingDirectory, (newConfig) => {
@@ -107,16 +107,26 @@ export class AgentManager {
}
// Update model registry with new config
this._initModelRegistry(newConfig);
- // Invalidate cached agent so next message uses updated config
- this.agent = null;
- this.emit({ type: "config-reload" });
+ // Invalidate cached agents so next message uses updated config
+ for (const tabAgent of this.tabAgents.values()) {
+ tabAgent.agent = null;
+ }
+ // Emit config-reload to all tabs
+ for (const tabId of this.tabAgents.keys()) {
+ this.emit({ type: "config-reload" }, tabId);
+ }
});
this.skillsWatcher = createSkillsWatcher(workingDirectory, (result) => {
this.skillsData = result;
- // Invalidate cached agent so next message uses updated skills
- this.agent = null;
- this.emit({ type: "config-reload" });
+ // Invalidate cached agents so next message uses updated skills
+ for (const tabAgent of this.tabAgents.values()) {
+ tabAgent.agent = null;
+ }
+ // Emit config-reload to all tabs
+ for (const tabId of this.tabAgents.keys()) {
+ this.emit({ type: "config-reload" }, tabId);
+ }
});
}
@@ -147,28 +157,51 @@ export class AgentManager {
return this.permissionManager;
}
- getTaskList(): TaskList {
- return this.taskList;
+ /** Get the TaskList for a specific tab (creates the tab entry if missing). */
+ getTaskList(tabId: string): TaskList {
+ return this._getOrCreateTabAgent(tabId).taskList;
}
getClaudeAccounts(): ClaudeAccount[] {
return this.claudeAccounts;
}
- private async getOrCreateAgent(keyId?: string, modelId?: string): Promise<Agent> {
- // Determine effective override: use provided values, or fall back to stored active values
- const effectiveKeyId = keyId ?? this.activeKeyId ?? undefined;
- const effectiveModelId = modelId ?? this.activeModelId ?? undefined;
+ /** Get or create the TabAgent entry for a tab (without creating an Agent). */
+ private _getOrCreateTabAgent(tabId: string): TabAgent {
+ let tabAgent = this.tabAgents.get(tabId);
+ if (!tabAgent) {
+ const taskList = new TaskList();
+ taskList.onChange((tasks) => {
+ this.emit({ type: "task-list-update", tasks }, tabId);
+ });
+ tabAgent = {
+ agent: null,
+ status: "idle",
+ keyId: null,
+ modelId: null,
+ taskList,
+ };
+ this.tabAgents.set(tabId, tabAgent);
+ }
+ return tabAgent;
+ }
+
+ private async getOrCreateAgentForTab(tabId: string, keyId?: string, modelId?: string): Promise<Agent> {
+ const tabAgent = this._getOrCreateTabAgent(tabId);
+
+ // Determine effective override: use provided values, or fall back to stored per-tab values
+ const effectiveKeyId = keyId ?? tabAgent.keyId;
+ const effectiveModelId = modelId ?? tabAgent.modelId;
// If the override differs from what the current agent was built with, invalidate the cache
if (
- this.agent &&
- (effectiveKeyId !== this.activeKeyId || effectiveModelId !== this.activeModelId)
+ tabAgent.agent &&
+ (effectiveKeyId !== tabAgent.keyId || effectiveModelId !== tabAgent.modelId)
) {
- this.agent = null;
+ tabAgent.agent = null;
}
- if (!this.agent) {
+ if (!tabAgent.agent) {
const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
const tools = [
@@ -176,7 +209,7 @@ export class AgentManager {
createWriteFileTool(workingDirectory),
createListFilesTool(workingDirectory),
createRunShellTool(workingDirectory),
- createTaskListTool(this.taskList),
+ createTaskListTool(tabAgent.taskList),
];
const ruleset = configToRuleset(this.config);
@@ -208,8 +241,8 @@ export class AgentManager {
baseURL = key.base_url;
model = effectiveModelId;
provider = "anthropic";
- this.activeKeyId = effectiveKeyId;
- this.activeModelId = effectiveModelId;
+ tabAgent.keyId = effectiveKeyId;
+ tabAgent.modelId = effectiveModelId;
useOverride = true;
} else {
// Token expired — await the async refresh
@@ -221,8 +254,8 @@ export class AgentManager {
baseURL = key.base_url;
model = effectiveModelId;
provider = "anthropic";
- this.activeKeyId = effectiveKeyId;
- this.activeModelId = effectiveModelId;
+ tabAgent.keyId = effectiveKeyId;
+ tabAgent.modelId = effectiveModelId;
useOverride = true;
} else {
console.warn(`dispatch: unable to refresh Claude credentials for "${account.label}" — using stale token`);
@@ -231,8 +264,8 @@ export class AgentManager {
baseURL = key.base_url;
model = effectiveModelId;
provider = "anthropic";
- this.activeKeyId = effectiveKeyId;
- this.activeModelId = effectiveModelId;
+ tabAgent.keyId = effectiveKeyId;
+ tabAgent.modelId = effectiveModelId;
useOverride = true;
}
}
@@ -246,13 +279,13 @@ export class AgentManager {
apiKey = envKey;
baseURL = key.base_url;
model = effectiveModelId;
- this.activeKeyId = effectiveKeyId;
- this.activeModelId = effectiveModelId;
+ tabAgent.keyId = effectiveKeyId;
+ tabAgent.modelId = effectiveModelId;
useOverride = true;
} else {
console.warn(`dispatch: env var "${key.env}" not set for key "${key.id}", falling back to env vars`);
- this.activeKeyId = effectiveKeyId;
- this.activeModelId = effectiveModelId;
+ tabAgent.keyId = effectiveKeyId;
+ tabAgent.modelId = effectiveModelId;
useOverride = true;
}
}
@@ -263,11 +296,11 @@ export class AgentManager {
if (!useOverride) {
// Clear any previous override when falling back to default resolution
- this.activeKeyId = null;
- this.activeModelId = null;
+ tabAgent.keyId = null;
+ tabAgent.modelId = null;
}
- this.agent = new Agent({
+ tabAgent.agent = new Agent({
model,
apiKey,
baseURL,
@@ -280,45 +313,112 @@ export class AgentManager {
...(claudeCredentials ? { claudeCredentials } : {}),
});
}
- return this.agent;
+ return tabAgent.agent;
+ }
+
+ getTabStatus(tabId: string): AgentStatus {
+ return this.tabAgents.get(tabId)?.status ?? "idle";
+ }
+
+ getAllStatuses(): Record<string, AgentStatus> {
+ const result: Record<string, AgentStatus> = {};
+ for (const [tabId, tabAgent] of this.tabAgents.entries()) {
+ result[tabId] = tabAgent.status;
+ }
+ return result;
}
+ /** @deprecated Use getTabStatus(tabId) instead */
getStatus(): AgentStatus {
- return this.status;
+ // Return running if any tab is running, otherwise idle
+ for (const tabAgent of this.tabAgents.values()) {
+ if (tabAgent.status === "running") return "running";
+ }
+ return "idle";
}
getMessageCount(): number {
return this.messageCount;
}
- onEvent(listener: (event: AgentEvent) => void): () => void {
+ onEvent(listener: (event: AgentEvent & { tabId: string }) => void): () => void {
this.eventListeners.add(listener);
return () => {
this.eventListeners.delete(listener);
};
}
- private emit(event: AgentEvent): void {
+ private emit(event: AgentEvent, tabId: string): void {
for (const listener of this.eventListeners) {
- listener(event);
+ listener({ ...event, tabId } as AgentEvent & { tabId: string });
}
}
- async processMessage(message: string, keyId?: string, modelId?: string, reasoningEffort?: "none" | "low" | "medium" | "high" | "max"): Promise<void> {
- this.status = "running";
+ stopTab(tabId: string): void {
+ const tabAgent = this.tabAgents.get(tabId);
+ if (tabAgent) {
+ tabAgent.status = "idle";
+ tabAgent.agent = null;
+ }
+ }
+
+ deleteTab(tabId: string): void {
+ this.stopTab(tabId);
+ this.tabAgents.delete(tabId);
+ }
+
+ async processMessage(tabId: string, message: string, keyId?: string, modelId?: string, reasoningEffort?: "none" | "low" | "medium" | "high" | "max"): Promise<void> {
+ const tabAgent = this._getOrCreateTabAgent(tabId);
+ tabAgent.status = "running";
this.messageCount += 1;
try {
- const agent = await this.getOrCreateAgent(keyId, modelId);
+ const agent = await this.getOrCreateAgentForTab(tabId, keyId, modelId);
+
+ // Persist user message to DB
+ appendMessage(tabId, crypto.randomUUID(), "user", JSON.stringify([{ type: "text", text: message }]));
+
+ let assistantText = "";
+ let assistantThinking = "";
+ const assistantToolCalls: Array<{ id: string; name: string; arguments: Record<string, unknown>; result?: string; isError?: boolean }> = [];
+
for await (const event of agent.run(message, reasoningEffort ? { reasoningEffort } : undefined)) {
- this.status = event.type === "status" ? event.status : this.status;
- this.emit(event);
+ if (event.type === "status") {
+ tabAgent.status = event.status;
+ }
+ this.emit(event, tabId);
+
+ // Accumulate content for DB persistence
+ if (event.type === "text-delta") {
+ assistantText += event.delta;
+ } else if (event.type === "reasoning-delta") {
+ assistantThinking += event.delta;
+ } else if (event.type === "tool-call") {
+ assistantToolCalls.push({ id: event.toolCall.id, name: event.toolCall.name, arguments: event.toolCall.arguments });
+ } else if (event.type === "tool-result") {
+ const tc = assistantToolCalls.find((t) => t.id === event.toolResult.toolCallId);
+ if (tc) { tc.result = event.toolResult.result; tc.isError = event.toolResult.isError; }
+ } else if (event.type === "done") {
+ // Persist assistant message to DB
+ const contentSegments: Array<Record<string, unknown>> = [];
+ if (assistantText) contentSegments.push({ type: "text", text: assistantText });
+ for (const tc of assistantToolCalls) {
+ contentSegments.push({ type: "tool-call", ...tc });
+ }
+ if (contentSegments.length > 0) {
+ appendMessage(tabId, crypto.randomUUID(), "assistant", JSON.stringify(contentSegments), assistantThinking || undefined);
+ }
+ // Reset for next turn
+ assistantText = "";
+ assistantThinking = "";
+ assistantToolCalls.length = 0;
+ }
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
- this.status = "error";
- this.emit({ type: "error", error: errorMsg });
- this.emit({ type: "status", status: "error" });
+ tabAgent.status = "error";
+ this.emit({ type: "error", error: errorMsg }, tabId);
+ this.emit({ type: "status", status: "error" }, tabId);
}
}
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index ce26aaa..f5588a6 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -5,6 +5,7 @@ import { PermissionManager } from "./permission-manager.js";
import { configRoutes } from "./routes/config.js";
import { skillsRoutes } from "./routes/skills.js";
import { modelsRoutes, startWakeScheduler } from "./routes/models.js";
+import { tabsRoutes } from "./routes/tabs.js";
export const permissionManager = new PermissionManager();
export const agentManager = new AgentManager(permissionManager);
@@ -17,7 +18,7 @@ app.use(
origin: "http://localhost:5173",
credentials: true,
allowHeaders: ["Content-Type", "Authorization"],
- allowMethods: ["GET", "POST", "OPTIONS"],
+ allowMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
}),
);
@@ -29,19 +30,24 @@ app.get("/status", (c) => {
return c.json({
status: agentManager.getStatus(),
messageCount: agentManager.getMessageCount(),
+ statuses: agentManager.getAllStatuses(),
});
});
app.post("/chat", async (c) => {
- const body = await c.req.json<{ message?: unknown; keyId?: unknown; modelId?: unknown; reasoningEffort?: unknown }>();
- const message = body.message;
+ const body = await c.req.json<{ tabId?: unknown; message?: unknown; keyId?: unknown; modelId?: unknown; reasoningEffort?: unknown }>();
+ const { tabId, message } = body;
+
+ if (typeof tabId !== "string" || tabId.trim() === "") {
+ return c.json({ error: "tabId must be a non-empty string" }, 400);
+ }
if (typeof message !== "string" || message.trim() === "") {
return c.json({ error: "message must be a non-empty string" }, 400);
}
- if (agentManager.getStatus() === "running") {
- return c.json({ error: "agent is already running" }, 409);
+ if (agentManager.getTabStatus(tabId) === "running") {
+ return c.json({ error: "agent is already running for this tab" }, 409);
}
const keyId = typeof body.keyId === "string" ? body.keyId : undefined;
@@ -52,7 +58,7 @@ app.post("/chat", async (c) => {
: undefined;
// Non-blocking — let the agent run in the background
- agentManager.processMessage(message, keyId, modelId, reasoningEffort).catch(console.error);
+ agentManager.processMessage(tabId, message, keyId, modelId, reasoningEffort).catch(console.error);
return c.json({ status: "ok" });
});
@@ -60,6 +66,7 @@ app.post("/chat", async (c) => {
app.route("/config", configRoutes);
app.route("/skills", skillsRoutes);
app.route("/models", modelsRoutes);
+app.route("/tabs", tabsRoutes);
// Start the wake scheduler on boot (restores persisted schedule)
startWakeScheduler();
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 6ac6bca..a05f800 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -13,12 +13,8 @@ app.get(
return {
onOpen(_event, ws) {
- // Send current status immediately
- ws.send(JSON.stringify({ type: "status", status: agentManager.getStatus() }));
-
- // Send current task list state
- const tasks = agentManager.getTaskList().getTasks();
- ws.send(JSON.stringify({ type: "task-list-update", tasks }));
+ // Send current statuses immediately
+ ws.send(JSON.stringify({ type: "statuses", statuses: agentManager.getAllStatuses() }));
// Send any pending permission prompts
const pending = permissionManager.getPending();
diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts
new file mode 100644
index 0000000..7fe05d9
--- /dev/null
+++ b/packages/api/src/routes/tabs.ts
@@ -0,0 +1,81 @@
+import { Hono } from "hono";
+import {
+ createTab,
+ getTab,
+ listOpenTabs,
+ updateTabTitle,
+ updateTabModel,
+ updateTabStatus,
+ archiveTab,
+ getMessagesForTab,
+ getSetting,
+ setSetting,
+} from "@dispatch/core";
+
+export const tabsRoutes = new Hono();
+
+let getAgentManager: () => { stopTab(id: string): void; deleteTab(id: string): void } | null = () => null;
+
+export function setTabsAgentManager(getter: () => { stopTab(id: string): void; deleteTab(id: string): void } | null): void {
+ getAgentManager = getter;
+}
+
+tabsRoutes.get("/", (c) => {
+ const tabs = listOpenTabs();
+ return c.json({ tabs });
+});
+
+tabsRoutes.post("/", async (c) => {
+ const body = await c.req.json<{ id?: string; title?: string }>();
+ const id = body.id ?? crypto.randomUUID();
+ const title = body.title ?? "New Tab";
+ const tab = createTab(id, title);
+ return c.json(tab);
+});
+
+// Settings routes (must be before /:id to avoid conflict)
+tabsRoutes.get("/settings/title-model", (c) => {
+ const keyId = getSetting("title_model_key_id");
+ const modelId = getSetting("title_model_id");
+ return c.json({ keyId, modelId });
+});
+
+tabsRoutes.put("/settings/title-model", async (c) => {
+ const body = await c.req.json<{ keyId?: string; modelId?: string }>();
+ if (body.keyId) setSetting("title_model_key_id", body.keyId);
+ if (body.modelId) setSetting("title_model_id", body.modelId);
+ return c.json({ success: true });
+});
+
+tabsRoutes.get("/:id", (c) => {
+ const id = c.req.param("id");
+ const tab = getTab(id);
+ if (!tab) return c.json({ error: "tab not found" }, 404);
+ return c.json(tab);
+});
+
+tabsRoutes.get("/:id/messages", (c) => {
+ const id = c.req.param("id");
+ const messages = getMessagesForTab(id);
+ return c.json({ messages });
+});
+
+tabsRoutes.patch("/:id", async (c) => {
+ const id = c.req.param("id");
+ const body = await c.req.json<{ title?: string; keyId?: string; modelId?: string; status?: string }>();
+ if (body.title !== undefined) updateTabTitle(id, body.title);
+ if (body.keyId !== undefined || body.modelId !== undefined) {
+ updateTabModel(id, body.keyId ?? null, body.modelId ?? null);
+ }
+ if (body.status !== undefined) updateTabStatus(id, body.status);
+ const tab = getTab(id);
+ return c.json(tab);
+});
+
+tabsRoutes.delete("/:id", (c) => {
+ const id = c.req.param("id");
+ const mgr = getAgentManager();
+ if (mgr) mgr.deleteTab(id);
+ archiveTab(id);
+ return c.json({ success: true });
+});
diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts
index 818aff0..2992708 100644
--- a/packages/core/src/db/index.ts
+++ b/packages/core/src/db/index.ts
@@ -74,6 +74,35 @@ export function getDatabase(): Database {
updated_at INTEGER NOT NULL
)`);
+ _db.run(`CREATE TABLE IF NOT EXISTS tabs (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ key_id TEXT,
+ model_id TEXT,
+ status TEXT NOT NULL DEFAULT 'idle',
+ is_open INTEGER NOT NULL DEFAULT 1,
+ position INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ )`);
+
+ _db.run(`CREATE TABLE IF NOT EXISTS messages (
+ id TEXT PRIMARY KEY,
+ tab_id TEXT NOT NULL REFERENCES tabs(id),
+ seq INTEGER NOT NULL,
+ role TEXT NOT NULL,
+ content_json TEXT NOT NULL,
+ thinking TEXT,
+ created_at INTEGER NOT NULL
+ )`);
+
+ _db.run(`CREATE INDEX IF NOT EXISTS idx_messages_tab ON messages(tab_id, seq)`);
+
+ _db.run(`CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ )`);
+
return _db;
}
diff --git a/packages/core/src/db/messages.ts b/packages/core/src/db/messages.ts
new file mode 100644
index 0000000..5a8758f
--- /dev/null
+++ b/packages/core/src/db/messages.ts
@@ -0,0 +1,47 @@
+import { getDatabase } from "./index.js";
+
+export interface MessageRow {
+ id: string;
+ tabId: string;
+ seq: number;
+ role: string;
+ contentJson: string;
+ thinking: string | null;
+ createdAt: number;
+}
+
+export function appendMessage(tabId: string, id: string, role: string, contentJson: string, thinking?: string): void {
+ const db = getDatabase();
+ const maxSeq = db.query("SELECT COALESCE(MAX(seq), -1) as max_seq FROM messages WHERE tab_id = $tabId").get({ $tabId: tabId }) as { max_seq: number };
+ const seq = (maxSeq?.max_seq ?? -1) + 1;
+ db.query(
+ `INSERT INTO messages (id, tab_id, seq, role, content_json, thinking, created_at)
+ VALUES ($id, $tabId, $seq, $role, $contentJson, $thinking, $now)`,
+ ).run({ $id: id, $tabId: tabId, $seq: seq, $role: role, $contentJson: contentJson, $thinking: thinking ?? null, $now: Date.now() });
+}
+
+export function updateMessage(id: string, contentJson: string, thinking?: string): void {
+ const db = getDatabase();
+ db.query(
+ "UPDATE messages SET content_json = $contentJson, thinking = $thinking WHERE id = $id",
+ ).run({ $id: id, $contentJson: contentJson, $thinking: thinking ?? null });
+}
+
+export function getMessagesForTab(tabId: string): MessageRow[] {
+ const db = getDatabase();
+ const rows = db.query("SELECT * FROM messages WHERE tab_id = $tabId ORDER BY seq ASC").all({ $tabId: tabId }) as Array<Record<string, unknown>>;
+ return rows.map((row) => ({
+ id: row.id as string,
+ tabId: row.tab_id as string,
+ seq: row.seq as number,
+ role: row.role as string,
+ contentJson: row.content_json as string,
+ thinking: row.thinking as string | null,
+ createdAt: row.created_at as number,
+ }));
+}
+
+export function clearMessagesForTab(tabId: string): void {
+ const db = getDatabase();
+ db.query("DELETE FROM messages WHERE tab_id = $tabId").run({ $tabId: tabId });
+}
diff --git a/packages/core/src/db/settings.ts b/packages/core/src/db/settings.ts
new file mode 100644
index 0000000..51d6ea2
--- /dev/null
+++ b/packages/core/src/db/settings.ts
@@ -0,0 +1,20 @@
+import { getDatabase } from "./index.js";
+
+export function getSetting(key: string): string | null {
+ const db = getDatabase();
+ const row = db.query("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as { value: string } | null;
+ return row?.value ?? null;
+}
+
+export function setSetting(key: string, value: string): void {
+ const db = getDatabase();
+ db.query(
+ `INSERT INTO settings (key, value) VALUES ($key, $value)
+ ON CONFLICT(key) DO UPDATE SET value = $value`,
+ ).run({ $key: key, $value: value });
+}
+
+export function deleteSetting(key: string): void {
+ const db = getDatabase();
+ db.query("DELETE FROM settings WHERE key = $key").run({ $key: key });
+}
diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts
new file mode 100644
index 0000000..0f4511f
--- /dev/null
+++ b/packages/core/src/db/tabs.ts
@@ -0,0 +1,73 @@
+import { getDatabase } from "./index.js";
+
+export interface TabRow {
+ id: string;
+ title: string;
+ keyId: string | null;
+ modelId: string | null;
+ status: string;
+ isOpen: boolean;
+ position: number;
+ createdAt: number;
+ updatedAt: number;
+}
+
+function rowToTab(row: Record<string, unknown>): TabRow {
+ return {
+ id: row.id as string,
+ title: row.title as string,
+ keyId: row.key_id as string | null,
+ modelId: row.model_id as string | null,
+ status: row.status as string,
+ isOpen: (row.is_open as number) === 1,
+ position: row.position as number,
+ createdAt: row.created_at as number,
+ updatedAt: row.updated_at as number,
+ };
+}
+
+export function createTab(id: string, title: string): TabRow {
+ const db = getDatabase();
+ const now = Date.now();
+ const maxPos = db.query("SELECT COALESCE(MAX(position), -1) as max_pos FROM tabs WHERE is_open = 1").get() as { max_pos: number };
+ const position = (maxPos?.max_pos ?? -1) + 1;
+ db.query(
+ `INSERT INTO tabs (id, title, key_id, model_id, status, is_open, position, created_at, updated_at)
+ VALUES ($id, $title, NULL, NULL, 'idle', 1, $position, $now, $now)`,
+ ).run({ $id: id, $title: title, $position: position, $now: now });
+ return { id, title, keyId: null, modelId: null, status: "idle", isOpen: true, position, createdAt: now, updatedAt: now };
+}
+
+export function getTab(id: string): TabRow | null {
+ const db = getDatabase();
+ const row = db.query("SELECT * FROM tabs WHERE id = $id").get({ $id: id }) as Record<string, unknown> | null;
+ return row ? rowToTab(row) : null;
+}
+
+export function listOpenTabs(): TabRow[] {
+ const db = getDatabase();
+ const rows = db.query("SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC").all() as Array<Record<string, unknown>>;
+ return rows.map(rowToTab);
+}
+
+export function updateTabTitle(id: string, title: string): void {
+ const db = getDatabase();
+ db.query("UPDATE tabs SET title = $title, updated_at = $now WHERE id = $id").run({ $id: id, $title: title, $now: Date.now() });
+}
+
+export function updateTabModel(id: string, keyId: string | null, modelId: string | null): void {
+ const db = getDatabase();
+ db.query("UPDATE tabs SET key_id = $keyId, model_id = $modelId, updated_at = $now WHERE id = $id").run({
+ $id: id, $keyId: keyId, $modelId: modelId, $now: Date.now(),
+ });
+}
+
+export function updateTabStatus(id: string, status: string): void {
+ const db = getDatabase();
+ db.query("UPDATE tabs SET status = $status, updated_at = $now WHERE id = $id").run({ $id: id, $status: status, $now: Date.now() });
+}
+
+export function archiveTab(id: string): void {
+ const db = getDatabase();
+ db.query("UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id").run({ $id: id, $now: Date.now() });
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index b1e2e14..3b49de9 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -32,3 +32,8 @@ export * from "./credentials/index.js";
// Database
export { getDatabase, closeDatabase, getDatabasePath } from "./db/index.js";
+
+// Tabs & Messages
+export { type TabRow, createTab, getTab, listOpenTabs, updateTabTitle, updateTabModel, updateTabStatus, archiveTab } from "./db/tabs.js";
+export { type MessageRow, appendMessage, updateMessage, getMessagesForTab, clearMessagesForTab } from "./db/messages.js";
+export { getSetting, setSetting, deleteSetting } from "./db/settings.js";
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,
};
}