Skip to content

feat(message): add the Message family - #121

Merged
TheGreatAxios merged 8 commits into
conversation/03-agent-stepsfrom
conversation/04-message
Oct 28, 2026
Merged

TheGreatAxios merged 8 commits into
conversation/03-agent-stepsfrom
conversation/04-message

Conversation

@pratikbuilds

Copy link
Copy Markdown
Collaborator

Message renders an agent, person or own message with an avatar, header, content, footer and send status; own messages sit in a bubble, and markdown comes only through MessageContent's render prop. MessageFold folds a long answer and keeps the reader's place when folding from inside it. MessageActions puts extras in an overflow menu without an optional peer, and MessageReactions, MessageReplies and useCopy cover reactions, reply summaries and copy feedback. A failed message offers a retry.

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

Notes for review

  • Message renders its own send status line (sending; failed with error and Retry) unless a MessageSendStatus is placed among its children; a failure that happens after mount is announced once (polite), never for messages that mount failed, and not inside a role=log (the log announces it). The article is named by MessageHeader's author unless the host labels it.
  • MessageFold takes a length (threshold 6000), never folds while streaming, starts open if it mounted while streaming, unfolds when focus moves inside; it starts folded by default, so pass defaultOpen to keep the newest answer open. Place-keeping uses the nearest scroll container, not useConversation.
  • Avatar initials come from a local helper (the old chat-message helper is removed at the cutover).
  • MessageSendStatus and MessageHeader register through Message's context, so they work at any depth (e.g. inside MessageFooter) and Message never doubles the status line. The live region belongs to Message and reads the current error (or "Not sent."). Server-rendered HTML shows the default line until hydration registers a placed one.

Verification

  • This commit passes bun run typecheck and bun run lint on its own (the stack is checked commit by commit).
  • At the top of the stack: bun run check, bun run build, bun run dep-guard, bun run contrast-test and bun run test:e2e (Playwright specs and the Storybook axe sweep in light and dark) pass.

Closes #98

Message renders an agent, person or own message with an avatar, header,
content, footer and send status; own messages sit in a bubble, and
markdown comes only through MessageContent's render prop. MessageFold
folds a long answer and keeps the reader's place when folding from
inside it. MessageActions puts extras in an overflow menu without an
optional peer, and MessageReactions, MessageReplies and useCopy cover
reactions, reply summaries and copy feedback. A failed message offers a
retry.
@corbits-triage

corbits-triage Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Triage: Fail

@pratikbuilds, please address the following:

  • required checks are failing: policy-check

Asking a maintainer about:

  • pull request is over the lines limit: 3249 lines changed (max 3000)

Stacked on #120: only this pull request's own changes were checked.

Notes:

  • added code matches a code pattern: src/ui/message-fold.tsx:148 Nested ternary; use if statements or a lookup., src/ui/message.tsx:432 Nested ternary; use if statements or a lookup., stories/chat/message.stories.tsx:283 Invented fallback `?? []`; handle the missing value instead., stories/chat/message.stories.tsx:314 Invented fallback `?? []`; handle the missing value instead., stories/chat/message.stories.tsx:135 Nested ternary; use if statements or a lookup.
  • Risk: medium (3249 lines)

Triage will request changes.

@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; only confirmed items listed (the top two verified by runtime tests run against the actual branch components).

  • should-fix (VERIFIED by runtime test) src/ui/message-actions.tsx:259 — a caller-supplied ref on MessageActionMenu replaces the internal root ref: the JSX is <div ref={rootRef} …{...props}> and under React 19 ref-as-prop the spread wins, so the internal ref never attaches. Outside-click dismissal (useDismissablePopover requires rootRef.current?.contains(target) === false) silently stops working, and clip-avoidance side-flipping dies with it (placement effect bails at root === null). Verified with and without a caller ref. Smallest fix: merge the caller ref with the internal one (the idiom exists as useMergedRef in src/ui/conversation.tsx) + a regression test asserting outside-click closes with a caller ref present.
  • minor (VERIFIED by runtime test) src/ui/message-fold.tsx:140-141 — for a controlled MessageFold, the requestAnimationFrame clear of foldedFrom erases the reader's-place restore whenever the host commits the close after the next paint (e.g. a startTransition-wrapped or async store update); the restore effect then sees null and the page jumps. Verified: sync commit corrects scroll, 60 ms late commit doesn't. Clear foldedFrom only once the close has been applied or definitively abandoned (next toggle / open back to true).
  • file-for-later src/ui/message.tsx:197, 213-214 — grid layout keys on exact MessageAvatar component identity + direct-child has-[> selectors; a wrapped/copied MessageAvatar silently loses its column (the code comment already concedes it — a dev warning would be cheaper than the eventual support ticket).
  • nit stories/chat/message.stories.tsx:372 — h-[32rem] where h-128 is the same value.

A ref passed to MessageActionMenu replaced the root ref, so a click
outside no longer closed the menu and it no longer flipped above a
trigger near the bottom. The host's ref is now merged into the root.

Folding a long answer kept the reader's place only when the host
committed the close in the same frame as the click. The fold now reads
the toggle's position with getSnapshotBeforeUpdate, after React decides
to fold and before the DOM changes, so the place is kept however and
whenever the close lands.

@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 the Message family: layout, actions, fold, reactions, and replies.

Findings

  • src/hooks/use-copy.ts:37 — A slow clipboard write can show "Copied" after a newer write has already failed. copy has no generation. It always setCopied(written) when it settles (:48), and a late rejection clears a newer success the same way. The last call to settle should win: ignore a result that is no longer the latest.

  • src/ui/message-actions.tsx:244 — ArrowUp records focusOnOpen as "last" before the menu actually opens. The effect that focuses an item and resets the flag (:233-238) runs only when open flips. If a controlled onOpenChange ignores the open, the flag sticks, and the next open focuses the last item instead of the first. Set the flag only once open becomes true, or reset it when the request does not commit.

  • src/ui/message-actions.tsx:283 — Dismissing the menu from outside drops focus on document.body. Blur and the popover's outside press call setOpen(false), not close(), so the trigger is not focused when the menuitem unmounts. Escape, choosing an item, and Shift+Tab do restore it. If the press lands on something that does not take focus, return focus to the trigger.

@pratikbuilds
pratikbuilds force-pushed the conversation/04-message branch from 74e70ca to cefe7be Compare October 28, 2026 08:41
useCopy let whichever clipboard write settled last set `copied`, so a
slow earlier write could show "Copied" after a newer copy failed, or
clear the state after a newer one succeeded. Each copy now takes a
number, and only the latest one updates `copied` and the timer.

The action menu kept the "focus the last item" request from an ArrowUp
that a controlled host ignored, so a later open, by a click or by the
host, landed on the last item. The request is now state and is dropped
in the render that finds the menu still closed, as the prompt's add
menu does, so any later open starts on the first item.

Also note in the menu's docs that a click outside closes it without
taking focus back, as the other popovers do.
@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Two points are fixed. The third is the library's convention, and it's now in the docs.

1. A slow clipboard write could overwrite a newer copy's result

  • The fix: each copy call takes a number, and only the latest call updates copied and its timer. An older call still resolves with its own result.
  • Tests: "a stale write that settles last does not override a newer one" and "a stale failure that settles last does not clear a newer copy". Both failed before the fix.

2. An ArrowUp the host ignored made the next open focus the last item

  • The fix: the "last item" request is now state, and it's dropped in the render that finds the menu still closed. A later open, whether by a click or by the host, starts on the first item. The prompt's add menu in feat(prompt-input): add the PromptInput toolbar parts #118 works the same way.
  • Tests: "a click opens on the first item after a host ignored an ArrowUp open" and "the host's own open after an ignored ArrowUp lands on the first item". Both failed before the fix.

3. Closing the menu from outside dropped focus on the body

  • Docs only. The menu's docs now say that a click outside closes it without taking focus back. NotificationsBell and the prompt's add menu say the same.
  • A press on content that can't take focus moves focus to the body in any browser, menu or not. Chromium then continues Tab from where the click landed. Taking focus back to the trigger would break pressing straight into another control.

Commit: fix(message): keep copy and menu focus on the latest request

A pressed MessageAction in the row took `bg-muted` and
`text-foreground`, the same two classes hover applies, and both are
colour. Once the pointer left, the only difference between a set vote
and an unset one was a faint tint and a darker icon, so a reader could
not tell which vote was set.

A pressed action now also draws an inset outline in the control-boundary
colour, which clears 3:1 against the page and which hover does not draw.
The MessageAction docs say what a pressed action looks like, and the e2e
action row gains a pressed and an unpressed vote to check it.
MessageActionMenu read `ref` from its props. React 18 strips `ref` from
the props of a plain function component, so a host's ref never reached
the root there, though the peer range accepts React 18. The menu is now
wrapped in forwardRef, which passes the ref on React 18 and 19 alike,
and still merges it with the root ref that outside-press dismissal and
the side flip measure.
@pratikbuilds
pratikbuilds force-pushed the conversation/04-message branch from 59cf89f to 59a9bc2 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.

  • 562f8ac: A pressed MessageAction (e.g. "Good response") differed only by colour and looked the same as hover. It now also gets an inset ring in the --input control-boundary colour, which hover does not; an e2e test checks the pressed and hovered styles differ.
  • 59a9bc2: React 18 refs: components that read ref from props only receive it on React 19, while the peer range allows ^18.2.0. They are now wrapped in forwardRef, so a host ref reaches the element on both; the peer range is unchanged. Covers MessageActionMenu.

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.

MessageAction read `ref` from its props. React 18 strips `ref` from the
props of a plain function component, so the trigger ref MessageActionMenu
passes never reached the button, and closing the overflow menu did not
return focus to "More actions". The peer range still accepts React 18.
MessageAction is now wrapped in forwardRef, which passes the ref on
React 18 and 19 alike, and attaches it to the button it renders in the
row and in the menu.
Focus already returns to the trigger when an overflow item is chosen.
Assert that MessageAction is a forwardRef there, instead of a separate test.

@corbits-triage corbits-triage Bot 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.

Triage requests changes:

  • CI green: required checks are failing

The previous run merged a stack commit whose mid-glide test failed
typecheck. That test is fixed on conversation/05-conversation.
@corbits-triage
corbits-triage Bot dismissed their stale review October 28, 2026 21:14

Nothing triage asked to change still fails, so this request no longer stands.

@TheGreatAxios
TheGreatAxios removed this pull request from stack #130 October 28, 2026 21:38
@TheGreatAxios
TheGreatAxios added this pull request to stack #139 October 28, 2026 21:38
@TheGreatAxios
TheGreatAxios force-pushed the conversation/04-message branch from d06a222 to 51539d7 Compare October 28, 2026 21:42

@corbits-triage corbits-triage Bot 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.

Triage requests changes:

  • CI green: required checks are failing

@TheGreatAxios
TheGreatAxios dismissed stale reviews from corbits-triage[bot] and themself October 28, 2026 21:49

Superseded by the completed stacked review; blocking fixes were addressed and the rebased stack is being landed.

@TheGreatAxios
TheGreatAxios merged commit ea976c4 into main Oct 28, 2026
3 of 10 checks passed
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.

Add the Message family

2 participants