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