feat(client): make the chat panel replaceable slot by slot - #81
Conversation
The built-in chat UI was one 1620-line component, so a host app that needed a different layout had to replace the whole panel through CustomChat and rebuild every region from scratch. Each region is now a slot - Header, EmptyState, Message, PendingIndicator, ToolApproval, Disclaimer and Composer - passed through `components` on the provider. Every slot's built-in lives in `chatSlots/` as an exported `Default*` component typed by its own slot props, so an override can wrap or re-invoke one instead of rebuilding the region. The panel drops to 515 lines and only wires props. Typing each default against its own props is what forces those props to be complete. Two gaps had to be closed to make it typecheck: `saveAsCommand` on the message slot, and `submitMode`, `attachmentProcessing`, `disclaimerVisible` and `slashCommands` on the composer. Both were read from panel scope, so replacing `Message` had silently dropped "save as slash command". A turn's raw messages and the run's ordered parts are exposed as well (`sourceMessages`, `streamingParts`, `executingTool`): merging a turn into one bubble drops its tool calls, tool results and per-step reasoning, and `streamingText` / `streamingReasoning` flatten a multi-step answer into two strings. Both strings stay, so the built-in bubble is unchanged. Also stops a turn's pendingSources from leaking into the next turn when its assistant messages carried only tool calls, and guards `streamingParts` with `displayedChatId` like its sibling streaming fields. The example app gains a slots showcase that lays a turn out as a timeline with tool cards, per-step reasoning and citation cards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A turn's persisted messages are merged into a single bubble at render time by mergeAssistantMessagesForDisplay. The in-flight answer has no equivalent, so `getTextFromStreamingParts` and `getReasoningPartsFromStreamingParts` do the same reduction over the run's ordered parts. Both join with a blank line and skip empty steps because the merge does: the provisional bubble and the persisted one render into the same element, and a different string remounts it, dropping the user's text selection. A test pins the two flattenings against each other for the same run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`streamingText` and `streamingReasoning` were kept as state next to `streamingParts`, and their `\n\n` insertion existed only to reproduce what mergeAssistantMessagesForDisplay produces for the persisted turn. So the same join lived in three places, and they already disagreed: a REASONING_MESSAGE_START that received no delta left an empty part, which the parts-based join turned into a leading blank line and the string-based one did not. The run is now kept as parts alone and flattened where it is displayed. useServerEvents drops both strings and the separator bookkeeping, `clearStreamingText` becomes `clearStreamingParts`, and DefaultMessage derives the bubble's text and reasoning from the parts. The provisional message's `content` is empty as a result: the slot decides how to render the answer, so the panel no longer pre-flattens it. Removes `streamingText` / `streamingReasoning` from the useServerEvents return value, from UseAIChatPanelProps, and from the props handed to CustomChat, which now receives `streamingParts` instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its only caller now hands the in-flight reasoning over as `reasoningParts`, the same shape a persisted turn carries, so the separate string branch appended nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A new reasoning part was opened only on REASONING_MESSAGE_START. A provider that streams reasoning deltas without a start chunk emits one START per step, so the deltas of a second block landed in the block that had already ended and the two texts were joined into one part. The persisted turn splits on END instead (client.ts pushes one block per END), so the thinking text changed the moment the run finished. REASONING_MESSAGE_END now marks the open part as closed and the next delta opens a new one, which puts the boundary in the same place on both sides. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The streaming reasoning and the persisted reasoning were two conditionals at different positions in DefaultMessage's fragment, so the handoff unmounted one Reasoning and mounted the other, collapsing a dropdown the user had opened mid-stream. Both sides now feed one element, with `isStreaming` following the `streaming` flag. The pending indicator had a second problem in the same window: from the render that appends the persisted answer until the streaming state clears, the provisional entry is gone but `loading` is still true, so an empty bubble was drawn under the finished answer. The panel now checks for the persisted answer as well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A panel passed through CustomChat received the run's parts but not the id they will be persisted under, so it could not render the in-flight answer under that id and its bubble was replaced rather than updated when the run finished. The built-in panel and the chat UI context already had it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The section still said `streamingText` and `streamingReasoning` were available on the message slot; both were removed when the run became parts only. Name the two exported helpers the built-in bubble uses instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
buildStreamingTimeline emitted the parts in arrival order, but a persisted message records no order between its text and its tool calls, so buildTimeline always puts the text first. A step whose tool call arrived before its text saw the card and the prose swap places when the run finished, remounting both. The parts are now grouped per step and each step emits reasoning, text, then tool calls, and the text is trimmed and joined the way the persisted side does it. Two smaller fixes alongside: a citation's `url` comes from a tool and `new URL` throws on a relative or malformed one, which would unmount the chat since nothing above renders an error boundary, so the hostname line is dropped instead; and the message bubble no longer shows "Completed." for a run that has started but has no entries yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The streaming, flattener and timeline walkthroughs duplicated what /custom-slots-demo already shows in working code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fe829e4 to
dbd5c2e
Compare
|
Claude review:
The extracted defaults (DefaultHeader, DefaultComposer, DefaultEmptyState, DefaultMessage, DefaultPendingIndicator, FeedbackButton) are faithful ports with no behavioural drift. tsc --noEmit is clean and 379 client tests pass on the branch. |

AI Disclosure: This PR is written by AI
Stacked on #77.
This PR makes each slot of the built-in chat panel replaceable on its own.
Before this PR, a host app could replace the chat UI only with
CustomChat.CustomChatreplaces the whole panel. This PR splits the panel into slots:HeaderEmptyStateMessagePendingIndicatorComposerToolApprovalDisclaimerA host app replaces one slot through
chatComponentson the provider, or throughcomponentson a single<UseAIChat />. Each slot receives the built-in UI aschildren. A slot that renderschildrendecorates the built-in UI instead of replacing it. The package also exports every built-in implementation, so a host app can reuse one of them.The
Messageslot now receives a turn as ordered parts.sourceMessagescarries the parts of a persisted turn.streamingPartscarries the parts of a run that is still active. As a result, a host app can render citations, the reasoning of each step, and tool call cards. The built-in bubble still merges the parts into one text. Thus the default UI does not change.This PR also removes an old code path. The client used to carry the streamed answer as a separate
streamingTextstring, next to the persisted messages.ReasoningcombinedreasoningPartswith astreamingTextprop. Both date from before the client persisted reasoning per block. The panel never populated both inputs ofReasoningat the same time.PR #77 moves the streamed answer into a provisional message. This PR drops
streamingTextfrom the panel and fromReasoning. Thus there is one render path.One behavior changes. An open reasoning dropdown now survives the end of a run.
apps/examplehas a demo of every slot at/custom-slots-demo.