mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
fix(macos): close focused side-panel tabs with Cmd+W (#142634)
* fix(macos): close focused side-panel tabs with Cmd+W * test(macos): target native close shortcut fixtures explicitly
This commit is contained in:
parent
3e4ed53ede
commit
c8d760ec46
9 changed files with 516 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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`<textarea aria-label="Side panel input"></textarea>`,
|
||||
workspace: html`<div>Workspace</div>`,
|
||||
},
|
||||
primary: html`<main>Conversation</main>`,
|
||||
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<HTMLElement>('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);
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>("[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<HTMLElement>("wa-tab[active]")?.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
deliverPanelEvent(slot: SidebarSlotId, event: Event): boolean {
|
||||
const panel = this.parentElement?.querySelector<HTMLElement>(
|
||||
|
|
|
|||
|
|
@ -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`<textarea aria-label="Side chat"></textarea>` };
|
||||
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<HTMLElement>('[data-panel-slot="detail"]')!;
|
||||
side.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true }));
|
||||
const main = root(region).querySelector<HTMLElement>("[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<HTMLElement>('[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<HTMLElement>(".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`<div data-native-browser-scope="native-owner"></div>` };
|
||||
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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue