fix(ui): make multi-user session avatars legible (#128040)

* fix(ui): make multi-user session avatars legible

Render sidebar owners and participants as equal 18px peers while preserving owner precedence, row height, and single-owner sizing.

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

* test(ui): isolate disclosure anchor geometry

Measure the raw-details toggle independently from the widget menu overlay, whose close and reposition behavior is already covered separately and was producing a deterministic false CI failure.

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

* test(ui): stabilize completed-work spacing geometry

Disable the unrelated settle-in animation in the final-layout fixture and restore the original tight spacing contract. This prevents CI timing from sampling a mid-animation transform.

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

* test(ui): prove sidebar lead alignment

Assert that collaborative avatar artwork stays within the fixed 20px lead contract and leaves titles aligned with single-owner rows.

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

---------

Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
ClawSweeper
2026-08-22 15:25:20 -07:00
committed by GitHub
parent cc56ea534f
commit aca83ac00c
5 changed files with 304 additions and 43 deletions
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it } from "vitest";
import "../test-helpers/load-styles.ts";
import type { SessionCreatedActor } from "./session-owner-chip.ts";
import "./session-owner-chip.ts";
type OwnerChipElement = HTMLElement & {
owner: SessionCreatedActor | null;
participants: readonly SessionCreatedActor[];
participantCount: number;
size: "row" | "header";
updateComplete: Promise<boolean>;
};
const hasBrowserLayout = !navigator.userAgent.toLowerCase().includes("jsdom");
afterEach(() => {
document.body.replaceChildren();
});
async function mountOwnerChip(params: {
participants?: readonly SessionCreatedActor[];
participantCount?: number;
}) {
// SAFETY: the imported module registers this custom element with these reactive properties.
const chip = document.createElement("openclaw-session-owner-chip") as OwnerChipElement;
chip.owner = { type: "human", id: "profile-ada", label: "Ada" };
chip.size = "row";
chip.participants = params.participants ?? [];
chip.participantCount = params.participantCount ?? chip.participants.length;
document.body.append(chip);
await chip.updateComplete;
return chip;
}
describe.skipIf(!hasBrowserLayout)("session owner stack layout", () => {
it.each([
{
backSelector: ".session-owner-stack__back .viewer-avatar",
name: "one participant avatar",
participantCount: 1,
participants: [{ type: "human" as const, id: "profile-bob", label: "Bob" }],
},
{
backSelector: ".session-owner-stack__overflow",
name: "participant overflow",
participantCount: 2,
participants: [
{ type: "human" as const, id: "profile-bob", label: "Bob" },
{ type: "agent" as const, id: "research", label: "Research" },
],
},
])("keeps $name legible as an equal peer behind the owner", async (fixture) => {
const chip = await mountOwnerChip(fixture);
const stack = chip.querySelector<HTMLElement>(".session-owner-stack");
const back = chip.querySelector<HTMLElement>(fixture.backSelector);
const front = chip.querySelector<HTMLElement>(".session-owner-stack__front");
if (!stack || !back || !front) {
throw new Error("expected complete session owner stack");
}
const stackBounds = stack.getBoundingClientRect();
const backBounds = back.getBoundingClientRect();
const frontBounds = front.getBoundingClientRect();
expect({
backSize: [backBounds.width, backBounds.height],
frontSize: [frontBounds.width, frontBounds.height],
stackSize: [stackBounds.width, stackBounds.height],
}).toEqual({
backSize: [18, 18],
frontSize: [18, 18],
stackSize: [28, 20],
});
expect(backBounds.right - frontBounds.left).toBe(8);
expect(frontBounds.left - backBounds.left).toBe(10);
});
it("keeps the single-owner row avatar at its established size", async () => {
const chip = await mountOwnerChip({});
const owner = chip.querySelector<HTMLElement>(".session-owner-chip--row");
if (!owner) {
throw new Error("expected single owner row avatar");
}
const bounds = owner.getBoundingClientRect();
expect([bounds.width, bounds.height]).toEqual([20, 20]);
expect(chip.querySelector(".session-owner-stack")).toBeNull();
});
});
@@ -388,30 +388,16 @@ suite.define(() => {
const widgetHost = page.locator(".chat-tool-card__widget-host");
const rawDetailsToggle = widgetHost.locator(".chat-tool-card__raw-toggle");
await rawDetailsToggle.waitFor({ state: "attached" });
const widgetActions = widgetHost.getByRole("button", { name: "Widget actions" });
const rawDetailsAction =
'.chat-tool-card__widget-actions wa-dropdown-item[value="raw-details"]';
const rawDetailsItem = widgetHost.locator(rawDetailsAction);
await page.locator(".chat-thread").evaluate((thread) => {
thread.scrollTop = thread.scrollHeight;
});
await waitForChatScrollIdle(page);
expect(Math.abs(await chatThreadDistanceFromBottom(page))).toBeLessThanOrEqual(2);
const traces: Record<string, DisclosureFrame[]> = {};
await widgetActions.click();
await rawDetailsItem.waitFor({ state: "visible" });
traces.rawDetailsEndExpand = await toggleDisclosureWithFrameTrace(
page,
rawDetailsToggle,
rawDetailsAction,
);
await widgetActions.click();
await rawDetailsItem.waitFor({ state: "visible" });
traces.rawDetailsEndCollapse = await toggleDisclosureWithFrameTrace(
page,
rawDetailsToggle,
rawDetailsAction,
);
// Menu selection already proves it clicks this toggle; exclude the popup's
// own close/reposition geometry from the transcript-anchor measurement.
traces.rawDetailsEndExpand = await toggleDisclosureWithFrameTrace(page, rawDetailsToggle);
traces.rawDetailsEndCollapse = await toggleDisclosureWithFrameTrace(page, rawDetailsToggle);
await rawDetailsToggle.evaluate((button) => {
const row = button.closest<HTMLElement>(".chat-virtual-row");
@@ -423,13 +409,7 @@ suite.define(() => {
thread.scrollTop += Math.round(rowTop - thread.clientHeight / 2);
});
await waitForChatScrollIdle(page);
await widgetActions.click();
await rawDetailsItem.waitFor({ state: "visible" });
traces.rawDetailsMiddleExpand = await toggleDisclosureWithFrameTrace(
page,
rawDetailsToggle,
rawDetailsAction,
);
traces.rawDetailsMiddleExpand = await toggleDisclosureWithFrameTrace(page, rawDetailsToggle);
if (artifactDir) {
await fs.mkdir(artifactDir, { recursive: true });
@@ -448,13 +428,7 @@ suite.define(() => {
path: path.join(artifactDir, "raw-details-geometry-dark.png"),
});
}
await widgetActions.click();
await rawDetailsItem.waitFor({ state: "visible" });
traces.rawDetailsMiddleCollapse = await toggleDisclosureWithFrameTrace(
page,
rawDetailsToggle,
rawDetailsAction,
);
traces.rawDetailsMiddleCollapse = await toggleDisclosureWithFrameTrace(page, rawDetailsToggle);
const video = page.video();
await context.close();
if (artifactDir) {
+201 -1
View File
@@ -1,7 +1,7 @@
// Control UI E2E tests cover session ownership dormancy and owner filtering.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import type { BrowserContext, Page } from "playwright";
import { expect as expectBrowser } from "playwright/test";
import { afterEach, expect, it } from "vitest";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
@@ -13,6 +13,12 @@ const suite = createControlUiE2eSuite({
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const uiProofArtifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "drafts-ux");
const sessionOwnerProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"session-owner-stack",
);
let page: Page | undefined;
function sessionsList(owners: [string, string]) {
@@ -67,6 +73,72 @@ function draftSessionsList() {
return result;
}
function collaborativeSessionsList() {
const ada = {
type: "human" as const,
id: "profile-ada",
label: "Ada",
avatarUrl: "/api/users/profile-ada/avatar?v=1",
};
const bob = {
type: "human" as const,
id: "profile-bob",
label: "Bob",
avatarUrl: "/api/users/profile-bob/avatar?v=1",
};
const carol = { type: "human" as const, id: "profile-carol", label: "Carol" };
return {
count: 3,
owners: [ada, bob, carol],
defaults: { contextTokens: null, model: null, modelProvider: null },
path: "",
sessions: [
{
key: "agent:main:collaboration",
kind: "direct",
label: "Fix issue #127689",
createdActor: ada,
owner: { actor: ada },
participants: [bob],
participantCount: 1,
updatedAt: 3,
},
{
key: "agent:main:release-planning",
kind: "direct",
label: "Release planning",
createdActor: bob,
owner: { actor: bob },
participants: [ada, { type: "agent" as const, id: "research", label: "Research" }],
participantCount: 2,
updatedAt: 2,
},
{
key: "agent:main:single-owner",
kind: "direct",
label: "Single-owner baseline",
createdActor: carol,
owner: { actor: carol },
updatedAt: 1,
},
],
ts: 1,
};
}
async function createAvatarPng(context: BrowserContext, background: string, label: string) {
const avatarPage = await context.newPage();
try {
await avatarPage.setViewportSize({ width: 64, height: 64 });
await avatarPage.setContent(
`<body style="margin:0;width:64px;height:64px;display:grid;place-items:center;background:${background};color:white;font:700 26px system-ui">${label}</body>`,
);
return await avatarPage.screenshot({ animations: "disabled", type: "png" });
} finally {
await avatarPage.close().catch(() => {});
}
}
async function captureUiProof(targetPage: Page, fileName: string) {
if (!captureUiProofEnabled) {
return;
@@ -79,6 +151,17 @@ async function captureUiProof(targetPage: Page, fileName: string) {
});
}
async function captureSessionOwnerProof(targetPage: Page, fileName: string) {
if (!captureUiProofEnabled) {
return;
}
await mkdir(sessionOwnerProofArtifactDir, { recursive: true });
await targetPage.locator(".sidebar-sessions").screenshot({
animations: "disabled",
path: path.join(sessionOwnerProofArtifactDir, fileName),
});
}
async function openSidebarSortMenu(targetPage: Page) {
const filterAndSort = targetPage.getByRole("button", { name: "Filter & sort" });
await expect.poll(() => filterAndSort.count(), { timeout: 2_000 }).toBe(1);
@@ -109,6 +192,123 @@ suite.define(() => {
page = undefined;
});
it("keeps collaborative owner stacks legible without shifting session rows", async () => {
const context = await suite.browser.newContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
page = currentPage;
const [adaAvatar, bobAvatar] = await Promise.all([
createAvatarPng(context, "#3f6f76", "A"),
createAvatarPng(context, "#985b42", "B"),
]);
await currentPage.route("**/api/users/profile-ada/avatar*", (route) =>
route.fulfill({ body: adaAvatar, contentType: "image/png", status: 200 }),
);
await currentPage.route("**/api/users/profile-bob/avatar*", (route) =>
route.fulfill({ body: bobAvatar, contentType: "image/png", status: 200 }),
);
await installMockGateway(currentPage, {
hasMultipleSessionSharingIdentities: true,
sessionKey: "agent:main:collaboration",
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
methodResponses: { "sessions.list": collaborativeSessionsList() },
});
await currentPage.goto(`${suite.server?.baseUrl ?? ""}chat`);
const collaborativeRow = currentPage.locator('[data-session-key="agent:main:collaboration"]');
const overflowRow = currentPage.locator('[data-session-key="agent:main:release-planning"]');
const singleOwnerRow = currentPage.locator('[data-session-key="agent:main:single-owner"]');
await collaborativeRow.waitFor();
await overflowRow.waitFor();
await singleOwnerRow.waitFor();
await expect.poll(() => collaborativeRow.locator(".session-owner-stack img").count()).toBe(2);
await expect
.poll(() =>
collaborativeRow
.locator(".session-owner-stack img")
.evaluateAll((images) =>
images.every(
(image) =>
image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0,
),
),
)
.toBe(true);
const geometry = await collaborativeRow.evaluate((row) => {
const stack = row.querySelector<HTMLElement>(".session-owner-stack");
const back = row.querySelector<HTMLElement>(".session-owner-stack__back .viewer-avatar");
const front = row.querySelector<HTMLElement>(".session-owner-stack__front");
const slot = row.querySelector<HTMLElement>(".sidebar-session-indicator");
const text = row.querySelector<HTMLElement>(".sidebar-recent-session__text");
if (!stack || !back || !front || !slot || !text) {
throw new Error("expected complete collaborative session row");
}
const stackBounds = stack.getBoundingClientRect();
const backBounds = back.getBoundingClientRect();
const frontBounds = front.getBoundingClientRect();
const slotBounds = slot.getBoundingClientRect();
const textBounds = text.getBoundingClientRect();
return {
backSize: [backBounds.width, backBounds.height],
centerDelta:
stackBounds.left + stackBounds.width / 2 - (slotBounds.left + slotBounds.width / 2),
frontSize: [frontBounds.width, frontBounds.height],
overlap: backBounds.right - frontBounds.left,
reveal: frontBounds.left - backBounds.left,
slotWidth: slotBounds.width,
stackSize: [stackBounds.width, stackBounds.height],
textGap: textBounds.left - stackBounds.right,
};
});
expect(geometry).toEqual({
backSize: [18, 18],
centerDelta: 0,
frontSize: [18, 18],
overlap: 8,
reveal: 10,
slotWidth: 20,
stackSize: [28, 20],
textGap: 4,
});
await expectBrowser(overflowRow.locator(".session-owner-stack__overflow")).toHaveText("+2");
const rowHeights = await Promise.all([
collaborativeRow.evaluate((row) => row.getBoundingClientRect().height),
singleOwnerRow.evaluate((row) => row.getBoundingClientRect().height),
]);
expect(rowHeights[0]).toBeCloseTo(rowHeights[1] ?? 0, 5);
const titleLefts = await Promise.all(
[collaborativeRow, singleOwnerRow].map((row) =>
row
.locator(".sidebar-recent-session__text")
.evaluate((text) => text.getBoundingClientRect().left),
),
);
expect(titleLefts[0]).toBeCloseTo(titleLefts[1] ?? 0, 5);
if (captureUiProofEnabled) {
const legacyStyles = await currentPage.addStyleTag({
content: `
.session-owner-stack { width: 24px; }
.session-owner-stack__back { width: 14px; height: 14px; }
.session-owner-stack__back .viewer-avatar,
.session-owner-stack__overflow { width: 14px; height: 14px; font-size: 7px; }
.session-owner-stack__front { width: 20px; height: 20px; }
`,
});
await captureSessionOwnerProof(currentPage, "00-before-light.png");
await currentPage.evaluate(() =>
document.documentElement.setAttribute("data-theme-mode", "dark"),
);
await captureSessionOwnerProof(currentPage, "01-before-dark.png");
await legacyStyles.evaluate((style) => style.parentNode?.removeChild(style));
await captureSessionOwnerProof(currentPage, "02-after-dark.png");
await currentPage.evaluate(() =>
document.documentElement.setAttribute("data-theme-mode", "light"),
);
await captureSessionOwnerProof(currentPage, "03-after-light.png");
}
});
it("shows permanent owner chips and filters existing custom groups", async () => {
const context = await suite.browser.newContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
@@ -1273,8 +1273,9 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
])("balances completed-work spacing on $label", async ({ width, hasTouch, expectedGap }) => {
const page = await openBrowserPage(width, 720, { hasTouch, isolated: true });
try {
// Isolate the final-layout contract from the 200ms settle-in transform.
await page.setContent(
`<!doctype html><html><head><style>${readUiCss()}</style></head><body>${completedWorkSpacingHtml()}</body></html>`,
`<!doctype html><html><head><style>${readUiCss()}</style><style>.chat-group--work { animation: none; }</style></head><body>${completedWorkSpacingHtml()}</body></html>`,
);
await waitForLayoutSettled(page, "[data-spacing-row], .chat-group--work");
@@ -1299,9 +1300,8 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
};
});
// Browser font metrics can shift subpixel geometry by up to two CSS pixels.
expect(Math.abs(gaps.before - expectedGap)).toBeLessThanOrEqual(2);
expect(Math.abs(gaps.after - expectedGap)).toBeLessThanOrEqual(2);
expect(gaps.before).toBeCloseTo(expectedGap, 0);
expect(gaps.after).toBeCloseTo(expectedGap, 0);
} finally {
await closeBrowserPage(page);
}
+6 -6
View File
@@ -70,7 +70,7 @@ openclaw-session-owner-chip {
.session-owner-stack {
position: relative;
display: inline-flex;
width: 24px;
width: 28px;
height: 20px;
align-items: center;
justify-content: flex-end;
@@ -81,17 +81,15 @@ openclaw-session-owner-chip {
z-index: 0;
left: 0;
display: inline-flex;
width: 14px;
height: 14px;
}
.session-owner-stack__back .viewer-avatar,
.session-owner-stack__overflow {
width: 14px;
height: 14px;
width: 18px;
height: 18px;
border: 1px solid var(--sidebar-bg, var(--bg));
border-radius: var(--radius-full);
font-size: 7px;
font-size: 8px;
}
.session-owner-stack__overflow {
@@ -107,6 +105,8 @@ openclaw-session-owner-chip {
.session-owner-stack__front {
position: relative;
z-index: 1;
width: 18px;
height: 18px;
}
/* Leading slot shared by every sidebar row: the row's own artwork plus a run