AI Patterns

errors

Generation Error

A console-style error card for a failed generation, with details and retry.

"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { ChevronDown, CircleAlert, RotateCcw } from "lucide-react";

import { cn } from "@/lib/utils";

export type GenerationErrorReason = "network" | "server" | "timeout" | "filtered";

export interface GenerationErrorProps {
  reason?: GenerationErrorReason;
  message?: string;
  errorCode?: string;
  onRetry?: () => void;
  retrying?: boolean;
  className?: string;
}

const reasonConfig: Record<GenerationErrorReason, { title: string; hint: string; retryable: boolean }> = {
  network: {
    title: "Connection lost",
    hint: "Check your connection and try again.",
    retryable: true,
  },
  server: {
    title: "Something went wrong",
    hint: "The response couldn't be generated.",
    retryable: true,
  },
  timeout: {
    title: "Request timed out",
    hint: "The model took too long to respond.",
    retryable: true,
  },
  filtered: {
    title: "Response blocked",
    hint: "This request was blocked by content safety filters.",
    retryable: false,
  },
};

export function GenerationError({
  reason = "server",
  message,
  errorCode,
  onRetry,
  retrying = false,
  className,
}: GenerationErrorProps) {
  const [detailsOpen, setDetailsOpen] = React.useState(false);
  const config = reasonConfig[reason];

  return (
    <div
      role="alert"
      className={cn("flex flex-col gap-0 rounded-2xl border border-border bg-muted/30 overflow-hidden", className)}
    >
      <div className="flex items-start gap-3 px-4 py-3">
        <CircleAlert className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />

        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <p className="text-xs font-medium text-foreground">{config.title}</p>
          <p className="text-[11px] text-muted-foreground">{message ?? config.hint}</p>
        </div>
      </div>

      <div className="flex items-center justify-between border-t border-border/60 bg-muted/40 px-4 py-2.5">
        {errorCode ? (
          <button
            type="button"
            onClick={() => setDetailsOpen((v) => !v)}
            aria-expanded={detailsOpen}
            className="flex items-center gap-1 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
          >
            <ChevronDown
              className={cn("size-3 transition-transform", detailsOpen && "rotate-180")}
              aria-hidden
            />
            Show details
          </button>
        ) : (
          <span />
        )}

        {config.retryable && onRetry && (
          <button
            type="button"
            onClick={onRetry}
            disabled={retrying}
            aria-label={retrying ? "Retrying" : "Retry"}
            className="flex items-center gap-1 rounded-full bg-foreground px-2.5 py-1 text-[11px] font-medium text-background transition-opacity hover:opacity-80 disabled:opacity-50"
          >
            <RotateCcw className={cn("size-3", retrying && "animate-spin")} aria-hidden />
            {retrying ? "Retrying…" : "Retry"}
          </button>
        )}
      </div>

      <AnimatePresence initial={false}>
        {detailsOpen && errorCode && (
          <motion.div
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.18, ease: "easeOut" }}
            className="overflow-hidden border-t border-border/60"
          >
            <pre className="whitespace-pre-wrap break-all px-4 py-2.5 font-mono text-[11px] text-muted-foreground">
              {errorCode}
            </pre>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Generation Error

## Summary
A message-thread card for a generation that produced no output at all — a network drop, a server failure, a timeout, or a content-safety block. Shows a short human-readable reason, an optional collapsible technical detail (a console-style error line), and a Retry action where retrying makes sense.

## When to use
- The request never produced any content — nothing to preserve, unlike a cut-short stream.
- In the message thread at the position of the failed assistant turn, replacing the loader that was there.
- Whenever a technical error code, request ID, or stack-like detail exists and might help a user reporting the issue — surface it behind "Show details" rather than inline.

## When not to use
- Some content was generated before the failure — use the Partial Response pattern instead so the user doesn't lose it.
- Quota/usage limits — use the Rate Limit pattern; those are expected, recurring states, not failures.
- Validation errors on the user's own input (empty prompt, unsupported file type) — handle inline in the composer, not as a thread message.

## Reasons
Four distinct reasons change the copy and whether Retry is offered:

- **network**: Connection dropped mid-request. Title "Connection lost". Retryable.
- **server**: Upstream failure. Title "Something went wrong". Retryable.
- **timeout**: The model didn't respond in time. Title "Request timed out". Retryable.
- **filtered**: Blocked by content-safety policy. Title "Response blocked". Not retryable — retrying the same prompt will fail the same way; the user needs to change the request instead.

## Anatomy
- Icon: a muted alert glyph, not an alarming red — consistent with this library's other error/limit states.
- Title (bold) + one-line hint, muted.
- Footer row: "Show details" toggle on the left (only rendered when `errorCode` is passed), Retry button on the right (only when the reason is retryable and `onRetry` is passed).
- Details panel: a monospace, console-style line with the technical error code / request ID, expandable under the footer.

## Behavior
- Details panel animates open/closed by height, collapsed by default.
- Retry enters a disabled "Retrying…" state immediately on tap; the spinner icon rotates. The caller owns the actual re-generation call and clears `retrying` when it resolves (success replaces this component; failure re-renders it).
- Non-retryable reasons (`filtered`) never show a Retry button even if `onRetry` is passed — the caller should instead let the user edit and resend their prompt.
- `message` overrides the default hint text per reason, for surfacing a more specific server-provided message without changing the title or retryability.

## Content guidelines
- Titles are short and human: "Connection lost", "Something went wrong", "Request timed out", "Response blocked" — never a raw HTTP status or exception class name in the title.
- The hint is one sentence, plain language, no blame ("Check your connection and try again," not "Your connection failed").
- Technical detail (`errorCode`) is the only place raw identifiers belong — request IDs, error codes, stack fragments — and it's opt-in behind "Show details", never shown by default.
- Retry label is always "Retry", not "Try again" or "Regenerate" — keep it distinct from the Partial Response pattern's "Continue"/"Retry" pair.

## Accessibility
- The card has `role="alert"` so screen readers announce the failure as it mounts.
- "Show details" uses `aria-expanded` reflecting panel state.
- The Retry button's `aria-label` switches to "Retrying" while in flight, since the visible label ("Retrying…") pairs with a spinning icon that conveys nothing to assistive tech on its own.
- The alert icon is `aria-hidden` — the card's text content, not the icon, carries the meaning.

## Related patterns
- Partial Response — for streams that produced some content before stopping.
- Rate Limit — for quota-exceeded states, which are expected and recurring rather than failures.