diff --git a/docs/README.md b/docs/README.md index c27c548f1d..41655d7db4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,9 @@ boundaries, or docs structure. page is `api-reference/index.md`; do not use `README.md` for public pages. - `architecture/`: Private Veryfront Code architecture notes. These docs are not part of the public docs sync. +- `rfcs/`: Proposed target-state designs and migration records. These docs are + not current-state architecture notes and must not claim merge readiness while + unresolved contract questions remain. Shared plans and unresolved work belong in the GitHub issue tracker. Local planning notes may use `docs/plans/`; that directory is Git-ignored and excluded diff --git a/docs/rfcs/29-chat-api-shape.md b/docs/rfcs/29-chat-api-shape.md new file mode 100644 index 0000000000..7cc82b2021 --- /dev/null +++ b/docs/rfcs/29-chat-api-shape.md @@ -0,0 +1,1124 @@ +# 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." + +## Reusability - generic core vs veryfront adapter (a hard requirement) + +**Every public hook and component must be reusable by ANY consumer to build ANY chat UI - not tied to the veryfront application.** This is non-negotiable. A full-surface review found the surface is overwhelmingly generic, but a small set of pieces are hard-wired to veryfront's backend/product and are currently documented as neutral "signature kept," hiding the coupling. Those must move behind a **veryfront adapter** (or gain injectable transport), leaving a clean generic core. + +> **`veryfront/chat` is an AG-UI client (`src/agent/ag-ui/`).** The agent / tool-call / status / streaming **shape is the [AG-UI protocol](https://ag-ui.com)** - a generic open standard the library is _meant_ to implement. That shape is **not** app coupling; supporting it is the point. So agent status values (`thinking` / `tool_execution` / `completed` / …), tool-call lifecycle, and streaming events are all generic. The genuine coupling is the veryfront-specific bits **around** the protocol - the REST agent-catalog transport, brand strings, product settings, and skill vocabulary - listed below. + +| Coupled piece | Coupling (verified in `src/`) | Decouple path | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `useAgents` · `useAgentMetadata` · `useAgent` | hardcoded `fetch("/api/agents")`, veryfront envelope/normalizers, error registry, SDK types | move to the adapter, or require an injected `transport`/`fetcher` and document the backend contract | +| `AgentCard` | imports veryfront agent-SDK message/tool **types** and duplicates `Message`/`ToolCall` for a runtime view (the status _values_ are AG-UI-standard, so **not** the coupling) | type against the generic AG-UI shape, or move the SDK-typed card to the adapter | +| `ToolCall` + `isSkillToolPart` | auto-compacts by hardcoded tool names (`load_skill`, `execute_skill_script`); "skill" is a veryfront concept | default `variant="card"`; opt a tool into compact via the `tools` registry; drop the skill guard from the public generic API | +| `ChatEmptyState.Avatar` | default `alt="Veryfront Agent"` - brand string shipped to screen readers | neutral default (`"Agent"`) or make `alt` required | +| `ChatActions.Preset` `settings` | `autoSubmit` / `autoFixErrors` are agent-runtime toggles | drop from the public reader; consumers compose a settings submenu from generic `.Item`s | +| `ModelSelector` logo | provider logo hardcoded to `https://models.dev/logos/…`, no override | add a logo-source slot/override; document the external dependency | +| `Message` defaults | default renderer reads hardcoded `metadata.agentName` / `agentId` / `agentAvatarUrl` / `model` | document as an override-able convention, not an implicit contract | +| `markdown` | "Veryfront hardening pass" branding on a standard sanitize step | neutral wording | + +**Generic core** (stays in `veryfront/chat`): `Chat` / `ChatRoot` / `ChatInput` / `ChatMessageList` / `Message`, every reader, `AgentPicker`, `ModelSelector`, the composer / upload / voice primitives, and **editing + branching** (verified fully client-side - no app coupling; a standard chat UX that earns its place). **veryfront adapter** (moves out): the `/api/agents` fetch hooks, `ChatAgentPicker`, `AgentCard`, skill-tool guards, and the envelope normalizers. + +Individual pages flag their coupling inline; this table is the single source of truth for the split. + +## Earns its place - proposed v1 scope cuts & relocations + +A generic library ships only what a generic consumer needs. The full-surface review flagged pieces that don't earn a place in the core chat surface - app-shaped, not chat-specific, or without a consumer. Each disposition below **removes or relocates public surface**, so it is a proposal to confirm, not a done deal: + +| Piece | Finding | Proposed disposition | +| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `AttachmentsPanel` + `useAttachments` + `useAttachmentsPanel` | durable "file browser" is a RAG / doc-Q&A product feature (empty state: _"upload files to start asking questions about them"_); it is the reason the attachment surface has **4 hooks, not 2** | ship as an **optional module** (e.g. `veryfront/chat/attachments`), not core v1 - the composer keeps `AttachmentPill` + `useUpload` | +| `useCompletion` | self-described non-chat one-shot text generation, no L2 consumer, couples to veryfront errors | **cut** from the chat public surface | +| `ChatErrorBoundary` + `useChatErrorHandler` | no chat-specific logic - a stock React error boundary + error-state hook | **move to `veryfront/ui`** as `ErrorBoundary` / `useErrorHandler` | +| `useClipboard` | a generic browser util, not a chat hook (already shared with the code-block copy button) | reposition as a generic util, or fold into `useMessageContext.copy`; disclose the reshaped signature | +| `useConversation` (single-by-id) | zero internal consumers - speculative public surface | **cut** until a real consumer exists | +| `MessageActionBar` | a re-export of `Message.Actions` that re-documents the same parts verbatim (drift risk) | trim to a thin alias stub - canonical home is `Message.Actions` | + +**Editing + branching stays** - confirmed generic (standard chat UX, fully client-side), so it earns its place despite the coupling worry raised earlier. + +## 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. **Compatibility is explicit.** The current styled components stay during the + migration, but this RFC includes one batched breaking release. Additive + layers may land first; removals and prop reshapes ship only through the + breaking-change ledger below. +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. +10. **Default-render parity: the styling is already right - keep it.** The + reshape moves _ownership_ of nodes; it does not redesign them. For every + component, the childless/L1 default render must produce the **identical DOM + tree and classes as today** - zero layout regressions. Wrappers deleted from + a primitive (e.g. `ChatInput`'s internal centering div, `ChatRoot`'s + container) reappear as explicit markup in the printed default composition, + so pixels never change. The only DOM deltas permitted are the ones + explicitly badged `changed` in the docs with a stated reason (currently: + `ChatMessageList`'s two-node root collapse, `StepIndicator`'s state + vocabulary, `Message.Tokens`' popover trim) - each is a review item, not a + side effect. The conformance harness pins this with default-render DOM + snapshots. + +--- + +## 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\|tool"` | `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`, `ChatInput.Model`, `AgentPicker.Trigger`, `ChatActions.Trigger`, `InlineCitation`) | expanded | +| `data-state="pending\|active\|complete"` | `StepIndicator.Root` | 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`. +- **Safe URL defaults:** links allow `http:`, `https:`, `mailto:`, `tel:`, + root-relative (`/`), same-directory (`./`), parent-relative (`../`), and hash + (`#`) URLs. Images allow `https:`, `blob:`, root-relative, same-directory, and + parent-relative URLs. Everything else, including `javascript:`, `data:`, + `vbscript:`, `file:`, and protocol-relative URLs, is removed unless the + caller explicitly widens the allowlist. +- **Plugin order:** repair/normalize the streamed tail first, run built-in + remark plugins (`remark-gfm`), then consumer `remarkPlugins`; convert to HAST; + run consumer `rehypePlugins`; finally apply Veryfront's URL hardening, + sanitizer, default components, and React rendering. The final hardening pass + means consumer plugins cannot reintroduce unsafe links or images. +- **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. +- **Readiness flows into chat context:** `ChatContextValue` includes `ready: + boolean` - `ChatRoot` reads `activeReady` from the nearest + `ConversationsProvider` (standalone: `true`). `Chat.If` selectors and the + default composition gate skeletons on it; consumers never re-derive it. +- **The edit mechanism, concretely:** `useChatInput` reads + `useMessageContextOptional()`. Inside a message with `isEditing`, it seeds + `value` from `textContent`, routes submit to `editMessage(message.id, value)` + instead of `sendMessage`, and maps Escape to `cancelEdit`. No extra props: + nesting _is_ the wiring. +- **Scroll attachment + button anchoring:** `useChatScroll` returns + `viewportRef` (and `getViewportProps(overrides?)`) - attach either to your + scroller. `ChatMessageList.ScrollButton` anchors via `position: sticky` at + the viewport's bottom edge (no wrapper node, no portal) - proposed + resolution, review welcome. +- **`useReasoning` gets an explicit-input form** - `useReasoning({ text, + isStreaming }?)` - so the L3 eject works without a `Reasoning.Root` + (mirrors `useToolCall(part?)` / `useSources(message?)`). +- **`StepIndicator` model:** per-boundary reader - `useStepIndicator(step?)` + → `{ stepIndex, state: 'pending' | 'active' | 'complete' }` (boundary explicit + at L3, from context at L2; mirrors `useToolCall(part?)` / `useSources(message?)`), + steps derived from `step-start` parts; `active` = the latest boundary while the + message streams. One shape, both docs pages. +- **Audit-settled details:** `AttachmentPill.Root` takes `upload?: + UseUploadResult`, defaulting to the nearest `ChatInput` context's upload: + that's how `.Retry`/`.Remove` route without handler props. `ChatSidebar` + gains `.Item.Menu.Trigger` (the icon-slot replacement). `Sources.Root` drops + `data-open` (it has no disclosure). Childless `` renders the + default per-type mapping (registry-aware) - the public default for + `.Content`. One conversation type: `Conversation` (no `ConversationSummary`). + Optional context hooks return `null` (never `undefined`), library-wide. + `Message.Tokens` popover trim is settled: it becomes a display-only `` + (a future breakdown popover anchors via the trigger ref). `formatSize` joins the + public helpers. The canonical DOM-delta ledger is the docs pages' `changed`/ + `new` badges - rule 10's inline list is illustrative, not exhaustive. + +--- + +## 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 (today: 48px AgentAvatar via ChatEmpty's icon slot; RFC: 64px ChatEmptyState.Avatar - see Chat.Empty) + ·

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; its default + content carries the max-w-2xl mx-auto px-4 pb-3 + centered wrapper around the Alert */ + } +
+ {/* composer outer: pinned by flex order, never shrinks */} +
+ {/* centered clamp - same width as the transcript */} + {upload.attachments.length > 0 && ( +
+ {/* pending AttachmentPill row */} + {upload.attachments.map((a) => ( + + ))} +
+ )} + + {/* one
; session from ChatRoot context */} +
+ {/* the composer card - also the file-drop target */} + +
+ {/* footer toolbar: space-between split */} +
+ +
+
+ + {/* 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). The proposed L1 default shows an agent-derived `Chat.Empty` in app mode and otherwise renders a blank empty transcript. 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 | Replaces the default composition. | + +`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. The preset does not expose a `ref`; use `ChatRoot` or composed parts when a root ref is needed. + +### 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 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` - `changed` + +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` - `changed` + +**Changed:** scroll state surfaces as imperative `data-*` attributes (see below); full ledger on [`ChatMessageList`](./chat-message-list.md). + +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` - `changed` + +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). L1 wires voice input by default when the browser supports it; `.Voice` null-renders otherwise. + +**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. + +| 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` - `changed` + +**Changed:** today's `icon` / `title` / `description` / `suggestions` / `onSuggestion*` / `quickActions` props give way to agent-derived defaults and `ChatEmptyState.*` composition. + +The idle hero (= [`ChatEmptyState`](./chat-empty-state.md) preset). One `
`. Default content today: the agent avatar - the **48px `AgentAvatar`** (`size-12`, image or initial) passed through `ChatEmpty`'s `icon` slot in app mode - → `

` 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_). **DOM delta (proposed):** the hero avatar becomes the **64px `ChatEmptyState.Avatar`** - the `icon` slot falls to the icon-slot ban, so the hero standardizes on the composable `ChatEmptyState.*` avatar instead of a slotted `AgentAvatar`. **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` - `kept` + +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` - `changed` + +**Changed:** today's `condition: boolean | fn` prop becomes the required `test` selector - the raw-boolean form is dropped. + +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 | +| ------------------- | ---------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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) | + +Outside a `Chat.Root`, the selector cannot run - today the part renders `fallback` (`null`) in that case. + +### `Chat.Message` - `changed` + +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. + +**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 | +| -------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `message` _(required)_ | `ChatMessage` | The turn to render - inside the preset's default map, `Chat.Message` resolves `message` from the iteration context, so the composition can print `` bare | +| `asChild` + native + `ref` | | Own the `
` | + +**State attributes (proposed):** `data-role` · `data-agent-id` · `data-streaming` · `data-editing` · `data-error`. + +### `Chat.ErrorBanner` - `changed` + +**Changed:** `error` becomes optional (falling back to the session error from context); the `icon` and `retryLabel` props are removed. + +Session error display. Default content today: a centered wrapper (`max-w-2xl mx-auto`) holding a `ui` `Alert` (`variant="error"`) with the error message and, when a retry handler exists, a link-style **Retry** button wired to `reload`. Today the `ui` `Alert` renders a plain `
` - `role="alert"` is a **proposed a11y addition** (streaming a11y contract), not today's DOM. **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 | +| -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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`. The wrapper + `Alert` collapse to one `
` so the single-node contract holds. + +## 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 + ready: boolean // ChatRoot reads activeReady from the nearest ConversationsProvider; standalone: true +} +``` + +`Chat.If`'s `test` selector receives this same object. The raw context object stays unexported. + +## Examples + +### Default + +Batteries included - runs every hook internally: + +```tsx + + + + + + + + + +; +``` + +### Per-piece customization - no ejection + +```tsx +{/* one tool renderer swapped (`tools`); rest untouched */} +; +``` + +### Composed (L2) + +Own every layout div; config on the leaf; state via `data-*`: + +```tsx +function Workspace() { + const { chat, ready } = useConversationChat({ agentId: "support-agent", api: "/api/ag-ui" }); + return ( + + + +
+ {/* YOUR div */} + +
+ {/* YOUR div */} + + {/* config on the leaf */} + +
+
+
+
+ ); +} +``` + +### Headless (L3) + +You render every element; consumer props go _into_ the getters - never `{...getter()} {...props}`: + +```tsx +function MyChatInput() { + const chat = useConversationChat({ agentId }); + const chatInput = useChatInput({ chat: chat.chat, upload: useUpload() }); + return ( + +