mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user