Skip to content
kitn AI/UI

Compare

kai-compare

”Which response do you prefer?” — render two assistant candidates for the same prompt side by side, let the user pick one, and turn that pick into a (prompt, chosen, rejected) preference pair.

  • 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/elements';
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 is an assistant response; it 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. It’s single-shot — <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 to 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; the pick controls stay disabled until both settle, then 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 (e.g. 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 two columns are a WAI-ARIA radiogroup with roving focus: Arrow keys move between A and B, Enter or Space picks the focused one.

PropertyTypeDefaultNotes
dataThe compare definition (prompt + the two candidates). Set as a JS PROPERTY: `el.data = { prompt, candidates: [A, B], collapse? }`. Import `ResponseCompareData` from `@kitn.ai/ui` for the full shape.
compareIdStable id correlating every emitted event. Attribute: `compare-id`.
selectionRe-hydrate / control the user's pick. Set as a JS PROPERTY: `el.selection = { chosenId, rejectedIds }`. Renders the collapsed winner.
layout'auto'Layout: `'auto'` (default — columns when wide, tabs when narrow, by CONTAINER width) | `'columns'` (side-by-side) | `'tabs'` (pills to switch). Attribute: `layout`.
proseSizeProse/text size for the rendered candidates. Attribute: `prose-size`.
codeThemeShiki theme for code blocks in the candidates. Attribute: `code-theme`.
codeHighlightWhether 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.

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

ResponseCompare