Skip to content

Replace the chat components with a stateless Conversation family #94

Closed

Description

@pratikbuilds

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

Level 2: samples and extensions

Level 3: families

Level 4: threads

Level 5: the whole chat

Level 6: cutover

Level 7: release

Build plan

Requirements

Contract (R1–R4)
  • R1 src/lib/conversation.ts is the canonical message model and part union. Until the old family is deleted it reuses the members of src/lib/chat-parts.ts (text, reasoning, block, file, event) and ToolTraceStatus; at the cutover those definitions move into it unchanged and chat-parts.ts is deleted. It adds a tool part carrying its approval record, PartToolBatch (calls that ran at the same time), generated images, sources, message status streaming, a replies summary and reactions. The Conversation* 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) and timeoutAt (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 is pending is pending approval whatever its ToolTraceStatus; a part with no record falls back to ToolTraceStatus === "approval-requested". ToolTraceStatus describes only the call's execution.
  • R3 Pure, exported, unit-tested helpers: groupActivity and summarizeActivity (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) and checkAttachments (count, size and duplicate limits).
  • R4 A new --attention-wash token, derived with color-mix from --primary the way theme.css derives 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-slot on each, a ref on the root (React 19 ref-as-prop), and "use client" exactly where AGENTS.md requires it.
  • R7 Markdown is rendered only through a render prop. 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-motion respected, 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, ToolOutput render every ToolTraceStatus, with input and output rendered through a caller render prop.
  • R14 ToolApproval renders a tool whose approval is pending with Approve, Approve always and Deny, reporting onDecide({ outcome: "approved" | "rejected", scope: "once" | "always" }), in a new design (not the old ApprovalCard). A resolved approval renders from the record itself: approved once or always, denied, timed out or expired.
  • R15 ToolGroup (a PartToolBatch), Reasoning (thinking with its duration) and Activity (folds them, closed summary line, shows the latest 10 with a control for the rest). Open state is controllable.
Messages (R16–R19)
  • R16 Message with MessageAvatar, MessageHeader, MessageContent (which takes the markdown render prop: one slot, no separate response part) and MessageFooter; own messages in a bubble; avatars and names can be left out.
  • R17 MessageFold folds an answer past a length threshold and keeps the reader's place when folding from inside it.
  • R18 MessageActions / MessageAction with extras in an overflow menu, MessageReactions, MessageReplies (count and last reply time), and a useCopy hook (copy with a short-lived copied state, timer cleaned up).
  • R19 A sending message shows that it is on its way; a failed one shows the error and a retry control that calls onRetry.
Conversation and scrolling (R20–R24)
  • R20 Conversation owns the scroll container and shares it through useConversation(): scrollToItem, scrollToEnd, atBottom, an item registry (ConversationItem registers its element by id) and captureAnchor() / 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; ConversationScrollButton appears 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 each ResizeObserver callback 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) and ConversationNotice (a one-line system event, e.g. from a PartEvent).
History and map (R25–R28)
  • R25 useHistoryWindow keeps 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), and ConversationEarlier shows 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 ConversationMap shows one tick per turn (faint, on screen, current), previews on hover, jumps on click with the same glide, and is fully keyboard reachable; useReadingPosition reports the current and on-screen turns.
Prompt input (R29–R32)
  • R29 PromptInput is fully controlled (value, onValueChange, onSubmit); every feature is opt-in by including its part.
  • R30 PromptInputTextarea is a native textarea over an aria-hidden mirror that draws highlighted @mention and /command tokens beneath the transparent text, with scroll kept in sync; it grows with content through CSS field-sizing where supported and a measured fallback elsewhere; it never submits during IME composition.
  • R31 PromptInputSuggestions drives the token list from the keyboard through usePromptInput; PromptInputSubmit turns Send into Stop while busy.
  • R32 Toolbar parts: PromptInputToolbar, PromptInputAddMenu, PromptInputButton, PromptInputModelSelect, PromptInputModeSelect, PromptInputFloat, PromptInputContext, PromptInputNote.
Attachments and media (R33–R38)
  • R33 Attachments in three variants (stack above the input, tiles in a message, list) with Attachment, AttachmentPreview, AttachmentInfo, AttachmentRemove, AttachmentMore.
  • R34 useAttachments applies checkAttachments, builds previews and revokes every object URL it creates; the host owns the list.
  • R35 useFileDrop and AttachmentDropZone accept files and whole folders and stop a stray drop from navigating the page.
  • R36 GeneratedImages, GeneratedImage, GeneratedImagePlaceholder and GeneratedImageActions. 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 Sources and Source list citations.
  • R38 MediaViewer on the native <dialog> element, with arrow keys between items and focus returned to the opener; useMediaViewer holds which item is open.
Panel, tabs and threads (R39–R42)
  • R39 Panel resizes from PanelHandle by 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. PanelContent with keepMounted keeps 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 in useTabs and useTabDrag. Separate from the existing Tabs.
  • R41 ConversationThread renders a reply conversation (a thread) headed by its parent message, with the same parts as the main conversation, inside a panel tab with its own PromptInput.
  • 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.ts is folded into conversation.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 in e2e/stories-a11y.spec.ts runs over the static Storybook build in light and dark, with known failures in e2e/stories-a11y.known.json.
  • Names are final. The main family keeps the Conversation prefix, and the Conversation* types are the permanent public names, not aliases. The reply conversation is ConversationThread. The markdown render prop lives on MessageContent; there is no MessageResponse.
  • 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: settle or stick.
  • 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); main receives the whole family when 0.4.0 is cut (Cut react-ui 0.4.0 #111).
  • src/index.ts and e2e/fixtures.tsx are 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

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-test

A task is done when its listed tests pass and its stories have no serious or critical axe violation in light or dark.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions