From c07985506aa2d690a9670bacb1e9e0f0f078efee Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Sat, 18 Jul 2026 12:13:02 +0200 Subject: [PATCH 01/23] =?UTF-8?q?RFC:=20veryfront/chat=20+=20attachments?= =?UTF-8?q?=20=E2=80=94=20proposed=20API=20shape=20(docs=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full proposed public surface of veryfront/chat, shaped before implementation: - docs/architecture/29-chat-api-shape.md — the RFC (conventions, hard rules, merge semantics, data-* contract, generics, three layers, adoption journey, prior-art audit, conformance/testing spec, migration + breaking ledger) - docs/architecture/29-chat-api-shape/ — user-facing docs page for every piece: 25 components, 34 hooks, helpers, providers No code. Enables #2973 #2974 #2975 #2976 #2977 #2978; relates to #2940. --- docs/architecture/29-chat-api-shape.md | 983 ++++++++++++++++++ docs/architecture/29-chat-api-shape/README.md | 124 +++ .../components/agent-card.md | 88 ++ .../components/agent-picker.md | 140 +++ .../29-chat-api-shape/components/app-shell.md | 62 ++ .../components/attachment-pill.md | 120 +++ .../components/attachments-panel.md | 169 +++ .../components/branch-picker.md | 106 ++ .../components/chat-actions.md | 114 ++ .../components/chat-agent-picker.md | 120 +++ .../components/chat-empty-state.md | 114 ++ .../components/chat-error-boundary.md | 93 ++ .../components/chat-input.md | 152 +++ .../components/chat-message-list.md | 122 +++ .../29-chat-api-shape/components/chat-root.md | 113 ++ .../components/chat-sidebar.md | 171 +++ .../components/chat-theme-scope.md | 87 ++ .../29-chat-api-shape/components/chat.md | 142 +++ .../components/inline-citation.md | 113 ++ .../29-chat-api-shape/components/markdown.md | 55 + .../components/message-action-bar.md | 123 +++ .../29-chat-api-shape/components/message.md | 188 ++++ .../components/model-selector.md | 104 ++ .../29-chat-api-shape/components/reasoning.md | 109 ++ .../29-chat-api-shape/components/sources.md | 110 ++ .../components/step-indicator.md | 101 ++ .../29-chat-api-shape/components/tool-call.md | 145 +++ .../architecture/29-chat-api-shape/helpers.md | 33 + .../29-chat-api-shape/hooks/use-agent-card.md | 53 + .../hooks/use-agent-metadata.md | 68 ++ .../hooks/use-agent-picker.md | 83 ++ .../29-chat-api-shape/hooks/use-agent.md | 60 ++ .../29-chat-api-shape/hooks/use-agents.md | 74 ++ .../29-chat-api-shape/hooks/use-app-shell.md | 24 + .../hooks/use-attachment-pill.md | 80 ++ .../hooks/use-attachments-panel.md | 74 ++ .../hooks/use-attachments.md | 105 ++ .../hooks/use-chat-actions.md | 73 ++ .../hooks/use-chat-context.md | 73 ++ .../hooks/use-chat-error-handler.md | 77 ++ .../hooks/use-chat-input-context.md | 77 ++ .../29-chat-api-shape/hooks/use-chat-input.md | 121 +++ .../hooks/use-chat-scroll.md | 101 ++ .../29-chat-api-shape/hooks/use-chat.md | 122 +++ .../29-chat-api-shape/hooks/use-clipboard.md | 74 ++ .../29-chat-api-shape/hooks/use-color-mode.md | 28 + .../29-chat-api-shape/hooks/use-completion.md | 45 + .../hooks/use-conversation-chat.md | 95 ++ .../hooks/use-conversation.md | 73 ++ .../hooks/use-conversations-context.md | 72 ++ .../hooks/use-conversations.md | 139 +++ .../hooks/use-message-branches.md | 75 ++ .../hooks/use-message-context.md | 99 ++ .../hooks/use-message-parts.md | 78 ++ .../hooks/use-model-selector.md | 55 + .../29-chat-api-shape/hooks/use-reasoning.md | 85 ++ .../29-chat-api-shape/hooks/use-sources.md | 83 ++ .../hooks/use-step-indicator.md | 76 ++ .../29-chat-api-shape/hooks/use-streaming.md | 45 + .../29-chat-api-shape/hooks/use-tool-call.md | 113 ++ .../29-chat-api-shape/hooks/use-upload.md | 102 ++ .../hooks/use-voice-input.md | 101 ++ .../29-chat-api-shape/providers.md | 40 + 63 files changed, 6844 insertions(+) create mode 100644 docs/architecture/29-chat-api-shape.md create mode 100644 docs/architecture/29-chat-api-shape/README.md create mode 100644 docs/architecture/29-chat-api-shape/components/agent-card.md create mode 100644 docs/architecture/29-chat-api-shape/components/agent-picker.md create mode 100644 docs/architecture/29-chat-api-shape/components/app-shell.md create mode 100644 docs/architecture/29-chat-api-shape/components/attachment-pill.md create mode 100644 docs/architecture/29-chat-api-shape/components/attachments-panel.md create mode 100644 docs/architecture/29-chat-api-shape/components/branch-picker.md create mode 100644 docs/architecture/29-chat-api-shape/components/chat-actions.md create mode 100644 docs/architecture/29-chat-api-shape/components/chat-agent-picker.md create mode 100644 docs/architecture/29-chat-api-shape/components/chat-empty-state.md create mode 100644 docs/architecture/29-chat-api-shape/components/chat-error-boundary.md create mode 100644 docs/architecture/29-chat-api-shape/components/chat-input.md create mode 100644 docs/architecture/29-chat-api-shape/components/chat-message-list.md create mode 100644 docs/architecture/29-chat-api-shape/components/chat-root.md create mode 100644 docs/architecture/29-chat-api-shape/components/chat-sidebar.md create mode 100644 docs/architecture/29-chat-api-shape/components/chat-theme-scope.md create mode 100644 docs/architecture/29-chat-api-shape/components/chat.md create mode 100644 docs/architecture/29-chat-api-shape/components/inline-citation.md create mode 100644 docs/architecture/29-chat-api-shape/components/markdown.md create mode 100644 docs/architecture/29-chat-api-shape/components/message-action-bar.md create mode 100644 docs/architecture/29-chat-api-shape/components/message.md create mode 100644 docs/architecture/29-chat-api-shape/components/model-selector.md create mode 100644 docs/architecture/29-chat-api-shape/components/reasoning.md create mode 100644 docs/architecture/29-chat-api-shape/components/sources.md create mode 100644 docs/architecture/29-chat-api-shape/components/step-indicator.md create mode 100644 docs/architecture/29-chat-api-shape/components/tool-call.md create mode 100644 docs/architecture/29-chat-api-shape/helpers.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-agent-card.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-agent-metadata.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-agent-picker.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-agent.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-agents.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-app-shell.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-attachment-pill.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-attachments-panel.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-attachments.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-chat-actions.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-chat-context.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-chat-error-handler.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-chat-input-context.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-chat-input.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-chat-scroll.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-chat.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-clipboard.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-color-mode.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-completion.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-conversation-chat.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-conversation.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-conversations-context.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-conversations.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-message-branches.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-message-context.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-message-parts.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-model-selector.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-reasoning.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-sources.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-step-indicator.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-streaming.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-tool-call.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-upload.md create mode 100644 docs/architecture/29-chat-api-shape/hooks/use-voice-input.md create mode 100644 docs/architecture/29-chat-api-shape/providers.md diff --git a/docs/architecture/29-chat-api-shape.md b/docs/architecture/29-chat-api-shape.md new file mode 100644 index 0000000000..7500afa2ad --- /dev/null +++ b/docs/architecture/29-chat-api-shape.md @@ -0,0 +1,983 @@ +# RFC: `veryfront/chat` API shape — a reset + +> **Per-piece documentation:** every proposed component and hook has a user-facing docs page under [`29-chat-api-shape/`](./29-chat-api-shape/README.md) — 25 components, 34 hooks, helpers, providers. + +**Status:** draft for discussion. **North star: `veryfront/ui`.** Chat should be a +**regular component library built exactly like `veryfront/ui`** — each component a +single, fully-controllable node. `veryfront/ui` already nails this (it's a Radix-API +fork + `cva`, `asChild`, `extends HTMLAttributes`); `veryfront/chat` should follow +the same convention and **build on those primitives**. No installer, no copied +source, no headless-only detour — just clean components you fully control from the +import. **Goal:** every node and every attribute is the consumer's. + +## The `veryfront/ui` convention chat must adopt + +This is *already how `ui/button.tsx` and `ui/dropdown-menu.tsx` are written* — apply +it to every chat component: + +1. **`extends React.HTMLAttributes`** (the right element type) and **`{...props}` + onto the single node.** That one line is what makes *every* native attribute the + consumer's: `className`, `style`, `data-*`, `aria-*`, `onClick`, `id`, `ref`. +2. **`asChild`** (the `ui` `Slot`) on every component — swap `div`→`p`, merge onto + your own element. +3. **`cva` variants + `className` merge** (`cx`) for styling — same tokens as `ui`. +4. **`ref` as a prop** (React 19), like `ui`. +5. **Compound + single node** — `DropdownMenu` is the template: `Root/Trigger/ + Content/Item`, each one node, `Trigger` `asChild`. + +```tsx +// a chat leaf, written like a ui component: +export interface ChatInputSubmitProps extends React.ButtonHTMLAttributes { + asChild?: boolean +} +export function ChatInputSubmit({ asChild, className, ...props }: ChatInputSubmitProps) { + const chatInput = useChatInputContext() // behaviour from the hook + const Comp = asChild ? Slot : Button + // consumer props go INTO the getter — handlers compose, className merges (rule 9) + return +} +``` + +Now the consumer gets everything for free: `` — or swaps the element entirely. + +--- + +## The one principle + +> **The library owns behaviour and state (hooks). The consumer owns markup (every +> div, every class).** + +Everything follows from this. React Aria proves you can do it **from a plain +package import**: hooks return **prop getters** (props you spread onto elements you +render) and primitives take **`asChild`** (merge behaviour onto your element). No +copying source, no CLI — **every node and every attribute is already in the +consumer's hands** through the API. The thing we keep tripping on — a component +that renders DOM you can't reach — simply never exists. + +**Why a per-node `className` prop is not the fix.** Customizing a node means owning +the *element*, not decorating it — the consumer may want to **change the tag** +(`div` → `p`, `button` → `a`), **add `data-*` / `aria-*` attributes**, wrap it, or +change its children. A `className` prop hands you none of that; it just lets you +paint a box the library still owns. The only real answer is to **own the element** +— via `asChild` or prop getters. So the requirement isn't "expose more class +hooks"; it's "never render an element the consumer can't supply themselves." + +## Hard rules (what "clean" means here) + +1. **No `xxxClassName` / `xxxProps` bags. Ever.** One `className` targets one node. +2. **No hidden DOM.** A primitive renders **one** element (or merges onto yours via + `asChild`). Structure = you compose primitives + your own divs. There is never + an "inner div you can't class" — because you rendered it. +3. **`asChild` everywhere** (Radix Slot). Any primitive can merge its behaviour + + a11y onto *your* element, so you pick the tag and own all classes. +4. **Prop getters for full headless.** Hooks return `getXProps()` you spread — you + render the elements. (React Aria model.) +5. **Config lives on the component that uses it.** `models` goes on the model + selector, not the root. Root context is opt-in (Layer 2), never required. +6. **Scoped context, not app-wide magic.** A `` shares state with *its* + children only; it is not a global store the whole tree reads implicitly. +7. **Style state via `data-*`, not props.** `data-streaming`, `data-active`, + `data-loading` — style with CSS/Tailwind variants, no boolean props. (React + Aria model.) +8. **Backward compatible / additive.** The current styled components stay and get + re-implemented *on top of* the new layers; nothing is ripped out. +9. **Merging is exact, or the contract is a lie.** Handlers compose (consumer + first, `preventDefault` cancels internal), classes merge Tailwind-aware + (consumer wins), refs compose, getters take overrides. See *Merge semantics* — + normative, conformance-tested. + +--- + +## Cross-cutting contracts + +These apply to every piece; reference blocks cite them instead of restating them. + +### Merge semantics (normative) + +1. **Event handlers compose, never clobber.** Consumer handler runs first; if it + calls `event.preventDefault()`, the internal handler is skipped (Radix + `composeEventHandlers` semantics). A naive `{...getXProps()} {...props}` spread + is **not** the pattern: L2 components compose internally; L3 consumers pass + their props *into* the getter. +2. **`getXProps(overrides?)`** — every prop getter accepts the consumer's props + and returns the merged result: handlers chained per rule 1, `className` merged + per rule 3, `style` shallow-merged consumer-wins, `id`/`aria-*` consumer-wins. +3. **`className` merges Tailwind-aware** (`cx` = clsx + tailwind-merge): consumer + classes beat variant defaults (`p-4` overrides a default `p-2`). +4. **Refs compose.** The `ref` prop and internal refs are merged; none dropped. +5. **`asChild` applies the same single merged result** onto the child element per + rules 1–4; getters are never double-applied. +6. **`mergeProps` is public API** — the exact merge used internally, exported for + L3 consumers composing several hooks onto one element (React Aria model). + +### `data-*` state contract + +State is exposed as data attributes (CSS variants + test selectors), never boolean +styling props. Global vocabulary (each block lists which apply): + +| Attribute | On | Meaning | +| --- | --- | --- | +| `data-status="ready\|submitted\|streaming\|error"` | `ChatRoot` · `ChatInput.Root` · `.Submit` | session status (mirrors `useChat().status`) | +| `data-streaming` | `Message.Root` · `.Text` · `Reasoning.Root` | this content is streaming now | +| `data-role="user\|assistant\|system"` | `Message.Root` | author | +| `data-agent-id=""` | `Message.Root` | producing agent (per-message — multi-agent ready) | +| `data-state="input-streaming\|input-available\|output-available\|output-error\|approval-requested\|approval-responded\|output-denied"` | `ToolCall.Root` | tool lifecycle incl. human-in-the-loop approval | +| `data-open` | disclosure + popper roots/triggers (`ToolCall`, `Reasoning`, `Sources`, `ChatInput.Model`, `AgentPicker.Trigger`, `ChatActions.Trigger`, `InlineCitation`) | expanded | +| `data-state="pending\|active\|complete"` | `StepIndicator` steps | step lifecycle | +| `data-active` | `ChatSidebar.Item` · picker items · `AttachmentsPanel.Item` · `BranchPicker` | selected | +| `data-loading` | async containers (`ChatMessageList`, `AttachmentsPanel.Root`, `ChatSidebar.Root`) | fetch in flight | +| `data-invalid` | `AgentPicker` inputs | validation failed (kept from today) | +| `data-error` | `Message.Root` · attachment rows | errored | +| `data-upload-state="idle\|uploading\|processing\|error\|done"` | `AttachmentPill.Root` · `AttachmentsPanel.Item` | upload lifecycle | +| `data-empty` | list containers | zero items | +| `data-editing` | `Message.Root` | edit composer active | +| `data-copied` | copy buttons | transient copied feedback | +| `data-dragging` | `ChatInput.Root` (drop target) | file drag-over | +| `data-compact` | `ChatInput.Root` | single-line/narrow layout | +| `data-at-bottom` · `data-autoscrolling` · `data-scrollable` | `ChatMessageList` | scroll state — updated imperatively (no React re-render per scroll tick) | +| `data-floating` | `Message.Actions` | hidden-but-animatable (never unmount-to-hide) | +| `data-listening` | `ChatInput.Voice` | dictation active | +| `data-disabled` | any interactive leaf | disabled | + +### Prop getters — resolved + +L2 primitives are the 95% path. **Every stateful hook still exposes getters for +its interactive nodes, because the L2 components are implemented with them** — so +the two can never drift. Display-only leaves (`Message.Avatar`, `Sources.Pill` +label) need no getter: hook state + your element suffices. Each hook's reference +block lists its exact getters. + +### TypeScript generics (locked before v1 — retrofit would be breaking) + +- **Messages:** `ChatMessage` (AI SDK v5 + `UIMessage` shape). `useChat` preserves the type through + `useMessageParts`, `Message.Parts`' render prop, and helpers. +- **Tools:** `useToolCall` narrows per tool name (`part.type === + 'tool-…'`). The tools registry (below) is typed against `TTools` — a wrong + renderer signature is a compile error. +- **Data parts** flow typed through the same path; custom part renderers receive + the narrowed part type. + +### Part rendering & the tools registry (per-piece ejection at every layer) + +The most common customization — "render *this* tool/part my way" — must never +force ejecting the tree: + +- **L1:** `` +- **L2:** ``, or per-message `{(part) => …}` +- **L3:** `useMessageParts()` + your own switch. + +Resolution order (assistant-ui model): inline render fn → registry by name → +default renderer. Registry values are components receiving the typed part. + +### The markdown exception (the only sanctioned multi-node primitive) + +`Markdown` (and therefore `Message.Text`) necessarily renders a node tree — the +one documented exception to the node contract, tamed by: + +- **`components={{ code, a, img, table, … }}` override map** (react-markdown + convention): every emitted element type is replaceable — still no unreachable + node. `RichCodeBlock` is the default `code` renderer; swap it via the map. +- **Streaming is owned here** (streamdown model): incremental block parsing (only + the tail block re-renders per token), unterminated fence/emphasis repair, and + hardening via `allowedLinkPrefixes` / `allowedImagePrefixes`. +- **Inline citations** are an override slot (`components.citation`) rendering + footnote markers from source parts; default = numbered pills. + +### Scroll contract (`useChatScroll`, subsumes `useStickToBottom`) + +Transcript scrolling is a subsystem, not a boolean (per shadcn MessageScroller / +assistant-ui viewport): + +- **State:** `isAtBottom`, `isAutoScrolling`, `currentAnchorId`, + `visibleMessageIds` (opt-in subscription). +- **Actions:** `scrollToBottom()`, `scrollToMessage(id)`, `scrollToStart/End()`. +- **Behavior:** escape-on-scroll-up + resume threshold; `turnAnchor: + "bottom" | "top"` (ChatGPT-style user-turn-to-top); position restore on thread + switch; `preserveScrollOnPrepend` for paged history. +- **Leaves:** `ChatMessageList.ScrollButton` (inert + unfocusable at bottom). + +### Streaming a11y contract + +- `ChatMessageList.Content`: `role="log"`, `aria-relevant="additions"`, + `aria-busy` while streaming (no token-level SR spam); completion announced once + via a visually-hidden `role="status"` region. +- Errors render with `role="alert"`; decorative icons/shimmer are `aria-hidden`. +- `getFieldProps` guards IME composition (no CJK double-submit); `submitMode: + "enter" | "ctrlEnter" | "none"` on `useChatInput`. + +### State ownership (resolves the races) + +- **Input state has one owner: `useChatInput`** — controlled (`value`/`onChange`) + or uncontrolled; `useChat` does **not** expose `input`/`handleInputChange`. + Voice folds in via `useChatInput({ voice })` — no userland transcript weaving. +- **Streams are provider-scoped, not mount-scoped:** keyed by conversation id in + the conversations/chat context; switching threads neither aborts nor orphans an + in-flight stream, and it persists to the correct thread. `useConversationChat` + exposes `ready` — consumers never write their own thread-ready guard. +- **Editing reuses the composer:** `ChatInput` inside a `Message` *is* the edit + form (context-sensitive, assistant-ui model); `Message.Root` gets + `data-editing`; nearest provider wins — the explicit nested-context rule. +- **Context precedence everywhere:** explicit prop > nearest context > default. + +--- + +## Three layers, one source of truth + +Each layer is built from the one below. Pick your altitude. + +``` +L1 Preset (black box) +L2 Components (ui-style) +L3 Headless hooks const c = useChatInput(); +
+ +
+
model ↑
+ +
+ + + + + +``` + +Empty-thread variants of the transcript slot: + +```html + + +
+ …user bubble skeleton (h-8 w-48 self-end = right-aligned)… + …assistant row (avatar circle + name bar, then full-width text lines)… +
+ Loading messages... +
+ + +
+ …avatar (64px) ·

heading · description

· suggestions chip row… +

+``` + +## The public default composition + +Per the adoption journey, **the L1 default composition is public** — ejecting = paste it and edit. The exact L2 source lands with the implementation; this tree is derived faithfully from today's preset source with RFC names — **illustrative until implementation**: + +```tsx +// what renders — illustrative until implementation +function ChatDefault({ agentId, api, uploadApi, tools, labels, chat: controlled, children }) { + // App mode: self-driven session — seed + persist via nearest ConversationsProvider. + // Controlled mode: `chat` prop wins. + const conversation = useConversationChat({ agentId, api }) + const chat = controlled ?? conversation.chat + const upload = useUpload({ api: uploadApi }) // no uploadApi → files inline as base64 data: URLs + + return ( + {/* context only — zero nodes (#2973) */} + + s.isEmpty && !s.ready}> + {/* covers history/agent load — no hero flash */} + + s.isEmpty && s.ready}> + {/* agent-derived hero + typed suggestions */} + + !s.isEmpty}> + + {/* default map: one per turn */} + + + + {/* null-renders without a session error */} + {/* session from ChatRoot context */} + + + + + {/* Send↔Stop off data-status */} + + + {children} + + + ) +} +``` + +Today's source notes folded in: the idle hero is **opt-in today** (`emptyState` prop; app mode derives one from agent metadata) — whether the L1 default shows `Chat.Empty` or a blank canvas on an empty thread is carried over from that behavior (agent-derived hero in app mode). Inside a `ConversationsProvider`, switching threads re-seeds the session for the active conversation and holds `Chat.Skeleton` until its messages load. + +## Props + +Trimmed from today's 28 props to seven: + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `agentId` | `string` | — | App mode: fetches agent name/avatar/suggestions and scopes requests | +| `api` \| `transport` | `string \| { url, headers, credentials, fetch, body }` | `"/api/ag-ui"` | Endpoint or transport object — auth works without a custom client | +| `uploadApi?` | `string` | — | Durable upload endpoint (multipart `file` → `{ url }`); omitted → attachments inline as base64 `data:` URLs (today's behavior, kept) | +| `tools?` | `{ [name: string]: Component }` | — | Tools registry; resolution: inline render fn → registry by name → default renderer | +| `labels?` | object | built-ins | i18n overrides for built-in strings (L1 only — at L2/L3 the consumer owns all text) | +| `chat?` | `UseChatResult` | — | Controlled mode — bring your own `useChat()`; app-mode props are ignored | +| `children?` | `ReactNode` | default composition | Compose inside the preset (replace-vs-append semantics TBD, see Anatomy) | + +`asChild` is **not** listed for the preset: `` deliberately renders a tree, not one node — the node contract applies to each L2 part it is made of. Whether the preset keeps a `ref` (and to which node) is TBD. + +### Removed (today → where it went) + +Every today-only prop, with its replacement — this is the ledger a reviewer should judge: + +| Today's prop | Replacement | +| --- | --- | +| `initialMessages` · `onError` · `onUpdate` | `useConversationChat` options (L2); presence-resolved persistence lives there | +| `placeholder` | `labels`, or compose `ChatInput.Field placeholder` | +| `className` · `maxHeight` · `theme` | Deleted; string `ChatTheme` retired (ledger) — style the pasted composition / `ChatThemeScope` | +| `renderMessage` | **Deleted** (render-prop-config ban) — `tools` registry or `Message.Parts` composition | +| `suggestions` · `onSuggestionClick` · `onSuggestionSelect` | `ChatEmptyState` composition + `getAgentPromptSuggestionItems(agent)` (#2978) | +| `emptyState` · `initializing` · `skeleton` | `Chat.If` composition with `Chat.Empty` / `Chat.Skeleton` | +| `agent` (`ChatAgentInfo`) | Derived from `agentId` metadata; message identity is **per-message** (multi-agent decision) | +| `onSourceClick` | `Sources` / `InlineCitation` composition | +| `onAttach` · `onSelectAttachment` · `onDrop` · `attachAccept` · `attachments` · `onRemoveAttachment` | `useUpload` + `ChatInput` (`upload` prop); drop target via `getDropTargetProps` | +| `onFeedback` | `MessageFeedback` **cut from v1** (no backend endpoint) | +| `toolbarStart` | Compose children inside `ChatInput.Toolbar` | ## Parts -Every part renders **one** node, takes `asChild`, extends its node's `HTMLAttributes`, merges `className` (Tailwind-aware, consumer wins), and composes `ref`. +Every part is one node + `asChild` + `extends HTMLAttributes` + composed `ref` (the whole contract), except where noted. Each part is the same component as its standalone export — never a parallel implementation. + +### `Chat.Root` + +The scoped session provider (= [`ChatRoot`](./chat-root.md)). **Renders no node by default** (RFC — today it renders the container `
`; see that page's ledger). All session state enters here; every other part reads it from context. + +**Layout:** none (zero nodes); with `asChild`, your element — today's container is the outer flex column. + +| Prop | Type | Description | +| --- | --- | --- | +| `chat` | `UseChatResult` | The one shared session (#2973) | +| `asChild` | `boolean` | Opt into a node by merging onto your element | +| `children` | `ReactNode` | Subtree that reads the context | + +**State attributes (proposed):** `data-status="ready|submitted|streaming|error"` — only on a DOM node when `asChild` provides one. + +### `Chat.MessageList` + +The transcript (= [`ChatMessageList`](./chat-message-list.md)). One scroll container `
`; default content = `.Content` (the centered `role="log"` column mapping one [`Chat.Message`](./message.md) per turn) + `.ScrollButton`. + +**Layout:** in-flow flex child — `flex-1 min-h-0`, the only scrolling element; anchors the absolutely-positioned scroll button. + +| Prop | Type | Description | +| --- | --- | --- | +| `tools?` | registry | Per-tool renderers for the default map | +| `children?` | `ReactNode` | Replace the default `.Content`/`.ScrollButton` anatomy | +| `asChild` + native + `ref` | | Own the scroll container node | + +**State attributes (proposed):** `data-at-bottom` · `data-autoscrolling` · `data-scrollable` (imperative — no re-render per scroll tick) · `data-loading` · `data-empty`. + +### `Chat.Input` + +The composer (= [`ChatInput`](./chat-input.md)). **One `
`** + scoped context — the current hidden `max-w-[850px]` centering div is deleted; in the pasted composition that layout div is yours. Default content: `.Field` textarea + toolbar with `.Attach` / `.Model` / `.Submit` (Send↔Stop morph; `.Stop`/`.Send`/`.Voice` self-gate to `null` by state today). + +**Layout:** in-flow flex child, `shrink-0` (never collapses under a long transcript); the composer card is `relative` and doubles as the file-drop target. -| Part | Renders | State attributes | Description | +| Prop | Type | Description | +| --- | --- | --- | +| `chat?` · `upload?` · `voice?` · `value?/onChange?` · `submitMode?` | see [`ChatInput`](./chat-input.md) | Session falls back to `ChatRoot` context | +| `asChild` + native (`FormHTMLAttributes`) + `ref` | | Own the `` | + +**State attributes (proposed):** `data-status` · `data-dragging` · `data-compact`. + +### `Chat.Empty` + +The idle hero (= [`ChatEmptyState`](./chat-empty-state.md) preset). One `
`. Default content: agent `Avatar` (64px, image or initial) → `

` heading (agent name; today's fallback string `"What can I help with?"`) → optional description `

` → suggestion chip row (typed `{ label, prompt }[]` via `getAgentPromptSuggestionItems`, #2978 — selection hands back the *item*). **Renders only on an empty, resolved thread** (via `Chat.If` in the composition). + +**Layout:** fills the transcript slot (`flex-1`), centers its column of children on both axes. + +| Prop | Type | Description | +| --- | --- | --- | +| `asChild` + native + `ref` | | Own the node; children replace the default hero anatomy | + +Today's `icon?: ReactNode` prop falls to the **icon-slot ban** — compose `ChatEmptyState.Avatar` / children instead. Today's `title`/`description`/`suggestions`/`onSuggestion*`/`quickActions` props: derived from agent metadata in the default; compose `ChatEmptyState.*` for custom content. + +### `Chat.Skeleton` + +The loading placeholder. One `` node. Default content: alternating skeleton rows in the same `max-w-[850px]` column as the real list — right-aligned user bubbles (`self-end`) and assistant rows (avatar circle + name bar + text lines) — plus a visually-hidden "Loading messages..." for assistive tech. Rendered while the thread's history or agent metadata is still loading (so the hero never flashes first). + +**Layout:** fills the transcript slot (`flex-1 min-h-0`), overflow hidden — a stand-in with the exact column box of `.Content`. + +| Prop | Type | Description | +| --- | --- | --- | +| `asChild` + native + `ref` | | Own the node; children replace the default rows *(today: `className` only — the convention row is the proposed reshape)* | + +### `Chat.If` + +The selector conditional — **renders no node**; renders `children` when the selector passes, else `fallback`. + +**Layout:** none (no node) — children participate in the parent's flex flow directly. + +| Prop | Type | Default | Description | | --- | --- | --- | --- | -| `Chat.Root` | *no node by default* (`asChild` for a node) | `data-status` | Scoped session provider — see [`ChatRoot`](./chat-root.md) | -| `Chat.MessageList` | `

` (scroll container) | `data-at-bottom` · `data-autoscrolling` · `data-scrollable` · `data-loading` · `data-empty` | Transcript — see [`ChatMessageList`](./chat-message-list.md) | -| `Chat.Input` | `` | `data-status` · `data-dragging` · `data-compact` | The composer (`ChatInput`) | -| `Chat.Empty` | `
` | — | Empty state (`ChatEmptyState`); typed suggestions via `getAgentPromptSuggestionItems(agent)` | -| `Chat.Skeleton` | TBD in implementation | — | Loading skeleton | -| `Chat.If` | *no node* | — | Selector conditional: ` s.isEmpty}>…` — no boolean-prop variants | -| `Chat.Message` | `
` | `data-role` · `data-agent-id` · `data-streaming` · `data-editing` · `data-error` | One message row (`Message`) | -| `Chat.ErrorBanner` | TBD in implementation (renders with `role="alert"`) | — | Session error display | +| `test` *(required)* | `(s: ChatContextValue) => boolean` | — | Selector over the shared session context (today's prop is `condition: boolean \| fn` — the rename and the drop of the raw-boolean form follow the no-boolean-variants rule) | +| `fallback?` | `ReactNode` | `null` | Rendered when the selector fails (kept from today) | -## Props +Outside a `Chat.Root`, the selector cannot run — today the part renders `fallback` (`null`) in that case. + +### `Chat.Message` + +One message row (= [`Message`](./message.md)). One **`
`** (today: a `
`) + scoped `MessageContext`. Default content: avatar/header, then parts in order (text as `Markdown`, reasoning, tool calls, sources), then hover-revealed actions. Session callbacks (`editMessage`, `reload`) come from `ChatRoot` context — never re-threaded per message. -Session props set here flow to all descendants through `ChatRoot` context (precedence: explicit prop > nearest context > default). Trimmed from today's 28 props: +**Layout:** in-flow column (`flex flex-col gap-1.5 w-full`) inside the transcript column; row actions are hidden-but-animatable (`opacity-0 group-hover:opacity-100` today → `data-floating`, never unmount-to-hide). | Prop | Type | Description | | --- | --- | --- | -| `agentId` | `string` | The agent to talk to | -| `api` \| `transport` | `string \| { url, headers, credentials, fetch, body }` | Endpoint or transport object — auth works without a custom client | -| `uploadApi?` | `string` | Upload endpoint for attachments | -| `tools?` | `{ [name: string]: Component }` | Tools registry — per-tool renderers receiving the typed part. Resolution order: inline render fn → registry by name → default renderer | -| `labels?` | object | i18n overrides for built-in strings (L1 only — at L2/L3 the consumer owns all text) | -| `chat?` | `UseChatResult` | Controlled session — bring your own `useChat()` | -| `children?` | `ReactNode` | Compose inside the preset using the compound parts | -| `asChild?` | `boolean` | Merge onto your own element | - -## State attributes - -| Attribute | Where | When | +| `message` *(required)* | `ChatMessage` | The turn to render | +| `asChild` + native + `ref` | | Own the `
` | + +**State attributes (proposed):** `data-role` · `data-agent-id` · `data-streaming` · `data-editing` · `data-error`. + +### `Chat.ErrorBanner` + +Session error display. Default content today: a centered wrapper (`max-w-2xl mx-auto`) holding a `ui` `Alert` (`variant="error"`, `role="alert"` per the a11y contract) with the error message and, when a retry handler exists, a link-style **Retry** button wired to `reload`. **Renders `null` while the session has no error** — safe to include unconditionally. + +**Layout:** in-flow between the transcript and the composer (not an overlay); appears/disappears with the error. + +| Prop | Type | Description | | --- | --- | --- | -| `data-status="ready\|submitted\|streaming\|error"` | `Chat.Root`, `Chat.Input` | Mirrors `useChat().status` | +| `error?` | `Error` | Explicit error; falls back to the session error from `ChatRoot` context *(today `error` is required and context-blind — the fallback is the reshape)* | +| `asChild` + native + `ref` | | Own the node; children replace the default Alert content | + +Today's `icon` prop falls to the **icon-slot ban**; `retryLabel` becomes children / `labels`. Whether the wrapper + `Alert` collapse to literally one node is TBD in implementation (the single-node contract says it must). -Sub-part attributes are documented on their own pages (see Parts table). +## Context (what the parts read) + +`useChatContext()` — throws outside `Chat.Root` / `ChatRoot`; `useChatContextOptional()` returns `null` instead. Today's context is a 25-field bag (messages, input, submit/stop, model, attachments, branching, feedback, theme, …); per #2973 it collapses to **the shared session plus derived flags**: + +```ts +{ + ...UseChatResult, // messages, status, error, streamingMessageId, sendMessage, stop, reload, … + isEmpty: boolean // derived — the selector field the RFC examples use + // exact derived-flag set beyond isEmpty: TBD in implementation +} +``` + +`Chat.If`'s `test` selector receives this same object. The raw context object stays unexported. ## Examples @@ -87,6 +322,17 @@ Batteries included — runs every hook internally: ``` +### Per-piece customization — no ejection + +```tsx + +``` + ### Composed (L2) Own every layout div; config on the leaf; state via `data-*`: @@ -95,23 +341,26 @@ Own every layout div; config on the leaf; state via `data-*`: function Workspace() { const { chat, ready } = useConversationChat({ agentId: 'support-agent', api: '/api/ag-ui' }) return ( - -
{/* YOUR div */} - -
{/* YOUR div */} - - {/* config on the leaf */} - + + + +
{/* YOUR div */} + +
{/* YOUR div */} + + {/* config on the leaf */} + +
-
- + + ) } ``` ### Headless (L3) -You render every element; prop getters carry behaviour. Consumer props go *into* the getters — never `{...getter()} {...props}`: +You render every element; consumer props go *into* the getters — never `{...getter()} {...props}`: ```tsx function MyChatInput() { @@ -128,15 +377,15 @@ function MyChatInput() { } ``` -## Customization +## Customization (eject path) The three layers are one graduation path — no rewrite cliff: -1. **Per-piece, without ejecting:** `` swaps one tool renderer; `labels` overrides strings; `children` recomposes the compound parts. -2. **Eject to L2:** paste the public default composition (identical pixels — it carries the theme scope, providers, and default classes) and edit the one piece you care about. Everything `` renders is reachable L2 — no private components, no internal-only props. -3. **Rebuild at L3:** replace any L2 leaf, one at a time, with your own element driven by the same hook (`asChild` or prop getters). +1. **L1, per-piece:** `tools` registry, `labels`, or `children` recomposition — swapping one tool renderer never forces ejecting the tree. +2. **L2 — eject:** paste the public default composition (identical pixels — it carries the theme scope, providers, and default classes) and edit the one piece you care about. Everything `` renders is reachable L2 — no private components, no internal-only props. +3. **L3 — rebuild:** replace any L2 leaf, one at a time, with your own element driven by the same hook (`asChild` or prop getters). ## Related -- [`ChatRoot`](./chat-root.md) · [`ChatMessageList`](./chat-message-list.md) · [`ChatThemeScope`](./chat-theme-scope.md) · [`ChatErrorBoundary`](./chat-error-boundary.md) -- [`useChat`](../hooks/use-chat.md) · [`useConversationChat`](../hooks/use-conversation-chat.md) · [`useChatContext`](../hooks/use-chat-context.md) +- [`ChatRoot`](./chat-root.md) · [`ChatMessageList`](./chat-message-list.md) · [`ChatInput`](./chat-input.md) · [`Message`](./message.md) · [`ChatEmptyState`](./chat-empty-state.md) · [`ChatThemeScope`](./chat-theme-scope.md) · [`ChatErrorBoundary`](./chat-error-boundary.md) +- [`useChat`](../hooks/use-chat.md) · [`useConversationChat`](../hooks/use-conversation-chat.md) · [`useChatContext`](../hooks/use-chat-context.md) · [`useUpload`](../hooks/use-upload.md) diff --git a/docs/architecture/29-chat-api-shape/components/inline-citation.md b/docs/architecture/29-chat-api-shape/components/inline-citation.md index bc2dd815a1..37d6a371b8 100644 --- a/docs/architecture/29-chat-api-shape/components/inline-citation.md +++ b/docs/architecture/29-chat-api-shape/components/inline-citation.md @@ -1,6 +1,6 @@ # InlineCitation -An inline footnote marker with a hover card — the default renderer behind the markdown `components.citation` slot. +An inline footnote marker with a hover card — the default renderer behind the markdown `components.citation` slot. Render it whole, or compose the parts. > **Status: proposed (RFC).** This page documents the *proposed* API shape — not yet implemented. Full rationale: [`29-chat-api-shape.md`](../../29-chat-api-shape.md). @@ -12,37 +12,122 @@ import { InlineCitation } from 'veryfront/chat' ## Anatomy -Each part renders one node, `extends` its native attributes, spreads `{...props}`, and takes `asChild`. - ```tsx - - + {/* Root: context only — renders NO node of its own */} + {/* superscript numbered pill; hover opens the card */} + {/* fixed-position hover card; null while closed */} + +``` + +`` with **no children renders exactly this default anatomy** (`Trigger` + `Card`). + +## Default DOM (childless render) + +The actual HTML of `` today, with the card open (classes abbreviated to layout-relevant ones): + +```html + + + + + + +
+ class="w-80"> + + +
+

…title…

+

+ https://… +

+
+

…snippet…

+
+
+ Relevance +
+
+
+ 72% +
+
+
``` +Hover mechanics today: entering the trigger opens the card after a **150ms delay**; leaving either the trigger or the card closes it after **100ms** (so the mouse can travel between them). Clicking the trigger fires `onClick(index)`. The card measures itself, then centers over the trigger and clamps to the viewport. + ## Parts -| Part | Renders | State attributes | Description | -| --- | --- | --- | --- | -| `InlineCitation.Trigger` | `` | `data-open` | The inline marker. Default appearance: numbered pill. | -| `InlineCitation.Card` | `
` | `data-open` | The citation detail card, shown when open. | +### `InlineCitation` (Root) -## Props +The compound's scoped context — **renders no node of its own**; children (or the default `Trigger` + `Card`) render in place. Owns the hover open/close timers and the card positioning math. -| Prop | Type | Description | -| --- | --- | --- | -| `asChild` | `boolean` | Merge the node onto your own element. | -| …rest | native attributes of the node (`` for `.Trigger`, `
` for `.Card`) | Spread onto the node — `className`, `data-*`, `aria-*`, handlers, `ref`. | +**Layout:** none — contributes no element; the trigger flows inline with the text where the root is placed. + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `index` *(required)* | `number` | — | Zero-based; the trigger shows `index + 1` and `onClick` receives it. | +| `source` | `Source` | — | `{ title, url?, score?, snippet? }` — drives the card. **The card never renders without it.** | +| `onClick` | `(index: number) => void` | — | Fired when the trigger is clicked (unless the click was `defaultPrevented`). | +| `className` | `string` | — | Today this is forwarded to the **trigger** (not a root node — there isn't one). | +| `children` | `ReactNode` | default anatomy | Compose the trigger and hover card. | -## State attributes +**State attributes (proposed):** | Attribute | Values | Meaning | | --- | --- | --- | -| `data-open` | present | The citation card is expanded. | +| `data-open` | present | The citation card is expanded — on `.Trigger` and `.Card`. Today open state is expressed only by mounting/unmounting the card. | ```css [data-open].my-citation-trigger { background: var(--accent-3); } ``` +### `InlineCitation.Trigger` + +The inline marker. Today a ` +
+
                           
+      …highlighted…
+    
+
+ + inline code + + +
+ + … +
+
+ +
+
+
+ + +

raw markdown text

+``` + +**Layout:** the container and everything in it is in-flow block content — nothing absolute, portalled, or hover-revealed. The two horizontal scrollers (code `
`, table wrapper) are the pressure valves that let the transcript column stay `min-w-0`.
+
+Notes for the reviewer:
+
+- **Today the code hook is `pre`, not `code`**: block code arrives as `
`, and the built-in `pre` renderer extracts language + raw text and hands them to the `ui` `CodeBlock`; inline code is left as a bare `` styled by the container. The proposed map is keyed **`code`** (react-markdown convention, `RichCodeBlock` default) — how the `pre`/`code` split maps onto that key (does `components.code` receive `inline`?) is **TBD** in the RFC.
+- Today react-markdown + remark-gfm load lazily from esm.sh at runtime (hence the plain-text fallback). The proposed streaming pipeline (below) replaces this wholesale; whether a fallback state remains is **TBD**.
+
+## Props
+
+### `Markdown`
+
+**Layout: one in-flow container `
` around the emitted tree** — the container carries the typographic rhythm; native attributes spread onto it. + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `children` *(required)* | `string` | — | The markdown source (possibly mid-stream) | +| `components` | `{ [element]: Component }` | built-ins | Override map, merged **over** the built-in renderers (consumer entries win). Keys: any emitted element (`code`, `a`, `img`, `table`, `blockquote`, `th`, `td`, …) plus the virtual **`citation`** slot | +| `allowedLinkPrefixes` *(proposed)* | `string[]` | TBD | Hardening — URL prefixes links may point at | +| `allowedImagePrefixes` *(proposed)* | `string[]` | TBD | Hardening — URL prefixes images may load from | +| `remarkPlugins` / `rehypePlugins` | `PluggableList` | GFM built-in | Appended after the built-in list (kept from today; interaction with the proposed incremental parser is **TBD**) | +| + native + `ref` | `HTMLAttributes` | | Onto the container div; `className` merges (today: `className` only) | + +**Removed (proposed):** `renderCodeBlock` — it *is* `components.code` now (breaking-changes ledger). + +### `components` — the override map -The `components` prop follows the react-markdown convention: a map from emitted element type to the component that renders it — `components={{ code, a, img, table, citation, … }}`. +The react-markdown convention: a map from emitted element type to the component that renders it. -- **Every emitted element type is replaceable.** Pass a component for any element `Markdown` emits and yours renders instead. -- **`code`** — defaults to [`RichCodeBlock`](#richcodeblock) (see below). Swap it via the map; the legacy `renderCodeBlock` prop is deleted (breaking-changes ledger) — it *is* `components.code` now. -- **`citation`** — an override slot rendering footnote markers from source parts. Defaults to [`InlineCitation`](./inline-citation.md), which renders numbered pills. +- **Every emitted element type is replaceable.** Pass a component for any element `Markdown` emits and yours renders instead — including the built-in defaults for `pre`/`code`, `table`, `th`, `td`, `a`, and `blockquote` documented in the DOM above. +- **`code`** — defaults to [`RichCodeBlock`](#richcodeblock). The default renderer receives `{ language, code }` extracted from the fence. +- **`citation`** — a *virtual* slot (no HTML element named `citation`): renders footnote markers generated from source parts. Defaults to [`InlineCitation`](./inline-citation.md) — numbered pills. Because [`ToolCall.Input`](./tool-call.md) and `ToolCall.Output` are `RichCodeBlock`/`Markdown`-backed, the same `components` map reaches those surfaces too. -## Streaming +### Streaming (proposed — owned here) -Streaming is owned here (the streamdown model) — consumers never hand-roll token handling: +Streaming is owned by `Markdown` (the streamdown model) — consumers never hand-roll token handling. None of this exists in today's implementation (which re-renders the whole tree per update and loads the parser lazily): -- **Incremental block parsing.** The document is parsed block-by-block; **only the tail block re-renders per token**. Completed blocks are stable. +- **Incremental block parsing.** The document is parsed block-by-block; **only the tail block re-renders per token**. Completed blocks are referentially stable. - **Repair of unterminated syntax.** Unterminated code fences and emphasis are repaired mid-stream, so a half-arrived ` ``` ` never breaks the rendered tree. - **Hardening.** `allowedLinkPrefixes` / `allowedImagePrefixes` restrict which URLs links and images may point at — streamed model output is a security surface, and hardening is table stakes. ## `RichCodeBlock` -`RichCodeBlock` is the **default `components.code` renderer**. It is an alias over the `veryfront/ui` `CodeBlock` (the `ui` component's `copyIcon`/`collapseIcon` props fall to the icon-slot ban — breaking-changes ledger). +The **default `components.code` renderer** — an alias over the `veryfront/ui` `CodeBlock` (shiki highlighting, copy button with ~2s copied feedback, language label, collapsible shell, mermaid support). **Layout: an in-flow block card (`my-4 rounded border overflow-hidden`): header row (`flex justify-between`, label left / actions right) above the scrolling `
`.**
 
-To use your own code rendering, pass a component as `components.code`; to use the default elsewhere (e.g. in your own part renderer), import `RichCodeBlock` directly.
+> Today's exported `RichCodeBlock` is a *deprecated plain-`
` fork with no highlighting*, kept for back-compat while `Markdown` already renders fences through the `ui` `CodeBlock`. The proposal collapses this: one name, aliasing the `ui` primitive.
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `code` *(required)* | `string` | — | The source text |
+| `language` | `string` | — | Highlight language + header label (`"text"` shown when absent) |
+| `collapsible` / `defaultCollapsed` | `boolean` | `false` | Collapsible shell — header stays, body toggles |
+| `mode` | `'light' \| 'dark'` | ColorMode context, else `light` | Forced highlight theme |
+| `onCopy` | `(e, next) => void` | — | Intercept the header copy; call `next()` to actually copy |
+| + native + `ref` | `HTMLAttributes` | | Onto the card (today: `className` + `ref`) |
+
+**Removed (proposed):** `copyIcon` / `collapseIcon` — icon-slot ban (breaking-changes ledger). The `ui` `CodeBlock`'s `renderHeader` render prop is not named in the ledger; whether it survives the render-prop ban is **TBD**.
+
+**State attributes (proposed):** `data-copied` on the copy button (global vocabulary; today copied feedback is internal state swapping the icon/label).
+
+## Context (what the parts read)
+
+None — `Markdown` is stateless from the consumer's perspective (input string in, tree out). The `components` map is the entire extension surface; `RichCodeBlock`'s copy behavior is available standalone via `useClipboard(text)` → `{ copied, copy }`.
 
 ## Where it appears
 
 - [`Message.Text`](./message.md) renders its part content through `Markdown`.
 - [`ToolCall.Input` / `ToolCall.Output`](./tool-call.md) are `RichCodeBlock`/`Markdown`-backed.
 - The default [`components.citation`](./inline-citation.md) renderer is `InlineCitation`.
+
+## Examples
+
+### Default
+
+```tsx
+{message.text}
+```
+
+### Custom code + citation renderers
+
+```tsx
+ {code},
+    citation: MyFootnotePill,
+  }}
+>
+  {message.text}
+
+```
+
+### Reusing the default renderer elsewhere
+
+```tsx
+import { RichCodeBlock } from 'veryfront/chat'
+
+
+```
+
+## Customization (eject path)
+
+1. **L1** — the default tree inside `` / `Message.Text` (defaults above).
+2. **L2** — override per element type via `components` — per-element ejection, never all-or-nothing; `RichCodeBlock` stays importable for use inside your overrides.
+3. **L3** — there is deliberately no lower layer: parsing + streaming repair are the library's job (the exception exists *because* this tree shouldn't be hand-built). For fully custom rendering, take `part.text` and use your own pipeline.
+
+## Related
+
+- [`Message.Text`](./message.md) · [`ToolCall`](./tool-call.md) · [`InlineCitation`](./inline-citation.md)
+- `useClipboard` — the copy hook behind the code-block header
+- The markdown exception + streaming contract: [`29-chat-api-shape.md`](../../29-chat-api-shape.md) *Cross-cutting contracts*
diff --git a/docs/architecture/29-chat-api-shape/components/message-action-bar.md b/docs/architecture/29-chat-api-shape/components/message-action-bar.md
index 57a43072f9..5e56027221 100644
--- a/docs/architecture/29-chat-api-shape/components/message-action-bar.md
+++ b/docs/architecture/29-chat-api-shape/components/message-action-bar.md
@@ -4,7 +4,7 @@ The message action buttons — a namespace re-export of the `Message.Actions` fa
 
 > **Status: proposed (RFC).** This page documents the *proposed* API shape — not yet implemented. Full rationale: [`29-chat-api-shape.md`](../../29-chat-api-shape.md).
 
-`MessageActionBar` **is** the `Message.Actions` family — one implementation, re-exported under a standalone name for use outside a `Message`. `Message.*` is canonical; there is never a parallel implementation.
+`MessageActionBar` **is** the `Message.Actions` family — one implementation, re-exported under a standalone name. `Message.*` is canonical; there is never a parallel implementation. This is a real deletion: today `MessageActionBar` is a *second, context-free* implementation (a `content` prop plus `onCopy`/`onEdit`/`onRegenerate` handler props, leaves `.Copy`/`.Copied`/`.Regenerate`/`.Edit`, each with an `icon` prop). The RFC collapses it onto the context-bound `Message.Actions` family.
 
 ## Import
 
@@ -19,46 +19,98 @@ import { Message } from 'veryfront/chat' // Message.Actions, Message.CopyAction,
 Each part renders one node, `extends` its native attributes, spreads `{...props}`, and takes `asChild`. A leaf renders its default icon when childless; pass children to replace it (no `icon` props).
 
 ```tsx
-
-  
-  
-  
+                {/* ONE 
— data-floating, holds its space */} + {/* copy icon → check while data-copied; null without text */} + {/* refresh icon; null on user turns / no reload */} + {/* pencil icon; enters edit mode → data-editing on the Root */} ``` +`` with **no children renders the default cluster**: `CopyAction` + `RegenerateAction` (`EditAction` is available but off by default). + +## Default DOM (childless render) + +What `` actually renders (today's source classes, abbreviated to layout). The bar lives inside a `Message` row whose root carries the `group/msg` hover scope — the reveal keys off *that ancestor*, not the bar itself. Nothing is absolutely positioned. + +```html + +
+ + + + + +
+``` + +In the `` childless default, this bar sits inside a footer layout div (`mt-1.5 flex items-center gap-0.5`) next to `Message.Tokens` — that footer div is yours after eject. + ## Parts -| Part | Renders | State attributes | Description | +### `Message.Actions` + +The bar container — one `
` + nothing else (the buttons read message context directly; there is no bar-scoped context). + +**Layout:** in-flow flex row (`gap-0.5`) that holds its space; revealed by opacity on ancestor `group/msg` hover — zero layout shift. + +Default content: `Message.CopyAction` + `Message.RegenerateAction`. **Renders `null` when the message has no text content** (today's gate). Hidden-but-animatable — **never unmounted to hide**: the RFC replaces today's baked `opacity-0 group-hover/msg:opacity-100` classes with `data-floating` so the reveal is your CSS. + +| Prop | Type | Default | Description | | --- | --- | --- | --- | -| `Message.Actions` | `
` | `data-floating` | Container. Hidden-but-animatable — never unmounted to hide. | -| `Message.CopyAction` | ` + +
+ + +
+
+``` + +Not in the childless default: `Message.Avatar` (standalone — the header embeds its own avatar), `Message.BranchPicker`, `Message.EditAction`. + ## Parts -| Part | Renders | State attributes | Description | -| --- | --- | --- | --- | -| `Message.Root` | `
` | `data-role` `data-agent-id` `data-streaming` `data-editing` `data-error` | The single message node + scoped context. | -| `Message.Avatar` | `
` | — | Author avatar; derives from **message** metadata (multi-agent ready), never from conversation-level agent config. | -| `Message.Header` | `
` | — | Header container. | -| `Message.Name` | `` | — | Author name; derives from the message's metadata. | -| `Message.Timestamp` | `