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
This commit is contained in:
Vyctor H. Brzezowski
2026-08-19 21:41:25 -03:00
committed by GitHub
parent 177828aa75
commit 3aff572d74
6 changed files with 127 additions and 1 deletions
@@ -0,0 +1,35 @@
/* @vitest-environment jsdom */
import { describe, expect, it, vi } from "vitest";
import { SidebarCatalogMenuController } from "./app-sidebar-catalog-menu.ts";
import { SESSION_MENU_OPEN_EVENT } from "./session-progress-hovercard-target.ts";
describe("SidebarCatalogMenuController", () => {
it("dismisses the matching hovercard before opening the catalog menu", () => {
const trigger = document.createElement("button");
const order: string[] = [];
trigger.addEventListener(SESSION_MENU_OPEN_EVENT, () => order.push("dismiss"));
const controller = new SidebarCatalogMenuController({
beforeOpen: () => order.push("open"),
requestUpdate: vi.fn(),
terminalAvailable: () => true,
navigate: vi.fn(),
});
controller.open(
{
key: { catalogId: "codex", hostId: "gateway:local", threadId: "thread-1" },
agentId: "main",
routeId: "chat",
navigation: {},
canOpenTerminal: true,
meta: "now",
},
10,
20,
trigger,
);
expect(order).toEqual(["dismiss", "open"]);
});
});
@@ -4,6 +4,7 @@ import { openCatalogSessionInTerminal } from "../lib/sessions/catalog-terminal.t
import type { CatalogSessionMenuRequest } from "./app-sidebar-session-catalogs.ts";
import "./catalog-session-menu.ts";
import type { CatalogSessionMenuAction } from "./catalog-session-menu.ts";
import { SESSION_MENU_OPEN_EVENT } from "./session-progress-hovercard-target.ts";
type SidebarCatalogSessionMenuState = CatalogSessionMenuRequest & { x: number; y: number };
@@ -30,6 +31,9 @@ export class SidebarCatalogMenuController {
y: number,
trigger: HTMLElement | null = null,
): void {
trigger?.dispatchEvent(
new CustomEvent(SESSION_MENU_OPEN_EVENT, { bubbles: true, composed: true }),
);
this.hooks.beforeOpen();
this.trigger = trigger;
this.state = { ...request, x, y };
@@ -1,5 +1,6 @@
const SESSION_PROGRESS_HOVER_LINK_SELECTOR = "a.markdown-session-link[data-session-key]";
const SESSION_PROGRESS_HOVER_SIDEBAR_SELECTOR = ".sidebar-recent-session[data-session-key]";
export const SESSION_MENU_OPEN_EVENT = "openclaw-session-menu-open";
export const SESSION_PROGRESS_HOVER_TARGET_SELECTOR = `${SESSION_PROGRESS_HOVER_LINK_SELECTOR}, ${SESSION_PROGRESS_HOVER_SIDEBAR_SELECTOR}`;
export function sessionProgressHoverTargetFromEvent(event: Event): HTMLElement | null {
@@ -20,6 +20,7 @@ import { createPortaledHovercard, PortaledHovercardController } from "./portaled
import { renderSessionHovercard } from "./session-hovercard.ts";
import { SessionLinkTitler } from "./session-link-titling.ts";
import {
SESSION_MENU_OPEN_EVENT,
sessionProgressHoverPlacementForTarget,
sessionProgressHoverTargetFromEvent,
} from "./session-progress-hovercard-target.ts";
@@ -99,6 +100,7 @@ export class SessionProgressHovercardProvider extends ReactiveElement {
this.addEventListener("focusin", this.handleFocusIn);
this.addEventListener("focusout", this.handleFocusOut);
this.addEventListener("keydown", this.handleKeyDown);
this.addEventListener(SESSION_MENU_OPEN_EVENT, this.handleSessionMenuOpen);
this.sessionLinkTitler.connect();
this.connectStore();
}
@@ -109,6 +111,7 @@ export class SessionProgressHovercardProvider extends ReactiveElement {
this.removeEventListener("focusin", this.handleFocusIn);
this.removeEventListener("focusout", this.handleFocusOut);
this.removeEventListener("keydown", this.handleKeyDown);
this.removeEventListener(SESSION_MENU_OPEN_EVENT, this.handleSessionMenuOpen);
this.sessionLinkTitler.disconnect();
this.disconnectStore();
this.close();
@@ -209,6 +212,12 @@ export class SessionProgressHovercardProvider extends ReactiveElement {
}
};
private readonly handleSessionMenuOpen = (event: Event) => {
if (sessionProgressHoverTargetFromEvent(event) === this.activeTarget) {
this.close();
}
};
private activate(target: HTMLElement, trigger: HTMLElement, delay: number): void {
const sessionKey = target.dataset.sessionKey;
if (!sessionKey || (target === this.activeTarget && sessionKey === this.activeSessionKey)) {
@@ -36,6 +36,7 @@ import type {
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;
@@ -324,6 +325,9 @@ export class SidebarMenusController implements ReactiveController, SidebarMenusC
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();
}
@@ -1,7 +1,7 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { Page } from "playwright";
import type { Locator, Page } from "playwright";
import { expect, it } from "vitest";
import { CONTROL_UI_SESSION_PULL_REQUESTS_CHANGED_EVENT } from "../../../src/gateway/control-ui-contract.js";
import { SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD } from "../lib/session-pull-requests.ts";
@@ -79,6 +79,79 @@ async function emitPullRequestSnapshot(
const suite = createChatFlowE2eSuite();
suite.define(() => {
it.each([
{
open: async (_page: Page, row: Locator) => {
await row.locator("[data-session-menu]").click();
},
source: "More",
},
{
open: async (_page: Page, row: Locator) => {
await row.click({ button: "right" });
},
source: "context menu",
},
{
open: async (page: Page, row: Locator) => {
await row.locator(".sidebar-recent-session__link").focus();
await page.keyboard.press("Shift+F10");
},
source: "keyboard",
},
])("dismisses the session hovercard before opening its menu from $source", async ({ open }) => {
const selectedSessionKey = "agent:main:selected-menu";
const sessionKey = "agent:main:hovered-menu";
await suite.withPage(
{
hasTouch: false,
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
},
async ({ page }) => {
await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "progressCard.get"],
methodResponses: {
"progressCard.get": { card: null },
"sessions.list": chatSessionListResponse([
{
key: selectedSessionKey,
kind: "direct",
label: "Selected session",
updatedAt: 2,
},
{
key: sessionKey,
kind: "direct",
label: "Hovered session",
updatedAt: 1,
},
]),
},
sessionKey: selectedSessionKey,
});
await page.goto(controlUiSessionUrl(suite.server.baseUrl, selectedSessionKey));
const row = page.locator(`.sidebar-recent-session[data-session-key="${sessionKey}"]`);
const trigger = row.locator("[data-session-menu]");
const card = page.locator(".session-progress-hovercard");
const menu = page.getByRole("menu", { name: "Actions for Hovered session" });
await row.waitFor({ state: "visible" });
await row.hover();
await card.waitFor({ state: "visible" });
await open(page, row);
await menu.waitFor({ state: "visible" });
await expect.poll(() => card.count()).toBe(0);
await expect.poll(() => menu.isVisible()).toBe(true);
await expect.poll(() => trigger.getAttribute("aria-expanded")).toBe("true");
},
);
});
it("renders safe progress markdown and refreshes the hovered card after a change event", async () => {
const now = Date.now();
const selectedSessionKey = "agent:main:selected";