fix(ui): align the sidebar to a shared grid and dedupe owner presence (#123938)

* test(ui): add sidebar alignment capture harness

* fix(ui): align sidebar rows to shared grid

* fix(ui): distinguish sidebar owner presence

* fix(ui): visually hide sidebar pages label

* fix(ui): collapse the hidden pages head row

* fix(ui): keep the floating pages action clickable

* fix(ui): scope facepile dedup to the rendered lead and reveal the pages editor on touch

ClawSweeper P1s on #123938: the facepile unconditionally excluded the
session creator even when the lead chip showed the archivist or nothing
at all, hiding a live viewer; renderSessionLeadingState now returns the
rendered owner identity as the single dedup source. The hover-revealed
pages editor gains the standard hoverless-pointer visibility override.
This commit is contained in:
Peter Steinberger
2026-08-14 20:42:37 -07:00
committed by GitHub
parent 4f4cef66c5
commit 295f2c0212
18 changed files with 374 additions and 91 deletions
+1 -1
View File
@@ -227,7 +227,7 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) {
export function renderAppSidebarPagesHead(host: AppSidebarRenderHost) {
return html`
<div class="sidebar-nav__head">
<span class="sidebar-recent-sessions__label-text">${t("nav.pages")}</span>
<span class="sidebar-recent-sessions__label-text sr-only">${t("nav.pages")}</span>
<button
type="button"
class="sidebar-nav__head-action"
@@ -313,6 +313,9 @@ function renderCatalogHostGroup(
aria-label=${errorHelp ? `${host.label}: ${errorHelp}` : host.label}
title=${errorHelp ?? host.label}
>
<span class="sidebar-session-group-toggle__lead" aria-hidden="true">
<span class="sidebar-session-group-toggle__icon">${icons.monitor}</span>
</span>
<span class="sidebar-session-catalog-host__label">${host.label}</span>
<span
class="sidebar-session-catalog-host__count ${host.error
@@ -475,6 +478,7 @@ function renderCatalogSessionRow(
}
}}
>
<span class="sidebar-session-indicator"></span>
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name hover-marquee">${label}</span>
</span>
@@ -40,6 +40,7 @@ import {
resolveSidebarSessionSubtitle,
} from "./session-row-subtitle.ts";
import type { SidebarMenusController } from "./sidebar-menus-controller.ts";
import { projectPresencePayload } from "./viewer-facepile.ts";
import "./elapsed-time.ts";
const SIDEBAR_VISIBLE_CHILD_SESSION_LIMIT = 4;
@@ -180,12 +181,22 @@ export function renderRecentSession(params: {
? session.archivedBy
: session.createdActor
: undefined;
const { running, leadingIndicator, trailingIndicator } = renderSessionLeadingState(
session,
pullRequestState,
ownerActor,
ownerAttribution,
);
const ownerId = ownerActor?.id?.trim();
const ownerViewing = ownerId
? projectPresencePayload(
host.sessionData.presencePayload,
host.sessionDataContext?.gateway.snapshot.selfUser?.id,
host.sessionData.presenceInstanceId,
).users.some((user) => user.id === ownerId && user.watchedSessions.includes(session.key))
: undefined;
const { running, leadingIndicator, trailingIndicator, renderedOwnerId } =
renderSessionLeadingState(
session,
pullRequestState,
ownerActor,
ownerAttribution,
ownerViewing,
);
const trailingDescription = session.isChild
? ""
: describeSessionTrailingState(session, pullRequestState);
@@ -242,8 +253,7 @@ export function renderRecentSession(params: {
requiredScope: "operator.write",
});
const rowDraggable = !session.isChild && groupWriteAccess.allowed;
// Empty 16/20px lead columns indent titles past section labels and siblings.
const showLead = leadingIndicator !== nothing || session.visibility === "draft";
// Always reserve the lead so every title shares the section-label text line.
const row = html`
<div
class=${rowClass}
@@ -278,18 +288,14 @@ export function renderRecentSession(params: {
aria-describedby=${[stateId, metaId].filter(Boolean).join(" ") || nothing}
@click=${(event: MouseEvent) => host.handleSessionRowClick(event, session)}
>
${showLead
? html`<span class="sidebar-session-indicator"
>${leadingIndicator}
${session.visibility === "draft"
? html`<span
class="session-row-draft-indicator"
title=${t("chat.sessionSharing.draft")}
>👻</span
>`
: nothing}</span
>`
: nothing}
<span class="sidebar-session-indicator"
>${leadingIndicator}
${session.visibility === "draft"
? html`<span class="session-row-draft-indicator" title=${t("chat.sessionSharing.draft")}
>👻</span
>`
: nothing}</span
>
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name hover-marquee"
>${session.archived
@@ -317,6 +323,7 @@ export function renderRecentSession(params: {
.selfUserId=${host.sessionDataContext?.gateway.snapshot.selfUser?.id}
.selfInstanceId=${host.sessionData.presenceInstanceId}
.sessionKey=${session.key}
.excludeUserId=${renderedOwnerId}
.maxVisible=${3}
variant="session"
></openclaw-viewer-facepile>
+12 -3
View File
@@ -1,4 +1,4 @@
import { html, nothing } from "lit";
import { html, nothing, type TemplateResult } from "lit";
import { t } from "../i18n/index.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import { icons } from "./icons.ts";
@@ -106,7 +106,13 @@ export function renderSessionLeadingState(
pullRequestState: SessionPullRequestIndicatorState,
ownerActor: SessionCreatedActor | null | undefined,
attribution: "created" | "archived",
) {
ownerViewing?: boolean,
): {
running: boolean;
leadingIndicator: TemplateResult | typeof nothing;
trailingIndicator: TemplateResult | typeof nothing;
renderedOwnerId?: string;
} {
const running = session.hasActiveRun;
const trailingIndicator = session.isChild
? nothing
@@ -159,11 +165,14 @@ export function renderSessionLeadingState(
return {
running,
leadingIndicator: renderSessionGlyph({
content: renderSessionOwnerChip(ownerActor, "row", attribution),
content: renderSessionOwnerChip(ownerActor, "row", attribution, ownerViewing),
running: false,
circular: true,
}),
trailingIndicator,
// Single source for facepile dedup: only the identity actually shown in
// the lead may be excluded, else attention/archived rows hide a viewer.
renderedOwnerId: ownerActor.id,
};
}
return {
+15 -9
View File
@@ -48,12 +48,14 @@ export function renderSessionOwnerChip(
createdActor: SessionCreatedActor | null | undefined,
size: "row" | "header",
attribution: "created" | "archived" = "created",
viewingNow?: boolean,
) {
return createdActor?.id
? html`<openclaw-session-owner-chip
.createdActor=${createdActor}
size=${size}
attribution=${attribution}
.viewingNow=${viewingNow}
></openclaw-session-owner-chip>`
: nothing;
}
@@ -84,18 +86,17 @@ function ownerHue(id: string): number {
}
/**
* Permanent session-owner avatar. Ownership is provenance, not presence:
* this chip is solid and never pulses/expires, in deliberate contrast to the
* translucent, ring-styled live-presence chips. Render only when the gateway
* has 2+ distinct creator identities (solo mode shows no attribution chrome).
* Human actors use only the durable profile projection carried by the session
* record. Actors without that source keep stable initials because provenance
* outlives live connection presence.
* Permanent session-owner avatar. Ownership is provenance, so the chip remains
* when its owner leaves; live viewing only changes avatar saturation. Render
* only when the gateway has 2+ distinct creator identities (solo mode shows no
* attribution chrome). Human actors use the durable profile projection carried
* by the session record; actors without it keep stable initials.
*/
class SessionOwnerChip extends OpenClawLightDomElement {
@property({ attribute: false }) createdActor: SessionCreatedActor | null = null;
@property({ type: String }) size: "row" | "header" = "row";
@property({ type: String }) attribution: "created" | "archived" = "created";
@property({ attribute: false }) viewingNow?: boolean;
override render() {
const createdActor = this.createdActor;
@@ -107,10 +108,13 @@ class SessionOwnerChip extends OpenClawLightDomElement {
return nothing;
}
const title = createdActor.label || createdActor.id;
const accessibleLabel = t(
const attributionLabel = t(
this.attribution === "archived" ? "sessionsView.archivedBy" : "sessionsView.createdBy",
{ name: title },
);
const accessibleLabel = this.viewingNow
? `${attributionLabel} · ${t("sessionsView.viewingNow")}`
: attributionLabel;
const avatar = createdActor.avatarUrl
? resolveAvatar({
id: createdActor.id,
@@ -120,7 +124,9 @@ class SessionOwnerChip extends OpenClawLightDomElement {
: null;
return html`
<span
class="session-owner-chip session-owner-chip--${this.size}"
class="session-owner-chip session-owner-chip--${this.size} ${this.viewingNow === false
? "session-owner-chip--away"
: ""}"
style="--owner-hue: ${ownerHue(createdActor.id)}"
role="img"
aria-label=${accessibleLabel}
+33
View File
@@ -153,6 +153,8 @@ type ViewerFacepileElement = HTMLElement & {
selfUserId?: string;
selfInstanceId?: string;
sessionKey?: string;
excludeUserId?: string;
maxVisible: number;
variant: "session" | "footer";
buildInfo: ControlUiBuildInfo;
gatewayVersion: string | null;
@@ -274,6 +276,37 @@ it("keeps session facepiles as plain non-interactive avatar clusters", async ()
expect(facepile.querySelectorAll("openclaw-tooltip")).toHaveLength(1);
});
it("excludes the session creator before choosing visible avatars and overflow", async () => {
const facepile = document.createElement("openclaw-viewer-facepile") as ViewerFacepileElement;
facepile.variant = "session";
facepile.sessionKey = "agent:main:active";
facepile.excludeUserId = "owner";
facepile.maxVisible = 2;
facepile.presencePayload = {
presence: ["owner", "alice", "bob", "carol"].map((id) => ({
instanceId: `${id}-instance`,
user: { id, name: id },
watchedSessions: ["agent:main:active"],
})),
};
document.body.append(facepile);
await vi.waitFor(async () => {
await facepile.updateComplete;
expect(
[...facepile.querySelectorAll("[data-viewer-id]")].map((avatar) =>
avatar.getAttribute("data-viewer-id"),
),
).toEqual(["alice", "bob"]);
});
expect(facepile.querySelector(".viewer-facepile")?.getAttribute("data-viewer-count")).toBe("3");
expect(facepile.querySelector(".viewer-avatar--overflow")?.textContent?.trim()).toBe("+1");
expect(facepile.querySelector('[data-viewer-id="owner"]')).toBeNull();
expect(facepile.querySelector(".viewer-avatar--overflow")?.getAttribute("aria-label")).toBe(
"carol",
);
});
it("detects only other viewers watching the requested session", () => {
const payload = {
presence: [
+7 -2
View File
@@ -84,7 +84,7 @@ let cachedAuthenticatedSelfUserId: string | undefined;
let cachedSelfInstanceId: string | undefined;
let cachedPresenceProjection: ReturnType<typeof projectPresenceViewers> | undefined;
function projectPresencePayload(
export function projectPresencePayload(
value: unknown,
authenticatedSelfUserId?: string,
selfInstanceId?: string,
@@ -189,6 +189,7 @@ class ViewerFacepile extends OpenClawLightDomContentsElement {
@property({ attribute: false }) selfUserId?: string;
@property({ attribute: false }) selfInstanceId?: string;
@property({ attribute: false }) sessionKey?: string;
@property({ attribute: false }) excludeUserId?: string;
@property({ type: Number, attribute: "max-visible" }) maxVisible = 3;
@property() variant: "session" | "footer" = "session";
@property({ attribute: false }) buildInfo: ControlUiBuildInfo = CONTROL_UI_BUILD_INFO;
@@ -201,9 +202,13 @@ class ViewerFacepile extends OpenClawLightDomContentsElement {
this.selfInstanceId,
);
const sessionKey = this.sessionKey;
const excludeUserId = normalized(this.excludeUserId);
const users = sessionKey
? projection.users.filter(
(user) => user.id !== projection.selfUserId && user.watchedSessions.includes(sessionKey),
(user) =>
user.id !== projection.selfUserId &&
user.id !== excludeUserId &&
user.watchedSessions.includes(sessionKey),
)
: this.variant === "footer"
? projection.users.filter((user) => user.id !== projection.selfUserId)
@@ -0,0 +1,149 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({
name: "Control UI sidebar alignment capture",
startServerBeforeBrowser: true,
unavailableMessage: (executablePath) =>
`Playwright Chromium is not installed or cannot start at ${executablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`.`,
});
const outputDir = path.resolve(
process.cwd(),
process.env.OPENCLAW_SIDEBAR_CAPTURE_OUTPUT_DIR ?? ".artifacts/control-ui-e2e/sidebar-alignment",
);
const screenshotPath = path.join(outputDir, "sidebar.png");
const baseTime = Date.parse("2026-08-14T12:00:00.000Z");
function session(
key: string,
label: string,
category: string,
overrides: Record<string, unknown> = {},
) {
return {
contextTokens: null,
displayName: label,
hasActiveRun: false,
key,
kind: "direct",
label,
model: "gpt-5.5",
modelProvider: "openai",
category,
status: "done",
totalTokens: 0,
updatedAt: baseTime,
...overrides,
};
}
suite.define(() => {
it("captures the complete sidebar alignment fixture", async () => {
await suite.withPage(
{
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1440 },
},
async ({ page }) => {
await installMockGateway(page, {
hasMultipleSessionSharingIdentities: true,
presenceUsers: [
{
self: true,
id: "profile-operator",
name: "Operator",
watchedSessions: ["agent:main:plain"],
},
{
id: "profile-ada",
name: "Ada",
watchedSessions: ["agent:main:owner-present"],
},
{
id: "profile-zoe",
name: "Zoe",
watchedSessions: ["agent:main:owner-present"],
},
],
sessionGroups: ["Research", "Operations"],
sessionKey: "agent:main:plain",
methodResponses: {
"sessions.list": {
count: 4,
creators: [
{ id: "profile-ada", label: "Ada" },
{ id: "profile-bob", label: "Bob" },
],
defaults: { contextTokens: null, model: "gpt-5.5", modelProvider: "openai" },
path: "",
sessions: [
session("agent:main:owner-present", "Owner present", "Research", {
createdActor: { type: "human", id: "profile-ada", label: "Ada" },
updatedAt: baseTime + 4_000,
}),
session("agent:main:plain", "No leading artwork", "Research", {
updatedAt: baseTime + 3_000,
}),
session("agent:main:owner-away", "Owner away", "Operations", {
createdActor: { type: "human", id: "profile-bob", label: "Bob" },
updatedAt: baseTime + 2_000,
}),
session("agent:main:running", "Running session", "Operations", {
activeRunIds: ["run-sidebar-capture"],
hasActiveRun: true,
status: "running",
updatedAt: baseTime + 1_000,
}),
],
ts: baseTime,
},
},
});
const response = await page.goto(`${suite.server.baseUrl}chat`);
expect(response?.status()).toBe(200);
const sidebar = page.locator(".sidebar");
await sidebar.waitFor({ state: "visible" });
await expect.poll(() => sidebar.locator(".nav-section__items .nav-item").count()).toBe(3);
await expect
.poll(() => sidebar.locator('[data-session-section^="category:"]').count())
.toBe(2);
await expect.poll(() => sidebar.locator(".sidebar-recent-session").count()).toBe(4);
await expect
.poll(() =>
sidebar
.locator('[data-session-key="agent:main:owner-present"] .viewer-facepile')
.getAttribute("data-viewer-count"),
)
.toBe("1");
await expect
.poll(() =>
sidebar
.locator('[data-session-key="agent:main:owner-present"] .session-owner-chip')
.getAttribute("class"),
)
.not.toContain("session-owner-chip--away");
await expect
.poll(() =>
sidebar
.locator('[data-session-key="agent:main:owner-away"] .session-owner-chip')
.getAttribute("class"),
)
.toContain("session-owner-chip--away");
const clip = await sidebar.boundingBox();
if (!clip) {
throw new Error("Sidebar did not expose a screenshot bounding box");
}
await mkdir(outputDir, { recursive: true });
await page.screenshot({ animations: "disabled", clip, path: screenshotPath });
process.stdout.write(`Sidebar screenshot: ${screenshotPath}\n`);
},
);
});
});
+1
View File
@@ -837,6 +837,7 @@ export const en: TranslationMap = {
limit: "Limit",
createdBy: "Created by {name}",
archivedBy: "Archived by {name}",
viewingNow: "viewing now",
people: "People",
allCreators: "All people",
filterControls: "Session filters",
+23 -8
View File
@@ -31,12 +31,20 @@ openclaw-session-owner-chip {
color: white;
font-weight: 700;
line-height: 1;
transition:
filter var(--duration-fast) ease,
opacity var(--duration-fast) ease;
}
.session-owner-chip--away {
filter: grayscale(0.8);
opacity: 0.45;
}
.session-owner-chip--row {
width: 16px;
height: 16px;
font-size: 8px;
width: 20px;
height: 20px;
font-size: 10px;
}
.session-owner-chip--header {
@@ -57,9 +65,16 @@ openclaw-session-owner-chip {
.session-glyph {
position: relative;
display: inline-flex;
width: 16px;
height: 16px;
flex: 0 0 16px;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex: 0 0 20px;
}
.session-glyph--circular .session-glyph__content {
width: 20px;
height: 20px;
}
.session-glyph__content {
@@ -72,8 +87,8 @@ openclaw-session-owner-chip {
}
/* The ring is a circle, so square artwork (page icons, thumbnails, arbitrary
SVGs) must shrink to keep its corners inside it. Circular avatars already
fit. A 16px square scaled below 0.75 clears the ring's 17px inner circle. */
SVGs) must shrink to keep its corners inside it. A 16px square scaled below
0.75 clears its 21px inner circle; 20px circular avatars fill their lead. */
.session-glyph--running .session-glyph__content {
transform: scale(0.72);
}
+53 -34
View File
@@ -1387,6 +1387,7 @@ body.update-dialog-open .sidebar-update-card__status {
}
.sidebar-nav {
position: relative;
padding: 0;
}
@@ -1426,7 +1427,10 @@ body.update-dialog-open .sidebar-update-card__status {
}
.sidebar-recent-sessions {
--sidebar-inset: 8px;
--sidebar-lead: 20px;
--sidebar-row-gap: 8px;
--sidebar-text-offset: calc(var(--sidebar-inset) + var(--sidebar-lead) + var(--sidebar-row-gap));
display: flex;
flex-direction: column;
@@ -1501,7 +1505,7 @@ body.update-dialog-open .sidebar-update-card__status {
align-items: center;
gap: 8px;
min-height: 24px;
padding: 0 10px 0 0;
padding: 0 10px 0 var(--sidebar-inset);
color: color-mix(in srgb, var(--muted) 72%, var(--text) 28%);
}
@@ -1516,7 +1520,7 @@ body.update-dialog-open .sidebar-update-card__status {
.sidebar-session-group-toggle {
display: flex;
align-items: center;
gap: 0;
gap: var(--sidebar-row-gap);
min-width: 0;
flex: 1 1 auto;
padding: 2px 0;
@@ -1617,8 +1621,8 @@ body.update-dialog-open .sidebar-update-card__status {
.sidebar-session-catalog-load-more {
display: block;
width: calc(100% - var(--sidebar-lead) - 10px);
margin: 3px 10px 5px var(--sidebar-lead);
width: calc(100% - var(--sidebar-text-offset) - 10px);
margin: 3px 10px 5px var(--sidebar-text-offset);
padding: 4px 0;
border: none;
border-radius: var(--radius-sm);
@@ -1786,11 +1790,6 @@ body.update-dialog-open .sidebar-update-card__status {
color: var(--muted);
}
.sidebar-session-group-toggle
> :not(.sidebar-session-group-toggle__lead):not(.sidebar-recent-sessions__label-text) {
margin-left: 5px;
}
/* Flex, not grid: WebKit does not re-run a grid flex-item's intrinsic sizing
when rows stream in or grow a second subtitle line after first layout, so a
grid list keeps a stale height and the next section paints over its rows. */
@@ -1813,19 +1812,23 @@ body.update-dialog-open .sidebar-update-card__status {
.sidebar-session-tree__children {
margin-left: var(--sidebar-lead);
box-shadow: inset 1px 0 0 color-mix(in srgb, var(--border-strong) 52%, transparent);
background: linear-gradient(
color-mix(in srgb, var(--border-strong) 52%, transparent),
color-mix(in srgb, var(--border-strong) 52%, transparent)
)
calc(var(--sidebar-inset) + 10px) 0 / 1px 100% no-repeat;
}
.sidebar-session-tree__loading {
min-height: 28px;
padding: 6px var(--sidebar-lead);
padding: 6px var(--sidebar-text-offset);
color: var(--muted);
font-size: var(--control-ui-text-xs);
}
.sidebar-session-tree__show-more {
min-height: 28px;
padding: 6px var(--sidebar-lead);
padding: 6px var(--sidebar-text-offset);
border: 0;
background: transparent;
color: var(--muted);
@@ -1854,7 +1857,7 @@ body.update-dialog-open .sidebar-update-card__status {
align-items: center;
gap: 8px;
min-height: 19px;
padding: 1px 12px 1px var(--sidebar-lead);
padding: 1px 12px 1px var(--sidebar-inset);
color: color-mix(in srgb, var(--muted) 82%, var(--text) 18%);
}
@@ -1894,10 +1897,10 @@ body.update-dialog-open .sidebar-update-card__status {
.sidebar-session-catalog-project__head {
display: flex;
align-items: center;
gap: 0;
gap: var(--sidebar-row-gap);
width: 100%;
min-height: 18px;
padding: 2px 12px 0 0;
padding: 2px 12px 0 var(--sidebar-inset);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
@@ -1959,7 +1962,7 @@ body.update-dialog-open .sidebar-update-card__status {
display: flex;
justify-content: flex-start;
gap: 14px;
padding: 0 10px 10px var(--sidebar-lead);
padding: 0 10px 10px var(--sidebar-text-offset);
}
.sidebar-session-pagination__button {
@@ -1978,7 +1981,7 @@ body.update-dialog-open .sidebar-update-card__status {
.sidebar-session-empty-hint {
display: block;
padding: 4px 10px 10px var(--sidebar-lead);
padding: 4px 10px 10px var(--sidebar-text-offset);
color: var(--muted);
font-size: var(--control-ui-text-xs);
}
@@ -2054,21 +2057,15 @@ body.update-dialog-open .sidebar-update-card__status {
.sidebar-recent-session__link {
display: flex;
align-items: center;
gap: 0;
gap: var(--sidebar-row-gap);
flex: 1 1 auto;
min-width: 0;
padding: 3px 4px 3px 0;
padding: 3px 4px 3px var(--sidebar-inset);
color: inherit;
cursor: var(--cursor-action);
text-decoration: none;
}
.sidebar-recent-sessions
.sidebar-recent-session__link
> :not(.sidebar-session-indicator):not(.sidebar-recent-session__text) {
margin-left: 8px;
}
.sidebar-child-session-toggle {
display: inline-flex;
align-items: center;
@@ -2836,7 +2833,7 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
justify-content: flex-start;
gap: 8px;
min-height: 32px;
padding: 0 9px;
padding: 0 8px;
border-radius: var(--radius-md);
border: 1px solid transparent;
background: transparent;
@@ -2851,18 +2848,24 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
}
.nav-item__icon {
width: 16px;
width: 20px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
flex: 0 0 20px;
opacity: 0.72;
transition:
opacity var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.nav-item .session-glyph .nav-item__icon {
width: 16px;
height: 16px;
flex-basis: 16px;
}
.nav-item__icon svg {
width: 16px;
height: 16px;
@@ -2939,7 +2942,7 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
.sidebar-zone-entry .sidebar-recent-session {
position: relative;
min-height: 32px;
padding: 0 9px;
padding: 0 8px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
@@ -3109,15 +3112,16 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
/* "Pages" section head: zone-style label plus the pin-editor affordance. */
.sidebar-nav__head {
position: absolute;
z-index: 1;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 2px 8px 2px 12px;
}
.sidebar-nav__head .sidebar-recent-sessions__label-text {
flex: 1 1 auto;
}
.sidebar-nav__head-action {
display: inline-flex;
align-items: center;
@@ -3130,9 +3134,24 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
background: transparent;
color: var(--muted);
cursor: var(--cursor-action);
opacity: 0;
transition:
color var(--duration-fast) ease,
background var(--duration-fast) ease;
background var(--duration-fast) ease,
opacity var(--duration-fast) ease;
}
.sidebar-nav:hover .sidebar-nav__head-action,
.sidebar-nav:focus-within .sidebar-nav__head-action {
opacity: 1;
}
/* Touch has no hover: keep the Pages editor discoverable, matching the
child-session-toggle touch override above. */
@media (hover: none), (pointer: coarse) {
.sidebar-nav__head-action {
opacity: 1;
}
}
.sidebar-nav__head-action:hover,
+1 -1
View File
@@ -222,7 +222,7 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
.shell--mobile-nav .nav-item {
margin: 0;
min-height: 40px;
padding: 0 12px;
padding: 0 12px 0 8px;
font-size: calc(13px * var(--control-ui-text-scale));
border-radius: var(--radius-md);
white-space: nowrap;
@@ -81,8 +81,12 @@ describe("AppSidebar project session activity", () => {
expect(active?.querySelector(".session-run-spinner")?.getAttribute("aria-label")).toBe(
"Active run",
);
expect(active?.querySelector(".sidebar-session-indicator")).toBeNull();
expect(idle?.querySelector(".sidebar-session-indicator")).toBeNull();
const activeLead = active?.querySelector(".sidebar-session-indicator");
const idleLead = idle?.querySelector(".sidebar-session-indicator");
expect(activeLead).not.toBeNull();
expect(activeLead?.childElementCount).toBe(0);
expect(idleLead).not.toBeNull();
expect(idleLead?.childElementCount).toBe(0);
expect(idle?.querySelector(".session-row-state")).toBeNull();
});
});
@@ -6,8 +6,10 @@ import { SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD } from "../../lib/session-pull-r
import { createGatewayHarness, createSessionsHarness, mountSidebar } from "../app-sidebar.ts";
import { waitForFast } from "../wait-for.ts";
function expectNoLead(row: Element | null) {
expect(row?.querySelector(".sidebar-session-indicator")).toBeNull();
function expectEmptyLead(row: Element | null) {
const lead = row?.querySelector(".sidebar-session-indicator");
expect(lead).not.toBeNull();
expect(lead?.childElementCount).toBe(0);
}
describe("AppSidebar session indicators", () => {
@@ -236,11 +238,11 @@ describe("AppSidebar session indicators", () => {
expect(sidebar.querySelector('[data-session-pr-state="merged"]')).not.toBeNull();
});
const plain = sidebar.querySelector(`[data-session-key="${keys.plain}"]`);
expectNoLead(plain);
expectEmptyLead(plain);
expect(plain?.querySelector(".session-row-state")).toBeNull();
const forked = sidebar.querySelector(`[data-session-key="${keys.forked}"]`);
expectNoLead(forked);
expectEmptyLead(forked);
expect(
forked?.querySelector(".session-row-aside > .session-row-state .session-row-fork-indicator"),
).not.toBeNull();
@@ -250,14 +252,14 @@ describe("AppSidebar session indicators", () => {
expect(forked?.querySelector(".session-row-fork-indicator")?.hasAttribute("title")).toBe(false);
const unread = sidebar.querySelector(`[data-session-key="${keys.unread}"]`);
expectNoLead(unread);
expectEmptyLead(unread);
expect(
unread?.querySelector(".session-row-aside > .session-row-state .session-unread-dot"),
).not.toBeNull();
const runningUnread = sidebar.querySelector(`[data-session-key="${keys.runningUnread}"]`);
expect(runningUnread?.classList.contains("session-row-host--running")).toBe(true);
expectNoLead(runningUnread);
expectEmptyLead(runningUnread);
expect(
runningUnread?.querySelector(".session-row-aside > .session-row-state .session-run-spinner"),
).not.toBeNull();
@@ -281,7 +283,7 @@ describe("AppSidebar session indicators", () => {
for (const key of [keys.openPullRequest, keys.mergedPullRequest]) {
const row = sidebar.querySelector(`[data-session-key="${key}"]`);
expectNoLead(row);
expectEmptyLead(row);
expect(row?.querySelector(".session-row-state [data-session-pr-state]")).not.toBeNull();
expect(row?.querySelector("a")?.getAttribute("title")).toContain(
key === keys.openPullRequest ? "Open PR" : "Merged",
@@ -297,7 +299,7 @@ describe("AppSidebar session indicators", () => {
sessions.publishList({ result });
await waitForFast(() => {
expect(sidebar.querySelector('[data-session-pr-state="open"]')).toBeNull();
expectNoLead(sidebar.querySelector(`[data-session-key="${keys.openPullRequest}"]`));
expectEmptyLead(sidebar.querySelector(`[data-session-key="${keys.openPullRequest}"]`));
});
});
});
@@ -121,6 +121,11 @@ describe("AppSidebar session ownership", () => {
const bobAvatarBefore = sidebar
.querySelector('[data-session-key="agent:main:bob"] openclaw-viewer-avatar img')
?.getAttribute("src");
expect(
sidebar
.querySelector('[data-session-key="agent:main:bob"] .session-owner-chip')
?.classList.contains("session-owner-chip--away"),
).toBe(true);
gateway.publishEvent("presence", {
presence: [
@@ -131,6 +136,7 @@ describe("AppSidebar session ownership", () => {
name: "Bob",
avatarUrl: "/api/users/profile-bob/avatar?v=99",
},
watchedSessions: ["agent:main:bob"],
},
],
});
@@ -140,6 +146,11 @@ describe("AppSidebar session ownership", () => {
.querySelector('[data-session-key="agent:main:bob"] openclaw-viewer-avatar img')
?.getAttribute("src"),
).toBe(bobAvatarBefore);
const bobChip = sidebar.querySelector(
'[data-session-key="agent:main:bob"] .session-owner-chip',
);
expect(bobChip?.classList.contains("session-owner-chip--away")).toBe(false);
expect(bobChip?.getAttribute("title")).toBe("Created by Bob · viewing now");
const adaChip = sidebar.querySelector(
'[data-session-key="agent:main:ada"] .session-owner-chip',
@@ -376,6 +387,12 @@ describe("AppSidebar session ownership", () => {
sidebar.querySelector('openclaw-session-owner-chip span[title="Archived by Bob"]'),
).not.toBeNull();
expect(sidebar.querySelector('span[title="Created by Ada"]')).toBeNull();
// Facepile dedup follows the rendered lead: the archivist chip is shown,
// so Bob is excluded while creator Ada must stay visible as a viewer.
const archivedFacepile = sidebar.querySelector(
'[data-session-key="agent:main:archived"] openclaw-viewer-facepile',
) as (HTMLElement & { excludeUserId?: string }) | null;
expect(archivedFacepile?.excludeUserId).toBe("profile-bob");
collaborator.createdActor = { type: "human", id: "profile-ada", label: "Ada" };
result.creators = [{ id: "profile-ada", label: "Ada" }];
@@ -383,6 +400,10 @@ describe("AppSidebar session ownership", () => {
await sidebar.updateComplete;
expect(sidebar.querySelector("openclaw-session-owner-chip")).toBeNull();
const soloFacepile = sidebar.querySelector(
'[data-session-key="agent:main:archived"] openclaw-viewer-facepile',
) as (HTMLElement & { excludeUserId?: string }) | null;
expect(soloFacepile?.excludeUserId).toBeUndefined();
});
it("filters by creator and hides custom groups without matching sessions", async () => {
@@ -367,7 +367,9 @@ describe("AppSidebar session accessibility", () => {
expect(row?.hasAttribute("aria-label")).toBe(false);
expect(link?.hasAttribute("aria-label")).toBe(false);
expect(link?.getAttribute("aria-current")).toBe("page");
expect(link?.querySelector(".sidebar-session-indicator")).toBeNull();
const lead = link?.querySelector(".sidebar-session-indicator");
expect(lead).not.toBeNull();
expect(lead?.childElementCount).toBe(0);
expect(link?.querySelector(".sidebar-recent-session__text")).not.toBeNull();
const rowState = row?.querySelector(".session-row-state");
expect(rowState?.getAttribute("role")).toBe("img");
@@ -149,8 +149,9 @@ describe("AppSidebar interleaved zone", () => {
// Pinning is not a status, so it must not claim the row's one leading slot.
const row = sidebar.querySelector('[data-session-key="agent:main:page"]');
const plain = sidebar.querySelector('[data-session-key="agent:main:plain"]');
expect(row?.querySelector(".sidebar-session-indicator")).toBeNull();
expect(plain?.querySelector(".sidebar-session-indicator")).toBeNull();
expect(row?.querySelector(".sidebar-session-indicator")?.innerHTML).toBe(
plain?.querySelector(".sidebar-session-indicator")?.innerHTML,
);
expect(row?.querySelector(".nav-item__state")).toBeNull();
expect(row?.querySelector(".session-row-state .sidebar-recent-session__state")).not.toBeNull();
});
@@ -96,6 +96,11 @@ describe("AppSidebar transient menus", () => {
it("ignores a stale More-menu hide after opening its replacement", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
const pagesLabel = sidebar.querySelector(
".sidebar-nav__head .sidebar-recent-sessions__label-text",
);
expect(pagesLabel?.classList.contains("sr-only")).toBe(true);
expect(pagesLabel?.textContent).toBe("Pages");
const trigger = sidebar.querySelector<HTMLButtonElement>(".sidebar-nav__head-action");
if (!trigger) {
throw new Error("expected Pages menu trigger");