Generation error Broken rather than declined, and the screen looks the same either way. The partial text stays and is labelled, a gap between deltas catches a connection that stopped arriving without closing, and three causes each want a different response. npx shadcn@latest add alert button npm i lucide-react Tokens this needs: --card, --card-foreground, --status-critical The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // GenerationError.tsx ──────────────────────────────────────────────────────────────────────── import { RotateCcw } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; /** * Three causes, three next moves. The set is closed because each one wants a * different control: a transient fault is retried, a spent quota belongs to * the meter, and a content stop is a refusal that retrying cannot undo. A * generic apology fits all three and helps with none. */ export type Cause = "transient" | "quota" | "content-stop"; const COPY: Record = { transient: { message: "Connection lost. Retry from the start?", action: "Retry" }, quota: { message: "This month's allowance is used up. Retrying will not help.", action: "See usage" }, "content-stop": { message: "Stopped by the content filter. Rephrase rather than retry.", action: "Edit prompt" }, }; export function GenerationError({ paragraphs, cause, onRetry, onOpenMeter, onEdit }: { /** What arrived before the stream died. Kept, never wiped: the earlier * paragraphs were probably fine. */ paragraphs: string[]; cause: Cause; /** Regenerates from the start. Resuming mid-stream is rarely possible, and * the copy says so rather than letting paragraph one surprise anyone. */ onRetry: () => void; onOpenMeter?: () => void; onEdit?: () => void; }) { const { message, action } = COPY[cause]; const act = cause === "quota" ? onOpenMeter : cause === "content-stop" ? onEdit : onRetry; return (
{paragraphs.map((p, i) => (

{p}

))} {/* The mark sits inside the text's own container so it travels with a copy and the model sees it in the history on the next turn. */}

Incomplete. The response failed after {paragraphs.length} paragraph{paragraphs.length === 1 ? "" : "s"}.

{message}
); } ──────────────────────────────────────────────────────────────────────── // stallTimeout.ts ──────────────────────────────────────────────────────────────────────── /** * Time the gap, not the call. A stream that stops arriving without closing * never rejects, so a timeout on the whole request never fires either. This * wraps a stream of deltas and throws when one delta is followed by silence. */ export class StallError extends Error { constructor(public gapMs: number) { super(`no delta for ${gapMs}ms`); } } export async function* withStallTimeout(deltas: AsyncIterable, gapMs: number): AsyncGenerator { const it = deltas[Symbol.asyncIterator](); for (;;) { let timer: ReturnType | undefined; const stall = new Promise((_, reject) => { timer = setTimeout(() => reject(new StallError(gapMs)), gapMs); }); const next = await Promise.race([it.next(), stall]).finally(() => clearTimeout(timer)); if (next.done) return; yield next.value; } } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { GenerationError } from "./GenerationError"; /** * Starts in the failed state: one paragraph landed, the second stopped mid * sentence, and the cause was a stalled connection. Retry regenerates from * the start, and the first paragraph comes back worded differently. */ const FIRST = [ "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.", "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. If the two sides cannot agree, the", ]; const SECOND = [ "Five-year term from 1 March 2024 at £42,000 a year. The tenant can break at the end of year two on six months' notice, provided the rent is paid up to date.", "The year-three review is upward-only: the rent can rise to the open market figure but never fall below £42,000. Where the parties disagree, an independent surveyor decides as an expert and the decision binds both.", ]; export default function Demo() { const [failed, setFailed] = useState(true); return (
{failed ? ( setFailed(false)} /> ) : ( SECOND.map((p, i) => (

{p}

)) )}
); }