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";
diff --git a/hooks/useStopChatWorkflow.ts b/hooks/useStopChatWorkflow.ts
new file mode 100644
index 000000000..504f6a311
--- /dev/null
+++ b/hooks/useStopChatWorkflow.ts
@@ -0,0 +1,24 @@
+import { useCallback } from "react";
+import { usePrivy } from "@privy-io/react-auth";
+import { stopChatWorkflow } from "@/lib/chat/stopChatWorkflow";
+
+/**
+ * Returns a fire-and-forget trigger for `POST /api/chat/{chatId}/stop`.
+ *
+ * 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();
+
+ return useCallback(() => {
+ void (async () => {
+ const token = await getAccessToken().catch(() => null);
+ await stopChatWorkflow(chatId, token).catch(() => {});
+ })();
+ }, [chatId, getAccessToken]);
+}
diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts
index ab9d202c2..cec9a70b4 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 { useStopChatWorkflow } from "./useStopChatWorkflow";
import { getChatPath } from "@/lib/chat/getChatPath";
import { usePersistSelectedModel } from "./usePersistSelectedModel";
@@ -205,7 +206,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,
@@ -221,6 +222,16 @@ export function useVercelChat({
},
});
+ // 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) stopWorkflow();
+ await aiStop();
+ }, [aiStop, sessionId, stopWorkflow]);
+
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.
+ }
+}