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:
@@ -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, {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Wrench,
|
||||
ChevronDown,
|
||||
Trash2,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
@@ -156,6 +157,10 @@ export function ChatPanel() {
|
||||
const [mcpToken, setMcpToken] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
// AbortController for the in-flight /api/chat fetch. Lives in a ref
|
||||
// so the Stop button can reach it without re-rendering on every
|
||||
// streaming chunk.
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -287,11 +292,15 @@ export function ChatPanel() {
|
||||
const assistantMsg: Message = { role: "assistant", content: "" };
|
||||
let msgIndex = -1;
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ thread_id: activeThread, message: text, workspace, mcp_token: mcpToken }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) throw new Error("Stream failed");
|
||||
@@ -363,22 +372,50 @@ export function ChatPanel() {
|
||||
|
||||
loadThreads();
|
||||
} catch (e) {
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
if (msgIndex >= 0 && next[msgIndex]) {
|
||||
next[msgIndex] = { ...next[msgIndex], content: "⚠️ Failed to get response. Please try again." };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const isAbort =
|
||||
e instanceof DOMException && e.name === "AbortError";
|
||||
if (isAbort) {
|
||||
// Server-side will have appended "(stopped by user)" to the
|
||||
// partial response and persisted it. We just need to make
|
||||
// sure the local UI reflects whatever streamed in before the
|
||||
// user clicked Stop — which it already does, because we've
|
||||
// been mutating `messages[msgIndex]` chunk-by-chunk above.
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
if (msgIndex >= 0 && next[msgIndex] && !next[msgIndex].content.includes("(stopped by user)")) {
|
||||
next[msgIndex] = {
|
||||
...next[msgIndex],
|
||||
content: (next[msgIndex].content || "") + "\n\n_(stopped by user)_",
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
if (msgIndex >= 0 && next[msgIndex]) {
|
||||
next[msgIndex] = { ...next[msgIndex], content: "⚠️ Failed to get response. Please try again." };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setSending(false);
|
||||
}
|
||||
}, [input, sending, activeThread, workspace, mcpToken, threads, loadThreads]);
|
||||
|
||||
const cancelMessage = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
} else if (e.key === "Escape" && sending) {
|
||||
e.preventDefault();
|
||||
cancelMessage();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -598,9 +635,9 @@ export function ChatPanel() {
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask Vibn AI anything…"
|
||||
placeholder={sending ? "Esc to stop generating…" : "Ask Vibn AI anything…"}
|
||||
rows={1}
|
||||
disabled={sending || !activeThread}
|
||||
disabled={!activeThread}
|
||||
style={{
|
||||
flex: 1, border: "none", outline: "none", background: "transparent",
|
||||
fontSize: "0.84rem", lineHeight: 1.5, resize: "none",
|
||||
@@ -613,23 +650,48 @@ export function ChatPanel() {
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + "px";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={sending || !input.trim() || !activeThread}
|
||||
style={{
|
||||
background: input.trim() && !sending ? "#1a1a1a" : "#e8e4dc",
|
||||
color: input.trim() && !sending ? "#fff" : "#a09a90",
|
||||
border: "none", borderRadius: 7, padding: "6px 8px",
|
||||
cursor: input.trim() && !sending ? "pointer" : "default",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
transition: "all 0.15s", flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{sending
|
||||
? <Loader2 style={{ width: 15, height: 15 }} className="animate-spin" />
|
||||
: <Send style={{ width: 15, height: 15 }} />
|
||||
}
|
||||
</button>
|
||||
{(() => {
|
||||
// While the AI is streaming or running tools, the button
|
||||
// turns into a Stop control. Click → AbortController fires,
|
||||
// server bails between rounds, partial text gets persisted.
|
||||
const isActive = sending;
|
||||
const canSend = !sending && input.trim() && activeThread;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={isActive ? cancelMessage : sendMessage}
|
||||
disabled={!isActive && !canSend}
|
||||
aria-label={isActive ? "Stop generating" : "Send message"}
|
||||
title={isActive ? "Stop generating (Esc)" : "Send"}
|
||||
style={{
|
||||
background: isActive ? "#1a1a1a" : canSend ? "#1a1a1a" : "#e8e4dc",
|
||||
color: isActive || canSend ? "#fff" : "#a09a90",
|
||||
border: "none", borderRadius: 7, padding: "6px 8px",
|
||||
cursor: isActive || canSend ? "pointer" : "default",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
transition: "all 0.15s", flexShrink: 0,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{isActive ? (
|
||||
<>
|
||||
<Loader2
|
||||
style={{
|
||||
width: 15, height: 15, position: "absolute",
|
||||
opacity: 0.35,
|
||||
}}
|
||||
className="animate-spin"
|
||||
/>
|
||||
<Square
|
||||
style={{ width: 9, height: 9, fill: "#fff", strokeWidth: 0 }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Send style={{ width: 15, height: 15 }} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.65rem", color: "#c0bab2", textAlign: "center", marginTop: 6 }}>
|
||||
Powered by Gemini 3.1 Pro
|
||||
|
||||
Reference in New Issue
Block a user