From 82a76b0b98c3c0af7b1216bbf441aab680f4206a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 09:25:07 -0700 Subject: [PATCH 1/3] Add failing coverage for system-row social chrome (CL-6739) Assert error, join, connect, and system timeline rows expose no reaction/reply/overflow chrome, while Fix this connection remains on classified inference failures. --- .../chat-ui/test/system-row-chrome.test.tsx | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 packages/chat-ui/test/system-row-chrome.test.tsx diff --git a/packages/chat-ui/test/system-row-chrome.test.tsx b/packages/chat-ui/test/system-row-chrome.test.tsx new file mode 100644 index 000000000..1d0a106b9 --- /dev/null +++ b/packages/chat-ui/test/system-row-chrome.test.tsx @@ -0,0 +1,233 @@ +// CL-6739: error, join, connect, and system rows must not expose message +// social chrome (reaction / reply / overflow). Fix-this-connection recovery +// on classified inference failures stays; only the social cluster goes. +import { afterEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; + +import type { MessageItem } from "../src/api"; +import { WorkbenchTimeline } from "../src/timeline"; +import type { PinActions, ReactionActions } from "../src/timeline"; + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + if (root !== null) act(() => root?.unmount()); + container?.remove(); + container = null; + root = null; +}); + +const socialActions = { + onOpenThread: () => undefined, + reactionActions: { onToggle: () => undefined } satisfies ReactionActions, + pinActions: { + onPin: () => undefined, + onUnpin: () => undefined, + } satisfies PinActions, +}; + +async function mount(items: MessageItem[], extra: { onFixConnection?: () => void } = {}) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + , + ); + }); + return container; +} + +function expectNoSocialChrome(el: HTMLElement) { + expect(el.querySelector(".chat-hover-toolbar")).toBeNull(); + expect(el.querySelector(".chat-reaction-add")).toBeNull(); + expect(el.querySelector(".chat-hover-reply")).toBeNull(); + expect(el.querySelector(".chat-hover-ellipsis")).toBeNull(); + expect(el.querySelector(".chat-reaction-chip")).toBeNull(); + expect(el.querySelector(".chat-thread-affordance")).toBeNull(); +} + +describe("CL-6739: system / error / connect rows hide social chrome", () => { + test("a join event row has no reaction, reply, or overflow", async () => { + const el = await mount([ + { + id: "join_1", + createdAt: "2026-01-01T00:00:00.000Z", + parts: [ + { + kind: "event", + event: "workbench.agent-joined", + data: { address: "ins_scout@agents.example" }, + }, + ], + sender: { name: null, address: "system@agents.example" }, + } as MessageItem, + ]); + + expect(el.querySelector(".chat-event-line")).not.toBeNull(); + expectNoSocialChrome(el); + }); + + test("a generic system event row has no reaction, reply, or overflow", async () => { + const el = await mount([ + { + id: "sys_1", + createdAt: "2026-01-01T00:00:00.000Z", + parts: [ + { + kind: "event", + event: "workbench.membership-changed", + data: {}, + }, + ], + sender: { name: null, address: "system@agents.example" }, + } as MessageItem, + ]); + + expect(el.querySelector(".chat-event-line")).not.toBeNull(); + expectNoSocialChrome(el); + }); + + test("a failed-turn error strip has no reaction, reply, or overflow", async () => { + const el = await mount([ + { + id: "fail_1", + createdAt: "2026-01-01T00:00:00.000Z", + parts: [ + { + kind: "text", + text: "I didn't get that one — send it again.", + turnFailed: true, + }, + ], + sender: { name: null, address: "ins_echo@agents.example" }, + } as MessageItem, + ]); + + expect(el.querySelector(".chat-turn-failed")).not.toBeNull(); + expectNoSocialChrome(el); + }); + + test("a connect-github card has no reaction, reply, or overflow", async () => { + const el = await mount([ + { + id: "connect_gh", + createdAt: "2026-01-01T00:00:00.000Z", + parts: [ + { + kind: "block", + block: { + type: "connect-github", + data: { requiredForTemplate: "github", state: "disconnected" }, + }, + }, + ], + sender: { name: "Myra", address: "myra@agents.example" }, + } as MessageItem, + ]); + + expectNoSocialChrome(el); + }); + + test("a connect-service card has no reaction, reply, or overflow", async () => { + const el = await mount([ + { + id: "connect_svc", + createdAt: "2026-01-01T00:00:00.000Z", + parts: [ + { + kind: "block", + block: { + type: "connect-service", + data: { + connectorId: "gmail", + displayName: "Gmail", + reason: "Connect Gmail so I can send this for you.", + }, + }, + }, + ], + sender: { name: "Myra", address: "myra@agents.example" }, + } as MessageItem, + ]); + + expectNoSocialChrome(el); + }); + + test("a classified inference failure keeps Fix this connection but drops social chrome", async () => { + const el = await mount( + [ + { + id: "cred_fail", + createdAt: "2026-01-01T00:00:00.000Z", + parts: [ + { + kind: "text", + text: "This agent could not complete your request due to a credential error [HTTP 401]: invalid api key", + }, + ], + sender: { name: null, address: "prn_fixture1@agents.example" }, + } as MessageItem, + ], + { onFixConnection: () => undefined }, + ); + + expect(el.querySelector(".chat-bubble-fix-connection")).not.toBeNull(); + expect(el.textContent).toContain("Fix this connection"); + expectNoSocialChrome(el); + }); + + test("an ordinary text message still gets the social hover toolbar", async () => { + const el = await mount([ + { + id: "m_ok", + createdAt: "2026-01-01T00:00:00.000Z", + parts: [{ kind: "text", text: "ship it" }], + sender: { name: "Researcher", address: "researcher@agents.example" }, + } as MessageItem, + ]); + + const toolbar = el.querySelector(".chat-hover-toolbar"); + expect(toolbar).not.toBeNull(); + expect(toolbar?.querySelector(".chat-reaction-add")).not.toBeNull(); + expect(toolbar?.querySelector(".chat-hover-reply")).not.toBeNull(); + expect(toolbar?.querySelector(".chat-hover-ellipsis")).not.toBeNull(); + }); + + test("right-click on a join row never opens the overflow menu", async () => { + const el = await mount([ + { + id: "join_2", + createdAt: "2026-01-01T00:00:00.000Z", + parts: [ + { + kind: "event", + event: "workbench.agent-joined", + data: { address: "ins_scout@agents.example" }, + }, + ], + sender: { name: null, address: "system@agents.example" }, + } as MessageItem, + ]); + + const group = el.querySelector(".chat-message-group") as HTMLElement; + await act(async () => { + group.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true }), + ); + }); + + expect(document.querySelector('[data-slot="menu-content"]')).toBeNull(); + }); +}); From c6764b3dd39dff4c96a3f6293707cc7aa51707cc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 09:25:11 -0700 Subject: [PATCH 2/3] Hide social chrome on error, join, and connect rows (CL-6739) Gate reaction, reply, and overflow affordances behind offersMessageSocialChrome so system/event, failed-turn, connect-card, and classified inference-failure rows stay quiet. Fix this connection recovery on classified failures is unchanged. --- packages/chat-ui/src/timeline.tsx | 61 +++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index cd99cfd01..f5a7d11bb 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -895,6 +895,34 @@ function messageText(item: MessageItem): string { .join("\n"); } +/** + * Whether a timeline row should expose message social chrome (CL-6739) — + * add-reaction, reply-in-thread, overflow/ellipsis, reaction chips, and the + * thread-summary affordance. System event lines, failed-turn strips, + * connect cards, and classified inference-failure bubbles are not + * conversational messages; reacting to "Scout joined" or pinning a + * connect-github card is noise. Fix-this-connection recovery on a + * classified failure lives on the bubble itself (`TextBubble`) and is + * independent of this gate. + */ +export function offersMessageSocialChrome(item: MessageItem): boolean { + if (item.parts.length === 0) return false; + return !item.parts.every((part) => { + if (part.kind === "event") return true; + if (part.kind === "text" && part.turnFailed === true) return true; + if (part.kind === "text" && isClassifiedInferenceFailureText(part.text)) { + return true; + } + if (part.kind === "block") { + return ( + part.block.type === "connect-github" || + part.block.type === "connect-service" + ); + } + return false; + }); +} + /** * A failed send's inline recovery row (CL-6251/CL-5879): appended below * the bubble text of the exact same message group a confirmed message @@ -1340,21 +1368,24 @@ function MessagePartsInner({ // issued an id for yet (see `TimelineMessageItem.pendingStatus`) offers // none of the round-trips below — reactions, pin, thread, context menu — // since every one of them targets a server-issued message id that - // doesn't exist yet for this item. + // doesn't exist yet for this item. System / error / connect rows + // (CL-6739) likewise offer none of the social chrome — see + // `offersMessageSocialChrome`. const isPending = item.pendingStatus !== undefined; + const offersSocialChrome = !isPending && offersMessageSocialChrome(item); const isOwn = currentUser !== undefined && item.sender !== undefined && localPartOf(item.sender.address) === currentUser.principalId; const contextMenu = useContextMenuState(); - const menu = isPending - ? { entries: [] } - : buildMessageMenu({ + const menu = offersSocialChrome + ? buildMessageMenu({ item, threadAffordanceMode, onOpenThread, pinActions, - }); + }) + : { entries: [] }; const replyCount = threadMeta?.replyCount ?? 0; const pendingNonce = item.pendingNonce ?? item.id; // Same identity `WorkbenchTimeline`'s render loop keys this whole group @@ -1366,7 +1397,7 @@ function MessagePartsInner({ const groupKey = item.clientId ?? item.id; function handleContextMenu(event: ReactMouseEvent) { - if (isPending || isContextMenuEmpty(menu)) return; + if (!offersSocialChrome || isContextMenuEmpty(menu)) return; event.preventDefault(); contextMenu.show(event.clientX, event.clientY, menu, event.currentTarget); } @@ -1479,7 +1510,9 @@ function MessagePartsInner({ })} {(() => { const hasReactions = - reactionActions !== undefined && (item.reactions?.length ?? 0) > 0; + 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 @@ -1487,9 +1520,13 @@ function MessagePartsInner({ // 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. - const isPinned = pinActions !== undefined && item.pinned === true; - if (isPending || (!hasReactions && !isPinned)) return null; + // 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 ? ( @@ -1509,7 +1546,7 @@ function MessagePartsInner({
); })()} - {!isPending && onOpenThread !== undefined && replyCount > 0 ? ( + {offersSocialChrome && onOpenThread !== undefined && replyCount > 0 ? ( onOpenThread(item.id)} /> ) : null} - {!isPending ? ( + {offersSocialChrome ? ( Date: Sun, 23 Aug 2026 09:37:37 -0700 Subject: [PATCH 3/3] Format system-row-chrome test for Prettier (CL-6739) --- packages/chat-ui/test/system-row-chrome.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/chat-ui/test/system-row-chrome.test.tsx b/packages/chat-ui/test/system-row-chrome.test.tsx index 1d0a106b9..92e608922 100644 --- a/packages/chat-ui/test/system-row-chrome.test.tsx +++ b/packages/chat-ui/test/system-row-chrome.test.tsx @@ -29,7 +29,10 @@ const socialActions = { } satisfies PinActions, }; -async function mount(items: MessageItem[], extra: { onFixConnection?: () => void } = {}) { +async function mount( + items: MessageItem[], + extra: { onFixConnection?: () => void } = {}, +) { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);