Repository navigation
Description
react-ui gets a new chat component family. Every part is stateless with respect to data: content and events come in as props and callbacks, and the parts compose under a Conversation parent that owns scrolling. Each part ships stories with sample data, unit tests and, where a unit test can't reach, a browser spec. Once the new family is complete, the old chat family (ChatThread, ChatComposer, MessageBubble, ApprovalCard, ToolBlock and the modules built on them) is deleted, so there is exactly one way to build chat UI in react-ui. The change ships as 0.4.0.
Tasks
Each task is one issue and one PR. A task depends only on tasks in earlier levels.
Level 1: contract and foundations
- Add the conversation data contract, its helpers and the attention wash #95
- Add Conversation and its scrolling #99
- Add PromptInput #102
- Add Panel #106
Level 2: samples and extensions
- Add sample conversations for the chat stories #96
- Add the PromptInput toolbar #103
- Add tabs for the Panel #107
Level 3: families
- Add Tool, ToolApproval, ToolGroup, Reasoning and Activity #97
- Add the Message family #98
- Add useHistoryWindow so long chats load without jumping #100
- Add ConversationMap #101
- Add Attachments and the file drop zone #104
- Add MediaViewer, generated images and sources #105
Level 4: threads
Level 5: the whole chat
Level 6: cutover
Level 7: release
Build plan
Requirements
Contract (R1–R4)
- R1
src/lib/conversation.tsis the canonical message model and part union. Until the old family is deleted it reuses the members ofsrc/lib/chat-parts.ts(text, reasoning, block, file, event) andToolTraceStatus; at the cutover those definitions move into it unchanged andchat-parts.tsis deleted. It adds a tool part carrying its approval record,PartToolBatch(calls that ran at the same time), generated images, sources, message statusstreaming, a replies summary and reactions. TheConversation*names (ConversationMessage,ConversationPart,ConversationMessageStatus, …) are the permanent public names. No AI SDK or Interchange types. - R2 A tool part that waited on a person carries an approval record shaped like Interchange's:
id,status(pending | approved | rejected | timeout | expired),scope(once | always | null) andtimeoutAt(string | null). The approval is its own record beside the tool call, never folded into the tool's status. The record is authoritative for the approval UI and for folding: a part whose record ispendingis pending approval whatever itsToolTraceStatus; a part with no record falls back toToolTraceStatus === "approval-requested".ToolTraceStatusdescribes only the call's execution. - R3 Pure, exported, unit-tested helpers:
groupActivityandsummarizeActivity(thinking and tool steps into one fold with a "Thought for 4s · 3 steps" line),mediaKindOf(image, video, audio, pdf, text, other from name and media type) andcheckAttachments(count, size and duplicate limits). - R4 A new
--attention-washtoken, derived withcolor-mixfrom--primarythe waytheme.cssderives its existing 10% primary wash, so every theme follows the orange; mapped as--color-attention-wash. The orange itself stays--primary.
Rules every component follows (R5–R12)
- R5 Stateless with respect to data: content and events come in as props and callbacks. UI state a parent might own (open, selected tab, width, draft) is controllable through
useControllableState(value/defaultValue/onChange). - R6 Compound parts with a
data-sloton each, a ref on the root (React 19 ref-as-prop), and"use client"exactly whereAGENTS.mdrequires it. - R7 Markdown is rendered only through a
renderprop. No markdown, syntax-highlighting or AI SDK dependency. - R8 No new dependency or peer. No import of an optional-peer module (
ui/menu,ui/dialog,ui/tooltip,ui/toast), so every new module can live in the root barrel. - R9 Until the old family is deleted, modules that existed before this work keep their behaviour and signatures, and no new name collides with an existing export. A module added by an earlier task may be extended by a later one when that task says so.
- R10 All styling goes through theme tokens: colours, shadows, radii and borders from
--*variables (directly or through Tailwind's token utilities), spacing from Tailwind's scale. No literal colour, shadow or arbitrary spacing value in a component, so a consumer can retheme by overriding variables. - R11 Accessible: full keyboard operation, visible focus that returns to the opener, an accessible name on every control, colour never the only signal,
prefers-reduced-motionrespected, and no serious or critical axe violation in light or dark. - R12 Every component ships a Storybook story for each meaningful state and unit tests for its logic; interaction a unit test can't reach is covered by a Playwright spec. Scroll positions are asserted to within 1px.
Agent steps (R13–R15)
- R13
Tool,ToolHeader,ToolInput,ToolOutputrender everyToolTraceStatus, with input and output rendered through a callerrenderprop. - R14
ToolApprovalrenders a tool whose approval is pending with Approve, Approve always and Deny, reportingonDecide({ outcome: "approved" | "rejected", scope: "once" | "always" }), in a new design (not the oldApprovalCard). A resolved approval renders from the record itself: approved once or always, denied, timed out or expired. - R15
ToolGroup(aPartToolBatch),Reasoning(thinking with its duration) andActivity(folds them, closed summary line, shows the latest 10 with a control for the rest). Open state is controllable.
Messages (R16–R19)
- R16
MessagewithMessageAvatar,MessageHeader,MessageContent(which takes the markdownrenderprop: one slot, no separate response part) andMessageFooter; own messages in a bubble; avatars and names can be left out. - R17
MessageFoldfolds an answer past a length threshold and keeps the reader's place when folding from inside it. - R18
MessageActions/MessageActionwith extras in an overflow menu,MessageReactions,MessageReplies(count and last reply time), and auseCopyhook (copy with a short-lived copied state, timer cleaned up). - R19 A
sendingmessage shows that it is on its way; afailedone shows the error and a retry control that callsonRetry.
Conversation and scrolling (R20–R24)
- R20
Conversationowns the scroll container and shares it throughuseConversation():scrollToItem,scrollToEnd,atBottom, an item registry (ConversationItemregisters its element by id) andcaptureAnchor()/restoreAnchor(). It never renders messages itself. - R21 Scroll policy is a prop,
scrollPolicy:"settle"(a sent message settles at the top of the view, the reply writes in below it, space is reserved below, streaming never moves the view) or"stick"(the view follows new content while the reader is at the bottom). In both, layout shifts never scroll and only the reader's own input breaks a follow; jumps are one smooth glide, instant under reduced motion;ConversationScrollButtonappears when the latest content is out of view. - R22 The anchor algorithm is specified and implemented once, in
src/lib/conversation-scroll.ts: capture records the topmost visible item and its offset from the container's top; after content changes above it, and on eachResizeObservercallback until released, the container scrolls by the difference; when the anchor item is trimmed, the next captured candidate below it takes over; browser scroll anchoring is off on the container (overflow-anchor: none). - R23 Scroll-derived state updates at most once per animation frame; the item registry lives in refs with a subscription, not in context state; the context value keeps its identity across scrolls, so streaming and scrolling re-render only what changed.
- R24
ConversationDivider(a day or label divider) andConversationNotice(a one-line system event, e.g. from aPartEvent).
History and map (R25–R28)
- R25
useHistoryWindowkeeps a window of the latest items, loads more near the top, freezes while the reader is scrolled up and trims back when they return to the bottom; the item in view moves by no more than 1px when older items load. - R26 It works with history the host pages in asynchronously (
hasEarlier,onLoadEarlier), andConversationEarliershows the loading marker. - R27 The windowing ceiling is written down beside the hook: why windowing rather than virtualization (native scrolling, find-in-page and focus within the window, variable heights with nothing to measure), the conversation size it is designed for, and when a host should page from the server instead.
- R28
ConversationMapshows one tick per turn (faint, on screen, current), previews on hover, jumps on click with the same glide, and is fully keyboard reachable;useReadingPositionreports the current and on-screen turns.
Prompt input (R29–R32)
- R29
PromptInputis fully controlled (value,onValueChange,onSubmit); every feature is opt-in by including its part. - R30
PromptInputTextareais a native textarea over anaria-hiddenmirror that draws highlighted@mentionand/commandtokens beneath the transparent text, with scroll kept in sync; it grows with content through CSSfield-sizingwhere supported and a measured fallback elsewhere; it never submits during IME composition. - R31
PromptInputSuggestionsdrives the token list from the keyboard throughusePromptInput;PromptInputSubmitturns Send into Stop while busy. - R32 Toolbar parts:
PromptInputToolbar,PromptInputAddMenu,PromptInputButton,PromptInputModelSelect,PromptInputModeSelect,PromptInputFloat,PromptInputContext,PromptInputNote.
Attachments and media (R33–R38)
- R33
Attachmentsin three variants (stack above the input, tiles in a message, list) withAttachment,AttachmentPreview,AttachmentInfo,AttachmentRemove,AttachmentMore. - R34
useAttachmentsappliescheckAttachments, builds previews and revokes every object URL it creates; the host owns the list. - R35
useFileDropandAttachmentDropZoneaccept files and whole folders and stop a stray drop from navigating the page. - R36
GeneratedImages,GeneratedImage,GeneratedImagePlaceholderandGeneratedImageActions. While generating, a tile holds the final size under the brand's dither grain with the elapsed time beside the label; an image made while watched is revealed by that grain, an image already there loads plainly; a failed image shows a message and a retry callback. The grain's colours come from existing tokens: ground--muted(or--card), field--foreground, band--primary. - R37
SourcesandSourcelist citations. - R38
MediaVieweron the native<dialog>element, with arrow keys between items and focus returned to the opener;useMediaViewerholds which item is open.
Panel, tabs and threads (R39–R42)
- R39
Panelresizes fromPanelHandleby pointer (no React render while dragging), by arrow keys, and resets on double-click or Enter; the main area keeps a minimum width; narrow screens get an overlay.PanelContentwithkeepMountedkeeps a hidden pane's scroll position and typed text. - R40
PanelTabs/PanelTab/PanelTabAdd: select by click, close by ×, middle-click or Delete, reorder by drag and by Ctrl/Cmd+Shift+Arrow, move between tabs by Arrow/Home/End; a long row keeps the active tab in view. Logic lives inuseTabsanduseTabDrag. Separate from the existingTabs. - R41
ConversationThreadrenders a reply conversation (a thread) headed by its parent message, with the same parts as the main conversation, inside a panel tab with its ownPromptInput. - R42 One story composes the whole chat from sample data: conversation, messages with agent steps, images, prompt input with attachments, the map, and the panel with a thread tab.
Hard cutover and release (R43–R44)
- R43 The old chat family is deleted, not deprecated:
chat-thread,chat-composer,chat-input,chat-panel,chat-dock-timing,message-bubble,approval-card,tool-block,tool-narrative,reasoning-block,src/lib/chat-message.ts, and the modules built only on them (agent-turn,parts-renderer,quick-reply-chips), with their stories, unit tests, browser specs and known-violation entries.chat-parts.tsis folded intoconversation.ts. The README no longer names the old family. - R44 The release that carries the cutover is 0.4.0, cut by the publisher after the cutover has merged and downstream consumers have migrated on a canary. Its notes, a new "Upgrading from 0.3" section in the README, list every removed export and what replaced it.
Decisions
- Storybook 10 is the story host (Replace Ladle with Storybook as the component workbench #89, build(deps): replace ladle with storybook 10 as the component workbench #88). Stories are plain CSF in
stories/chat/. The axe sweep ine2e/stories-a11y.spec.tsruns over the static Storybook build in light and dark, with known failures ine2e/stories-a11y.known.json. - Names are final. The main family keeps the
Conversationprefix, and theConversation*types are the permanent public names, not aliases. The reply conversation isConversationThread. The markdownrenderprop lives onMessageContent; there is noMessageResponse. - Approvals follow Interchange's record. An approval is its own record beside the tool call. Decisions are approved or rejected, once or always; timeout and expiry are rendered outcomes. "Ask every time" is the absence of a standing grant, not a decision. The record, not
ToolTraceStatus, drives the approval UI and folding (R2). - Scroll policy is a prop:
settleorstick. - One new token. The orange stays
--primary; the only new token is--attention-wash. - The prompt textarea uses an overlay mirror, not
contenteditable, so it keeps native editing, undo and IME behaviour. - Generated images form under the brand's dither grain and are revealed by it, coloured from existing tokens.
- Hard cutover, no deprecation period. The old family is deleted and shipped as 0.4.0 (pre-1.0, so a minor bump carries the removal). The release notes live in the README as "Upgrading from 0.3", following "Upgrading from 0.1"; there is no CHANGELOG.
- Composition tasks only compose. Add ConversationThread #108 and Add a story of the whole chat #109 change no family module; a gap they find is fixed in the owning family's PR first.
Out of scope
- A diff view and a changes tab.
- Live tool progress and threads within threads.
- A standing "deny always" button: Interchange supports
scope: "always"on reject, but the first version offers Approve, Approve always and Deny. - Fetching data, adapters for any particular host, and runtime validation of the contract: data arrives as props, and parsing is the host's job.
Merge order and stacking
- Each task is one PR. PRs stack, each based on the one before it, in this order: 01, 05, 08, 12, 02, 09, 13, 03, 04, 06, 07, 10, 11, 14, 15, 16. The first targets the feature branch
feat/conversation(to be created by a maintainer);mainreceives the whole family when 0.4.0 is cut (Cut react-ui 0.4.0 #111). src/index.tsande2e/fixtures.tsxare shared: add entries at the end of the relevant block, never reorder or remove (until Remove the old chat components #110).- E2E fixture scenarios build their data inline from the package's public exports; they can't import
stories/. - Commits follow
CONTRIBUTING.md(Conventional Commits, e.g.feat(conversation): …). Comments and docs name files and symbols only.
Releases
- The publisher publishes canary prereleases (npm
canarytag) from the feature branch when a consumer needs to validate the new family. - The stable 0.4.0 is cut last (Cut react-ui 0.4.0 #111), after Remove the old chat components #110 has merged and downstream consumers have migrated on a canary.
Verification
Every PR passes:
bun run check # typecheck, lint, format, unit tests
bun run build
bun run test:e2e # Playwright specs and the Storybook axe sweep, light and dark
bun run dep-guard
bun run contrast-testA task is done when its listed tests pass and its stories have no serious or critical axe violation in light or dark.
Activity