Files
openclaw/ui/src/components/sidebar-menus-controller.ts
Vyctor H. Brzezowski 3aff572d74 fix(ui): dismiss session hovercard before its menu opens (#126345)
* fix(ui): dismiss session hovercard before menu

* test(ui): keep hovercard regression focused

* fix(ui): dismiss catalog session hovercards

* test(ui): use typed catalog session key
2026-08-19 21:41:25 -03:00

618 lines
22 KiB
TypeScript

import { nothing, type ReactiveController, type ReactiveControllerHost } from "lit";
import type { AgentIdentityResult } from "../api/types.ts";
import {
cancelRoutePreload,
scheduleRoutePreload,
type NavigationRouteId,
type SidebarZoneEntry,
} from "../app-navigation.ts";
import { isSessionRouteId, pathForRoute, type RouteId } from "../app-route-paths.ts";
import type { ApplicationContext, ApplicationNavigationOptions } from "../app/context.ts";
import type { ThemeMode } from "../app/theme.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
import { createIdleImport } from "../lib/idle-import.ts";
import {
scopedSessionPullRequestKey,
SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD,
sessionPullRequestsForGateway,
} from "../lib/session-pull-requests.ts";
import type { CatalogProjectGrouping } from "../lib/sessions/catalog-project-grouping.ts";
import { sessionNavigationTarget } from "../lib/sessions/route-navigation.ts";
import { parseAgentSessionKey } from "../lib/sessions/session-key.ts";
import { SidebarCatalogMenuController } from "./app-sidebar-catalog-menu.ts";
import { isSidebarRouteActive, renderSidebarNavRoute } from "./app-sidebar-nav-menus.ts";
import type {
SidebarRecentSession,
SidebarSessionGroupMenuState,
SidebarSessionMenuState,
SidebarSessionSortMode,
} from "./app-sidebar-session-types.ts";
import type { SidebarWorkboardBoard, SidebarWorkboardRenderers } from "./app-sidebar-workboard.ts";
import type { SessionDataController } from "./session-data-controller.ts";
import { fetchSessionMenuWork } from "./session-menu-work.ts";
import type { SessionMenuWork } from "./session-menu.ts";
import type {
SessionOrganizerController,
SessionOrganizerControllerHost,
} from "./session-organizer-controller.ts";
import type { SessionOwnerOption } from "./session-owner-chip.ts";
import { SESSION_MENU_OPEN_EVENT } from "./session-progress-hovercard-target.ts";
type SidebarMenuAgent = {
id: string;
name?: string;
identity?: { name?: string; emoji?: string; avatar?: string; avatarUrl?: string };
};
interface SidebarMenusControllerState {
customizeMenuPosition: { x: number; y: number } | null;
moreMenuPosition: { x: number; y: number } | null;
sessionMenu: SidebarSessionMenuState | null;
sessionMenuWork: SessionMenuWork | null;
sessionGroupMenu: SidebarSessionGroupMenuState | null;
sessionSortMenuPosition: { 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;
}
type SidebarMenusRenderer = {
renderSidebarAgentMenuForController(controller: SidebarMenusController): unknown;
renderSidebarCatalogViewMenuForController(controller: SidebarMenusController): unknown;
renderSidebarCustomizeMenuForController(controller: SidebarMenusController): unknown;
renderSidebarIdentityMenuForController(controller: SidebarMenusController): unknown;
renderSidebarMoreMenuForController(controller: SidebarMenusController): unknown;
renderSidebarSessionGroupMenuForController(controller: SidebarMenusController): unknown;
renderSidebarSessionMenuForController(controller: SidebarMenusController): unknown;
renderSidebarSessionSortMenuForController(controller: SidebarMenusController): unknown;
};
interface SidebarMenusControllerHost
extends ReactiveControllerHost, SessionOrganizerControllerHost {
readonly activeRouteId?: NavigationRouteId;
readonly activeWorkboardBoardId: string;
readonly basePath: string;
readonly canPairDevice: boolean;
readonly connected: boolean;
readonly offline: boolean;
readonly enabledRouteIds?: readonly NavigationRouteId[];
readonly gatewayVersion: string | null;
readonly onNavigate?: (
routeId: NavigationRouteId,
options?: ApplicationNavigationOptions,
) => void;
readonly onPairMobile?: () => void;
readonly onRetryConnect?: () => void;
readonly onUpdateSidebarEntries?: (entries: string[]) => void;
readonly onPreloadRoute?: (routeId: NavigationRouteId) => Promise<void>;
readonly pinnedAgentIds: readonly string[];
readonly preferencesBrowserOnly: boolean;
readonly selectedSessionKeys: ReadonlySet<string>;
readonly sessionData: SessionOrganizerControllerHost["sessionData"] &
Pick<
SessionDataController,
| "approvalBadgeSnapshot"
| "presenceInstanceId"
| "presencePayload"
| "sessionResultsByAgent"
| "sessionsLoading"
| "sessionsResult"
>;
readonly sessionDataContext: ApplicationContext<RouteId> | undefined;
readonly sessionOrganizer: SessionOrganizerController;
readonly sessionOwnerFilterActive: boolean;
sessionOwnerFilterId: string | null;
sessionInvolvingMeFilterActive: boolean;
readonly sessionOwnerOptions: readonly SessionOwnerOption[];
readonly sessionOwnershipVisible: boolean;
readSessionMutationAccess(request: {
method: string;
params?: unknown;
requiredScope?: "operator.write" | "operator.admin";
}): import("../lib/session-method-access.ts").SessionMethodAccess;
readonly sidebarEntries: readonly string[];
readonly catalogProjectGrouping: CatalogProjectGrouping;
setCatalogProjectGrouping(grouping: CatalogProjectGrouping): void;
hideSessionCatalog(catalogId: string): void;
sessionSortMode: SidebarSessionSortMode;
effectiveSessionSortMode(): SidebarSessionSortMode;
sessionPeopleSortAvailable(): boolean;
setSessionSortMode(mode: SidebarSessionSortMode): void;
readonly terminalAvailable: boolean;
readonly themeMode: ThemeMode;
readonly workboardBoards: readonly SidebarWorkboardBoard[];
readonly workboardRenderers?: SidebarWorkboardRenderers;
activeChipAgent(): {
activeId: string;
agent: SidebarMenuAgent | undefined;
agents: readonly SidebarMenuAgent[];
identity: AgentIdentityResult | null;
identities: ReadonlyMap<string, AgentIdentityResult>;
};
ensureAgentIdentities(agentIds: readonly string[]): void;
agentUnreadCount(agentId: string): number;
askAgentCapabilities(agentId: string): void;
getRouteSessionKey(): string;
getSessionNavigationState(): { selectedAgentId: string };
reconciledSidebarZone(): {
entries: readonly SidebarZoneEntry[];
sidebarEntries: readonly string[];
};
selectedVisibleSessions(): SidebarRecentSession[];
switchChipAgent(agentId: string): void;
}
/** Popup ownership and stateless menu-renderer wiring. */
export class SidebarMenusController implements ReactiveController, SidebarMenusControllerState {
customizeMenuPosition: { x: number; y: number } | null = null;
moreMenuPosition: { x: number; y: number } | null = null;
sessionMenu: SidebarSessionMenuState | null = null;
sessionMenuWork: SessionMenuWork | null = null;
sessionGroupMenu: SidebarSessionGroupMenuState | null = null;
sessionSortMenuPosition: { 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.
identityMenuPosition: { x: number; bottom: number; width: number } | null = null;
customizeMenuTrigger: HTMLElement | null = null;
moreMenuTrigger: HTMLElement | null = null;
sessionMenuTrigger: HTMLElement | null = null;
private sessionMenuWorkVersion = 0;
sessionGroupMenuTrigger: HTMLElement | null = null;
sessionSortMenuTrigger: HTMLElement | null = null;
catalogViewMenuTrigger: HTMLElement | null = null;
agentMenuTrigger: HTMLElement | null = null;
identityMenuTrigger: HTMLElement | null = null;
private readonly routePreloadTimers = new Map<
EventTarget,
ReturnType<typeof globalThis.setTimeout>
>();
private menuRenderer: SidebarMenusRenderer | null = null;
// Popup rendering pulls Web Awesome menu code out of startup JS. It preloads
// at idle and is requested immediately by the first menu interaction.
private readonly menuRendererImport = createIdleImport(
() => import("./sidebar-menus-render.ts"),
(renderer) => {
this.menuRenderer = renderer;
this.host.requestUpdate();
},
);
readonly catalogMenu: SidebarCatalogMenuController;
constructor(readonly host: SidebarMenusControllerHost) {
host.addController(this);
this.catalogMenu = new SidebarCatalogMenuController({
// Closing every transient menu keeps one popover at a time.
beforeOpen: () => void this.dismissTransientMenus(),
requestUpdate: () => host.requestUpdate(),
terminalAvailable: () => host.terminalAvailable,
navigate: ({ routeId, navigation }) => host.onNavigate?.(routeId, navigation),
});
}
hostConnected(): void {
this.menuRendererImport.schedule();
}
hostDisconnected(): void {
this.menuRendererImport.dispose();
for (const timer of this.routePreloadTimers.values()) {
globalThis.clearTimeout(timer);
}
this.routePreloadTimers.clear();
}
private updateState<Key extends keyof SidebarMenusControllerState>(
key: Key,
value: SidebarMenusControllerState[Key],
): void {
Object.assign(this, { [key]: value });
this.host.requestUpdate();
}
preloadMenuRenderer() {
return this.menuRendererImport.load();
}
private loadMenuRenderer() {
void this.preloadMenuRenderer().catch(() => undefined);
}
// The shell calls this before CSS hides the panel or drawer. Mounted menus
// keep document-level shortcuts alive even when an ancestor is hidden.
dismissTransientMenus(): boolean {
const hadTransientMenu = Boolean(
this.customizeMenuPosition ||
this.moreMenuPosition ||
this.sessionMenu ||
this.catalogMenu.isOpen ||
this.sessionGroupMenu ||
this.sessionSortMenuPosition ||
this.catalogViewMenuPosition ||
this.agentMenuPosition ||
this.identityMenuPosition,
);
this.closeCustomizeMenu();
this.closeMoreMenu();
this.closeSessionMenu();
this.catalogMenu.close();
this.closeSessionGroupMenu();
this.closeSessionSortMenu();
this.closeCatalogViewMenu();
this.closeAgentMenu();
this.closeIdentityMenu();
return hadTransientMenu;
}
preloadRoute(routeId: NavigationRouteId, event: Event, immediate = false) {
scheduleRoutePreload(
this.routePreloadTimers,
routeId,
event,
(nextRouteId) => this.host.onPreloadRoute?.(nextRouteId),
routeId === this.host.activeRouteId || !this.isRouteEnabled(routeId),
immediate,
);
}
readonly cancelPreload = (event: Event) => {
cancelRoutePreload(this.routePreloadTimers, event);
};
isRouteEnabled(routeId: NavigationRouteId): boolean {
return this.host.enabledRouteIds?.includes(routeId) ?? true;
}
readonly openCustomizeMenuFromContext = (event: MouseEvent) => {
event.preventDefault();
this.openCustomizeMenu(event.clientX, event.clientY);
};
openCustomizeMenu(x: number, y: number, trigger: HTMLElement | null = null) {
const menuWidth = 240;
const menuMaxHeight = 420;
this.loadMenuRenderer();
this.dismissTransientMenus();
this.customizeMenuTrigger = trigger;
this.updateState("customizeMenuPosition", {
x: Math.max(8, Math.min(x, window.innerWidth - menuWidth - 8)),
y: Math.max(8, Math.min(y, window.innerHeight - menuMaxHeight - 8)),
});
}
closeCustomizeMenu(options: { restoreFocus?: boolean } = {}) {
const trigger = this.customizeMenuTrigger;
this.customizeMenuTrigger = null;
this.updateState("customizeMenuPosition", null);
if (options.restoreFocus) {
trigger?.focus();
}
}
toggleMoreMenu(trigger: HTMLElement) {
if (this.moreMenuPosition) {
this.closeMoreMenu();
return;
}
this.loadMenuRenderer();
const menuWidth = 240;
const menuMaxHeight = 420;
const rect = trigger.getBoundingClientRect();
this.dismissTransientMenus();
this.moreMenuTrigger = trigger;
this.updateState("moreMenuPosition", {
x: Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8)),
y: Math.max(8, Math.min(rect.bottom + 4, window.innerHeight - menuMaxHeight - 8)),
});
}
closeMoreMenu(options: { restoreFocus?: boolean } = {}) {
const trigger = this.moreMenuTrigger;
this.moreMenuTrigger = null;
this.updateState("moreMenuPosition", null);
if (options.restoreFocus) {
trigger?.focus();
}
}
/** A row outside the current selection retargets before the menu opens. */
openSessionMenu(
session: SidebarRecentSession,
x: number,
y: number,
trigger: HTMLElement | null = null,
) {
trigger?.dispatchEvent(
new CustomEvent(SESSION_MENU_OPEN_EVENT, { bubbles: true, composed: true }),
);
if (!this.host.selectedSessionKeys.has(session.key)) {
this.host.clearSessionSelection();
}
this.showSessionMenu(session, x, y, trigger);
}
private showSessionMenu(
session: SidebarRecentSession,
x: number,
y: number,
trigger: HTMLElement | null = null,
) {
this.loadMenuRenderer();
this.dismissTransientMenus();
this.sessionMenuTrigger = trigger;
this.updateState("sessionMenu", { session, x, y });
this.loadSessionMenuWork(session);
}
closeSessionMenu() {
const gateway = this.host.sessionDataContext?.gateway;
if (gateway) {
sessionPullRequestsForGateway(gateway).unwatch(this);
}
this.sessionMenuTrigger = null;
this.sessionMenuWorkVersion += 1;
this.updateState("sessionMenu", null);
this.updateState("sessionMenuWork", null);
}
private loadSessionMenuWork(session: SidebarRecentSession) {
const version = ++this.sessionMenuWorkVersion;
if (!session.worktreeId) {
this.updateState("sessionMenuWork", null);
return;
}
this.updateState("sessionMenuWork", {
loading: true,
pullRequestUrl: null,
worktreePath: null,
});
const context = this.host.sessionDataContext;
const client = context?.gateway.snapshot.client;
if (!context || !client) {
this.updateState("sessionMenuWork", {
loading: false,
pullRequestUrl: null,
worktreePath: null,
});
return;
}
const { selectedAgentId } = this.host.getSessionNavigationState();
const store = sessionPullRequestsForGateway(context.gateway);
const pullRequestKey = scopedSessionPullRequestKey(
session.key,
parseAgentSessionKey(session.key)?.agentId ?? selectedAgentId,
);
void fetchSessionMenuWork({
client,
pullRequestsAvailable:
isGatewayMethodAdvertised(
context.gateway.snapshot,
SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD,
) === true,
sessionKey: session.key,
agentId: parseAgentSessionKey(session.key)?.agentId ?? selectedAgentId,
loadPullRequests: () => store.load(this, pullRequestKey),
worktreeId: session.worktreeId,
}).then((work) => {
if (version === this.sessionMenuWorkVersion) {
this.updateState("sessionMenuWork", { loading: false, ...work });
}
});
}
openSessionGroupMenu(group: string, x: number, y: number, trigger: HTMLElement | null) {
const menuWidth = 224;
const menuMaxHeight = 160;
this.loadMenuRenderer();
this.dismissTransientMenus();
this.sessionGroupMenuTrigger = trigger;
this.updateState("sessionGroupMenu", {
group,
x: Math.max(8, Math.min(x, window.innerWidth - menuWidth - 8)),
y: Math.max(8, Math.min(y, window.innerHeight - menuMaxHeight - 8)),
});
}
closeSessionGroupMenu(options: { restoreFocus?: boolean } = {}) {
const trigger = this.sessionGroupMenuTrigger;
this.sessionGroupMenuTrigger = null;
this.updateState("sessionGroupMenu", null);
if (options.restoreFocus) {
trigger?.focus();
}
}
toggleSessionSortMenu(trigger: HTMLElement) {
if (this.sessionSortMenuPosition) {
this.closeSessionSortMenu();
return;
}
this.loadMenuRenderer();
const menuWidth = 200;
const menuMaxHeight = 280;
const rect = trigger.getBoundingClientRect();
this.dismissTransientMenus();
this.sessionSortMenuTrigger = trigger;
this.updateState("sessionSortMenuPosition", {
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)),
});
}
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 = 360;
this.dismissTransientMenus();
this.catalogViewMenuTrigger = trigger;
this.updateState("catalogViewMenuPosition", {
catalogId,
x: Math.max(8, Math.min(x, window.innerWidth - menuWidth - 8)),
y: Math.max(8, Math.min(y, window.innerHeight - menuMaxHeight - 8)),
});
}
closeCatalogViewMenu(options: { restoreFocus?: boolean } = {}) {
const trigger = this.catalogViewMenuTrigger;
this.catalogViewMenuTrigger = null;
this.updateState("catalogViewMenuPosition", null);
if (options.restoreFocus) {
trigger?.focus();
}
}
closeSessionSortMenu(options: { restoreFocus?: boolean } = {}) {
const trigger = this.sessionSortMenuTrigger;
this.sessionSortMenuTrigger = null;
this.updateState("sessionSortMenuPosition", null);
if (options.restoreFocus) {
trigger?.focus();
}
}
toggleAgentMenu(trigger: HTMLElement) {
if (this.agentMenuPosition) {
this.closeAgentMenu();
return;
}
this.loadMenuRenderer();
const menuWidth = 240;
const rect = trigger.getBoundingClientRect();
this.closeCustomizeMenu();
this.closeMoreMenu();
this.closeSessionMenu();
this.closeSessionGroupMenu();
this.closeSessionSortMenu();
this.closeCatalogViewMenu();
this.closeIdentityMenu();
this.agentMenuTrigger = trigger;
this.updateState("agentMenuFilter", "");
// The agent card sits at the top of the sidebar, so the menu drops below it
// and shares its left edge; anchoring above would cover the card you clicked.
this.updateState("agentMenuPosition", {
x: Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8)),
top: Math.min(rect.bottom + 4, window.innerHeight - 8),
});
}
closeAgentMenu(options: { restoreFocus?: boolean } = {}) {
const trigger = this.agentMenuTrigger;
this.agentMenuTrigger = null;
this.updateState("agentMenuPosition", null);
this.updateState("agentMenuFilter", "");
if (options.restoreFocus) {
trigger?.focus();
}
}
setAgentMenuFilter(next: string) {
this.updateState("agentMenuFilter", next);
}
toggleIdentityMenu(trigger: HTMLElement) {
if (this.identityMenuPosition) {
this.closeIdentityMenu();
return;
}
this.loadMenuRenderer();
const rect = trigger.getBoundingClientRect();
const menuWidth = Math.max(240, rect.width);
this.dismissTransientMenus();
this.identityMenuTrigger = trigger;
this.updateState("identityMenuPosition", {
x: Math.max(8, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - 8)),
bottom: Math.max(8, window.innerHeight - rect.top + 4),
width: rect.width,
});
}
closeIdentityMenu(options: { restoreFocus?: boolean } = {}) {
const trigger = this.identityMenuTrigger;
this.identityMenuTrigger = null;
this.updateState("identityMenuPosition", null);
if (options.restoreFocus) {
trigger?.focus();
}
}
renderCustomizeMenu() {
return this.menuRenderer?.renderSidebarCustomizeMenuForController(this) ?? nothing;
}
renderAgentMenu() {
return this.menuRenderer?.renderSidebarAgentMenuForController(this) ?? nothing;
}
renderIdentityMenu() {
return this.menuRenderer?.renderSidebarIdentityMenuForController(this) ?? nothing;
}
renderSessionMenu() {
return this.menuRenderer?.renderSidebarSessionMenuForController(this) ?? nothing;
}
renderSessionGroupMenu() {
return this.menuRenderer?.renderSidebarSessionGroupMenuForController(this) ?? nothing;
}
renderSessionSortMenu() {
return this.menuRenderer?.renderSidebarSessionSortMenuForController(this) ?? nothing;
}
renderCatalogViewMenu() {
return this.menuRenderer?.renderSidebarCatalogViewMenuForController(this) ?? nothing;
}
renderRoute(routeId: NavigationRouteId) {
if (!this.isRouteEnabled(routeId)) {
return nothing;
}
const routeSessionKey = isSessionRouteId(routeId) ? this.host.getRouteSessionKey() : "";
const context = this.host.sessionDataContext;
const sessionTarget =
isSessionRouteId(routeId) && routeSessionKey && context
? sessionNavigationTarget({ context, face: routeId, sessionKey: routeSessionKey })
: null;
return renderSidebarNavRoute({
routeId,
href: sessionTarget?.href ?? pathForRoute(routeId, this.host.basePath),
active:
isSidebarRouteActive(this.host.activeRouteId, routeId) &&
!(routeId === "workboard" && this.activeWorkboardBoardIsPinned()),
onNavigate: () => {
this.host.onNavigate?.(routeId, sessionTarget?.options);
},
onPreload: (event, immediate) => this.preloadRoute(routeId, event, immediate),
onCancelPreload: this.cancelPreload,
});
}
renderMoreMenu() {
return this.menuRenderer?.renderSidebarMoreMenuForController(this) ?? nothing;
}
private activeWorkboardBoardIsPinned(): boolean {
return Boolean(
this.host.activeWorkboardBoardId &&
this.host
.reconciledSidebarZone()
.entries.some(
(entry) =>
entry.type === "workboard" && entry.boardId === this.host.activeWorkboardBoardId,
),
);
}
}