mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
8ee945b907
commit
3c5f89449e
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user