Skip to content

Fix window collapsing to sliver after disconnecting external displays (#2666) - #2667

Closed
austinywang wants to merge 6 commits into
mainfrom
issue-2666-window-collapse-clamshell
Closed

austinywang wants to merge 6 commits into
mainfrom
issue-2666-window-collapse-clamshell

Conversation

@austinywang

@austinywang austinywang commented Apr 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • validate restored main-window frames against the current display set instead of treating any tiny intersection as usable
  • fall back to a centered primary-screen frame when a saved or live frame is below the minimum size or only leaves a narrow sliver visible
  • persist the last window geometry per display-configuration fingerprint and reconcile the primary window on screen-parameter changes
  • enforce contentMinSize / minSize and run restored frames back through constrainFrameRect(_:to:) before applying them
  • add regression coverage for sliver, undersized, and display-fingerprint restore behavior

Code paths changed

  • Sources/AppDelegate.swift
    • extended the persisted geometry payload with per-display-configuration entries
    • added an NSApplication.didChangeScreenParametersNotification observer to validate or restore live window frames after monitor changes
    • tightened resolvedWindowFrame(...) so bad intersections default to a sane centered frame instead of reopening as a sliver
    • enforced main-window minimum content/frame sizes before applying restored geometry
  • cmuxTests/SessionPersistenceTests.swift
    • added restore regression coverage for sliver visibility, undersized frames, and fingerprinted geometry lookup
  • Sources/Workspace.swift
    • removed stale tabTitleFontSize bonsplit calls so this branch builds against the current checked-out vendor/bonsplit API

Validation

  • did not run local tests (repo policy)
  • built successfully with ./scripts/reload.sh --tag issue-2666-window-collapse-clamshell

Note

Medium Risk
Changes core window restore/persistence logic and adds live reconciliation on display-configuration changes; mistakes could still place windows off-screen or persist incorrect geometry across monitor setups.

Overview
Fixes main-window restore and live monitor-change behavior to avoid windows reopening as thin off-screen slivers after docking/undocking.

Window geometry persistence is extended to store an LRU-capped set of per-display configuration entries keyed by a fingerprint, with all read/merge/write operations serialized on sessionPersistenceQueue to avoid lost updates. Restore logic now validates visibility per display, preserves legitimately spanning windows, and falls back to a centered default size when frames are undersized or only minimally visible, while enforcing minimum window sizes and re-constraining frames before applying.

Adds an NSApplication.didChangeScreenParametersNotification observer to reconcile window frames when displays change (preferring usable live frames, otherwise restoring the matching fingerprinted geometry), updates Workspace.applyGhosttyChrome to avoid unintended font-size changes, and adds extensive regression tests for these scenarios.

Reviewed by Cursor Bugbot for commit 8feca44. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Fixes the main window collapsing to a sliver after disconnecting or rearranging displays by validating visibility per display, preserving legitimate spanning windows, preferring the live frame only when it’s usable, and centering at a sane size when needed. Geometry saves/restores are keyed per-display configuration and serialized to avoid lost updates.

  • Bug Fixes
    • Serialize per-display geometry writes on sessionPersistenceQueue with read-merge-encode-write; unify snapshot and ad‑hoc writers; cap at 8 entries and keep the newest fingerprint.
    • Restore/reconcile picks the matching per‑display saved geometry for the current display fingerprint (fallback to the legacy entry if missing).
    • On screen changes, prefer the live frame only when it’s genuinely usable; otherwise restore from the matching saved geometry; skip mini/fullscreen and persist the primary frame only after a valid reconcile.
    • Validate visibility per display (not a union); preserve spanning and otherwise accessible frames; reject disjoint slivers; remap to the best display or center without inflating size; enforce min content/frame sizes and re-run constrainFrameRect.
    • Restore tab-title font size plumbing for bonsplit and add a background-only applyGhosttyChrome overload; add tests for sliver/undersize fallbacks, per‑display visibility and spanning, LRU eviction/recency, live-vs-persisted selection, and fingerprinted-geometry restores.

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

Summary by CodeRabbit

  • New Features

    • Remember window positions per-display-configuration (fingerprinting) with bounded per-fingerprint history.
  • Bug Fixes

    • Prevent lost/overwritten window-position updates during saves.
    • Live handling of screen/layout changes to keep windows visible and choose the best restore frame.
    • Stronger validation, minimum-size/clamping, and safer fallback centering/spanning preservation.
  • Tests

    • Added coverage for restore/fallback behavior, visibility rules, merging/eviction, and selection.

@vercel

vercel Bot commented Apr 7, 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 Apr 10, 2026 0:22am

@cubic-dev-ai

cubic-dev-ai Bot commented Apr 7, 2026

Copy link
Copy Markdown

This review could not be run because your cubic account has exceeded the monthly review limit. If you need help restoring access, please contact contact@cubic.dev.

@coderabbitai

coderabbitai Bot commented Apr 7, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Introduces a versioned, per-display persisted window-geometry payload with fingerprinted selection, serialized read-merge-write via a persistence queue to avoid lost updates, LRU-capped per-fingerprint storage, live screen-change reconciliation, centralized validated frame application, and accompanying tests plus a small Workspace API overload.

Changes

Cohort / File(s) Summary
Window Geometry Persistence & Validation
Sources/AppDelegate.swift
Add versioned PersistedWindowGeometry.StoredGeometry with displayConfigurations: [String: StoredGeometry]?, compute/store displayConfigurationFingerprint, prefer fingerprinted entry via persistedWindowGeometryEntry(...), capture fingerprint on main actor, defer read/merge/write through writePersistedWindowGeometry and PendingGeometryWrite, add mergedDisplayConfigurations(...) with LRU eviction (maxStoredDisplayConfigurations = 8), add DispatchSpecificKey reentrancy handling for persistence queue, and consolidate frame validation/application (min-size, visibility, spanning preservation, fallback frames).
Screen-parameter Change Handling & Live Reconciliation
Sources/AppDelegate.swift
Install NSApplication.didChangeScreenParametersNotification observer; on change select preferred primary main window, compute resolvedWindowFrameForScreenParameterChange, apply via centralized validator (applyValidatedMainWindowFrame(...)), skip miniaturized/fullscreen windows, and persist updated geometry when applicable.
Frame Resolution & Helpers
Sources/AppDelegate.swift
Standardize restored coordinates, add hasSufficientVisibleFrame(...), shouldPreserveSpanningFrame(...), bestIntersectingDisplay(...), intersectsAnyDisplay(...), fallbackFrameForInvalidRestore(...), enforce minimum visible restored size and default sizing constants, and replace direct setFrame calls with validated application across flows.
Session Persistence Tests
cmuxTests/SessionPersistenceTests.swift
Adjust expected fallback geometry and add tests for resolvedWindowFrame variants (centering, slivers, undersized), resolvedWindowFrameForScreenParameterChange, spanning-preservation cases, hasSufficientVisibleFrame behavior, shouldPreserveSpanningFrame, and mergedDisplayConfigurations eviction/recency and persistedWindowGeometryEntry fingerprint selection.
Workspace Ghostty Chrome
Sources/Workspace.swift
Refactor applyGhosttyChrome to use internal helper overloads; add public overload applyGhosttyChrome(backgroundColor:backgroundOpacity:reason:) that leaves tab-title font size unchanged; update no-op detection and conditional logging.

Sequence Diagram

sequenceDiagram
    participant User as User
    participant App as Application
    participant Observer as ScreenObserver
    participant Queue as PersistQueue
    participant Storage as Storage

    User->>Observer: Display parameters change
    Observer->>App: didChangeScreenParametersNotification
    App->>App: Select preferred primary window
    App->>App: Compute resolved & validated frame
    App->>App: Apply validated frame to window
    App->>App: Compute displayConfigurationFingerprint
    App->>Queue: Enqueue PendingGeometryWrite (fingerprint + geometry)
    Queue->>Storage: Read existing persisted geometry
    Queue->>Queue: Merge displayConfigurations, evict LRU if > max
    Queue->>Storage: Encode & write updated persisted geometry
    Storage-->>Queue: Write complete
    Queue-->>App: Persistence complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰
I hop through frames with gentle care,
Fingerprints find each window's lair.
I queue the writes and evict the old,
Recenter, clamp, and keep the bold.
Hop—your windows land where they belong.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.51% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning PR description provides detailed summary, code paths, and validation; however, Testing and Demo Video sections are entirely missing. Add Testing section explaining how the changes were tested and what was verified. For UI/behavior changes, include a demo video link or note if no UI changes apply.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix window collapsing to sliver after disconnecting external displays' directly describes the main bug fix, which aligns with the PR's primary objective of validating window frames and preventing slivers when displays disconnect.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-2666-window-collapse-clamshell

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 Apr 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes window-collapsing-to-sliver after external display disconnect by adding per-display-configuration geometry keying (fingerprinted by display ID + frame dimensions), a didChangeScreenParametersNotification observer that reconciles live window frames, and sliver/undersized detection in resolvedWindowFrame that falls back to a safe centered default. The Workspace.swift change is a build fix removing a removed bonsplit API call.

  • P2: displayConfigurations dict in UserDefaults grows unboundedly — new fingerprint entries are added on every save but never pruned; consider capping to a fixed LRU limit (e.g. 8 entries).
  • P2: persistWindowGeometry(from: primaryWindow) fires unconditionally after the screen-parameter reconciliation loop, including when the primary window was miniaturized and skipped — writing its stale off-screen frame into the new display-config entry can reintroduce the sliver scenario on next launch.

Confidence Score: 4/5

Safe to merge; core fix is correct and well-tested, with two P2 edge cases that don't affect the primary bug-fix path

The sliver-detection and per-display-config fingerprinting logic is sound and covered by behavioral regression tests. Both P2 findings are latent rather than regressions: unbounded dict growth is a long-horizon concern, and the miniaturized-frame overwrite requires a specific sequence (miniaturize → disconnect display → reconnect → restart) to trigger.

Sources/AppDelegate.swift — encodedPersistedWindowGeometryData (dict growth, lines ~3819–3830) and the tail of handleScreenParametersDidChange (unconditional persist of possibly-stale frame, lines ~3911–3913)

Important Files Changed

Filename Overview
Sources/AppDelegate.swift Core window-frame restore and reconciliation logic; adds sliver/undersized safety and per-display-config geometry keying, with two P2 edge cases around displayConfigurations growth and miniaturized-frame persistence during screen-parameter changes
Sources/Workspace.swift Removes stale tabTitleFontSize bonsplit API calls and dead fontSizeChanged/isNoOp logic to build cleanly against current vendor/bonsplit API
cmuxTests/SessionPersistenceTests.swift Adds well-targeted runtime regression tests for sliver, undersized, and per-display-config geometry lookup — all exercise observable behavior through public static APIs, consistent with the project test quality policy

Sequence Diagram

sequenceDiagram
    participant NS as NSApp
    participant AD as AppDelegate
    participant UD as UserDefaults
    participant W as NSWindow

    Note over AD: Startup restore
    AD->>UD: persistedWindowGeometry()
    AD->>AD: displayConfigurationFingerprint(current displays)
    AD->>AD: persistedWindowGeometryEntry(matchingOnly:false)
    AD->>AD: resolvedWindowFrame(frame, display, availableDisplays)
    AD->>W: applyValidatedMainWindowFrame(_:to:display:)

    Note over NS,AD: Display connect / disconnect
    NS-->>AD: didChangeScreenParametersNotification
    AD->>AD: handleScreenParametersDidChange()
    AD->>UD: persistedWindowGeometry()
    AD->>AD: displayConfigurationFingerprint(new displays)
    AD->>AD: persistedWindowGeometryEntry(matchingOnly:true)
    loop each mainWindowContext
        AD->>W: shouldReconcileMainWindowFrameOnScreenParameterChange?
        alt primary window + matching persisted geometry
            AD->>AD: resolvedWindowFrame(persistedFrame)
        else other / non-reconcilable window
            AD->>AD: resolvedWindowFrame(liveFrame, display:nil)
        end
        AD->>W: applyValidatedMainWindowFrame(_:to:display:)
    end
    AD->>UD: persistWindowGeometry(from: primaryWindow)
Loading

Reviews (1): Last reviewed commit: "Fix window restore after display collaps..." | Re-trigger Greptile

Comment thread Sources/AppDelegate.swift Outdated
Comment thread Sources/AppDelegate.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.

Actionable comments posted: 2

🧹 Nitpick comments (2)
Sources/Workspace.swift (1)

6793-6848: Collapse the config-based overload into the raw-value overload.

Now that the tab-title font path is gone, both applyGhosttyChrome overloads do the same background-hex / no-op / logging update flow. Routing the GhosttyConfig variant through the raw-color variant would remove an easy drift point.

♻️ Suggested simplification
 func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") {
-    let nextHex = Self.bonsplitChromeHex(
-        backgroundColor: config.backgroundColor,
-        backgroundOpacity: config.backgroundOpacity
-    )
-    let currentAppearance = bonsplitController.configuration.appearance
-    let currentBackgroundHex = currentAppearance.chromeColors.backgroundHex
-    let backgroundChanged = currentBackgroundHex != nextHex
-    let isNoOp = !backgroundChanged
-
-    if GhosttyApp.shared.backgroundLogEnabled {
-        GhosttyApp.shared.logBackground(
-            "theme apply workspace=\(id.uuidString) reason=\(reason) " +
-            "currentBg=\(currentBackgroundHex ?? "nil") nextBg=\(nextHex) " +
-            "noop=\(isNoOp)"
-        )
-    }
-
-    guard !isNoOp else { return }
-
-    if backgroundChanged {
-        bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex
-    }
-
-    if GhosttyApp.shared.backgroundLogEnabled {
-        GhosttyApp.shared.logBackground(
-            "theme applied workspace=\(id.uuidString) reason=\(reason) " +
-            "resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")"
-        )
-    }
+    applyGhosttyChrome(
+        backgroundColor: config.backgroundColor,
+        backgroundOpacity: config.backgroundOpacity,
+        reason: reason
+    )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 6793 - 6848, The config-based overload
applyGhosttyChrome(from config: GhosttyConfig, reason: String) duplicates the
raw-value overload; replace its body with a single call to the raw overload so
there is one canonical implementation. Concretely, in
applyGhosttyChrome(from:reason:) call applyGhosttyChrome(backgroundColor:
config.backgroundColor, backgroundOpacity: config.backgroundOpacity, reason:
reason) and remove the duplicate backgroundHex/no-op/logging logic from the
config overload so all behavior (including
GhosttyApp.shared.backgroundLogEnabled logging and the early return on no-op) is
performed only by applyGhosttyChrome(backgroundColor:backgroundOpacity:reason:).
cmuxTests/SessionPersistenceTests.swift (1)

794-836: Consider asserting fallback geometry on fingerprint miss (non-matchingOnly path).

You already verify the strict matchingOnly: true miss case; adding the default miss-path assertion would lock in fallback behavior and prevent regressions.

Proposed test addition
         XCTAssertNil(
             AppDelegate.persistedWindowGeometryEntry(
                 from: payload,
                 displayConfigurationFingerprint: "missing-fingerprint",
                 matchingOnly: true
             )
         )
+        let fallbackResolved = AppDelegate.persistedWindowGeometryEntry(
+            from: payload,
+            displayConfigurationFingerprint: "missing-fingerprint"
+        )
+        XCTAssertEqual(fallbackResolved?.frame.x, fallbackGeometry.x, accuracy: 0.001)
+        XCTAssertEqual(fallbackResolved?.frame.y, fallbackGeometry.y, accuracy: 0.001)
+        XCTAssertEqual(fallbackResolved?.frame.width, fallbackGeometry.width, accuracy: 0.001)
+        XCTAssertEqual(fallbackResolved?.frame.height, fallbackGeometry.height, accuracy: 0.001)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/SessionPersistenceTests.swift` around lines 794 - 836, Add an
assertion that when persistedWindowGeometryEntry is called with a missing
fingerprint but without matchingOnly (the default path), it returns the
payload.frame fallback geometry; call
AppDelegate.persistedWindowGeometryEntry(from: payload,
displayConfigurationFingerprint: "missing-fingerprint") and assert the returned
entry's frame.x, frame.y, frame.width, and frame.height equal the
fallbackGeometry's corresponding values (with same accuracy checks already
used), so fallback behavior is locked in alongside the existing
matchingOnly:true nil check.
🤖 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 3797-3809: The persisted display-configuration fingerprint map is
read on the main actor (persistedWindowGeometry(...).displayConfigurations) but
written later on sessionPersistenceQueue, causing a lost-update race between
saveSessionSnapshot(...) and persistWindowGeometry(...); fix by moving the
read/merge/write into the same sessionPersistenceQueue write path: in
encodedPersistedWindowGeometryData / or in the code that currently calls
defaults.set(...), fetch the current persistedWindowGeometry from UserDefaults
on sessionPersistenceQueue, merge the new per-display entry into its
displayConfigurations map (preserving other entries), then write the merged data
back to defaults; alternatively, ensure all geometry writes (saveSessionSnapshot
and persistWindowGeometry) are dispatched to and serialized on
sessionPersistenceQueue so reads and merges happen at write time and cannot be
overwritten.
- Around line 3891-3896: The call to Self.resolvedWindowFrame(from:
SessionRectSnapshot(window.frame), display: nil, availableDisplays:
displays.available, fallbackDisplay: displays.fallback) uses the `display: nil`
path which collapses visibility to a single bounding box and can force spanning
windows into one monitor; update the logic so
`resolvedWindowFrame(from:display:availableDisplays:fallbackDisplay:)` (or the
caller) evaluates visibility per display: compute each display's intersection
with the saved frame (or the true union geometry), determine if the frame is
"sufficiently usable" on multiple displays (e.g. width/height above a threshold
on more than one display) and preserve the spanning frame in that case,
otherwise clamp to the single display's visibleFrame (or pass the specific
display instead of nil); apply the same per-display visibility fix for the other
call sites (the ranges noted: ~4256-4270 and ~4387-4411).

---

Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 794-836: Add an assertion that when persistedWindowGeometryEntry
is called with a missing fingerprint but without matchingOnly (the default
path), it returns the payload.frame fallback geometry; call
AppDelegate.persistedWindowGeometryEntry(from: payload,
displayConfigurationFingerprint: "missing-fingerprint") and assert the returned
entry's frame.x, frame.y, frame.width, and frame.height equal the
fallbackGeometry's corresponding values (with same accuracy checks already
used), so fallback behavior is locked in alongside the existing
matchingOnly:true nil check.

In `@Sources/Workspace.swift`:
- Around line 6793-6848: The config-based overload applyGhosttyChrome(from
config: GhosttyConfig, reason: String) duplicates the raw-value overload;
replace its body with a single call to the raw overload so there is one
canonical implementation. Concretely, in applyGhosttyChrome(from:reason:) call
applyGhosttyChrome(backgroundColor: config.backgroundColor, backgroundOpacity:
config.backgroundOpacity, reason: reason) and remove the duplicate
backgroundHex/no-op/logging logic from the config overload so all behavior
(including GhosttyApp.shared.backgroundLogEnabled logging and the early return
on no-op) is performed only by
applyGhosttyChrome(backgroundColor:backgroundOpacity:reason:).
🪄 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: 15d582fd-f17b-4e7e-b287-597acf636966

📥 Commits

Reviewing files that changed from the base of the PR and between 0bbaa48 and d3aec2f.

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

Comment thread Sources/AppDelegate.swift Outdated
Comment thread Sources/AppDelegate.swift Outdated
Lost-update race: persistWindowGeometry and saveSessionSnapshot both
wrote the displayConfigurations map with their own copies, racing on
UserDefaults. Both writers now go through sessionPersistenceQueue and
do read-merge-encode-write inside the queue (writePersistedWindowGeometry).

Per-display visibility: hasSufficientVisibleFrame previously unioned
intersections into a bbox, which falsely passed two disjoint slivers
across two displays. It now checks each display individually so a
window is only "reachable" if at least one display has a usable chunk.

LRU cap: bound the per-display geometry map at 8 entries so users
cycling through many docks/displays don't accumulate UserDefaults
indefinitely. The just-written fingerprint is always preserved.

Gate post-reconcile persist: handleScreenParametersDidChange now only
persists the primary window's frame after the new display set if it
actually reconciled the primary (skipping miniaturized/fullscreen
windows), so a stale off-screen frame can't get baked into the new
fingerprint.

Restore tab title font size plumbing: the prior commit removed the
surfaceTabBarFontSize wiring in Workspace.swift, silently reverting
PR #2645. The bonsplit API still exposes tabTitleFontSize, so the
plumbing is restored.

Add regression tests for the per-display visibility check and the
LRU eviction so the contracts are pinned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@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 (1)
Sources/AppDelegate.swift (1)

4336-4350: ⚠️ Potential issue | 🟠 Major

Preserve already-usable spanning frames here.

When displaySnapshot is nil and the live frame is already sufficiently visible on the current display set, this branch still picks bestIntersectingDisplay and clamps into that single visibleFrame. During NSApplication.didChangeScreenParametersNotification, that still collapses legitimate multi-monitor windows to one screen even though no fallback is needed.

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

In `@Sources/AppDelegate.swift` around lines 4336 - 4350, When displaySnapshot is
nil and hasSufficientVisibleFrame(...) already returns true for the current
frame, avoid forcing a single-display clamp by returning the original frame
unchanged instead of calling bestIntersectingDisplay(...) and clampFrame(...);
update the branch that currently calls bestIntersectingDisplay and clampFrame so
it first checks displaySnapshot == nil (or the appropriate sentinel) and if so
returns frame directly, otherwise proceed to pick intersecting display and clamp
as before.
🤖 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/AppDelegate.swift`:
- Around line 4336-4350: When displaySnapshot is nil and
hasSufficientVisibleFrame(...) already returns true for the current frame, avoid
forcing a single-display clamp by returning the original frame unchanged instead
of calling bestIntersectingDisplay(...) and clampFrame(...); update the branch
that currently calls bestIntersectingDisplay and clampFrame so it first checks
displaySnapshot == nil (or the appropriate sentinel) and if so returns frame
directly, otherwise proceed to pick intersecting display and clamp as before.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db854aa8-44ca-4364-8a8f-0d70ee4c1edc

📥 Commits

Reviewing files that changed from the base of the PR and between d3aec2f and 0ab2256.

📒 Files selected for processing (2)
  • Sources/AppDelegate.swift
  • cmuxTests/SessionPersistenceTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • cmuxTests/SessionPersistenceTests.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.

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

5143-5146: ⚠️ Potential issue | 🟠 Major

The quit-time “sync” save still bypasses the geometry-write serializer.

When synchronously is true, writeBlock() runs on the caller thread while persistWindowGeometry(...) continues to enqueue read/merge/write work on sessionPersistenceQueue. That reopens the same race during quit: a queued writer can merge against stale defaults and overwrite the geometry saved here. The synchronous path still needs to execute through sessionPersistenceQueue.

🛠️ Suggested change
-        if synchronously {
-            writeBlock()
-        } else {
-            sessionPersistenceQueue.async(execute: writeBlock)
-        }
+        if synchronously {
+            sessionPersistenceQueue.sync(execute: writeBlock)
+        } else {
+            sessionPersistenceQueue.async(execute: writeBlock)
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 5143 - 5146, persistWindowGeometry's
synchronous branch currently calls writeBlock() directly, which bypasses the
sessionPersistenceQueue and reintroduces a race; change the synchronous path to
run the writeBlock on sessionPersistenceQueue synchronously (e.g., use
sessionPersistenceQueue.sync { ... } or an equivalent barrier) so both
synchronous and asynchronous flows serialize via sessionPersistenceQueue,
ensuring writeBlock and any read/merge/write work are ordered; update references
in AppDelegate to call sessionPersistenceQueue.sync with writeBlock instead of
invoking writeBlock() directly.

4046-4052: ⚠️ Potential issue | 🟠 Major

Don't collapse preserved spanning frames back to one display.

resolvedWindowFrame(...) now intentionally preserves already-usable multi-display frames, but this branch always picks a single NSScreen and constrains the rect to it before setFrame. A legitimate window spanning two monitors will still get squeezed onto one screen during restore/reconciliation. The single-screen clamp needs to be skipped for frames that were preserved because they are already sufficiently usable across the current display set.

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

In `@Sources/AppDelegate.swift` around lines 4046 - 4052, The branch that calls
Self.screenForConstrainingFrame(...) then constrains targetFrame with
window.constrainFrameRect(...) always forces the rect onto a single NSScreen and
therefore collapses legitimately multi-display frames; change it to skip the
single-screen clamp when resolvedWindowFrame(...) indicated the frame was
preserved as a usable multi-display rect (i.e., only run
Self.screenForConstrainingFrame and the subsequent constrainFrameRect + min-size
adjustments when the frame was NOT the preserved multi-display frame). Locate
the code paths that produce or tag preserved multi-display frames (the
resolvedWindowFrame logic) and use that predicate/flag when deciding whether to
call Self.screenForConstrainingFrame, window.constrainFrameRect, and the
min-size re-clamp so restored spanning windows are left across displays.
🧹 Nitpick comments (2)
Sources/AppDelegate.swift (1)

3870-3893: This capped history isn't actually recency-based.

Updating an existing fingerprint doesn't refresh any recency information, and eviction just removes the first non-current entry. A recently reused display config can still disappear while an older one survives. If this history is meant to keep the most recent dock/undock layouts, track recency explicitly and evict the true least-recently-used entry.

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

In `@Sources/AppDelegate.swift` around lines 3870 - 3893, The
mergedDisplayConfigurations function currently treats the stored configurations
as an unordered set and evicts an arbitrary non-current key; instead make the
storage recency-aware by adding and updating a last-used timestamp or explicit
recency list whenever a fingerprint is created or reused (e.g. attach a
lastAccess/lastUsed property to PersistedWindowGeometry.StoredGeometry or
maintain a separate [String:Date] or ordered keys array), update that
timestamp/recency entry inside nonisolated static func
mergedDisplayConfigurations when fingerprint is added or found, and change the
eviction logic that uses maxStoredDisplayConfigurations to remove the entry with
the oldest lastAccess (the true LRU) rather than merged.keys.first to ensure
recently reused display configs are retained.
cmuxTests/SessionPersistenceTests.swift (1)

808-810: Fix typo and cap wording mismatch in test docs/identifier.

For clarity: Line 810 uses stradlingFrame (typo), and the comment at Line 853 says “one more than the cap” while the setup loop at Line 858 creates exactly cap entries before merge.

✏️ Proposed cleanup
-        // Pre-populate the map with one more than the cap, all under
+        // Pre-populate the map with exactly the cap entries, all under
         // distinct fingerprints. The newly-written fingerprint must survive;
         // the map size must be capped to maxStoredDisplayConfigurations.
         let cap = AppDelegate.maxStoredDisplayConfigurations
         var existing: [String: AppDelegate.PersistedWindowGeometry.StoredGeometry] = [:]
         for index in 0..<cap {
@@
-        let stradlingFrame = CGRect(x: 950, y: 200, width: 1_100, height: 600)
+        let straddlingFrame = CGRect(x: 950, y: 200, width: 1_100, height: 600)
@@
-                stradlingFrame,
+                straddlingFrame,

Also applies to: 853-859

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

In `@cmuxTests/SessionPersistenceTests.swift` around lines 808 - 810, Rename the
misspelled variable `stradlingFrame` to `straddlingFrame` everywhere it’s used
(e.g., in the test that constructs the frame and any assertions referencing it)
and update the nearby test comment that currently reads “one more than the cap”
to accurately describe the code path (the setup loop creates exactly `cap`
entries before the merge) — either change the comment to say “equal to the cap”
or, if the intent was to test cap+1 behavior, change the setup loop to create
`cap + 1` entries; keep all identifier references consistent (use
`straddlingFrame` and `cap`) so the test compiles and the docs match the logic.
🤖 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/AppDelegate.swift`:
- Around line 5143-5146: persistWindowGeometry's synchronous branch currently
calls writeBlock() directly, which bypasses the sessionPersistenceQueue and
reintroduces a race; change the synchronous path to run the writeBlock on
sessionPersistenceQueue synchronously (e.g., use sessionPersistenceQueue.sync {
... } or an equivalent barrier) so both synchronous and asynchronous flows
serialize via sessionPersistenceQueue, ensuring writeBlock and any
read/merge/write work are ordered; update references in AppDelegate to call
sessionPersistenceQueue.sync with writeBlock instead of invoking writeBlock()
directly.
- Around line 4046-4052: The branch that calls
Self.screenForConstrainingFrame(...) then constrains targetFrame with
window.constrainFrameRect(...) always forces the rect onto a single NSScreen and
therefore collapses legitimately multi-display frames; change it to skip the
single-screen clamp when resolvedWindowFrame(...) indicated the frame was
preserved as a usable multi-display rect (i.e., only run
Self.screenForConstrainingFrame and the subsequent constrainFrameRect + min-size
adjustments when the frame was NOT the preserved multi-display frame). Locate
the code paths that produce or tag preserved multi-display frames (the
resolvedWindowFrame logic) and use that predicate/flag when deciding whether to
call Self.screenForConstrainingFrame, window.constrainFrameRect, and the
min-size re-clamp so restored spanning windows are left across displays.

---

Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 808-810: Rename the misspelled variable `stradlingFrame` to
`straddlingFrame` everywhere it’s used (e.g., in the test that constructs the
frame and any assertions referencing it) and update the nearby test comment that
currently reads “one more than the cap” to accurately describe the code path
(the setup loop creates exactly `cap` entries before the merge) — either change
the comment to say “equal to the cap” or, if the intent was to test cap+1
behavior, change the setup loop to create `cap + 1` entries; keep all identifier
references consistent (use `straddlingFrame` and `cap`) so the test compiles and
the docs match the logic.

In `@Sources/AppDelegate.swift`:
- Around line 3870-3893: The mergedDisplayConfigurations function currently
treats the stored configurations as an unordered set and evicts an arbitrary
non-current key; instead make the storage recency-aware by adding and updating a
last-used timestamp or explicit recency list whenever a fingerprint is created
or reused (e.g. attach a lastAccess/lastUsed property to
PersistedWindowGeometry.StoredGeometry or maintain a separate [String:Date] or
ordered keys array), update that timestamp/recency entry inside nonisolated
static func mergedDisplayConfigurations when fingerprint is added or found, and
change the eviction logic that uses maxStoredDisplayConfigurations to remove the
entry with the oldest lastAccess (the true LRU) rather than merged.keys.first to
ensure recently reused display configs are retained.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9730c808-3c61-4533-9405-f84fdf2291a8

📥 Commits

Reviewing files that changed from the base of the PR and between 0ab2256 and 8e1861a.

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

Comment thread Sources/AppDelegate.swift
Comment thread Sources/AppDelegate.swift

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is kicking off a free cloud agent to fix this issue. This run is complimentary, but you can enable autofix for all future PRs in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1a1045a. Configure here.

Comment thread cmuxTests/SessionPersistenceTests.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.

Actionable comments posted: 2

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

6828-6830: Optional: extract the font delta epsilon into a named constant.

This would make the threshold easier to tune and keep comparisons consistent.

♻️ Proposed refactor
     private func applyGhosttyChrome(
         backgroundColor: NSColor,
         backgroundOpacity: Double,
         tabTitleFontSize: CGFloat?,
         reason: String
     ) {
+        let fontDeltaEpsilon: CGFloat = 0.0001
         let nextHex = Self.bonsplitChromeHex(
             backgroundColor: backgroundColor,
             backgroundOpacity: backgroundOpacity
         )
         let currentAppearance = bonsplitController.configuration.appearance
         let currentBackgroundHex = currentAppearance.chromeColors.backgroundHex
         let currentTabTitleFontSize = currentAppearance.tabTitleFontSize
         let backgroundChanged = currentBackgroundHex != nextHex
         let fontSizeChanged = tabTitleFontSize.map {
-            abs(currentTabTitleFontSize - $0) > 0.0001
+            abs(currentTabTitleFontSize - $0) > fontDeltaEpsilon
         } ?? false

Also applies to: 6850-6851

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

In `@Sources/Workspace.swift` around lines 6828 - 6830, Extract the magic
threshold used when comparing tab title font sizes into a named constant (e.g.,
fontSizeEpsilon) and use it in the comparisons that set fontSizeChanged (the
expression using tabTitleFontSize and currentTabTitleFontSize) and the other
similar comparison around the second occurrence; replace the literal 0.0001 with
the constant to centralize tuning and ensure both comparisons use the same
epsilon.
🤖 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/SessionPersistenceTests.swift`:
- Around line 924-925: The test currently uses
XCTAssertLessThanOrEqual(resolved.count, cap) which allows over-eviction to
pass; change the assertion to assert exact size by replacing that line with
XCTAssertEqual(resolved.count, cap) so the test fails if more than cap entries
were evicted (keep the subsequent XCTAssertNil(resolved["existing-0"]) check
as-is to verify the intended eviction target).

In `@Sources/AppDelegate.swift`:
- Around line 3967-3969: The loop iterates mainWindowContexts.values directly
which can crash if resolvedWindow(for:) mutates mainWindowContexts; fix by
snapshotting the collection before iterating (e.g., let contexts =
Array(mainWindowContexts.values)) and then iterate over that snapshot, using
resolvedWindow(for:) and
shouldReconcileMainWindowFrameOnScreenParameterChange(_:) against the snapshot
to avoid concurrent mutation during the loop.

---

Nitpick comments:
In `@Sources/Workspace.swift`:
- Around line 6828-6830: Extract the magic threshold used when comparing tab
title font sizes into a named constant (e.g., fontSizeEpsilon) and use it in the
comparisons that set fontSizeChanged (the expression using tabTitleFontSize and
currentTabTitleFontSize) and the other similar comparison around the second
occurrence; replace the literal 0.0001 with the constant to centralize tuning
and ensure both comparisons use the same epsilon.
🪄 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: 4074ef83-9bde-497c-9f72-253d4b6308d0

📥 Commits

Reviewing files that changed from the base of the PR and between 8e1861a and 1a1045a.

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

Comment thread cmuxTests/SessionPersistenceTests.swift Outdated
Comment thread Sources/AppDelegate.swift Outdated
@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

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

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants