From 904df41dee1018988250c5e25b52ad779cac7537 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 2 Jun 2026 22:52:30 +0530 Subject: [PATCH 1/8] feat(chat): wire stop button to POST /api/chat/{chatId}/stop The stop button only called the AI SDK stop() (local fetch abort), leaving the durable workflow run streaming and billing server-side. Wrap stop so it also fires POST /api/chat/{chatId}/stop (fire-and-forget, workflow chats only) to cancel the run. Co-Authored-By: Claude Opus 4.8 --- hooks/useVercelChat.ts | 15 +++++- lib/chat/__tests__/stopChatWorkflow.test.ts | 54 +++++++++++++++++++++ lib/chat/stopChatWorkflow.ts | 30 ++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 lib/chat/__tests__/stopChatWorkflow.test.ts create mode 100644 lib/chat/stopChatWorkflow.ts diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 20d5d5713..a3e996288 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -23,6 +23,7 @@ import { formatTextAttachments } from "@/lib/chat/formatTextAttachments"; import { useDeleteTrailingMessages } from "./useDeleteTrailingMessages"; import { getFileContents } from "@/lib/sandboxes/getFileContents"; import getMimeFromPath from "@/lib/files/getMimeFromPath"; +import { stopChatWorkflow } from "@/lib/chat/stopChatWorkflow"; interface UseVercelChatProps { id: string; @@ -184,7 +185,7 @@ export function useVercelChat({ [id, artistId, organizationId, accountIdOverride, model], ); - const { messages, status, stop, sendMessage, setMessages, regenerate } = + const { messages, status, stop: aiStop, sendMessage, setMessages, regenerate } = useChat({ id, transport, @@ -200,6 +201,18 @@ export function useVercelChat({ }, }); + // Stop both the local stream (AI SDK abort) and the durable workflow + // run server-side. The backend cancel is fire-and-forget so the UI + // stops instantly; it only applies to workflow chats (sessionId set). + const stop = useCallback(async () => { + if (sessionId) { + void getAccessToken() + .catch(() => null) + .then((token) => stopChatWorkflow(id, token)); + } + await aiStop(); + }, [aiStop, sessionId, id, getAccessToken]); + const earliestFailedUserMessageId = useMemo( () => getEarliestFailedUserMessageId(messages), [messages], diff --git a/lib/chat/__tests__/stopChatWorkflow.test.ts b/lib/chat/__tests__/stopChatWorkflow.test.ts new file mode 100644 index 000000000..fedf56d89 --- /dev/null +++ b/lib/chat/__tests__/stopChatWorkflow.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { stopChatWorkflow } from "../stopChatWorkflow"; +import { NEW_API_BASE_URL } from "../../consts"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe("stopChatWorkflow", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("POSTs to the chat stop endpoint with a bearer token", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + await stopChatWorkflow("chat-1", "tok-123"); + + expect(mockFetch).toHaveBeenCalledWith( + `${NEW_API_BASE_URL}/api/chat/chat-1/stop`, + { + method: "POST", + headers: { Authorization: "Bearer tok-123" }, + }, + ); + }); + + it("omits the Authorization header when unauthenticated", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + await stopChatWorkflow("chat-1", null); + + expect(mockFetch).toHaveBeenCalledWith( + `${NEW_API_BASE_URL}/api/chat/chat-1/stop`, + { method: "POST", headers: {} }, + ); + }); + + it("url-encodes the chat id", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + await stopChatWorkflow("a/b c", "tok"); + + expect(mockFetch).toHaveBeenCalledWith( + `${NEW_API_BASE_URL}/api/chat/a%2Fb%20c/stop`, + expect.any(Object), + ); + }); + + it("never throws when the request fails", async () => { + mockFetch.mockRejectedValueOnce(new Error("network down")); + + await expect(stopChatWorkflow("chat-1", "tok")).resolves.toBeUndefined(); + }); +}); diff --git a/lib/chat/stopChatWorkflow.ts b/lib/chat/stopChatWorkflow.ts new file mode 100644 index 000000000..970880d04 --- /dev/null +++ b/lib/chat/stopChatWorkflow.ts @@ -0,0 +1,30 @@ +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; + +/** + * Cancels the in-flight workflow run for a chat via recoup-api + * `POST /api/chat/{chatId}/stop`. + * + * The client AI SDK `stop()` only aborts the local fetch; the durable + * workflow run keeps streaming (and billing) server-side until it's + * cancelled here. Callers should fire this without blocking the UI stop. + * + * @param chatId - Chat row id (the workflow run is keyed off it). + * @param accessToken - Privy access token; omitted when unauthenticated. + * @returns Resolves once the request settles; never throws. + */ +export async function stopChatWorkflow( + chatId: string, + accessToken: string | null, +): Promise { + try { + await fetch( + `${getClientApiBaseUrl()}/api/chat/${encodeURIComponent(chatId)}/stop`, + { + method: "POST", + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {}, + }, + ); + } catch { + // Best-effort: the run also self-cancels when its slot is cleared. + } +} From 3adb3236a4689833fff81f2c1708b2e923d197ad Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 3 Jun 2026 00:50:52 +0530 Subject: [PATCH 2/8] fix(chat): await stop POST and skip aiStop for workflow chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calling aiStop() immediately after firing the stop POST tore down the local SSE before the server had finished cancelling — chunks the server kept emitting (and persisting via onStepFinish/onFinish) in that 1–1.5s window never reached the UI, so the assistant message visible at stop time differed from what got persisted. Reload showed more. Pair with the api-side change that holds /stop until the workflow run is terminal: await stopChatWorkflow, then return without aiStop. The server's runAgentWorkflow.finally closes the workflow writable, the SSE drains the remaining chunks to the client, and useChat transitions to "ready" on its own. Frontend at stop-complete now matches the DB. Legacy chats (no sessionId) keep the AI-SDK local abort — there's no backend endpoint to drive their teardown. Co-Authored-By: Claude Opus 4.8 --- hooks/useVercelChat.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index a3e996288..cd4aa6081 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -201,14 +201,19 @@ export function useVercelChat({ }, }); - // Stop both the local stream (AI SDK abort) and the durable workflow - // run server-side. The backend cancel is fire-and-forget so the UI - // stops instantly; it only applies to workflow chats (sessionId set). + // Producer-level stop for workflow chats: await the backend cancel so the + // server has time to run its cancellation, close the workflow writable, and + // drain SSE to the client. Once SSE closes naturally, the AI SDK + // transitions to "ready" on its own — calling `aiStop()` here would tear + // down the local fetch before in-flight chunks (which the server persists) + // reach the UI, so frontend and DB would disagree on reload. + // + // Legacy chats keep the AI-SDK local abort: no backend endpoint to call. const stop = useCallback(async () => { if (sessionId) { - void getAccessToken() - .catch(() => null) - .then((token) => stopChatWorkflow(id, token)); + const token = await getAccessToken().catch(() => null); + await stopChatWorkflow(id, token); + return; } await aiStop(); }, [aiStop, sessionId, id, getAccessToken]); From e470b81c12bb38679e75b56f4fcff87fcfa1495b Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 3 Jun 2026 03:28:23 +0530 Subject: [PATCH 3/8] chore(chat): trim verbose stop wrapper comment --- hooks/useVercelChat.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index cd4aa6081..25ec45a26 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -201,14 +201,9 @@ export function useVercelChat({ }, }); - // Producer-level stop for workflow chats: await the backend cancel so the - // server has time to run its cancellation, close the workflow writable, and - // drain SSE to the client. Once SSE closes naturally, the AI SDK - // transitions to "ready" on its own — calling `aiStop()` here would tear - // down the local fetch before in-flight chunks (which the server persists) - // reach the UI, so frontend and DB would disagree on reload. - // - // Legacy chats keep the AI-SDK local abort: no backend endpoint to call. + // Workflow chats: await the backend cancel and let SSE close naturally — + // calling aiStop here would tear down SSE before in-flight chunks reach + // the UI and frontend/DB would disagree on reload. Legacy chats abort locally. const stop = useCallback(async () => { if (sessionId) { const token = await getAccessToken().catch(() => null); From b99ded4ca01cc010bace4d28fd83dd7265893c8a Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Thu, 4 Jun 2026 03:07:10 +0530 Subject: [PATCH 4/8] feat(chat): instant stop feedback via isStopping flag useVercelChat exposes a local isStopping state set immediately on click and cleared in finally. VercelChatProvider threads it through context. ChatInput overrides the submit button to a spinner (status="submitted") and disables re-clicks while the backend round trip is in flight, so the UI no longer looks dead for ~1-2s before SSE closes. SSE close still happens naturally via the backend watcher, preserving frontend == DB on reload. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/VercelChat/ChatInput.tsx | 16 +++++++++++++--- hooks/useVercelChat.ts | 16 ++++++++++++++-- providers/VercelChatProvider.tsx | 3 +++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/components/VercelChat/ChatInput.tsx b/components/VercelChat/ChatInput.tsx index 0340790e8..fbfc904dd 100644 --- a/components/VercelChat/ChatInput.tsx +++ b/components/VercelChat/ChatInput.tsx @@ -22,6 +22,7 @@ export function ChatInput() { isLoadingSignedUrls, handleSendMessage, isGeneratingResponse, + isStopping, stop, setInput, input, @@ -33,6 +34,10 @@ export function ChatInput() { const handleSend = (event: React.FormEvent) => { event.preventDefault(); + // Already cancelling — ignore further clicks until the backend + // round-trip resolves and the SSE watcher closes the stream. + if (isStopping) return; + // Allow stop action regardless of input state if (isGeneratingResponse) { stop(); @@ -83,13 +88,18 @@ export function ChatInput() { diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 25ec45a26..bfab56cf5 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -204,10 +204,21 @@ export function useVercelChat({ // Workflow chats: await the backend cancel and let SSE close naturally — // calling aiStop here would tear down SSE before in-flight chunks reach // the UI and frontend/DB would disagree on reload. Legacy chats abort locally. + // + // `isStopping` is set the moment the user clicks so the submit button can + // flip to a clear "stopping" state without waiting for the backend round + // trip — otherwise the UI looks dead for ~1-2s while the workflow detects + // the cancel and the SSE watcher closes the stream. + const [isStopping, setIsStopping] = useState(false); const stop = useCallback(async () => { if (sessionId) { - const token = await getAccessToken().catch(() => null); - await stopChatWorkflow(id, token); + setIsStopping(true); + try { + const token = await getAccessToken().catch(() => null); + await stopChatWorkflow(id, token); + } finally { + setIsStopping(false); + } return; } await aiStop(); @@ -384,6 +395,7 @@ export function useVercelChat({ isLoading, hasError, isGeneratingResponse, + isStopping, model, isLoadingSignedUrls, diff --git a/providers/VercelChatProvider.tsx b/providers/VercelChatProvider.tsx index d72bd0119..7c6b2764f 100644 --- a/providers/VercelChatProvider.tsx +++ b/providers/VercelChatProvider.tsx @@ -23,6 +23,7 @@ interface VercelChatContextType { isLoading: boolean; hasError: boolean; isGeneratingResponse: boolean; + isStopping: boolean; isLoadingSignedUrls: boolean; handleSendMessage: (event: React.FormEvent) => Promise; stop: UseChatHelpers["stop"]; @@ -108,6 +109,7 @@ export function VercelChatProvider({ isLoading, hasError, isGeneratingResponse, + isStopping, isLoadingSignedUrls, handleSendMessage, stop, @@ -151,6 +153,7 @@ export function VercelChatProvider({ isLoading, hasError, isGeneratingResponse, + isStopping, isLoadingSignedUrls, handleSendMessage: handleSendMessageWithClear, stop, From 1c696842b8acd429d69cadf4b9b50ad3d439c2f5 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Thu, 4 Jun 2026 03:35:39 +0530 Subject: [PATCH 5/8] fix(chat): render cancelled tool-calls with stop icon, not spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MessageParts only distinguished output-available vs everything-else, so any tool part in state output-error or output-denied fell through to getToolCallComponent and painted the spinning skeleton — making a cancelled tool look like it was still running, both live and on reload. Add a getCancelledToolComponent renderer (OctagonX icon + "Cancelled {toolName}" / "Failed {toolName}" label) and route output-error / output-denied parts to it. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/VercelChat/MessageParts.tsx | 16 ++++++++++++--- components/VercelChat/ToolComponents.tsx | 26 +++++++++++++++++++++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/components/VercelChat/MessageParts.tsx b/components/VercelChat/MessageParts.tsx index 1e522c846..1383ade1c 100644 --- a/components/VercelChat/MessageParts.tsx +++ b/components/VercelChat/MessageParts.tsx @@ -10,7 +10,11 @@ import { Dispatch, SetStateAction } from "react"; import { cn } from "@/lib/utils"; import ViewingMessage from "./ViewingMessage"; import EditingMessage from "./EditingMessage"; -import { getToolCallComponent, getToolResultComponent } from "./ToolComponents"; +import { + getCancelledToolComponent, + getToolCallComponent, + getToolResultComponent, +} from "./ToolComponents"; import MessageFileViewer from "./message-file-viewer"; import { EnhancedReasoning } from "@/components/reasoning/EnhancedReasoning"; import { Actions, Action } from "@/components/actions"; @@ -107,11 +111,17 @@ export function MessageParts({ message, mode, setMode }: MessagePartsProps) { if (isToolOrDynamicToolUIPart(part)) { const { state } = part as ToolUIPart; + // Terminal-error states (user-cancel via stop, approval-denied, + // tool-thrown errors) need their own renderer — falling through + // to getToolCallComponent paints the spinning skeleton, which + // makes a cancelled tool look like it's still running. + if (state === "output-error" || state === "output-denied") { + return getCancelledToolComponent(part as ToolUIPart); + } if (state !== "output-available") { return getToolCallComponent(part as ToolUIPart); - } else { - return getToolResultComponent(part as ToolUIPart); } + return getToolResultComponent(part as ToolUIPart); } } )} diff --git a/components/VercelChat/ToolComponents.tsx b/components/VercelChat/ToolComponents.tsx index 763a69408..9267072b2 100644 --- a/components/VercelChat/ToolComponents.tsx +++ b/components/VercelChat/ToolComponents.tsx @@ -23,7 +23,7 @@ import UpdateArtistSocialsSuccess from "./tools/UpdateArtistSocialsSuccess"; import { UpdateArtistSocialsResult } from "./tools/UpdateArtistSocialsSuccess"; import { TxtFileResult } from "@/components/ui/TxtFileResult"; import { TxtFileGenerationResult } from "@/components/ui/TxtFileResult"; -import { Loader } from "lucide-react"; +import { Loader, OctagonX } from "lucide-react"; import GenericSuccess from "./tools/GenericSuccess"; import getToolInfo from "@/lib/utils/getToolsInfo"; import { isSearchProgressUpdate } from "@/lib/search/searchProgressUtils"; @@ -220,6 +220,30 @@ export function getToolCallComponent(part: ToolUIPart) { ); } +/** + * Render a tool-call that terminated in `output-error` or `output-denied` + * (cancelled by the user, denied by approval flow, or errored). Without + * this branch the renderer falls through to the spinning skeleton and + * the pill keeps "running" forever. + */ +export function getCancelledToolComponent(part: ToolUIPart | DynamicToolUIPart) { + const { toolCallId } = part; + const toolName = getToolOrDynamicToolName(part); + const label = + (part as { errorText?: string }).errorText === "Cancelled" + ? `Cancelled ${toolName}` + : `Failed ${toolName}`; + return ( +
+ + {label} +
+ ); +} + export function getToolResultComponent(part: ToolUIPart | DynamicToolUIPart) { const { toolCallId, output, type } = part; const isMcp = type === "dynamic-tool"; From 81a45498c61744e1f76470c28e481ddd5da32dd0 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Thu, 4 Jun 2026 03:57:23 +0530 Subject: [PATCH 6/8] refactor(chat): extract useStopChatWorkflow hook useVercelChat was managing isStopping state, token fetch, and the backend round-trip inline. Pull it out into a dedicated hook so the stop concern is encapsulated, useVercelChat thins out, and the hook is reusable from any consumer that needs workflow-stop semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- hooks/useStopChatWorkflow.ts | 30 ++++++++++++++++++++++++++++++ hooks/useVercelChat.ts | 22 ++++++---------------- 2 files changed, 36 insertions(+), 16 deletions(-) create mode 100644 hooks/useStopChatWorkflow.ts diff --git a/hooks/useStopChatWorkflow.ts b/hooks/useStopChatWorkflow.ts new file mode 100644 index 000000000..4b05bb48d --- /dev/null +++ b/hooks/useStopChatWorkflow.ts @@ -0,0 +1,30 @@ +import { useCallback, useState } from "react"; +import { usePrivy } from "@privy-io/react-auth"; +import { stopChatWorkflow } from "@/lib/chat/stopChatWorkflow"; + +/** + * Wraps the `POST /api/chat/{chatId}/stop` round-trip with a local + * `isStopping` flag. Consumers (e.g. the submit-button) read `isStopping` + * to flip to a "stopping" state the instant the user clicks, so the UI + * doesn't sit dead for the 1–2s while the backend cancels the workflow + * and the SSE watcher closes the stream. + * + * Workflow-chat-only: legacy `/api/chat` aborts locally via the AI SDK's + * `stop()` and never hits this hook. + */ +export function useStopChatWorkflow(chatId: string) { + const { getAccessToken } = usePrivy(); + const [isStopping, setIsStopping] = useState(false); + + const stop = useCallback(async () => { + setIsStopping(true); + try { + const token = await getAccessToken().catch(() => null); + await stopChatWorkflow(chatId, token); + } finally { + setIsStopping(false); + } + }, [chatId, getAccessToken]); + + return { stop, isStopping }; +} diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index bfab56cf5..78416a7d2 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -23,7 +23,7 @@ import { formatTextAttachments } from "@/lib/chat/formatTextAttachments"; import { useDeleteTrailingMessages } from "./useDeleteTrailingMessages"; import { getFileContents } from "@/lib/sandboxes/getFileContents"; import getMimeFromPath from "@/lib/files/getMimeFromPath"; -import { stopChatWorkflow } from "@/lib/chat/stopChatWorkflow"; +import { useStopChatWorkflow } from "./useStopChatWorkflow"; interface UseVercelChatProps { id: string; @@ -203,26 +203,16 @@ export function useVercelChat({ // Workflow chats: await the backend cancel and let SSE close naturally — // calling aiStop here would tear down SSE before in-flight chunks reach - // the UI and frontend/DB would disagree on reload. Legacy chats abort locally. - // - // `isStopping` is set the moment the user clicks so the submit button can - // flip to a clear "stopping" state without waiting for the backend round - // trip — otherwise the UI looks dead for ~1-2s while the workflow detects - // the cancel and the SSE watcher closes the stream. - const [isStopping, setIsStopping] = useState(false); + // the UI and frontend/DB would disagree on reload. Legacy chats abort + // locally via the AI SDK's `stop()`. + const { stop: stopWorkflow, isStopping } = useStopChatWorkflow(id); const stop = useCallback(async () => { if (sessionId) { - setIsStopping(true); - try { - const token = await getAccessToken().catch(() => null); - await stopChatWorkflow(id, token); - } finally { - setIsStopping(false); - } + await stopWorkflow(); return; } await aiStop(); - }, [aiStop, sessionId, id, getAccessToken]); + }, [aiStop, sessionId, stopWorkflow]); const earliestFailedUserMessageId = useMemo( () => getEarliestFailedUserMessageId(messages), From 8f9d9f41711732c1acb536d6bf960bad0ec430c5 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Thu, 4 Jun 2026 04:03:38 +0530 Subject: [PATCH 7/8] refactor(chat): useStopChatWorkflow on react-query useMutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the established hook pattern (useDeleteChat, useCreateTask, usePulseToggle, etc.) and drop the hand-rolled useState. Same public shape — { stop, isStopping } — so useVercelChat is untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- hooks/useStopChatWorkflow.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/hooks/useStopChatWorkflow.ts b/hooks/useStopChatWorkflow.ts index 4b05bb48d..21c02ba0e 100644 --- a/hooks/useStopChatWorkflow.ts +++ b/hooks/useStopChatWorkflow.ts @@ -1,30 +1,29 @@ -import { useCallback, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; import { usePrivy } from "@privy-io/react-auth"; import { stopChatWorkflow } from "@/lib/chat/stopChatWorkflow"; /** - * Wraps the `POST /api/chat/{chatId}/stop` round-trip with a local - * `isStopping` flag. Consumers (e.g. the submit-button) read `isStopping` - * to flip to a "stopping" state the instant the user clicks, so the UI - * doesn't sit dead for the 1–2s while the backend cancels the workflow - * and the SSE watcher closes the stream. + * Wraps the `POST /api/chat/{chatId}/stop` round-trip with a React Query + * mutation. Consumers read `isStopping` (`mutation.isPending`) to flip + * the submit button to a "stopping" state the instant the user clicks, + * so the UI doesn't sit dead for the 1–2s while the backend cancels the + * workflow and the SSE watcher closes the stream. * * Workflow-chat-only: legacy `/api/chat` aborts locally via the AI SDK's * `stop()` and never hits this hook. */ export function useStopChatWorkflow(chatId: string) { const { getAccessToken } = usePrivy(); - const [isStopping, setIsStopping] = useState(false); - const stop = useCallback(async () => { - setIsStopping(true); - try { + const mutation = useMutation({ + mutationFn: async () => { const token = await getAccessToken().catch(() => null); await stopChatWorkflow(chatId, token); - } finally { - setIsStopping(false); - } - }, [chatId, getAccessToken]); + }, + }); - return { stop, isStopping }; + return { + stop: mutation.mutateAsync, + isStopping: mutation.isPending, + }; } From f0b3d7a5a6f70175c4a8138a1b7c25001198b684 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 8 Jun 2026 10:50:52 -0500 Subject: [PATCH 8/8] draft: simplify stop to open-agents model (instant aiStop + void POST, drop isStopping) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DRAFT for comparison vs #1770. Stop now aborts locally instantly (aiStop) for all chats and fire-and-forgets POST /api/chat/{chatId}/stop for workflow chats; removes the isStopping mutation/pending-state threaded through hook→provider→ChatInput. Reload-correctness is covered server-side by api#590. --- components/VercelChat/ChatInput.tsx | 14 +++---------- hooks/useStopChatWorkflow.ts | 31 ++++++++++++----------------- hooks/useVercelChat.ts | 16 ++++++--------- providers/VercelChatProvider.tsx | 3 --- 4 files changed, 22 insertions(+), 42 deletions(-) diff --git a/components/VercelChat/ChatInput.tsx b/components/VercelChat/ChatInput.tsx index 44dcc2bba..1e9eb99f0 100644 --- a/components/VercelChat/ChatInput.tsx +++ b/components/VercelChat/ChatInput.tsx @@ -23,7 +23,6 @@ export function ChatInput() { isLoadingSignedUrls, handleSendMessage, isGeneratingResponse, - isStopping, workspaceStatus, stop, setInput, @@ -43,10 +42,6 @@ export function ChatInput() { const handleSend = (event: React.FormEvent) => { event.preventDefault(); - // Already cancelling — ignore further clicks until the backend - // round-trip resolves and the SSE watcher closes the stream. - if (isStopping) return; - // Allow stop action regardless of input state if (isGeneratingResponse) { stop(); @@ -99,15 +94,12 @@ export function ChatInput() { diff --git a/hooks/useStopChatWorkflow.ts b/hooks/useStopChatWorkflow.ts index 21c02ba0e..504f6a311 100644 --- a/hooks/useStopChatWorkflow.ts +++ b/hooks/useStopChatWorkflow.ts @@ -1,29 +1,24 @@ -import { useMutation } from "@tanstack/react-query"; +import { useCallback } from "react"; import { usePrivy } from "@privy-io/react-auth"; import { stopChatWorkflow } from "@/lib/chat/stopChatWorkflow"; /** - * Wraps the `POST /api/chat/{chatId}/stop` round-trip with a React Query - * mutation. Consumers read `isStopping` (`mutation.isPending`) to flip - * the submit button to a "stopping" state the instant the user clicks, - * so the UI doesn't sit dead for the 1–2s while the backend cancels the - * workflow and the SSE watcher closes the stream. + * Returns a fire-and-forget trigger for `POST /api/chat/{chatId}/stop`. * - * Workflow-chat-only: legacy `/api/chat` aborts locally via the AI SDK's - * `stop()` and never hits this hook. + * Matches open-agents: the instant UI stop comes from the AI SDK's local + * `stop()`, so we deliberately do not await the backend cancel. Correct + * persisted state on reload is guaranteed server-side (api#590 persists the + * assistant message per step and closes open tool-calls on abort). + * + * Workflow-chat-only: legacy `/api/chat` aborts locally and never hits this. */ export function useStopChatWorkflow(chatId: string) { const { getAccessToken } = usePrivy(); - const mutation = useMutation({ - mutationFn: async () => { + return useCallback(() => { + void (async () => { const token = await getAccessToken().catch(() => null); - await stopChatWorkflow(chatId, token); - }, - }); - - return { - stop: mutation.mutateAsync, - isStopping: mutation.isPending, - }; + await stopChatWorkflow(chatId, token).catch(() => {}); + })(); + }, [chatId, getAccessToken]); } diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 1aad4c367..cec9a70b4 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -222,16 +222,13 @@ export function useVercelChat({ }, }); - // Workflow chats: await the backend cancel and let SSE close naturally — - // calling aiStop here would tear down SSE before in-flight chunks reach - // the UI and frontend/DB would disagree on reload. Legacy chats abort - // locally via the AI SDK's `stop()`. - const { stop: stopWorkflow, isStopping } = useStopChatWorkflow(transportChatId); + // Stop = instant local abort for all chats; for workflow chats also fire a + // best-effort backend cancel (POST /api/chat/{chatId}/stop). We don't await + // it — the local stop() gives the instant UI stop, and api#590 persists the + // correct turn state server-side, so reload stays consistent either way. + const stopWorkflow = useStopChatWorkflow(transportChatId); const stop = useCallback(async () => { - if (sessionId) { - await stopWorkflow(); - return; - } + if (sessionId) stopWorkflow(); await aiStop(); }, [aiStop, sessionId, stopWorkflow]); @@ -422,7 +419,6 @@ export function useVercelChat({ isLoading, hasError, isGeneratingResponse, - isStopping, model, isLoadingSignedUrls, diff --git a/providers/VercelChatProvider.tsx b/providers/VercelChatProvider.tsx index 6e3370493..d085a8f4c 100644 --- a/providers/VercelChatProvider.tsx +++ b/providers/VercelChatProvider.tsx @@ -25,7 +25,6 @@ interface VercelChatContextType { isLoading: boolean; hasError: boolean; isGeneratingResponse: boolean; - isStopping: boolean; isLoadingSignedUrls: boolean; handleSendMessage: (event: React.FormEvent) => Promise; stop: UseChatHelpers["stop"]; @@ -119,7 +118,6 @@ export function VercelChatProvider({ isLoading, hasError, isGeneratingResponse, - isStopping, isLoadingSignedUrls, handleSendMessage, stop, @@ -165,7 +163,6 @@ export function VercelChatProvider({ isLoading, hasError, isGeneratingResponse, - isStopping, isLoadingSignedUrls, handleSendMessage: handleSendMessageWithClear, stop,