Skip to content

feat(client): make the chat panel replaceable slot by slot - #81

Merged
mm-zacharydavison merged 12 commits into
mainfrom
masudahiroto/chat-component-slots
Sep 2, 2026
Merged

mm-zacharydavison merged 12 commits into
mainfrom
masudahiroto/chat-component-slots

Conversation

@masudahiroto

@masudahiroto masudahiroto commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

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. CustomChat replaces the whole panel. This PR splits the panel into slots:

  • Header
  • EmptyState
  • Message
  • PendingIndicator
  • Composer
  • ToolApproval
  • Disclaimer

A host app replaces one slot through chatComponents on the provider, or through components on a single <UseAIChat />. Each slot receives the built-in UI as children. A slot that renders children decorates 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 Message slot now receives a turn as ordered parts. sourceMessages carries the parts of a persisted turn. streamingParts carries 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 streamingText string, next to the persisted messages. Reasoning combined reasoningParts with a streamingText prop. Both date from before the client persisted reasoning per block. The panel never populated both inputs of Reasoning at the same time.

PR #77 moves the streamed answer into a provisional message. This PR drops streamingText from the panel and from Reasoning. Thus there is one render path.

One behavior changes. An open reasoning dropdown now survives the end of a run.

apps/example has a demo of every slot at /custom-slots-demo.

@masudahiroto
masudahiroto requested review from ryumasaotome-dev and yuma2024 and removed request for ryumasaotome-dev and yuma2024 September 2, 2026 05:27
@masudahiroto
masudahiroto changed the base branch from main to masudahiroto/fix-streaming-text-selection September 2, 2026 05:31
@masudahiroto
masudahiroto marked this pull request as ready for review September 2, 2026 05:40
Base automatically changed from masudahiroto/fix-streaming-text-selection to main September 2, 2026 08:34
masudahiroto and others added 10 commits September 2, 2026 10:34
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>
@mm-zacharydavison
mm-zacharydavison force-pushed the masudahiroto/chat-component-slots branch from fe829e4 to dbd5c2e Compare September 2, 2026 08:34
@mm-zacharydavison

Copy link
Copy Markdown
Contributor

Claude review:


  1. Re-render per token on tool args — packages/client/src/hooks/useServerEvents.ts:253
    The new TOOL_CALL_ARGS branch does setStreamingParts(prev => prev.map(...)), so every argument token allocates a new array and updates state. The server emits one TOOL_CALL_ARGS per input delta (AISDKAgent.ts:1224), and UseAIChatPanel.tsx:193 re-runs the unmemoized mergeAssistantMessagesForDisplay(messages) over the whole conversation on each of those renders. Before this PR, tool-args deltas caused no state change at all. Returning prev unchanged when no part matches, plus memoizing displayMessages, fixes it.

  2. executingTool is unreachable — packages/client/src/components/UseAIChatPanel.tsx:407
    TOOL_CALL_START sets executingTool and pushes a tool_call part in the same handler, so provisionalMessage is non-null for exactly the window where executingTool is non-null. The gate loading && !provisionalMessage means the PendingIndicator slot never renders with a tool set. This makes the executingTool?.displayText branch in the demo's OrbitPendingIndicator dead code — the user sees an empty streaming bubble instead. The slot doc at chatSlots/types.ts:107 is wrong for the same reason.

  3. Streaming text can disagree with persisted text — packages/client/src/utils/streamingParts.ts:14
    getTextFromStreamingParts joins every non-empty text part with \n\n; mergeAssistantMessagesForDisplay joins per assistant message. A reasoning delta arriving between two text deltas of the same step splits the text into two parts, so streaming shows \n\n where the persisted message has no separator. At the handoff MarkdownContent re-renders different text and drops the user's selection — the exact failure the branch's previous commit set out to fix.

  4. Breaking prop removal — packages/client/src/providers/useAIProvider.tsx:239
    streamingText / streamingReasoning are gone from the exported ChatPanelProps, and streamingText from ReasoningProps. Intentional per the PR description, but CLAUDE.md has no NOT-RELEASED marker, so it needs a version bump and changelog entry.

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.

@mm-zacharydavison

Copy link
Copy Markdown
Contributor

I didn't like the SEO slop type page that Claude created in the example app, so I re-designed to look like other pages.
I kept your customized UI the same.

image

@mm-zacharydavison
mm-zacharydavison merged commit 0abbd01 into main Sep 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants