fix(ui): restore sidebar and Appearance accessibility (#121360)

* fix(ui): restore sidebar accessibility

Amp-Thread-ID: https://ampcode.com/threads/T-019fe94c-c697-71e9-982f-c164bb98bb6c

* refactor(ui): own sidebar list semantics at hierarchy boundaries

Amp-Thread-ID: https://ampcode.com/threads/T-019fe94c-c697-71e9-982f-c164bb98bb6c

* test(ui): keep sidebar cases within lint limits

Amp-Thread-ID: https://ampcode.com/threads/T-019fe94c-c697-71e9-982f-c164bb98bb6c

* test(ui): assert session tree list ownership

Amp-Thread-ID: https://ampcode.com/threads/T-019fe94c-c697-71e9-982f-c164bb98bb6c

* chore: refresh control UI CI

Amp-Thread-ID: https://ampcode.com/threads/T-019fe94c-c697-71e9-982f-c164bb98bb6c

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-09 22:25:26 -07:00
committed by GitHub
parent 8ee945b907
commit 3c5f89449e
15 changed files with 264 additions and 106 deletions
@@ -328,27 +328,42 @@ function renderCatalogHostGroup(
const sectionId = `catalog-project:${catalog.id}:${host.hostId}:${group.key}`;
const collapsed = params.collapsedSections.has(sectionId);
return html`
<button
type="button"
class="sidebar-session-catalog-project__head"
data-session-catalog-project=${group.key}
aria-expanded=${String(!collapsed)}
title=${group.title}
@click=${() => params.onToggleSection(sectionId)}
>
<span class="sidebar-session-catalog-project__icon" aria-hidden="true"
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
<div class="sidebar-session-catalog-project" role="listitem">
<button
type="button"
class="sidebar-session-catalog-project__head"
data-session-catalog-project=${group.key}
aria-expanded=${String(!collapsed)}
title=${group.title}
@click=${() => params.onToggleSection(sectionId)}
>
<span class="sidebar-session-catalog-project__label">${group.label}</span>
<span class="sidebar-session-catalog-project__count" aria-hidden="true"
>${group.sessions.length}</span
>
</button>
${collapsed
? nothing
: group.sessions.map((session) =>
renderCatalogSessionRow(catalog, host, session, liveRowsByKey, params, true),
)}
<span class="sidebar-session-catalog-project__icon" aria-hidden="true"
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
>
<span class="sidebar-session-catalog-project__label">${group.label}</span>
<span class="sidebar-session-catalog-project__count" aria-hidden="true"
>${group.sessions.length}</span
>
</button>
${collapsed
? nothing
: html`<div
class="sidebar-session-catalog-project__sessions"
role="list"
aria-label=${`${host.label}: ${group.label}`}
>
${group.sessions.map((session) =>
renderCatalogSessionRow(
catalog,
host,
session,
liveRowsByKey,
params,
true,
),
)}
</div>`}
</div>
`;
})}
${projectGroups.ungrouped.map((session) =>
@@ -464,7 +479,11 @@ function renderCatalogSessionRow(
</a>
<span class="sidebar-recent-session__aside session-row-aside">
${running
? html`<span class="session-row-state" id=${stateId} aria-label=${stateDescription}
? html`<span
class="session-row-state"
id=${stateId}
role="img"
aria-label=${stateDescription}
>${renderSessionRunSpinner(false)}</span
>`
: nothing}
@@ -1,7 +1,6 @@
import { html, nothing } from "lit";
import type { SessionCatalog } from "../../../packages/gateway-protocol/src/index.ts";
import type { GatewaySessionRow } from "../api/types.ts";
import { titleForRoute } from "../app-navigation.ts";
import type { CatalogOpenTarget } from "../app/settings.ts";
import { t } from "../i18n/index.ts";
import type { CatalogProjectGrouping } from "../lib/sessions/catalog-project-grouping.ts";
@@ -248,7 +247,7 @@ function renderSessionSection(params: {
function renderDraftSessionRow() {
return html`
<div class="sidebar-recent-session sidebar-recent-session--draft">
<div class="sidebar-recent-session sidebar-recent-session--draft" role="listitem">
<span class="sidebar-recent-session__link">
<span class="sidebar-session-indicator"></span>
<span class="sidebar-recent-session__text">
@@ -484,7 +483,7 @@ export function renderSessionList(params: {
</div>
`
: nothing}
<div class="sidebar-recent-sessions" aria-label=${titleForRoute("sessions")}>
<div class="sidebar-recent-sessions">
${renderSessionListBody({
host,
sections: params.sections,
@@ -1,4 +1,5 @@
import { html, nothing, type TemplateResult } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { keyed } from "lit/directives/keyed.js";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import type { NavigationRouteId } from "../app-navigation.ts";
@@ -156,8 +157,9 @@ export function renderRecentSession(params: {
host: SessionListHost;
session: SidebarRecentSession;
display?: CatalogBackingSessionDisplay;
listItem?: boolean;
}) {
const { host, session, display } = params;
const { host, session, display, listItem = true } = params;
const pinAccess = host.readSessionMutationAccess({
method: "sessions.patch",
params: { key: session.key, pinned: !session.pinned },
@@ -239,7 +241,7 @@ export function renderRecentSession(params: {
<div
class=${rowClass}
data-session-key=${session.key}
role="listitem"
role=${ifDefined(listItem ? "listitem" : undefined)}
draggable=${rowDraggable ? "true" : "false"}
title=${!session.isChild && !groupWriteAccess.allowed ? groupWriteAccess.reason : nothing}
@dragstart=${!rowDraggable
@@ -357,7 +359,11 @@ export function renderRecentSession(params: {
<span class="sidebar-recent-session__aside session-row-aside">
${trailingIndicator === nothing
? nothing
: html`<span class="session-row-state" id=${stateId} aria-label=${trailingDescription}
: html`<span
class="session-row-state"
id=${stateId}
role="img"
aria-label=${trailingDescription}
>${trailingIndicator}</span
>`}
${hasTrail
@@ -415,22 +421,34 @@ export function renderRecentSession(params: {
export function renderSessionTree(params: {
host: SessionListHost;
session: SidebarRecentSession;
listItem?: boolean;
}): TemplateResult {
const { host, session } = params;
const { host, session, listItem = true } = params;
const expanded = host.isSessionChildrenExpanded(session);
const visibleChildren = visibleSessionChildren({
session,
fullyShownChildSessionKeys: host.fullyShownChildSessionKeys,
});
const hiddenChildCount = session.children.length - visibleChildren.length;
return html`<div class="sidebar-session-tree" data-session-tree=${session.key}>
${renderRecentSession({ host, session })}
return html`<div
class="sidebar-session-tree"
data-session-tree=${session.key}
role=${ifDefined(listItem ? "listitem" : undefined)}
>
${renderRecentSession({ host, session, listItem: false })}
${expanded
? html`<div
class="sidebar-session-tree__children"
aria-label=${t("sessionsView.childSessions")}
>
${visibleChildren.map((child) => renderSessionTree({ host, session: child }))}
? html`<div class="sidebar-session-tree__children">
${visibleChildren.length > 0
? html`<div
class="sidebar-session-tree__list"
role=${ifDefined(listItem ? "list" : undefined)}
aria-label=${ifDefined(listItem ? t("sessionsView.childSessions") : undefined)}
>
${visibleChildren.map((child) =>
renderSessionTree({ host, session: child, listItem }),
)}
</div>`
: nothing}
${hiddenChildCount > 0
? html`<button
class="sidebar-session-tree__show-more"
+2 -1
View File
@@ -367,7 +367,8 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
}
renderPinnedSidebarSession(session: SidebarRecentSession): TemplateResult {
return renderSessionTree({ host: this, session });
// Pinned sessions live in the navigation zone, not a session list.
return renderSessionTree({ host: this, session, listItem: false });
}
private renderSessions() {
@@ -876,8 +876,12 @@ suite.define(() => {
.toContainEqual(expect.objectContaining({ includeDerivedTitles: true }));
const label = row.locator(".sidebar-recent-session__name");
const link = row.locator("a.sidebar-recent-session__link");
const tree = row.locator("..");
const list = tree.locator("..");
await expect.poll(() => label.textContent()).toBe(readableTitle);
expect(await row.getAttribute("role")).toBe("listitem");
expect(await list.getAttribute("role")).toBe("list");
expect(await tree.getAttribute("role")).toBe("listitem");
expect(await row.getAttribute("role")).toBeNull();
expect(await row.getAttribute("aria-label")).toBeNull();
expect(await link.getAttribute("aria-label")).toBeNull();
expect(await link.getAttribute("aria-current")).toBe("page");
+33
View File
@@ -430,11 +430,24 @@ suite.define(() => {
expect(await section.locator('[data-session-catalog-host="node:build"]').count()).toBe(1);
expect(await section.getByText("Offline Workstation", { exact: true }).count()).toBe(0);
expect(await section.getByText("Offline Laptop", { exact: true }).count()).toBe(0);
const localHost = section.locator('[data-session-catalog-host="gateway:local"]');
const localHostList = localHost.locator(":scope > .sidebar-session-catalog-host__sessions");
expect(await localHostList.getAttribute("role")).toBe("list");
expect(await localHostList.getAttribute("aria-label")).toBe("Local Codex");
const projectHeads = section.locator("[data-session-catalog-project]");
await expect.poll(() => projectHeads.count()).toBe(2);
expect(
await localHostList
.locator(":scope > *")
.evaluateAll((items) => items.map((item) => item.getAttribute("role"))),
).toEqual(["listitem", "listitem"]);
const openclawProject = section.locator(
'[data-session-catalog-project="/Users/dev/openclaw"]',
);
const openclawProjectItem = openclawProject.locator("..");
const openclawProjectList = openclawProjectItem.locator(":scope > [role=list]");
expect(await openclawProjectItem.getAttribute("role")).toBe("listitem");
expect(await openclawProjectList.getAttribute("aria-label")).toBe("Local Codex: openclaw");
expect(
await openclawProject.locator(".sidebar-session-catalog-project__label").textContent(),
).toBe("openclaw");
@@ -443,6 +456,16 @@ suite.define(() => {
).toBe("2");
const projectRows = section.locator(".sidebar-recent-session--catalog-project-child");
await expect.poll(() => projectRows.count()).toBe(3);
expect(
await openclawProjectList
.locator(":scope > *")
.evaluateAll((items) => items.map((item) => item.getAttribute("role"))),
).toEqual(["listitem", "listitem"]);
const buildHostList = section.locator(
'[data-session-catalog-host="node:build"] > .sidebar-session-catalog-host__sessions',
);
expect(await buildHostList.getAttribute("aria-label")).toBe("Build Node");
expect(await buildHostList.locator(":scope > [role=listitem]").count()).toBe(1);
const threadRows = page.locator(
'[data-session-section="ungrouped"] .sidebar-recent-session, [data-session-section="catalog:codex"] .sidebar-recent-session--catalog-project-child',
);
@@ -545,6 +568,11 @@ suite.define(() => {
.evaluate((element) => (element as HTMLElement).click());
await expect.poll(() => projectHeads.count()).toBe(0);
expect(await section.locator("[data-session-key]").count()).toBe(4);
expect(
await localHostList
.locator(":scope > *")
.evaluateAll((items) => items.map((item) => item.getAttribute("role"))),
).toEqual(["listitem", "listitem", "listitem"]);
expect(
await page.evaluate((key) => localStorage.getItem(key), catalogGroupingStorageKey),
).toBe("none");
@@ -564,6 +592,11 @@ suite.define(() => {
.getByRole("menuitemradio", { name: "Person" })
.evaluate((element) => (element as HTMLElement).click());
await expect.poll(() => projectHeads.count()).toBe(2);
expect(
await localHostList
.locator(":scope > *")
.evaluateAll((items) => items.map((item) => item.getAttribute("role"))),
).toEqual(["listitem", "listitem", "listitem"]);
expect(
await section
.locator('[data-session-catalog-project="person:profile-ada"]')
@@ -396,6 +396,7 @@ export function renderLobsterPetSection(props: ConfigProps) {
: "lobsterdex__mini--unseen"}"
style=${lobsterLookStyle(look)}
tabindex="0"
role="img"
aria-label=${ariaLabel}
>
${renderLobsterSvg(look, { standalone: true })}
+8
View File
@@ -1550,6 +1550,14 @@ describe("config view", () => {
]) {
expect(text).toContain(expected);
}
const lobsterPreviews = container.querySelectorAll(".lobsterdex__mini");
expect(lobsterPreviews).toHaveLength(42);
expect([...lobsterPreviews].every((preview) => preview.getAttribute("role") === "img")).toBe(
true,
);
expect(
[...lobsterPreviews].every((preview) => Boolean(preview.getAttribute("aria-label")?.trim())),
).toBe(true);
expect(container.querySelector('button[aria-label="Reset to default"]')).toBeNull();
});
+13 -19
View File
@@ -486,7 +486,7 @@ html.openclaw-native-web-chrome .shell-chrome-controls {
font-weight: 650;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--muted) 76%, transparent);
color: var(--muted);
}
/* Settings rail entries are anchors but read as app chrome: default arrow,
@@ -696,7 +696,7 @@ openclaw-settings-save-indicator {
margin-left: auto;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
color: color-mix(in srgb, var(--muted) 82%, transparent);
color: var(--muted);
}
/* ===========================================
@@ -1721,8 +1721,7 @@ html.openclaw-native-macos
font-weight: 650;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--muted) 72%, var(--text) 28%);
opacity: 0.85;
color: var(--muted);
}
.sidebar-session-group-toggle
@@ -1733,21 +1732,23 @@ html.openclaw-native-macos
/* 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. */
.sidebar-recent-sessions__list {
display: flex;
flex-direction: column;
gap: 2px;
min-height: 12px;
}
.sidebar-recent-sessions__list,
.sidebar-session-tree,
.sidebar-session-tree__children {
.sidebar-session-tree__children,
.sidebar-session-tree__list,
.sidebar-session-catalog-host__sessions,
.sidebar-session-catalog-project,
.sidebar-session-catalog-project__sessions {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sidebar-recent-sessions__list {
min-height: 12px;
}
.sidebar-session-tree__children {
margin-left: var(--sidebar-lead);
box-shadow: inset 1px 0 0 color-mix(in srgb, var(--border-strong) 52%, transparent);
@@ -1828,13 +1829,6 @@ html.openclaw-native-macos
stroke-width: 1.8px;
}
.sidebar-session-catalog-host__sessions {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sidebar-session-catalog-project__head {
display: flex;
align-items: center;
+29 -2
View File
@@ -97,7 +97,8 @@ function readOpacity(ruleBody: string): number {
describe("Control UI theme contrast", () => {
const baseCss = readFileSync(path.join(here, "base.css"), "utf8");
const groupedCss = readFileSync(path.join(here, "chat", "grouped.css"), "utf8");
const layoutCss = readFileSync(path.join(here, "chat", "layout.css"), "utf8");
const chatLayoutCss = readFileSync(path.join(here, "chat", "layout.css"), "utf8");
const layoutCss = readFileSync(path.join(here, "layout.css"), "utf8");
it("keeps default dark muted text tokens at WCAG AA on declared surfaces", () => {
const dark = readCssVarBlock(baseCss, ":root");
@@ -130,7 +131,7 @@ describe("Control UI theme contrast", () => {
requireCssColor(dark, "card"),
];
const timestampRule = readRuleBody(groupedCss, ".chat-group-timestamp");
const slashArgsRule = readRuleBody(layoutCss, ".slash-menu-args");
const slashArgsRule = readRuleBody(chatLayoutCss, ".slash-menu-args");
expect(timestampRule).toMatch(/color:\s*var\(--muted\)/);
expect(slashArgsRule).toMatch(/color:\s*var\(--muted\)/);
@@ -147,4 +148,30 @@ describe("Control UI theme contrast", () => {
expect(contrastRatio(slashArgsFg, background)).toBeGreaterThanOrEqual(4.5);
}
});
it("keeps sidebar metadata on the AA-tested muted token without opacity dimming", () => {
const groupLabelRule = readRuleBody(layoutCss, ".settings-sidebar__group-label");
const buildRule = readRuleBody(layoutCss, ".settings-sidebar__footer .sidebar-footer-build");
const sessionLabelRule = readRuleBody(
layoutCss,
".sidebar-recent-sessions__label-text,\n.sidebar-session-catalog-host__label",
);
expect(groupLabelRule).toMatch(/color:\s*var\(--muted\)/);
expect(buildRule).toMatch(/color:\s*var\(--muted\)/);
expect(sessionLabelRule).toMatch(/color:\s*var\(--muted\)/);
expect(readOpacity(sessionLabelRule)).toBe(1);
for (const selector of [
':root[data-theme-mode="light"]',
':root[data-theme="openknot-light"]',
':root[data-theme="dash-light"]',
]) {
const theme = readCssVarBlock(baseCss, selector);
const muted = requireCssColor(theme, "muted");
for (const surface of ["bg", "bg-elevated", "bg-muted", "card"]) {
expect(contrastRatio(muted, requireCssColor(theme, surface))).toBeGreaterThanOrEqual(4.5);
}
}
});
});
@@ -37,6 +37,14 @@ describe("AppSidebar project session activity", () => {
canContinue: true,
canArchive: true,
},
{
threadId: "loose-thread",
name: "Loose session",
status: "idle",
archived: false,
canContinue: true,
canArchive: true,
},
],
},
],
@@ -48,8 +56,28 @@ describe("AppSidebar project session activity", () => {
const project = sidebar.querySelector('[data-session-catalog-project="/work/openclaw"]');
const active = sidebar.querySelector('[data-session-key*="active-thread"]');
const idle = sidebar.querySelector('[data-session-key*="idle-thread"]');
const loose = sidebar.querySelector('[data-session-key*="loose-thread"]');
const projectItem = project?.closest(".sidebar-session-catalog-project");
const hostList = projectItem?.parentElement;
const projectList = active?.closest('[role="list"]');
expect(project).not.toBeNull();
expect(active?.querySelector(".session-row-state .session-run-spinner")).not.toBeNull();
expect(hostList?.getAttribute("role")).toBe("list");
expect(hostList?.getAttribute("aria-label")).toBe("Local Codex");
expect(
[...(hostList?.children ?? [])].every((item) => item.getAttribute("role") === "listitem"),
).toBe(true);
expect(projectItem?.getAttribute("role")).toBe("listitem");
expect(projectList?.getAttribute("aria-label")).toBe("Local Codex: openclaw");
expect(
[...(projectList?.children ?? [])].every((item) => item.getAttribute("role") === "listitem"),
).toBe(true);
expect(idle?.closest('[role="list"]')).toBe(projectList);
expect(loose?.parentElement).toBe(hostList);
expect(loose?.getAttribute("role")).toBe("listitem");
const activeState = active?.querySelector(".session-row-state");
expect(activeState?.getAttribute("role")).toBe("img");
expect(activeState?.getAttribute("aria-label")).toBe("Active run");
expect(activeState?.querySelector(".session-run-spinner")).not.toBeNull();
expect(active?.querySelector(".session-run-spinner")?.getAttribute("aria-label")).toBe(
"Active run",
);
@@ -83,6 +83,16 @@ describe("AppSidebar agent chip", () => {
configuredAgentsOnly: true,
});
const childRows = [...sidebar.querySelectorAll<HTMLElement>(".sidebar-recent-session--child")];
const parentTree = sidebar.querySelector('[data-session-tree="agent:main:parent"]');
const childList = parentTree?.querySelector(
":scope > .sidebar-session-tree__children [role=list]",
);
const childTrees = [...(childList?.children ?? [])];
expect(childList?.getAttribute("aria-label")).toBe("Child sessions");
expect(childTrees).toHaveLength(2);
expect(childTrees.every((tree) => tree.getAttribute("role") === "listitem")).toBe(true);
expect(childRows.every((row) => !row.hasAttribute("role"))).toBe(true);
expect(childRows.every((row) => row.closest("[role=list]") === childList)).toBe(true);
expect(childRows.map((row) => row.textContent)).toEqual([
expect.stringContaining("Research sources"),
expect.stringContaining("Check tests"),
@@ -689,35 +699,4 @@ describe("AppSidebar agent chip", () => {
sidebar.querySelector('[data-session-key="agent:worker:child"] [aria-label="Done"]'),
).not.toBeNull();
});
it("keeps a selected child reachable when its parent is outside the loaded window", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const harness = createSessionsHarness("main", ["agent:main:child"]);
const { sidebar } = await mountSidebar(gateway, harness.sessions);
harness.publishList({
result: {
ts: 2,
path: "",
count: 1,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [
{
key: "agent:main:child",
spawnedBy: "agent:main:missing-parent",
kind: "direct",
label: "Reachable orphan",
updatedAt: 2,
status: "done",
},
],
},
});
(sidebar as unknown as { activeRouteId: string }).activeRouteId = "chat";
sidebar.sessionKey = "agent:main:child";
await sidebar.updateComplete;
const row = sidebar.querySelector('[data-session-key="agent:main:child"]');
expect(row?.textContent).toContain("Reachable orphan");
expect(row?.classList.contains("sidebar-recent-session--child")).toBe(false);
});
});
@@ -29,9 +29,11 @@ describe("AppSidebar session section visibility", () => {
section?.querySelector(".sidebar-session-group-toggle")?.getAttribute("aria-expanded"),
).toBe("true");
expect(list?.firstElementChild?.classList.contains("sidebar-recent-session--draft")).toBe(true);
expect(list?.querySelector(".sidebar-recent-session--draft")?.textContent?.trim()).toBe(
"New session",
);
const draft = list?.querySelector(".sidebar-recent-session--draft");
expect(list?.getAttribute("role")).toBe("list");
expect(draft?.parentElement).toBe(list);
expect(draft?.getAttribute("role")).toBe("listitem");
expect(draft?.textContent?.trim()).toBe("New session");
const draftLead = list?.querySelector(
".sidebar-recent-session--draft .sidebar-session-indicator",
);
@@ -172,4 +174,46 @@ describe("AppSidebar session section visibility", () => {
await sidebar.updateComplete;
expect(sidebar.querySelector('[data-session-section="ungrouped"]')).not.toBeNull();
});
it("renders no chat rows when only the main session exists", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
(sidebar as unknown as { activeRouteId: string }).activeRouteId = "chat";
await sidebar.updateComplete;
// The identity card is the main-session entry; the list stays empty.
expect(sidebar.querySelectorAll(".sidebar-recent-session")).toHaveLength(0);
expect(sidebar.querySelector("openclaw-sidebar-agent-card")).not.toBeNull();
});
it("keeps a selected child reachable when its parent is outside the loaded window", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const harness = createSessionsHarness("main", ["agent:main:child"]);
const { sidebar } = await mountSidebar(gateway, harness.sessions);
harness.publishList({
result: {
ts: 2,
path: "",
count: 1,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [
{
key: "agent:main:child",
spawnedBy: "agent:main:missing-parent",
kind: "direct",
label: "Reachable orphan",
updatedAt: 2,
status: "done",
},
],
},
});
(sidebar as unknown as { activeRouteId: string }).activeRouteId = "chat";
sidebar.sessionKey = "agent:main:child";
await sidebar.updateComplete;
const row = sidebar.querySelector('[data-session-key="agent:main:child"]');
expect(row?.textContent).toContain("Reachable orphan");
expect(row?.classList.contains("sidebar-recent-session--child")).toBe(false);
});
});
@@ -290,9 +290,15 @@ describe("AppSidebar session accessibility", () => {
const list = sidebar.querySelector('[data-session-section="ungrouped"] [role="list"]');
const row = sidebar.querySelector(`[data-session-key="${key}"]`);
const tree = row?.closest(".sidebar-session-tree");
const link = row?.querySelector<HTMLAnchorElement>(".sidebar-recent-session__link");
expect(list?.getAttribute("aria-label")).toBe("Sessions");
expect(row?.getAttribute("role")).toBe("listitem");
expect(tree?.parentElement).toBe(list);
expect(tree?.getAttribute("role")).toBe("listitem");
expect(row?.hasAttribute("role")).toBe(false);
expect(sidebar.querySelector(".sidebar-recent-sessions")?.hasAttribute("aria-label")).toBe(
false,
);
expect(row?.hasAttribute("aria-label")).toBe(false);
expect(link?.hasAttribute("aria-label")).toBe(false);
expect(link?.getAttribute("aria-current")).toBe("page");
@@ -300,7 +306,10 @@ describe("AppSidebar session accessibility", () => {
expect(lead).not.toBeNull();
expect(lead?.childElementCount).toBe(0);
expect(link?.querySelector(".sidebar-recent-session__text")).not.toBeNull();
expect(row?.querySelector(".session-row-state .session-unread-dot")).not.toBeNull();
const rowState = row?.querySelector(".session-row-state");
expect(rowState?.getAttribute("role")).toBe("img");
expect(rowState?.getAttribute("aria-label")).toBe("Unread");
expect(rowState?.querySelector(".session-unread-dot")).not.toBeNull();
expect(link?.querySelector(".sidebar-recent-session__name")?.textContent).toBe(
"Quarterly launch plan",
);
@@ -310,17 +319,6 @@ describe("AppSidebar session accessibility", () => {
);
expect(row?.querySelector(".session-row-trail")).toBeNull();
});
it("renders no chat rows when only the main session exists", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
(sidebar as unknown as { activeRouteId: string }).activeRouteId = "chat";
await sidebar.updateComplete;
// The identity card is the main-session entry; the list stays empty.
expect(sidebar.querySelectorAll(".sidebar-recent-session")).toHaveLength(0);
expect(sidebar.querySelector("openclaw-sidebar-agent-card")).not.toBeNull();
});
});
describe("AppSidebar session navigation", () => {
@@ -251,6 +251,11 @@ describe("AppSidebar interleaved zone", () => {
);
expect(labels).toEqual(["Usage", "Alpha", "Plugins"]);
expect(sidebar.querySelector('[data-session-section="pinned"]')).toBeNull();
const pinnedRow = sidebar.querySelector('[data-session-key="agent:main:alpha"]');
const pinnedTree = pinnedRow?.closest(".sidebar-session-tree");
expect(pinnedRow?.hasAttribute("role")).toBe(false);
expect(pinnedTree?.hasAttribute("role")).toBe(false);
expect(pinnedRow?.closest('[role="list"]')).toBeNull();
expect(sidebar.querySelector(".nav-item--home")?.hasAttribute("draggable")).toBe(false);
});