From 1294df882a1b2f5106be9f1462067bc3168a4b53 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 02:06:44 +0800 Subject: [PATCH 1/7] fix(ui): make terminal session picker keyboard accessible --- CHANGELOG.md | 2 +- .../terminal/terminal-panel.test.ts | 49 +++++++++++++++ ui/src/components/terminal/terminal-panel.ts | 61 ++++++++++++++++++- .../terminal/terminal-session-picker.ts | 22 ++++++- 4 files changed, 129 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c04f3ae887..91bddda1da38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,7 @@ Docs: https://docs.openclaw.ai ### Fixes - **Skill Workshop offline apply:** preserve configless local proposal apply after upgrades under exclusive Gateway startup ownership, while keeping running Gateway snapshot invalidation fail-closed when CLI credentials are unavailable. -- **macOS and Control UI keyboard navigation:** let Tab traverse links and controls inside embedded Dashboard, browser, and Canvas web views, and keep shortcuts working on non-Latin keyboard layouts without firing during IME composition. +- **macOS and Control UI keyboard navigation:** let Tab traverse links and controls inside embedded Dashboard, browser, and Canvas web views; keep shortcuts working on non-Latin keyboard layouts without firing during IME composition; and make the terminal session picker move focus inside, close on Escape or light dismiss, and return focus to its trigger. - **Control UI session diffs:** hide unchanged checkout modifications and untracked files that already existed when a thread started, so the diff panel attributes only files touched by that session. Fixes #115628. - **Code Mode small-model repair:** give malformed pre-dispatch `exec` calls one bounded correction turn, expose typed failure-phase and bridge-dispatch evidence, and stop retries after nested tools begin. Fixes #115311. - **Shared state corruption recovery:** evict only the exact cached SQLite owner after proven read or write corruption so a repaired database recovers without a Gateway restart while caller-injected handles remain untouched. Fixes #114269. Thanks @rizquuula. diff --git a/ui/src/components/terminal/terminal-panel.test.ts b/ui/src/components/terminal/terminal-panel.test.ts index d3ca7cd90d35..af5f4dfab288 100644 --- a/ui/src/components/terminal/terminal-panel.test.ts +++ b/ui/src/components/terminal/terminal-panel.test.ts @@ -660,6 +660,55 @@ describe("OpenClawTerminalPanel", () => { expect(menu).not.toContain("old-agent"); }); + it("manages focus and dismissal for the terminal session picker", 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"]', + ); + expect(trigger?.getAttribute("aria-haspopup")).toBe("dialog"); + expect(trigger?.getAttribute("aria-controls")).toBe("terminal-session-picker-dialog"); + + trigger?.click(); + await waitForFast(() => + expect(panel.renderRoot.activeElement).toBe( + panel.renderRoot.querySelector(".tp-session-refresh"), + ), + ); + expect(panel.renderRoot.querySelector(".tp-session-menu")?.getAttribute("role")).toBe("dialog"); + + panel.renderRoot + .querySelector(".tp-session-refresh") + ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await panel.updateComplete; + expect(panel.renderRoot.querySelector(".tp-session-menu")).toBeNull(); + expect(panel.renderRoot.activeElement).toBe(trigger); + + trigger?.click(); + await waitForFast(() => + expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull(), + ); + document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true })); + await panel.updateComplete; + expect(panel.renderRoot.querySelector(".tp-session-menu")).toBeNull(); + }); + 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 a3251fbb0d43..8ffd1d0247df 100644 --- a/ui/src/components/terminal/terminal-panel.ts +++ b/ui/src/components/terminal/terminal-panel.ts @@ -112,6 +112,8 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { }); private readonly onGlobalKeyDown = (event: KeyboardEvent) => this.handleGlobalKey(event); private readonly onToggleRequest = (event: Event) => this.handleToggleRequest(event); + private readonly onDocumentPointerDown = (event: PointerEvent) => + this.handleDocumentPointerDown(event); override connectedCallback(): void { super.connectedCallback(); @@ -123,6 +125,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { window.addEventListener("keydown", this.onGlobalKeyDown); window.addEventListener(TERMINAL_PANEL_TOGGLE_EVENT, this.onToggleRequest); } + document.addEventListener("pointerdown", this.onDocumentPointerDown, true); if (this.dockLayout.open) { void this.terminalSessions.restoreSessions(); } @@ -132,6 +135,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { super.disconnectedCallback(); window.removeEventListener("keydown", this.onGlobalKeyDown); window.removeEventListener(TERMINAL_PANEL_TOGGLE_EVENT, this.onToggleRequest); + document.removeEventListener("pointerdown", this.onDocumentPointerDown, true); this.terminalSessions.disconnectHost(); } @@ -199,6 +203,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { } closeTerminalPanel(): void { + this.closeSessionPicker(false); this.dockLayout.setOpen(false); } @@ -231,10 +236,58 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { } private toggleSessionPicker(): void { - this.sessionPickerOpen = !this.sessionPickerOpen; if (this.sessionPickerOpen) { - void this.refreshSessionPicker(); + this.closeSessionPicker(true); + return; } + this.sessionPickerOpen = true; + void this.refreshSessionPicker(); + void this.updateComplete.then(() => { + if (this.sessionPickerOpen) { + this.renderRoot.querySelector(".tp-session-refresh")?.focus(); + } + }); + } + + private closeSessionPicker(restoreFocus: boolean): void { + if (!this.sessionPickerOpen) { + return; + } + this.sessionPickerOpen = false; + if (restoreFocus) { + void this.updateComplete.then(() => { + this.renderRoot + .querySelector('[aria-controls="terminal-session-picker-dialog"]') + ?.focus(); + }); + } + } + + private handleDocumentPointerDown(event: PointerEvent): void { + if (!this.sessionPickerOpen) { + return; + } + const picker = this.renderRoot.querySelector(".tp-session-picker"); + if (picker && !event.composedPath().includes(picker)) { + this.closeSessionPicker(false); + } + } + + private handleSessionPickerFocusOut(event: FocusEvent): void { + const picker = event.currentTarget; + const next = event.relatedTarget; + if (picker instanceof HTMLElement && next instanceof Node && picker.contains(next)) { + return; + } + queueMicrotask(() => { + if ( + picker instanceof HTMLElement && + !picker.contains(this.renderRoot.activeElement) && + this.sessionPickerOpen + ) { + this.closeSessionPicker(false); + } + }); } private refreshSessionPicker(): Promise { @@ -255,7 +308,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { } resetTerminalSessionPicker(): void { - this.sessionPickerOpen = false; + this.closeSessionPicker(false); void this.sessionPickerTask.run([null]); this.pickerSessions = []; } @@ -293,6 +346,8 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { ), ), onToggle: () => this.toggleSessionPicker(), + onDismiss: (restoreFocus) => this.closeSessionPicker(restoreFocus), + onFocusOut: (event) => this.handleSessionPickerFocusOut(event), onRefresh: () => void this.refreshSessionPicker(), onAttach: (sessionId, owner) => void this.attachPickedSession(sessionId, owner), }); diff --git a/ui/src/components/terminal/terminal-session-picker.ts b/ui/src/components/terminal/terminal-session-picker.ts index f3b38cdd9c80..595c7bc9a861 100644 --- a/ui/src/components/terminal/terminal-session-picker.ts +++ b/ui/src/components/terminal/terminal-session-picker.ts @@ -8,10 +8,14 @@ type TerminalSessionPickerProps = { sessions: TerminalSessionInfo[]; currentSessionIds: ReadonlySet; onToggle: () => void; + onDismiss: (restoreFocus: boolean) => void; + onFocusOut: (event: FocusEvent) => void; onRefresh: () => void; onAttach: (sessionId: string, owner: TerminalSessionInfo["owner"]) => void; }; +const TERMINAL_SESSION_PICKER_ID = "terminal-session-picker-dialog"; + export function renderTerminalSessionPicker(props: TerminalSessionPickerProps) { return html`
@@ -21,6 +25,8 @@ export function renderTerminalSessionPicker(props: TerminalSessionPickerProps) { title=${t("terminal.sessions")} aria-label=${t("terminal.sessions")} aria-expanded=${props.open ? "true" : "false"} + aria-haspopup="dialog" + aria-controls=${TERMINAL_SESSION_PICKER_ID} @click=${props.onToggle} > ${props.open - ? html`