Skip to content

Fix stale browser pane content after drag splits - #1215

Merged
austinywang merged 5 commits into
mainfrom
issue-1208-browser-pane-stale-content
Mar 12, 2026
Merged

austinywang merged 5 commits into
mainfrom
issue-1208-browser-pane-stale-content

Conversation

@austinywang

@austinywang austinywang commented Mar 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • rearm browser portal host replacement whenever Bonsplit splits a pane so the new same-pane host can take ownership after drag-to-split layout churn
  • keep browser portal drop context cleared/restored consistently while transient detach and geometry recovery paths preserve visible content
  • force a browser portal refresh when transient recovery completes so WKWebView does not keep stale tiles in the old subframe

Testing

  • ./scripts/reload.sh --tag fix-browser-drag-redraw
  • manual repro in the tagged debug build; user confirmed the stale browser content issue appears fixed

Summary by cubic

Fixes stale browser content after drag-to-split and pane reparenting. Keeps the right pane host attached, preserves drag/drop context through SwiftUI churn, and forces WKWebView redraws; addresses Linear #1208.

  • Bug Fixes
    • Rearm portal host replacement on both panes after Bonsplit splits so the new host owns the web view and avoids stale content.
    • Clear pane drop context and portal drag-drop zone during hide/host-bounds-not-ready; preserve them during transient SwiftUI host teardown to keep internal tab drags working.
    • Force a portal refresh after transient geometry recovery (with safe inspector sync) so WKWebView drops stale tiles.
    • Guard all web view observers/delegates and favicon refreshes by web view instance to prevent stale title/URL/loading/progress updates after replacement.
    • Add workspace-wide resetSidebarContext to clear sidebar state and reset all browser panels to a clean new-tab state; updates tab title/icon/loading; TerminalController now uses this.
    • Add test verifying sidebar reset clears browser panel state, search, and navigation history.

Written for commit 8aafb68. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Better handling of transient geometry recoveries: panels redraw reliably and transient drag/drop overlays are cleared when needed
    • Fixed stale drag/drop and pane-visibility edge cases during off-screen or transient detach scenarios
  • New Features

    • Workspace-level reset APIs to clear sidebar and reinitialize browser panels on context changes
    • Panel-level workspace-reset path to fully reinitialize browser view state when required
    • Prepared portal host replacements when splitting panes
  • Tests

    • Added test validating sidebar/browser reset clears state and replaces web view

@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 2:17am

@coderabbitai

coderabbitai Bot commented Mar 12, 2026 •

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 60f1fdc3-36ef-41f6-a477-d29bf0d966ab

📥 Commits

Reviewing files that changed from the base of the PR and between 1dbd1e5 and 8aafb68.

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

📝 Walkthrough

Walkthrough

Tracks transient geometry recovery in portal sync, clears or preserves pane/portal drop contexts during hide/recovery/off-window paths, adds workspace-level sidebar/browser reset APIs and browser-panel reset logic, updates split handling to rearm portal host replacements, and adds a unit test for sidebar reset behavior.

Changes

Cohort / File(s) Summary
Portal State Management
Sources/BrowserWindowPortal.swift
Track previous transientRecoveryReason; compute recoveredFromTransientGeometry; clear or conditionally preserve paneDropContext, portalDragDropZone, and drop-zone overlays across hide, transient-detach-preserve, and early-return/off-window sync paths; append "transientRecovery" to refreshReasons to force redraw when recovered.
Browser Panel Reset
Sources/Panels/BrowserPanel.swift
Add resetForWorkspaceContextChange(reason:), isCurrentWebView(...), and needsWorkspaceContextReset to detect workspace-context drift and, when needed, hide devtools, clear omnibar/search/navigation state, replace the WKWebView, rebind observers, and reinitialize navigation state.
Workspace Coordination
Sources/Workspace.swift
Add resetSidebarContext(reason:) and resetBrowserPanelsForContextChange(reason:) to clear sidebar state and trigger panel resets; add rearm closure in split handling to prepare portal-host replacements for original and new panes.
Controller API Usage
Sources/TerminalController.swift
Replace multiple direct sidebar-field mutations with a single tab.resetSidebarContext(reason:) call to centralize sidebar reset behavior.
Panel View Teardown
Sources/Panels/BrowserPanelView.swift
Stop clearing certain portal-related state (pane top chrome height, pane drop context, search overlay) on SwiftUI dismantle to preserve portal-hosted WKWebView state during SwiftUI churn; only update drop-zone overlay.
Tests
cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Add testResetSidebarContextClearsBrowserPanelsIntoNewTabState() to assert sidebar fields are cleared and browser panel/webView is reset after resetSidebarContext.

Sequence Diagram

sequenceDiagram
    participant Workspace
    participant BrowserPanel
    participant BrowserWindowPortal
    participant Container as ContainerView

    Workspace->>BrowserPanel: resetForWorkspaceContextChange(reason)
    BrowserPanel->>BrowserPanel: hide devtools, clear omnibar/search, clear history
    BrowserPanel->>BrowserPanel: replace WKWebView, rebind observers, reinit navigation
    BrowserPanel->>BrowserWindowPortal: update portal bindings/state
    BrowserWindowPortal->>Container: setPaneDropContext(nil) / setPaneDropContext(conditionally)
    BrowserWindowPortal->>Container: setPortalDragDropZone(nil)
    BrowserWindowPortal->>Container: setDropZoneOverlay(zone: nil)
    BrowserWindowPortal->>BrowserWindowPortal: compare previousTransientRecoveryReason -> transientRecoveryReason
    BrowserWindowPortal->>BrowserWindowPortal: if recoveredFromTransientGeometry append "transientRecovery" to refreshReasons -> force redraw
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • PR #995: Overlaps on transientRecoveryReason tracking and pane drop context handling in BrowserWindowPortal.
  • PR #1026: Related pane drop-context and drop-zone overlay update changes for portal-hosted surfaces.
  • PR #1136: Similar adjustments to portal visibility/off-tree preservation and transient detach behavior.

Poem

🐰 I hopped through panes both near and far,
Cleared every drop and healed each scar,
When workspaces shift and portals roam,
I tuck the webview safe at home,
Fresh tabs bloom bright — a springtime charm.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 clearly and concisely summarizes the main fix: addressing stale browser content after drag-to-split operations.
Description check ✅ Passed The description covers the key what/why items and includes testing details; however, it lacks a demo video and the checklist items are 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-1208-browser-pane-stale-content

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 fixes stale WKWebView content after drag-to-split layout churn through three coordinated changes: rearming the browser portal host-replacement gate in BonsplitDelegate.didSplit, consistently clearing paneDropContext in every hide/preserve path of BrowserWindowPortal, and forcing a portal refresh when transient geometry recovery completes. It also introduces resetForWorkspaceContextChange / resetBrowserPanelsForContextChange to provide a clean "nuke and recreate the webview" path, wired into a new resetSidebarContext helper.

Key changes and concerns:

  • BrowserWindowPortal.swift — recoveredFromTransientGeometry correctly detects the transition out of transient-recovery state and appends "transientRecovery" to refreshReasons; the actual refresh is gated by !shouldHide && containerOwnsWebView, so false-positive entries are harmless but slightly misleading.
  • BrowserPanel.swift — resetForWorkspaceContextChange tears down the old WKWebView synchronously and creates a fresh replacement; webViewInstanceID is assigned one line after webView = replacement, leaving a brief window where the new view carries the old identifier — this should be inverted.
  • TerminalController.swift — The inline field-clear block was replaced by resetSidebarContext, but that function now also calls resetBrowserPanelsForContextChange. The reset_sidebar RPC command previously only cleared sidebar metadata; it now silently destroys and recreates all WKWebView instances in the workspace, which may surprise callers.
  • cmuxTests/ — The new test is thorough for observable panel state but does not verify that the WKWebView object itself was replaced (i.e., webViewInstanceID changed, webView !== priorWebView), which is the core invariant the fix depends on.

Confidence Score: 3/5

  • Mostly safe to merge, but the reset_sidebar behavioral expansion and the webViewInstanceID ordering issue should be resolved first.
  • The portal sync and rearm logic is well-reasoned and the new test provides good coverage of visible state. However, two issues lower confidence: (1) the TerminalController refactoring silently makes reset_sidebar destroy active browser sessions — a non-obvious side effect that could regress agents relying on the narrower previous behavior; and (2) webViewInstanceID is assigned after webView = replacement, creating a window where callbacks tied to the new view could read the wrong instance ID.
  • Sources/TerminalController.swift (unexpected behavioral expansion of reset_sidebar) and Sources/Panels/BrowserPanel.swift (webViewInstanceID assignment ordering).

Important Files Changed

Filename Overview
Sources/BrowserWindowPortal.swift Adds recoveredFromTransientGeometry flag to force a WebKit refresh after drag/reparent geometry churn resolves, and consistently clears paneDropContext in all hide/preserve paths — logic is sound but the flag can be true while shouldHide is also true (harmless due to the downstream guard).
Sources/Panels/BrowserPanel.swift Adds resetForWorkspaceContextChange which tears down and recreates the WKWebView; webViewInstanceID is set after webView = replacement creating a brief window where the new view carries the old ID, and deinit has the inverse observer-teardown ordering relative to this new path.
Sources/Workspace.swift Adds resetSidebarContext / resetBrowserPanelsForContextChange and hooks them into BonsplitDelegate.didSplit to rearm portal host replacement; the sidebar-reset entry point is correct, but the refactoring in TerminalController silently expands the reset_sidebar command to also destroy browser sessions.
Sources/TerminalController.swift Replaces nine inline field clears with tab.resetSidebarContext(reason: "reset_sidebar"), which now implicitly calls resetBrowserPanelsForContextChange — a silent behavioral expansion that terminates active browser sessions when the RPC command is invoked.
cmuxTests/CmuxWebViewKeyEquivalentTests.swift Adds a comprehensive testResetSidebarContextClearsBrowserPanelsIntoNewTabState test covering sidebar and browser-panel state; missing assertions for webViewInstanceID rotation and webView object identity, which are core to the fix being tested.

Sequence Diagram

sequenceDiagram
    participant BS as BonsplitDelegate
    participant WS as Workspace
    participant BP as BrowserPanel
    participant BWP as BrowserWindowPortal

    Note over BS,BWP: Drag-to-split layout event
    BS->>WS: didSplit(originalPane, newPane)
    WS->>BP: preparePortalHostReplacementForNextDistinctClaim(inPane:)
    Note over BP: rearms portal host so new<br/>same-pane host can take ownership

    Note over BS,BWP: Portal sync cycle (post-split geometry churn)
    BWP->>BWP: syncContainerView()
    Note over BWP: captures previousTransientRecoveryReason
    BWP->>BWP: compute transientRecoveryReason (nil = geometry is OK)
    BWP->>BWP: recoveredFromTransientGeometry = (prev≠nil && cur==nil)
    alt recoveredFromTransientGeometry && !shouldHide
        BWP->>BWP: refreshReasons.append("transientRecovery")
        BWP->>BWP: force WKWebView refresh (clears stale tiles)
    end

    Note over BS,BWP: Workspace context reset (reset_sidebar / drag-context change)
    WS->>WS: resetSidebarContext(reason:)
    WS->>WS: clear statusEntries/logEntries/gitBranch/etc.
    WS->>WS: resetBrowserPanelsForContextChange(reason:)
    loop each BrowserPanel
        WS->>BP: resetForWorkspaceContextChange(reason:)
        BP->>BWP: BrowserWindowPortalRegistry.detach(oldWebView)
        BP->>BP: create new WKWebView replacement
        BP->>BP: bindWebView(replacement)
        WS->>WS: bonsplitController.updateTab(title/favicon/loading)
    end
Loading

Comments Outside Diff (1)

  1. Sources/TerminalController.swift, line 13813-13820 (link)

    reset_sidebar now silently destroys browser sessions

    Before this PR, the reset_sidebar RPC command cleared only sidebar metadata fields (status entries, git branch, PR, ports, etc.). The refactoring to tab.resetSidebarContext(reason: "reset_sidebar") now also calls resetBrowserPanelsForContextChange, which destroys and recreates every WKWebView in the workspace via resetForWorkspaceContextChange.

    This is a silent behavioral expansion. Any agent or script that calls reset_sidebar expecting only sidebar metadata to be cleared will now also kill active browser sessions (navigation history, active downloads, in-flight loads, developer tools state). If this is intentional, resetSidebarContext should accept a parameter to opt out of the browser reset, e.g.:

    tab.resetSidebarContext(reason: "reset_sidebar", resetBrowserPanels: false)

    so callers that only want sidebar metadata cleared don't inadvertently destroy user browser state.

Last reviewed commit: 81618bf

Comment thread Sources/Workspace.swift
Comment on lines +4899 to +4912
let rearmBrowserPortalHostReplacement: (PaneID, String) -> Void = { paneId, reason in
for tab in controller.tabs(inPane: paneId) {
guard let panelId = self.panelIdFromSurfaceId(tab.id),
let browserPanel = self.browserPanel(for: panelId) else {
continue
}
browserPanel.preparePortalHostReplacementForNextDistinctClaim(
inPane: paneId,
reason: reason
)
}
}
rearmBrowserPortalHostReplacement(originalPane, "workspace.didSplit.original")
rearmBrowserPortalHostReplacement(newPane, "workspace.didSplit.new")

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.

recoveredFromTransientGeometry fires even when shouldHide is still true

recoveredFromTransientGeometry evaluates to true whenever the entry transitions from having a transientRecoveryReason to having none, regardless of whether shouldHide is still true for a non-transient reason (e.g., !entry.visibleInUI). In that case, "transientRecovery" is appended to refreshReasons but the guard at the bottom (if !shouldHide, containerOwnsWebView, !refreshReasons.isEmpty) will prevent the actual refresh from firing, so the entry is harmless.

However, if a future code path reads refreshReasons before that guard (e.g., logging or metrics), it may see a misleading "transientRecovery" reason on a frame that was never actually shown. Consider narrowing the condition:

let recoveredFromTransientGeometry =
    previousTransientRecoveryReason != nil &&
    transientRecoveryReason == nil &&
    !shouldHide

This would avoid appending the reason when the view is hidden for other reasons and keep semantics tight.

Comment on lines +2791 to +2807
let oldWebView = webView
webViewObservers.removeAll()
webViewCancellables.removeAll()
BrowserWindowPortalRegistry.detach(webView: oldWebView)
oldWebView.stopLoading()
oldWebView.navigationDelegate = nil
oldWebView.uiDelegate = nil
if let oldCmuxWebView = oldWebView as? CmuxWebView {
oldCmuxWebView.onContextMenuDownloadStateChanged = nil
}

let replacement = Self.makeWebView()
webView = replacement
webViewInstanceID = UUID()
shouldRenderWebView = false
bindWebView(replacement)
refreshNavigationAvailability()

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.

deinit clears observers after async detach; resetForWorkspaceContextChange does it before

deinit schedules BrowserWindowPortalRegistry.detach asynchronously and then clears webViewObservers/webViewCancellables, meaning observers are still live during the brief window until the async task runs. resetForWorkspaceContextChange does the correct thing — clears observers first, then calls detach synchronously — but the inconsistency means the portal registry's sync path and the dealloc path have different guarantees around observer lifetimes.

This won't cause an immediate crash (the Task hop is a single run-loop cycle), but if a KVO notification fires on the old webView between the Task dispatch and its execution in deinit, it will hit an observer whose owning BrowserPanel is in teardown. Consider reordering deinit to match the reset path:

deinit {
    developerToolsRestoreRetryWorkItem?.cancel()
    developerToolsRestoreRetryWorkItem = nil
    if let obs = detachedDeveloperToolsWindowCloseObserver {
        NotificationCenter.default.removeObserver(obs)
    }
    webViewObservers.removeAll()      // clear before any async work
    webViewCancellables.removeAll()
    let webView = webView
    Task { @MainActor in
        BrowserWindowPortalRegistry.detach(webView: webView)
    }
}

Comment on lines +2802 to +2807
let replacement = Self.makeWebView()
webView = replacement
webViewInstanceID = UUID()
shouldRenderWebView = false
bindWebView(replacement)
refreshNavigationAvailability()

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.

webViewInstanceID assigned after webView — binding window is open

Between webView = replacement (line 2803) and webViewInstanceID = UUID() (line 2804), the new webView is live but still carries the old webViewInstanceID. If bindWebView(replacement) (line 2806) or any synchronously-called initializer reads webViewInstanceID to identify the current web view (e.g., correlating completion callbacks to the right instance), it may use a stale ID for the interval between assignment and the UUID update.

Reorder to assign webViewInstanceID before assigning webView:

let replacement = Self.makeWebView()
webViewInstanceID = UUID()
webView = replacement
shouldRenderWebView = false
bindWebView(replacement)
refreshNavigationAvailability()

Comment on lines +2491 to 2505
XCTAssertTrue(workspace.panelGitBranches.isEmpty)
XCTAssertNil(workspace.pullRequest)
XCTAssertTrue(workspace.panelPullRequests.isEmpty)
XCTAssertTrue(workspace.surfaceListeningPorts.isEmpty)
XCTAssertTrue(workspace.listeningPorts.isEmpty)
XCTAssertFalse(browser.shouldRenderWebView)
XCTAssertNil(browser.preferredURLStringForOmnibar())
XCTAssertFalse(browser.canGoBack)
XCTAssertFalse(browser.canGoForward)
XCTAssertNil(browser.searchState)
}

}

@MainActor

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.

Test doesn't verify webViewInstanceID rotation or webView replacement

resetForWorkspaceContextChange explicitly rotates the WKWebView instance (key to the fix — the old stale-tile webView is discarded). The test verifies observable state after the reset but doesn't assert that:

  1. browser.webViewInstanceID changed (a new UUID was issued)
  2. browser.webView is a different object than before the reset

Without these checks, a regression that skips webView replacement (e.g., needsWorkspaceContextReset returns false prematurely) would still pass the existing assertions. Consider adding:

let priorWebView = browser.webView
let priorInstanceID = browser.webViewInstanceID
workspace.resetSidebarContext(reason: "test")
// …existing assertions…
XCTAssertNotEqual(browser.webViewInstanceID, priorInstanceID)
XCTAssertFalse(browser.webView === priorWebView)

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

2 issues found across 5 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="cmuxTests/CmuxWebViewKeyEquivalentTests.swift">

<violation number="1" location="cmuxTests/CmuxWebViewKeyEquivalentTests.swift:2487">
P3: This assertion is currently vacuous for clear-on-reset behavior because `logEntries` is never populated before reset. Seed at least one log entry before `resetSidebarContext` so the test can catch regressions where logs are not cleared.</violation>
</file>

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

<violation number="1" location="Sources/Panels/BrowserPanel.swift:2786">
P2: Cancel/invalidate the favicon fetch task during workspace-context reset; otherwise an in-flight old task can repopulate stale favicon data after reset.</violation>
</file>

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

Comment thread Sources/Panels/BrowserPanel.swift
Comment thread cmuxTests/CmuxWebViewKeyEquivalentTests.swift

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

🤖 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 2453-2495: The test never populates workspace.logEntries before
calling workspace.resetSidebarContext(reason:), so the assertion that logEntries
is empty only verifies the default state; add a log entry to the setup (e.g.,
append a SidebarLogEntry or whatever type is used into workspace.logEntries,
using the same test workspace instance) before the reset call so
resetSidebarContext(reason:) is exercised, then keep the existing
XCTAssertTrue(workspace.logEntries.isEmpty) after the reset to verify the
entries are cleared; locate and modify the test function around the setup lines
that set workspace.statusEntries, workspace.metadataBlocks, workspace.progress,
workspace.updatePanelGitBranch(...), workspace.updatePanelPullRequest(...), and
workspace.surfaceListeningPorts[contextPanelId].

In `@Sources/BrowserWindowPortal.swift`:
- Around line 2793-2794: The hide/transient branches currently clear
paneDropContext via containerView.setPaneDropContext(nil) and the overlay via
containerView.setDropZoneOverlay(zone: nil) but do not reset
containerView.portalDragDropZone, leaving activeDropZone still able to prefer
the stale portal zone; update each transient/hide branch (the locations around
containerView.setPaneDropContext and setDropZoneOverlay calls at the noted
spots) to also set containerView.portalDragDropZone = nil so the portal drag
zone is cleared whenever the pane context and overlay are cleared.
- Around line 3032-3034: The inspector-layout fast path is still preventing
refreshHostedWebViewPresentation(...) when hostedInspectorAdjustedDuringSync is
true, which lets transientRecovery changes get ignored; update the suppression
condition to allow a refresh when transientRecovery has just cleared (use
recoveredFromTransientGeometry or compare previousTransientRecoveryReason vs
transientRecoveryReason) so that if recoveredFromTransientGeometry is true you
call refreshHostedWebViewPresentation(...) regardless of
hostedInspectorAdjustedDuringSync; apply the same change wherever the
hostedInspectorAdjustedDuringSync check suppresses the refresh (including the
blocks around transientRecovery checks).

In `@Sources/Panels/BrowserPanel.swift`:
- Around line 2802-2807: After creating the replacement web view in the reset
path, reapply the current browserThemeMode to that replacement (the same logic
used in init / didFinish) before binding it: after let replacement =
Self.makeWebView() and before
bindWebView(replacement)/refreshNavigationAvailability(), apply the
browserThemeMode to the new webView (e.g., call the same helper or code path
that sets appearance on the web view using browserThemeMode) so the replacement
starts with the correct appearance instead of defaulting to system.
- Around line 2791-2806: Async callbacks (didFinish/didFailNavigation) and
refreshFavicon(from:) can run after webView has been replaced and repopulate the
panel with stale state; fix by having those async paths verify the emitting web
view is still the active one before mutating panel state. Concretely, in
didFinish(_:), didFailNavigation(_:with:), and refreshFavicon(from:) capture the
emitter (either the WKWebView instance or webViewInstanceID) at the start of the
Task and after any await check that it matches self.webView /
self.webViewInstanceID (return early if not); this ensures any Task started for
oldWebView will no-op after the replacement made by makeWebView()/bindWebView().

In `@Sources/Workspace.swift`:
- Around line 1722-1742: The loop currently mutates panelTitles directly causing
single-panel workspaces to skip the Workspace.title/processTitle sync; replace
the direct assignment panelTitles[browserPanel.id] = nextTitle with a call to
updatePanelTitle(panelId: browserPanel.id, title: nextTitle) (or the existing
updatePanelTitle signature) so the Workspace title sync runs; call
updatePanelTitle before computing resolvedTitle/titleUpdate, then compute
faviconUpdate/loadingUpdate as before and call
bonsplitController.updateTab(tabId, ...) — keep bonsplitController.updateTab
usage but derive title state from the post-update panel state (i.e., after
updatePanelTitle) rather than by writing panelTitles directly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 160e2628-051e-4bb6-a8a4-7cc1059b4283

📥 Commits

Reviewing files that changed from the base of the PR and between f71c2c1 and 81618bf.

📒 Files selected for processing (5)
  • Sources/BrowserWindowPortal.swift
  • Sources/Panels/BrowserPanel.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift

Comment on lines +2453 to +2495
workspace.statusEntries["task"] = SidebarStatusEntry(key: "task", value: "Issue #1208")
workspace.metadataBlocks["notes"] = SidebarMetadataBlock(
key: "notes",
markdown: "test",
priority: 0,
timestamp: Date()
)
workspace.progress = SidebarProgressState(value: 0.5, label: "Loading")
workspace.updatePanelGitBranch(panelId: contextPanelId, branch: "issue-1208", isDirty: false)
workspace.updatePanelPullRequest(
panelId: contextPanelId,
number: 1208,
label: "PR",
url: try XCTUnwrap(URL(string: "https://example.com/pull/1208")),
status: .open
)
workspace.surfaceListeningPorts[contextPanelId] = [3000]
workspace.recomputeListeningPorts()

XCTAssertTrue(browser.shouldRenderWebView)
XCTAssertNotNil(browser.preferredURLStringForOmnibar())
XCTAssertTrue(browser.canGoBack)
XCTAssertTrue(browser.canGoForward)
XCTAssertNotNil(browser.searchState)
XCTAssertFalse(workspace.statusEntries.isEmpty)
XCTAssertFalse(workspace.metadataBlocks.isEmpty)
XCTAssertNotNil(workspace.progress)
XCTAssertNotNil(workspace.gitBranch)
XCTAssertNotNil(workspace.pullRequest)
XCTAssertEqual(workspace.listeningPorts, [3000])

workspace.resetSidebarContext(reason: "test")

XCTAssertTrue(workspace.statusEntries.isEmpty)
XCTAssertTrue(workspace.logEntries.isEmpty)
XCTAssertTrue(workspace.metadataBlocks.isEmpty)
XCTAssertNil(workspace.progress)
XCTAssertNil(workspace.gitBranch)
XCTAssertTrue(workspace.panelGitBranches.isEmpty)
XCTAssertNil(workspace.pullRequest)
XCTAssertTrue(workspace.panelPullRequests.isEmpty)
XCTAssertTrue(workspace.surfaceListeningPorts.isEmpty)
XCTAssertTrue(workspace.listeningPorts.isEmpty)

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

Seed logEntries before asserting it clears.

Line 2487 currently only re-checks the default empty state because this test never adds anything to workspace.logEntries. That means the test still passes if resetSidebarContext(reason:) forgets to clear existing log entries. Add at least one log entry during setup so this path is actually exercised.

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

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` around lines 2453 - 2495, The
test never populates workspace.logEntries before calling
workspace.resetSidebarContext(reason:), so the assertion that logEntries is
empty only verifies the default state; add a log entry to the setup (e.g.,
append a SidebarLogEntry or whatever type is used into workspace.logEntries,
using the same test workspace instance) before the reset call so
resetSidebarContext(reason:) is exercised, then keep the existing
XCTAssertTrue(workspace.logEntries.isEmpty) after the reset to verify the
entries are cleared; locate and modify the test function around the setup lines
that set workspace.statusEntries, workspace.metadataBlocks, workspace.progress,
workspace.updatePanelGitBranch(...), workspace.updatePanelPullRequest(...), and
workspace.surfaceListeningPorts[contextPanelId].

Comment thread Sources/BrowserWindowPortal.swift
Comment thread Sources/BrowserWindowPortal.swift Outdated
Comment thread Sources/Panels/BrowserPanel.swift
Comment thread Sources/Panels/BrowserPanel.swift
Comment thread Sources/Workspace.swift Outdated

@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)
Sources/BrowserWindowPortal.swift (2)

3256-3260: ⚠️ Potential issue | 🟠 Major

Don't suppress the forced refresh after transient recovery when the inspector layout also moved.

The new transientRecovery reason is still dropped whenever hostedInspectorAdjustedDuringSync is true, so inspector-attached panes can come out of recovery without the redraw this fix relies on.

Suggested fix
-        if !shouldHide, containerOwnsWebView, !refreshReasons.isEmpty {
-            if hostedInspectorAdjustedDuringSync {
+        if !shouldHide, containerOwnsWebView, !refreshReasons.isEmpty {
+            if hostedInspectorAdjustedDuringSync && !recoveredFromTransientGeometry {
 `#if` DEBUG
                 dlog(
                     "browser.portal.refresh.skip web=\(browserPortalDebugToken(webView)) " +
                     "container=\(browserPortalDebugToken(containerView)) reason=\(source):" +
                     "\(refreshReasons.joined(separator: ",")) adjustedDuringSync=1"

Also applies to: 3267-3285

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

In `@Sources/BrowserWindowPortal.swift` around lines 3256 - 3260, The
transientRecovery refresh reason is being skipped when
hostedInspectorAdjustedDuringSync is true; ensure that when
recoveredFromTransientGeometry is true you always append "transientRecovery" to
refreshReasons regardless of hostedInspectorAdjustedDuringSync. Update the logic
around the recoveredFromTransientGeometry check (and the similar block in the
adjacent area handling inspector adjustments) so that
refreshReasons.append("transientRecovery") is executed unconditionally when
recoveredFromTransientGeometry is true, leaving the
hostedInspectorAdjustedDuringSync handling separate.

2855-2860: ⚠️ Potential issue | 🟡 Minor

Also clear portalDragDropZone in these reset paths.

activeDropZone prefers portalDragDropZone, so these branches can still leave the old split highlight painted after paneDropContext and the forwarded overlay are cleared. Please apply the same reset in the later preserved-frame transient branch as well.

Suggested fix
         func hideContainerView(reason: String) {
             containerView.setPaneTopChromeHeight(0)
             containerView.setSearchOverlay(nil)
             containerView.setPaneDropContext(nil)
+            containerView.setPortalDragDropZone(nil)
             containerView.setDropZoneOverlay(zone: nil)
             if !containerView.isHidden, webView.superview === containerView {
                 webView.browserPortalNotifyHidden(reason: reason)
             }
             containerView.isHidden = true
@@
             containerView.setPaneDropContext(nil)
+            containerView.setPortalDragDropZone(nil)
             containerView.setDropZoneOverlay(zone: nil)
             return true
@@
                     containerView.setPaneDropContext(nil)
+                    containerView.setPortalDragDropZone(nil)
                     containerView.setDropZoneOverlay(zone: nil)
                     return

Also applies to: 2874-2891, 3018-3035

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

In `@Sources/BrowserWindowPortal.swift` around lines 2855 - 2860, In
hideContainerView and the other reset branches that clear paneDropContext /
forwarded overlays (including the preserved-frame transient branch and the
similar reset blocks around the preserved-frame logic), also clear the
portalDragDropZone so activeDropZone won't continue to prefer the stale portal
zone; update the branches that call containerView.setPaneDropContext(nil) and
containerView.setDropZoneOverlay(zone: nil) to also reset portalDragDropZone
(and any backing state that activeDropZone reads) so the split highlight is
fully removed. Ensure you modify the hideContainerView function and the
preserved-frame transient reset path to reset portalDragDropZone consistently
with the other cleared state.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Sources/BrowserWindowPortal.swift`:
- Around line 3256-3260: The transientRecovery refresh reason is being skipped
when hostedInspectorAdjustedDuringSync is true; ensure that when
recoveredFromTransientGeometry is true you always append "transientRecovery" to
refreshReasons regardless of hostedInspectorAdjustedDuringSync. Update the logic
around the recoveredFromTransientGeometry check (and the similar block in the
adjacent area handling inspector adjustments) so that
refreshReasons.append("transientRecovery") is executed unconditionally when
recoveredFromTransientGeometry is true, leaving the
hostedInspectorAdjustedDuringSync handling separate.
- Around line 2855-2860: In hideContainerView and the other reset branches that
clear paneDropContext / forwarded overlays (including the preserved-frame
transient branch and the similar reset blocks around the preserved-frame logic),
also clear the portalDragDropZone so activeDropZone won't continue to prefer the
stale portal zone; update the branches that call
containerView.setPaneDropContext(nil) and containerView.setDropZoneOverlay(zone:
nil) to also reset portalDragDropZone (and any backing state that activeDropZone
reads) so the split highlight is fully removed. Ensure you modify the
hideContainerView function and the preserved-frame transient reset path to reset
portalDragDropZone consistently with the other cleared state.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ce0d9a65-cce6-4f2d-becb-76ae58ef0d9c

📥 Commits

Reviewing files that changed from the base of the PR and between 81618bf and 1dbd1e5.

📒 Files selected for processing (2)
  • Sources/BrowserWindowPortal.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift

@austinywang
austinywang merged commit 6272f50 into main Mar 12, 2026
11 of 12 checks passed

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

1 issue found across 5 files (changes from recent commits).

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/Workspace.swift">

<violation number="1" location="Sources/Workspace.swift:1717">
P2: Title resync can be skipped when `panelTitles` is unchanged, allowing Bonsplit tab titles to stay stale after browser panel context reset.</violation>
</file>

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

Comment thread Sources/Workspace.swift
for browserPanel in browserPanels {
browserPanel.resetForWorkspaceContextChange(reason: reason)
let nextTitle = browserPanel.displayTitle
_ = updatePanelTitle(panelId: browserPanel.id, title: nextTitle)

@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: Title resync can be skipped when panelTitles is unchanged, allowing Bonsplit tab titles to stay stale after browser panel context reset.

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

<comment>Title resync can be skipped when `panelTitles` is unchanged, allowing Bonsplit tab titles to stay stale after browser panel context reset.</comment>

<file context>
@@ -1713,29 +1713,23 @@ final class Workspace: Identifiable, ObservableObject {
         for browserPanel in browserPanels {
             browserPanel.resetForWorkspaceContextChange(reason: reason)
+            let nextTitle = browserPanel.displayTitle
+            _ = updatePanelTitle(panelId: browserPanel.id, title: nextTitle)
 
             guard let tabId = surfaceIdFromPanelId(browserPanel.id),
</file context>
Fix with Cubic

bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
…er-pane-stale-content

Fix stale browser pane content after drag splits

This branch was successfully deployed

1 active deployment
Preview — 8aafb689 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