Presentational building blocks for AI chat UIs. They hold no fetching or message state — you pass data in and compose them with messages.map(), so they connect to the AI SDK or your own backend. Import from @k8ordo/ui/ai.
A working chat screen. Pick a suggestion or type a message and send it — bubbles stack up in the conversation, and reasoning and tool calls fold into collapsible blocks.
Input
{
"query": "k8ordo UI ai getting started"
}Output
Starting with Conversation, Message, and PromptInput is recommended.
Conversation provides the scroll region with stick-to-bottom and a scroll-to-latest button; Message renders the bubble per role; PromptInput is the composer. You own the message list.
Enter sends, Shift+Enter inserts a newline, and the Enter that confirms an IME composition never submits. status switches the button between send and stop.
Suggestion lays out canned prompts as chips and passes the selected value straight to your send handler.
Response renders streaming Markdown and tolerates unterminated blocks. It lives in a separate subpath and needs the streamdown optional peer plus its stylesheet.
ToolInvocation and Reasoning show collapsible tool activity and thinking. Their state vocabulary matches the AI SDK tool part states.
mapMessageParts (from @k8ordo/ui/ai-sdk) turns an AI SDK UIMessage.parts array into a flat list you render yourself. It needs the ai optional peer.
Because Message.Content takes any children, you can render an LLM-generated UI spec inside a bubble with the json-render registry — generative UI delivered straight into the conversation.
Props generated from the component types. Collapsible components (Reasoning / ToolInvocation) support both controlled and uncontrolled usage via isOpen / defaultOpen / onChange.
childrenReactNode| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
childrenReactNodeisStreaming?booleanfalselabel?string| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
isStreaming? | boolean | false |
label? | string | - |
label?string| Prop | Type | Default |
|---|---|---|
label? | string | - |
childrenReactNodefromMessageRole| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
from | MessageRole | - |
Type base (some attrs are managed internally): HTMLAttributes<HTMLDivElement>
childrenReactNodeisStreaming?booleanfalse| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
isStreaming? | boolean | false |
Type base (some attrs are managed internally): HTMLAttributes<HTMLDivElement>
childrenReactNodedefaultValue?string''onChange?(value: string) => voidonStop?() => voidonSubmit?(message: string) => voidstatus?ChatStatus'ready'children?ReactNodeplaceholder?stringref?Ref<HTMLTextAreaElement>| Prop | Type | Default |
|---|---|---|
children? | ReactNode | - |
placeholder? | string | - |
ref? | Ref<HTMLTextAreaElement> | - |
Type base (some attrs are managed internally): TextareaHTMLAttributes<HTMLTextAreaElement>
sendLabel?stringstopLabel?string| Prop | Type | Default |
|---|---|---|
sendLabel? | string | - |
stopLabel? | string | - |
childrenReactNodelabel?string| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
label? | string | - |
valuestringchildren?ReactNodeonSelect?(value: string) => void| Prop | Type | Default |
|---|---|---|
value | string | - |
children? | ReactNode | - |
onSelect? | (value: string) => void | - |
childrenstringisStreaming?booleanfalse| Prop | Type | Default |
|---|---|---|
children | string | - |
isStreaming? | boolean | false |
Type base (some attrs are managed internally): StreamdownProps
childrenReactNodedefaultOpen?booleanfalseisOpen?booleanisStreaming?booleanfalseonChange?(isOpen: boolean) => void| Prop | Type | Default |
|---|---|---|
children |
namestringstateToolStatedefaultOpen?booleanfalsedeniedReason?stringerrorText?stringinput?unknownisOpen?value?string| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
defaultValue? | string | '' |
onChange? | (value: string) => void | - |
onStop? | () => void | - |
onSubmit? | (message: string) => void | - |
status? | ChatStatus | 'ready' |
value? | string | - |
ReactNode| - |
defaultOpen? | boolean | false |
isOpen? | boolean | - |
isStreaming? | boolean | false |
onChange? | (isOpen: boolean) => void | - |
booleanonChange?(isOpen: boolean) => voidoutput?ReactNode| Prop | Type | Default |
|---|---|---|
name | string | - |
state | ToolState | - |
defaultOpen? | boolean | false |
deniedReason? | string | - |
errorText? | string | - |
input? | unknown | - |
isOpen? | boolean | - |
onChange? | (isOpen: boolean) => void | - |
output? |
ReactNode |
| - |
'use client';
import { Avatar, AssistantIcon } from '@k8ordo/ui';
import {
Conversation,
Message,
PromptInput,
Suggestion,
} from '@k8ordo/ui/ai';
export function Chat({ messages, send }: Props) {
return (
<div className="flex h-full flex-col gap-3">
<Conversation.Root>
<Conversation.Messages>
{messages.map((m) => (
<Message.Root from={m.role} key={m.id}>
{m.role === 'assistant' && (
<Avatar color="primary" icon={<AssistantIcon />} name="AI" size="sm" />
)}
<Message.Content>{m.text}</Message.Content>
</Message.Root>
))}
</Conversation.Messages>
<Conversation.ScrollButton />
</Conversation.Root>
<Suggestion.List>
<Suggestion.Item onSelect={send} value="Tell me about IME support" />
</Suggestion.List>
<PromptInput.Root onSubmit={send}>
<PromptInput.Textarea placeholder="Type a message…" />
<PromptInput.Submit />
</PromptInput.Root>
</div>
);
}'use client';
import { Avatar, AssistantIcon } from '@k8ordo/ui';
import { Conversation, Message, PromptInput } from '@k8ordo/ui/ai';
import { useChat } from '@ai-sdk/react';
export function Chat() {
const { messages, sendMessage, status, stop } = useChat();
return (
<div className="flex h-full flex-col gap-3">
<Conversation.Root>
<Conversation.Messages isStreaming={status === 'streaming'}>
{messages.map((m) => (
<Message.Root from={m.role === 'user' ? 'user' : 'assistant'} key={m.id}>
{m.role !== 'user' && (
<Avatar color="primary" icon={<AssistantIcon />} name="AI" size="sm" />
)}
<Message.Content>{textOf(m)}</Message.Content>
</Message.Root>
))}
</Conversation.Messages>
<Conversation.ScrollButton />
</Conversation.Root>
<PromptInput.Root
onStop={stop}
onSubmit={(text) => sendMessage({ text })}
status={status}
>
<PromptInput.Textarea placeholder="Type a message" />
<PromptInput.Submit />
</PromptInput.Root>
</div>
);
}// Enter to send, Shift+Enter for a newline, IME-confirm Enter never submits.
// status: 'ready' | 'submitted' | 'streaming' | 'error' (matches AI SDK).
<PromptInput.Root status={status} onSubmit={send} onStop={stop}>
<PromptInput.Textarea placeholder="Type a message" />
<PromptInput.Submit /> {/* send when ready, stop while streaming */}
</PromptInput.Root>import { Suggestion } from '@k8ordo/ui/ai';
<Suggestion.List>
{suggestions.map((s) => (
<Suggestion.Item key={s} onSelect={send} value={s} />
))}
</Suggestion.List>// pnpm add streamdown
import { Response } from '@k8ordo/ui/ai/response';
import 'streamdown/styles.css';
<Message.Content>
<Response isStreaming={isStreaming}>{markdown}</Response>
</Message.Content>import { Reasoning, ToolInvocation } from '@k8ordo/ui/ai';
<Reasoning isStreaming={isThinking}>{reasoningText}</Reasoning>
<ToolInvocation
name="search_web"
state="output-available" // 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
input={{ query: 'k8ordo UI' }}
output="…"
/>import { mapMessageParts } from '@k8ordo/ui/ai-sdk';
import { Reasoning, ToolInvocation } from '@k8ordo/ui/ai';
import { Response } from '@k8ordo/ui/ai/response';
<Message.Content>
{mapMessageParts(message).map((part, i) => {
if (part.kind === 'reasoning') return <Reasoning key={i}>{part.text}</Reasoning>;
if (part.kind === 'tool') return <ToolInvocation key={i} {...part} />;
return <Response key={i}>{part.text}</Response>;
})}
</Message.Content>'use client';
import { Message } from '@k8ordo/ui/ai';
import { JsonRenderUI } from '@k8ordo/ui/json-render/registry';
// An LLM returned a UI spec as a tool result — render it inside the bubble.
<Message.Root from="assistant">
<Message.Content>
<JsonRenderUI spec={spec} />
</Message.Content>
</Message.Root>