A guided chat
The parts a conversation whose turns the host decides is built from, beside the transcript and the composer — work under way, a model in the page as it loads, a point to go back to, the steps of the work, the sources a thread rests on and the ones a message carries — and the question it puts to the person, which is @kanzo-tech/ui's.
Chat is a conversation whose turns are the model's. A guided chat is one whose
turns are the host's — keasy's assistant asks which files to read, reads them, says what it found,
puts the decisions it cannot take to the person and offers questions to ask — and it is drawn from
Chat's parts and these, so no turn is hand-rolled:
| Part | For | AI Elements |
|---|---|---|
Working | Work under way, said: a spinner and its words | Shimmer, Loader |
ModelBadge | A model in the page, as it downloads and loads | Context |
Checkpoint and its parts | A point in the transcript the host can go back to | Checkpoint |
ChainOfThought, ChainOfThoughtStep | The steps of the work — done, under way, waiting on the reader, still to come — as a list | ChainOfThought |
Sources and its parts | What the thread rests on, folded above the transcript: the reader's words about the data and the sources picked | Sources |
PromptInputHeader | The row above the composer's field, for the sources a message carries | PromptInputHeader |
A question put to the person is @kanzo-tech/ui's Question, which knows
no model either: a decision's options are data, and drawing them is a form. What a model offers is
@kanzo-tech/ui's Suggestions, which knows no model: each Suggestion's
description is its rationale, in the tooltip, and pending (SuggestionsProps) holds room for
the pills still arriving, in skeleton, the strip aria-busy until they land. Chat and Assist draw their offers with it. Everything
else a turn carries is @kanzo-tech/ui too: a figure is a Stat, a
callout an Alert, a bar a Progress (under Working's words, its value), a heading inside a block
an Eyebrow, a file tree a TreeView
with its checkboxes, a failure a Problem.
A decision is the question part
A Task's Decision is Question's shape — its question the prompt, each
option one of its choices — and the guided chat draws it in a turn with @kanzo-tech/ui's part, the
same part a Questionnaire asks several of in a row.
import { Question, QuestionChoice, QuestionChoices, QuestionDescription, QuestionPrompt } from "@kanzo-tech/ui";
<Question name={decision.id} value={chosen} onValueChange={(d) => choose(decision.id, d.value)}>
<QuestionPrompt>{decision.question}</QuestionPrompt>
<QuestionDescription>It has no safe answer, so it is yours to decide.</QuestionDescription>
<QuestionChoices>
{decision.options.map((option) => (
<QuestionChoice key={option.id} value={option.id}>
{option.label}
</QuestionChoice>
))}
</QuestionChoices>
</Question>A pick is the answer, in one press. Each choice reports itself through onValueChange as it is
picked, so there is no Submit after it, and the arrow keys move between the choices and choose as
they move — Ark's radio group, a <label> card around each radio. A decision whose answer is typed —
a threshold — takes QuestionInput in place of the choices, and the answer is still a value, never
parsed out of the composer's text.
Working
<Working>Thinking…</Working>
<Working value={(done / total) * 100}>Reading your files… {done} of {total}</Working>
<Working value={null}>Making your graph…</Working>A role="status" with a spinner and the words for what is under way, so the words are announced as
they change. Chat draws one where the answer will be. Given a value, @kanzo-tech/ui's
Progress sits under the words — Ark's progressbar, named by its value — and without one there is no
bar, which is the common case.
WorkingProps | Type | |
|---|---|---|
value | number | null | 0–100 draws the bar at that value; null draws it indeterminate; left out, no bar |
ModelBadge
A model the page fetches before it answers, as a badge: Preparing the model · 42% while it
downloads and loads, Model ready, Model failed. Pressed, it opens the detail — the bar and the
engine's own words, or the failure as a Problem with a retry. AI Elements' Context is the shape:
a small trigger, the detail behind it. It reads a ModelLoad —
localModelLoad's, or the host's own for a model fetched some other way.
const load = useSyncExternalStore((l) => watchLocalModel(MODEL, l), () => localModelLoad(MODEL));
<ModelBadge load={load} onRetry={() => void loadLocalModel(MODEL)} />A popover rather than Context's hover card, because a failure carries a retry and what a hover card
holds cannot be reached by the keyboard or on touch. onRetry is offered for a failure the device
can recover from, and not for llm/unavailable — no WebGPU, or no model served — which a second try
ends the same way.
ModelBadgeProps | Type | |
|---|---|---|
load | ModelLoad | Where the load stands |
onRetry | () => void | Starts a failed load again |
describe | ProblemProps["describe"] | The host's words for a failure's code |
translations | Partial<ModelBadgeTranslations> | The badge's four words, the two details and retry |
Checkpoint
- Preview 1
- Preview 2
- Preview 3
A point in the conversation the host can go back to — each preview keasy's assistant draws — as a
thin rule across the transcript: a mark, the label, Restore, and the rule to the end of the row.
AI Elements' Checkpoint in its composition and its look, and v0's and Lovable's version restore in
what it is for.
import { Checkpoint, CheckpointIcon, CheckpointTrigger } from "@kanzo-tech/ai";
<MessageList>
{turns.map((turn) => (
<Fragment key={turn.id}>
<Message role={turn.role}>…</Message>
{turn.preview && (
<Checkpoint>
<CheckpointIcon />
<span>{turn.preview}</span>
<CheckpointTrigger onClick={() => restore(turn.id)} />
</Checkpoint>
)}
</Fragment>
))}
</MessageList>| Part | |
|---|---|
Checkpoint | The row: its children, then the rule. An <li>, an item of the MessageList beside the turns; asChild for a transcript that is not one |
CheckpointIcon | The mark, a bookmark unless given another glyph as its child; hidden from assistive tech |
CheckpointTrigger | The way back: a quiet Button, Restore unless given words of its own. onClick is the restore |
CheckpointTriggerProps | Type | |
|---|---|---|
translations | Partial<CheckpointTranslations> | restore, the trigger's word |
The rest are Button's.
It restores nothing. What going back means is the host's, and so is the thread afterwards. Keasy's
— and AI Elements' own example — cuts the thread: the turns after the checkpoint go, the
preview it marks is current again, and the latest checkpoint carries no trigger because there is
nowhere to go back to. A host that keeps the cut turns as a branch draws them itself; that is
MessageBranch, which is not admitted
(The references).
No tooltip on the trigger. AI Elements' takes one, saying what restoring does; a host that wants the sentence gives the trigger it as its words, which a reader on touch can also read.
ChainOfThought
- Done: Read your files6 files, 4 of them tables
- In progress: Match the columns to the questions
- Waiting for you: Choose what identifies a prizeTwo columns could; it is yours to say
- Not started: Write the program
- Not started: Build your graph
The steps of the work, all visible: Read your files done, Match the columns under way, Choose what
identifies a prize waiting on the reader, Write the program and the rest still to come. Each row is
a line with a mark, an optional detail under it and an optional action at its end. AI Elements'
ChainOfThought and ChainOfThoughtStep in their names and props — label, description,
status, icon — for the host's steps rather than the model's reasoning, which is Reasoning.
import { ChainOfThought, ChainOfThoughtStep } from "@kanzo-tech/ai";
import { Button, Eyebrow } from "@kanzo-tech/ui";
<Eyebrow>What I am doing</Eyebrow>
<ChainOfThought>
<ChainOfThoughtStep label="Read your files" description="6 files" />
<ChainOfThoughtStep label="Match the columns to the questions" status="active" />
<ChainOfThoughtStep label="Choose what identifies a prize" status="waiting" />
<ChainOfThoughtStep
action={<Button size="sm" variant="link">Open in the editor</Button>}
label="Write the program"
status="pending"
/>
</ChainOfThought>ChainOfThoughtStepProps | Type | |
|---|---|---|
label | ReactNode | The step's line |
description | ReactNode | Under the label, quieter |
status | ChainOfThoughtStatus: "complete" | "active" | "waiting" | "pending" | Defaults to complete. waiting is the step that is the reader's to take; the other three are AI Elements' |
icon | ComponentType<{ className?: string }> | A glyph in place of the status's own |
action | ReactNode | Trailing the row: the step's one verb |
translations | Partial<ChainOfThoughtTranslations> | The four statuses, said to assistive tech |
children are drawn under the detail, for a body of the host's own. A row's mark is paint, so the
status is also said in words, ahead of the label, and the step that is the work's present — active
or waiting — is aria-current="step". It is an <ol>: the order is the meaning.
Sources
What the thread rests on, folded above the transcript: Sources, a pill with how many, and — open —
the reader's own words about the data and a pill per source. AI Elements' Sources,
SourcesTrigger and SourcesContent, which sit under an answer, drawn once above the whole thread.
import { Source, Sources, SourcesContent, SourcesTrigger } from "@kanzo-tech/ai";
<Sources defaultOpen>
<SourcesTrigger count={sources.length} />
<SourcesContent>
<p className="basis-full">{about}</p>
{sources.map((source) => (
<Source key={source.id}>{source.name} · {source.files}</Source>
))}
</SourcesContent>
</Sources>| Part | |
|---|---|
Sources | @kanzo-tech/ui's Collapsible, closed unless defaultOpen |
SourcesTrigger | The row that folds it: Sources (or its children), the count as a pill, the chevron. SourcesTriggerProps adds count and translations (SourcesTranslations: sources) |
SourcesContent | What is inside, wrapping: a child that should have a line of its own takes basis-full |
Source | One pill; its words are its children. A source that opens somewhere is asChild over the host's link |
Two places for sources, two jobs. The pills in PromptInputHeader are what the next message is
asked over, and they change; Sources is what the thread has been about, read-only, with the reader's
words beside it.
PromptInputHeader
The row above the composer's field, for what the message will carry. Keasy puts a pill there per
connection the question is asked over; pressed, a pill opens @kanzo-tech/ui's Menu with a
MenuCheckboxItem per file, so the reader picks what the question reads without leaving the
composer. The pills and the menu are the host's: the header is the place, and it wraps, so a row of
sources in a narrow panel takes a second line.
import { PromptInput, PromptInputHeader, PromptInputSubmit, PromptInputTextarea, PromptInputToolbar } from "@kanzo-tech/ai";
import { InputGroupButton, Menu, MenuCheckboxItem, MenuContent, MenuTrigger } from "@kanzo-tech/ui";
<PromptInput onSubmit={send}>
<PromptInputHeader>
{connections.map((connection) => (
<Menu closeOnSelect={false} key={connection.id}>
<MenuTrigger asChild>
<InputGroupButton pill variant="outline">{connection.name} · {chosen(connection).length}</InputGroupButton>
</MenuTrigger>
<MenuContent>
{connection.files.map((file) => (
<MenuCheckboxItem checked={isChosen(file)} key={file} onCheckedChange={(on) => choose(file, on)} value={file}>
{file}
</MenuCheckboxItem>
))}
</MenuContent>
</Menu>
))}
</PromptInputHeader>
<PromptInputTextarea placeholder="Ask about these files…" />
<PromptInputToolbar>
<PromptInputSubmit />
</PromptInputToolbar>
</PromptInput>A press on the row outside a pill puts the caret in the field, as the toolbar's does. No
attachment cards: AI Elements fills this row with Attachments, a card per file sent, and a
source here is a connection the question reads rather than a file uploaded with it. Chat's
context stays beside Send, in the toolbar: it is one read-only pill, and a row of its own would
cost the side panel a line (The references
has what would move it).
Held by packages/ui/src/composites/question.test.tsx, "is a radio group named by its prompt, a
choice per radio, outside any questionnaire", "answers with the choice picked, in one press, and draws
the answer it is given", "moves between the choices with the arrow keys, choosing as it moves";
packages/ai/src/working.test.tsx,
"is one status saying what is under way, its spinner hidden so it is not announced twice" and "draws
a bar under the words given a value, indeterminate given null";
packages/ai/src/model-badge.test.tsx, "opens a failure as a Problem, with a retry only where the
device can recover"; packages/ai/src/checkpoint.test.tsx, "is an item of the transcript between two
turns, its rule hidden as paint", "restores nothing itself: the trigger is a button that hands the
press to the host"; packages/ai/src/prompt-input.test.tsx, "carries the host's pills above the
field, each opening what it holds"; packages/ai/src/chain-of-thought.test.tsx, "is an ordered list with
every step visible, the future ones too" and "says each status in words, since its mark is paint, and
marks the present step"; packages/ai/src/sources.test.tsx, "is a trigger naming the block and counting
its sources, closed until pressed". Why these and not the rest of AI Elements is
The references.
Chat
A conversation with a model, whole — the transcript, markdown that streams, the model's reasoning and tool calls as they happen, and a composer that sends, stops and retries. The host brings the conversation (useAgentChat) and draws its own tools' results.
Ask your data
Ask — questions about data answered with a dashboard tile over a relation, from a spec the model fills with paths the page generated, resolved by the page's joins — the types it may answer about, the chat it runs in, the questions to start from, and the card each answer is drawn in — @kanzo-tech/ai/data.