Skip to content

Fix main window not refitting after a display reconfiguration (Screen Sharing / resolution change) - #5193

Closed
domsilvestre wants to merge 2 commits into
manaflow-ai:mainfrom
domsilvestre:fix/reconcile-window-frame-on-screen-params-change
Closed

domsilvestre wants to merge 2 commits into
manaflow-ai:mainfrom
domsilvestre:fix/reconcile-window-frame-on-screen-params-change

Conversation

@domsilvestre

@domsilvestre domsilvestre commented Jun 2, 2026 •

Copy link
Copy Markdown

Problem

On a Mac accessed via macOS Screen Sharing (for example a headless Mac mini reached from a laptop), connecting or resizing the viewer resizes the host's virtual display. macOS refits ordinary windows to the new geometry, but the cmux window stays where it was and ends up pinned against a screen edge or overflowing the display. The same happens on a plain resolution change or when an external display is connected or disconnected.

Root cause

cmux main windows set isMovable = false for the custom titlebar drag handling (WindowDragHandleView, configureCmuxMainWindowDragBehavior). A non-movable NSWindow is excluded from AppKit's automatic on-screen constraining on display reconfiguration, so nothing repositions or resizes the window when NSApplication.didChangeScreenParametersNotification fires. The geometry-resolution helpers already exist (resolvedWindowFrame, clampFrame, display(for:in:)) but only run at launch and session restore, never reactively while the app is open.

Fix

  • Observe NSApplication.didChangeScreenParametersNotification in AppDelegate.
  • For each registered main window, refit it onto the visible frame of the display it now belongs to (matched by display ID, then overlap or proximity, then the fallback display), reusing the existing helpers (currentDisplayGeometries, display(for:in:), clampFrame).
  • The pass is a no-op when a window already fits, so windows on displays the reconfiguration did not affect are left untouched.
  • Notifications are coalesced (one reconcile pass per reconfiguration) with a short settle delay so the NSScreen geometry is current when read back.

This is intentionally scoped to the missing reactive refit: it clamps the window to fit, it does not restore previously saved per-display geometry (that broader behavior is what #2667 is working toward).

Tests

Adds a Swift Testing suite for the pure decision (AppDelegate.reconciledFrameAfterScreenChange): an oversized window on a shrunk display is refit, an already-fitting window is untouched, an off-screen window is pulled back on-screen, and an empty display list returns nil.

Verification

  • New unit tests pass; the existing window-geometry tests still pass.
  • Verified end to end on a Mac mini reached from a MacBook via macOS Screen Sharing: as the viewer (virtual display) resized between 3440x1440, 1920x1080 and 1512x883, the window refit to stay fully on-screen each time instead of staying pinned to an edge.

Related: #2666, #2824, #2667 (same area: window geometry on display configuration change).


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.


Summary by cubic

Fixes main window not refitting after display changes (Screen Sharing, resolution changes, display connect/disconnect). Windows now clamp to the visible screen so they stay fully on-screen.

  • Bug Fixes
    • Observe NSApplication.didChangeScreenParametersNotification and run one coalesced reconcile pass after changes.
    • Refit each main window to its display’s visible frame using currentDisplayGeometries, display(for:in:), and clampFrame (no-op if it already fits).
    • Handle off-screen and disappeared-display cases; enforce minimum window size. Added AppDelegate.reconciledFrameAfterScreenChange with unit tests for oversized, already-fitting, off-screen, and no-display scenarios.
    • Snapshot mainWindowContexts before iterating to avoid mutate-during-enumeration crashes, and skip redundant setFrame calls when the frame is unchanged.

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

Review in cubic

Summary by CodeRabbit

  • New Features

    • Improved window handling when display geometry changes: windows are automatically repositioned and resized into visible areas after monitor connects, disconnects, or resolution changes.
  • Tests

    • Added tests covering window frame reconciliation across display changes to ensure stable, idempotent behavior.

cmux main windows set `isMovable = false` for the custom titlebar drag
handling (WindowDragHandleView / configureCmuxMainWindowDragBehavior). A
non-movable NSWindow is excluded from AppKit's automatic on-screen
constraining when the display configuration changes, so after a resolution
change, a display connect/disconnect, or a Screen Sharing virtual display
being resized, the system leaves the window where it was. A window sized for
the previous geometry then overflows the new screen and stays pinned to an
edge, while every ordinary (movable) app refits.

Observe NSApplication.didChangeScreenParametersNotification and reconcile
each registered main window onto the visible frame of the display it now
belongs to, reusing the existing geometry helpers (currentDisplayGeometries,
display(for:in:), clampFrame). The pass is a no-op when a window already
fits, so windows on unaffected displays are untouched; notifications are
coalesced so one reconfiguration triggers one pass.

Adds Swift Testing coverage for the pure reconciliation decision.
@vercel

vercel Bot commented Jun 2, 2026

Copy link
Copy Markdown

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

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 2, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

AppDelegate now observes macOS screen-geometry changes and reconciles main-window frames to respect display bounds. When screen parameters change, a coalesced deferred pass iterates all tracked windows, selecting the appropriate target display and computing clamped or remapped frames while preserving frames that already fit. Comprehensive tests validate oversized/displaced windows, fitting windows, and nil-display edge cases.

Changes

Screen Parameter Reconciliation

Layer / File(s) Summary
Notification observer and handler registration
Sources/AppDelegate.swift
AppDelegate registers an observer in applicationDidFinishLaunching for NSApplication.didChangeScreenParametersNotification, enabling the screen-change handler to be invoked whenever display geometry changes.
Coalescing notification handler
Sources/AppDelegate.swift
Adds a coalescing token and an @objc notification handler that debounces bursts of screen-parameter notifications and schedules reconciliation.
Frame reconciliation pass
Sources/AppDelegate.swift
Snapshots mainWindowContexts, resolves each main window, computes a reconciled frame constrained to the chosen display's visibleFrame, skips approximately-equal updates, and applies frames via setFrame(display: true).
Display selection & clamping helper
Sources/AppDelegate.swift
Chooses target display by ID/overlap/proximity and clamps the window frame within the display's visibleFrame, returning nil when no target exists.
Approximate-geometry comparison
Sources/AppDelegate.swift
Compares two CGRects with per-component tolerances to avoid redundant frame updates when geometry is effectively unchanged.
Screen reconciliation tests
cmuxTests/SessionPersistenceTests.swift
Adds import Testing and ScreenParameterReconciliationTests suite validating reconciliation outcomes: oversized windows are resized/moved, fitting windows remain unchanged, orphaned windows are pulled to available displays, and nil when no displays exist.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 When screens dance and pixels rearrange,
Our windows find their perfect place—
No more astray beyond the edge!
A frame that fits with gentle grace,
Through tests we trust this screen-dance's embrace.


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error Line 3271 of AppDelegate.swift introduces DispatchQueue.main.asyncAfter(0.1s) in production code, violating swift-blocking-runtime.md which treats asyncAfter as default failure in shipped code. Replace asyncAfter debounce with event-driven synchronization using NSScreen notifications or state transitions instead of delayed dispatch.
Docstring Coverage ⚠️ Warning Docstring coverage is 41.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (16 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed AppDelegate is explicitly @MainActor; new static utility methods correctly marked nonisolated; mutable state properly isolated. All changes follow approved patterns; tests are test-only.
Cmux No Hacky Sleeps ✅ Passed Check applies to non-Swift runtime changes. PR's substantive changes are in Swift (AppDelegate.swift). Swift timing issues are covered by swift-blocking-runtime check, not runtime-no-hacky-sleeps.
Cmux Algorithmic Complexity ✅ Passed Iterates over 2-10 windows and 1-6 displays. O(W×D) with small fixed constants. No nested loops over scalable collections or repeated scans. Passes complexity rules.
Cmux Swift Concurrency ✅ Passed The PR introduces DispatchQueue.main.asyncAfter for debouncing NSApplication.didChangeScreenParametersNotification, which is an allowed AppKit callback boundary case per the modernization rules.
Cmux Swift @Concurrent ✅ Passed All new functions are synchronous. Pure helpers marked nonisolated static. Main-actor work intentionally stays on main. No invalid @concurrent on sync functions.
Cmux Swift File And Package Boundaries ✅ Passed PR adds only 115 lines to AppDelegate as focused bug fix with clear extraction path; well below 250-line threshold triggering failure. Geometry logic is pure/testable.
Cmux Swift Logging ✅ Passed No logging violations found. Debug-only log guarded by #if DEBUG contains no sensitive data; no production NSLog/print/dump statements, file logging, or MainActor Logger issues.
Cmux User-Facing Error Privacy ✅ Passed PR contains no user-facing errors, alerts, or sensitive information. All code is internal geometry logic with debug logs properly wrapped in #if DEBUG.
Cmux Full Internationalization ✅ Passed PR adds internal screen parameter reconciliation logic with no user-facing strings; all new code is operational geometry logic with no UI text, localization calls, or changes to string catalogs.
Cmux Swiftui State Layout ✅ Passed PR adds AppKit-only screen parameter reconciliation to AppDelegate and unit tests. No SwiftUI state patterns (@State, @Observable, @Published, GeometryReader) are introduced.
Cmux Architecture Rethink ✅ Passed Required platform bridge. Non-movable windows skip AppKit auto-constraining on display change. Observer + idempotent reconciliation using existing helpers with clear invariants.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds screen reconciliation for existing main windows only—no new NSWindow/NSPanel/NSWindowController/SwiftUI Window/WindowGroup created; no cmux.* identifiers added.
Title check ✅ Passed The PR title clearly and accurately summarizes the main change: fixing window repositioning/resizing after display reconfigurations.
Description check ✅ Passed The PR description comprehensively covers the problem, root cause, fix, tests, and verification. It follows the repository template with Problem/Root cause/Fix sections, includes testing details, and verification steps, though it lacks an explicit Demo Video URL.
✨ 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.

@greptile-apps

greptile-apps Bot commented Jun 2, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes the long-standing bug where cmux main windows (which set isMovable = false for custom drag handling) are excluded from AppKit's automatic display-geometry constraining, leaving them mispositioned after a display reconfiguration such as Screen Sharing resize, resolution change, or display connect/disconnect.

  • AppDelegate.swift: Observes NSApplication.didChangeScreenParametersNotification, coalesces the burst of notifications with an incrementing token and a short asyncAfter, then iterates mainWindowContexts and calls window.setFrame only when reconciledFrameAfterScreenChange produces a frame that differs from the current one by more than 0.5 pts.
  • New pure helpers: reconciledFrameAfterScreenChange (nonisolated static, testable without a live window) and framesAreApproximatelyEqual reuse the existing geometry stack (display(for:in:), clampFrame) to locate the target display by ID then by overlap/proximity, and clamp the window frame to its visible frame.
  • Tests: Four Swift Testing cases in SessionPersistenceTests.swift cover the oversized-window refit, already-fitting no-op, off-screen pull-back, and empty-display nil return.

Confidence Score: 5/5

Safe to merge — the reconcile pass is idempotent and runs only when NSScreen geometry actually changes; windows that already fit are untouched.

The core reconcile logic is a well-guarded, idempotent pass over mainWindowContexts: it only calls setFrame when the clamped result differs from the current frame. The pure geometry helpers are covered by four targeted unit tests. The coalescing token prevents double execution within a notification burst. The only observation is a style concern about adding pure geometry helpers to an already very large AppDelegate rather than a bug or behavioral regression.

Sources/AppDelegate.swift — the growing collection of nonisolated static geometry helpers now exceeds 3600 lines in the file and would benefit from extraction to a dedicated geometry module.

Important Files Changed

Filename Overview
Sources/AppDelegate.swift Adds screen-parameter notification observer, coalescing reconcile handler, and pure geometry helpers to refit windows after display reconfiguration. Logic is correct and well-guarded; new code lands in an already very large file (3600+ lines).
cmuxTests/SessionPersistenceTests.swift Adds a Swift Testing suite with four unit tests covering oversized-window refit, already-fitting window no-op, off-screen pull-back, and empty-display nil-return for the new pure geometry function.

Sequence Diagram

sequenceDiagram
    participant macOS
    participant AppDelegate
    participant NSWindow

    macOS->>AppDelegate: didChangeScreenParametersNotification (burst)
    Note over AppDelegate: screenParametersReconcileToken &+= 1
    macOS->>AppDelegate: didChangeScreenParametersNotification (burst)
    Note over AppDelegate: token incremented again

    Note over AppDelegate: 0.1s elapses, only latest token survives
    AppDelegate->>AppDelegate: reconcileMainWindowFramesAfterScreenChange()
    loop each mainWindowContext
        AppDelegate->>NSWindow: window.frame, window.screen?.cmuxDisplayID
        AppDelegate->>AppDelegate: reconciledFrameAfterScreenChange()
        alt "frame changed by >0.5pts"
            AppDelegate->>NSWindow: setFrame(reconciledFrame, display: true)
        else already fits
            Note over AppDelegate: no-op
        end
    end
Loading

Reviews (2): Last reviewed commit: "Address review: snapshot window contexts..." | Re-trigger Greptile

Comment thread Sources/AppDelegate.swift
Comment on lines +3267 to +3271
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self, self.screenParametersReconcileToken == token else { return }
self.reconcileMainWindowFramesAfterScreenChange()
}
}

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 Fixed timing delay used as geometry-settle synchronization

DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) is a wall-clock wait used to let NSScreen geometry settle after didChangeScreenParametersNotification fires — this is the pattern the cmux-swift-blocking-runtime rule flags as a failure even when the delay is small. The allowed exception in that rule is specifically for init-time / passive-callback deferrals used to avoid AppKit constraint-pass crashes with a documented reason; settling geometry is a different case.

The 0.1 s is also untested: on a heavily loaded Mac or an unusual display configuration the geometry might not yet be final, and the only signal the code can wait for is another notification that never comes. If a second reconfiguration races in slightly past the 100 ms window, the token comparison guards against double-execution, but the geometry read on whichever pass fires first may still be from an intermediate state.

The cmux-swift-blocking-runtime rule asks for "the real event that should replace it." Here the burst-coalescing token already handles multiple rapid firings correctly; the remaining issue is that NSApplication.didChangeScreenParametersNotification can precede the final NSScreen update. A runloop-based observation via CGDisplayRegisterReconfigurationCallback (which fires after reconfiguration is complete) would be the cleaner signal. At minimum the delay constant should be extracted and documented as a // FIXME: with the tracking issue, and an explicit comment noting that no synchronous AppKit finalization signal exists should be added per the rule's documentation requirement.

Rule Used: Flag new blocking or timing-based synchronization ... (source)

coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 2, 2026

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 3280-3281: The loop iterates mainWindowContexts.values while
calling resolvedWindow(for:), but resolvedWindow(for:) can mutate
mainWindowContexts causing mutation-during-enumeration crashes; fix by taking a
stable snapshot before iterating (e.g., capture Array(mainWindowContexts.values)
into a local variable) and iterate that snapshot when calling
resolvedWindow(for:) so mutations to mainWindowContexts won't affect the
enumeration; update the loop that references mainWindowContexts.values and
ensure resolvedWindow(for:) is still called with each snapped context.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 126d1d49-eac9-49ad-9f56-37fe1ecc0512

📥 Commits

Reviewing files that changed from the base of the PR and between 11a0778 and e30a668.

📒 Files selected for processing (2)
  • Sources/AppDelegate.swift
  • cmuxTests/SessionPersistenceTests.swift

Comment thread Sources/AppDelegate.swift Outdated

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

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread Sources/AppDelegate.swift Outdated
…ebounce

resolvedWindow(for:) can reindex mainWindowContexts, so iterating
mainWindowContexts.values directly risks a mutate-during-enumeration crash.
Snapshot into an Array first, matching the existing pattern used elsewhere in
the file. Also document the coalescing asyncAfter as a debounce rather than a
correctness dependency, and add docstrings to the reconcile pass and the
frame-equality helper.
@domsilvestre

Copy link
Copy Markdown
Author

Thanks for the reviews. Addressed in e7a8920:

  • Snapshot Array(mainWindowContexts.values) before iterating: resolvedWindow(for:) can reindex the dictionary, so this avoids the mutate-during-enumeration crash flagged by CodeRabbit and cubic. Matches the snapshot pattern already used elsewhere in the file.
  • Documented the 0.1s asyncAfter as a debounce, not a correctness dependency: the notification is posted after NSScreen updates, the reconcile re-runs on every notification and is idempotent, so the window ends up correct regardless of the exact delay.
  • Added docstrings to the reconcile pass and the frame-equality helper.

Verified locally: app builds and the 4 Swift Testing cases for reconciledFrameAfterScreenChange pass.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

Thanks for this! Windows refit after display reconfiguration landed on main in #6913. You opened this first, so you got there first. Closing since main covers it now.

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