React components for agent products: conversations with reasoning, tool calls and approvals, run status, artifacts and charts, styled by one Tailwind v4 token theme. The UI package of Corbits: it renders data a host web app loads from Interchange, and works in any React 18 or 19 app without Interchange.
- Props in, markup out. No component fetches, subscribes or holds a client. Your data layer stays yours, and every component renders the same in stories, tests and production.
- Agent-shaped building blocks.
Messageholds a turn whileTool,ReasoningandActivityrender its parts (text, reasoning, tool calls),ToolApprovalasks a person before a call runs, andGateBlockandStepListcover a run that waits on a person. - Pay for what you import. One root entry, the package is side-effect free apart from its CSS, and heavy peers (Radix dialog, dropdown-menu, tooltip,
sonner) are optional. - Server Components ready. Stateful modules ship
"use client", so a React Server Components app imports them directly. Outside RSC the directive is a no-op.
bun add @corbits/react-ui react react-dom lucide-react @radix-ui/react-slotRequires Node 24 or later. Tested on React 19; the peer range also accepts React 18.2+.
Optional peers are needed only by the modules that import them. Those modules are not in the root entry, so import them by subpath:
| Peer | Subpaths |
|---|---|
@radix-ui/react-dialog |
ui/dialog, ui/command-palette, ui/sidebar-search, ui/mic-permission-dialog |
@radix-ui/react-dropdown-menu |
ui/menu |
@radix-ui/react-tooltip |
ui/tooltip |
sonner |
ui/toast |
Import the stylesheet once at the app root, before your own CSS, and mount ThemeProvider near the root:
import "@corbits/react-ui/styles.css";
import {
Activity,
Conversation,
ConversationContent,
ConversationItem,
ConversationScrollButton,
Message,
MessageContent,
MessageHeader,
ThemeProvider,
groupActivity,
type ConversationMessage,
type ConversationParticipant,
} from "@corbits/react-ui";
const you: ConversationParticipant = { id: "u1", kind: "person", name: "You" };
const scout: ConversationParticipant = {
id: "a1",
kind: "agent",
name: "Scout",
};
const messages: ConversationMessage[] = [
{
id: "m1",
author: you,
at: "2026-01-01T09:00:00Z",
status: "sent",
parts: [{ kind: "text", text: "What changed in the repo today?" }],
},
{
id: "m2",
author: scout,
at: "2026-01-01T09:00:04Z",
status: "sent",
parts: [
{
kind: "reasoning",
text: "Check the commit log first.",
durationMs: 1200,
},
{
kind: "tool-trace",
toolCallId: "t1",
name: "git__log",
status: "output-available",
output: "3 commits",
},
{ kind: "text", text: "Three commits landed today, all in the docs." },
],
},
];
function Turn({ message }: { message: ConversationMessage }) {
const own = message.author.id === you.id;
return (
<Message
from={own ? "own" : message.author.kind}
status={message.status}
aria-label={own ? "You" : undefined}
>
{own ? null : (
<MessageHeader name={message.author.name} at={message.at} />
)}
{groupActivity(message.parts).map((group) => {
if (group.kind === "activity")
return <Activity key={group.key} steps={group.steps} />;
if (group.part.kind === "text")
return <MessageContent key={group.key} text={group.part.text} />;
return null;
})}
</Message>
);
}
export function App() {
return (
<ThemeProvider>
<div style={{ display: "flex", flexDirection: "column", height: 480 }}>
<Conversation>
<ConversationContent>
{messages.map((message) => (
<ConversationItem key={message.id} itemId={message.id}>
<Turn message={message} />
</ConversationItem>
))}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
</div>
</ThemeProvider>
);
}This renders the user message in a bubble, the agent's thinking and tool call folded into one activity line that opens onto both, and the agent's reply. Every other part kind (tool calls waiting on a person, images, sources, files) has its own component; see Chat.
styles.css includes Tailwind preflight and a base layer, so it restyles the page. If you already run Tailwind v4, import @corbits/react-ui/theme.css instead and let your build generate the utilities. Import one of the two, never both; without either, components render unstyled.
- Interchange runs AI agents as principals (first-class identities with their own permissions and credentials). The hub is its multi-tenant control plane: tenants, principals, grants (permissions a principal holds) and credentials. The sidecar is the portable agent runtime.
- Corbits is the set of published packages around Interchange that turn an agent into a product. This package is the UI bucket: it runs in the host web app and plugs into the host's data layer, not into the hub or the sidecar.
- It pairs with the hub modules whose data it renders, such as
@corbits/artifacts,@corbits/mailboxand@corbits/cron.
Import from the root; it tree-shakes to what you use. Only the optional-peer modules have their own subpath:
import { Button } from "@corbits/react-ui";
import { Toaster, toast } from "@corbits/react-ui/ui/toast";
export function SignOut() {
return (
<>
<Button onClick={() => toast("Signed out.")}>Sign out</Button>
<Toaster position="bottom-right" />
</>
);
}Dark mode is a .dark class on an ancestor. With neither .dark nor .light on the root, the tokens follow prefers-color-scheme and set color-scheme for native controls; dark: utilities still need a .dark ancestor.
ThemeProvider toggles both classes and persists the choice under storageKey. Hosts that manage the class themselves add .light for an explicit light choice, since removing .dark falls back to the OS. To avoid a dark first paint for a stored light choice on a dark OS, add .light before first paint only when the stored choice is light:
<script>
if (localStorage.getItem("corbits-theme")?.includes('"light"'))
document.documentElement.classList.add("light");
</script>The theme names Red Hat Display and Space Mono but does not bundle them. Load them yourself or the stack falls back to system fonts; override --font-sans and --font-mono for a face loaded under another name.
Everything imports from @corbits/react-ui, except rows marked "optional peer", which import from the subpath shown; see Install.
| Component | What it is |
|---|---|
Conversation |
Scrolling conversation that settles or follows new messages; useConversation |
ConversationContent |
The scrolling log; a ConversationItem wraps each entry |
ConversationScrollButton |
Jump back to the latest message |
ConversationEmptyState |
No messages yet |
ConversationDivider |
A rule with an optional label, such as a day; ConversationNotice for a system line |
ConversationEarlier |
Loads older messages as the reader scrolls up; useHistoryWindow |
ConversationMap |
Rail of ticks, one per turn, to find your way around; useReadingPosition |
ConversationThread |
A reply thread headed by its parent message |
Message |
One message, built from MessageAvatar, MessageHeader, MessageContent, MessageSendStatus and MessageFooter |
MessageFold |
Folds a very long answer to its opening |
MessageParts |
A message's parts as their components, any kind replaceable; keeps focus when a decided call folds |
MessageActions |
Row of actions under a message: MessageAction, MessageActionMenu |
MessageReactions |
Emoji reactions with counts |
MessageReplies |
Reply count and time that opens the thread |
Activity |
A run of thinking and tool calls folded to one line; groupActivity |
Reasoning |
An agent's thinking, folded |
Tool |
One tool call that opens onto its input and output |
ToolGroup |
Tool calls that ran at the same time |
ToolApproval |
Approve, approve always or deny a tool call waiting on a person |
GeneratedImages |
Images an agent made, with placeholders while generating |
Sources |
Numbered citations for an answer |
MediaViewer |
Pictures, video, audio, PDFs and text full size; useMediaViewer |
Attachments |
Files as a stack, tiles or a list; useAttachments |
AttachmentDropZone |
Area that takes dropped files and folders; useFileDrop |
PromptInput |
The message box: PromptInputTextarea, PromptInputSubmit, PromptInputSuggestions; usePromptInput |
PromptInputToolbar |
Controls under the text: add menu, model and mode selects, notes |
ToolPicker |
Browse, search and select tools |
TypingIndicator |
Agent-is-typing dots |
ThinkingIndicator |
Animated Corbits mark with a status label |
ThinkingMark |
Decorative animated Corbits mark |
ThinkingLabel |
Rotating-verb status label |
ShimmerText |
Shimmer sweep over text |
MicButton |
Permission-aware microphone toggle |
VoiceWaveform |
Live audio level bars |
DictationStatusLine |
Composer dictation status row |
MicPermissionDialog |
Microphone permission prompt (optional peer, ui/mic-permission-dialog) |
Panel |
Side panel resized from its edge; hidden panes keep their state |
PanelTabs |
A panel's tab row: select, close, drag or keyboard to reorder; useTabs |
| Component | What it is |
|---|---|
LiveRunBanner |
Status of a run in progress |
LiveStatusLine |
The single "what's happening now" line |
GateBlock |
Chrome for a run parked on a human decision |
StepList |
Vertical list of run steps and their status |
HorizontalStepper |
Horizontal step progress |
ProgressChecklist |
Steps of a long operation and how far along it is |
TraceWaterfall |
Run-trace timeline, one row per span |
IntakeForm |
Inputs a workflow asks for before it runs |
RunNowButton |
Run something immediately |
ConfirmButton |
Button that asks for a second click |
NowCards |
Attention band of items needing action |
NotificationsBell |
Bell with count and a panel slot |
| Component | What it is |
|---|---|
ArtifactBody |
One artifact, drawn the way its kind implies |
ArtifactNotice |
"Nothing to draw here" message for artifact viewers |
BlockCard |
Generative-UI block frame |
ResearchBody |
Research brief and report |
CompareBody |
Side-by-side comparison |
EmbedBody |
Sandboxed third-party embed |
CsvTable |
Capped preview of CSV text |
QuoteCard |
A rotating quote |
ProfileCard |
Identity, status, actions and channels |
| Component | What it is |
|---|---|
ChartFrame |
Caption, legend, plot, and the same data as a table |
TimeSeriesChart |
Values over time |
BarChart |
Vertical bars |
CategoryBars |
Dense horizontal category ranking |
Sparkline |
Inline trend line |
TokenMosaic |
Shares of a whole as one stacked strip |
StatGrid |
Grid of headline numbers |
AnimatedNumber |
Number that counts to its new value |
| Component | What it is |
|---|---|
PageShell |
Page margins and scroll ownership |
PagePanel |
Bordered page-content frame |
TopBar |
Title, breadcrumbs and actions row |
Sidebar |
Collapsible navigation sidebar |
SidebarSearch |
Search row labelled Cmd+K (optional peer, ui/sidebar-search) |
SidebarPanel |
Sidebar with pins, body and footer |
SidebarItemRow |
One sidebar row |
ListDetail |
Index on the left, open item on the right |
InspectorShell |
Side inspector rail |
Section |
Titled block of content |
SettingsPanel |
Settings frame with optional save |
LibraryPageHeader |
Title, count and controls for a library page |
Card |
Bordered content card |
EmptyState |
Title, description, icon and action |
RichEmptyState |
Empty state with suggested next-step actions |
BootScreen |
Screen shown before the app loads |
DitherCanvas |
Animated dithered background |
CorbitsMark |
Corbits logo |
AuthLayout |
Sign-in page layout |
LoginForm |
Sign-in and sign-up form, with optional SSO |
| Component | What it is |
|---|---|
Button |
Button with variants and sizes |
Input |
Text input |
Textarea |
Multi-line input |
Select |
Native select |
Checkbox |
Checkbox |
SelectionCheckbox |
Row-selection checkbox |
Switch |
On/off toggle |
FileInput |
File picker |
Tabs |
Tab strip |
ViewToggle |
Grid or rows segmented control |
FilterBar |
Row of filters with an active-filter summary |
FilterChip |
One toggleable filter |
KindCardGrid |
Selectable card grid |
BulkActionBar |
Actions for a multi-row selection |
Table |
Table primitives |
SortableTable |
Table with sortable columns |
Badge |
Status label |
StatusDot |
Colored status dot |
Avatar |
Initials or image avatar |
ProviderMark |
Which service an action goes through |
Skeleton |
Loading placeholder |
Dialog |
Modal dialog (optional peer, ui/dialog) |
CommandPalette |
Global search overlay (optional peer, ui/command-palette) |
Menu |
Dropdown menu (optional peer, ui/menu) |
InfoTooltip |
Hover info tooltip (optional peer, ui/tooltip) |
Toaster |
Toast host; call toast() anywhere (optional peer, ui/toast) |
ThemeProvider |
Persists light, dark or system mode and presets |
ThemeToggle |
Light, dark or system switch |
The host app owns the connection to the hub; components only take props.
- Load run state with
@intx/hub-client:createRunSessionpolls a workflow run's event log and exposes a seq-orderedWorkflowRunEventtimeline. - Map that timeline to props: agent output to
ConversationMessageparts rendered byMessageinside aConversation, a tool call waiting on a person to aPartToolcarrying itsToolApprovalRecordforToolApproval, step events toStepListitems, and a run parked on a person toGateBlock. - Wire callbacks back to the hub.
ToolApproval'sonDecidecalls your hub route that resolves the awaited approval with the decision's outcome and scope; the hub checks the caller's grant, not the component. The settled record comes back on the part, andToolApprovalshows what was decided.
The old chat family is removed. The conversation family replaces it: a message is a ConversationMessage whose parts are a ConversationPart[] (discriminated by kind), and the components are built from parts you compose rather than one component per screen.
Part,PartText,PartReasoning,PartToolTrace,PartBlock,PartFile,PartEventandToolTraceStatusare unchanged and still import from the root.
Removed exports and what replaces them:
| Removed | Use instead |
|---|---|
ChatThread, ChatThreadProps |
Conversation with ConversationContent, a ConversationItem per message and ConversationScrollButton |
AgentTurn, AgentTurnProps |
Message with from="agent", MessageAvatar, MessageHeader and MessageContent, with MessageParts for the parts |
MessageBubble, MessageBubbleProps |
Message with MessageContent; from="own" draws the bubble |
PartsRenderer, PartsRendererProps |
MessageParts inside a Message; renderBlock for block parts |
toolTraceToBlockState |
No replacement: Tool reads ToolTraceStatus and the approval record directly |
ReasoningBlock, ReasoningBlockProps |
Reasoning (ReasoningTrigger, ReasoningContent) on a PartReasoning |
ToolBlock, ToolBlockProps, ToolBlockState |
Tool (ToolHeader, ToolInput, ToolOutput) on a PartTool; the state is its ToolTraceStatus |
ToolNarrative, ToolNarrativeProps |
Tool, or ToolGroup for calls that ran together |
ApprovalCard, ApprovalCardProps |
ToolApproval on a PartTool with a ToolApprovalRecord; onApprove, onReject and onAllowAlways become onDecide(ToolApprovalDecision), and a busy state becomes disabled |
ApprovalRequest, ApprovalDetail |
The PartTool itself: name and input, with the host's own account of the action as ToolApproval's children |
ApprovalState |
ToolApprovalRecord's status for the outcome, ToolApproval's disabled while a decision is recorded |
ChatInput, ChatInputProps |
PromptInput with PromptInputTextarea and PromptInputSubmit; PromptInputToolbar for controls under the text |
ChatAttachment |
AttachmentData for Attachments, AttachmentItem from useAttachments |
ChatComposer, ChatComposerProps |
PromptInput, with ConversationEmptyState for an empty conversation |
QuickReplyChips, QuickReply |
No replacement: suggested replies are the host's own buttons, for example inside ConversationEmptyState |
ChatPanel, ChatPanelHeader, ChatPanelFooter |
Panel, PanelHeader and PanelContent for a side panel; put Conversation and PromptInput in your own frame |
ChatMessage |
ConversationMessage: role becomes author (ConversationParticipant), createdAt becomes at, status is required |
ChatRole |
ConversationParticipant's kind (person or agent); a system line is a PartEvent, shown with ConversationNotice |
MessageStatus |
ConversationMessageStatus, which adds streaming |
MessagePart |
ConversationPart, discriminated by kind instead of type |
TextPart, ReasoningPart, FilePart |
PartText, PartReasoning, PartFile |
ToolPart |
PartTool: toolName becomes name, state becomes status, and label moves to ToolHeader's label |
ToolPartState |
ToolTraceStatus: done becomes output-available |
AgentIdentity |
ConversationParticipant with kind: "agent"; tagline becomes role, and MessageAvatar takes initials |
agentInitials |
MessageAvatar, which derives initials from name |
SubagentRun, SubagentState |
No replacement: the conversation model has no subagent type; show a helper's calls as PartTools or a PartToolBatch |
ChatThreadSummary |
No replacement: a list of conversations is the host's own data |
messageText, reasoningText, toolParts |
No replacement: filter message.parts by kind |
isTurnWorking |
parts.some((p) => p.kind === "tool-trace" && p.status === "running") (any tool still running; message.status === "streaming" is the whole message) |
toolLabel |
No replacement: ToolHeader shows the raw tool name, after the label you pass |
ToolProviderMark |
ProviderMark with the provider read off the tool id (name.split("__")[0], for ids shaped provider__operation) |
useDelayedAutofocus |
No replacement: focus the element from an effect once its entrance has finished |
- Install from npm. Git installs (
github:corbitsdev/react-ui#<sha>) no longer builddist/. - Per-module subpaths are gone: import from
@corbits/react-ui(import { Button } from "@corbits/react-ui"). ui/dialog,ui/command-palette,ui/sidebar-search,ui/mic-permission-dialog,ui/menu,ui/tooltipandui/toastare subpath-only, and their peers are optional:import { Toaster, toast } from "@corbits/react-ui/ui/toast". Install the peer for each subpath you keep.lib/csv,lib/url,lib/utils,lib/workflow-registryandui/chat-dock-timing(removed in 0.4) are internal. ReplacecnwithtwMerge(clsx(...)).- Stateful components ship
"use client". Client wrappers you wrote for them still work, and can go. - 84 unused modules are removed,
ui/commandis removed,ActivityBlockis renamedReasoningBlock(removed in 0.4; useReasoning, see Upgrading from 0.3), andAuthLayoutno longer rendersDitherCanvasby default. - With neither
.darknor.lighton the root, the theme follows the OS. Hosts that toggle.darkmust add.lightfor an explicit light choice, or mountThemeProvider.
LGPL-2.1-only. See LICENSE.