Skip to content

feat(chat): add MessageParts to render a message's parts - #129

Open
pratikbuilds wants to merge 5 commits into
conversation/16-remove-old-chatfrom
conversation/17-parts-renderer
Open

pratikbuilds wants to merge 5 commits into
conversation/16-remove-old-chatfrom
conversation/17-parts-renderer

Conversation

@pratikbuilds

Copy link
Copy Markdown
Collaborator

Stacked on #128: review only this commit. Part of #94.

Why

Every host maps ConversationPart to components by hand, and each one re-implements the approval focus handoff. When groupActivity folds a decided call into the run before it, the call's buttons unmount, and without that handoff focus drops to <body>. The old PartsRenderer only knew the part type that #128 removes.

What

  • MessageParts (src/ui/message-parts.tsx, in the root barrel) groups a message's parts with groupActivity and renders each kind as its component:
    • runs of reasoning and tool work → Activity
    • a call waiting on a person → ToolApproval
    • a batch with a waiting call → ToolGroup
    • text → MessageContent, using renderMarkdown as its render prop
    • consecutive file parts → one row of Attachments tiles
    • image parts → GeneratedImages, with onOpenImage
    • sources → Sources
    • events → ConversationNotice
  • Per-kind overrides: renderText, renderActivity, renderStep, renderApproval, renderBatch, renderBlock, renderFiles, renderEvent, renderImages, renderSources. An override can return undefined to keep the default or null to render nothing. A block's data belongs to the host, so blocks render nothing without renderBlock.
  • Focus handoff: after a decision folds the call away, if focus is left on the page it moves to the run's ActivityTrigger. In a batch that still has waiting calls, it moves to the next waiting call's buttons, wrapping to the first. The handoff waits until the host has recorded the decision in parts, and it never takes focus from an element the person has moved to. A custom renderApproval or renderBatch gets the same behaviour through the decide callback it receives.
  • streaming defaults to the enclosing Message's status.
  • Replaced hand-written mappings: stories/chat/full-chat.stories.tsx (its focus code is removed), stories/chat/activity.stories.tsx, and e2e/fixtures.tsx (the tool-approval and thread scenarios, plus a new tool-batch-approval scenario).

Tests

  • src/ui/message-parts.test.tsx (16 tests): every part kind, overrides, streaming, image open, and focus after a decision:

    • each of the three decisions
    • a call with no run before it
    • moving to the next waiting call in a batch, then wrapping
    • a decision the host records later
    • focus the person moved elsewhere stays there
    • a host's renderApproval

    The focus tests fail when the handoff is disabled.

  • Playwright: e2e/focus.ts checks document.activeElement on every frame. The tool-approval, batch-approval and full-chat specs assert that focus is never on <body> for even one frame after a keyboard decision.

  • bun run check, build, dep-guard and the full test:e2e pass: 126 passed, including the Storybook axe sweep. The known list is unchanged.

Closes #112

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated multi-agent review: independent review + second-agent validation. Independent re-derivation confirms MessageParts is a composition layer, not a slot-fragmenter: renderMarkdown delegates to MessageContent's single render prop (src/ui/message-parts.tsx:333), the hard-cutover files are untouched, and the PR removes ~195 lines of hand-rolled part rendering from the stories — the opposite of a second rendering path. Nothing blocking; all items below are doc gaps or static-verified a11y edges.

  • minor src/ui/message-parts.tsx:331-333 — renderText silently takes precedence over renderMarkdown for text parts; a host passing both gets a dead renderMarkdown with no warning. One line in the renderText JSDoc ("Takes precedence over renderMarkdown.") closes it.
  • minor a11y (static, HIGH) message-parts.tsx:110, 132-139 — the documented focus guarantee for custom renderApproval/renderBatch implicitly requires that markup to contain a plain <button> (or mirror data-slot="tool-approval"/data-status="pending"), because focusTarget finds the next target by those selectors. State the DOM contract in the prop docs.
  • minor a11y (static trace) message-parts.tsx:264-285 + src/ui/tool-approval.tsx:116-122 — when a waiting call stops waiting without a user decision (approval resolves to timeout/expired, which the contract explicitly allows), the card folds into its run and the focused buttons unmount with no focus recovery: MessageParts' effect only acts after a user decide, and ToolApproval's recovery effect can't fire on the unmounting commit. A regression test that focuses Approve and then resolves the record server-side would pin the expected behavior.
  • latent edge message-parts.tsx:267-283 — if the host removes the decided call from parts entirely (instead of recording a decided status), focus recovery early-returns and focus stays on <body>. Fall back to the group the card sat under, or document that decided tools must remain in parts.
  • minor doc README.md:345 — the 0.3 upgrade row still tells upgraders to hand-map groupActivity(message.parts) onto Activity/MessageContent/… — the exact mapping this PR automates. Point the row at MessageParts.
  • nit message-parts.tsx:363 — the adapter lambda names its first parameter image, but per GeneratedImages' prop type that value is the image's index; rename to index. (Same misreading exists in full-chat.stories.tsx:470 — PR #127.)

@pratikbuilds

pratikbuilds commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Here's what happened to each point.

Fixed

1. Focus fell to the page when a waiting call timed out or expired (minor a11y)

  • The fix: MessageParts now tracks which waiting call has focus. When that call stops waiting without a decision, focus moves on the same way it does after a decision: to the next waiting call in the batch, or else to the run's line. Focus that has already gone somewhere else is left alone.
  • Tests: unit tests, plus Playwright cases for a timeout, an expiry and a batch. Each checks focus is never on <body>.

2. Focus was lost if the host removed the decided call from parts (latent edge)

  • The fix: focus now falls back to the group the call was in. If that group is gone, it goes to the group before it, and after that to the first control in the message.

3. Doc gaps (minor)

  • The renderBatch and renderActivity docs say which markup the focus handoff looks for. renderApproval needs nothing special.
  • renderText says it takes precedence over renderMarkdown.
  • The 0.3 upgrade rows point to MessageParts instead of hand-mapping groupActivity.

4. The image adapter's argument was misnamed (nit)

Also in this PR

MessageParts is now one of the names the CI pack check looks for.

Commits: fix(chat): keep focus when a waiting call lapses or is dropped, ci: check MessageParts in the pack smoke

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review · Request changes

Adds MessageParts, which renders each part of a message and moves focus when a waiting tool call lapses or is dropped.

Findings

  • src/ui/message-parts.tsx:181 — Dropping a waiting call from the middle of a batch focuses the first remaining card, not the next one. focusTarget looks the id up in the batch after it is gone, so findIndex is -1. waiting.findIndex((index) => index > decided) is then 0, and Math.max(0, …) keeps that. The next Enter approves the wrong call. The previous groups are already on leaving; use that call's old index and focus the next card that is still waiting after it.

  • src/ui/message-parts.tsx:379 — Removing an earlier part drops focus to the page while a later call is still waiting. groupActivity keys parts by index (conversation-activity.ts:68), so the later card's key changes and it remounts (message-parts.tsx:520). The layout effect then returns because that call is still isAwaitingApproval, and it never restores focus. Key the card by toolCallId, or restore focus when the focused control has disconnected even though the call is still waiting.

@pratikbuilds
pratikbuilds force-pushed the conversation/17-parts-renderer branch from 5f79929 to cccd2d7 Compare October 28, 2026 08:25
@pratikbuilds
pratikbuilds force-pushed the conversation/16-remove-old-chat branch 2 times, most recently from 31d704f to 831f99f Compare October 28, 2026 08:41
@pratikbuilds
pratikbuilds force-pushed the conversation/17-parts-renderer branch from cccd2d7 to 492128c Compare October 28, 2026 08:41
@pratikbuilds
pratikbuilds force-pushed the conversation/16-remove-old-chat branch from 831f99f to c5f1b9e Compare October 28, 2026 11:28
@pratikbuilds
pratikbuilds force-pushed the conversation/17-parts-renderer branch from 492128c to 8c2f88c Compare October 28, 2026 11:28
Every host mapped ConversationPart to components by hand and
re-implemented the focus handoff when a decided call folds into the run
before it. MessageParts groups parts with groupActivity and renders each
kind as its component (Activity, ToolApproval, ToolGroup,
MessageContent, Attachments tiles, GeneratedImages, Sources,
ConversationNotice), with a render prop per kind to replace any of them.
After a decision folds a call away, focus moves to the run's trigger, or
to the next waiting call in a batch, instead of falling to the page.

The full chat and activity stories and the e2e fixtures use it in place
of their own mappings; the e2e specs check that focus never sits on
<body> for a frame after a keyboard decision.
MessageParts moved focus only after a decision made through its own
decide. When the server resolved a call to timeout or expired while
focus was on its buttons, the call folded into its run, the buttons
unmounted, and focus fell to the page. The same happened when a host
recorded a decision by removing the call from parts.

Track which waiting call's card holds focus from the root's focus and
blur events, and give a call that stops waiting under focus the same
handoff as a decided one: the next waiting call in a batch, else the
run's trigger. For a call that is gone from parts, fall back to the
group it stood in, then the group before it, then the first control in
the message.

Document the markup custom renderBatch and renderActivity must keep
for the handoff, and that renderText takes precedence over
renderMarkdown. Point the 0.3 upgrade rows at MessageParts, and name
the image adapter's argument index.
The consumer smoke checks one export per chat module so a dropped
export * line fails CI. MessageParts arrived after that list was
written; add it.
Dropping a waiting call from the middle of a batch moved focus to the
first waiting card, not the next one. The handoff now skips every call
up to and including the one that left, so focus lands on the call that
waited after it.

Removing a part before a waiting call shifted the index keys of every
group after it, so React remounted the waiting card and focus fell to
the page. A tool trace or batch is now keyed by its call id, and the
handoff finds a group by that key.

As in ToolGroup, the handoff focuses the next call's card rather than
its Approve, so a held Enter cannot decide a call nobody chose; a host
card without the card slot gets its first button. The batch e2e specs
expect the card.
MessageParts watches two calls whose buttons may go away under focus:
the one last decided and the waiting one whose card holds focus. It
merged them into one, preferring the decided call, and cleared both
once that one stopped waiting. In a batch, a decision moves focus to
the next card, which starts the watch on that call, and recording the
decision then ended it too: when the next call timed out or expired,
focus fell to the page. A decision the host never records kept the
merged watch stuck on that call, so a different call lapsing under
focus later also dropped focus to the page.

Each watch now ends only when its own call stops waiting. When both end
in one update, the handoff starts from the focused call, since that is
where focus was.
@pratikbuilds
pratikbuilds force-pushed the conversation/17-parts-renderer branch from 8c2f88c to 45c2783 Compare October 28, 2026 14:47
@pratikbuilds
pratikbuilds force-pushed the conversation/16-remove-old-chat branch from c5f1b9e to 177ed17 Compare October 28, 2026 14:47
@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Round 3 fixes

A fresh review pass over the stack turned up the points below; each is fixed in a new commit at the end of this branch, and the stack is rebased onto it.

  • 45c2783: After a decision in a batch, ToolGroup moved focus to the next card, but the MessageParts effect cleared both the decided and focused watches while handling the decided call, so when that next call timed out or expired, focus fell to the body; a decision the host never recorded also hid lapse handling for good. The effect now settles each watch on its own, focused first. Three unit tests and an e2e test cover it.
  • The pack-smoke commit adds MessageParts to the full module list from the PR below, and the upgrade table keeps the MessageParts wording.

bun run check and dep-guard pass on every branch of the stack, and the full e2e suite passes at the top of the stack (163/163, including the stories axe sweep). Each behaviour fix comes with a test that fails without it; the forwardRef tests can only guard, since the suite runs on React 19.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants