Skip to content

Prevent attached DevTools from re-entering unsafe side-dock layouts - #1230

Merged
austinywang merged 5 commits into
mainfrom
issue-1183-devtools-side-dock-guard
Mar 12, 2026
Merged

austinywang merged 5 commits into
mainfrom
issue-1183-devtools-side-dock-guard

Conversation

@austinywang

@austinywang austinywang commented Mar 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • adapt attached DevTools to narrow panes by requesting bottom dock when side-docking would leave too little page width
  • hide and disable WebInspector left/right side-dock controls while an attached pane is too narrow, routing those actions back to bottom dock instead
  • keep the host-side regression seam updated with narrow-pane coverage for the bottom-dock fallback and the dock-control disable path

Testing

  • not run locally per repo policy
  • ./scripts/reload.sh --tag issue-1183-devtools-resize-layout

Summary by cubic

Prevents attached DevTools from entering unsafe side-dock layouts on narrow panes by auto-switching to bottom dock and disabling side-dock controls when needed. Coalesces visibility toggles to avoid flicker and redundant Inspector calls, aligning with Linear issue 1183.

  • New Features

    • Adaptive fallback: request bottom dock when side-docking would leave <240px page width; enforce a 120px minimum inspector width.
    • Dock controls guard: hide and reroute WebInspector left/right actions to bottom when side-dock isn’t allowed; keep buttons in sync on layout/dock changes.
    • Managed side-dock: normalized heights, blocked autoresizing during drags, full-height divider; remember and reapply preferred widths.
    • DevTools transitions: queue and settle rapid show/hide requests so only the final intent runs.
  • Bug Fixes

    • Avoid divider snap back and flicker by skipping same-size reflows and repainting after frame updates.
    • Preserve WebKit-managed page frame on rehost/resize when a side-docked inspector exists, avoiding full-width resets.
    • Keep DevTools resizable after hitting minimum width; immediately promote visible left/right dock into the managed path for consistent drags.
    • CI: match WKWebView test override signature.

Written for commit f429907. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Adaptive inspector docking with cooldowns, recorded side-dock widths, and UI sync to the inspector web UI.
    • Staged Developer Tools visibility transitions with debouncing and queued settle logic.
  • Bug Fixes

    • Better divider hit-testing, sizing, and normalization across resizes and promotions.
    • Stronger minimum width enforcement and preservation of hosted web view frames when companion subviews manage layout.
  • Tests

    • Expanded tests covering docking, drag-resize, promotion, hit-testing, and frame-preservation scenarios.

@vercel

vercel Bot commented Mar 12, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 12, 2026 6:19am

@coderabbitai

coderabbitai Bot commented Mar 12, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Refactors inspector divider sizing to use container-bound anchoring and explicit frame normalization, propagates a minimumInspectorWidth through resizing flows, adds WebKit companion-subview detection to preserve managed frames, implements staged Developer Tools visibility transitions with debouncing, and adds adaptive side/bottom docking logic and tests.

Changes

Cohort / File(s) Summary
Divider Resizing & Frame Preservation
Sources/BrowserWindowPortal.swift
Replace per-element minY calculations with container-bound anchoring; normalize origins/heights in resizedFrames; add companion-subview detection to avoid resetting WebKit-managed webView.frames.
Hosted Inspector Resizing & Docking
Sources/Panels/BrowserPanelView.swift, Sources/Panels/.../HostContainerView*
Increase minimumHostedInspectorWidth to 120; add adaptive side/bottom dock heuristics, cooldown, recorded side-dock width, promotion/enforcement helpers; extend applyHostedInspectorDividerWidth and propagate minimumInspectorWidth through drag/reapply paths.
Developer Tools Transition Orchestration
Sources/Panels/BrowserPanel.swift
Introduce queued/staged visibility transitions with per-panel target/pending state, settle work item debounce, enqueue/perform/finish helpers, and updated toggle/show/hide flows and diagnostics.
Pinning/Hosting Behavior
Sources/.../WindowBrowserHostView.swift, Sources/.../pinHostedWebView*
Add detection of WebKit companion subviews (hasWebKitCompanionSubview) and conditionally skip webView.frame resets; adjust pin/host logic to respect companion-managed frames.
Tests & Coverage
cmuxTests/CmuxWebViewKeyEquivalentTests.swift, cmuxTests/BrowserPanelHostContainerViewTests.swift, cmuxTests/*
Add extensive tests for divider hit-testing, drag-resize, promotion/demotion between docks, minimum-width enforcement, portal pin/host/rehost flows, frame normalization, and transition waiting helpers.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant User
participant BrowserPanel
participant HostContainerView
participant BrowserWindowPortal as Portal/WebView
User->>BrowserPanel: drag divider / toggle devtools
BrowserPanel->>HostContainerView: compute preferredWidth, call resizedFrames(preferredWidth, containerBounds,..., minimumInspectorWidth)
HostContainerView->>HostContainerView: applyHostedInspectorDividerWidth(..., minimumInspectorWidth, reason)
HostContainerView->>Portal/WebView: request layout update / JS (dockLeft/dockRight/_dockBottom)
alt WebKit companion subview present
Portal/WebView-->>HostContainerView: keep WebKit-managed frame (skip reset)
else no companion
HostContainerView->>Portal/WebView: set webView.frame
end
HostContainerView->>BrowserPanel: update UI state / setNeedsDisplay
BrowserPanel->>User: visual update

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I nudged the dividers, soft and light,
Saved WebKit friends from sudden flight,
Docking dances, side then down,
Transitions settle, no more frown,
A tiny hop — the UI’s right! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: preventing attached DevTools from entering unsafe side-dock layouts on narrow panes by implementing adaptive fallback and control guards.
Description check ✅ Passed The description includes a Summary section covering what changed and why (adaptive bottom dock, dock controls guard, managed side-dock, DevTools transitions), and references testing with a reload script tag, though formal Testing section structure is incomplete.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch issue-1183-devtools-side-dock-guard

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Mar 12, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes the attached DevTools panel safe to use in narrow side-pane layouts by (1) detecting when a side-docked inspector would leave too little page width and automatically requesting WI._dockBottom() instead, (2) monkey-patching the WebInspector left/right dock controls to reroute them to bottom-dock while the pane is too narrow, and (3) normalizing the divider hit area and subview heights to the full container bounds so frame calculations are correct after pane resizes.

Key changes:

  • BrowserWindowPortal.swift: Divider hit rect now spans the full container height; minimumInspectorWidth is wired through instead of ignored; Y-axis/height normalization applied uniformly in both side-dock layout cases
  • BrowserPanelView.swift: New minimumHostedInspectorWidth (120 pt) and minimumHostedInspectorPageWidthForSideDock (240 pt) thresholds drive enforceAdaptiveBottomDockIfNeeded; HostedInspectorSideDockContainerView gains wantsLayer/masksToBounds and suppresses resizeSubviews to prevent stale divider snapping during live drags; JS injection via updateHostedInspectorDockControlAvailabilityIfNeeded hides and reroutes side-dock controls when the pane is too narrow
  • BrowserPanel.swift: New transition-queuing system (150 ms settle work-item, pending/in-flight target state) prevents rapid toggle calls from firing multiple inspector open/close operations; showDeveloperTools/hideDeveloperTools/toggleDeveloperTools all route through the new queue
  • Regression tests cover: divider full-height hit, minimum-width clamping, adaptive bottom-dock JS dispatch path, autoresize suppression, and rapid-toggle coalescing

Minor issues:

  • applyHostedInspectorDividerWidth calls needsDisplay = true and setNeedsDisplay(bounds) on each view consecutively — the second call is redundant since setting the property already marks the whole view dirty
  • setHostedInspectorFrontendWebView(nil) does not reset adaptiveBottomDockRequestCooldownDeadline; if the inspector is dismissed during the 250 ms cooldown the function will report "handled" while no JS is actually dispatched, causing normalizeHostedInspectorLayoutIfNeeded to skip subsequent layout steps

Confidence Score: 4/5

  • Safe to merge with minor cleanup; the adaptive bottom-dock cooldown race is an unlikely edge case that does not affect correctness in the common path.
  • The logic changes are well-tested, the three-file structure is coherent, and the behavioral regressions I initially suspected turned out not to exist. Two style-level issues remain: redundant display invalidation calls and a narrow cooldown race when the inspector frontend is dismissed mid-cooldown.
  • Sources/Panels/BrowserPanelView.swift — contains both flagged issues; the JS monkeypatching of WI.* internals is inherently fragile but consistent with the existing approach in this codebase.

Important Files Changed

Filename Overview
Sources/BrowserWindowPortal.swift Divider hit rect expanded to full container height (y/height normalized to containerBounds); minimumInspectorWidth parameter wired through instead of ignored; Y-axis normalization applied to both side-dock layouts. pinHostedWebView frame reset made conditional on companion WK subviews to avoid clobbering WebKit-managed splits. All changes look correct.
Sources/Panels/BrowserPanel.swift Transition-queuing infrastructure added (target/pending visibility state, settle work-item, 150 ms settle delay). showDeveloperTools/hideDeveloperTools/toggleDeveloperTools all route through enqueueDeveloperToolsVisibilityTransition. The cancelDeveloperToolsRestoreRetry() and forceDeveloperToolsRefreshOnNextAttach = false calls in the hide path are preserved in the second if targetVisible block. Logic looks sound; two new unit tests exercise the rapid-toggle coalescing path.
Sources/Panels/BrowserPanelView.swift Bulk of the PR changes. Adds adaptive bottom-dock fallback, side-dock control monkeypatching, minimumHostedInspectorWidth enforcement, and HostedInspectorSideDockContainerView layer masking + autoresize suppression. Contains redundant needsDisplay=true/setNeedsDisplay(bounds) pairs (style) and a subtle cooldown-vs-nil-frontend race when setHostedInspectorFrontendWebView(nil) is called during the 250 ms cooldown window.
cmuxTests/CmuxWebViewKeyEquivalentTests.swift Good regression coverage: divider full-height hit test, minimum-width clamping after manual drag, adaptive bottom-dock JS dispatch, autoresize suppression, rapid-toggle coalescing (two new BrowserPanel tests), and portal slot pin with side-dock companions. Tests are well-scoped and the new TrackingInspectorFrontendWebView helper is a clean pattern.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[layout / setFrameSize / setFrameOrigin] --> B[normalizeHostedInspectorLayoutIfNeeded]
    B --> C{enforceAdaptiveBottomDockIfNeeded}
    C -->|dividerCandidate && tooNarrow| D[recordHostedInspectorSideDockWidth]
    D --> E{within cooldown?}
    E -->|yes| F[return true — skip further layout]
    E -->|no| G{hostedInspectorFrontendWebView?}
    G -->|nil| H[return false]
    G -->|set| I[evaluateJS: WI._dockBottom]
    I --> J[scheduleHostedInspectorDockConfigurationSync]
    I --> K[updateHostedInspectorDockControlAvailabilityIfNeeded]
    K --> L[evaluateJS: patch WI._dockLeft/_dockRight/_togglePreviousDockConfiguration]
    C -->|not narrow / no candidate| M[promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded]
    M --> N{isHostedInspectorSideDockActive?}
    N -->|yes| O[layoutHostedInspectorSideDockIfNeeded]
    N -->|no| P[captureHostedInspectorPreferredWidthFromCurrentLayout]

    Q[toggleDeveloperTools / showDeveloperTools / hideDeveloperTools] --> R[enqueueDeveloperToolsVisibilityTransition]
    R --> S{isDeveloperToolsTransitionInFlight?}
    S -->|yes| T[queue pending target, return true]
    S -->|no| U[performDeveloperToolsVisibilityTransition]
    U --> V{targetVisible?}
    V -->|true + not yet visible| W[revealDeveloperTools]
    V -->|true + already visible| X[clear grace deadline]
    V -->|false + currently visible| Y[concealDeveloperTools]
    V -->|false + already hidden| Z[cancelRestoreRetry + clearForceRefresh]
    W --> AA[scheduleDeveloperToolsTransitionSettle 150ms]
    Y --> AA
    AA --> BB[finishDeveloperToolsTransition]
    BB --> CC{pendingTarget != currentVisible?}
    CC -->|yes| U
    CC -->|no| DD[clear transitionTargetVisible]
Loading

Comments Outside Diff (1)

  1. Sources/Panels/BrowserPanelView.swift, line 4354-4358 (link)

    Cooldown may suppress layout when hostedInspectorFrontendWebView is cleared mid-cooldown

    requestAdaptiveHostedInspectorBottomDock returns true within the cooldown window without checking whether hostedInspectorFrontendWebView is still set. The cooldown is intentionally coarse-grained, but setHostedInspectorFrontendWebView(nil) does not reset adaptiveBottomDockRequestCooldownDeadline. If the inspector closes during the 250 ms window, a subsequent call to normalizeHostedInspectorLayoutIfNeeded will see enforceAdaptiveBottomDockIfNeeded return true (cooldown still active, divider candidate may still be present during teardown), skip promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded, and dispatch no JS — potentially leaving the side-dock views in a stale state.

    Resetting the cooldown deadline in setHostedInspectorFrontendWebView when the value is set to nil would close this gap:

    func setHostedInspectorFrontendWebView(_ webView: WKWebView?) {
        hostedInspectorFrontendWebView = webView
        lastHostedInspectorManualSideDockAllowed = nil
        if webView == nil {
            adaptiveBottomDockRequestCooldownDeadline = nil
        }
        updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "setHostedInspectorFrontendWebView")
    }

Last reviewed commit: f843ce2

Comment on lines +5036 to +5047
hit.pageView.needsDisplay = true
hit.pageView.setNeedsDisplay(hit.pageView.bounds)
hit.inspectorView.needsDisplay = true
hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds)
hit.containerView.needsDisplay = true
hit.containerView.setNeedsDisplay(hit.containerView.bounds)
if let localInlineSlotView {
localInlineSlotView.needsDisplay = true
localInlineSlotView.setNeedsDisplay(localInlineSlotView.bounds)
}
needsDisplay = true
setNeedsDisplay(bounds)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant needsDisplay + setNeedsDisplay(bounds) pairs

Each view gets needsDisplay = true immediately followed by setNeedsDisplay(view.bounds). Setting needsDisplay = true already marks the view's full visible rect as dirty, which is exactly what setNeedsDisplay(bounds) does. The paired calls are no-ops after the first line in each pair.

Suggested change
hit.pageView.needsDisplay = true
hit.pageView.setNeedsDisplay(hit.pageView.bounds)
hit.inspectorView.needsDisplay = true
hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds)
hit.containerView.needsDisplay = true
hit.containerView.setNeedsDisplay(hit.containerView.bounds)
if let localInlineSlotView {
localInlineSlotView.needsDisplay = true
localInlineSlotView.setNeedsDisplay(localInlineSlotView.bounds)
}
needsDisplay = true
setNeedsDisplay(bounds)
hit.pageView.setNeedsDisplay(hit.pageView.bounds)
hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds)
hit.containerView.setNeedsDisplay(hit.containerView.bounds)
if let localInlineSlotView {
localInlineSlotView.setNeedsDisplay(localInlineSlotView.bounds)
}
setNeedsDisplay(bounds)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
cmuxTests/CmuxWebViewKeyEquivalentTests.swift (1)

9421-9431: Record semantic dock actions here instead of raw JavaScript strings.

The new narrow-pane coverage now depends on exact frontend snippets, so harmless refactors in the injected script will break the test without changing behavior. Prefer capturing typed events like “request bottom dock” / “disable side dock” in this test double.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` around lines 9421 - 9431, The
test double TrackingInspectorFrontendWebView currently records raw JavaScript in
evaluatedJavaScript which is brittle; change it to record semantic dock actions
instead by introducing a typed enum (e.g., DockAction with cases like
requestBottomDock, disableSideDock, enableSideDock, etc.) and replacing
evaluatedJavaScript: [String] with recordedActions: [DockAction]; update the
override of evaluateJavaScript(_:completionHandler:) to parse/map incoming
javaScriptString patterns (or substrings) to the correct DockAction and append
that action (and call the completion handler) so tests assert on semantic
actions rather than exact JS snippets.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift`:
- Around line 2609-2611: Replace the fixed 0.5s sleep in
waitForDeveloperToolsTransitions() with a state-based poll: repeatedly check the
relevant inspector state/counts (the same conditions your tests expect after the
transition) until they match or a short timeout elapses, sleeping briefly
between polls; update waitForDeveloperToolsTransitions to accept/derive the
expected state or counts, poll those using RunLoop.run(mode:before:) or
DispatchQueue with small intervals, and throw or XCTFail on timeout so fast runs
don't wait and slow CI won't race the transition.
- Around line 9876-9919: The test is flaky because it asserts
inspectorView.evaluatedJavaScript immediately after setFrameSize/layout calls
while the bottom-dock fallback is throttle/cooldown driven; change the
testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth test
to wait/poll (reuse the existing wait helper or an XCTest expectation with a
short timeout and polling) until inspectorView.evaluatedJavaScript contains the
expected strings ("WI._dockBottom()" and "const allowSideDock = false;") before
making the two XCTAssertTrue checks, keeping the calls to host.setFrameSize,
contentView.layoutSubtreeIfNeeded and host.layoutSubtreeIfNeeded as-is and
referencing inspectorView.evaluatedJavaScript and
promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded to locate the relevant
test logic.

In `@Sources/Panels/BrowserPanelView.swift`:
- Around line 3842-3849: The function shouldAllowHostedInspectorManualSideDock()
currently only uses recordedHostedInspectorSideDockWidth (falling back to
Self.minimumHostedInspectorWidth) which ignores the previously
restored/persisted preferred width; update the baselineWidth calculation to
prefer the restored/persisted inspector width (e.g.
restoredHostedInspectorSideDockWidth or persistedHostedInspectorSideDockWidth)
when non-nil, then fall back to recordedHostedInspectorSideDockWidth and finally
Self.minimumHostedInspectorWidth so the restored width is used when deciding
whether to keep side-dock controls enabled.
- Around line 4452-4454: The current layout() sequence promotes the side dock
via promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() before checking
enforceAdaptiveBottomDockIfNeeded(reason:), which can momentarily recreate a
too-narrow left/right layout; change the call order in layout() to run
enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") (and its subsequent
updateHostedInspectorDockControlAvailabilityIfNeeded(reason:)) before calling
promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), mirroring the safe
ordering used by normalizeHostedInspectorLayoutIfNeeded(...), so the bottom-dock
guard runs first and prevents transient re-promotion into an unsafe side-dock
layout.

---

Nitpick comments:
In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift`:
- Around line 9421-9431: The test double TrackingInspectorFrontendWebView
currently records raw JavaScript in evaluatedJavaScript which is brittle; change
it to record semantic dock actions instead by introducing a typed enum (e.g.,
DockAction with cases like requestBottomDock, disableSideDock, enableSideDock,
etc.) and replacing evaluatedJavaScript: [String] with recordedActions:
[DockAction]; update the override of evaluateJavaScript(_:completionHandler:) to
parse/map incoming javaScriptString patterns (or substrings) to the correct
DockAction and append that action (and call the completion handler) so tests
assert on semantic actions rather than exact JS snippets.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 24fb210c-3e77-40f7-ad22-e64130d39e89

📥 Commits

Reviewing files that changed from the base of the PR and between dc1f714 and f843ce2.

📒 Files selected for processing (4)
  • Sources/BrowserWindowPortal.swift
  • Sources/Panels/BrowserPanel.swift
  • Sources/Panels/BrowserPanelView.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift

Comment on lines +2609 to +2611
private func waitForDeveloperToolsTransitions() {
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make this wait helper state-based instead of sleeping for 500 ms.

A fixed RunLoop sleep is both slow and flaky here: fast runs still pay the full half-second, and slow CI runs can still race the transition. Poll the expected inspector state/counts and fail on timeout instead.

⏱️ Suggested helper shape
-    private func waitForDeveloperToolsTransitions() {
-        RunLoop.current.run(until: Date().addingTimeInterval(0.5))
-    }
+    private func waitForDeveloperToolsTransitions(
+        timeout: TimeInterval = 1.0,
+        until condition: `@escaping` () -> Bool
+    ) {
+        let deadline = Date().addingTimeInterval(timeout)
+        while !condition() && Date() < deadline {
+            RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
+        }
+        XCTAssertTrue(condition(), "Timed out waiting for DevTools transition")
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func waitForDeveloperToolsTransitions() {
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
private func waitForDeveloperToolsTransitions(
timeout: TimeInterval = 1.0,
until condition: `@escaping` () -> Bool
) {
let deadline = Date().addingTimeInterval(timeout)
while !condition() && Date() < deadline {
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
}
XCTAssertTrue(condition(), "Timed out waiting for DevTools transition")
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` around lines 2609 - 2611,
Replace the fixed 0.5s sleep in waitForDeveloperToolsTransitions() with a
state-based poll: repeatedly check the relevant inspector state/counts (the same
conditions your tests expect after the transition) until they match or a short
timeout elapses, sleeping briefly between polls; update
waitForDeveloperToolsTransitions to accept/derive the expected state or counts,
poll those using RunLoop.run(mode:before:) or DispatchQueue with small
intervals, and throw or XCTFail on timeout so fast runs don't wait and slow CI
won't race the transition.

Comment on lines +9876 to +9919
func testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}

let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 280, height: contentView.bounds.height))
host.autoresizingMask = [.minXMargin, .height]
contentView.addSubview(host)

let slotView = host.ensureLocalInlineSlotView()
let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height))
let inspectorView = TrackingInspectorFrontendWebView(
frame: NSRect(x: 120, y: 0, width: slotView.bounds.width - 120, height: host.bounds.height)
)
slotView.addSubview(pageView)
slotView.addSubview(inspectorView)
host.pinHostedWebView(pageView, in: slotView)
host.setHostedInspectorFrontendWebView(inspectorView)
contentView.layoutSubtreeIfNeeded()
host.layoutSubtreeIfNeeded()

XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())

host.setFrameSize(NSSize(width: 210, height: host.frame.height))
contentView.layoutSubtreeIfNeeded()
host.layoutSubtreeIfNeeded()

XCTAssertTrue(
inspectorView.evaluatedJavaScript.contains(where: { $0.contains("WI._dockBottom()") }),
"Narrow pane widths should request bottom-docked DevTools instead of leaving the side-docked inspector in an unstable layout"
)
XCTAssertTrue(
inspectorView.evaluatedJavaScript.contains(where: { $0.contains("const allowSideDock = false;") }),
"Once a narrow pane proves it cannot safely side-dock DevTools, the inspector frontend should hide and disable left/right dock controls"
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Yield before asserting the bottom-dock fallback fired.

These assertions run immediately after setFrameSize and layout. Since this PR’s fallback path is throttle/cooldown driven, the resize can still have the JS request queued when evaluatedJavaScript is checked, which makes the test timing-sensitive. Reuse the wait helper or poll until the expected calls are recorded before asserting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` around lines 9876 - 9919, The
test is flaky because it asserts inspectorView.evaluatedJavaScript immediately
after setFrameSize/layout calls while the bottom-dock fallback is
throttle/cooldown driven; change the
testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth test
to wait/poll (reuse the existing wait helper or an XCTest expectation with a
short timeout and polling) until inspectorView.evaluatedJavaScript contains the
expected strings ("WI._dockBottom()" and "const allowSideDock = false;") before
making the two XCTAssertTrue checks, keeping the calls to host.setFrameSize,
contentView.layoutSubtreeIfNeeded and host.layoutSubtreeIfNeeded as-is and
referencing inspectorView.evaluatedJavaScript and
promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded to locate the relevant
test logic.

Comment on lines +3842 to +3849
private func shouldAllowHostedInspectorManualSideDock() -> Bool {
let containerWidth = max(0, bounds.width)
guard containerWidth > 1 else { return true }
let baselineWidth = max(
Self.minimumHostedInspectorWidth,
recordedHostedInspectorSideDockWidth ?? Self.minimumHostedInspectorWidth
)
return containerWidth - baselineWidth >= Self.minimumHostedInspectorPageWidthForSideDock

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the restored inspector width when deciding whether side-dock controls stay enabled.

shouldAllowHostedInspectorManualSideDock() only looks at recordedHostedInspectorSideDockWidth, so a fresh attach falls back to the 120px minimum even though the persisted preferred width was already restored earlier. In a narrow pane, that can leave left/right dock controls enabled until the user clicks them, at which point the remembered wider side dock immediately becomes unsafe again.

Suggested fix
         private func shouldAllowHostedInspectorManualSideDock() -> Bool {
             let containerWidth = max(0, bounds.width)
             guard containerWidth > 1 else { return true }
-            let baselineWidth = max(
-                Self.minimumHostedInspectorWidth,
-                recordedHostedInspectorSideDockWidth ?? Self.minimumHostedInspectorWidth
-            )
+            let preferredWidth = resolvedPreferredHostedInspectorWidth(in: bounds)
+            let baselineWidth = max(
+                Self.minimumHostedInspectorWidth,
+                recordedHostedInspectorSideDockWidth ?? 0,
+                preferredWidth ?? 0
+            )
             return containerWidth - baselineWidth >= Self.minimumHostedInspectorPageWidthForSideDock
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/BrowserPanelView.swift` around lines 3842 - 3849, The function
shouldAllowHostedInspectorManualSideDock() currently only uses
recordedHostedInspectorSideDockWidth (falling back to
Self.minimumHostedInspectorWidth) which ignores the previously
restored/persisted preferred width; update the baselineWidth calculation to
prefer the restored/persisted inspector width (e.g.
restoredHostedInspectorSideDockWidth or persistedHostedInspectorSideDockWidth)
when non-nil, then fall back to recordedHostedInspectorSideDockWidth and finally
Self.minimumHostedInspectorWidth so the restored width is used when deciding
whether to keep side-dock controls enabled.

Comment on lines +4452 to +4454
_ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
if enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") {
updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Run the bottom-dock guard before re-promoting a side-dock layout.

Line 4452 promotes the current split back into managed side-dock state before Line 4453 checks whether that split is already too narrow. That means a resize can briefly re-enter the unsafe left/right layout the PR is trying to suppress. normalizeHostedInspectorLayoutIfNeeded(...) already does this in the safer order; layout() should match it.

Suggested fix
         override func layout() {
             super.layout()
-            _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
             if enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") {
                 updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout")
                 notifyGeometryChangedIfNeeded()
 `#if` DEBUG
                 debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
 `#endif`
                 return
             }
+            _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
             if let previousSize = lastHostedInspectorLayoutBoundsSize,
                Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/BrowserPanelView.swift` around lines 4452 - 4454, The current
layout() sequence promotes the side dock via
promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() before checking
enforceAdaptiveBottomDockIfNeeded(reason:), which can momentarily recreate a
too-narrow left/right layout; change the call order in layout() to run
enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") (and its subsequent
updateHostedInspectorDockControlAvailabilityIfNeeded(reason:)) before calling
promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), mirroring the safe
ordering used by normalizeHostedInspectorLayoutIfNeeded(...), so the bottom-dock
guard runs first and prevents transient re-promotion into an unsafe side-dock
layout.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/Panels/BrowserPanel.swift">

<violation number="1" location="Sources/Panels/BrowserPanel.swift:3292">
P1: `showDeveloperToolsConsole()` fails to switch to the console tab when opening from a closed state.</violation>
</file>

<file name="Sources/BrowserWindowPortal.swift">

<violation number="1" location="Sources/BrowserWindowPortal.swift:592">
P2: The `applyHostedInspectorDividerWidth` call uses the global `Self.minimumHostedInspectorWidth` instead of the local dynamically calculated `minimumInspectorWidth`. This causes the inspector to aggressively snap to the global minimum during a drag, defeating the purpose of dynamically computing the minimum based on `initialInspectorFrame.width`.</violation>
</file>

<file name="cmuxTests/CmuxWebViewKeyEquivalentTests.swift">

<violation number="1" location="cmuxTests/CmuxWebViewKeyEquivalentTests.swift:2610">
P3: Replace this fixed delay with a condition-based wait loop so transition assertions are deterministic and the test doesn’t always pay a 500ms sleep penalty.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@discardableResult
func showDeveloperToolsConsole() -> Bool {
guard showDeveloperTools() else { return false }
guard !isDeveloperToolsTransitionInFlight else { return true }

@cubic-dev-ai cubic-dev-ai Bot Mar 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: showDeveloperToolsConsole() fails to switch to the console tab when opening from a closed state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Panels/BrowserPanel.swift, line 3292:

<comment>`showDeveloperToolsConsole()` fails to switch to the console tab when opening from a closed state.</comment>

<file context>
@@ -3184,30 +3278,18 @@ extension BrowserPanel {
     @discardableResult
     func showDeveloperToolsConsole() -> Bool {
         guard showDeveloperTools() else { return false }
+        guard !isDeveloperToolsTransitionInFlight else { return true }
         guard let inspector = webView.cmuxInspectorObject() else { return true }
         // WebKit private inspector API differs by OS; try known console selectors.
</file context>
Fix with Cubic

inspectorView: dragState.inspectorView,
dockSide: dragState.dockSide
),
minimumInspectorWidth: Self.minimumHostedInspectorWidth,

@cubic-dev-ai cubic-dev-ai Bot Mar 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The applyHostedInspectorDividerWidth call uses the global Self.minimumHostedInspectorWidth instead of the local dynamically calculated minimumInspectorWidth. This causes the inspector to aggressively snap to the global minimum during a drag, defeating the purpose of dynamically computing the minimum based on initialInspectorFrame.width.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/BrowserWindowPortal.swift, line 592:

<comment>The `applyHostedInspectorDividerWidth` call uses the global `Self.minimumHostedInspectorWidth` instead of the local dynamically calculated `minimumInspectorWidth`. This causes the inspector to aggressively snap to the global minimum during a drag, defeating the purpose of dynamically computing the minimum based on `initialInspectorFrame.width`.</comment>

<file context>
@@ -572,6 +589,7 @@ final class WindowBrowserHostView: NSView {
                 inspectorView: dragState.inspectorView,
                 dockSide: dragState.dockSide
             ),
+            minimumInspectorWidth: Self.minimumHostedInspectorWidth,
             reason: "drag"
         )
</file context>
Suggested change
minimumInspectorWidth: Self.minimumHostedInspectorWidth,
minimumInspectorWidth: minimumInspectorWidth,
Fix with Cubic

}

private func waitForDeveloperToolsTransitions() {
RunLoop.current.run(until: Date().addingTimeInterval(0.5))

@cubic-dev-ai cubic-dev-ai Bot Mar 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Replace this fixed delay with a condition-based wait loop so transition assertions are deterministic and the test doesn’t always pay a 500ms sleep penalty.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxTests/CmuxWebViewKeyEquivalentTests.swift, line 2610:

<comment>Replace this fixed delay with a condition-based wait loop so transition assertions are deterministic and the test doesn’t always pay a 500ms sleep penalty.</comment>

<file context>
@@ -2606,6 +2606,10 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
     }
 
+    private func waitForDeveloperToolsTransitions() {
+        RunLoop.current.run(until: Date().addingTimeInterval(0.5))
+    }
+
</file context>
Fix with Cubic

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
cmuxTests/CmuxWebViewKeyEquivalentTests.swift (2)

2609-2611: ⚠️ Potential issue | 🟡 Minor

Make the DevTools wait helper state-based.

This fixed 500 ms sleep is still both slow and flaky: fast runs always pay the delay, and slow CI can still race the transition. Poll the expected inspector state/counts and fail on timeout instead.

⏱️ Suggested shape
-    private func waitForDeveloperToolsTransitions() {
-        RunLoop.current.run(until: Date().addingTimeInterval(0.5))
-    }
+    private func waitForDeveloperToolsTransitions(
+        timeout: TimeInterval = 1.0,
+        until condition: `@escaping` () -> Bool
+    ) {
+        let deadline = Date().addingTimeInterval(timeout)
+        while !condition() && Date() < deadline {
+            RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
+        }
+        XCTAssertTrue(condition(), "Timed out waiting for DevTools transition")
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` around lines 2609 - 2611,
Replace the fixed 0.5s RunLoop sleep in waitForDeveloperToolsTransitions() with
a state-based polling loop: repeatedly pump the RunLoop (e.g. short intervals)
and check the actual DevTools/inspector state or expected counts (the condition
your tests assert) until the condition is true or a timeout elapses; on timeout
fail the helper so tests fail fast. Locate waitForDeveloperToolsTransitions()
and implement a predicate-based wait that returns when the inspector
state/counts match expected values or throws/asserts on timeout instead of
unconditionally waiting 0.5s.

9876-9919: ⚠️ Potential issue | 🟡 Minor

Wait for the bottom-dock fallback JS before asserting.

These assertions still run immediately after setFrameSize and layout. If the fallback request is queued, evaluatedJavaScript can still be empty here and make the test timing-sensitive. Poll until the expected calls are recorded before asserting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` around lines 9876 - 9919, The
test currently asserts on inspectorView.evaluatedJavaScript immediately after
host.setFrameSize, which is racing with async JS calls; replace the direct
XCTAssertTrue checks in
testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth()
with a short polling/wait mechanism (e.g. XCTNSPredicateExpectation or a manual
loop with timeout ~1–2s and small sleep interval) that repeatedly checks
inspectorView.evaluatedJavaScript for entries containing "WI._dockBottom()" and
"const allowSideDock = false;" after calling host.setFrameSize(...) and
layoutSubtreeIfNeeded(), then assert only once the expected JS calls are
observed or fail on timeout; reference inspectorView.evaluatedJavaScript,
host.setFrameSize, contentView.layoutSubtreeIfNeeded(), and
host.layoutSubtreeIfNeeded() when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift`:
- Around line 2609-2611: Replace the fixed 0.5s RunLoop sleep in
waitForDeveloperToolsTransitions() with a state-based polling loop: repeatedly
pump the RunLoop (e.g. short intervals) and check the actual DevTools/inspector
state or expected counts (the condition your tests assert) until the condition
is true or a timeout elapses; on timeout fail the helper so tests fail fast.
Locate waitForDeveloperToolsTransitions() and implement a predicate-based wait
that returns when the inspector state/counts match expected values or
throws/asserts on timeout instead of unconditionally waiting 0.5s.
- Around line 9876-9919: The test currently asserts on
inspectorView.evaluatedJavaScript immediately after host.setFrameSize, which is
racing with async JS calls; replace the direct XCTAssertTrue checks in
testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth()
with a short polling/wait mechanism (e.g. XCTNSPredicateExpectation or a manual
loop with timeout ~1–2s and small sleep interval) that repeatedly checks
inspectorView.evaluatedJavaScript for entries containing "WI._dockBottom()" and
"const allowSideDock = false;" after calling host.setFrameSize(...) and
layoutSubtreeIfNeeded(), then assert only once the expected JS calls are
observed or fail on timeout; reference inspectorView.evaluatedJavaScript,
host.setFrameSize, contentView.layoutSubtreeIfNeeded(), and
host.layoutSubtreeIfNeeded() when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4459d997-d6ee-4225-858c-a3d9f59bb17b

📥 Commits

Reviewing files that changed from the base of the PR and between f843ce2 and f429907.

📒 Files selected for processing (1)
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift

@austinywang
austinywang merged commit 378a417 into main Mar 12, 2026
17 checks passed
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
…ols-side-dock-guard

Prevent attached DevTools from re-entering unsafe side-dock layouts

This branch was successfully deployed

1 active deployment
Preview — f429907d Deployed Mar 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant