Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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%;
}

Expand Down
266 changes: 136 additions & 130 deletions packages/chat-ui/src/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1429,139 +1429,145 @@ function MessagePartsInner({
>
{showDayDivider && <DayDivider createdAt={item.createdAt} />}
<div className="chat-message-row">
{groupTimelineParts(item.parts, groupKey).map((group) => {
const key = group.key;
if (group.kind === "tool-activity") {
return <ToolActivityGroup key={key} rows={group.rows} />;
}
const part = group.part;
if (part.kind === "text" && part.turnFailed === true) {
const retryText = findRetryText(items, item);
return (
<FailedTurnStrip
key={key}
item={item}
detailText={part.text}
participants={participants}
currentUser={currentUser}
{...(retryText !== undefined ? { retryText } : {})}
{...(onRetryFailedTurn !== undefined
? { onRetryFailedTurn }
: {})}
{...(onWhatHappenedFailedTurn !== undefined
? { onWhatHappenedFailedTurn }
: {})}
/>
);
}
if (part.kind === "text") {
return (
<TextBubble
key={key}
text={part.text}
createdAt={item.createdAt}
sender={item.sender}
participants={participants}
currentUser={currentUser}
showHeader={showHeader}
{...(item.pendingStatus !== undefined
? { pendingStatus: item.pendingStatus, pendingNonce }
: {})}
{...(pendingActions !== undefined ? { pendingActions } : {})}
{...(onOpenProfile !== undefined ? { onOpenProfile } : {})}
{...(onFixConnection !== undefined ? { onFixConnection } : {})}
/>
);
}
if (part.kind === "event") {
return (
<EventLine
key={key}
part={part}
createdAt={item.createdAt}
participants={participants}
/>
);
}
if (part.kind === "file") {
return (
<FilePartView
key={key}
part={part}
{...(onOpenArtifact !== undefined ? { onOpenArtifact } : {})}
{...(onOpenArtifactInLibrary !== undefined
? { onOpenArtifactInLibrary }
: {})}
/>
);
}
// 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 (
<PartsRenderer key={key} parts={[toReactUiReasoning(part)]} />
);
}
if (part.kind === "block") {
return (
<BlockPartView
key={key}
block={part.block}
messageId={item.id}
{...(approvalActions !== undefined ? { approvalActions } : {})}
{...(blockResponses !== undefined ? { blockResponses } : {})}
{...(connectGithubActions !== undefined
? { connectGithubActions }
: {})}
{...(connectServiceActions !== undefined
? { connectServiceActions }
: {})}
/>
);
}
return <FallbackPart key={key} part={part} />;
})}
{(() => {
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 (
<div className="chat-message-actions">
{hasReactions && reactionActions !== undefined ? (
<ReactionChips
messageId={item.id}
reactions={item.reactions ?? []}
reactionActions={reactionActions}
<div className="chat-message-body">
{groupTimelineParts(item.parts, groupKey).map((group) => {
const key = group.key;
if (group.kind === "tool-activity") {
return <ToolActivityGroup key={key} rows={group.rows} />;
}
const part = group.part;
if (part.kind === "text" && part.turnFailed === true) {
const retryText = findRetryText(items, item);
return (
<FailedTurnStrip
key={key}
item={item}
detailText={part.text}
participants={participants}
currentUser={currentUser}
{...(retryText !== undefined ? { retryText } : {})}
{...(onRetryFailedTurn !== undefined
? { onRetryFailedTurn }
: {})}
{...(onWhatHappenedFailedTurn !== undefined
? { onWhatHappenedFailedTurn }
: {})}
/>
);
}
if (part.kind === "text") {
return (
<TextBubble
key={key}
text={part.text}
createdAt={item.createdAt}
sender={item.sender}
participants={participants}
currentUser={currentUser}
showHeader={showHeader}
{...(item.pendingStatus !== undefined
? { pendingStatus: item.pendingStatus, pendingNonce }
: {})}
{...(pendingActions !== undefined ? { pendingActions } : {})}
{...(onOpenProfile !== undefined ? { onOpenProfile } : {})}
{...(onFixConnection !== undefined
? { onFixConnection }
: {})}
/>
);
}
if (part.kind === "event") {
return (
<EventLine
key={key}
part={part}
createdAt={item.createdAt}
participants={participants}
/>
);
}
if (part.kind === "file") {
return (
<FilePartView
key={key}
part={part}
{...(onOpenArtifact !== undefined ? { onOpenArtifact } : {})}
{...(onOpenArtifactInLibrary !== undefined
? { onOpenArtifactInLibrary }
: {})}
/>
) : null}
{isPinned && pinActions !== undefined ? (
<PinToggleButton
);
}
// 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 (
<PartsRenderer key={key} parts={[toReactUiReasoning(part)]} />
);
}
if (part.kind === "block") {
return (
<BlockPartView
key={key}
block={part.block}
messageId={item.id}
pinned={true}
pinActions={pinActions}
{...(approvalActions !== undefined
? { approvalActions }
: {})}
{...(blockResponses !== undefined ? { blockResponses } : {})}
{...(connectGithubActions !== undefined
? { connectGithubActions }
: {})}
{...(connectServiceActions !== undefined
? { connectServiceActions }
: {})}
/>
) : null}
</div>
);
})()}
);
}
return <FallbackPart key={key} part={part} />;
})}
{(() => {
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 (
<div className="chat-message-actions">
{hasReactions && reactionActions !== undefined ? (
<ReactionChips
messageId={item.id}
reactions={item.reactions ?? []}
reactionActions={reactionActions}
/>
) : null}
{isPinned && pinActions !== undefined ? (
<PinToggleButton
messageId={item.id}
pinned={true}
pinActions={pinActions}
/>
) : null}
</div>
);
})()}
</div>
{offersSocialChrome && onOpenThread !== undefined && replyCount > 0 ? (
<ThreadAffordance
messageId={item.id}
Expand Down
18 changes: 18 additions & 0 deletions packages/chat-ui/test/reactions-and-pins.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,24 @@ describe("reaction chip row", () => {
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(), {
Expand Down
Loading