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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/model-compare.jsonPrompt
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
nulland 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.
Streaming Code
A code panel that renders while the model is still writing it — caret on the last line, frozen finished lines, regex tinting, copy-what-arrived and auto-scroll the reader can break.
Pinned Messages
A pinned-messages strip for a conversation — one chip per pin, as many as genuinely fit measured against the real container, the rest collapsed into a +N more panel, unpin behind a confirmation that fires once, and real loading/empty/error states.