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
7 changes: 5 additions & 2 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions packages/chat-ui/src/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
33 changes: 27 additions & 6 deletions packages/chat-ui/test/own-message-alignment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
103 changes: 103 additions & 0 deletions packages/chat-ui/test/system-join-edge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<WorkbenchTimeline
items={items}
onOpenThread={socialActions.onOpenThread}
reactionActions={socialActions.reactionActions}
pinActions={socialActions.pinActions}
{...(currentUser !== undefined ? { currentUser } : {})}
/>,
);
});
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/);
});
});
Loading