Skip to content
kitn AI/UI

Compare

kai-compare

Collects a preference pair from a single pick, for evaluation or feedback data.

  • Shadow DOM
  • Preference capture
  • Both candidates stream
  • Keyboard radiogroup
  • Collapses to the winner

Set data in JavaScript — it’s an object with exactly two candidates (arrays and objects can’t be HTML attributes). Listen for kai-compare-select:

<kai-compare id="cmp"></kai-compare>
<script type="module">
import '@kitn.ai/ui/web-components';
await customElements.whenDefined('kai-compare');
const cmp = document.getElementById('cmp');
cmp.data = {
prompt: 'Explain a closure in one paragraph.',
candidates: [
{ id: 'a', label: 'A', model: 'gpt-4o', content: 'A closure is a function that…' },
{ id: 'b', label: 'B', model: 'claude', content: 'Closures let a function remember…' },
],
};
cmp.addEventListener('kai-compare-select', (e) => {
const { chosenId, rejectedIds } = e.detail;
// Send the preference pair to your backend.
recordPreference({ prompt: cmp.data.prompt, chosenId, rejectedIds });
});
</script>
  • data: { prompt?, candidates: [A, B], collapse? }. Each candidate renders exactly like a message body (markdown, reasoning, tool calls, attachments).
  • kai-compare-select — fires once when the user picks. detail is { chosenId, rejectedIds, at }.
  • The pick commits and collapses to the chosen candidate, and <kai-compare> won’t emit again for the same definition.

Each entry in candidates describes one assistant response:

FieldTypeNotes
idstringUnique within the pair; echoed back in the selection.
contentstringThe response text (markdown).
reasoning{ text, label? }Optional collapsible reasoning block.
toolsToolPart[]Tool-call parts rendered above the content.
attachmentsAttachmentData[]Inline attachment previews.
labelstringShort column label (e.g. 'A', 'Concise').
modelstringModel name shown as a sub-label.
streamingbooleantrue while this candidate is still generating.

A comparison needs exactly two candidates with unique, non-empty ids. Anything else renders an inline error and fires kai-error.

The selection is the whole point. kai-compare-select hands you chosenId and rejectedIds (for a pair, the other candidate’s id); pair that with the prompt and send it wherever you collect preference data:

cmp.addEventListener('kai-compare-select', (e) => {
const { chosenId, rejectedIds, at } = e.detail;
fetch('/api/preferences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: cmp.data.prompt,
chosen: chosenId,
rejected: rejectedIds,
at,
}),
});
});

Both responses can stream at once: mark a candidate streaming: true and its column shows a shimmer, while the pick controls stay disabled until both settle and kai-ready fires.

Push a fresh data reference per chunk — assign a new object, never mutate the existing one in place, or the element won’t re-render:

cmp.data = {
prompt,
candidates: [
{ id: 'a', label: 'A', content: '', streaming: true },
{ id: 'b', label: 'B', content: '', streaming: true },
],
};
for await (const { id, delta, done } of streamBoth()) {
cmp.data = {
...cmp.data,
candidates: cmp.data.candidates.map((c) =>
c.id === id ? { ...c, content: c.content + delta, streaming: !done } : c,
),
};
}

To show a comparison the user already answered (loading a conversation), set selection: the card renders collapsed to the chosen candidate and emits nothing.

cmp.data = { prompt, candidates: [a, b] };
cmp.selection = { chosenId: 'a', rejectedIds: ['b'] };

layout controls the columns:

ValueBehavior
'auto' (default)Container query — tabs (pills) on narrow, side-by-side columns when wide.
'columns'Always two columns.
'tabs'Always tabbed — one candidate at a time with pills to switch.

The columns are a WAI-ARIA radiogroup with roving focus: Arrow keys move between A and B, Enter or Space picks the focused one.

PropertyTypeDefaultNotes
theme'auto'Color mode (`auto` follows prefers-color-scheme).
data—The compare definition (prompt + the two candidates). JS property: `el.data = { prompt, candidates: [A, B] }`.
compareId—Stable id correlating every emitted event. Attribute: `compare-id`.
selection—Re-hydrate / control the user's pick. Set as a JS PROPERTY: `el.selection = { chosenId, rejectedIds }`. Renders the collapsed winner.
layout'auto'Layout: `auto` (default, by CONTAINER width), `columns` (side-by-side), or `tabs` (pills).
proseSize—Prose/text size for the rendered candidates. Attribute: `prose-size`.
codeTheme—Shiki theme for code blocks in the candidates. Attribute: `code-theme`.
codeHighlight—Whether code blocks are syntax-highlighted. Attribute: `code-highlight`.
EventDetailNotes
The user committed a pick. `detail` = `{ chosenId, rejectedIds, at }`.
The definition was unusable.
Both candidates have settled and the pick is live.
MethodSignatureNotes
(candidateId: string): voidProgrammatically commit a pick by candidate id, on the same path as the "Pick this" button: fires kai-compare-select and optimistically collapses (single-shot; inert while streaming or already resolved). `select` does NOT collide with the `selection` prop (distinct identifier).
(options?: FocusOptions): voidFocus the current roving tab stop (the focused candidate's "Pick this" radio) so a consumer can move keyboard focus into the radiogroup.

This element wraps these SolidJS components — reach for them directly when you need finer control than the props expose.

ResponseCompare