diff options
| author | Adam Malczewski <[email protected]> | 2026-05-22 20:54:19 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-22 20:54:19 +0900 |
| commit | c47346cc6237044ecb60ff22c4011d89744af581 (patch) | |
| tree | 2359a25e687e1290ba5180fd60eae83b03b53a23 /packages/api | |
| parent | 288b21cec98421fda57028a0c8c9d835cfbb14b0 (diff) | |
| download | dispatch-c47346cc6237044ecb60ff22c4011d89744af581.tar.gz dispatch-c47346cc6237044ecb60ff22c4011d89744af581.zip | |
feat: message queue/interrupt system, CORS fix, mobile fixes, chat splitting
- Add message queue allowing users to send messages while agent is running
- Queue messages are injected into tool results as [USER INTERRUPT]
- Retrieve tool interrupted via Promise.race when user message arrives
- Queued messages show with 'queued' badge and cancel button
- Consumed messages repositioned and chat splits at interrupt point
- New assistant message block created after interrupt for clean flow
- Add POST /chat/cancel endpoint for cancelling queued messages
- Fix CORS to allow any origin (Tailscale/LAN access)
- Fix crypto.randomUUID fallback for non-secure contexts (HTTP)
- Fix frontend API URL derivation from page hostname
- Auto-create DB tab if missing on processMessage (foreign key fix)
- Add error logging to processMessage catch block
- Fix working directory input sync on agent switch
- Fix agent mode button to re-apply agent settings
Diffstat (limited to 'packages/api')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 91 | ||||
| -rw-r--r-- | packages/api/src/app.ts | 19 |
2 files changed, 105 insertions, 5 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 82b8456..70cada4 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -21,6 +21,7 @@ import { loadConfig, loadSkills, ModelRegistry, + type QueuedMessage, refreshAccountCredentials, refreshAccountCredentialsAsync, resolveApiKey, @@ -129,6 +130,10 @@ interface TabAgent { toolsOverride?: string[]; /** Working directory override for child agents. */ workingDirectoryOverride?: string; + /** Queue of messages sent while the agent is running. */ + messageQueue: QueuedMessage[]; + /** Callbacks to wake up blocking tools waiting for queued messages. */ + queueListeners: Array<() => void>; } export class AgentManager { @@ -262,6 +267,8 @@ export class AgentManager { keyId: null, modelId: null, taskList, + messageQueue: [], + queueListeners: [], }; this.tabAgents.set(tabId, tabAgent); } @@ -503,8 +510,9 @@ export class AgentManager { tabAgent.modelId = null; } - const customSystemPrompt = getSetting("system_prompt") || undefined; - tabAgent.agent = new Agent({ + const customSystemPrompt = getSetting("system_prompt") || undefined; + tabAgent.agent = new Agent( + { model, apiKey, baseURL, @@ -515,7 +523,12 @@ export class AgentManager { ruleset, provider, ...(claudeCredentials ? { claudeCredentials } : {}), - }); + }, + { + dequeueMessages: () => this.dequeueMessages(tabId), + waitForQueuedMessage: () => this.waitForQueuedMessage(tabId), + }, + ); } return tabAgent.agent; } @@ -744,6 +757,19 @@ export class AgentManager { try { const agent = await this.getOrCreateAgentForTab(tabId, keyId, modelId); + // Ensure tab exists in DB (frontend may have failed to create it) + try { + const { getDatabase } = await import("@dispatch/core"); + const db = getDatabase(); + const exists = db.query("SELECT 1 FROM tabs WHERE id = $id").get({ $id: tabId }); + if (!exists) { + const { createTab } = await import("@dispatch/core"); + createTab(tabId, "New Tab", { keyId: keyId ?? null, modelId: modelId ?? null }); + } + } catch { + // Best-effort — if this fails, appendMessage will throw and we'll catch it below + } + // Persist user message to DB appendMessage( tabId, @@ -805,6 +831,7 @@ export class AgentManager { } } } catch (err) { + console.error(`[dispatch] processMessage error for tab ${tabId}:`, err); const errorMsg = err instanceof Error ? err.message : String(err); processError = errorMsg; tabAgent.status = "error"; @@ -839,6 +866,64 @@ export class AgentManager { } } + queueMessage(tabId: string, message: string, clientId?: string): { messageId: string } { + const tabAgent = this.tabAgents.get(tabId); + if (!tabAgent) throw new Error("Tab not found"); + const id = clientId || crypto.randomUUID(); + const queued: QueuedMessage = { id, message, timestamp: Date.now() }; + tabAgent.messageQueue.push(queued); + // Wake up any blocking tools waiting for queue + for (const listener of tabAgent.queueListeners) { + listener(); + } + tabAgent.queueListeners = []; + this.emit({ type: "message-queued", tabId, messageId: id, message }, tabId); + return { messageId: id }; + } + + cancelQueuedMessage(tabId: string, messageId: string): boolean { + const tabAgent = this.tabAgents.get(tabId); + if (!tabAgent) return false; + const idx = tabAgent.messageQueue.findIndex((m) => m.id === messageId); + if (idx === -1) return false; + tabAgent.messageQueue.splice(idx, 1); + this.emit({ type: "message-cancelled", tabId, messageId }, tabId); + return true; + } + + dequeueMessages(tabId: string): QueuedMessage[] { + const tabAgent = this.tabAgents.get(tabId); + if (!tabAgent) return []; + const messages = [...tabAgent.messageQueue]; + tabAgent.messageQueue = []; + if (messages.length > 0) { + this.emit( + { type: "message-consumed", tabId, messageIds: messages.map((m) => m.id) }, + tabId, + ); + } + return messages; + } + + waitForQueuedMessage(tabId: string): { promise: Promise<void>; cancel: () => void } { + const tabAgent = this.tabAgents.get(tabId); + if (!tabAgent) return { promise: Promise.resolve(), cancel: () => {} }; + if (tabAgent.messageQueue.length > 0) return { promise: Promise.resolve(), cancel: () => {} }; + + let listener: (() => void) | null = null; + const promise = new Promise<void>((resolve) => { + listener = resolve; + tabAgent.queueListeners.push(resolve); + }); + const cancel = () => { + if (listener) { + tabAgent.queueListeners = tabAgent.queueListeners.filter(l => l !== listener); + listener = null; + } + }; + return { promise, cancel }; + } + destroy(): void { this.configWatcher?.close(); this.skillsWatcher?.close(); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 37514c3..53cfb6c 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -16,7 +16,7 @@ export const app = new Hono(); app.use( "*", cors({ - origin: "http://localhost:5173", + origin: (origin) => origin || "*", credentials: true, allowHeaders: ["Content-Type", "Authorization"], allowMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], @@ -43,6 +43,7 @@ app.post("/chat", async (c) => { modelId?: unknown; reasoningEffort?: unknown; workingDirectory?: unknown; + queueId?: unknown; }>(); const { tabId, message } = body; @@ -55,7 +56,9 @@ app.post("/chat", async (c) => { } if (agentManager.getTabStatus(tabId) === "running") { - return c.json({ error: "agent is already running for this tab" }, 409); + const queueId = typeof body.queueId === "string" ? body.queueId : undefined; + const { messageId } = agentManager.queueMessage(tabId, message, queueId); + return c.json({ status: "queued", messageId }); } const keyId = typeof body.keyId === "string" ? body.keyId : undefined; @@ -74,6 +77,18 @@ app.post("/chat", async (c) => { }); app.route("/config", configRoutes); + +app.post("/chat/cancel", async (c) => { + const body = await c.req.json(); + if (typeof body.tabId !== "string" || typeof body.messageId !== "string") { + return c.json({ error: "tabId and messageId are required strings" }, 400); + } + const tabId = body.tabId; + const messageId = body.messageId; + const cancelled = agentManager.cancelQueuedMessage(tabId, messageId); + return c.json({ success: cancelled }); +}); + app.route("/skills", skillsRoutes); app.route("/models", modelsRoutes); app.route("/tabs", tabsRoutes); |
