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..a8e457ccb 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -1429,139 +1429,145 @@ 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 ? ( - + {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 ( + - ) : null} - {isPinned && pinActions !== undefined ? ( - + ); + } + 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 ? ( { 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(); + 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); + 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(), {