From 74490753f4f671aebfafda808fe11eff716349f2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 08:18:05 -0700 Subject: [PATCH 1/3] Anchor reaction pills under the message bubble Wrap bubble+actions in .chat-message-body so reaction chips sit under the bubble instead of as a loose sibling of the header+bubble cluster. Closes CL-6661 --- packages/chat-ui/src/styles.css | 12 +++++++++++- packages/chat-ui/src/timeline.tsx | 2 ++ .../chat-ui/test/reactions-and-pins.test.tsx | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index c27f4b5d9..0902d567b 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -2862,6 +2862,16 @@ margin-left: 0; } +/* Message body column: bubble/parts + reaction/pin row share one flex + column so chips sit under the bubble instead of as a loose sibling of + the whole header+bubble cluster (CL-6661). */ +.chat-message-body { + display: flex; + flex-direction: column; + min-width: 0; + max-width: 100%; +} + /* Existing reaction chips + pin toggle, indented to align under the bubble text the same way `.chat-thread-affordance` does. Chips are content — they always show once a reaction exists. The pin toggle fades in on @@ -2872,7 +2882,7 @@ display: flex; align-items: center; gap: 0.35rem; - margin: -0.1rem 0 0.2rem 2.9rem; + margin: 0.05rem 0 0.15rem 2.9rem; max-width: 100%; } diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index baac298bf..96631815b 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -1429,6 +1429,7 @@ function MessagePartsInner({ > {showDayDivider && }
+
{groupTimelineParts(item.parts, groupKey).map((group) => { const key = group.key; if (group.kind === "tool-activity") { @@ -1562,6 +1563,7 @@ function MessagePartsInner({
); })()} +
{offersSocialChrome && onOpenThread !== undefined && replyCount > 0 ? ( { expect(chip.dataset["reacted"]).toBe("false"); }); + test("reaction row sits under the bubble inside the message body column", async () => { + const el = await mount(messageWithReactions(), { + onToggle: () => {}, + }); + + const body = el.querySelector(".chat-message-body"); + expect(body).not.toBeNull(); + const bubble = body!.querySelector(".chat-bubble"); + const actions = body!.querySelector(".chat-message-actions"); + expect(bubble).not.toBeNull(); + expect(actions).not.toBeNull(); + // DOM order: bubble precedes the reaction row within the same body column. + const following = bubble!.compareDocumentPosition(actions!); + expect(following & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + test("clicking an existing chip toggles it — the host's onToggle, not local state", async () => { const calls: { messageId: string; emoji: string }[] = []; const el = await mount(messageWithReactions(), { From 218ac857087d34be31a4f6f8aa6ee9be6542ebfb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 09:24:34 -0700 Subject: [PATCH 2/3] Format files changed in this PR --- packages/chat-ui/src/timeline.tsx | 264 +++++++++++++++--------------- 1 file changed, 134 insertions(+), 130 deletions(-) diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index 96631815b..a8e457ccb 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -1430,139 +1430,143 @@ function MessagePartsInner({ {showDayDivider && }
- {groupTimelineParts(item.parts, groupKey).map((group) => { - const key = group.key; - if (group.kind === "tool-activity") { - return ; - } - const part = group.part; - if (part.kind === "text" && part.turnFailed === true) { - const retryText = findRetryText(items, item); - return ( - - ); - } - if (part.kind === "text") { - return ( - - ); - } - if (part.kind === "event") { - return ( - - ); - } - if (part.kind === "file") { - return ( - - ); - } - // The agent's own thinking still renders through react-ui, which - // owns the reasoning disclosure. Tool calls no longer do: they - // arrive here already folded into rounds by `groupTimelineParts` - // above, and react-ui's `ToolBlock` renders one call at a time - // with its arguments and result as `JSON.stringify` output. - if (part.kind === "reasoning") { - return ( - - ); - } - if (part.kind === "block") { - return ( - - ); - } - return ; - })} - {(() => { - const hasReactions = - offersSocialChrome && - reactionActions !== undefined && - (item.reactions?.length ?? 0) > 0; - // Unpinned messages offer no persistent glyph here — pinning - // itself stays reachable through the ellipsis menu's own - // "Pin"/"Unpin" entry (`buildMessageMenu`); this row only shows - // once there's something to show (a reaction, or a message - // already pinned, which needs a visible way to unpin). Before - // this, a pin toggle mounted for every message the moment a host - // wired `pinActions` at all, CSS-hidden until hover but present - // in the DOM under every line, greeting included. System / error - // / connect rows (CL-6739) never show this cluster either. - const isPinned = - offersSocialChrome && - pinActions !== undefined && - item.pinned === true; - if (!hasReactions && !isPinned) return null; - return ( -
- {hasReactions && reactionActions !== undefined ? ( - { + const key = group.key; + if (group.kind === "tool-activity") { + return ; + } + const part = group.part; + if (part.kind === "text" && part.turnFailed === true) { + const retryText = findRetryText(items, item); + return ( + + ); + } + if (part.kind === "text") { + return ( + + ); + } + if (part.kind === "event") { + return ( + - ) : null} - {isPinned && pinActions !== undefined ? ( - + ); + } + // The agent's own thinking still renders through react-ui, which + // owns the reasoning disclosure. Tool calls no longer do: they + // arrive here already folded into rounds by `groupTimelineParts` + // above, and react-ui's `ToolBlock` renders one call at a time + // with its arguments and result as `JSON.stringify` output. + if (part.kind === "reasoning") { + return ( + + ); + } + if (part.kind === "block") { + return ( + - ) : null} -
- ); - })()} + ); + } + return ; + })} + {(() => { + const hasReactions = + offersSocialChrome && + reactionActions !== undefined && + (item.reactions?.length ?? 0) > 0; + // Unpinned messages offer no persistent glyph here — pinning + // itself stays reachable through the ellipsis menu's own + // "Pin"/"Unpin" entry (`buildMessageMenu`); this row only shows + // once there's something to show (a reaction, or a message + // already pinned, which needs a visible way to unpin). Before + // this, a pin toggle mounted for every message the moment a host + // wired `pinActions` at all, CSS-hidden until hover but present + // in the DOM under every line, greeting included. System / error + // / connect rows (CL-6739) never show this cluster either. + const isPinned = + offersSocialChrome && + pinActions !== undefined && + item.pinned === true; + if (!hasReactions && !isPinned) return null; + return ( +
+ {hasReactions && reactionActions !== undefined ? ( + + ) : null} + {isPinned && pinActions !== undefined ? ( + + ) : null} +
+ ); + })()}
{offersSocialChrome && onOpenThread !== undefined && replyCount > 0 ? ( Date: Mon, 24 Aug 2026 09:48:19 -0700 Subject: [PATCH 3/3] Fix lint on reaction pill DOM order test --- packages/chat-ui/test/reactions-and-pins.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/chat-ui/test/reactions-and-pins.test.tsx b/packages/chat-ui/test/reactions-and-pins.test.tsx index d88ee44e9..a59858b69 100644 --- a/packages/chat-ui/test/reactions-and-pins.test.tsx +++ b/packages/chat-ui/test/reactions-and-pins.test.tsx @@ -84,12 +84,14 @@ describe("reaction chip row", () => { const body = el.querySelector(".chat-message-body"); expect(body).not.toBeNull(); - const bubble = body!.querySelector(".chat-bubble"); - const actions = body!.querySelector(".chat-message-actions"); + if (body === null) return; + const bubble = body.querySelector(".chat-bubble"); + const actions = body.querySelector(".chat-message-actions"); expect(bubble).not.toBeNull(); expect(actions).not.toBeNull(); + if (bubble === null || actions === null) return; // DOM order: bubble precedes the reaction row within the same body column. - const following = bubble!.compareDocumentPosition(actions!); + const following = bubble.compareDocumentPosition(actions); expect(following & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); });