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 {