fix(ui): keep native context menus out of the sidebar and make session catalogs hideable (#118217)

* fix(ui): keep native context menus out of the sidebar and make session catalogs hideable

Suppress the WKWebView default context menu across the sidebar while preserving editable inputs, and route agent cards and session-catalog headers to the existing menus. Persist hidden session sections in localStorage and expose live-synced Show controls under Settings → Appearance → Sidebar.

Co-authored-by: Codex <codex@openai.com>

* test(ui): serve mock session catalogs so the sidebar catalog sections are exercisable in dev:ui:mock

Advertises sessions.catalog.list and returns synthetic Codex/Claude Code
catalogs, enabling reproducible live proof of the catalog header context
menu and hide/restore preference flow.

Co-authored-by: Codex <codex@openai.com>

---------

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Peter Steinberger
2026-08-02 16:02:53 -07:00
committed by GitHub
parent 6adbd0901a
commit faf2602d22
19 changed files with 394 additions and 24 deletions
+67
View File
@@ -1463,6 +1463,7 @@ async function createChatPickerScenario(
"openclaw.chat.history",
"sessions.diff",
"sessions.files.set",
"sessions.catalog.list",
"system.info",
],
historyMessages: buildScrollableChatHistory(baseTime),
@@ -1540,6 +1541,72 @@ async function createChatPickerScenario(
// Custom session group catalog so the sidebar's category zone (and its
// drag-reordering against built-in sections) is exercised in the mock.
"sessions.groups.list": { groups: [{ name: "Research", position: 0 }] },
// Coding session catalogs so the sidebar's catalog sections (header
// right-click menu, hide/restore preference) are exercised in the mock.
"sessions.catalog.list": {
catalogs: [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: false },
hosts: [
{
hostId: "gateway",
label: "This Mac",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "codex-thread-1",
name: "Release checklist sweep",
cwd: "/Users/demo/projects/openclaw",
status: "idle",
updatedAt: baseTime - 10 * 60_000,
archived: false,
canContinue: true,
canArchive: false,
},
{
threadId: "codex-thread-2",
name: "Sidebar context-menu proof",
cwd: "/Users/demo/projects/openclaw",
status: "idle",
updatedAt: baseTime - 45 * 60_000,
archived: false,
canContinue: true,
canArchive: false,
},
],
},
],
},
{
id: "claude-code",
label: "Claude Code",
capabilities: { continueSession: true, archive: false },
hosts: [
{
hostId: "gateway",
label: "This Mac",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "claude-thread-1",
name: "Docs refresh",
cwd: "/Users/demo/projects/peekaboo",
status: "idle",
updatedAt: baseTime - 30 * 60_000,
archived: false,
canContinue: true,
canArchive: false,
},
],
},
],
},
],
},
"system.info": {
machineName: "Peters-Mac-Studio",
hostname: "peters-mac-studio.local",
+9
View File
@@ -101,6 +101,15 @@ export function renderAppSidebarBrand(host: AppSidebarRenderHost) {
.approvalCount=${approvalCount}
.switcherAvailable=${cardAgents.length > 1}
.onToggleMenu=${(trigger: HTMLElement) => host.sidebarMenus.toggleAgentMenu(trigger)}
@contextmenu=${(event: MouseEvent) => {
event.preventDefault();
if (host.sidebarMenus.agentMenuPosition !== null) {
return;
}
const card = event.currentTarget as HTMLElement;
const trigger = card.querySelector<HTMLElement>(".sidebar-agent-card__main") ?? card;
host.sidebarMenus.toggleAgentMenu(trigger);
}}
></openclaw-sidebar-agent-card>
<div class="sidebar-brand__actions">
<openclaw-tooltip
@@ -52,7 +52,11 @@ type SessionCatalogGroupsParams = {
onFinishSectionDrag: () => void;
viewMenuOpenCatalogId: string | null;
creatorFilterActive: boolean;
onOpenViewMenu: (trigger: HTMLElement) => void;
onOpenViewMenu: (
catalogId: string,
trigger: HTMLElement,
position?: { x: number; y: number },
) => void;
onLoadMore: (catalogId: string) => void;
onOpenNewSession?: (agentId: string, target?: NewSessionTarget) => void;
newSessionDisabledReason?: string;
@@ -175,6 +179,16 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) {
disabledReason: params.sectionDragDisabledReason,
onStartDrag: params.onStartSectionDrag,
onFinishDrag: params.onFinishSectionDrag,
onContextMenu: (event) => {
event.preventDefault();
const header = event.currentTarget as HTMLElement;
const trigger =
header.querySelector<HTMLElement>("[data-session-catalog-view-menu]") ?? header;
params.onOpenViewMenu(catalog.id, trigger, {
x: event.clientX,
y: event.clientY,
});
},
content: html`
<button
type="button"
@@ -217,7 +231,7 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) {
aria-expanded=${String(params.viewMenuOpenCatalogId === catalog.id)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
params.onOpenViewMenu(event.currentTarget as HTMLElement);
params.onOpenViewMenu(catalog.id, event.currentTarget as HTMLElement);
}}
>
${icons.listFilter}
@@ -347,15 +347,15 @@ function renderSessionCatalog(params: {
onSectionDrop: (event, sectionId) => host.sectionDrop(event, sectionId),
onStartSectionDrag: (sectionId) => host.startSidebarSectionDrag(sectionId),
onFinishSectionDrag: () => host.finishSidebarSectionDrag(),
// aria-expanded must land on the one header whose menu is open, so the
// catalog id rides on the trigger's data attribute instead of a global flag.
viewMenuOpenCatalogId: host.sidebarMenus.catalogViewMenuPosition
? (host.sidebarMenus.catalogViewMenuTrigger?.getAttribute(
"data-session-catalog-view-menu",
) ?? null)
: null,
viewMenuOpenCatalogId: host.sidebarMenus.catalogViewMenuPosition?.catalogId ?? null,
creatorFilterActive: host.sessionCreatorFilterActive,
onOpenViewMenu: (trigger) => host.sidebarMenus.toggleCatalogViewMenu(trigger),
onOpenViewMenu: (catalogId, trigger, position) => {
if (position) {
host.sidebarMenus.openCatalogViewMenu(catalogId, position.x, position.y, trigger);
return;
}
host.sidebarMenus.toggleCatalogViewMenu(catalogId, trigger);
},
onLoadMore: (catalogId) => void host.sessionData.loadMoreSessionCatalog(catalogId),
onOpenNewSession: (agentId, target) => host.requestOpenNewSession(agentId, target),
newSessionDisabledReason: newSessionAccess.allowed ? undefined : newSessionAccess.reason,
@@ -111,6 +111,7 @@ export function renderSidebarCatalogViewMenu(params: {
creatorFilterId: string | null;
onGroupingChange: (grouping: CatalogProjectGrouping) => void;
onCreatorFilterChange: (creatorId: string | null) => void;
onHide: () => void;
onClose: (restoreFocus: boolean) => void;
}) {
const position = params.position;
@@ -139,6 +140,8 @@ export function renderSidebarCatalogViewMenu(params: {
params.onGroupingChange(value.slice("grouping:".length) as CatalogProjectGrouping);
} else if (value?.startsWith("creator:")) {
params.onCreatorFilterChange(value.slice("creator:".length) || null);
} else if (value === "hide-catalog") {
params.onHide();
}
}}
@keydown=${(event: KeyboardEvent) =>
@@ -212,6 +215,10 @@ export function renderSidebarCatalogViewMenu(params: {
)}
`
: nothing}
<div class="session-menu__separator" role="separator"></div>
<wa-dropdown-item class="sidebar-session-sort-menu__item" value="hide-catalog">
<span class="session-menu__text">${t("chat.sidebar.hideFromSidebar")}</span>
</wa-dropdown-item>
</wa-dropdown>
</openclaw-menu-surface>
`,
@@ -72,7 +72,7 @@ export interface SessionListHost {
readonly sidebarMenus: Pick<
SidebarMenusController,
| "catalogViewMenuPosition"
| "catalogViewMenuTrigger"
| "openCatalogViewMenu"
| "openSessionGroupMenu"
| "openSessionMenu"
| "sessionGroupMenu"
@@ -2,7 +2,9 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
loadStoredHiddenSessionCatalogIds,
loadStoredSidebarSessionStatusFilter,
storeHiddenSessionCatalogIds,
storeSidebarSessionStatusFilter,
} from "./app-sidebar-session-types.ts";
@@ -54,3 +56,18 @@ describe("sidebar session status preference", () => {
expect(loadStoredSidebarSessionStatusFilter()).toBe("all");
});
});
describe("hidden session catalog preference", () => {
it("round-trips catalog ids", () => {
storeHiddenSessionCatalogIds(new Set(["codex", "claude"]));
expect([...loadStoredHiddenSessionCatalogIds()]).toEqual(["codex", "claude"]);
});
it.each(["not-json", JSON.stringify({ catalog: "codex" })])(
"treats malformed storage as empty: %s",
(stored) => {
localStorage.setItem("openclaw:sidebar:sessions:hidden-catalogs", stored);
expect(loadStoredHiddenSessionCatalogIds().size).toBe(0);
},
);
});
@@ -202,6 +202,9 @@ const SIDEBAR_SESSION_SHOW_CRON_STORAGE_KEY = "openclaw:sidebar:sessions:show-cr
const SIDEBAR_SESSION_STATUS_FILTER_STORAGE_KEY = "openclaw:sidebar:sessions:status-filter";
const SIDEBAR_SESSION_COLLAPSED_SECTIONS_STORAGE_KEY =
"openclaw:sidebar:sessions:collapsed-sections";
const SIDEBAR_HIDDEN_SESSION_CATALOGS_STORAGE_KEY = "openclaw:sidebar:sessions:hidden-catalogs";
export const SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT =
"openclaw:sidebar-hidden-catalogs-changed";
export function limitSidebarSessionRows(rows: SidebarRecentSession[], limit: number) {
const requiredCount = rows.filter((row) => row.active || row.pinned).length;
@@ -260,6 +263,21 @@ export function loadStoredCollapsedSessionSections(): ReadonlySet<string> {
}
}
export function loadStoredHiddenSessionCatalogIds(): ReadonlySet<string> {
try {
const parsed: unknown = JSON.parse(
getSafeLocalStorage()?.getItem(SIDEBAR_HIDDEN_SESSION_CATALOGS_STORAGE_KEY) ?? "[]",
);
return new Set(
Array.isArray(parsed)
? parsed.flatMap((value) => (typeof value === "string" && value ? [value] : []))
: [],
);
} catch {
return new Set();
}
}
export function storeSidebarSessionsGrouping(grouping: SidebarSessionsGrouping) {
getSafeLocalStorage()?.setItem(SIDEBAR_SESSION_GROUPING_STORAGE_KEY, grouping);
}
@@ -283,6 +301,16 @@ export function storeCollapsedSessionSections(sections: ReadonlySet<string>) {
);
}
export function storeHiddenSessionCatalogIds(ids: ReadonlySet<string>) {
getSafeLocalStorage()?.setItem(
SIDEBAR_HIDDEN_SESSION_CATALOGS_STORAGE_KEY,
JSON.stringify([...ids]),
);
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT));
}
}
export const SIDEBAR_SESSION_SORT_OPTIONS = [
{ mode: "created", labelKey: "chat.sidebar.sortCreated" },
{ mode: "updated", labelKey: "chat.sidebar.sortUpdated" },
+39 -5
View File
@@ -40,7 +40,10 @@ import {
visibleSessionChildren,
} from "./app-sidebar-session-row-render.ts";
import {
loadStoredHiddenSessionCatalogIds,
loadStoredSidebarCatalogGrouping,
SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT,
storeHiddenSessionCatalogIds,
storeSidebarCatalogGrouping,
type SidebarRecentSession,
} from "./app-sidebar-session-types.ts";
@@ -76,6 +79,9 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
private narrationLoad: Promise<void> | null = null;
private readonly narrationSubscriptions = this.createNarrationSubscriptions();
private readonly nativeGatewaysChanged = () => this.requestUpdate();
private readonly hiddenSessionCatalogsChanged = () => {
this.hiddenSessionCatalogIds = loadStoredHiddenSessionCatalogIds();
};
// Catalog rows are non-startup content. Load their renderer through the same
// idle boundary as other sidebar chrome, then repaint when the chunk arrives.
@@ -95,6 +101,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
);
@state() catalogProjectGrouping = loadStoredSidebarCatalogGrouping();
@state() hiddenSessionCatalogIds = loadStoredHiddenSessionCatalogIds();
constructor() {
super();
@@ -144,6 +151,10 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
override disconnectedCallback() {
window.removeEventListener("openclaw:native-gateways-changed", this.nativeGatewaysChanged);
window.removeEventListener(
SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT,
this.hiddenSessionCatalogsChanged,
);
this.narration?.disconnect();
this.catalogRendererImport.dispose();
super.disconnectedCallback();
@@ -241,6 +252,11 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
override connectedCallback() {
super.connectedCallback();
window.addEventListener("openclaw:native-gateways-changed", this.nativeGatewaysChanged);
this.hiddenSessionCatalogsChanged();
window.addEventListener(
SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT,
this.hiddenSessionCatalogsChanged,
);
// The decorative pet's large module stays out of startup and upgrades in place.
// Its first visit is at least 15 seconds after load, so idle loading cannot miss one.
sidebarChromeImport.schedule();
@@ -337,6 +353,10 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
this.catalogProjectGrouping = next;
}
hideSessionCatalog(catalogId: string): void {
storeHiddenSessionCatalogIds(new Set([...this.hiddenSessionCatalogIds, catalogId]));
}
openCatalogMenu(
request: CatalogSessionMenuRequest,
x: number,
@@ -364,11 +384,17 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
sidebarRowsByKey.set(row.key, navigationState.toSidebarSession(row));
}
}
const { sections } = this.zonedVisibleSections(visibleSessions);
const { sections: allSections } = this.zonedVisibleSections(visibleSessions);
const catalogs = this.sessionData.sessionCatalogs.filter(
(catalog) => !this.hiddenSessionCatalogIds.has(catalog.id),
);
const visibleCatalogIds = new Set(catalogs.map((catalog) => catalog.id));
const sections = allSections.filter(
(section) => !section.id.startsWith("catalog:") || visibleCatalogIds.has(section.id.slice(8)),
);
if (
!this.catalogRenderer &&
(this.sessionData.sessionCatalogs.length > 0 ||
this.sessionData.sessionCatalogRefreshStatus.error !== null)
(catalogs.length > 0 || this.sessionData.sessionCatalogRefreshStatus.error !== null)
) {
void this.preloadCatalogRenderer().catch(() => undefined);
}
@@ -382,7 +408,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
Boolean(this.draftSessionAgentId) &&
normalizeAgentId(this.draftSessionAgentId) === expandedAgentId,
catalogs: {
catalogs: this.sessionData.sessionCatalogs,
catalogs,
refreshStatus: this.sessionData.sessionCatalogRefreshStatus,
basePath: this.basePath,
routeSessionKey: isSessionRouteId(this.activeRouteId) ? this.getRouteSessionKey() : "",
@@ -402,7 +428,15 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
override render() {
const sidebarZone = this.reconciledSidebarZone();
return html`
<aside class="sidebar">
<aside
class="sidebar"
@contextmenu=${(event: MouseEvent) => {
// Editable controls keep the platform editing menu; all other sidebar chrome is owned here.
if (!(event.target as Element).closest("input, textarea, [contenteditable]")) {
event.preventDefault();
}
}}
>
<div class="sidebar-shell" @mousedown=${beginNativeWindowDragFromTopInset}>
${renderAppSidebarBrand(this)}
<div
+14 -8
View File
@@ -48,7 +48,7 @@ interface SidebarMenusControllerState {
sessionMenuWork: SessionMenuWork | null;
sessionGroupMenu: SidebarSessionGroupMenuState | null;
sessionSortMenuPosition: { x: number; y: number } | null;
catalogViewMenuPosition: { x: number; y: number } | null;
catalogViewMenuPosition: { catalogId: string; x: number; y: number } | null;
agentMenuPosition: { x: number; top: number } | null;
agentMenuFilter: string;
identityMenuPosition: { x: number; bottom: number; width: number } | null;
@@ -99,6 +99,7 @@ export interface SidebarMenusControllerHost
readonly sidebarEntries: readonly string[];
readonly catalogProjectGrouping: CatalogProjectGrouping;
setCatalogProjectGrouping(grouping: CatalogProjectGrouping): void;
hideSessionCatalog(catalogId: string): void;
sessionSortMode: SidebarSessionSortMode;
readonly terminalAvailable: boolean;
readonly themeMode: ThemeMode;
@@ -132,7 +133,7 @@ export class SidebarMenusController implements ReactiveController, SidebarMenusC
sessionMenuWork: SessionMenuWork | null = null;
sessionGroupMenu: SidebarSessionGroupMenuState | null = null;
sessionSortMenuPosition: { x: number; y: number } | null = null;
catalogViewMenuPosition: { x: number; y: number } | null = null;
catalogViewMenuPosition: { catalogId: string; x: number; y: number } | null = null;
agentMenuPosition: { x: number; top: number } | null = null;
agentMenuFilter = "";
// Anchored by its bottom edge so the footer menu grows upward regardless of height.
@@ -420,20 +421,25 @@ export class SidebarMenusController implements ReactiveController, SidebarMenusC
});
}
toggleCatalogViewMenu(trigger: HTMLElement) {
if (this.catalogViewMenuPosition) {
toggleCatalogViewMenu(catalogId: string, trigger: HTMLElement) {
if (this.catalogViewMenuPosition?.catalogId === catalogId) {
this.closeCatalogViewMenu();
return;
}
const rect = trigger.getBoundingClientRect();
this.openCatalogViewMenu(catalogId, rect.right, rect.bottom + 4, trigger);
}
openCatalogViewMenu(catalogId: string, x: number, y: number, trigger: HTMLElement | null = null) {
this.loadMenuRenderer();
const menuWidth = 200;
const menuMaxHeight = 120;
const rect = trigger.getBoundingClientRect();
const menuMaxHeight = 360;
this.dismissTransientMenus();
this.catalogViewMenuTrigger = trigger;
this.updateState("catalogViewMenuPosition", {
x: Math.max(8, Math.min(rect.right, window.innerWidth - menuWidth - 8)),
y: Math.max(8, Math.min(rect.bottom + 4, window.innerHeight - menuMaxHeight - 8)),
catalogId,
x: Math.max(8, Math.min(x, window.innerWidth - menuWidth - 8)),
y: Math.max(8, Math.min(y, window.innerHeight - menuMaxHeight - 8)),
});
}
@@ -432,6 +432,13 @@ export function renderSidebarCatalogViewMenuForController(controller: SidebarMen
host.setCatalogProjectGrouping(grouping);
controller.closeCatalogViewMenu({ restoreFocus: true });
},
onHide: () => {
if (!position || controller.catalogViewMenuPosition !== position) {
return;
}
host.hideSessionCatalog(position.catalogId);
controller.closeCatalogViewMenu();
},
onCreatorFilterChange: (creatorId) => {
host.sessionCreatorFilterId = creatorId;
void host.sessionDataContext?.sessions.setCreatorFilter(creatorId);
+3
View File
@@ -4784,6 +4784,9 @@ export const en: TranslationMap = {
coding: "Coding",
noSessionsForAgent: "No sessions found for this agent",
catalogViewOptions: "View options",
hideFromSidebar: "Hide from sidebar",
hiddenSessionSections: "Hidden session sections",
showSessionSection: "Show",
catalogGroupByProject: "Project",
catalogGroupByPerson: "Person",
openSessionMenu: "Open thread menu",
+28
View File
@@ -34,6 +34,11 @@ import {
} from "../../app/settings.ts";
import { startThemeTransition } from "../../app/theme-transition.ts";
import { resolveTheme, type ThemeMode, type ThemeName } from "../../app/theme.ts";
import {
loadStoredHiddenSessionCatalogIds,
SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT,
storeHiddenSessionCatalogIds,
} from "../../components/app-sidebar-session-types.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { i18n, isSupportedLocale, t, type Locale } from "../../i18n/index.ts";
import { resolveControlUiServerQueueMode } from "../../lib/chat/follow-up-mode.ts";
@@ -226,6 +231,7 @@ export class ConfigPage extends OpenClawLightDomElement {
@property({ attribute: false }) routeData: ConfigRouteData | null = null;
@state() private settings = loadSettings();
@state() private hiddenSessionCatalogIds = loadStoredHiddenSessionCatalogIds();
@state() private systemInfo: SystemInfoResult | null = null;
@state() private systemInfoUnavailable = false;
@state() private sessionObserverModels: ModelCatalogEntry[] = [];
@@ -353,9 +359,17 @@ export class ConfigPage extends OpenClawLightDomElement {
);
},
);
private readonly hiddenSessionCatalogsChanged = () => {
this.hiddenSessionCatalogIds = loadStoredHiddenSessionCatalogIds();
};
override connectedCallback() {
super.connectedCallback();
this.hiddenSessionCatalogsChanged();
window.addEventListener(
SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT,
this.hiddenSessionCatalogsChanged,
);
this.customThemeImportOwner.connect(
this.context.gateway.connection.gatewayUrl,
this.context.theme.serverSelection,
@@ -365,6 +379,10 @@ export class ConfigPage extends OpenClawLightDomElement {
}
override disconnectedCallback() {
window.removeEventListener(
SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT,
this.hiddenSessionCatalogsChanged,
);
this.customThemeImportOwner.retireImport();
this.systemInfoPolling.stop();
this.invalidateSystemInfoRequest();
@@ -1016,6 +1034,16 @@ export class ConfigPage extends OpenClawLightDomElement {
sidebarLiveActivity:
this.settings.sidebarLiveActivity ?? UI_APPEARANCE_DEFAULTS.sidebarLiveActivity,
setSidebarLiveActivity: (enabled) => this.setSetting("sidebarLiveActivity", enabled),
hiddenSessionCatalogIds: this.hiddenSessionCatalogIds,
setSessionCatalogHidden: (catalogId, hidden) => {
const next = new Set(this.hiddenSessionCatalogIds);
if (hidden) {
next.add(catalogId);
} else {
next.delete(catalogId);
}
storeHiddenSessionCatalogIds(next);
},
chatMessageMaxWidth: this.settings.chatMessageMaxWidth,
setChatMessageMaxWidth: (value) => this.setSetting("chatMessageMaxWidth", value),
showAdvancedSettings: this.settings.showAdvancedSettings === true,
+1
View File
@@ -148,6 +148,7 @@ export const SETTINGS_SEARCH_TARGETS = {
"configView.sidebarPrefs.hint",
"configView.sidebarPrefs.liveActivity",
"configView.sidebarPrefs.liveActivityHint",
"chat.sidebar.hiddenSessionSections",
"configView.sessionObserver.title",
"configView.sessionObserver.hint",
"configView.sessionObserver.toggle",
@@ -439,6 +439,7 @@ export function renderLobsterPetSection(props: ConfigProps) {
}
export function renderSidebarPreferencesSection(props: ConfigProps) {
const hiddenCatalogIds = [...props.hiddenSessionCatalogIds].toSorted();
const liveActivityDefaultState = renderSettingsDefaultState({
value: t("common.enabled"),
overridden: props.sidebarLiveActivity !== UI_APPEARANCE_DEFAULTS.sidebarLiveActivity,
@@ -460,6 +461,28 @@ export function renderSidebarPreferencesSection(props: ConfigProps) {
actions: liveActivityDefaultState.action,
})}
</div>
${hiddenCatalogIds.length > 0
? html`
<div class="settings-section__header settings-section__header--subsection">
<h3 class="settings-section__heading">${t("chat.sidebar.hiddenSessionSections")}</h3>
</div>
<div class="settings-group">
${hiddenCatalogIds.map((catalogId) =>
renderSettingsRow({
title: catalogId,
description: t("quickSettings.personal.browserOnly"),
control: html`<button
type="button"
class="btn btn--sm"
@click=${() => props.setSessionCatalogHidden(catalogId, false)}
>
${t("chat.sidebar.showSessionSection")}
</button>`,
}),
)}
</div>
`
: nothing}
<div class="settings-section__header settings-section__header--subsection">
<h3 class="settings-section__heading">${t("configView.sessionObserver.title")}</h3>
</div>
+2
View File
@@ -125,6 +125,8 @@ export type ConfigProps = {
resetTextScale: () => void;
sidebarLiveActivity: boolean;
setSidebarLiveActivity: (enabled: boolean) => void;
hiddenSessionCatalogIds: ReadonlySet<string>;
setSessionCatalogHidden: (catalogId: string, hidden: boolean) => void;
chatMessageMaxWidth?: string;
setChatMessageMaxWidth: (value: string | undefined) => void;
showAdvancedSettings: boolean;
+24
View File
@@ -84,6 +84,8 @@ describe("config view", () => {
resetTextScale: vi.fn(),
sidebarLiveActivity: true,
setSidebarLiveActivity: vi.fn(),
hiddenSessionCatalogIds: new Set<string>(),
setSessionCatalogHidden: vi.fn(),
chatMessageMaxWidth: undefined,
setChatMessageMaxWidth: vi.fn(),
showAdvancedSettings: false,
@@ -2106,6 +2108,28 @@ describe("config view", () => {
expect(setSidebarLiveActivity).toHaveBeenCalledWith(false);
});
it("lists hidden session sections and offers to show them", () => {
const setSessionCatalogHidden = vi.fn();
const { container } = renderConfigView({
activeSection: "__appearance__",
includeSections: ["__appearance__"],
hiddenSessionCatalogIds: new Set(["codex"]),
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(
(candidate) =>
candidate.querySelector(".settings-row__title")?.textContent?.trim() === "codex",
);
expect(heading).toBeDefined();
expect(row).toBeDefined();
row?.querySelector<HTMLButtonElement>("button")?.click();
expect(setSessionCatalogHidden).toHaveBeenCalledWith("codex", false);
});
it("uses rich Lobsterdex lore tooltips and opens the full collection", () => {
const firstSeenAt = new Date("2026-07-10T12:00:00.000Z").getTime();
vi.stubGlobal("localStorage", window.localStorage);
@@ -222,6 +222,37 @@ describe("AppSidebar agent chip", () => {
expect(menu?.querySelector('[slot="trigger"]')?.getAttribute("style")).toContain("top: 92px");
});
it("opens the agent menu on right-click without toggling an open menu", async () => {
const { sidebar } = await mountSidebar(
createGateway({} as GatewayBrowserClient),
createSessions("main", ["agent:main:main"]),
"panel",
TWO_AGENTS,
);
const card = sidebar.querySelector<HTMLElement>("openclaw-sidebar-agent-card");
const trigger = card?.querySelector<HTMLElement>(".sidebar-agent-card__main");
const label = card?.querySelector<HTMLElement>(".sidebar-agent-card__name");
if (!card || !trigger || !label) {
throw new Error("Expected the sidebar agent card");
}
trigger.getBoundingClientRect = () =>
({ bottom: 88, left: 12, right: 252, top: 40 }) as DOMRect;
const firstContextMenu = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
});
label.dispatchEvent(firstContextMenu);
await sidebar.updateComplete;
const firstMenu = sidebar.querySelector(".sidebar-agent-menu");
expect(firstContextMenu.defaultPrevented).toBe(true);
expect(firstMenu).not.toBeNull();
label.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-agent-menu")).toBe(firstMenu);
});
it("collapses a single-agent roster to the three agent actions", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(
@@ -5,6 +5,10 @@ import type {
} from "../../../../packages/gateway-protocol/src/index.ts";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationGatewaySnapshot } from "../../app/context.ts";
import {
loadStoredHiddenSessionCatalogIds,
storeHiddenSessionCatalogIds,
} from "../../components/app-sidebar-session-types.ts";
import { TERMINAL_PANEL_TOGGLE_EVENT } from "../../components/panel-toggle-contract.ts";
import { CATALOG_SESSION_CONTINUED_EVENT } from "../../lib/sessions/catalog-key.ts";
import {
@@ -19,6 +23,30 @@ import {
import { waitForFast } from "../wait-for.ts";
import "../../components/app-sidebar.ts";
describe("AppSidebar context menu boundary", () => {
it("suppresses native menus except on editable controls", async () => {
const { sidebar } = await mountSidebar(
createGateway({} as GatewayBrowserClient),
createSessions("main", ["agent:main:main"]),
);
const aside = sidebar.querySelector<HTMLElement>("aside.sidebar");
const footer = sidebar.querySelector<HTMLElement>(".sidebar-shell__footer");
if (!aside || !footer) {
throw new Error("expected sidebar chrome");
}
const chromeMenu = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
footer.dispatchEvent(chromeMenu);
expect(chromeMenu.defaultPrevented).toBe(true);
const input = document.createElement("input");
aside.append(input);
const editableMenu = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
input.dispatchEvent(editableMenu);
expect(editableMenu.defaultPrevented).toBe(false);
});
});
describe("AppSidebar multi-select", () => {
const KEYS = ["agent:main:main", "agent:main:a", "agent:main:b", "agent:main:c"];
@@ -401,6 +429,47 @@ describe("AppSidebar catalog session rows", () => {
return { sidebar, request };
}
it("opens the catalog view menu from its header and hides that section", async () => {
vi.useFakeTimers();
try {
const { sidebar } = await mountWithCatalog(
catalogList([{ threadId: "thread-1", name: "Release checklist" }]),
["agent:main:main"],
);
const header = sidebar.querySelector<HTMLElement>(
'[data-session-section="catalog:codex"] .sidebar-recent-sessions__head',
);
if (!header) {
throw new Error("expected catalog section header");
}
const contextMenu = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: 24,
clientY: 36,
});
header.dispatchEvent(contextMenu);
await sidebar.updateComplete;
expect(contextMenu.defaultPrevented).toBe(true);
const menu = sidebar.querySelector<HTMLElement>(".sidebar-catalog-view-menu");
const hide = menu?.querySelector<HTMLElement>('wa-dropdown-item[value="hide-catalog"]');
expect(menu).not.toBeNull();
expect(hide).not.toBeNull();
menu?.dispatchEvent(new CustomEvent("wa-select", { bubbles: true, detail: { item: hide } }));
await sidebar.updateComplete;
expect(loadStoredHiddenSessionCatalogIds().has("codex")).toBe(true);
expect(sidebar.querySelector('[data-session-section="catalog:codex"]')).toBeNull();
storeHiddenSessionCatalogIds(new Set());
await sidebar.updateComplete;
expect(sidebar.querySelector('[data-session-section="catalog:codex"]')).not.toBeNull();
} finally {
vi.useRealTimers();
}
});
it("renders local rows directly and keeps paired-node rows under their host heading", async () => {
vi.useFakeTimers();
try {