diff --git a/apps/macos/Sources/OpenClaw/DashboardNativeBrowserHost.swift b/apps/macos/Sources/OpenClaw/DashboardNativeBrowserHost.swift index 6fa6171c934d..8e60b6404e80 100644 --- a/apps/macos/Sources/OpenClaw/DashboardNativeBrowserHost.swift +++ b/apps/macos/Sources/OpenClaw/DashboardNativeBrowserHost.swift @@ -108,6 +108,12 @@ final class DashboardNativeBrowserHost { self.tabs.first { $0.id == tabId }?.browser.webView } + func presentationScope(for webView: WKWebView) -> String? { + guard !webView.isHiddenOrHasHiddenAncestor, + let tab = self.tabs.first(where: { $0.browser.webView === webView }) else { return nil } + return self.presentation(for: tab.id)?.key + } + @discardableResult func open(tabId: String, url: URL) throws -> String { let requestedURL = try DashboardBrowserMessageHandler.url(url.absoluteString) @@ -258,7 +264,7 @@ final class DashboardNativeBrowserHost { for tab in self.tabs { // One WKWebView cannot be in two scopes: the newest presenter wins. // Releasing it restores the older scope if that scope is still visible. - let presentation = self.presentations.values.filter { $0.tabId == tab.id }.max { $0.order < $1.order } + let presentation = self.presentation(for: tab.id)?.value guard let presentation else { tab.browser.webView.isHidden = true continue @@ -269,6 +275,10 @@ final class DashboardNativeBrowserHost { } } + private func presentation(for tabId: String) -> (key: String, value: Presentation)? { + self.presentations.filter { $0.value.tabId == tabId }.max { $0.value.order < $1.value.order } + } + private func requireWebView(_ tabId: String) throws -> WKWebView { guard let webView = self.webView(for: tabId) else { throw DashboardBrowserError.unknownTab } return webView diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift index 53c049d92243..7d002d64accc 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -8,6 +8,20 @@ private final class DashboardWindowContentView: NSView { override var mouseDownCanMoveWindow: Bool { true } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + // Command-translated characters preserve the shortcut on alternate layouts. + guard event.type == .keyDown, + event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command, + event.characters?.lowercased() == "w", + let window, window.attachedSheet == nil, + let controller = window.windowController as? DashboardWindowController + else { return super.performKeyEquivalent(with: event) } + // Claim the key before AppKit's Close Window menu action. The web owner + // decides whether a focused side tab exists; the traffic light is unchanged. + controller.closeFocusedPanelOrWindow() + return true + } } /// The dashboard's empty unified toolbar exists only to grow the titlebar to @@ -981,6 +995,34 @@ extension DashboardWindowController { self.activeNavigationWebView.goForward() } + fileprivate func closeFocusedPanelOrWindow() { + guard let window else { return } + guard self.canDispatchNativeCommands else { + window.performClose(nil) + return + } + let sourceID = self.notificationSourceID + let intent = self.windowIntentGeneration + let lifetime = self.windowLifetimeRevision + let browserScope = self.nativeBrowser.presentationScope(for: self.activeNavigationWebView) + let detail = browserScope.map { "{browserScope:\(Self.jsStringLiteral($0))}" } ?? "null" + let script = Self.scopedDashboardScript(""" + return !window.dispatchEvent(new CustomEvent('openclaw:native-close-focused-panel', { + cancelable: true, detail: \(detail) + })); + """, url: self.currentURL) + Task { @MainActor [weak self, weak window] in + guard let self, let window else { return } + let handled = try? await self.webView.evaluateJavaScript(script) + // A delayed reply must not close a replacement/reopened window or a + // new document. Close intent is never queued for a future dashboard. + guard self.window === window, self.notificationSourceID == sourceID, + self.windowIntentGeneration == intent, self.windowLifetimeRevision == lifetime, + !self.webView.isLoading, handled as? Bool != true else { return } + window.performClose(nil) + } + } + private static func makeJavaScriptConfirmAlert(message: String, host: String?) -> NSAlert { let alert = NSAlert() alert.messageText = "OpenClaw Dashboard" @@ -1215,7 +1257,7 @@ extension DashboardWindowController { } var windowLifetimeRevision: UInt64? { - (window as? DashboardWindow)?.lifetimeRevision + (self.window as? DashboardWindow)?.lifetimeRevision } private func advanceWindowIntent() { diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardCloseShortcutTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardCloseShortcutTests.swift new file mode 100644 index 000000000000..5a9557e515bc --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardCloseShortcutTests.swift @@ -0,0 +1,177 @@ +import AppKit +import Foundation +import Testing +import WebKit +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct DashboardCloseShortcutTests { + @Test func `command W routes native browser focus to the visible presenting panel`() async throws { + let previousMenu = self.installCloseMenu() + defer { NSApp.mainMenu = previousMenu } + let server = try await DashboardHTTPFixture.start() + defer { server.stop() } + let controller = self.makeController(server: server) + defer { controller.closeDashboard() } + try await self.waitForDocument(controller) + let window = try #require(controller.window) + try await controller.webView.evaluateJavaScript(""" + window.closeScope = null; + window.addEventListener('openclaw:native-close-focused-panel', event => { + window.closeScope = event.detail?.browserScope; + event.preventDefault(); + }); + """) + try controller.nativeBrowser.open(tabId: "focused", url: #require(URL(string: "about:blank"))) + let browser = try #require(controller.nativeBrowser.webView(for: "focused")) + let rect = DashboardBrowserRect(x: 100, y: 100, width: 300, height: 200) + try controller.nativeBrowser.present(scope: "older-panel", tabId: "focused", rect: rect, visible: true) + try controller.nativeBrowser.present(scope: "visible-panel", tabId: "focused", rect: rect, visible: true) + #expect(window.makeFirstResponder(browser)) + + try self.pressCommandW(in: window) + try await self.waitUntil { + try await controller.webView.evaluateJavaScript("window.closeScope") as? String == "visible-panel" + } + #expect(window.isVisible) + controller.nativeBrowser.releaseScope("visible-panel") + try self.pressCommandW(in: window) + try await self.waitUntil { + try await controller.webView.evaluateJavaScript("window.closeScope") as? String == "older-panel" + } + #expect(window.isVisible) + } + + @Test(arguments: ["w", "ц", ","]) + func `command W lets the focused panel close before the native window`(_ baseCharacter: String) async throws { + let previousMenu = self.installCloseMenu() + defer { NSApp.mainMenu = previousMenu } + let server = try await DashboardHTTPFixture.start() + defer { server.stop() } + let controller = self.makeController(server: server) + defer { controller.closeDashboard() } + try await self.waitForDocument(controller) + let window = try #require(controller.window) + try await controller.webView.evaluateJavaScript(""" + window.panelOpen = true; + window.addEventListener('openclaw:native-close-focused-panel', event => { + if (!window.panelOpen) return; + window.panelOpen = false; + event.preventDefault(); + }); + """) + + try self.pressCommandW(in: window, baseCharacter: baseCharacter) + try await self.waitUntil { + if !window.isVisible { + return true + } + return try await controller.webView.evaluateJavaScript("window.panelOpen") as? Bool == false + } + #expect(window.isVisible) + #expect(try await controller.webView.evaluateJavaScript("window.panelOpen") as? Bool == false) + + // With no panel claiming the next command, ordinary window closing remains available. + try self.pressCommandW(in: window, baseCharacter: baseCharacter) + try await self.waitUntil { !window.isVisible } + } + + @Test func `traffic light closes the window without asking the focused panel`() async throws { + let previousMenu = self.installCloseMenu() + defer { NSApp.mainMenu = previousMenu } + let server = try await DashboardHTTPFixture.start() + defer { server.stop() } + let controller = self.makeController(server: server) + defer { controller.closeDashboard() } + try await self.waitForDocument(controller) + try await controller.webView.evaluateJavaScript(""" + window.addEventListener('openclaw:native-close-focused-panel', event => event.preventDefault()); + """) + let window = try #require(controller.window) + try #require(window.isVisible) + try #require(window.standardWindowButton(.closeButton)).performClick(nil) + #expect(!window.isVisible) + } + + @Test func `command W on an unavailable dashboard still closes the window`() async throws { + let previousMenu = self.installCloseMenu() + defer { NSApp.mainMenu = previousMenu } + let controller = try DashboardWindowController( + url: #require(URL(string: "about:blank")), + auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil), + websiteDataStore: .nonPersistent(), + windowAutosaveName: "", + requestBrowserProfileImportOffer: { _ in false }) + defer { controller.closeDashboard() } + controller.showFailure(title: "Unavailable", message: "Offline") + let window = try #require(controller.window) + try #require(window.isVisible) + try self.pressCommandW(in: window) + try await self.waitUntil { !window.isVisible } + #expect(!window.isVisible) + } + + private func installCloseMenu() -> NSMenu? { + _ = AppKitTestSupport.application + let previous = NSApp.mainMenu + let menu = NSMenu() + let file = menu.addItem(withTitle: "File", action: nil, keyEquivalent: "") + file.submenu = NSMenu(title: "File") + file.submenu?.addItem( + withTitle: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + NSApp.mainMenu = menu + return previous + } + + private func makeController(server: DashboardHTTPFixture) -> DashboardWindowController { + _ = AppKitTestSupport.application + let controller = DashboardWindowController( + url: server.url(), + auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil), + websiteDataStore: .nonPersistent(), + windowAutosaveName: "", + requestBrowserProfileImportOffer: { _ in false }) + controller.loadInBackground( + url: server.url(), auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil)) + controller.show() + return controller + } + + private func pressCommandW(in window: NSWindow, baseCharacter: String = "w") throws { + // The accessory test runner has no app event loop to establish a key window. + // Give its Close menu the same target AppKit resolves in the running app. + let close = try #require(NSApp.mainMenu?.item(withTitle: "File")?.submenu?.item(withTitle: "Close Window")) + close.target = window + let event = try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: .command, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "w", + charactersIgnoringModifiers: baseCharacter, + isARepeat: false, + keyCode: 13)) + // Command-remapped layouts translate characters but retain their base glyph + // in charactersIgnoringModifiers. AppKit tries the window before the menu. + if !window.performKeyEquivalent(with: event) { + #expect(NSApp.sendAction(#selector(NSWindow.performClose(_:)), to: window, from: nil)) + } + } + + private func waitForDocument(_ controller: DashboardWindowController) async throws { + try await self.waitUntil { + controller.webView.url != nil && !controller.webView.isLoading && controller.canDeliverNativeCommands + } + } + + private func waitUntil(_ condition: () async throws -> Bool) async throws { + let deadline = ContinuousClock.now + .seconds(5) + while try await !condition() { + guard ContinuousClock.now < deadline else { throw URLError(.timedOut) } + try await Task.sleep(for: .milliseconds(10)) + } + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardNativeBrowserTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardNativeBrowserTests.swift index fa09e0d6bf41..e11243bf906b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/DashboardNativeBrowserTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardNativeBrowserTests.swift @@ -274,15 +274,19 @@ struct DashboardNativeBrowserHostTests { let tab = try #require(fixture.host.webView(for: "mac-first")) let firstRect = DashboardBrowserRect(x: 10, y: 20, width: 200, height: 100) let secondRect = DashboardBrowserRect(x: 300, y: 50, width: 300, height: 250) + #expect(fixture.host.presentationScope(for: tab) == nil) try fixture.host.present(scope: "panel-one", tabId: "mac-first", rect: firstRect, visible: true) try fixture.host.present(scope: "panel-two", tabId: "mac-first", rect: secondRect, visible: true) + #expect(fixture.host.presentationScope(for: tab) == "panel-two") #expect(fixture.container.convert(tab.bounds, from: tab) == CGRect(x: 320, y: 330, width: 300, height: 250)) fixture.host.releaseScope("panel-two") + #expect(fixture.host.presentationScope(for: tab) == "panel-one") #expect(!tab.isHidden) #expect(fixture.container.convert(tab.bounds, from: tab) == CGRect(x: 30, y: 510, width: 200, height: 100)) try fixture.host.present(scope: "panel-one", tabId: "mac-first", rect: firstRect, visible: false) + #expect(fixture.host.presentationScope(for: tab) == nil) #expect(tab.isHidden) try fixture.host.present(scope: "panel-one", tabId: "mac-first", rect: firstRect, visible: true) fixture.host.releaseAllScopes() diff --git a/ui/src/components/browser/browser-panel-native-presentation.ts b/ui/src/components/browser/browser-panel-native-presentation.ts index c8d8b5ec0309..d493024a7a99 100644 --- a/ui/src/components/browser/browser-panel-native-presentation.ts +++ b/ui/src/components/browser/browser-panel-native-presentation.ts @@ -38,11 +38,18 @@ export class BrowserPanelNativePresentation { constructor(private readonly controller: BrowserPanelNativePresentationHost) {} + private get hostElement(): Element | null { + const root = this.controller.host.renderRoot; + return root instanceof ShadowRoot ? root.host : root instanceof Element ? root : null; + } + connect(): void { if (this.connected) { return; } this.connected = true; + // Native responder focus can route back to this panel without DOM events. + this.hostElement?.setAttribute("data-native-browser-scope", this.scope); this.unsubscribeOcclusion = subscribeNativeOverlayOcclusion((occluded) => { this.occluded = occluded; if (occluded) { @@ -57,6 +64,7 @@ export class BrowserPanelNativePresentation { disconnect(): void { this.hide(); + this.hostElement?.removeAttribute("data-native-browser-scope"); this.connected = false; if (this.frame !== null) { cancelAnimationFrame(this.frame); @@ -147,8 +155,7 @@ export class BrowserPanelNativePresentation { return; } const rect = stage.getBoundingClientRect(); - const root = this.controller.host.renderRoot; - const host = root instanceof ShadowRoot ? root.host : root; + const host = this.hostElement; let hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2); // document hit-testing stops at shadow hosts. Descend to distinguish this // panel from a dialog or another panel within the same application root. diff --git a/ui/src/components/browser/browser-panel-native.test.ts b/ui/src/components/browser/browser-panel-native.test.ts index 7a6659009aa7..dc2203fdc78d 100644 --- a/ui/src/components/browser/browser-panel-native.test.ts +++ b/ui/src/components/browser/browser-panel-native.test.ts @@ -365,7 +365,12 @@ describe("native Browser panel ownership", () => { hit = panel; window.dispatchEvent(new Event("resize")); flushFrames(); - expect(native.messages().at(-1)).toMatchObject({ type: "present", visible: true }); + expect(native.messages().at(-1)).toMatchObject({ + type: "present", + visible: true, + scope: panel.dataset.nativeBrowserScope, + }); + expect(panel.dataset.nativeBrowserScope).toBeTruthy(); if (property === "presented") { panel.presented = false; } else { @@ -375,6 +380,7 @@ describe("native Browser panel ownership", () => { expect(native.messages().at(-1)).toMatchObject({ type: "present", visible: false }); panel.remove(); expect(native.messages().at(-1)).toMatchObject({ type: "release-scope" }); + expect(panel.hasAttribute("data-native-browser-scope")).toBe(false); }, ); diff --git a/ui/src/pages/chat/chat-pane-sidebar-layout.test.ts b/ui/src/pages/chat/chat-pane-sidebar-layout.test.ts index 58114b6729de..d7efad0c4fa0 100644 --- a/ui/src/pages/chat/chat-pane-sidebar-layout.test.ts +++ b/ui/src/pages/chat/chat-pane-sidebar-layout.test.ts @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { html, render } from "lit"; +import { html, LitElement, render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ResolvedBoardView } from "./chat-pane-shared.ts"; import { @@ -11,6 +11,7 @@ import { import type { ChatPageHost } from "./chat-state-host.ts"; import "./components/chat-sidebar-region.runtime.ts"; import { + closeSlot, openSlot, promoteSidebarPanel, setSidebarDock, @@ -44,6 +45,44 @@ function callbacks() { }; } +class NativeCloseLayoutFixture extends LitElement { + static override properties = { layout: { attribute: false } }; + declare layout: SidebarLayout; + + constructor() { + super(); + this.layout = openSlot(openSlot({ columns: [] }, "workspace"), "detail"); + } + + override createRenderRoot() { + return this; + } + + override render() { + return renderSidebarRegion({ + availableWidth: 1_400, + availableSlots: ["detail", "workspace"], + callbacks: { + ...callbacks(), + closeSlot: (slot) => { + this.layout = closeSlot(this.layout, slot); + }, + }, + layout: this.layout, + narrow: false, + panelActions: {}, + panelTemplates: { + detail: html``, + workspace: html`
Workspace
`, + }, + primary: html`
Conversation
`, + requestUpdate: () => this.requestUpdate(), + }); + } +} + +customElements.define("native-close-layout-fixture", NativeCloseLayoutFixture); + async function renderLayout(container: HTMLElement, layout: SidebarLayout, narrow = false) { render( renderSidebarRegion({ @@ -70,6 +109,27 @@ afterEach(() => { }); describe("chat pane sidebar layout", () => { + it("restores focus to the surviving tab after its parent commits native Close", async () => { + const parent = new NativeCloseLayoutFixture(); + containers.push(parent); + document.body.append(parent); + await parent.updateComplete; + const region = parent.querySelector("openclaw-chat-sidebar-region")!; + await region.updateComplete; + parent.querySelector("textarea")!.focus(); + + const command = new CustomEvent("openclaw:native-close-focused-panel", { cancelable: true }); + window.dispatchEvent(command); + expect(command.defaultPrevented).toBe(true); + await parent.updateComplete; + await region.updateComplete; + + expect(sidebarActivePanel(parent.layout)?.slot).toBe("workspace"); + const nextTab = parent.querySelector('wa-tab[panel="workspace"]')!; + expect(nextTab).not.toBeNull(); + expect(document.activeElement).toBe(nextTab); + }); + it("preserves drafts and panel state across swapping, docking, focus, minimize, and mobile", async () => { const container = document.createElement("div"); document.body.append(container); diff --git a/ui/src/pages/chat/components/chat-sidebar-region.runtime.ts b/ui/src/pages/chat/components/chat-sidebar-region.runtime.ts index 13c99d7cd8f1..b1c0bf240dcd 100644 --- a/ui/src/pages/chat/components/chat-sidebar-region.runtime.ts +++ b/ui/src/pages/chat/components/chat-sidebar-region.runtime.ts @@ -79,6 +79,85 @@ class ChatSidebarRegion extends OpenClawLightDomElement { @property({ type: Number }) availableWidth = 0; private previousGeometry = ""; private contentMounted = false; + private focusedSurface: Element | null = null; + private nativeCloseListeners: AbortController | undefined; + + override connectedCallback(): void { + super.connectedCallback(); + this.nativeCloseListeners = new AbortController(); + const options = { capture: true, signal: this.nativeCloseListeners.signal }; + // The region renders its content into siblings, not into this element. + // Document focus also clears the owner when another pane or chrome wins it. + document.addEventListener("pointerdown", this.trackFocus, options); + document.addEventListener("focusin", this.trackFocus, options); + window.addEventListener("openclaw:native-close-focused-panel", this.closeFocusedPanel, options); + } + + override disconnectedCallback(): void { + this.nativeCloseListeners?.abort(); + this.nativeCloseListeners = undefined; + this.focusedSurface = null; + super.disconnectedCallback(); + } + + private readonly trackFocus = (event: Event): void => { + const surface = event + .composedPath() + .find( + (node): node is Element => + node instanceof Element && node.matches("[data-region], [data-region-header]"), + ); + this.focusedSurface = + surface && surface.closest(".sidebar-region") === this.parentElement ? surface : null; + }; + + private readonly closeFocusedPanel = (event: Event): void => { + if (event.defaultPrevented || !this.layout.open || this.layout.expanded || !this.callbacks) { + return; + } + const browserScope = event instanceof CustomEvent ? event.detail?.browserScope : undefined; + // Native browser content is a separate NSView, so its responder scope is + // authoritative over the dashboard document's previous DOM focus. + const browser = + typeof browserScope === "string" + ? [ + ...(this.parentElement?.querySelectorAll("[data-native-browser-scope]") ?? + []), + ].find((element) => element.dataset.nativeBrowserScope === browserScope) + : undefined; + const frame = + document.activeElement instanceof HTMLIFrameElement + ? document.activeElement.closest("[data-region]") + : null; + const surface = + typeof browserScope === "string" + ? browser?.closest("[data-region]") + : (frame ?? this.focusedSurface); + const active = sidebarActivePanel(this.layout); + if ( + !active || + !surface?.isConnected || + surface.closest(".sidebar-region") !== this.parentElement || + !surface.matches('[data-region="side"], [data-region-header="side"]') || + surface.closest('[hidden], [inert], [aria-hidden="true"]') || + document.openClawModalLayers?.size || + document.querySelector("dialog[open], [aria-modal='true']") + ) { + return; + } + event.preventDefault(); + // Keep successive Close commands in the tab strip after its content unmounts. + const header = this.parentElement?.querySelector('[data-region-header="side"]') ?? null; + this.focusedSurface = header; + this.callbacks.closeSlot(active.slot); + // The callback invalidates the parent first; await this region's next commit. + this.requestUpdate(); + void this.updateComplete.then(() => { + if (this.layout.open && this.focusedSurface === header && header?.isConnected) { + header.querySelector("wa-tab[active]")?.focus(); + } + }); + }; deliverPanelEvent(slot: SidebarSlotId, event: Event): boolean { const panel = this.parentElement?.querySelector( diff --git a/ui/src/pages/chat/components/chat-sidebar-region.test.ts b/ui/src/pages/chat/components/chat-sidebar-region.test.ts index 8eaefcb80d95..9862de4b35bd 100644 --- a/ui/src/pages/chat/components/chat-sidebar-region.test.ts +++ b/ui/src/pages/chat/components/chat-sidebar-region.test.ts @@ -87,6 +87,131 @@ afterEach(() => { }); describe("chat sidebar region", () => { + it("claims native Close for the focused side tab and preserves its neighbor", async () => { + const region = await createRegion( + openSlot(openSlot({ columns: [] }, "workspace"), "companion"), + ); + region.panelTemplates = { companion: html`` }; + region.callbacks!.closeSlot = (slot) => { + region.layout = closeSlot(region.layout, slot); + }; + await region.updateComplete; + root(region).querySelector("textarea")!.focus(); + + const command = new CustomEvent("openclaw:native-close-focused-panel", { cancelable: true }); + window.dispatchEvent(command); + expect(command.defaultPrevented).toBe(true); + await region.updateComplete; + expect(region.layout.columns[0]?.panels.map((panel) => panel.slot)).toEqual(["workspace"]); + expect(region.layout.open).toBe(true); + + const nextCommand = new CustomEvent("openclaw:native-close-focused-panel", { + cancelable: true, + }); + window.dispatchEvent(nextCommand); + expect(nextCommand.defaultPrevented).toBe(true); + expect(region.layout.open).toBe(false); + }); + + it("yields native Close after pointer focus moves from Side chat to main or outside", async () => { + const region = await createRegion(); + const side = root(region).querySelector('[data-panel-slot="detail"]')!; + side.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); + const main = root(region).querySelector("[data-primary]")!; + main.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); + const mainCommand = new CustomEvent("openclaw:native-close-focused-panel", { + cancelable: true, + }); + window.dispatchEvent(mainCommand); + expect(mainCommand.defaultPrevented).toBe(false); + side.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); + document.body.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); + const outsideCommand = new CustomEvent("openclaw:native-close-focused-panel", { + cancelable: true, + }); + window.dispatchEvent(outsideCommand); + expect(outsideCommand.defaultPrevented).toBe(false); + expect(region.callbacks!.closeSlot).not.toHaveBeenCalled(); + }); + + it("uses current main/side roles and closes through the conversation owner", async () => { + const layout = promoteSidebarPanel( + openSlot(openSlot({ columns: [] }, "conversation"), "detail"), + "detail", + ); + const region = await createRegion(layout); + const main = root(region).querySelector('[data-panel-slot="detail"]')!; + main.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); + const mainCommand = new CustomEvent("openclaw:native-close-focused-panel", { + cancelable: true, + }); + window.dispatchEvent(mainCommand); + expect(mainCommand.defaultPrevented).toBe(false); + const conversation = root(region).querySelector(".sidebar-region__primary")!; + conversation.dataset.region = "side"; + conversation.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); + const command = new CustomEvent("openclaw:native-close-focused-panel", { cancelable: true }); + window.dispatchEvent(command); + expect(command.defaultPrevented).toBe(true); + expect(region.callbacks!.closeSlot).toHaveBeenCalledExactlyOnceWith("conversation"); + }); + + it("routes native Browser focus by its presentation scope, not stale page focus", async () => { + const other = await createRegion(); + const region = await createRegion(openSlot({ columns: [] }, "browser")); + region.panelTemplates = { browser: html`
` }; + await region.updateComplete; + root(other) + .querySelector("[data-panel-slot]")! + .dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); + const command = new CustomEvent("openclaw:native-close-focused-panel", { + cancelable: true, + detail: { browserScope: "native-owner" }, + }); + window.dispatchEvent(command); + expect(command.defaultPrevented).toBe(true); + expect(region.callbacks!.closeSlot).toHaveBeenCalledExactlyOnceWith("browser"); + expect(other.callbacks!.closeSlot).not.toHaveBeenCalled(); + }); + + it.each(["hidden", "minimized", "disconnected"] as const)( + "does not claim native Close from a %s retained panel", + async (state) => { + const region = await createRegion(); + root(region) + .querySelector("[data-panel-slot]")! + .dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); + if (state === "hidden") { + root(region).hidden = true; + } + if (state === "minimized") { + region.layout = setSidebarOpen(region.layout, false); + } + if (state === "disconnected") { + root(region).remove(); + } + const command = new CustomEvent("openclaw:native-close-focused-panel", { cancelable: true }); + window.dispatchEvent(command); + expect(command.defaultPrevented).toBe(false); + expect(region.callbacks!.closeSlot).not.toHaveBeenCalled(); + }, + ); + + it("leaves browser Command-W untouched", async () => { + const region = await createRegion(); + const side = root(region).querySelector("[data-panel-slot]")!; + side.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); + const key = new KeyboardEvent("keydown", { + key: "w", + metaKey: true, + bubbles: true, + cancelable: true, + }); + side.dispatchEvent(key); + expect(key.defaultPrevented).toBe(false); + expect(region.callbacks!.closeSlot).not.toHaveBeenCalled(); + }); + it.each([false, true])( "retains unavailable plugin tabs and recovers their registration (initially active: %s)", async (initiallyActive) => {