From faf2602d22aa0d9d7fa7894a95ef3703b506d049 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 16:02:53 -0700 Subject: [PATCH] fix(ui): keep native context menus out of the sidebar and make session catalogs hideable (#118217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Codex --- scripts/control-ui-mock-dev.ts | 67 ++++++++++++++++++ ui/src/components/app-sidebar-render.ts | 9 +++ .../app-sidebar-session-catalog-render.ts | 18 ++++- .../app-sidebar-session-list-render.ts | 16 ++--- .../app-sidebar-session-menu-renderers.ts | 7 ++ .../app-sidebar-session-row-render.ts | 2 +- .../app-sidebar-session-types.test.ts | 17 +++++ .../components/app-sidebar-session-types.ts | 28 ++++++++ ui/src/components/app-sidebar.ts | 44 ++++++++++-- ui/src/components/sidebar-menus-controller.ts | 22 +++--- ui/src/components/sidebar-menus-render.ts | 7 ++ ui/src/i18n/locales/en.ts | 3 + ui/src/pages/config/config-page.ts | 28 ++++++++ ui/src/pages/config/settings-targets.ts | 1 + .../config/view-appearance-preferences.ts | 23 +++++++ ui/src/pages/config/view-types.ts | 2 + ui/src/pages/config/view.browser.test.ts | 24 +++++++ .../app-sidebar-cases/agent-menu.ts | 31 +++++++++ .../app-sidebar-cases/interactions.ts | 69 +++++++++++++++++++ 19 files changed, 394 insertions(+), 24 deletions(-) diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index e54e510e06a4..3e35f080ecd1 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -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", diff --git a/ui/src/components/app-sidebar-render.ts b/ui/src/components/app-sidebar-render.ts index 764b51cb770c..701b7f1883a4 100644 --- a/ui/src/components/app-sidebar-render.ts +++ b/ui/src/components/app-sidebar-render.ts @@ -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(".sidebar-agent-card__main") ?? card; + host.sidebarMenus.toggleAgentMenu(trigger); + }} > + ` + : nothing}

${t("configView.sessionObserver.title")}

diff --git a/ui/src/pages/config/view-types.ts b/ui/src/pages/config/view-types.ts index 20ce5c92cf5f..ee1b34fe5d1c 100644 --- a/ui/src/pages/config/view-types.ts +++ b/ui/src/pages/config/view-types.ts @@ -125,6 +125,8 @@ export type ConfigProps = { resetTextScale: () => void; sidebarLiveActivity: boolean; setSidebarLiveActivity: (enabled: boolean) => void; + hiddenSessionCatalogIds: ReadonlySet; + setSessionCatalogHidden: (catalogId: string, hidden: boolean) => void; chatMessageMaxWidth?: string; setChatMessageMaxWidth: (value: string | undefined) => void; showAdvancedSettings: boolean; diff --git a/ui/src/pages/config/view.browser.test.ts b/ui/src/pages/config/view.browser.test.ts index edfdede195b4..5b84e9d80e75 100644 --- a/ui/src/pages/config/view.browser.test.ts +++ b/ui/src/pages/config/view.browser.test.ts @@ -84,6 +84,8 @@ describe("config view", () => { resetTextScale: vi.fn(), sidebarLiveActivity: true, setSidebarLiveActivity: vi.fn(), + hiddenSessionCatalogIds: new Set(), + 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(".settings-row")).find( + (candidate) => + candidate.querySelector(".settings-row__title")?.textContent?.trim() === "codex", + ); + expect(heading).toBeDefined(); + expect(row).toBeDefined(); + row?.querySelector("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); diff --git a/ui/src/test-helpers/app-sidebar-cases/agent-menu.ts b/ui/src/test-helpers/app-sidebar-cases/agent-menu.ts index 3d249e71b7c6..558d100491f5 100644 --- a/ui/src/test-helpers/app-sidebar-cases/agent-menu.ts +++ b/ui/src/test-helpers/app-sidebar-cases/agent-menu.ts @@ -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("openclaw-sidebar-agent-card"); + const trigger = card?.querySelector(".sidebar-agent-card__main"); + const label = card?.querySelector(".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( diff --git a/ui/src/test-helpers/app-sidebar-cases/interactions.ts b/ui/src/test-helpers/app-sidebar-cases/interactions.ts index e32e16b0d773..69eb53013d1c 100644 --- a/ui/src/test-helpers/app-sidebar-cases/interactions.ts +++ b/ui/src/test-helpers/app-sidebar-cases/interactions.ts @@ -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("aside.sidebar"); + const footer = sidebar.querySelector(".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( + '[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(".sidebar-catalog-view-menu"); + const hide = menu?.querySelector('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 {