Merge pull request #1627 from manaflow-ai/issue-1623-devtools-x-close-state

Fix browser devtools X-close persistence
This commit is contained in:
Austin Wang 2026-03-17 17:48:01 -07:00 committed by GitHub
commit 59901034bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 100 additions and 0 deletions

View file

@ -2256,7 +2256,11 @@ final class BrowserPanel: Panel, ObservableObject {
private var developerToolsTransitionTargetVisible: Bool?
private var pendingDeveloperToolsTransitionTargetVisible: Bool?
private var developerToolsTransitionSettleWorkItem: DispatchWorkItem?
private var developerToolsVisibilityLossCheckWorkItem: DispatchWorkItem?
private let developerToolsTransitionSettleDelay: TimeInterval = 0.15
private let developerToolsAttachedManualCloseDetectionDelay: TimeInterval = 0.35
private var developerToolsLastAttachedHostAt: Date?
private var developerToolsLastKnownVisibleAt: Date?
private var detachedDeveloperToolsWindowCloseObserver: NSObjectProtocol?
private var preferredAttachedDeveloperToolsWidth: CGFloat?
private var preferredAttachedDeveloperToolsWidthFraction: CGFloat?
@ -3699,6 +3703,8 @@ final class BrowserPanel: Panel, ObservableObject {
developerToolsRestoreRetryWorkItem = nil
developerToolsTransitionSettleWorkItem?.cancel()
developerToolsTransitionSettleWorkItem = nil
developerToolsVisibilityLossCheckWorkItem?.cancel()
developerToolsVisibilityLossCheckWorkItem = nil
if let detachedDeveloperToolsWindowCloseObserver {
NotificationCenter.default.removeObserver(detachedDeveloperToolsWindowCloseObserver)
}
@ -4082,6 +4088,7 @@ extension BrowserPanel {
let isVisibleSelector = NSSelectorFromString("isVisible")
if inspector.cmuxCallBool(selector: isVisibleSelector) ?? false {
developerToolsDetachedOpenGraceDeadline = nil
developerToolsLastKnownVisibleAt = Date()
return true
}
@ -4091,6 +4098,9 @@ extension BrowserPanel {
guard inspector.responds(to: showSelector) else { return false }
inspector.cmuxCallVoid(selector: showSelector)
let visibleAfterShow = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
if visibleAfterShow {
developerToolsLastKnownVisibleAt = Date()
}
if preferredDeveloperToolsPresentation == .detached {
developerToolsDetachedOpenGraceDeadline = visibleAfterShow
? nil
@ -4307,6 +4317,7 @@ extension BrowserPanel {
developerToolsDetachedOpenGraceDeadline = nil
syncDeveloperToolsPresentationPreferenceFromUI()
preferredDeveloperToolsVisible = true
developerToolsLastKnownVisibleAt = Date()
cancelDeveloperToolsRestoreRetry()
return
}
@ -4314,9 +4325,74 @@ extension BrowserPanel {
return
}
preferredDeveloperToolsVisible = false
developerToolsLastKnownVisibleAt = nil
cancelDeveloperToolsRestoreRetry()
}
func noteDeveloperToolsHostAttached() {
cancelPendingDeveloperToolsVisibilityLossCheck()
developerToolsLastAttachedHostAt = Date()
if isDeveloperToolsVisible() {
developerToolsLastKnownVisibleAt = Date()
}
}
func scheduleDeveloperToolsVisibilityLossCheck() {
developerToolsVisibilityLossCheckWorkItem?.cancel()
let attachedAge = developerToolsLastAttachedHostAt.map { Date().timeIntervalSince($0) } ?? 0
let delay = max(
developerToolsTransitionSettleDelay,
developerToolsAttachedManualCloseDetectionDelay - attachedAge
)
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.developerToolsVisibilityLossCheckWorkItem = nil
_ = self.consumeAttachedDeveloperToolsManualCloseIfNeeded()
}
developerToolsVisibilityLossCheckWorkItem = workItem
DispatchQueue.main.asyncAfter(
deadline: .now() + max(0, delay),
execute: workItem
)
}
func cancelPendingDeveloperToolsVisibilityLossCheck() {
developerToolsVisibilityLossCheckWorkItem?.cancel()
developerToolsVisibilityLossCheckWorkItem = nil
}
@discardableResult
func consumeAttachedDeveloperToolsManualCloseIfNeeded(inspector: NSObject? = nil) -> Bool {
guard preferredDeveloperToolsVisible else { return false }
guard preferredDeveloperToolsPresentation != .detached else { return false }
guard !isDeveloperToolsTransitionInFlight else { return false }
guard webView.superview != nil, webView.window != nil else { return false }
guard let developerToolsLastAttachedHostAt else { return false }
guard Date().timeIntervalSince(developerToolsLastAttachedHostAt) >= developerToolsAttachedManualCloseDetectionDelay else {
return false
}
guard developerToolsLastKnownVisibleAt != nil else { return false }
guard let inspector = inspector ?? webView.cmuxInspectorObject() else { return false }
guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return false }
guard !visible else {
developerToolsLastKnownVisibleAt = Date()
return false
}
preferredDeveloperToolsVisible = false
developerToolsDetachedOpenGraceDeadline = nil
developerToolsLastKnownVisibleAt = nil
forceDeveloperToolsRefreshOnNextAttach = false
cancelDeveloperToolsRestoreRetry()
#if DEBUG
dlog(
"browser.devtools attachedClose.consume panel=\(id.uuidString.prefix(5)) " +
"\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())"
)
#endif
return true
}
/// Called after WKWebView reattaches to keep inspector stable across split/layout churn.
func restoreDeveloperToolsAfterAttachIfNeeded() {
guard preferredDeveloperToolsVisible else {
@ -4337,6 +4413,7 @@ extension BrowserPanel {
if visible {
developerToolsDetachedOpenGraceDeadline = nil
syncDeveloperToolsPresentationPreferenceFromUI()
developerToolsLastKnownVisibleAt = Date()
#if DEBUG
if shouldForceRefresh {
dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
@ -4360,6 +4437,10 @@ extension BrowserPanel {
return
}
if consumeAttachedDeveloperToolsManualCloseIfNeeded(inspector: inspector) {
return
}
#if DEBUG
if shouldForceRefresh {
dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
@ -4375,6 +4456,7 @@ extension BrowserPanel {
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if visibleAfterShow {
syncDeveloperToolsPresentationPreferenceFromUI()
developerToolsLastKnownVisibleAt = Date()
cancelDeveloperToolsRestoreRetry()
scheduleDetachedDeveloperToolsWindowDismissal()
} else {

View file

@ -612,6 +612,16 @@ struct BrowserPanelView: View {
refreshSuggestions()
}
}
.onChange(of: isVisibleInUI) { visibleInUI in
if visibleInUI {
panel.cancelPendingDeveloperToolsVisibilityLossCheck()
return
}
// Pane/workspace churn can briefly mark the browser hidden before the
// final host settles. Only treat a stable hide as a signal to consume
// an attached-inspector X-close.
panel.scheduleDeveloperToolsVisibilityLossCheck()
}
.onChange(of: isFocused) { focused in
#if DEBUG
logBrowserFocusState(
@ -627,6 +637,12 @@ struct BrowserPanelView: View {
panel.invalidateAddressBarPageFocusRestoreAttempts()
hideSuggestions()
setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused")
// Surface switches in split layouts can keep the browser visible, so
// `isVisibleInUI` never flips to false. Check for an attached-inspector
// X-close when focus leaves as well so the persisted intent stays in sync.
DispatchQueue.main.async {
panel.scheduleDeveloperToolsVisibilityLossCheck()
}
}
syncWebViewResponderPolicyWithViewState(
reason: "panelFocusChanged",
@ -5937,6 +5953,7 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator.lastPortalHostId = nil
coordinator.lastSynchronizedHostGeometryRevision = 0
if didAttachWebViewToLocalHost {
panel.noteDeveloperToolsHostAttached()
panel.restoreDeveloperToolsAfterAttachIfNeeded()
webView.needsLayout = true
webView.layoutSubtreeIfNeeded()
@ -5950,6 +5967,7 @@ struct WebViewRepresentable: NSViewRepresentable {
host.scheduleHostedInspectorDockConfigurationSync(reason: "localInline.update.async")
}
} else {
panel.consumeAttachedDeveloperToolsManualCloseIfNeeded()
host.scheduleHostedInspectorDockConfigurationSync(reason: "localInline.update")
}