Merge pull request #1173 from manaflow-ai/task-browser-pane-devtools-resize-disappear

Preserve browser devtools when resizing pane
This commit is contained in:
Lawrence Chen 2026-03-10 21:48:39 -07:00 committed by GitHub
commit bcb712fde4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 344 additions and 13 deletions

View file

@ -1672,12 +1672,15 @@ final class WindowBrowserSlotView: NSView {
func pinHostedWebView(_ webView: WKWebView) {
guard webView.superview === self else { return }
let needsPlainWebViewFrameReset =
!Self.hasWebKitCompanionSubview(in: self, primaryWebView: webView) &&
Self.frameDiffersFromBounds(webView.frame, bounds: bounds)
let needsFrameHosting =
hostedWebView !== webView ||
!hostedWebViewConstraints.isEmpty ||
needsPlainWebViewFrameReset ||
!webView.translatesAutoresizingMaskIntoConstraints ||
webView.autoresizingMask != [.width, .height] ||
!Self.rectApproximatelyEqual(webView.frame, bounds)
webView.autoresizingMask != [.width, .height]
guard needsFrameHosting else {
needsLayout = true
layoutSubtreeIfNeeded()
@ -1688,7 +1691,8 @@ final class WindowBrowserSlotView: NSView {
hostedWebViewConstraints = []
hostedWebView = webView
// Attached Web Inspector mutates the moved WKWebView's frame directly.
// Edge constraints fight side-docked resizing and cause visible churn.
// Re-pin plain web views after cross-host reattach, but preserve the
// WebKit-managed split frame when docked DevTools siblings are present.
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = bounds
@ -1696,6 +1700,27 @@ final class WindowBrowserSlotView: NSView {
layoutSubtreeIfNeeded()
}
private static func frameDiffersFromBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
abs(frame.minX - bounds.minX) > epsilon ||
abs(frame.minY - bounds.minY) > epsilon ||
abs(frame.width - bounds.width) > epsilon ||
abs(frame.height - bounds.height) > epsilon
}
private static func hasWebKitCompanionSubview(in host: NSView, primaryWebView: WKWebView) -> Bool {
var stack = host.subviews.filter { $0 !== primaryWebView }
while let current = stack.popLast() {
if current.isDescendant(of: primaryWebView) {
continue
}
if String(describing: type(of: current)).contains("WK") {
return true
}
stack.append(contentsOf: current.subviews)
}
return false
}
func effectivePaneTopChromeHeight() -> CGFloat {
paneTopChromeHeight
}
@ -1975,6 +2000,7 @@ final class WindowBrowserPortal: NSObject {
guard let webView = entry.webView,
let containerView = entry.containerView,
!containerView.isHidden else { continue }
guard webView.superview === containerView else { continue }
refreshHostedWebViewPresentation(
webView,
in: containerView,
@ -2650,7 +2676,7 @@ final class WindowBrowserPortal: NSObject {
containerView.setPaneTopChromeHeight(0)
containerView.setSearchOverlay(nil)
containerView.setDropZoneOverlay(zone: nil)
if !containerView.isHidden {
if !containerView.isHidden, webView.superview === containerView {
webView.browserPortalNotifyHidden(reason: reason)
}
containerView.isHidden = true
@ -2752,7 +2778,18 @@ final class WindowBrowserPortal: NSObject {
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
refreshReasons.append("syncAttachContainer")
}
if webView.superview !== containerView {
let shouldPreserveExternalHostForHiddenEntry =
!entry.visibleInUI &&
webView.superview !== containerView
if shouldPreserveExternalHostForHiddenEntry {
#if DEBUG
dlog(
"browser.portal.reparent.skip web=\(browserPortalDebugToken(webView)) " +
"reason=hiddenEntryExternalHost super=\(browserPortalDebugToken(webView.superview)) " +
"container=\(browserPortalDebugToken(containerView))"
)
#endif
} else if webView.superview !== containerView {
#if DEBUG
dlog(
"browser.portal.reparent web=\(browserPortalDebugToken(webView)) " +
@ -2943,15 +2980,16 @@ final class WindowBrowserPortal: NSObject {
refreshReasons.append("bounds")
}
let containerOwnsWebView = webView.superview === containerView
let containerBounds = containerView.bounds
let preNormalizeWebFrame = webView.frame
let preNormalizeWebFrame = containerOwnsWebView ? webView.frame : .zero
let inspectorHeightFromInsets = max(0, containerBounds.height - preNormalizeWebFrame.height)
let inspectorHeightFromOverflow = max(0, preNormalizeWebFrame.maxY - containerBounds.maxY)
let inspectorHeightApprox = max(inspectorHeightFromInsets, inspectorHeightFromOverflow)
#if DEBUG
let inspectorSubviews = Self.inspectorSubviewCount(in: containerView)
#endif
if Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) {
if containerOwnsWebView && Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) {
let oldWebFrame = preNormalizeWebFrame
CATransaction.begin()
CATransaction.setDisableActions(true)
@ -3010,14 +3048,16 @@ final class WindowBrowserPortal: NSObject {
if transientRecoveryReason == nil {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
if !shouldHide, !refreshReasons.isEmpty {
if !shouldHide, containerOwnsWebView, !refreshReasons.isEmpty {
refreshHostedWebViewPresentation(
webView,
in: containerView,
reason: "\(source):" + refreshReasons.joined(separator: ",")
)
}
hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync")
if containerOwnsWebView {
hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync")
}
#if DEBUG
dlog(
"browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " +
@ -3027,6 +3067,7 @@ final class WindowBrowserPortal: NSObject {
"old=\(browserPortalDebugFrame(oldFrame)) raw=\(browserPortalDebugFrame(frameInHost)) " +
"target=\(browserPortalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
"entryVisible=\(entry.visibleInUI ? 1 : 0) " +
"containerOwnsWeb=\(containerOwnsWebView ? 1 : 0) " +
"containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " +
"containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " +
"preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " +

View file

@ -3786,8 +3786,7 @@ struct WebViewRepresentable: NSViewRepresentable {
hostedWebView !== webView ||
!hostedWebViewConstraints.isEmpty ||
!webView.translatesAutoresizingMaskIntoConstraints ||
webView.autoresizingMask != [.width, .height] ||
webView.frame != container.bounds
webView.autoresizingMask != [.width, .height]
guard needsFrameHosting else {
needsLayout = true
layoutSubtreeIfNeeded()
@ -3799,8 +3798,8 @@ struct WebViewRepresentable: NSViewRepresentable {
hostedWebView = webView
// WebKit's attached inspector does not reliably dock into a constraint-managed
// WKWebView hierarchy on macOS. Host the moved webview with autoresizing so
// the inspector can resize the content view in place.
// WKWebView hierarchy on macOS. Host the moved webview with autoresizing and
// keep WebKit-owned page frames intact when DevTools is side-docked.
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = container.bounds
@ -4487,6 +4486,34 @@ struct WebViewRepresentable: NSViewRepresentable {
)
}
let shouldPreserveExistingExternalLocalHost =
host.window == nil &&
webView.superview != nil &&
webView.superview !== slotView
if shouldPreserveExistingExternalLocalHost {
// Split zoom can instantiate a replacement local host before it joins a window.
// Never let that off-window host steal the live page + inspector hierarchy away
// from the currently visible local host.
host.setLocalInlineSlotHidden(true)
coordinator.lastPortalHostId = nil
coordinator.lastSynchronizedHostGeometryRevision = 0
#if DEBUG
dlog(
"browser.localHost.reparent.skip web=\(Self.objectID(webView)) " +
"reason=offWindowReplacementHost super=\(Self.objectID(webView.superview)) " +
"host=\(Self.objectID(host)) slot=\(Self.objectID(slotView))"
)
Self.logDevToolsState(
panel,
event: "localHost.skip",
generation: coordinator.attachGeneration,
retryCount: 0,
details: Self.attachContext(webView: webView, host: host)
)
#endif
return false
}
if webView.superview !== slotView {
if let sourceSuperview = webView.superview {
Self.moveWebKitRelatedSubviewsIntoHostIfNeeded(

View file

@ -2481,6 +2481,18 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
return (panel, inspector)
}
private func findHostContainerView(in root: NSView) -> WebViewRepresentable.HostContainerView? {
if let host = root as? WebViewRepresentable.HostContainerView {
return host
}
for subview in root.subviews {
if let host = findHostContainerView(in: subview) {
return host
}
}
return nil
}
func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() {
let (panel, inspector) = makePanelWithInspector()
@ -2691,6 +2703,89 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
}
func testOffWindowReplacementLocalHostDoesNotStealVisibleDevToolsWebView() {
let (panel, _) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
let paneId = PaneID(id: UUID())
let representable = WebViewRepresentable(
panel: panel,
paneId: paneId,
shouldAttachWebView: false,
useLocalInlineHosting: true,
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0,
paneDropZone: nil,
searchOverlay: nil,
paneTopChromeHeight: 0
)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let visibleHosting = NSHostingView(rootView: representable)
visibleHosting.frame = contentView.bounds
visibleHosting.autoresizingMask = [.width, .height]
contentView.addSubview(visibleHosting)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
visibleHosting.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
guard let visibleHost = findHostContainerView(in: visibleHosting) else {
XCTFail("Expected visible local host")
return
}
guard let visibleSlot = panel.webView.superview as? WindowBrowserSlotView else {
XCTFail("Expected visible local inline slot")
return
}
let inspectorView = WKInspectorProbeView(
frame: NSRect(x: 0, y: 0, width: visibleSlot.bounds.width, height: 72)
)
inspectorView.autoresizingMask = [.width]
visibleSlot.addSubview(inspectorView)
panel.webView.frame = NSRect(
x: 0,
y: inspectorView.frame.maxY,
width: visibleSlot.bounds.width,
height: visibleSlot.bounds.height - inspectorView.frame.height
)
visibleSlot.layoutSubtreeIfNeeded()
let detachedRoot = NSView(frame: visibleHosting.frame)
let offWindowHosting = NSHostingView(rootView: representable)
offWindowHosting.frame = detachedRoot.bounds
offWindowHosting.autoresizingMask = [.width, .height]
detachedRoot.addSubview(offWindowHosting)
detachedRoot.layoutSubtreeIfNeeded()
offWindowHosting.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertNotNil(findHostContainerView(in: offWindowHosting), "Expected off-window replacement host")
XCTAssertTrue(visibleHost.window === window)
XCTAssertTrue(
panel.webView.superview === visibleSlot,
"An off-window replacement host should not steal a visible DevTools-hosted web view during split zoom churn"
)
XCTAssertTrue(
inspectorView.superview === visibleSlot,
"An off-window replacement host should leave DevTools companion views in the visible local host"
)
}
}
final class WorkspaceShortcutMapperTests: XCTestCase {
@ -9370,6 +9465,33 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
XCTAssertEqual(webView.autoresizingMask, [.width, .height])
XCTAssertEqual(webView.frame, slot.bounds)
}
func testWindowBrowserSlotReattachesPlainWebViewAtFullBoundsAfterHiddenHostResize() {
let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 400, height: 180))
let webView = WKWebView(frame: .zero)
slot.addSubview(webView)
slot.pinHostedWebView(webView)
XCTAssertEqual(webView.frame, slot.bounds)
let externalHost = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 180))
webView.removeFromSuperview()
externalHost.addSubview(webView)
webView.frame = externalHost.bounds
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
slot.addSubview(webView)
slot.pinHostedWebView(webView)
slot.frame = NSRect(x: 0, y: 0, width: 300, height: 180)
slot.layoutSubtreeIfNeeded()
XCTAssertEqual(
webView.frame,
slot.bounds,
"Reattaching a plain web view should restore full-bounds hosting instead of preserving a stale inset frame from a hidden host"
)
}
}
@MainActor
@ -11010,6 +11132,8 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
}
}
private final class WKInspectorProbeView: NSView {}
private func realizeWindowLayout(_ window: NSWindow) {
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
@ -11274,6 +11398,145 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5)
}
func testPortalResizePreservesSideDockedInspectorManagedWebViewFrame() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor, visibleInUI: true)
contentView.layoutSubtreeIfNeeded()
portal.synchronizeWebViewForAnchor(anchor)
guard let slot = webView.superview as? WindowBrowserSlotView else {
XCTFail("Expected browser slot")
return
}
let initialInspectorWidth: CGFloat = 110
let inspectorContainer = NSView(
frame: NSRect(
x: slot.bounds.width - initialInspectorWidth,
y: 0,
width: initialInspectorWidth,
height: slot.bounds.height
)
)
inspectorContainer.autoresizingMask = [.minXMargin, .height]
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
inspectorView.autoresizingMask = [.width, .height]
inspectorContainer.addSubview(inspectorView)
slot.addSubview(inspectorContainer)
webView.frame = NSRect(
x: 0,
y: 0,
width: slot.bounds.width - initialInspectorWidth,
height: slot.bounds.height
)
webView.autoresizingMask = [.width, .height]
slot.layoutSubtreeIfNeeded()
anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180)
contentView.layoutSubtreeIfNeeded()
portal.synchronizeWebViewForAnchor(anchor)
XCTAssertFalse(slot.isHidden, "Resizing the browser pane should keep the hosted browser visible")
XCTAssertEqual(
webView.frame.maxX,
inspectorContainer.frame.minX,
accuracy: 0.5,
"Portal sync should preserve the side-docked inspector split instead of stretching the page back over the inspector"
)
XCTAssertLessThan(
webView.frame.width,
slot.bounds.width,
"Side-docked inspector should still own part of the slot after pane resize"
)
}
func testHiddenPortalSyncDoesNotStealLocallyHostedDevToolsWebViewDuringResize() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor, visibleInUI: true)
contentView.layoutSubtreeIfNeeded()
portal.synchronizeWebViewForAnchor(anchor)
advanceAnimations()
guard let hiddenPortalSlot = webView.superview as? WindowBrowserSlotView else {
XCTFail("Expected browser slot")
return
}
portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
portal.synchronizeWebViewForAnchor(anchor)
advanceAnimations()
XCTAssertTrue(hiddenPortalSlot.isHidden, "Hidden portal entry should keep its slot hidden")
let localInlineSlot = WindowBrowserSlotView(frame: anchor.frame)
contentView.addSubview(localInlineSlot)
let inspectorView = WKInspectorProbeView(
frame: NSRect(x: 0, y: 0, width: localInlineSlot.bounds.width, height: 72)
)
inspectorView.autoresizingMask = [.width]
localInlineSlot.addSubview(inspectorView)
localInlineSlot.addSubview(webView)
webView.frame = NSRect(
x: 0,
y: inspectorView.frame.maxY,
width: localInlineSlot.bounds.width,
height: localInlineSlot.bounds.height - inspectorView.frame.height
)
localInlineSlot.layoutSubtreeIfNeeded()
anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180)
localInlineSlot.frame = anchor.frame
contentView.layoutSubtreeIfNeeded()
localInlineSlot.layoutSubtreeIfNeeded()
portal.synchronizeWebViewForAnchor(anchor)
XCTAssertTrue(
webView.superview === localInlineSlot,
"Hidden portal sync should not steal a DevTools-hosted web view back out of local inline hosting during pane resize"
)
XCTAssertTrue(
inspectorView.superview === localInlineSlot,
"Hidden portal sync should leave local DevTools companion views in the local inline host"
)
XCTAssertTrue(hiddenPortalSlot.isHidden, "The retiring hidden portal slot should stay hidden during local inline hosting")
}
func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),