Stop generation One control slot holding two states. While generation runs the send button is a stop button, the partial text stays on screen labelled as stopped, and the tokens already written are still billed. npx shadcn@latest add button textarea npm i lucide-react Tokens this needs: --background, --card, --card-foreground, --accent, --accent-foreground, --muted, --muted-foreground, --border, --chart-1 ──────────────────────────────────────────────────────────────────────── // StopGeneration.tsx ──────────────────────────────────────────────────────────────────────── import { useEffect } from "react"; import { ArrowUp, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; /** * One slot, two states. While a response streams the send button is the * stop button, because that is where the pointer already is. Esc stops too, * for the hand that is still on the keyboard. */ export function SendOrStop({ streaming, canSend, onSend, onStop }: { streaming: boolean; canSend: boolean; onSend: () => void; onStop: () => void; }) { useEffect(() => { if (!streaming) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onStop(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [streaming, onStop]); return streaming ? ( ) : ( ); } /** * The partial output stays. The first two paragraphs were often fine, and * truncating throws away the part that worked. It stays marked, because a * partial answer that looks complete is worse than none, and the mark is in * the text's own container so it travels with a copy. */ export function StoppedTurn({ paragraphs, expected, tokens }: { /** What was written before the stop. */ paragraphs: string[]; /** The model's own estimate of length, so the mark can say how far it got. */ expected: number; /** Already billed. Stopping does not refund the part that was written. */ tokens: { used: number; budget: number }; }) { return (
{paragraphs.map((p, i) => (

{p}

))}

Stopped. {paragraphs.length} of about {expected} paragraphs.

tokens billed for the part that was written
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useEffect, useState } from "react"; import { Textarea } from "@/components/ui/textarea"; import { SendOrStop, StoppedTurn } from "./StopGeneration"; /** * Starts just after a stop: two paragraphs kept, the second cut mid-sentence * and marked. Send starts the answer over, and while it streams the slot is * stop; stop (or Esc) brings this state back. */ const P1 = "The lease runs five years from 1 March 2024 at £42,000 a year. There is a tenant-only break at the end of year two on six months' written notice, conditional on the rent being paid up to date and vacant possession being given on the break date."; const P2 = "The rent review at year three is upward-only, which means the rent can rise to the open market figure but never fall below £42,000. The mechanism if the two sides cannot agree is an independent surveyor acting as an expert, whose decision binds both."; const P2_HEAD = "The rent review at year three is upward-only, which means the rent can rise to the open market figure but never fall below £42,000. The mechanism if the two sides cannot agree is"; export default function Demo() { const [p2, setP2] = useState(P2_HEAD); const [streaming, setStreaming] = useState(false); const [stopped, setStopped] = useState(true); const [draft, setDraft] = useState(""); useEffect(() => { if (!streaming) return; const rest = P2.slice(p2.length).split(/(?<=\s)/); let i = 0; const id = setInterval(() => { if (i >= rest.length) { setStreaming(false); clearInterval(id); return; } setP2((t) => t + rest[i++]); }, 300); return () => clearInterval(id); }, [streaming]); const stop = () => { setStreaming(false); setStopped(true); }; const send = () => { setP2(P2_HEAD); setStopped(false); setStreaming(true); setDraft(""); }; return (
{stopped ? ( ) : ( <>

{P1}

{p2} {streaming &&

)}