Skip to content
KONIGI Patterns

AI Assistants / Messages and responses / Stop generation

‹ 5 of 6 ›

Stop generation

People need to stop an unwanted answer before it finishes.

Updated September 12, 2026

Problem

People may notice that an answer is going in the wrong direction before it finishes. They need to stop it without losing useful text already produced.

Solution

Show a clearly labeled Stop control while the assistant is generating an answer. It can occupy the Send button’s position while sending is unavailable. Provide a keyboard shortcut that does not conflict with other active controls.

Cancel the generation on the server as well as stopping its display. Keep the partial answer and mark it as stopped. Preserve that status when the conversation is shared or used in a follow-up.

If usage is metered, show the amount consumed before cancellation. Prevent repeated send inputs from accidentally activating Stop.

Use when

An answer takes long enough that people may want to interrupt it.

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

Sharing space with Send saves room but can cause accidental stops if input handling is careless. Partial answers need clear labels. Stopping the display alone can leave generation running and consuming usage.

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 makes an answer visible before it finishes. Regenerate requests another attempt. Composer holds the send and stop controls.

Generation error indicates an unplanned interruption. Usage meter records the usage consumed before stopping.

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 screenshots yet for this pattern. Browse Products to see other examples.