AI Patterns

compare

Response Compare

A side-by-side pair of response panels with independent regenerate and a single preferred pick.

How does photosynthesis work?

"use client";

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

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

export interface CompareResponse {
  id: string;
  label: string;
  content: string;
}

export interface ResponseCompareProps {
  prompt?: string;
  responses: [CompareResponse, CompareResponse];
  onPick?: (id: string) => void;
  onRegenerate?: (id: string) => void;
  className?: string;
}

export function ResponseCompare({ prompt, responses, onPick, onRegenerate, className }: ResponseCompareProps) {
  const [pickedId, setPickedId] = React.useState<string | null>(null);

  function handlePick(id: string) {
    setPickedId(id);
    onPick?.(id);
  }

  function handleRegenerate(id: string) {
    setPickedId((current) => (current === id ? null : current));
    onRegenerate?.(id);
  }

  return (
    <div className={cn("w-full", className)}>
      {prompt && <p className="mb-3 text-sm text-muted-foreground">{prompt}</p>}
      <div role="radiogroup" aria-label="Preferred response" className="grid grid-cols-1 gap-3 md:grid-cols-2">
        {responses.map((response) => (
          <ResponsePanel
            key={response.id}
            response={response}
            picked={pickedId === response.id}
            dimmed={pickedId !== null && pickedId !== response.id}
            onPick={() => handlePick(response.id)}
            onRegenerate={() => handleRegenerate(response.id)}
          />
        ))}
      </div>
    </div>
  );
}

function ResponsePanel({
  response,
  picked,
  dimmed,
  onPick,
  onRegenerate,
}: {
  response: CompareResponse;
  picked: boolean;
  dimmed: boolean;
  onPick: () => void;
  onRegenerate: () => void;
}) {
  const [copied, setCopied] = React.useState(false);

  async function handleCopy() {
    await navigator.clipboard.writeText(response.content);
    setCopied(true);
    window.setTimeout(() => setCopied(false), 1500);
  }

  return (
    <div
      role="radio"
      aria-checked={picked}
      aria-label={response.label}
      tabIndex={0}
      onClick={onPick}
      onKeyDown={(e) => {
        if (e.key === "Enter" || e.key === " ") {
          e.preventDefault();
          onPick();
        }
      }}
      className={cn(
        "flex h-80 cursor-pointer flex-col overflow-hidden rounded-2xl border bg-card transition-opacity outline-none",
        picked ? "border-primary ring-1 ring-primary" : "border-border hover:border-foreground/20",
        dimmed && "opacity-60",
        "focus-visible:ring-2 focus-visible:ring-ring"
      )}
    >
      <div className="flex items-center justify-between gap-2 border-b px-4 py-2.5">
        <span className="text-xs font-medium">{response.label}</span>
        {picked && (
          <motion.span
            initial={{ opacity: 0, scale: 0.9 }}
            animate={{ opacity: 1, scale: 1 }}
            className="flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
          >
            <Sparkles className="size-3" />
            Preferred
          </motion.span>
        )}
      </div>

      <div className="flex-1 overflow-y-auto px-4 py-3">
        <p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">{response.content}</p>
      </div>

      <div className="flex items-center justify-between gap-2 border-t px-3 py-2">
        <div className="flex items-center gap-0.5 text-muted-foreground">
          <IconButton
            label="Copy"
            onClick={(e) => {
              e.stopPropagation();
              handleCopy();
            }}
          >
            {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
          </IconButton>
          <IconButton
            label={`Regenerate ${response.label}`}
            onClick={(e) => {
              e.stopPropagation();
              onRegenerate();
            }}
          >
            <RotateCcw className="size-3.5" />
          </IconButton>
        </div>
        <button
          type="button"
          onClick={(e) => {
            e.stopPropagation();
            onPick();
          }}
          className={cn(
            "rounded-full border px-3 py-1 text-xs font-medium transition-colors",
            picked ? "border-primary bg-primary text-primary-foreground" : "hover:bg-accent"
          )}
        >
          {picked ? "Chosen" : "Choose this"}
        </button>
      </div>
    </div>
  );
}

function IconButton({
  label,
  onClick,
  children,
}: {
  label: string;
  onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-label={label}
      className="rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground"
    >
      {children}
    </button>
  );
}

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

# Response Compare

## Summary
A side-by-side pair of full response panels answering the same prompt, letting the person read both in full and pick the one they prefer. It turns "regenerate and hope" into a direct A/B choice, and is the natural expansion of thumbs up/down when there are two concrete candidates to weigh instead of one.

## When to use
- Comparing two model outputs (different models, prompts, temperatures, or system prompts) for the same input, where quality is genuinely close and worth a human call.
- Eval/preference-collection tooling that needs an explicit, recorded choice between two candidates rather than a vague quality signal.
- Regeneration flows where showing the new answer next to the old one (instead of replacing it) helps the person decide which to keep.

## When not to use
- When one answer is obviously wrong (factual error, refusal, empty output) — surface an error state or a single corrected answer instead of asking someone to compare against garbage.
- More than two candidates at once. Side-by-side reading breaks down past two full-length responses; use a ranked list or a carousel instead.
- Low-stakes, low-effort answers (a one-line lookup, a short factual reply) where the ceremony of comparing outweighs the value of choosing.

## Anatomy
- Optional shared prompt header showing the question both responses are answering.
- Two response panels in a row (stacking vertically on narrow viewports): a label (model/version name or "Response A"/"Response B"), the response body, and a footer action row (Copy, Regenerate, "Choose this").
- A selection state: the chosen panel gets a visibly distinct border/highlight and a "Preferred" badge; the other panel recedes (reduced emphasis) without disappearing.

## Behavior
- The whole panel is a single click/tap target for selecting it, not just the "Choose this" button — clicking anywhere on a panel's card (its header, body text, or empty space) picks it, the same as clicking the explicit button.
- Copy and Regenerate stay independent actions that don't select the panel — clicking either one acts on that panel's content without also making it the preferred choice, so a person can copy or regenerate one side while still undecided.
- Selecting a panel is a single choice between the two — choosing one always deselects the other; it is not two independent toggles.
- The choice is changeable: picking the other panel (by clicking it anywhere, or its "Choose this" button) after the fact swaps the preferred state, it doesn't require undoing the first choice explicitly.
- Regenerating one panel only replaces that panel's content and clears any existing selection — it never touches the other panel's content.
- Both panels render at equal height with independently scrolling content, so a long response on one side doesn't push the other side's footer out of alignment or off-screen.
- Copy acts on that panel's response text only.

## Content guidelines
- Label panels neutrally ("Response A" / "Response B", or the model name) — avoid labels that imply a quality judgment before the person has read either one.
- Keep the "Choose this" action's label consistent across both panels; don't rephrase it based on which one you'd expect to win.

## Accessibility
- Group the two panels with `role="radiogroup"` and expose each panel itself as `role="radio"` with `aria-checked` and a focusable `tabindex`, since the whole card — not just its "Choose this" button — is the selectable unit and exactly one of two options can be selected.
- Each panel needs a visible hover/focus affordance (e.g. a border change, a focus ring) so the card reads as clickable, not just as a static container with buttons inside it.
- The nested Copy and Regenerate buttons must stop click/keyboard events from also triggering the panel's own selection, so using them never has the side effect of picking that panel.
- Selecting via keyboard must work on the panel itself (Enter or Space while it's focused), not only by tabbing all the way to the "Choose this" button.
- Each panel's response text must be reachable by screen reader in a sensible order — visual left/right placement shouldn't be the only thing separating them; label each region (e.g. `aria-label="Response A"`) so assistive tech announces which one is being read.
- The preferred badge's meaning must not rely on color/border alone — include visible text ("Preferred") or an icon with an accessible name.
- All actions (Copy, Regenerate, Choose this, and selecting the panel itself) must be reachable and operable by keyboard.

## Related patterns
- Chat Bubble with Actions — the single-response equivalent (thumbs up/down, Regenerate) for when there's one reply to judge rather than two to choose between.
- Diff Summary Card — a different kind of side-by-side comparison (before/after a batch of file edits) rather than two independent generations.