# Partial Response

> A cut-short assistant reply with Continue and Retry actions.

- Category: errors
- Job: Recover — Explain failures and limits, keep partial work, and offer a way forward.
- Slug: `partial-response`
- HTML: https://ai-patterns.app/patterns/errors/partial-response
- JSON: https://ai-patterns.app/api/patterns/errors/partial-response

## Summary
A message component for responses that ended before completion. Displays whatever text was generated, clearly marks it as incomplete, and offers Continue (resume from the cut-off point) and Retry (regenerate from scratch) actions.

## When to use
- The stream ended before a natural conclusion — stopped by the user, cut off by an output token limit, or dropped by a transient error — and some content was produced.
- Any time preserving and resuming the partial output is preferable to discarding it. A partial response with continuation is nearly always more useful than a blank error card.
- In the message thread at the position of the incomplete assistant turn, not as a modal or a separate panel.

## When not to use
- Zero content produced — if the response never started, use the Generation Error pattern instead.
- The content is corrupted or nonsensical (e.g. a mid-token cut-off in a code block) — in this case retry is the better default action; hide Continue.
- Read-only transcript views where resuming is not an option.

## Reasons
Three distinct reasons change the copy and available actions:

- **interrupted**: User clicked Stop. Label "Stopped". Both Continue and Retry are available.
- **max-tokens**: Hit the model's output token ceiling. Label "Cut off". Both Continue (model continues from the cut-off) and Retry are available.
- **error**: Transient error mid-stream. Label "Incomplete". Only Retry is available — continuing from an error state may reproduce the error.

## Anatomy
- Content area: the partial text, rendered as-is, with a blinking cursor appended to signal incompleteness.
- Status bar: a hairline-bordered footer row.
  - Left: reason label (bold) + separator + one-line hint in muted text.
  - Right: Retry button (ghost, muted) and Continue button (filled, primary). Continue is the primary action.
- Blinking cursor: a narrow vertical bar animating opacity 1→0 on a 0.8s loop. Hidden with `aria-hidden`.

## Behavior
- The cursor blinks continuously at rest to keep the "mid-stream" sense alive even after the stream stopped.
- Tapping Continue puts the button into "Continuing…" disabled state immediately; the caller owns the actual resumption request and passes `resuming={true}` back.
- Resumption semantics (sending the prior context + the partial response as the new prompt) are the caller's responsibility. This component signals intent; it does not issue API calls.
- Tapping Retry discards the partial content and issues a fresh generation from the original prompt. The caller handles this by removing the partial message and resubmitting.
- Only one action may be in-flight at a time (Continue disables during `resuming`). Retry does not need a loading state if the caller immediately replaces this component with a new streaming response.

## Content guidelines
- Reason labels: "Stopped", "Cut off", "Incomplete" — short, factual, no punctuation.
- Hint text: one sentence, no more. "Response was stopped before it finished." / "Response reached the output limit." / "Response stopped due to an error."
- Continue label: "Continue" with a › chevron — implies the stream will pick up from here, not start over.
- Retry label: "Retry" — unambiguous, separate from Continue.
- Never expose the token count or technical limit to the user ("stopped at 4096 tokens").

## Accessibility
- The blinking cursor has `aria-hidden` — it is a decorative animation.
- Continue button `aria-label` changes to "Resuming response" when `resuming` is true, giving screen-reader users feedback without a visible spinner.
- The status bar text is available to assistive technology as static text; no additional live region is needed since the component appears fully rendered.
- Keyboard: both actions are reachable via Tab; Continue is the last focused element in the component (rightmost in the footer), matching its primary-action status.

## Related patterns
- **Generation Error** — Use it instead when the request produced no output at all.
- **Rate Limit** — Use it instead when the person has to wait for a quota to reset before retrying.
- **Stop Generation Button** — Use it as well, since stopping is what produces the interrupted reason here.
- **Streaming Text** — Use it before this one, while the answer is still streaming in.

## Implementation

Reference implementation (React, TypeScript, Tailwind v4, Motion, Radix). Keep every rule from the spec above; if the host project has its own design system, re-express the markup and motion in it rather than copying these classes.

npm dependencies: `lucide-react`, `motion`

Local imports to provide: `@/lib/utils` (`cn` is the usual `clsx` + `tailwind-merge` helper).

```tsx title="component.tsx"
"use client";

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

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

export type PartialResponseReason = "interrupted" | "max-tokens" | "error";

export interface PartialResponseProps {
  content: string;
  reason?: PartialResponseReason;
  onResume?: () => void;
  onRetry?: () => void;
  resuming?: boolean;
  className?: string;
}

const reasonConfig: Record<PartialResponseReason, { label: string; hint: string; showResume: boolean }> = {
  interrupted: {
    label: "Stopped",
    hint: "Response was stopped before it finished.",
    showResume: true,
  },
  "max-tokens": {
    label: "Cut off",
    hint: "Response reached the output limit.",
    showResume: true,
  },
  error: {
    label: "Incomplete",
    hint: "Response stopped due to an error.",
    showResume: false,
  },
};

export function PartialResponse({
  content,
  reason = "interrupted",
  onResume,
  onRetry,
  resuming = false,
  className,
}: PartialResponseProps) {
  const config = reasonConfig[reason];

  return (
    <div className={cn("flex flex-col gap-0 rounded-2xl border border-border bg-muted/30 overflow-hidden", className)}>
      <div className="px-4 py-3 text-sm leading-relaxed text-foreground">
        {content}
        <motion.span
          className="ml-0.5 inline-block h-[1em] w-0.5 translate-y-[1px] rounded-sm bg-foreground/40"
          animate={{ opacity: [1, 0] }}
          transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
          aria-hidden
        />
      </div>

      <div className="flex items-center justify-between border-t border-border/60 bg-muted/40 px-4 py-2.5">
        <p className="text-xs text-muted-foreground">
          <span className="font-medium text-foreground">{config.label}</span>
          {" · "}
          {config.hint}
        </p>

        <div className="flex items-center gap-1.5">
          {onRetry && (
            <button
              type="button"
              onClick={onRetry}
              aria-label="Retry from scratch"
              className="flex items-center gap-1 rounded-full px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
            >
              <RotateCcw className="size-3" aria-hidden />
              Retry
            </button>
          )}
          {config.showResume && onResume && (
            <button
              type="button"
              onClick={onResume}
              disabled={resuming}
              aria-label={resuming ? "Resuming response" : "Continue response"}
              className="flex items-center gap-1 rounded-full bg-foreground px-2.5 py-1 text-xs font-medium text-background transition-opacity hover:opacity-80 disabled:opacity-50"
            >
              {resuming ? (
                "Continuing…"
              ) : (
                <>
                  Continue
                  <ChevronRight className="size-3" aria-hidden />
                </>
              )}
            </button>
          )}
        </div>
      </div>
    </div>
  );
}
```
