mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 15:43:57 -06:00
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:
committed by
GitHub
parent
4f4cef66c5
commit
295f2c0212
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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`);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user