summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-21 19:52:35 +0900
committerAdam Malczewski <[email protected]>2026-05-21 19:52:35 +0900
commit4ba2673557c7cfd0bc31b03e2c35ab5e1efe60e7 (patch)
treedddae8fc37a12157f4c8e840475e9e5d6203464a /packages/api/src
parent55633c90c0d96e62153a4b6655f3f833a3b46ad4 (diff)
downloaddispatch-4ba2673557c7cfd0bc31b03e2c35ab5e1efe60e7.tar.gz
dispatch-4ba2673557c7cfd0bc31b03e2c35ab5e1efe60e7.zip
feat: tab system with per-tab agents, DB persistence, and DaisyUI tabs-lift UI
- Add tabs, messages, and settings tables to SQLite database - Backend: refactor AgentManager to manage per-tab Agent instances via Map<tabId, TabAgent> - Backend: WebSocket events tagged with tabId for multiplexing - Backend: tab CRUD routes (create, list, update, archive, messages) - Backend: persist user and assistant messages to DB during chat - Frontend: new tabStore replaces single chatStore with multi-tab reactive state - Frontend: TabBar component using DaisyUI tabs-lift style with status dots - Frontend: Settings sidebar panel for title generation model selection - Frontend: wire ChatPanel, ChatInput, Header to use tabStore - Fix HMR listener accumulation via wsClient.clearCallbacks() - Delete old single-chat chatStore (chat.svelte.ts)
Diffstat (limited to 'packages/api/src')
-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
4 files changed, 252 insertions, 68 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 });
+});