diff --git a/ui/src/components/terminal/terminal-panel.test.ts b/ui/src/components/terminal/terminal-panel.test.ts index fa840ced4f62..1d6ffeb1e38a 100644 --- a/ui/src/components/terminal/terminal-panel.test.ts +++ b/ui/src/components/terminal/terminal-panel.test.ts @@ -709,6 +709,115 @@ describe("OpenClawTerminalPanel", () => { expect(panel.renderRoot.querySelector(".tp-session-menu")).toBeNull(); }); + it("keeps a trigger pointerdown inside the picker before toggling closed", async () => { + createGhosttyTerminalMock.mockResolvedValue(createTerminalController()); + const client: TerminalGatewayClient = { + forceReconnect: () => {}, + request: async (method: string) => + (method === "terminal.open" + ? terminalOpenResult("current-1") + : method === "terminal.list" + ? { sessions: [] } + : {}) as T, + addEventListener: () => () => {}, + }; + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; + panel.client = client; + panel.available = true; + document.body.append(panel); + panel.toggle(); + await waitForFast(() => expect(panel.renderRoot.querySelector(".tp-actions")).not.toBeNull()); + + const trigger = panel.renderRoot.querySelector( + '[aria-label="Terminal sessions"]', + )!; + trigger.click(); + await waitForFast(() => + expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull(), + ); + trigger.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true })); + expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull(); + trigger.click(); + await panel.updateComplete; + expect(panel.renderRoot.querySelector(".tp-session-menu")).toBeNull(); + }); + + it("keeps shadow-DOM pointer actions inside the terminal session picker", async () => { + createGhosttyTerminalMock.mockResolvedValue(createTerminalController()); + const requests: Array<{ method: string; params: unknown }> = []; + const client: TerminalGatewayClient = { + forceReconnect: () => {}, + request: async (method: string, params?: unknown) => { + requests.push({ method, params }); + if (method === "terminal.open") { + return terminalOpenResult("current-1") as T; + } + if (method === "terminal.list") { + return { + sessions: [ + { + ...terminalOpenResult("detached-1"), + agentId: "detached-agent", + attached: false, + createdAtMs: 1, + }, + ], + } as T; + } + if (method === "terminal.attach") { + return { + ...terminalOpenResult("detached-1"), + agentId: "detached-agent", + buffer: "", + seq: 0, + } as T; + } + return {} as T; + }, + addEventListener: () => () => {}, + }; + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; + panel.client = client; + panel.available = true; + document.body.append(panel); + panel.toggle(); + await waitForFast(() => expect(panel.renderRoot.querySelector(".tp-actions")).not.toBeNull()); + + const trigger = panel.renderRoot.querySelector( + '[aria-label="Terminal sessions"]', + )!; + trigger.click(); + await waitForFast(() => + expect(panel.renderRoot.querySelector(".tp-session")?.textContent).toContain( + "detached-agent", + ), + ); + + const refresh = panel.renderRoot.querySelector(".tp-session-refresh")!; + refresh.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true })); + expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull(); + refresh.click(); + await waitForFast(() => { + expect(requests.filter(({ method }) => method === "terminal.list")).toHaveLength(2); + }); + await waitForFast(() => + expect(panel.renderRoot.querySelector(".tp-session")?.textContent).toContain( + "detached-agent", + ), + ); + + const attach = panel.renderRoot.querySelector(".tp-session")!; + attach.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true })); + expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull(); + attach.click(); + await waitForFast(() => + expect(requests).toContainEqual({ + method: "terminal.attach", + params: { sessionId: "detached-1" }, + }), + ); + }); + it("shows a picker attach failure after the listed session disappears", async () => { createGhosttyTerminalMock .mockResolvedValueOnce(createTerminalController()) diff --git a/ui/src/components/terminal/terminal-panel.ts b/ui/src/components/terminal/terminal-panel.ts index c6e5faabae69..01b1cbaf351c 100644 --- a/ui/src/components/terminal/terminal-panel.ts +++ b/ui/src/components/terminal/terminal-panel.ts @@ -268,7 +268,10 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { return; } const picker = this.renderRoot.querySelector(".tp-session-picker"); - if (picker && !event.composedPath().includes(picker)) { + // Document capture sees retargeted shadow-DOM events. The composed path + // preserves the picker wrapper so its trigger and actions stay clickable. + const path = event.composedPath(); + if (picker && !path.includes(picker)) { this.closeSessionPicker(false); } }