diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index 6e8a1770a..c27f4b5d9 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -1176,9 +1176,12 @@ .chat-event-line { display: flex; align-items: center; - justify-content: center; + justify-content: flex-start; gap: 0.5rem; - margin: 0.15rem 0; + /* CL-6772: same left gutter as failed-turn / gen-ui blocks — system + notices sit under the message column, never centered and never on + the signed-in user's right edge. */ + margin: 0.15rem 0 0.15rem 2.9rem; padding: 0.2rem 0.5rem; font-size: 0.75rem; color: var(--muted-foreground); diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index f5a7d11bb..baac298bf 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -923,6 +923,19 @@ export function offersMessageSocialChrome(item: MessageItem): boolean { }); } +/** + * System notices (event-only rows) are never "own" for any viewer — + * DESIGN.md Message Alignment and CL-6772. Join / rename / membership + * lines often carry the acting principal as `sender`, but they still + * align left; treating them as own put them on the signed-in user's + * right edge. + */ +export function isSystemNoticeItem(item: MessageItem): boolean { + return ( + item.parts.length > 0 && item.parts.every((part) => part.kind === "event") + ); +} + /** * 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 @@ -1373,9 +1386,12 @@ function MessagePartsInner({ // `offersMessageSocialChrome`. const isPending = item.pendingStatus !== undefined; const offersSocialChrome = !isPending && offersMessageSocialChrome(item); + // System notices (join / rename / membership) never read as own even when + // this viewer triggered them — see `isSystemNoticeItem` (CL-6772). const isOwn = currentUser !== undefined && item.sender !== undefined && + !isSystemNoticeItem(item) && localPartOf(item.sender.address) === currentUser.principalId; const contextMenu = useContextMenuState(); const menu = offersSocialChrome diff --git a/packages/chat-ui/test/own-message-alignment.test.tsx b/packages/chat-ui/test/own-message-alignment.test.tsx index a395b8f2e..d1ca6eec4 100644 --- a/packages/chat-ui/test/own-message-alignment.test.tsx +++ b/packages/chat-ui/test/own-message-alignment.test.tsx @@ -95,14 +95,35 @@ describe("own-message alignment is per viewer, not per message", () => { }, ]; const el = await mount(items, { principalId: "sawyer" }); - // The acting principal IS this reader, so the group itself reads as - // "own" — but an event line has no bubble/avatar to align, and the - // rendered `.chat-event-line` carries no own/alignment styling of its - // own kind, so a system notice always reads the same regardless of - // who triggered it. + // System notices align left for every viewer (CL-6772 / DESIGN.md) — + // even when this reader caused the event. Marking the group as own + // would put them on the signed-in user's right edge. expect( el.querySelector(".chat-message-group")?.getAttribute("data-own"), - ).toBe("true"); + ).toBe("false"); + expect(el.querySelector(".chat-event-line")).not.toBeNull(); + expect(el.querySelector(".chat-bubble-row")).toBeNull(); + }); + + test("a join event posted under the viewing user's address stays left-aligned, never own", async () => { + const items: MessageItem[] = [ + { + id: "join_1", + createdAt: "2026-01-01T00:00:00.000Z", + sender: { name: null, address: "sawyer@agents.example" }, + parts: [ + { + kind: "event", + event: "workbench.agent-joined", + data: { address: "ins_scout@agents.example" }, + }, + ], + }, + ]; + const el = await mount(items, { principalId: "sawyer" }); + expect( + el.querySelector(".chat-message-group")?.getAttribute("data-own"), + ).toBe("false"); expect(el.querySelector(".chat-event-line")).not.toBeNull(); expect(el.querySelector(".chat-bubble-row")).toBeNull(); }); diff --git a/packages/chat-ui/test/system-join-edge.test.tsx b/packages/chat-ui/test/system-join-edge.test.tsx new file mode 100644 index 000000000..6281d1689 --- /dev/null +++ b/packages/chat-ui/test/system-join-edge.test.tsx @@ -0,0 +1,103 @@ +// CL-6772: join / system notice rows sit on the left message edge — never +// the signed-in user's right edge — and keep the CL-6739 social-chrome gate. +import { afterEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; + +import type { MessageItem } from "../src/api"; +import type { CurrentUser } from "../src/timeline"; +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: readonly MessageItem[], currentUser?: CurrentUser) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + , + ); + }); + return container; +} + +function joinItem(senderAddress: string): MessageItem { + return { + id: "join_1", + createdAt: "2026-01-01T00:00:00.000Z", + sender: { name: null, address: senderAddress }, + parts: [ + { + kind: "event", + event: "workbench.agent-joined", + data: { address: "ins_scout@agents.example" }, + }, + ], + }; +} + +describe("CL-6772: join / system notices stay on the left edge", () => { + test("join row is never data-own, even when the viewer authored the post", async () => { + const el = await mount([joinItem("sawyer@agents.example")], { + principalId: "sawyer", + }); + const group = el.querySelector(".chat-message-group"); + expect(group?.getAttribute("data-own")).toBe("false"); + expect(el.querySelector(".chat-event-line")).not.toBeNull(); + expect(el.querySelector(".chat-bubble-row")).toBeNull(); + }); + + test("join row still hides reaction / reply / overflow chrome", async () => { + const el = await mount([joinItem("sawyer@agents.example")], { + principalId: "sawyer", + }); + 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(); + }); + + test("event-line CSS anchors left under the message gutter, not centered", () => { + const cssPath = join( + dirname(fileURLToPath(import.meta.url)), + "../src/styles.css", + ); + const css = readFileSync(cssPath, "utf8"); + const block = css.match(/\.chat-event-line\s*\{[^}]+\}/); + expect(block).not.toBeNull(); + expect(block?.[0]).toContain("justify-content: flex-start"); + expect(block?.[0]).not.toContain("justify-content: center"); + // Same left gutter the failed-turn strip and gen-ui blocks use, so the + // notice sits under the message column rather than the row's midpoint. + expect(block?.[0]).toMatch(/2\.9rem/); + }); +});