Skip to content
KONIGI

AI Assistants / Turn and response / Stop generation

5 of 6

Stop generation

The answer went wrong in its first sentence and the viewer has to sit through the other four hundred words.

Updated September 12, 2026

Problem

The first sentence makes it obvious the model misread the question. The remaining four hundred words are going to be a careful, well-formatted answer to something nobody asked, and they’ll take another twenty seconds to arrive.

Solution

Streaming created this problem. It also has to solve it. Before responses streamed there was no middle of a response to stand in, so there was nothing to interrupt. Now there’s a twenty-second window in which the viewer knows more than the model does.

The dominant convention puts stop in the send button’s position and swaps the two by state. One control slot, two mutually exclusive states, and the pointer is already there because that’s where the viewer just clicked. It costs nothing in layout and preserves the muscle memory.

Stopping has to do four things, and products routinely ship two of them:

  1. Abort the request. The client cancels the stream. Separately, the server has to cancel the generation, or the model keeps producing tokens nobody will read and the viewer keeps paying for them.
  2. Keep the partial output. Discarding it is hostile. The first two paragraphs were often exactly right, and the interruption was about the third. Truncating on stop throws away the part that worked.
  3. Mark it stopped. A partial answer that looks complete is worse than no answer, because a later reader has no way to tell the model stopped mid-sentence rather than concluding there. Label the turn, and keep the label when the conversation is exported or shared.
  4. Say what it cost. If the product meters usage, a stopped response still consumed tokens. Silence here produces support tickets.

This is HAX guideline 8 and guideline 9 in the same control. Efficient dismissal means getting out of an unwanted service quickly. Efficient correction means making it easy to steer the system when it’s wrong. Stop is the moment both become available at once, and the reason it earns such a prominent slot.

The keyboard binding is the part most often missed. Escape is the obvious key, it’s unclaimed in a chat surface, and binding it costs one line.

Use when

Anything that streams for longer than a couple of seconds. If a response can be interrupted usefully, the control has to exist.

Don’t use when

Generation is short enough that the button would appear and vanish before it could be hit. Below roughly a second the swap is visual noise, and a viewer who lands on a flickering control learns to distrust it.

Trade-offs

Swapping send and stop in the same position means a fast typist who hits enter twice stops their own generation on the second press. Guarding with a short disabled window fixes it and introduces a moment where neither action is available. Keeping partial output leaves incomplete text in the transcript that later turns may reference, and the model will read its own truncated sentence as context on the next turn unless the stop is recorded. A stop that only cancels the client stream is the quiet failure here—the interface goes calm, the meter keeps running, and nothing on screen says so.

Checklist

  • Does stop cancel the generation server-side, or only the client’s stream?
  • Does the partial response stay on screen?
  • Is the turn marked as stopped, and does the mark survive export and sharing?
  • Does the model see that the previous turn was interrupted when it reads the history?
  • Is Escape bound to it?
  • Does a double-press of enter stop the generation the first press started?
  • If usage is metered, does the viewer learn what the stopped response cost?
  • Can the viewer edit and resend immediately, or do they have to clear something first?
  • What happens to a tool call or a document already in flight when stop is pressed?
  • Does the control return to send cleanly, with no intermediate state that accepts neither?

Compare

ChatGPT puts stop in the composer where send was and leaves the partial answer in place, so the next move is a follow-up rather than a recovery. Claude uses the same slot and keeps a document already opened alongside the stopped turn, so interrupting the prose does not discard the artifact it was building. Perplexity has less need for the control, because an answer short enough to read in one pass finishes before the intent to stop it forms. GitHub Copilot Chat inherits the editor’s conventions instead of the composer’s, where Escape already means dismiss, and the in-editor suggestion disappears entirely rather than being kept as partial output.

Streaming response is the pattern that makes this control necessary. Regenerate is the other half of the correction: stop kills the answer, regenerate asks for another one. Composer owns the slot the button lives in. Generation error looks almost identical on screen and means something completely different. Usage meter is where the cost of a stopped response has to show up.

Stop generation anatomy A response interrupted part-way. The partial text stays on screen and is labelled as stopped, the send control in the composer has swapped to a stop control while generation runs, and a note records that the stopped response still consumed quota. One control slot, two states Stopped. 2 of about 9 paragraphs. esc tokens billed for the part that was written 1 2 3 4 1 PARTIAL OUTPUT STAYS The first two paragraphs were often fine. Truncating throws away the part that worked. 2 MARKED AS STOPPED A partial answer that looks complete is worse than none. The mark has to travel. 3 THE SEND SLOT Stop takes the position send just had, so the pointer is already there. Escape too. 4 THE SERVER SIDE A stop that only cancels the client stream looks calm while the meter keeps running. The model reads its own truncated sentence next turn unless the interruption is recorded.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

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.

shadcn
npx shadcn@latest add button textarea
npm
lucide-react
Tokens
--background--card--card-foreground--accent--accent-foreground--muted--muted-foreground--border--chart-1

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.

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

Stopped. 2 of about 9 paragraphs.

tokens billed for the part that was written

StopGeneration.tsxSendOrStop is the slot, with Esc wired to it while streaming. StoppedTurn keeps the partial text and marks it.

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 ? (
    <Button variant="outline" size="sm" className="border-chart-1 text-chart-1" onClick={onStop} aria-label="Stop generating (Esc)">
      <Square className="size-3 fill-current" /> esc
    </Button>
  ) : (
    <Button size="sm" onClick={onSend} disabled={!canSend}>
      <ArrowUp className="size-4" /> send
    </Button>
  );
}

/**
 * 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 (
    <div>
      {paragraphs.map((p, i) => (
        <p key={i} className={`text-sm leading-relaxed text-card-foreground ${i ? "mt-3" : ""}`}>{p}</p>
      ))}
      <p className="mt-3 text-xs tabular-nums text-chart-1">Stopped. {paragraphs.length} of about {expected} paragraphs.</p>

      <div className="mt-4 flex items-center gap-3 text-xs text-muted-foreground">
        <span>tokens billed for the part that was written</span>
        <span
          role="meter" aria-valuemin={0} aria-valuemax={tokens.budget} aria-valuenow={tokens.used}
          aria-label="tokens used of this turn's budget"
          className="ml-auto flex h-2 w-44 overflow-hidden rounded-full bg-muted"
        >
          <span className="bg-chart-1" style={{ width: `${(100 * tokens.used) / tokens.budget}%` }} />
        </span>
      </div>
    </div>
  );
}

demo.tsxHow it is called: a turn mid-stream with the slot showing stop. Stop keeps and marks what was written.

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 (
    <div className="rounded-lg border bg-card p-4">
      {stopped ? (
        <StoppedTurn paragraphs={[P1, p2]} expected={9} tokens={{ used: 680, budget: 2000 }} />
      ) : (
        <>
          <p className="text-sm leading-relaxed text-card-foreground">{P1}</p>
          <p className="mt-3 text-sm leading-relaxed text-card-foreground">
            {p2}
            {streaming && <span aria-hidden="true" className="ml-0.5 inline-block h-3.5 w-0.5 animate-pulse bg-foreground align-middle" />}
          </p>
        </>
      )}

      <div className="mt-5 flex items-end gap-2 rounded-lg border bg-background p-2">
        <Textarea
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          rows={1}
          placeholder="Message"
          aria-label="Message"
          className="min-h-0 resize-none border-0 px-2 py-1.5 shadow-none focus-visible:ring-0"
        />
        <SendOrStop streaming={streaming} canSend onSend={send} onStop={stop} />
      </div>
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.