AI

Model Compare

A side-by-side arena for two answers to one prompt — per-side streaming states, derived speed, optional blind names, and a vote that fires exactly once.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  CircleAlert,
  Clock,
  Equal,
  EyeOff,
  Gauge,
  Hash,
  Link2,
  Link2Off,
  LoaderCircle,
  RotateCw,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/model-compare.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ModelCompare" block (zod, lucide-react).
It is an arena: one prompt, two answers, one human verdict. Most of its rules
exist to avoid leading the witness.

Contract
- A zod schema (`model-compare.contract.ts`) is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; prompt: string;
    items: { id, model, vendor?, text, state, latencyMs?, tokens?, error? }[] }.
- `state` is per ANSWER and is not the arena's `status`: queued (accepted, no
  tokens, no latency) · streaming (text is a partial string that keeps growing,
  tokens may climb, latency is still meaningless) · done (the only state in
  which latency and tokens are comparable) · error (settled, but not judgeable
  on its own).
- A separate `modelCompareVerdictSchema` = "a" | "tie" | "b". Those are SLOT
  positions, not model ids — that is exactly what a blind arena records — so
  the vote event also carries { winnerId, winnerModel }, both null on a tie.
- Props: the three contract fields, plus vote? (a verdict recorded earlier; a
  non-null value on mount means the lock starts closed), onVote?, blind?,
  syncScroll? (initial state of the link toggle, the control owns it after
  that), bodyMaxHeight? (default 320), onReload?, onRetryResponse?(id), label?,
  emptyState?, className, and the rest spread onto the root <section>.
  forwardRef to that section.
- Affordances are handler-gated: no onVote means no vote bar exists at all, no
  onRetryResponse means a failed side has no Retry, no onReload means the error
  branch has no button. Nothing dead ever renders.

Behavior
- Slots. The first two UNIQUE ids render as A and B; duplicates are dropped
  (first occurrence wins — they would collide on React keys and on the vote
  payload) and anything past the second item is ignored. A lone answer renders
  a held-open second slot with the same geometry, because a column that appears
  later must not shove the first answer sideways while it is being read.
  `ready` with zero answers falls through to the empty branch: ready with
  nothing in it is empty.
- Order is the CONSUMER's job. Never shuffle inside render — a shuffle that
  re-runs swaps the two answers under the reader mid-comparison. Randomise
  positions upstream, once per round, and pass them in.
- Latency honesty. `latencyMs` is printed only while `state === "done"`; a
  streaming side reads "still streaming" instead. A number that is still going
  to change gets compared anyway, so it is withheld until it is final. Tokens
  ARE shown live — a count is not a verdict. tok/s is derived (tokens ÷
  latency) and only for finished sides.
- The "faster" badge is derived, and deliberately separate from the vote: it
  goes to the side whose latency is lower by more than 10% of the slower one,
  and to nobody when either side is unfinished or the host did not measure.
  Speed is computed, quality is voted; keep the two visually distinct.
- Vote gating is a small state machine, and every closed state says why:
  any side still queued or streaming -> "Voting opens once both answers have
  finished"; both settled but both failed -> "nothing to compare"; both settled
  with at least one `done` -> open; already voted -> the bar becomes a readout
  of what was recorded. A failed side does NOT close voting: a working answer
  really does beat a crash.
- One-shot lock. The verdict is committed through a REF, not state. The second
  click of a double-click — and a held Enter key, which auto-repeats — lands
  before React has re-rendered with the new verdict, so a state-only guard
  reads the stale null and fires onVote twice for one opinion. The handler
  slams the ref shut, sets the verdict, then calls onVote exactly once.
- Rounds. A new pair of ids is a new round: the verdict clears, the ref
  reopens, the prompt re-collapses and blind names hide again. Do it as a
  render-phase adjustment keyed on the joined ids (an effect would paint the
  previous round's verdict once over the new pair first), and re-sync the ref
  from the committed verdict in an effect rather than writing it during render.
- Blind mode. Both the model name and the vendor are masked (a vendor names a
  model just as well); metrics stay visible because they are the thing being
  compared. The vote is what reveals the names — there is no "reveal without
  voting" escape, since that is only a slower way of not being blind.
- Link-scroll. Two answers are never the same length, so the columns are kept
  in step by scroll RATIO, not by pixel offset. Writing scrollTop fires the
  other column's scroll handler, so hold a guard flag until the frame AFTER the
  write (requestAnimationFrame) or the two columns drive each other to the
  bottom. The toggle renders only when BOTH columns are mounted and a body
  actually overflows — measured with a ResizeObserver on the content boxes,
  since streaming grows the content and not the frame. Remove the listeners,
  cancel the frame and disconnect the observer on unmount.
- Long prompts clamp to three lines behind a "Show full prompt" disclosure
  (aria-expanded + aria-controls) so a 2,000-character prompt cannot push both
  answers below the fold.
- Announcements go through ONE polite live region carrying a derived sentence:
  "Both answers have finished. Voting is open.", then the recorded verdict. The
  streaming text itself is never piped into a live region — that makes a screen
  reader unusable. Because the sentence is derived, an unchanged sentence is
  simply never re-announced.

Rendering & styling
- Semantic tokens only: bg-card shells, border-primary + ring-1 ring-primary on
  the side you voted for, bg-primary + text-primary-foreground on the chosen
  vote button, bg-primary/10 + text-primary for the Blind chip, "faster" and
  "Your pick", border-destructive/40 + bg-destructive/5 + text-destructive for
  a failed side and the error branch, bg-muted/40 for the prompt bar,
  text-muted-foreground for every metric. Numbers are tabular-nums; cn() merges
  the consumer className onto the root.
- Geometry: md:grid-cols-2 with items-stretch, both bodies capped at the same
  bodyMaxHeight so the columns line up and a short answer stretches instead of
  ragging. Answer text is whitespace-pre-wrap + wrap-anywhere inside a min-w-0
  column, so a pasted URL cannot burst the layout at 375px.
- Accessibility: each column is a role="group" whose accessible name is
  "Answer A: <model>" or "Answer A, model name hidden" — the label must not
  leak the model while blind. The scrolling body is a focusable (tabIndex 0)
  role="region" and carries aria-busy while streaming. The vote buttons form a
  labelled group; once voted they carry aria-pressed and are marked
  aria-disabled rather than `disabled`, so the button you just pressed keeps
  focus instead of dropping the caret onto <body>. focus-visible rings on every
  control, and each state carries a word, not only a colour.
- Only the streaming caret, the spinner and the skeleton pulse animate, all
  with motion-reduce:animate-none; with motion off every control still works.

Customization levers
- Sides: the slot loop is [0, 1]; widen it to N-way with a grid whose column
  count follows the pair length and replace the three-button bar with one
  "best" button per column — the lock, the gating and the blind rule all still
  hold.
- Verdict vocabulary: VOTE_OPTIONS is one array — swap it for "Left / Both bad
  / Right", a 1-5 quality scale, or add a fourth "Both bad" option, keeping the
  verdict enum in the contract as the source of truth.
- Judging strictness: drop the `judgeable` requirement to allow a vote when
  both sides failed, or raise it to require both sides `done` if a crash should
  not be votable at all.
- Metrics row: it is a flat list of chips — add cost per answer, prompt tokens,
  time-to-first-token or a cache-hit flag, and drop tok/s on consumer surfaces.
  SPEED_TIE_RATIO (0.1) decides how big a latency gap has to be before it is
  called a win at all.
- Density: bodyMaxHeight sets how much answer is visible before scrolling
  (raise it for essay comparisons, drop it to ~180 in a sidebar), and the
  prompt bar can be removed entirely when the surrounding page already shows
  the prompt.
- Blind policy: mask the metrics too (a distinctive latency profile leaks a
  model to a regular), or keep names visible and use the block purely as a
  side-by-side reader by leaving `blind` off and `onVote` out.

Concepts

  • One-shot vote lock — the verdict is committed through a ref, not state. The second event of a double-click, or a held Enter key, lands before React has re-rendered, so a state-only guard reads the stale null and records a second opinion for the same pair. The ref shuts on the first event and the render catches up afterwards; a new pair of ids is what reopens it.
  • Blind until the vote lands — names and vendors stay masked while you read, and the vote is what reveals them. There is no peek button, because a reveal available before the decision is only a slower way of not being blind. Position is not randomised here either: shuffling inside render would swap the answers under the reader, so the consumer shuffles once per round.
  • Settled is not judged — voting opens only when both sides stop moving, and a failed side counts as stopped (a working answer beats a crash) while two failed sides close the bar entirely. Every closed state prints its own reason instead of leaving a greyed-out button to guess at.
  • Derived speed vs voted quality — the "faster" badge is computed from latency, and only when the gap clears a 10% noise floor; the winner ring comes from the human. Keeping the two visually separate is the whole point, because the faster answer is very often not the better one.
  • Latency honesty — a streaming side shows its token count but not its latency. Any number placed beside an answer will be compared, and a duration that is still climbing invites a comparison that is already wrong by the time it settles.
  • Ratio-linked scrolling — the columns follow each other by proportion rather than by pixels, because two answers are never the same length; a guard released one frame after each programmatic write stops the two containers from driving each other to the bottom, and the toggle only appears once something genuinely overflows.

On This Page