mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
6adbd0901a
commit
faf2602d22
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user