Skip to content

feat(chat): wire stop button to POST /api/chat/{chatId}/stop#1770

Open
arpitgupta1214 wants to merge 9 commits into
testfrom
feat/wire-stop-button-to-backend
Open

feat(chat): wire stop button to POST /api/chat/{chatId}/stop#1770
arpitgupta1214 wants to merge 9 commits into
testfrom
feat/wire-stop-button-to-backend

Conversation

@arpitgupta1214

@arpitgupta1214 arpitgupta1214 commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Wires the chat stop button to the backend so stopping actually cancels the durable workflow run, not just the local stream.

Previously the stop button only called the AI SDK stop() (aborts the client fetch). The Vercel Workflow run kept streaming and billing server-side. Now stop() also fires POST /api/chat/{chatId}/stop (recoupable/api#590) — fire-and-forget so the UI stops instantly — for workflow chats (sessionId present). Legacy chats are unchanged.

Test plan

  • Workflow chat: start a generation, hit stop → stream halts in UI and the run is cancelled server-side (no further token billing).
  • Legacy chat (no sessionId): stop still aborts locally, no backend call made.
  • Unit: lib/chat/__tests__/stopChatWorkflow.test.ts (bearer header, unauth, url-encoding, never-throws).

Depends on recoupable/api#590 being live.


Summary by cubic

Wires the chat Stop button to POST /api/chat/{chatId}/stop for workflow chats and waits for the server to cancel, letting SSE drain so the UI matches the DB and avoids extra billing. Legacy chats still abort locally; the UI shows instant stop feedback, and cancelled/denied/errored tool-calls render as stopped.

  • New Features

    • Added useStopChatWorkflow on @tanstack/react-query; useVercelChat/VercelChatProvider expose isStopping. ChatInput disables re-clicks and forces a spinner (status="submitted") while stopping.
  • Bug Fixes

    • Workflow chats (sessionId) await the stop POST and skip aiStop, letting the server close SSE so the stream drains and UI == DB. Added stopChatWorkflow with unit tests (bearer header, unauth, URL-encode, never-throws). Tool-calls in output-error/output-denied render with a stop icon and “Cancelled/Failed” label instead of a spinner. Depends on recoupable/api#590.

Written for commit 82c88a2. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • More reliable cancellation for workflow-based chats via a best-effort server stop to improve message cancellation.
  • New Features

    • UI shows a stopping state: disables send, prevents duplicate submits, and exits streaming immediately.
    • Chat context now exposes an isStopping flag for components.
    • Tool results that were errored/denied render a labeled “cancelled” pill instead of a running skeleton.
    • Added a client hook to trigger workflow stops and surface isStopping.

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 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chat Ready Ready Preview Jun 8, 2026 3:40pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cdc228ea-34fb-451d-9b9e-55b43a7a777a

📥 Commits

Reviewing files that changed from the base of the PR and between a05055b and 82c88a2.

📒 Files selected for processing (2)
  • hooks/useVercelChat.ts
  • providers/VercelChatProvider.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • providers/VercelChatProvider.tsx
  • hooks/useVercelChat.ts

📝 Walkthrough

Walkthrough

Adds a backend-aware stop flow: new stopChatWorkflow POSTs to /api/chat/{chatId}/stop; useStopChatWorkflow wraps it as a React Query mutation; useVercelChat conditionally calls workflow stop when sessionId exists (falls back to legacy stop) and exposes isStopping; UI/context/tool UI updated.

Changes

Chat Workflow Cancellation

Layer / File(s) Summary
Workflow stop helper
lib/chat/stopChatWorkflow.ts
New stopChatWorkflow function sends a POST to /api/chat/{chatId}/stop with optional Bearer token and suppresses errors to guarantee non-throwing, best-effort cancellation.
React Query stop hook
hooks/useStopChatWorkflow.ts
Adds useStopChatWorkflow(chatId) which obtains an access token via usePrivy(), calls stopChatWorkflow, and exposes { stop: mutateAsync, isStopping: mutation.isPending }.
Hook integration for workflow cancellation
hooks/useVercelChat.ts
Imports useStopChatWorkflow, aliases the built-in useChat stop as aiStop, introduces isStopping, and replaces the exported stop with an async callback that calls stopWorkflow() when sessionId is present, otherwise calls aiStop(). The hook now returns isStopping.
UI and context integration
components/VercelChat/ChatInput.tsx, providers/VercelChatProvider.tsx
ChatInput reads isStopping from context to ignore duplicate send/stop interactions, disables the submit while stopping, and forces status to submitted during stopping. VercelChatProvider adds isStopping to the context value.
Tool UI terminal-state rendering
components/VercelChat/MessageParts.tsx, components/VercelChat/ToolComponents.tsx
Tool-part renderer routes output-error/output-denied to a new getCancelledToolComponent (which uses OctagonX and labels cancelled/error), preventing terminal tool calls from showing the running spinner skeleton.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

  • recoupable/chat#1748: Modifies useVercelChat/provider wiring around sessionId—overlaps with stop routing changes.
  • recoupable/chat#1752: Rewires session-scoped hook/provider behavior relevant to the new isStopping/stop logic.
  • recoupable/chat#1765: Also modifies useVercelChat/provider hooks and may affect stop/context behavior.

Suggested reviewers

  • sweetmantech

Poem

🛑 Click the stop, a quiet plea,
A POST sent off to set it free,
Legacy abort still at the helm,
isStopping guards the streaming realm,
Tools show "Cancelled" — tidy harmony.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Solid & Clean Code ✅ Passed Code follows SOLID principles: single responsibilities, excellent documentation, proper error handling, clean naming, appropriate complexity, and comprehensive tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wire-stop-button-to-backend

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Confidence score: 4/5

  • This PR is likely safe to merge, with mainly maintainability risk rather than a clear functional breakage.
  • The main concern is in hooks/useVercelChat.ts: adding workflow-cancellation logic inline to an already large (~400-line) hook increases complexity and raises regression risk when modifying chat flow behavior later.
  • Given the medium severity/confidence signal (6/10, 7/10), this looks like a design/readability issue that should be refactored soon, but it is not clearly merge-blocking on its own.
  • Pay close attention to hooks/useVercelChat.ts - large, mixed-responsibility logic can make cancellation behavior harder to reason about and safely change.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="hooks/useVercelChat.ts">

<violation number="1" location="hooks/useVercelChat.ts:207">
P2: Custom agent: **Code Structure and Size Limits for Readability and Single Responsibility**

New workflow-cancellation logic added inline to an already 400-line hook instead of being extracted into a focused unit, violating the 100-line limit and single-responsibility rule.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread hooks/useVercelChat.ts
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 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

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) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="hooks/useVercelChat.ts">

<violation number="1" location="hooks/useVercelChat.ts:218">
P2: Awaiting workflow stop without a timeout can freeze the chat input in a long-lived "stopping" state when the stop request hangs.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread hooks/useVercelChat.ts Outdated
setIsStopping(true);
try {
const token = await getAccessToken().catch(() => null);
await stopChatWorkflow(id, token);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Awaiting workflow stop without a timeout can freeze the chat input in a long-lived "stopping" state when the stop request hangs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At hooks/useVercelChat.ts, line 218:

<comment>Awaiting workflow stop without a timeout can freeze the chat input in a long-lived "stopping" state when the stop request hangs.</comment>

<file context>
@@ -204,10 +204,21 @@ export function useVercelChat({
+      setIsStopping(true);
+      try {
+        const token = await getAccessToken().catch(() => null);
+        await stopChatWorkflow(id, token);
+      } finally {
+        setIsStopping(false);
</file context>
Suggested change
await stopChatWorkflow(id, token);
await Promise.race([
stopChatWorkflow(id, token),
new Promise<void>((resolve) => setTimeout(resolve, 5000)),
]);

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) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

Re-trigger cubic

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) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

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) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

…-to-backend

# Conflicts:
#	components/VercelChat/ChatInput.tsx
#	hooks/useVercelChat.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
components/VercelChat/ToolComponents.tsx (1)

441-446: 💤 Low value

getCancelledToolComponent is missing from the ToolComponents aggregate.

MessageParts imports the new renderer directly as a named export, so nothing is broken today. But the ToolComponents object advertises itself as the canonical bundle of these renderers, and now it's inconsistent — getCancelledToolComponent is the odd one out. For DRY/discoverability, either add it to the object or skip the comment if you intend the aggregate to be deprecated in favor of named imports.

♻️ Optional: keep the aggregate complete
 export const ToolComponents = {
   getToolCallComponent,
   getToolResultComponent,
+  getCancelledToolComponent,
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/VercelChat/ToolComponents.tsx` around lines 441 - 446, The
ToolComponents export is missing getCancelledToolComponent, making the aggregate
incomplete; update the export object to include getCancelledToolComponent
alongside getToolCallComponent and getToolResultComponent so the aggregate
remains canonical (referencing getCancelledToolComponent, getToolCallComponent,
getToolResultComponent and the default export ToolComponents) or remove/mark the
aggregate as deprecated if you intend to rely only on named exports.
hooks/useVercelChat.ts (1)

219-225: 💤 Low value

Clean branch — one resilience nit worth a glance.

The sessionId split reads well and keeps each path single-purpose. One thing to keep an eye on: ChatInput invokes stop() fire-and-forget (no await/catch). Today that's safe because stopChatWorkflow swallows its own errors, so neither stopWorkflow() nor this callback rejects. But the safety net lives a couple of layers away — if a future refactor lets either path reject, this becomes an unhandled rejection with no UI recovery. A defensive try/catch here would make the contract self-contained rather than relying on the helper's internals.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hooks/useVercelChat.ts` around lines 219 - 225, The stop callback currently
awaits stopWorkflow() or aiStop() but relies on those helpers to swallow errors;
make stop self-defensive by wrapping the await calls in a try/catch so it never
propagates a rejection to the caller (e.g., ChatInput's fire-and-forget). Locate
the stop function (useCallback) and add a single try/catch around the branch so
both branches call await inside the try and any thrown error is caught and
handled (log via console or existing logger) to prevent unhandled promise
rejections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@components/VercelChat/ToolComponents.tsx`:
- Around line 441-446: The ToolComponents export is missing
getCancelledToolComponent, making the aggregate incomplete; update the export
object to include getCancelledToolComponent alongside getToolCallComponent and
getToolResultComponent so the aggregate remains canonical (referencing
getCancelledToolComponent, getToolCallComponent, getToolResultComponent and the
default export ToolComponents) or remove/mark the aggregate as deprecated if you
intend to rely only on named exports.

In `@hooks/useVercelChat.ts`:
- Around line 219-225: The stop callback currently awaits stopWorkflow() or
aiStop() but relies on those helpers to swallow errors; make stop self-defensive
by wrapping the await calls in a try/catch so it never propagates a rejection to
the caller (e.g., ChatInput's fire-and-forget). Locate the stop function
(useCallback) and add a single try/catch around the branch so both branches call
await inside the try and any thrown error is caught and handled (log via console
or existing logger) to prevent unhandled promise rejections.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5d8a3c3a-7b09-4266-b15c-ab3404e42484

📥 Commits

Reviewing files that changed from the base of the PR and between b99ded4 and a05055b.

📒 Files selected for processing (6)
  • components/VercelChat/ChatInput.tsx
  • components/VercelChat/MessageParts.tsx
  • components/VercelChat/ToolComponents.tsx
  • hooks/useStopChatWorkflow.ts
  • hooks/useVercelChat.ts
  • providers/VercelChatProvider.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • providers/VercelChatProvider.tsx
  • components/VercelChat/ChatInput.tsx

@arpitgupta1214

Copy link
Copy Markdown
Collaborator Author

E2E verified on preview

Chat preview: this PR's head (post test-branch merge)
API override: https://api-git-feat-chat-workflow-stop-recoup.vercel.app (recoupable/api#590)
Scenario: start a chat, prompt the agent to run sleep 30 via bash, click stop mid-tool.

Live UI (no reload)

  • Submit button flips to spinner instantly via isStopping from useStopChatWorkflow's useMutation — no dead time during the ~1s backend round-trip.
  • tool-bash pill transitions in-place from spinning "Using bash" → "⊘ Cancelled bash" once the backend's SSE watcher synthesizes the terminator chunks.
  • Input area returns to ready when SSE closes naturally. No error toast.

Reload (DB-sourced render)

Same state — "⊘ Cancelled bash" pill rendered from the persisted snapshot.

Persisted snapshot (GET /api/sessions/{sid}/chats/{cid})

{
  "isStreaming": false,
  "toolPart": {
    "type": "tool-bash",
    "state": "output-error",
    "errorText": "Cancelled",
    "input": { "command": "sleep 30" }
  }
}

What landed in this PR

  • lib/chat/stopChatWorkflow.ts — fire-and-forget POST /api/chat/{chatId}/stop (4 vitest cases).
  • hooks/useStopChatWorkflow.tsuseMutation wrapper exposing { stop, isStopping }; matches the codebase's existing hook pattern (useDeleteChat, usePulseToggle, etc.).
  • useVercelChat — workflow chats await the hook (no aiStop() — that would tear down the SSE before the watcher's terminator chunks arrive); legacy chats fall back to aiStop().
  • ChatInput — disables submit during isStopping, overrides the icon to submitted (spinner) for instant feedback. Composes cleanly with the merged workspace-status gate.
  • MessageParts + getCancelledToolComponent — explicit branch for output-error / output-denied parts so cancelled tools render with OctagonX icon instead of falling through to the spinning skeleton.

Tsc + lint clean.

@sweetmantech

Copy link
Copy Markdown
Collaborator

isStopping is a symptom of an await-design that diverges from open-agents (and the description misdescribes it)

isStopping itself is a clean React-Query isPending, but it only exists because of a deliberate design choice that's worth reconsidering — and it's absent from open-agents entirely.

The two designs

open-agents (use-session-chat-runtime.ts):

void fetch(`/api/chat/${chatId}/stop`, {...});  // fire-and-forget, NOT awaited
void chatInstance.stop();                        // local abort → stream stops on screen INSTANTLY

Instant UI stop comes from the local stop(); the POST is void. No isStopping needed.

This PR (useVercelChat.ts):

const stop = useCallback(async () => {
  if (sessionId) { await stopWorkflow(); return; }  // awaits backend; never calls aiStop()
  await aiStop();
});

For workflow chats it never locally aborts — it awaits the backend POST and lets the SSE close naturally (~1–2s). Because the stream doesn't visibly stop on click, isStopping (threaded hook → provider → ChatInput) is needed to give button feedback during the wait.

Why this is worth reconsidering

  1. The reload-consistency justification is already handled server-side. The code comment avoids aiStop() to prevent "frontend/DB disagree on reload" — but api#590 already persists the assistant message per-step and finalizeAbortedAssistantMessage closes open tool-calls + re-persists on abort. So the DB is correct on reload regardless of local abort. The design adds complexity to solve a problem the server already solves.
  2. It's less snappy, not more. Since aiStop() is never called for workflow chats, hitting Stop leaves the stream visibly running ~1–2s until the backend cancels; isStopping is a band-aid for that lag. open-agents stops on screen instantly.
  3. The description is inaccurate — it says "fire-and-forget so the UI stops instantly," but the code awaits the POST and the UI does not stop instantly.

Suggested simplification (open-agents-style)

Call aiStop() immediately for all chats + void-fire the /stop POST, and remove the entire isStopping path (the mutation, the provider field, the ChatInput wiring) — ~3 layers simpler, snappier, and reload-correctness is covered by api#590's persistence.

The only thing the await-design buys that the server doesn't is rendering the last few in-flight chunks in the current view before stopping. If that's not a hard requirement, I'd drop it and match open-agents. (Drafting the simplified diff for comparison.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants