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 | |
| 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)
| -rw-r--r-- | packages/api/src/agent-manager.ts | 212 | ||||
| -rw-r--r-- | packages/api/src/app.ts | 19 | ||||
| -rw-r--r-- | packages/api/src/index.ts | 8 | ||||
| -rw-r--r-- | packages/api/src/routes/tabs.ts | 81 | ||||
| -rw-r--r-- | packages/core/src/db/index.ts | 29 | ||||
| -rw-r--r-- | packages/core/src/db/messages.ts | 47 | ||||
| -rw-r--r-- | packages/core/src/db/settings.ts | 20 | ||||
| -rw-r--r-- | packages/core/src/db/tabs.ts | 73 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 5 | ||||
| -rw-r--r-- | packages/frontend/src/App.svelte | 41 | ||||
| -rw-r--r-- | packages/frontend/src/lib/chat.svelte.ts | 414 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatInput.svelte | 6 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatPanel.svelte | 33 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/Header.svelte | 12 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SettingsPanel.svelte | 128 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 5 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/TabBar.svelte | 47 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 455 | ||||
| -rw-r--r-- | packages/frontend/src/lib/ws.svelte.ts | 6 |
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, }; } |
