diff options
| author | Adam Malczewski <[email protected]> | 2026-05-29 18:45:45 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-29 18:45:45 +0900 |
| commit | dcbc51e22dcd3d7b5a01d6c3b7b0285efa49bca4 (patch) | |
| tree | 72e1a635662ed20deaee1f0b01408a5954445b26 /packages | |
| parent | 5b3e1ac64681e233f35e1b4d2230d9988667c37e (diff) | |
| download | dispatch-dcbc51e22dcd3d7b5a01d6c3b7b0285efa49bca4.tar.gz dispatch-dcbc51e22dcd3d7b5a01d6c3b7b0285efa49bca4.zip | |
feat: stop generation button with abort signal plumbing
- Add POST /chat/stop endpoint on API
- Thread abortSignal from agent-manager through Agent.run() to streamText
- Thread abortSignal option through the Agent.run() signature
- Emit status:idle on stopTab() so frontend WS gets the update
- Add stopGeneration() store method on frontend tabStore
- Add stop button in ChatInput (btn-sm lg:btn-xs for mobile tap target)
- Add tests for /chat/stop endpoint
- Refactor processMessage to pass abortSignal to agent.run
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 9 | ||||
| -rw-r--r-- | packages/api/src/app.ts | 9 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 22 | ||||
| -rw-r--r-- | packages/core/src/agent/agent.ts | 6 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatInput.svelte | 11 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 13 |
6 files changed, 64 insertions, 6 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 69c071d..c09a607 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -885,6 +885,7 @@ export class AgentManager { } tabAgent.abortController?.abort(); tabAgent.status = "idle"; + this.emit({ type: "status", status: "idle" }, tabId); tabAgent.agent = null; // Resolve any pending completion promise so retrieve doesn't hang tabAgent.completionResolve?.({ status: "error", error: "Agent was stopped." }); @@ -1193,10 +1194,10 @@ export class AgentManager { // Best-effort — if this fails, appendMessage will throw and we'll catch it below } - for await (const event of agent.run( - message, - reasoningEffort ? { reasoningEffort } : undefined, - )) { + for await (const event of agent.run(message, { + ...(reasoningEffort ? { reasoningEffort } : {}), + abortSignal: tabAgent.abortController?.signal, + })) { // Stop processing if the tab was aborted (closed/stopped). // stopTab() already injected a `cancelled` system chunk into // `chunks` before flipping the abort flag, so we just need diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index ba5dabd..73d3de5 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -94,6 +94,15 @@ app.post("/chat/cancel", async (c) => { return c.json({ success: cancelled }); }); +app.post("/chat/stop", async (c) => { + const body = await c.req.json(); + if (typeof body.tabId !== "string") { + return c.json({ error: "tabId is required" }, 400); + } + agentManager.stopTab(body.tabId); + return c.json({ success: true }); +}); + app.route("/skills", skillsRoutes); app.route("/models", modelsRoutes); app.route("/tabs", tabsRoutes); diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index 49bae49..eba2226 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -318,3 +318,25 @@ describe("POST /chat", () => { expect(typeof body.messageId).toBe("string"); }); }); + +describe("POST /chat/stop", () => { + it("returns 200 with success: true for valid tabId", async () => { + const res = await app.request("/chat/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tabId: "tab-stop-1" }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ success: true }); + }); + + it("returns 400 when tabId is missing", async () => { + const res = await app.request("/chat/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); +}); diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 4eace0c..82b98df 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -673,7 +673,10 @@ export class Agent { async *run( userMessage: string, - options?: { reasoningEffort?: "none" | "low" | "medium" | "high" | "max" }, + options?: { + reasoningEffort?: "none" | "low" | "medium" | "high" | "max"; + abortSignal?: AbortSignal; + }, ): AsyncGenerator<AgentEvent> { this.status = "running"; yield { type: "status", status: "running" }; @@ -779,6 +782,7 @@ export class Agent { model, messages: coreMessages, tools, + abortSignal: options?.abortSignal, }; // Encourage tool use on Anthropic. Without an explicit diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte index 3bbc4ab..f9c9546 100644 --- a/packages/frontend/src/lib/components/ChatInput.svelte +++ b/packages/frontend/src/lib/components/ChatInput.svelte @@ -5,6 +5,7 @@ let inputEl: HTMLInputElement | undefined; let inputValue = $state(""); const agentStatus = $derived(tabStore.activeTab?.agentStatus ?? "idle"); +const tabId = $derived(tabStore.activeTab?.id ?? ""); $effect(() => { inputEl?.focus(); @@ -27,7 +28,15 @@ function submit() { <div class="flex items-center gap-2 p-3"> {#if agentStatus === "running"} - <span class="loading loading-spinner loading-sm text-primary"></span> + <button + type="button" + class="btn btn-ghost gap-1 btn-sm lg:btn-xs" + onclick={() => tabStore.stopGeneration(tabId)} + title="Stop generation" + > + <span class="loading loading-spinner loading-sm text-primary"></span> + <span class="text-xs">Stop</span> + </button> {:else if agentStatus === "idle"} <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5 text-success"> <polyline points="20 6 9 17 4 12"></polyline> diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index ccdd587..f96a35e 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -1595,6 +1595,18 @@ export function createTabStore() { } } + async function stopGeneration(tabId: string): Promise<void> { + try { + await fetch(`${config.apiBase}/chat/stop`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tabId }), + }); + } catch { + // ignore + } + } + function copyConversation(): string { const tab = getActiveTab(); if (!tab) return ""; @@ -1738,6 +1750,7 @@ export function createTabStore() { closeTab, sendMessage, cancelQueuedMessage, + stopGeneration, changeModel, setKey, setAgent, |
