Skip to content

Fix #1802, #1917: prevent duplicate/stray windows on display reconnect - #1914

Closed
elvistranhere wants to merge 4 commits into
manaflow-ai:mainfrom
elvistranhere:fix/duplicate-window-display-reconnect-1802
Closed

elvistranhere wants to merge 4 commits into
manaflow-ai:mainfrom
elvistranhere:fix/duplicate-window-display-reconnect-1802

Conversation

@elvistranhere

@elvistranhere elvistranhere commented Mar 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Disable macOS native window restoration (NSQuitAlwaysKeepsWindows, isRestorable) so unplugging/replugging an external monitor no longer spawns a duplicate window
  • Add a didChangeScreenParametersNotification observer to save a consistent session snapshot after display topology changes
  • Set isRestorable = false on all non-main windows (browser popups, import dialogs, debug panels) to prevent macOS from restoring any window type on display reconnect
  • The app manages its own session persistence via SessionPersistenceStore, so system-level restoration is unnecessary and was causing the duplicate

Test plan

  • Connect an external monitor via HDMI, open cmux
  • Unplug and replug the HDMI cable
  • Verify only one cmux window remains (no duplicate)
  • Open a browser popup (e.g. OAuth flow), disconnect/reconnect display, verify no stray popup windows
  • Verify normal multi-window (File > New Window) still works
  • Verify session restore on app relaunch still works

Fixes #1802
Also addresses #1917

Copilot AI review requested due to automatic review settings March 21, 2026 12:57
@vercel

vercel Bot commented Mar 21, 2026

Copy link
Copy Markdown

@elvistranhere 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 Mar 21, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Monitor screen-parameter changes and debounce session-snapshot saves; disable macOS automatic window restoration globally and per-window; cancel pending snapshot work on termination; mark various import/debug panels and main windows as non-restorable.

Changes

Cohort / File(s) Summary
AppDelegate — lifecycle & restoration
Sources/AppDelegate.swift
Added screenChangeSnapshotWorkItem state; register NSQuitAlwaysKeepsWindows = false; observe NSApplication.didChangeScreenParametersNotification and debounce 0.5s to call saveSessionSnapshot(includeScrollback: false) on main actor; cancel pending work on terminate; set window.isRestorable = false in registerMainWindow.
Browser import UI — disable restoration
Sources/Panels/BrowserPanel.swift
Set isRestorable = false on import wizard NSPanel and associated import/progress windows to prevent Cocoa window restoration during import flows.
Popup windows — disable restoration
Sources/Panels/BrowserPopupWindowController.swift
Set panel.isRestorable = false when creating popup NSPanel instances.
Debug/dev windows — disable restoration
Sources/cmuxApp.swift
Set window.isRestorable = false for several auxiliary/debug window controllers after disabling isReleasedWhenClosed.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application (AppDelegate)
    participant NC as NotificationCenter
    participant Deb as Debounce WorkItem
    participant SS as SessionSaver (main actor)

    App->>NC: register for didChangeScreenParametersNotification
    NC->>App: didChangeScreenParametersNotification
    App->>Deb: scheduleScreenChangeSnapshot() (create/cancel work item)
    Deb-->>Deb: debounce 0.5s
    Deb->>App: invoke (if not terminating)
    App->>SS: saveSessionSnapshot(includeScrollback: false) on main actor
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I twitch my whiskers, hop in place,
When displays dance and shift their space.
I wait a beat, then gently snap,
Cancel the ghosts, close duplicate gap.
One window stays — a tidy chase.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Code changes directly address the root cause in #1802 by disabling macOS window restoration and managing session persistence, preventing duplicate windows on external display reconnect.
Out of Scope Changes check ✅ Passed All changes are directly aligned with fixing the duplicate window issue: disabling window restoration globally and per-window, adding display-change snapshot handling, and marking all non-main windows non-restorable.
Title check ✅ Passed The title clearly and specifically summarizes the main objective: preventing duplicate/stray windows on display reconnection, which is the primary purpose of all code changes across multiple files.
Description check ✅ Passed The description covers the required Summary and Testing sections with clear explanations of what changed and why. However, Demo Video and Review Trigger sections are missing, and the checklist items are incomplete (only first three unchecked).

✏️ 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.

…econnect

Disable macOS native window restoration so that unplugging/replugging an
external monitor no longer spawns a second identical window. The app
already manages its own session persistence via SessionPersistenceStore,
so system-level restoration is unnecessary and actively harmful.

Three changes:
- Set NSQuitAlwaysKeepsWindows to false in applicationDidFinishLaunching
- Mark every registered window as isRestorable = false
- Observe didChangeScreenParametersNotification to save a consistent
  session snapshot after display topology changes
@elvistranhere
elvistranhere force-pushed the fix/duplicate-window-display-reconnect-1802 branch from 6fa5585 to ee54d85 Compare March 21, 2026 12:58

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

No issues found across 1 file


Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Add one-off context when rerunning by tagging @cubic-dev-ai with guidance or docs links (including llms.txt)
  • Ask questions if you need clarification on any suggestion

@greptile-apps

greptile-apps Bot commented Mar 21, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR addresses duplicate/stray window creation on external display reconnect (#1802, #1917) by disabling macOS system-level window restoration app-wide and supplementing the app's own SessionPersistenceStore with a debounced snapshot save on screen topology changes.

Key changes:

  • AppDelegate: uses UserDefaults.standard.register(defaults:) to opt out of NSQuitAlwaysKeepsWindows, adds a debounced didChangeScreenParametersNotification observer (addresses the rapid-fire notification burst concern from prior review), cancels the pending work item in applicationShouldTerminate, and marks the main window isRestorable = false
  • BrowserPopupWindowController + BrowserPanel: correctly sets isRestorable = false on OAuth popup and import dialog windows
  • cmuxApp.swift: sets isRestorable = false on all debug window controllers — but misses three production windows: AboutWindowController, AcknowledgmentsWindowController, and SettingsWindowController, leaving them still susceptible to the spurious restoration the PR aims to eliminate

Confidence Score: 4/5

  • Safe to merge after adding isRestorable = false to the three missed production windows.
  • The core fix is correct and well-implemented — debouncing is in place, termination handling is solid, and the approach of disabling system restoration in favour of the app's own SessionPersistenceStore is sound. The one concrete gap is that AboutWindowController, AcknowledgmentsWindowController, and SettingsWindowController were not given isRestorable = false, which is inconsistent with the PR's stated goal. These are real windows (not debug-only) and the omission is a one-line fix per window, so it's a targeted P1 with a trivial resolution rather than a deep structural problem.
  • Sources/cmuxApp.swift — three production windows (AboutWindowController, AcknowledgmentsWindowController, SettingsWindowController) are missing isRestorable = false.

Important Files Changed

Filename Overview
Sources/AppDelegate.swift Adds debounced didChangeScreenParametersNotification observer to save a session snapshot after display topology changes, cancels the work item on termination, uses register(defaults:) for NSQuitAlwaysKeepsWindows, and marks the main window as non-restorable. Implementation is sound; previous review concerns about debouncing are addressed.
Sources/cmuxApp.swift Adds isRestorable = false to all debug window controllers, but misses three production windows: AboutWindowController, AcknowledgmentsWindowController, and SettingsWindowController.
Sources/Panels/BrowserPanel.swift Correctly adds isRestorable = false to both the import dialog panel and the import progress window in BrowserDataImportCoordinator.
Sources/Panels/BrowserPopupWindowController.swift Correctly adds isRestorable = false to the browser popup panel, preventing stray OAuth/popup windows after display reconnect.

Sequence Diagram

sequenceDiagram
    participant macOS
    participant AppDelegate
    participant DispatchQueue
    participant SessionPersistenceStore

    macOS->>AppDelegate: didChangeScreenParametersNotification
    AppDelegate->>AppDelegate: scheduleScreenChangeSnapshot()
    AppDelegate->>AppDelegate: cancel previous DispatchWorkItem
    AppDelegate->>DispatchQueue: asyncAfter(0.5s, workItem)

    Note over macOS,AppDelegate: Notification may fire 5-10x during single event

    macOS->>AppDelegate: didChangeScreenParametersNotification (again)
    AppDelegate->>AppDelegate: scheduleScreenChangeSnapshot()
    AppDelegate->>AppDelegate: cancel previous DispatchWorkItem ✓
    AppDelegate->>DispatchQueue: asyncAfter(0.5s, new workItem)

    DispatchQueue->>AppDelegate: workItem fires (debounced)
    AppDelegate->>SessionPersistenceStore: saveSessionSnapshot(includeScrollback: false)

    Note over AppDelegate,SessionPersistenceStore: On termination: workItem cancelled,<br/>full snapshot saved synchronously
Loading

Comments Outside Diff (1)

  1. Sources/cmuxApp.swift, line 2516-2601 (link)

    P1 Three non-debug windows missing isRestorable = false

    The PR description says it will set isRestorable = false on all non-main windows, but AboutWindowController (line 2516), AcknowledgmentsWindowController (line 2549), and SettingsWindowController (line 2601) were each missed. These are production-facing windows with non-trivial UI, and they carry the same risk of being spuriously restored by macOS after a display reconnect event that motivated all the other isRestorable = false additions.

    // AboutWindowController
    window.isReleasedWhenClosed = false
    window.isRestorable = false   // add this
    window.identifier = ...
    
    // AcknowledgmentsWindowController
    window.isReleasedWhenClosed = false
    window.isRestorable = false   // add this
    window.title = ...
    
    // SettingsWindowController
    window.isReleasedWhenClosed = false
    window.isRestorable = false   // add this
    window.identifier = ...

Last reviewed commit: "Set isRestorable = f..."

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

Copilot AI 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.

Pull request overview

Prevents macOS from creating duplicate cmux windows when an external display is disconnected/reconnected by disabling native window restoration and ensuring the app persists a consistent session snapshot after display-topology changes.

Changes:

  • Disable macOS “reopen/restoration” behavior via NSQuitAlwaysKeepsWindows.
  • Mark main windows as non-restorable (window.isRestorable = false).
  • Observe NSApplication.didChangeScreenParametersNotification to persist a fresh session snapshot after display changes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Sources/AppDelegate.swift Outdated
Comment thread Sources/AppDelegate.swift
Comment thread Sources/AppDelegate.swift
…rver

- Use UserDefaults.register(defaults:) instead of .set to avoid
  overwriting user-level preferences
- Debounce didChangeScreenParametersNotification with 0.5s coalescing
  since it can fire many times during a single display reconfiguration

@elvistranhere elvistranhere left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed all feedback — switched to register(defaults:) and added 0.5s debounce on the screen change observer. Regarding the regression test for isRestorable: per the project's test policy, tests must verify observable runtime behavior, not property assertions — so skipping a property-check test here.

@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 the current code and only fix it if needed.

Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 3315-3323: The screen-change observer calls
saveSessionSnapshot(includeScrollback: false) asynchronously and can race with
termination writes; modify the observer/termination flow so session persistence
writes are serialized and the final quit snapshot cannot be clobbered by a
queued screen-change save: route termination writes through
sessionPersistenceQueue.sync (or add a write-generation/cancellation token
checked by saveSessionSnapshot) and ensure saveSessionSnapshot consults that
generation/token (or the queue) before performing its write; reference
saveSessionSnapshot(includeScrollback:), sessionPersistenceQueue,
isTerminatingApp and the NotificationCenter observer so the display-change
handler either performs its save on sessionPersistenceQueue synchronously or is
canceled/ignored when a termination write is in-flight.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f105ca60-ab3c-42d3-ba3b-693a3a10fe26

📥 Commits

Reviewing files that changed from the base of the PR and between 6ff8157 and ee54d85.

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

Comment thread Sources/AppDelegate.swift
Ensures the debounced screen-change snapshot cannot race with the final
quit snapshot by explicitly cancelling the work item in
applicationShouldTerminate.

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

2647-2649: ⚠️ Potential issue | 🟠 Major

Queued screen-change saves can still overwrite the final quit snapshot.

Line 3336 still goes through the async sessionPersistenceQueue path. If that debounced save fires just before Line 2648, canceling the work item no longer helps, and the older/no-scrollback write can run after the synchronous quit save. Drain or serialize sessionPersistenceQueue before every terminating save, or gate async writes with a generation/token so the final snapshot always wins.

Also applies to: 3331-3340

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

In `@Sources/AppDelegate.swift` around lines 2647 - 2649, Queued async saves can
still race and overwrite the synchronous final quit snapshot; ensure the
terminating save always wins by either draining/serializing
sessionPersistenceQueue before calling saveSessionSnapshot or by adding a
generation/token guard to all async writes so only the latest generation is
persisted. Update the shutdown path where isTerminatingApp is set and
screenChangeSnapshotWorkItem is cancelled to: flush or await completion of
sessionPersistenceQueue (or acquire its serial lock) before calling _ =
saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false), or
implement and check a persistenceGeneration token in saveSessionSnapshot and the
sessionPersistenceQueue task submissions (increment at termination) so queued
tasks skip older generations.
🤖 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 2647-2649: Queued async saves can still race and overwrite the
synchronous final quit snapshot; ensure the terminating save always wins by
either draining/serializing sessionPersistenceQueue before calling
saveSessionSnapshot or by adding a generation/token guard to all async writes so
only the latest generation is persisted. Update the shutdown path where
isTerminatingApp is set and screenChangeSnapshotWorkItem is cancelled to: flush
or await completion of sessionPersistenceQueue (or acquire its serial lock)
before calling _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty:
false), or implement and check a persistenceGeneration token in
saveSessionSnapshot and the sessionPersistenceQueue task submissions (increment
at termination) so queued tasks skip older generations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6ea0d6cc-86e2-4e03-89ae-89450c6c174f

📥 Commits

Reviewing files that changed from the base of the PR and between ee54d85 and b564761.

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

@elvistranhere

elvistranhere commented Mar 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Confirmed this issue occurs in the nightly build. Duplicate windows appear when an external display is disconnected and reconnected.

@elvistranhere

elvistranhere commented Mar 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Related issues filed for remaining window-spawning bugs

This PR fixes the main window duplication on display reconnect (#1802), but investigation found additional related issues:

Popup panels, import wizard/progress dialogs, and debug windows
were never marked non-restorable, allowing macOS to recreate them
as blank/duplicate windows on display reconnect or wake from sleep.
@elvistranhere
elvistranhere requested a review from Copilot March 21, 2026 13:45
@elvistranhere elvistranhere changed the title Fix #1802: prevent duplicate windows on external display reconnect Fix #1802, #1917: prevent duplicate/stray windows on display reconnect Mar 21, 2026

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@elvistranhere

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@elvistranhere

Copy link
Copy Markdown
Contributor Author

@greptileai review

@elvistranhere

Copy link
Copy Markdown
Contributor Author

@copilot review

@coderabbitai

coderabbitai Bot commented Mar 21, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@MaxiAschenbrenner

Copy link
Copy Markdown
Contributor

@elvistranhere Just wanted to say thanks for this PR, had the same issue and was about to dive into the code when i found it, can confirm it fixed the issue 🙏

@elvistranhere

Copy link
Copy Markdown
Contributor Author

@elvistranhere Just wanted to say thanks for this PR, had the same issue and was about to dive into the code when i found it, can confirm it fixed the issue 🙏

thanks! @lawrencecchen love to get this resolved

@msjaber

msjaber commented Apr 14, 2026

Copy link
Copy Markdown

@elvistranhere I've been struggling with this bug, thanks for fixing it. @lawrencecchen looking to get it resolved!

@teamleaderleo

Copy link
Copy Markdown
Collaborator

Thanks for this! The duplicate-window-on-reconnect bug is fixed (SwiftUI window restore is off) landed on main in #3164. You opened this first, so you got there first. Closing since main covers it now.

@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 26, 2026
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.

one cmux window is displayed on multiple desktops.

5 participants