Skip to content

Restore window positions when external displays reconnect - #2491

Closed
joshuaswanson wants to merge 8 commits into
manaflow-ai:mainfrom
joshuaswanson:fix/display-reconnect-restore
Closed

joshuaswanson wants to merge 8 commits into
manaflow-ai:mainfrom
joshuaswanson:fix/display-reconnect-restore

Conversation

@joshuaswanson

@joshuaswanson joshuaswanson commented Apr 1, 2026 •

Copy link
Copy Markdown
Contributor

Windows don't restore to external displays when reconnected. macOS moves windows to the primary display when an external display disconnects, and the 8-second autosave overwrites the saved position before the display comes back.

This caches each managed window's frame per display ID whenever it moves or resizes. When NSApplication.didChangeScreenParametersNotification fires and a previously-disconnected display reappears, windows that were on it get moved back to their cached positions (clamped to the screen's visible frame, with animation).


Summary by cubic

Restore window positions to their original external displays when they reconnect, and run browser JS in WKContentWorld.defaultClient so it works on strict CSP sites.

  • Bug Fixes
    • Per-display window-frame cache keyed by stable window UUID; seed on registration; update on move/resize/screen-change; preserve disconnected-display entries during system relayout but clear them on user moves so windows don’t snap back; restore on reconnect via NSApplication.didChangeScreenParametersNotification; clamp to the screen’s visible frame with animation; purge on window close.
    • Track connected displays by cmuxDisplayID to detect reconnections and keep entries for disconnected displays so autosave doesn’t overwrite saved frames.
    • Use WKContentWorld.defaultClient for all evaluateJavaScript and WKUserScript injections; add preferAsync to enforce the contentWorld, expose a contentWorld param on BrowserPanel.evaluateJavaScript, remove the isolated-world retry, update call sites to Result-based completions, and retain a macOS < 11 fallback.

Written for commit 4c83c9f. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Window positions now correctly restore per-display when external monitors disconnect and reconnect, preventing misplaced or duplicated windows.
  • Improvements

    • More reliable window-frame caching and restoration across display connect/disconnect events.
    • More consistent and robust in-page script execution and web UI interactions (address bar focus, telemetry), reducing evaluation failures and improving stability.

Sites with strict CSP (Airtable, various React SPAs) block scripts
injected into WKContentWorld.page, causing js_error from browser.eval
and snapshot --interactive. Switch all evaluateJavaScript calls and
WKUserScript registrations targeting browser panel webviews to use
.defaultClient, which is CSP-exempt but retains full DOM access.
… add contentWorld parameter to BrowserPanel.evaluateJavaScript
@vercel

vercel Bot commented Apr 1, 2026

Copy link
Copy Markdown

@joshuaswanson is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps

greptile-apps Bot commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR addresses two distinct problems: (1) the primary feature — restoring managed windows to their previous positions on external displays when those displays reconnect, and (2) a housekeeping migration of all WKWebView JavaScript evaluation calls from the deprecated two-argument callback form to the explicit in:in:contentWorld API, consistently using .defaultClient as the content world.

The display-restoration mechanism is clean in its overall design: NSWindow move/resize/screen-change notifications populate an in-memory [UInt32: [ObjectIdentifier: CGRect]] cache, and NSApplication.didChangeScreenParametersNotification drives the restore logic by diffing the current set of display IDs against the previously-known set. The existing clampFrame helper is reused to keep restored frames within the visible area.

Key changes:

  • AppDelegate: new displayWindowFrameCache + knownConnectedDisplayIDs properties, four new notification observers, and handleScreenParametersDidChange / cacheWindowFrameForDisplay helpers.
  • BrowserPanel, CmuxWebView, TerminalController: systematic switch to evaluateJavaScript(_:in:in:completionHandler:) / WKUserScript(source:injectionTime:forMainFrameOnly:in:) with .defaultClient, and removal of the isolated-world retry fallback in TerminalController.

Issues found:

  • P1 — displayWindowFrameCache is never pruned when a window closes. Because ObjectIdentifier values are memory-address-based, a new window allocated at the same address as a recently-closed window will inherit stale cached frames and can be unexpectedly repositioned the next time that display reconnects.
  • P2 — A window that the user deliberately drags off display A will still be pulled back to display A when it reconnects, since the cache stores frames per display without recording whether the window left voluntarily or was evicted by macOS.
  • P2 — Minor comment wording and one inconsistent Result decomposition style in BrowserPanel.

Confidence Score: 4/5

Safe to merge after addressing the frame-cache cleanup on window close; the WKContentWorld migration is mechanical and correct throughout.

One P1 defect exists: stale ObjectIdentifier entries in displayWindowFrameCache can cause wrong window positioning via address reuse after a window closes. This is a real correctness bug on the changed code path. The remaining findings are P2 (design trade-off and comment wording). The WKContentWorld migration across BrowserPanel, CmuxWebView, and TerminalController looks mechanically correct.

Sources/AppDelegate.swift — specifically the window close path in unregisterMainWindow, which needs to purge the corresponding displayWindowFrameCache entries.

Important Files Changed

Filename Overview
Sources/AppDelegate.swift Core of the PR: adds display-reconnection tracking, frame caching, and window restoration logic. Two issues: stale cache entries are never pruned when windows close (P1 ObjectIdentifier reuse hazard), and windows manually moved away from a display can still be pulled back when it reconnects (P2).
Sources/Panels/BrowserPanel.swift Migrates all WKWebView JS evaluation calls to the explicit in:in:contentWorld API, correctly handling the new Result-based callback. One style note: the addressBarFocusRestoreScript handler needlessly decomposes the Result into legacy result/error variables.
Sources/Panels/CmuxWebView.swift Straightforward migration of four callback-style evaluateJavaScript calls to in:in:.defaultClient with try? callResult.get() extraction — no logic changes, looks correct.
Sources/TerminalController.swift Switches v2RunJavaScript calls and WKUserScript additions to .defaultClient world, removes the isolated-world retry fallback, and adds preferAsync: true to several call sites. One minor: the legacy-path comment says 'contentWorld parameter is ignored' but should say the API accepting that parameter is unavailable on that OS version.

Sequence Diagram

sequenceDiagram
    participant User
    participant macOS
    participant AppDelegate
    participant Cache as displayWindowFrameCache
    participant NC as NotificationCenter

    Note over User,NC: Normal usage — window on external display
    User->>macOS: Move/resize window on Display A
    macOS->>NC: NSWindow.didMoveNotification
    NC->>AppDelegate: handleWindowDidMove(_:)
    AppDelegate->>Cache: Store frame keyed by [displayA][windowID]

    Note over User,NC: External display disconnects
    macOS->>macOS: Move window to primary display
    macOS->>NC: NSWindow.didMoveNotification
    NC->>AppDelegate: handleWindowDidMove(_:)
    AppDelegate->>Cache: Store frame keyed by [primary][windowID]
    Note over Cache: displayA entry preserved

    macOS->>NC: NSApplication.didChangeScreenParametersNotification
    NC->>AppDelegate: handleScreenParametersDidChange(_:)
    AppDelegate->>AppDelegate: reconnected = currentIDs minus knownIDs (empty)
    AppDelegate->>AppDelegate: knownConnectedDisplayIDs updated

    Note over User,NC: External display reconnects
    macOS->>NC: NSApplication.didChangeScreenParametersNotification
    NC->>AppDelegate: handleScreenParametersDidChange(_:)
    AppDelegate->>AppDelegate: reconnected = {displayA}
    AppDelegate->>Cache: Look up displayWindowFrameCache[displayA]
    Cache-->>AppDelegate: {windowID: savedFrame}
    AppDelegate->>AppDelegate: clampFrame(savedFrame, within: screen.visibleFrame)
    AppDelegate->>macOS: window.setFrame(clamped, animate: true)
    macOS->>User: Window animates back to Display A
Loading

Reviews (1): Last reviewed commit: "Restore window positions when external d..." | Re-trigger Greptile

Comment thread Sources/AppDelegate.swift Outdated
Comment on lines +3564 to +3567
private func cacheWindowFrameForDisplay(_ window: NSWindow) {
guard mainWindowContexts[ObjectIdentifier(window)] != nil else { return }
guard let displayID = window.screen?.cmuxDisplayID else { return }
displayWindowFrameCache[displayID, default: [:]][ObjectIdentifier(window)] = window.frame

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.

P1 Stale cache entries never pruned on window close

displayWindowFrameCache entries are added whenever a window moves or resizes, but they are never removed when a window closes. unregisterMainWindow (called from willCloseNotification) clears mainWindowContexts and many other per-window maps, but leaves displayWindowFrameCache untouched.

Because ObjectIdentifier is derived from the object's memory address, once a window is deallocated its address can be reused by a new NSWindow. If a new window is registered in mainWindowContexts at the same ObjectIdentifier, handleScreenParametersDidChange will find the stale entry and silently move the brand-new window to the position that belonged to the long-closed window the next time that display reconnects.

The fix is to purge all cache entries for the closing window's ObjectIdentifier inside unregisterMainWindow, at the same point where the other per-window state (commandPaletteVisibilityByWindowId, etc.) is cleaned up.

Comment thread Sources/AppDelegate.swift
Comment on lines +3585 to +3611
@objc private func handleScreenParametersDidChange(_ notification: Notification) {
let currentDisplayIDs = Set(NSScreen.screens.compactMap { $0.cmuxDisplayID })
let reconnected = currentDisplayIDs.subtracting(knownConnectedDisplayIDs)
knownConnectedDisplayIDs = currentDisplayIDs

guard !reconnected.isEmpty else { return }

for displayID in reconnected {
guard let cachedFrames = displayWindowFrameCache[displayID],
let screen = NSScreen.screens.first(where: { $0.cmuxDisplayID == displayID }) else {
continue
}
for (windowID, frame) in cachedFrames {
guard let (_, ctx) = mainWindowContexts.first(where: { $0.key == windowID }),
let window = ctx.window else {
continue
}
let clamped = Self.clampFrame(
frame,
within: screen.visibleFrame,
minWidth: CGFloat(SessionPersistencePolicy.minimumWindowWidth),
minHeight: CGFloat(SessionPersistencePolicy.minimumWindowHeight)
)
window.setFrame(clamped, display: true, animate: true)
}
}
}

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.

P2 Restoration triggers when user intentionally moved window away from the display

displayWindowFrameCache stores frames per display without any notion of why the window left the display. Consider this sequence:

  1. External display A is connected; window lives on A.
  2. User manually drags the window to the primary display (cache now has entries for both displays).
  3. External display A disconnects and later reconnects.

handleScreenParametersDidChange sees display A as "reconnected," finds the stale cache entry for the window under display A's ID, and moves the window back to A — overriding the user's explicit choice. Filtering out windows that are already on a still-connected display before restoring would prevent this.

}
}
} else {
// macOS < 11.0: contentWorld parameter is ignored; JS runs in .page

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.

P2 Misleading comment on the legacy evaluateJavaScript fallback

The comment says "contentWorld parameter is ignored" which implies the parameter exists but is silently discarded. What actually happens is that the evaluateJavaScript(_:in:in:completionHandler:) overload that accepts a WKContentWorld does not exist on macOS < 11.0, so the legacy evaluateJavaScript(_:completionHandler:) is used, which always executes in the .page world with no way to opt out.

Suggested change
// macOS < 11.0: contentWorld parameter is ignored; JS runs in .page
// macOS < 11.0: WKContentWorld API unavailable; JS always runs in .page world
webView.evaluateJavaScript(script) { value, error in

Comment on lines 5310 to 5323
completion(false)
return
}
webView.evaluateJavaScript(Self.addressBarFocusRestoreScript) { [weak self] result, error in
webView.evaluateJavaScript(Self.addressBarFocusRestoreScript, in: nil, in: .defaultClient) { [weak self] callResult in
let result: Any?
let error: Error?
switch callResult {
case .success(let value): result = value; error = nil
case .failure(let err): result = nil; error = err
}

guard let self else {
completion(false)
return

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.

P2 Unnecessary decomposition of Result into legacy variables

The callback decomposes the Result back into separate result and error variables to feed the existing downstream code, unlike every other converted site in this PR which switches on the Result directly. The current approach works correctly but is inconsistent with the pattern used elsewhere in the same changeset.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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

<violation number="1" location="Sources/TerminalController.swift:7110">
P1: Switching browser eval/addscript execution from page world to defaultClient can break scripts that rely on page-defined globals/functions.</violation>
</file>

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

timeout: timeout,
preferAsync: true,
contentWorld: .page
contentWorld: .defaultClient

@cubic-dev-ai cubic-dev-ai Bot Apr 1, 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: Switching browser eval/addscript execution from page world to defaultClient can break scripts that rely on page-defined globals/functions.

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

<comment>Switching browser eval/addscript execution from page world to defaultClient can break scripts that rely on page-defined globals/functions.</comment>

<file context>
@@ -7106,33 +7107,15 @@ class TerminalController {
                 timeout: timeout,
                 preferAsync: true,
-                contentWorld: .page
+                contentWorld: .defaultClient
             )
         } else {
</file context>
Fix with Cubic

@coderabbitai

coderabbitai Bot commented Apr 1, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Per-display window-frame caching and reconnection-aware restoration were added to AppDelegate; numerous WKWebView evaluateJavaScript call sites and injected WKUserScript instances were migrated to run in the .defaultClient content world with Result-style callbacks; BrowserPanel.evaluateJavaScript gained an optional contentWorld parameter.

Changes

Cohort / File(s) Summary
Display Window Frame Caching
Sources/AppDelegate.swift
Added displayWindowFrameCache, knownConnectedDisplayIDs, isHandlingDisplayDisconnect; register screen/window observers at launch; cache frames on move/resize/screen-change; detect disconnected/reconnected displays, clamp cached frames to visibleFrame (respecting min-size policy) and restore with animation; purge cache on window unregister.
Browser JS Content World & Result Handling
Sources/Panels/BrowserPanel.swift, Sources/Panels/CmuxWebView.swift, Sources/TerminalController.swift
Replaced legacy evaluateJavaScript usages with evaluateJavaScript(_:in:in:) using in: nil and .defaultClient; updated injected WKUserScript to target .defaultClient and enabled preferAsync where applicable; completion handlers now accept a Result-style callResult and extract values via try? callResult.get().
Public API Change
Sources/Panels/BrowserPanel.swift
Extended evaluateJavaScript signature to evaluateJavaScript(_ script: String, contentWorld: WKContentWorld = .defaultClient) async throws -> Any?, forwarding to the webView call with explicit content world.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application
    participant DispMgr as AppDelegate
    participant Screen as NSScreen(s)
    participant Window as MainWindow
    participant Cache as FrameCache

    App->>DispMgr: applicationDidFinishLaunching
    DispMgr->>Screen: read NSScreen.screens -> knownConnectedDisplayIDs
    DispMgr->>DispMgr: register screen & window observers

    Window->>DispMgr: moved / resized / screen changed
    DispMgr->>Cache: cacheWindowFrameForDisplay(windowUUID, displayID, frame)

    Note over Screen,DispMgr: display disconnect / relayout detected
    Screen->>DispMgr: didChangeScreenParametersNotification
    DispMgr->>DispMgr: compute disconnected vs reconnected IDs
    DispMgr->>DispMgr: set isHandlingDisplayDisconnect (suppress pruning briefly)

    alt reconnected displays exist
      loop each cached window for displayID
        DispMgr->>Cache: get cached frame for displayID
        Cache-->>DispMgr: return cached frame
        DispMgr->>DispMgr: clamp frame to display.visibleFrame (apply min size)
        DispMgr->>Window: setFrame(clampedFrame, display:true, animate:true)
      end
    end

    Window->>DispMgr: unregisterMainWindow (on close)
    DispMgr->>Cache: purge cached frames for windowUUID across displays
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I cached each frame beneath the moonlit sky,

When displays wander off, I softly sigh —
Then hop back quick and clamp them safe and neat,
JS now jumps to defaultClient with a beat,
A rabbit cheers as windows find their seat.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.92% 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 specifically describes the main change: restoring window positions when external displays reconnect, which aligns with the primary bug fix detailed in the PR description.
Description check ✅ Passed The PR description is comprehensive and covers the main change and rationale. However, the Testing section is missing specific details about manual testing, and the Demo Video section is empty despite UI behavior changes being involved.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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 1 file (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/AppDelegate.swift">

<violation number="1" location="Sources/AppDelegate.swift:3571">
P2: Clearing this window’s cached frame on all other displays during every move/resize can erase the disconnected monitor’s last-known frame when macOS auto-relocates windows after a display unplug. That leaves handleScreenParametersDidChange with no cached frame to restore on reconnect, reintroducing the “window doesn’t return to external display” bug.</violation>
</file>

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

Comment thread Sources/AppDelegate.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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Sources/TerminalController.swift (2)

7049-7118: ⚠️ Potential issue | 🟠 Major

Isolate user-authored JS in the page world, not the client world.

Lines 7110–7118 move v2RunBrowserJavaScript to .defaultClient, an isolated sandbox. This breaks public APIs (browser.eval, browser.wait(function=...), browser.addscript, browser.addinitscript) that accept user-written JS—users cannot access page-defined globals, functions, or state. For example, browser.wait(function='typeof window.MyAppGlobal !== undefined') will fail because MyAppGlobal doesn't exist in the isolated world.

The telemetry hooks (console, error, dialog capture) are intentionally in .defaultClient to avoid CAPTCHA interference, but public JS-execution paths require page-world access.

Add a contentWorld parameter to v2RunBrowserJavaScript defaulting to .page, and explicitly pass .defaultClient only for internal cmux-owned operations.

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

In `@Sources/TerminalController.swift` around lines 7049 - 7118, The
v2RunBrowserJavaScript function is forcing execution into the isolated
.defaultClient world which prevents user-authored scripts from seeing page
globals; change v2RunBrowserJavaScript to accept a new contentWorld parameter
(defaulting to .page) and forward that to v2RunJavaScript (instead of always
using .defaultClient), and only call it with .defaultClient from internal cmux
telemetry/own operations; update both the macOS-11 and fallback evaluateFallback
v2RunJavaScript calls to use the new contentWorld argument so public APIs
(browser.eval, browser.wait, browser.addscript, browser.addinitscript) run in
the page world while internal hooks can explicitly request .defaultClient.

9354-9402: ⚠️ Potential issue | 🟠 Major

Hooks in .defaultClient cannot observe page-world console/error/dialog activity.

Lines 9360/9370 install telemetry hooks in .defaultClient, and lines 9402/10053/10091 read window.__cmuxDialogQueue, window.__cmuxConsoleLog, and window.__cmuxErrorLog from that same isolated world. Because client worlds do not share JS state with the page, page-originated console.*, error, alert, confirm, and prompt traffic will not populate these structures. browser.console.list, browser.errors.list, and dialog automation will only capture cmux-side JavaScript unless hooks/readers use .page world or implement explicit bridging between worlds.

The CAPTCHA concern (detecting overridden console.* methods in cross-origin iframes) applies to iframe injection, not the .page vs .defaultClient choice on the main frame. Consider whether capturing page-world activity is a required feature, and if so, implement .page world injection for the main frame.

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

In `@Sources/TerminalController.swift` around lines 9354 - 9402, The telemetry and
dialog hook injections in v2BrowserEnsureTelemetryHooks and
v2BrowserEnsureDialogHooks currently use contentWorld: .defaultClient which
cannot observe page-world console/error/dialog activity that
v2BrowserDialogRespond (which reads window.__cmuxDialogQueue), and the
console/error readers expect; change the injection calls to use contentWorld:
.page (or detect/main-frame and use .page for the main frame) when injecting
BrowserPanel.telemetryHookBootstrapScriptSource and
BrowserPanel.dialogTelemetryHookBootstrapScriptSource via v2RunJavaScript so the
page and client worlds share those window.__cmux* globals, ensuring
browser.console.list, browser.errors.list and dialog automation capture
page-originated events; keep .defaultClient only if you intentionally want
cmux-side isolation.
🧹 Nitpick comments (1)
Sources/AppDelegate.swift (1)

8147-8149: Consider using a consistent wrapper for WebKit evaluateJavaScript calls.

Lines 8147 and 8355 directly call webView.evaluateJavaScript(script, in: nil, in: .defaultClient) with completion handlers, while BrowserPanel already provides an evaluateJavaScript() wrapper with async/throws semantics. The raw calls use a different calling convention (callback-based vs. async), but maintaining a single centralized path for these calls reduces the risk of future drift or inconsistencies (e.g., if contentWorld handling changes). If the callback pattern is needed here, consider adding a callback-based wrapper to BrowserPanel rather than calling the raw API directly.

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

In `@Sources/AppDelegate.swift` around lines 8147 - 8149, The two direct calls to
panel.webView.evaluateJavaScript(script, in: nil, in: .defaultClient) should be
routed through BrowserPanel’s centralized evaluateJavaScript wrapper to keep
contentWorld handling consistent; either replace these raw callback-based usages
with the existing async/throws BrowserPanel.evaluateJavaScript call (await and
handle errors) or add a callback-based overload to BrowserPanel (e.g.,
evaluateJavaScript(_:in:completion:)) and use that from AppDelegate, updating
the call sites that currently capture callResult and cast payload so they use
the wrapper’s result/error handling instead.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 2435-2460: Seed the display cache immediately when a main window
becomes managed by populating knownConnectedDisplayIDs from the window's current
screen/frame instead of waiting for move/resize/screen-change notifications:
after the observers are registered (the block that sets knownConnectedDisplayIDs
= Set(NSScreen.screens.compactMap { $0.cmuxDisplayID }) and adds
NSApplication.didChangeScreenParametersNotification /
NSWindow.didChangeScreenNotification / NSWindow.didMoveNotification /
NSWindow.didResizeNotification), also examine the managed window's current
screen via window.screen and its frame (window.frame) and add that screen's
cmuxDisplayID into knownConnectedDisplayIDs (or call the same seeding logic used
by handleScreenParametersDidChange) so a window already on an external display
is backfilled; make the same change at the other occurrence noted (the block
around lines 3577-3590) and ensure any restoration logic in
handleScreenParametersDidChange uses this preseeded set.
- Around line 3567-3575: cacheWindowFrameForDisplay currently removes cached
frames for other displays when a window's screen changes, which clears the old
display entry during disconnect-driven moves; stop deleting otherDisplayID
entries here and only update the mapping for the current display. Specifically,
in cacheWindowFrameForDisplay remove the loop that calls
displayWindowFrameCache[otherDisplayID]?.removeValue(forKey: windowID) so
existing entries for other displays are preserved, and add an explicit pruning
path to be invoked only on user-initiated moves (e.g., from the window-move
handler or a new method like pruneCachedFramesForWindow(_:) referenced by
handleScreenParametersDidChange) that will remove stale display entries when the
user explicitly moves a window.

---

Outside diff comments:
In `@Sources/TerminalController.swift`:
- Around line 7049-7118: The v2RunBrowserJavaScript function is forcing
execution into the isolated .defaultClient world which prevents user-authored
scripts from seeing page globals; change v2RunBrowserJavaScript to accept a new
contentWorld parameter (defaulting to .page) and forward that to v2RunJavaScript
(instead of always using .defaultClient), and only call it with .defaultClient
from internal cmux telemetry/own operations; update both the macOS-11 and
fallback evaluateFallback v2RunJavaScript calls to use the new contentWorld
argument so public APIs (browser.eval, browser.wait, browser.addscript,
browser.addinitscript) run in the page world while internal hooks can explicitly
request .defaultClient.
- Around line 9354-9402: The telemetry and dialog hook injections in
v2BrowserEnsureTelemetryHooks and v2BrowserEnsureDialogHooks currently use
contentWorld: .defaultClient which cannot observe page-world
console/error/dialog activity that v2BrowserDialogRespond (which reads
window.__cmuxDialogQueue), and the console/error readers expect; change the
injection calls to use contentWorld: .page (or detect/main-frame and use .page
for the main frame) when injecting
BrowserPanel.telemetryHookBootstrapScriptSource and
BrowserPanel.dialogTelemetryHookBootstrapScriptSource via v2RunJavaScript so the
page and client worlds share those window.__cmux* globals, ensuring
browser.console.list, browser.errors.list and dialog automation capture
page-originated events; keep .defaultClient only if you intentionally want
cmux-side isolation.

---

Nitpick comments:
In `@Sources/AppDelegate.swift`:
- Around line 8147-8149: The two direct calls to
panel.webView.evaluateJavaScript(script, in: nil, in: .defaultClient) should be
routed through BrowserPanel’s centralized evaluateJavaScript wrapper to keep
contentWorld handling consistent; either replace these raw callback-based usages
with the existing async/throws BrowserPanel.evaluateJavaScript call (await and
handle errors) or add a callback-based overload to BrowserPanel (e.g.,
evaluateJavaScript(_:in:completion:)) and use that from AppDelegate, updating
the call sites that currently capture callResult and cast payload so they use
the wrapper’s result/error handling instead.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 79c112c9-3d69-4eb1-bf36-a96b317c96d2

📥 Commits

Reviewing files that changed from the base of the PR and between 3d889fe and d96cf7d.

📒 Files selected for processing (4)
  • Sources/AppDelegate.swift
  • Sources/Panels/BrowserPanel.swift
  • Sources/Panels/CmuxWebView.swift
  • Sources/TerminalController.swift

Comment thread Sources/AppDelegate.swift
Comment on lines +2435 to +2460
// Track external display connect/disconnect for window position restoration.
knownConnectedDisplayIDs = Set(NSScreen.screens.compactMap { $0.cmuxDisplayID })
NotificationCenter.default.addObserver(
self,
selector: #selector(handleScreenParametersDidChange(_:)),
name: NSApplication.didChangeScreenParametersNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleWindowDidChangeScreen(_:)),
name: NSWindow.didChangeScreenNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleWindowDidMove(_:)),
name: NSWindow.didMoveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleWindowDidResize(_:)),
name: NSWindow.didResizeNotification,
object: nil
)

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

Seed the display cache when a main window becomes managed.

Right now the cache is only populated by future move/resize/screen-change notifications. A window that's already sitting on an external display when it registers can unplug before any of those fire, leaving handleScreenParametersDidChange with nothing to restore. Please backfill from the current window.frame once registration completes.

Also applies to: 3577-3590

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

In `@Sources/AppDelegate.swift` around lines 2435 - 2460, Seed the display cache
immediately when a main window becomes managed by populating
knownConnectedDisplayIDs from the window's current screen/frame instead of
waiting for move/resize/screen-change notifications: after the observers are
registered (the block that sets knownConnectedDisplayIDs =
Set(NSScreen.screens.compactMap { $0.cmuxDisplayID }) and adds
NSApplication.didChangeScreenParametersNotification /
NSWindow.didChangeScreenNotification / NSWindow.didMoveNotification /
NSWindow.didResizeNotification), also examine the managed window's current
screen via window.screen and its frame (window.frame) and add that screen's
cmuxDisplayID into knownConnectedDisplayIDs (or call the same seeding logic used
by handleScreenParametersDidChange) so a window already on an external display
is backfilled; make the same change at the other occurrence noted (the block
around lines 3577-3590) and ensure any restoration logic in
handleScreenParametersDidChange uses this preseeded set.

Comment thread Sources/AppDelegate.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: 1

♻️ Duplicate comments (1)
Sources/AppDelegate.swift (1)

3567-3594: ⚠️ Potential issue | 🟠 Major

Manual moves/resizes still leave stale disconnected-display restore targets behind.

After display A disconnects, every later didMove/didResize on display B still preserves A because A is no longer in knownConnectedDisplayIDs. If the user intentionally repositions the window while A is unplugged, reconnecting A will still snap the window back to the stale cached frame. You still need an explicit prune path for user-initiated moves/resizes, and only the disconnect-driven relayout path should preserve the old display entry.

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

In `@Sources/AppDelegate.swift` around lines 3567 - 3594,
cacheWindowFrameForDisplay currently preserves cached frames for disconnected
displays, causing stale restore positions when the user manually moves/resizes
on another display; change cacheWindowFrameForDisplay to accept a flag (e.g.,
preserveDisconnected: Bool = true) that controls whether to skip pruning entries
for displays not in knownConnectedDisplayIDs, update the loop to prune
otherDisplayID entries unconditionally when preserveDisconnected is false, and
call cacheWindowFrameForDisplay(window, preserveDisconnected: false) from the
user-driven handlers handleWindowDidMove, handleWindowDidResize and
handleWindowDidChangeScreen while keeping the disconnect/relayout callers using
the default true to preserve entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 2305-2309: displayWindowFrameCache currently keys per-window
frames by ObjectIdentifier(window), which breaks when the logical window is
re-instantiated; change the cache to key by the stable logical identifier
(MainWindowContext.windowId) instead of ObjectIdentifier(window), update all
uses that read/write the cache (store/restore/cleanup paths) to accept and pass
windowId, and ensure close-time purge logic removes entries for any old object
IDs by removing entries keyed by that windowId; refer to
displayWindowFrameCache, knownConnectedDisplayIDs, MainWindowContext.windowId
and any places that currently build ObjectIdentifier(window) to locate and
update the code paths.

---

Duplicate comments:
In `@Sources/AppDelegate.swift`:
- Around line 3567-3594: cacheWindowFrameForDisplay currently preserves cached
frames for disconnected displays, causing stale restore positions when the user
manually moves/resizes on another display; change cacheWindowFrameForDisplay to
accept a flag (e.g., preserveDisconnected: Bool = true) that controls whether to
skip pruning entries for displays not in knownConnectedDisplayIDs, update the
loop to prune otherDisplayID entries unconditionally when preserveDisconnected
is false, and call cacheWindowFrameForDisplay(window, preserveDisconnected:
false) from the user-driven handlers handleWindowDidMove, handleWindowDidResize
and handleWindowDidChangeScreen while keeping the disconnect/relayout callers
using the default true to preserve entries.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e0cbebbe-bcc2-44c6-bff6-d58581cbb680

📥 Commits

Reviewing files that changed from the base of the PR and between d96cf7d and 4c585e4.

📒 Files selected for processing (1)
  • Sources/AppDelegate.swift

Comment thread Sources/AppDelegate.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: 1

🧹 Nitpick comments (1)
Sources/AppDelegate.swift (1)

8152-8155: Prefer the new BrowserPanel.evaluateJavaScript wrapper here as well.

These DEBUG helpers call WebKit directly, so they bypass the BrowserPanel execution path this PR introduced for preferAsync, contentWorld, and older-macOS fallback behavior. That makes the UI-test JS path diverge from the production browser path. Please confirm the bypass is intentional; otherwise route these through the wrapper too.

Also applies to: 8360-8361

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

In `@Sources/AppDelegate.swift` around lines 8152 - 8155, The DEBUG helper is
calling WebKit directly via panel.webView.evaluateJavaScript which bypasses the
new BrowserPanel.evaluateJavaScript wrapper (and thus skips preferAsync,
contentWorld and macOS fallback behavior); change this to call
BrowserPanel.evaluateJavaScript (the wrapper method on BrowserPanel) instead,
passing the same script and completion handling and preserving the existing weak
self capture and payload parsing; apply the same replacement for the other
occurrence referenced (around the second location) so both debug paths use
BrowserPanel.evaluateJavaScript rather than webView.evaluateJavaScript.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 3567-3578: The cacheWindowFrameForDisplay logic must avoid
restoring windows that were intentionally moved on a different (connected)
display after a disconnect: when caching a window frame into
displayWindowFrameCache for the current display (in cacheWindowFrameForDisplay),
clear any stale entries for that same ctx.windowId that live under disconnected
displayIDs by removing displayWindowFrameCache[oldDisplayID]?[windowID] if
oldDisplayID is not equal to displayID (instead of only pruning connected
displays), and/or record a per-window "lastMovedAt" timestamp or a
pendingRestore flag on mainWindowContexts[ObjectIdentifier(window)] that you set
here; then update the reconnect/restore path to consult that timestamp/flag
(e.g., skip restoration if lastMovedAt is newer than the disconnect time or
pendingRestore is false). Use the existing symbols displayWindowFrameCache,
mainWindowContexts, ctx.windowId, knownConnectedDisplayIDs and the reconnect
restore code to implement this recency check.

---

Nitpick comments:
In `@Sources/AppDelegate.swift`:
- Around line 8152-8155: The DEBUG helper is calling WebKit directly via
panel.webView.evaluateJavaScript which bypasses the new
BrowserPanel.evaluateJavaScript wrapper (and thus skips preferAsync,
contentWorld and macOS fallback behavior); change this to call
BrowserPanel.evaluateJavaScript (the wrapper method on BrowserPanel) instead,
passing the same script and completion handling and preserving the existing weak
self capture and payload parsing; apply the same replacement for the other
occurrence referenced (around the second location) so both debug paths use
BrowserPanel.evaluateJavaScript rather than webView.evaluateJavaScript.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4e5d995a-d2a9-4885-9260-0a02253609cf

📥 Commits

Reviewing files that changed from the base of the PR and between 4c585e4 and a8bea41.

📒 Files selected for processing (1)
  • Sources/AppDelegate.swift

Comment thread Sources/AppDelegate.swift
Comment on lines +3567 to +3578
private func cacheWindowFrameForDisplay(_ window: NSWindow) {
guard let ctx = mainWindowContexts[ObjectIdentifier(window)] else { return }
guard let displayID = window.screen?.cmuxDisplayID else { return }
let windowID = ctx.windowId
for otherDisplayID in displayWindowFrameCache.keys where otherDisplayID != displayID {
// Only prune entries for displays that are still connected.
// Disconnected display entries are preserved for restoration.
if knownConnectedDisplayIDs.contains(otherDisplayID) {
displayWindowFrameCache[otherDisplayID]?.removeValue(forKey: windowID)
}
}
displayWindowFrameCache[displayID, default: [:]][windowID] = window.frame

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

Don't restore windows that were intentionally rehomed while a display was away.

The disconnected-display entry survives later moves/resizes on a connected display, and the reconnect path restores every window still cached under that old display. If a user unplugs display A, keeps working on display B, and manually repositions the window on B, reconnecting A will still snap it back to A. This needs a per-window recency / pending-restore check so post-disconnect user moves clear the stale entry.

Also applies to: 3603-3620

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

In `@Sources/AppDelegate.swift` around lines 3567 - 3578, The
cacheWindowFrameForDisplay logic must avoid restoring windows that were
intentionally moved on a different (connected) display after a disconnect: when
caching a window frame into displayWindowFrameCache for the current display (in
cacheWindowFrameForDisplay), clear any stale entries for that same ctx.windowId
that live under disconnected displayIDs by removing
displayWindowFrameCache[oldDisplayID]?[windowID] if oldDisplayID is not equal to
displayID (instead of only pruning connected displays), and/or record a
per-window "lastMovedAt" timestamp or a pendingRestore flag on
mainWindowContexts[ObjectIdentifier(window)] that you set here; then update the
reconnect/restore path to consult that timestamp/flag (e.g., skip restoration if
lastMovedAt is newer than the disconnect time or pendingRestore is false). Use
the existing symbols displayWindowFrameCache, mainWindowContexts, ctx.windowId,
knownConnectedDisplayIDs and the reconnect restore code to implement this
recency check.

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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 3599-3635: The reconnect logic restores windows from
displayWindowFrameCache but session saving still reads live window.frame and
displaySnapshot(for:), which can record fallback-display data and overwrite the
cached placement; update buildSessionSnapshot(...) and
persistWindowGeometry(from:) to prefer using displayWindowFrameCache and the
cached displayID/frame when present (lookup by window UUID and/or displayID)
instead of the current NSScreen-derived values, and fall back to
window.frame/displaySnapshot(for:) only if no cached entry exists; ensure you
reference displayWindowFrameCache, window UUID keys used in mainWindowContexts,
and the same clamping/SessionPersistencePolicy minimums so persisted snapshots
match the restored geometry.
- Around line 3570-3597: cacheWindowFrameForDisplay currently always prunes
other-display entries, which causes disconnected-display entries to be removed
by non-user-driven geometry changes; change cacheWindowFrameForDisplay(_:) to
accept a Bool (e.g., shouldPruneOtherDisplays) or add an internal flag to
control pruning and only run the for-otherDisplayID removal when
shouldPruneOtherDisplays is true; then call cacheWindowFrameForDisplay(window,
shouldPruneOtherDisplays: true) from user-driven handlers handleWindowDidMove
and handleWindowDidResize, but call it with shouldPruneOtherDisplays: false (or
leave default false) from handleWindowDidChangeScreen so cross-display pruning
only happens on real user moves/resizes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 90c92862-671d-48a4-9d38-e04a186ea9c5

📥 Commits

Reviewing files that changed from the base of the PR and between a8bea41 and 4c83c9f.

📒 Files selected for processing (1)
  • Sources/AppDelegate.swift

Comment thread Sources/AppDelegate.swift
Comment on lines +3570 to +3597
private func cacheWindowFrameForDisplay(_ window: NSWindow) {
guard let ctx = mainWindowContexts[ObjectIdentifier(window)] else { return }
guard let displayID = window.screen?.cmuxDisplayID else { return }
let windowID = ctx.windowId
for otherDisplayID in displayWindowFrameCache.keys where otherDisplayID != displayID {
if isHandlingDisplayDisconnect && !knownConnectedDisplayIDs.contains(otherDisplayID) {
// Preserve disconnected display entries during system relayout.
continue
}
displayWindowFrameCache[otherDisplayID]?.removeValue(forKey: windowID)
}
displayWindowFrameCache[displayID, default: [:]][windowID] = window.frame
}

@objc private func handleWindowDidMove(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
cacheWindowFrameForDisplay(window)
}

@objc private func handleWindowDidResize(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
cacheWindowFrameForDisplay(window)
}

@objc private func handleWindowDidChangeScreen(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
cacheWindowFrameForDisplay(window)
}

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

Only prune other display entries on confirmed user moves/resizes.

didMove, didResize, and didChangeScreen all funnel through the same pruning path here. After the one-turn isHandlingDisplayDisconnect window closes, any later non-user geometry change on the fallback display clears the disconnected-display entry too, so reconnect restore stops working. Keep the frame update here, but gate cross-display pruning on real user-initiated moves/resizes only.

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

In `@Sources/AppDelegate.swift` around lines 3570 - 3597,
cacheWindowFrameForDisplay currently always prunes other-display entries, which
causes disconnected-display entries to be removed by non-user-driven geometry
changes; change cacheWindowFrameForDisplay(_:) to accept a Bool (e.g.,
shouldPruneOtherDisplays) or add an internal flag to control pruning and only
run the for-otherDisplayID removal when shouldPruneOtherDisplays is true; then
call cacheWindowFrameForDisplay(window, shouldPruneOtherDisplays: true) from
user-driven handlers handleWindowDidMove and handleWindowDidResize, but call it
with shouldPruneOtherDisplays: false (or leave default false) from
handleWindowDidChangeScreen so cross-display pruning only happens on real user
moves/resizes.

Comment thread Sources/AppDelegate.swift
Comment on lines +3599 to +3635
@objc private func handleScreenParametersDidChange(_ notification: Notification) {
let currentDisplayIDs = Set(NSScreen.screens.compactMap { $0.cmuxDisplayID })
let reconnected = currentDisplayIDs.subtracting(knownConnectedDisplayIDs)
let disconnected = knownConnectedDisplayIDs.subtracting(currentDisplayIDs)
knownConnectedDisplayIDs = currentDisplayIDs

// Briefly suppress pruning of disconnected display entries so that
// the system-driven window relocation preserves the restore frame.
if !disconnected.isEmpty {
isHandlingDisplayDisconnect = true
DispatchQueue.main.async { [weak self] in
self?.isHandlingDisplayDisconnect = false
}
}

guard !reconnected.isEmpty else { return }

for displayID in reconnected {
guard let cachedFrames = displayWindowFrameCache[displayID],
let screen = NSScreen.screens.first(where: { $0.cmuxDisplayID == displayID }) else {
continue
}
for (windowUUID, frame) in cachedFrames {
guard let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowUUID }),
let window = ctx.window else {
continue
}
let clamped = Self.clampFrame(
frame,
within: screen.visibleFrame,
minWidth: CGFloat(SessionPersistencePolicy.minimumWindowWidth),
minHeight: CGFloat(SessionPersistencePolicy.minimumWindowHeight)
)
window.setFrame(clamped, display: true, animate: true)
}
}
}

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

Wire the disconnected-display cache into session persistence too.

This reconnect handler restores the live window, but buildSessionSnapshot(...) and persistWindowGeometry(from:) still save the current window.frame / displaySnapshot(for:) from the fallback display. If the app terminates before another save after reconnection, the saved session is still overwritten and the next launch loses the original placement.

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

In `@Sources/AppDelegate.swift` around lines 3599 - 3635, The reconnect logic
restores windows from displayWindowFrameCache but session saving still reads
live window.frame and displaySnapshot(for:), which can record fallback-display
data and overwrite the cached placement; update buildSessionSnapshot(...) and
persistWindowGeometry(from:) to prefer using displayWindowFrameCache and the
cached displayID/frame when present (lookup by window UUID and/or displayID)
instead of the current NSScreen-derived values, and fall back to
window.frame/displaySnapshot(for:) only if no cached entry exists; ensure you
reference displayWindowFrameCache, window UUID keys used in mainWindowContexts,
and the same clamping/SessionPersistencePolicy minimums so persisted snapshots
match the restored geometry.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

Display reconnect frame restoration is covered by the current window geometry handling on main: 38c93ff.

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.

2 participants