Repository navigation
feat(message): add the Message family - #121
Conversation
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.
|
Triage: Fail @pratikbuilds, please address the following:
Asking a maintainer about:
Stacked on #120: only this pull request's own changes were checked. Notes:
Triage will request changes. |
86a8632 to
dd1a072
Compare
dd1a072 to
77e248f
Compare
TheGreatAxios
left a comment
There was a problem hiding this comment.
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-suppliedrefonMessageActionMenureplaces 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 (useDismissablePopoverrequiresrootRef.current?.contains(target) === false) silently stops working, and clip-avoidance side-flipping dies with it (placement effect bails atroot === null). Verified with and without a caller ref. Smallest fix: merge the caller ref with the internal one (the idiom exists asuseMergedRefinsrc/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 controlledMessageFold, therequestAnimationFrameclear offoldedFromerases the reader's-place restore whenever the host commits the close after the next paint (e.g. astartTransition-wrapped or async store update); the restore effect then seesnulland the page jumps. Verified: sync commit corrects scroll, 60 ms late commit doesn't. ClearfoldedFromonly once the close has been applied or definitively abandoned (next toggle /openback to true). - file-for-later
src/ui/message.tsx:197, 213-214— grid layout keys on exactMessageAvatarcomponent identity + direct-childhas-[>selectors; a wrapped/copiedMessageAvatarsilently 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]whereh-128is 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
left a comment
There was a problem hiding this comment.
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.copyhas no generation. It alwayssetCopied(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 recordsfocusOnOpenas"last"before the menu actually opens. The effect that focuses an item and resets the flag (:233-238) runs only whenopenflips. If a controlledonOpenChangeignores the open, the flag sticks, and the next open focuses the last item instead of the first. Set the flag only onceopenbecomes true, or reset it when the request does not commit. -
src/ui/message-actions.tsx:283— Dismissing the menu from outside drops focus ondocument.body. Blur and the popover's outside press callsetOpen(false), notclose(), 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.
74e70ca to
cefe7be
Compare
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.
cefe7be to
59cf89f
Compare
|
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
2. An ArrowUp the host ignored made the next open focus the last item
3. Closing the menu from outside dropped focus on the body
Commit: |
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.
59cf89f to
59a9bc2
Compare
Round 3 fixesA 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.
|
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.
The previous run merged a stack commit whose mid-glide test failed typecheck. That test is fixed on conversation/05-conversation.
Nothing triage asked to change still fails, so this request no longer stands.
d06a222 to
51539d7
Compare
Superseded by the completed stacked review; blocking fixes were addressed and the rebased stack is being landed.
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
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 passdefaultOpento keep the newest answer open. Place-keeping uses the nearest scroll container, not useConversation.Verification
bun run typecheckandbun run linton its own (the stack is checked commit by commit).bun run check,bun run build,bun run dep-guard,bun run contrast-testandbun run test:e2e(Playwright specs and the Storybook axe sweep in light and dark) pass.Closes #98