fix(ui): show catalog labels in hidden section settings (#122320)

* test(ui): reproduce hidden catalog id labels

* fix(ui): label hidden session catalogs

* test(ui): capture hidden catalog label evidence

* test(ui): harden hidden catalog label proof
This commit is contained in:
Vyctor H. Brzezowski
2026-08-12 02:18:05 -03:00
committed by GitHub
parent 0295b7ab54
commit dd57cfb6c1
5 changed files with 140 additions and 9 deletions
@@ -20,6 +20,7 @@ const suite = createControlUiE2eSuite({
});
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const hiddenSessionCatalogsStorageKey = "openclaw:sidebar:sessions:hidden-catalogs";
const uiProofArtifactDir = path.join(
process.cwd(),
".artifacts",
@@ -93,6 +94,19 @@ async function holdUiProof(page: Page, durationMs = 600) {
}
}
async function setThemeMode(page: Page, mode: "dark" | "light") {
await page.emulateMedia({ colorScheme: mode });
await page.evaluate((nextMode) => {
const root = document.documentElement;
root.dataset.themeMode = nextMode;
root.dataset.themeResolved = nextMode;
root.classList.toggle("wa-light", nextMode === "light");
root.classList.toggle("wa-dark", nextMode === "dark");
root.style.colorScheme = nextMode;
}, mode);
await expect.poll(() => page.locator("html").getAttribute("data-theme-mode")).toBe(mode);
}
async function openSidebarTestPage() {
const context = await suite.browser.newContext({
locale: "en-US",
@@ -107,6 +121,71 @@ async function openSidebarTestPage() {
}
suite.define(() => {
it("uses catalog labels in the hidden-section recovery rows", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1440 },
});
const page = await context.newPage();
await page.addInitScript(({ key, value }) => localStorage.setItem(key, JSON.stringify(value)), {
key: hiddenSessionCatalogsStorageKey,
value: ["claude", "offline-catalog"],
});
const gateway = await installMockGateway(page, {
featureMethods: ["sessions.catalog.list"],
methodResponses: {
"sessions.catalog.list": {
catalogs: [
{
id: "claude",
label: "Claude Code",
capabilities: { continueSession: true, archive: false },
hosts: [],
},
],
},
},
});
try {
await page.goto(`${suite.server.baseUrl}settings/appearance`);
await waitForControlUiSettingsTakeover(page);
await gateway.waitForRequest("sessions.catalog.list");
const sidebarSettings = page.locator("#settings-appearance-sidebar");
await sidebarSettings.getByRole("heading", { name: "Hidden session sections" }).waitFor();
const recovery = sidebarSettings.locator(".settings-group", { hasText: "offline-catalog" });
const row = recovery.locator(".settings-row", { hasText: "Claude Code" });
await expect.poll(() => recovery.textContent()).toContain("Claude Code");
await expect.poll(() => recovery.textContent()).toContain("offline-catalog");
expect(await recovery.getByText("claude", { exact: true }).count()).toBe(0);
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
await recovery.scrollIntoViewIfNeeded();
for (const theme of ["light", "dark"] as const) {
await setThemeMode(page, theme);
await page.screenshot({
animations: "disabled",
path: path.join(uiProofArtifactDir, `after-${theme}-context.png`),
});
await recovery.screenshot({
animations: "disabled",
path: path.join(uiProofArtifactDir, `after-${theme}-rows.png`),
});
}
}
await row.getByRole("button", { name: "Show" }).click();
await expect.poll(() => row.count()).toBe(0);
expect(
await page.evaluate((key) => localStorage.getItem(key), hiddenSessionCatalogsStorageKey),
).toBe('["offline-catalog"]');
} finally {
await context.close();
}
});
it("pins routes, restores defaults, and persists navigation state across reloads", async () => {
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
+45 -1
View File
@@ -4,7 +4,10 @@ import { initialState, Task, TaskStatus } from "@lit/task";
import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce";
import { html, nothing, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import type { SystemInfoResult } from "../../../../packages/gateway-protocol/src/index.js";
import type {
SessionsCatalogListResult,
SystemInfoResult,
} from "../../../../packages/gateway-protocol/src/index.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ModelCatalogEntry } from "../../api/types.ts";
import { titleForRoute } from "../../app-navigation.ts";
@@ -114,6 +117,7 @@ const MOVED_SECTION_ROUTES: Record<string, { routeId: RouteId; keepSection: bool
};
const SESSION_OBSERVER_STATUS_POLL_INTERVAL_MS = 10_000;
const EMPTY_SESSION_CATALOG_LABELS: ReadonlyMap<string, string> = new Map();
function defaultConfigSelection(pageId: ConfigPageId): ConfigSelection {
switch (pageId) {
@@ -338,6 +342,42 @@ export class ConfigPage extends OpenClawLightDomElement {
}
},
});
private readonly hiddenSessionCatalogLabelsTask = new Task(this, {
args: () => {
const gateway = this.context?.gateway.snapshot;
const hiddenCatalogIds = [...this.hiddenSessionCatalogIds].toSorted();
const client =
this.pageId === "appearance" &&
hiddenCatalogIds.length > 0 &&
canCallGatewayMethod(gateway, "sessions.catalog.list", "operator.read")
? gateway?.client
: null;
return [
client,
this.context?.agentSelection.state.selectedId ?? null,
hiddenCatalogIds.join("\0"),
] as const;
},
task: async ([client, agentId], { signal }) => {
if (!client) {
return EMPTY_SESSION_CATALOG_LABELS;
}
try {
const result = await client.request<SessionsCatalogListResult>(
"sessions.catalog.list",
{
...(agentId ? { agentId } : {}),
limitPerHost: 1,
},
{ signal },
);
return new Map(result.catalogs.map((catalog) => [catalog.id, catalog.label]));
} catch {
// Recovery must remain available when catalog discovery is unsupported or offline.
return EMPTY_SESSION_CATALOG_LABELS;
}
},
});
private pendingRouteTargetId: string | null = null;
private readonly subscriptions = new SubscriptionsController(this)
.watch(
@@ -1170,6 +1210,10 @@ export class ConfigPage extends OpenClawLightDomElement {
this.settings.sidebarLiveActivity ?? UI_APPEARANCE_DEFAULTS.sidebarLiveActivity,
setSidebarLiveActivity: (enabled) => this.setSetting("sidebarLiveActivity", enabled),
hiddenSessionCatalogIds: this.hiddenSessionCatalogIds,
hiddenSessionCatalogLabels:
this.hiddenSessionCatalogLabelsTask.status === TaskStatus.COMPLETE
? (this.hiddenSessionCatalogLabelsTask.value ?? EMPTY_SESSION_CATALOG_LABELS)
: EMPTY_SESSION_CATALOG_LABELS,
setSessionCatalogHidden: setStoredSessionCatalogHidden,
chatMessageMaxWidth: this.settings.chatMessageMaxWidth,
setChatMessageMaxWidth: (value) => this.setSetting("chatMessageMaxWidth", value),
@@ -486,7 +486,7 @@ export function renderSidebarPreferencesSection(props: ConfigProps) {
<div class="settings-group">
${hiddenCatalogIds.map((catalogId) =>
renderSettingsRow({
title: catalogId,
title: props.hiddenSessionCatalogLabels.get(catalogId) ?? catalogId,
description: t("quickSettings.personal.browserOnly"),
control: html`<button
type="button"
+1
View File
@@ -128,6 +128,7 @@ export type ConfigProps = {
sidebarLiveActivity: boolean;
setSidebarLiveActivity: (enabled: boolean) => void;
hiddenSessionCatalogIds: ReadonlySet<string>;
hiddenSessionCatalogLabels: ReadonlyMap<string, string>;
setSessionCatalogHidden: (catalogId: string, hidden: boolean) => void;
chatMessageMaxWidth?: string;
setChatMessageMaxWidth: (value: string | undefined) => void;
+14 -7
View File
@@ -85,6 +85,7 @@ describe("config view", () => {
sidebarLiveActivity: true,
setSidebarLiveActivity: vi.fn(),
hiddenSessionCatalogIds: new Set<string>(),
hiddenSessionCatalogLabels: new Map<string, string>(),
setSessionCatalogHidden: vi.fn(),
chatMessageMaxWidth: undefined,
setChatMessageMaxWidth: vi.fn(),
@@ -2129,26 +2130,32 @@ describe("config view", () => {
expect(setSidebarLiveActivity).toHaveBeenCalledWith(false);
});
it("lists hidden session sections and offers to show them", () => {
it("labels hidden session sections from the catalog and keeps ids as the fallback", () => {
const setSessionCatalogHidden = vi.fn();
const { container } = renderConfigView({
activeSection: "__appearance__",
includeSections: ["__appearance__"],
hiddenSessionCatalogIds: new Set(["codex"]),
hiddenSessionCatalogIds: new Set(["claude", "offline-catalog"]),
hiddenSessionCatalogLabels: new Map([["claude", "Claude Code"]]),
setSessionCatalogHidden,
});
const heading = Array.from(container.querySelectorAll("h3")).find(
(candidate) => candidate.textContent?.trim() === "Hidden session sections",
);
const row = Array.from(container.querySelectorAll<HTMLElement>(".settings-row")).find(
const labeledRow = Array.from(container.querySelectorAll<HTMLElement>(".settings-row")).find(
(candidate) =>
candidate.querySelector(".settings-row__title")?.textContent?.trim() === "codex",
candidate.querySelector(".settings-row__title")?.textContent?.trim() === "Claude Code",
);
const fallbackRow = Array.from(container.querySelectorAll<HTMLElement>(".settings-row")).find(
(candidate) =>
candidate.querySelector(".settings-row__title")?.textContent?.trim() === "offline-catalog",
);
expect(heading).toBeDefined();
expect(row).toBeDefined();
row?.querySelector<HTMLButtonElement>("button")?.click();
expect(setSessionCatalogHidden).toHaveBeenCalledWith("codex", false);
expect(labeledRow).toBeDefined();
expect(fallbackRow).toBeDefined();
labeledRow?.querySelector<HTMLButtonElement>("button")?.click();
expect(setSessionCatalogHidden).toHaveBeenCalledWith("claude", false);
});
it("uses rich Lobsterdex lore tooltips and opens the full collection", () => {