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:
Patrick Erichsen 2026-09-08 17:07:29 -07:00 • committed by GitHub
parent 3e4ed53ede
commit c8d760ec46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 516 additions and 6 deletions

View file

@ -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

View file

@ -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() {

View file

@ -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))
}
}
}

View file

@ -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()

View file

@ -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.

View file

@ -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);
},
);

View file

@ -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);

View file

@ -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>(

View file

@ -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) => {