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`