feat(chat): Stop button to cancel in-flight AI turns

Standard chat-app pattern: while the AI is streaming or running
tools, the Send button morphs into a Stop control (filled square
inside a faded spinner). Click it (or press Esc) to abort the turn.

Why: with MAX_TOOL_ROUNDS=18, a confused tool-loop can chew through
60-90s of compute and tokens. The user had no way to interrupt — they
just watched ✓ icons accumulate. Stop fixes that.

How:

Client (chat-panel.tsx):
- abortRef holds the in-flight AbortController; lives in a ref so the
  Stop button can reach it without re-rendering on every chunk.
- sendMessage creates a fresh controller and passes signal to fetch.
- cancelMessage calls .abort(); also bound to Escape while sending.
- Button morph: while `sending`, render lucide Square overlaid on a
  faded Loader2 spin, switch onClick to cancelMessage, swap aria/title
  to "Stop generating (Esc)".
- Catch DOMException AbortError separately from network errors and
  append "(stopped by user)" to the partial assistant message.
- Textarea no longer disabled during streaming so users can queue
  the next prompt; Enter still won't submit until the turn ends.

Server (app/api/chat/route.ts):
- request.signal is captured before the ReadableStream and an `aborted`
  flag is flipped on the addEventListener('abort', ...) callback.
- Loop checkpoints `aborted` (a) at the top of every round, (b) before
  the inner tool-call loop, (c) before each individual executeMcpTool
  call. Picks the next safe boundary instead of yanking mid-call.
- On abort: emit a "(stopped by user)" text chunk + an "aborted" event,
  skip the round-cap recovery summary (don't pay for tokens the user
  just canceled), persist the partial assistant message normally.
- Fetch errors that come from the abort propagating into Gemini's HTTP
  client are recognized and downgraded from "error" to "aborted".
- safeClose() guards against double controller.close() when the abort
  races with normal completion.

Made-with: Cursor
This commit is contained in:
2026-04-28 14:56:35 -07:00
parent a897d07179
commit 4f84a19e75
2 changed files with 152 additions and 31 deletions

View File

@@ -235,23 +235,51 @@ export async function POST(request: Request) {
const proto = host.startsWith('localhost') ? 'http' : 'https';
const baseUrl = `${proto}://${host}`;
// Honor client-side abort (Stop button). When the user clicks Stop
// the browser's AbortController fires `request.signal.aborted` and
// the fetch stream is closed; we use it as a polite checkpoint
// between rounds and tool calls so we (a) don't keep paying Gemini
// for tokens the user no longer wants and (b) persist whatever the
// assistant produced before the cancel.
const clientSignal = request.signal;
// Stream response
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
let streamClosed = false;
function emit(chunk: object) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
if (streamClosed) return;
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
} catch {
// controller may have been closed by the abort handler
streamClosed = true;
}
}
function safeClose() {
if (streamClosed) return;
streamClosed = true;
try {
controller.close();
} catch {}
}
let messages = [...history];
let round = 0;
let assistantText = '';
const assistantToolCalls: ToolCall[] = [];
let aborted = clientSignal.aborted;
const onAbort = () => {
aborted = true;
};
clientSignal.addEventListener('abort', onAbort);
try {
// Tool-calling loop: use non-streaming so thought_signature is
// always present in the complete response (required by thinking models).
while (round < MAX_TOOL_ROUNDS) {
if (aborted) break;
round++;
const toolDefs = mcp_token ? VIBN_TOOL_DEFINITIONS : [];
@@ -283,9 +311,11 @@ export async function POST(request: Request) {
});
if (!resp.toolCalls.length) break;
if (aborted) break;
// Execute tool calls and add results
for (const tc of resp.toolCalls) {
if (aborted) break;
const result = mcp_token
? await executeMcpTool(tc.name, tc.args, mcp_token, baseUrl)
: JSON.stringify({ error: 'No MCP token — read-only mode.' });
@@ -302,6 +332,19 @@ export async function POST(request: Request) {
}
}
// If the user clicked Stop, surface the cancel marker so the
// client renders "(stopped by user)" inline with the partial
// assistant message, then skip the round-cap recovery summary
// (we shouldn't pay Gemini for a turn the user just canceled).
if (aborted) {
const stopMarker = assistantText
? '\n\n_(stopped by user)_'
: '_(stopped by user before any response)_';
assistantText += stopMarker;
emit({ type: 'text', text: stopMarker });
emit({ type: 'aborted' });
}
// If the loop exited because we hit MAX_TOOL_ROUNDS while the
// model still wanted to call tools, the user has only seen a
// tray of ✓ icons with no narrative. Force one final no-tools
@@ -309,7 +352,7 @@ export async function POST(request: Request) {
const lastTurnHadTools =
messages.length > 0 &&
messages[messages.length - 1].role === 'tool';
if (round >= MAX_TOOL_ROUNDS && lastTurnHadTools) {
if (!aborted && round >= MAX_TOOL_ROUNDS && lastTurnHadTools) {
try {
const summary = await callGeminiChat({
systemPrompt:
@@ -340,12 +383,28 @@ export async function POST(request: Request) {
);
emit({ type: 'done' });
controller.close();
safeClose();
} catch (e) {
emit({ type: 'error', error: e instanceof Error ? e.message : String(e) });
controller.close();
// AbortError is the expected shape when the client cancels
// mid-Gemini-call — don't surface it as a real error.
const isAbort =
aborted ||
(e instanceof Error && (e.name === 'AbortError' || /aborted/i.test(e.message)));
if (!isAbort) {
emit({ type: 'error', error: e instanceof Error ? e.message : String(e) });
} else {
emit({ type: 'aborted' });
}
safeClose();
} finally {
clientSignal.removeEventListener('abort', onAbort);
}
},
cancel() {
// Browser disconnected (tab closed, navigated away). Nothing to
// do — the abort handler above already flipped the flag and the
// loop will bail at the next checkpoint.
},
});
return new Response(stream, {