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
61 changes: 49 additions & 12 deletions packages/chat-ui/src/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -1366,7 +1397,7 @@ function MessagePartsInner({
const groupKey = item.clientId ?? item.id;

function handleContextMenu(event: ReactMouseEvent<HTMLDivElement>) {
if (isPending || isContextMenuEmpty(menu)) return;
if (!offersSocialChrome || isContextMenuEmpty(menu)) return;
event.preventDefault();
contextMenu.show(event.clientX, event.clientY, menu, event.currentTarget);
}
Expand Down Expand Up @@ -1479,17 +1510,23 @@ 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
// 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.
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 (
<div className="chat-message-actions">
{hasReactions && reactionActions !== undefined ? (
Expand All @@ -1509,7 +1546,7 @@ function MessagePartsInner({
</div>
);
})()}
{!isPending && onOpenThread !== undefined && replyCount > 0 ? (
{offersSocialChrome && onOpenThread !== undefined && replyCount > 0 ? (
<ThreadAffordance
messageId={item.id}
meta={threadMeta}
Expand All @@ -1518,7 +1555,7 @@ function MessagePartsInner({
onOpen={() => onOpenThread(item.id)}
/>
) : null}
{!isPending ? (
{offersSocialChrome ? (
<MessageHoverToolbar
messageId={item.id}
menu={menu}
Expand Down
236 changes: 236 additions & 0 deletions packages/chat-ui/test/system-row-chrome.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// 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(
<WorkbenchTimeline
items={items}
onOpenThread={socialActions.onOpenThread}
reactionActions={socialActions.reactionActions}
pinActions={socialActions.pinActions}
{...(extra.onFixConnection !== undefined
? { onFixConnection: extra.onFixConnection }
: {})}
/>,
);
});
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();
});
});
Loading