summaryrefslogtreecommitdiffhomepage
path: root/packages/api
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-22 20:54:19 +0900
committerAdam Malczewski <[email protected]>2026-05-22 20:54:19 +0900
commitc47346cc6237044ecb60ff22c4011d89744af581 (patch)
tree2359a25e687e1290ba5180fd60eae83b03b53a23 /packages/api
parent288b21cec98421fda57028a0c8c9d835cfbb14b0 (diff)
downloaddispatch-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.ts91
-rw-r--r--packages/api/src/app.ts19
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);