fix(desktop): preserve main window bounds#3851

merged13
main
GitHub fork: t3code/preserve-macos-window-size
openedJul 10, 2026byAani
View on GitHubTwo-way sync enabledPR last imported 20d ago

What Changed

Persist the desktop main window position and size across launches.

Move and resize updates are debounced. Maximized windows save their normal bounds, and fullscreen bounds are ignored. Saved bounds are restored only when the complete rectangle fits within a currently connected display; otherwise, the app uses the existing 1100 x 780 default size and lets Electron choose the position.

Why

The desktop app currently creates every main window with fixed dimensions and no persisted position, so restarting resets the user's window layout.

Fixes #1829.

Verification

  • 344 desktop tests passed
  • vp run typecheck
  • vp check
  • Regression coverage includes minimized Windows bounds and shutdown write flushing
  • Manually tested size and position restoration after a clean quit and relaunch on a physical macOS device

Checklist

  • This PR is small and focused
  • I explained what changed and why

Note

Preserve and restore main window bounds and sidebar width across sessions

  • The desktop app now saves the main window's position, size, and maximized state to settings on move/resize/maximize/unmaximize/close (with a 500ms debounce) and restores them on next launch if they fit within a connected display.
  • A new flushMainWindowBounds method on DesktopWindow is called during shutdown to ensure bounds are persisted before the app exits.
  • The web sidebar width is restored from local storage on load, clamped to valid min/max bounds, and the CSS variable is hydrated via useLayoutEffect to avoid a flash of the default width.
  • resolveInitialMainWindowBounds falls back to DEFAULT_MAIN_WINDOW_SIZE when persisted bounds don't fit any connected display, and logs a warning when display detection fails.

Macroscope summarized c28dfce.


Note

Low Risk Localized desktop window/settings and web layout UX; no auth or data-path changes, with fallbacks when display lookup or bounds validation fails.

Overview The desktop app persists main window position, size, and maximized state in DesktopAppSettings, restores them when opening the main window (only if the saved rectangle fits a connected display), and flushes pending bounds on window close and app shutdown via flushMainWindowBounds in DesktopLifecycle.

Bounds updates are debounced on move/resize/maximize; maximized, fullscreen, and minimized windows save normal bounds. Invalid or off-screen saved geometry falls back to defaults without overwriting disk until the user moves the window.

The web shell applies the stored thread sidebar width on first paint (threadSidebarWidth helpers + --sidebar-width on SidebarProvider, with useLayoutEffect hydration in the sidebar rail) to avoid a width flash on load.

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

Added 2 commits

  • fix(desktop): refine window bounds fallback271e8bb
  • fix(desktop): preserve main window bounds93dfb63
Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 7d187606-e1a0-42ec-8ebf-72b306d8f130

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ 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.

apps/desktop/src/window/DesktopWindow.tsline 355Outdated · 271e8bbOpen thread
……@@ -275,6 +326,60 @@ export const make = Effect.gen(function* () {
352+ }),
353+ ),
354+ ),
355+ );

Close persist is not awaited

Medium Severity

On close, bounds are written with a fire-and-forget runPromise and nothing in the quit path waits for that disk write. A clean quit right after a move/resize can exit before setMainWindowBounds finishes, so the last layout never lands on disk.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 271e8bb40ca5c3bdab79f52cb767212835564b98. Configure here.

apps/desktop/src/window/DesktopWindow.tsline 340Outdated · 271e8bbOpen thread
……@@ -275,6 +326,60 @@ export const make = Effect.gen(function* () {
337+ const width = Math.round(bounds.width);
338+ const height = Math.round(bounds.height);
339+ return { x, y, width, height };
340+ };

Minimized windows save invalid bounds

Medium Severity

The readPersistableBounds function doesn't check if a window is minimized. On Windows, getBounds returns off-screen coordinates for minimized windows, which are then persisted. This results in the app losing the user's window layout and reverting to default sizing on subsequent launches.

Reviewed by Cursor Bugbot for commit 271e8bb40ca5c3bdab79f52cb767212835564b98. Configure here.

Synced from GitHub

Approvability

Verdict: Needs human review

This PR introduces new window bounds persistence functionality with significant runtime behavior changes across settings, window lifecycle, and UI state management. Two unresolved review comments identify potential bugs (stale sidebar max width, race condition with maximized state). New features with substantive logic and unresolved findings warrant human review.

You can customize Macroscope's approvability policy. Learn more.

Added 1 commit

  • fix(desktop): handle bounds persistence on shutdown3e81c69
apps/desktop/src/window/DesktopWindow.tsline 386Outdated · 3e81c69Open thread
……@@ -275,6 +328,71 @@ export const make = Effect.gen(function* () {
383+ boundsPersistFiber = undefined;
384+ runFork(Fiber.interrupt(fiber));
385+ };
386+ const flushBoundsPersist = Effect.sync(() => {
MmacroscopeappBoton line 386Jul 10, 2026

🟡 Medium window/DesktopWindow.ts:386

When the user moves or resizes the window and then quits while the window is minimized, the new bounds are silently lost and the next launch restores stale geometry. flushBoundsPersist cancels the debounced save via clearBoundsPersist before calling persistCurrentBounds, but readPersistableBounds returns null whenever window.isMinimized() is true, so the replacement write is skipped and the pending debounced save is discarded with no write taking its place. Consider reading the bounds before cancelling the pending fiber, or falling back to window.getNormalBounds() when minimized.

✅ Resolved in b11feec4abf8a69c1dfcf66907963de4e6ff836c

Added 3 commits

  • fix(desktop): preserve minimized window boundsb11feec
  • Merge main into t3code/preserve-macos-window-sizee1aec4a
  • Merge latest main into t3code/preserve-macos-window-sizec99ac62
apps/desktop/src/window/DesktopWindow.tsline 351Outdated · c99ac62Open thread
……@@ -275,6 +328,74 @@ export const make = Effect.gen(function* () {
348+ const bounds = readPersistableBounds();
349+ if (bounds === null) {
350+ return pendingBoundsPersistFiber;
351+ }

Fullscreen drops pending bounds save

Low Severity

A move or resize only schedules a debounced persist. Entering fullscreen fires another resize, which resets that timer; when it runs, isFullScreen() makes readPersistableBounds return null and nothing is written. Quitting while fullscreen clears the debounce and skips persist the same way, so the last normal bounds from that session never reach settings.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit c99ac6227dca398ecb1693a2daa24f3a3359533a. Configure here.

Added 2 commits

  • fix(desktop): preserve normal bounds while fullscreen10f5dcb
  • Merge branch 'main' into t3code/preserve-macos-window-sized7de06d
apps/desktop/src/window/DesktopWindow.tsline 345Outdated · d7de06dOpen thread
……@@ -275,6 +328,74 @@ export const make = Effect.gen(function* () {
342+ const y = Math.round(bounds.y);
343+ const width = Math.round(bounds.width);
344+ const height = Math.round(bounds.height);
345+ return { x, y, width, height };

Persist skips bounds schema checks

Medium Severity

readPersistableBounds persists rounded Electron geometry without applying DesktopWindowBoundsSchema, while load rejects non-integers and sizes below the minimum as null. A successful save can therefore be discarded on the next launch, resetting the user’s window layout after sessions where Electron reports slightly undersized or inconsistent bounds (for example with a hidden title bar under DPI scaling).

Additional Locations (1)

Reviewed by Cursor Bugbot for commit d7de06d7fa2b442b1b824d0c22f3a1b1fd3eb92c. Configure here.

Fixed in da1f349db2. Rounded Electron bounds now pass through the shared DesktopWindowBounds schema normalization before any write, so invalid geometry is skipped rather than persisted.

apps/desktop/src/window/DesktopWindow.tsline 492Outdated · d7de06dOpen thread
……@@ -364,6 +485,11 @@ export const make = Effect.gen(function* () {
489+ window.on("move", scheduleBoundsPersist);
490+ window.on("close", () => {
491+ runFork(flushBoundsPersist);
492+ });

Failed restore overwrites saved bounds

Medium Severity

When persisted bounds do not fit any connected display, the window opens with defaults, but close and shutdown still call flushMainWindowBounds. That writes the fallback geometry over the previous saved rectangle, so reconnecting the original display cannot restore the user’s layout even if they never moved the window.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit d7de06d7fa2b442b1b824d0c22f3a1b1fd3eb92c. Configure here.

Fixed in da1f349db2. When persisted bounds cannot be restored, the fallback window state is retained as a baseline and close/shutdown skips persistence until the window state actually changes.

Added 1 commit

  • Persist current bounds for maximized desktop windows261b057
apps/desktop/src/window/DesktopWindow.tsline 338Outdated · 261b057Open thread
……@@ -275,6 +328,74 @@ export const make = Effect.gen(function* () {
335+ return null;
336+ }
337+ const bounds =
338+ window.isFullScreen() || window.isMinimized()

Maximized bounds break restore

Medium Severity

When maximized, readPersistableBounds now saves the window's maximized dimensions instead of its normal bounds. Since the maximize state isn't persisted, relaunching opens a non-maximized window at the full display size, losing the user's previous normal layout. On Windows, these maximized bounds can also be invalid and discarded.

Reviewed by Cursor Bugbot for commit 261b057455f844c539d0430acbb258a9aaa09349. Configure here.

Fixed in da1f349db2. Maximized windows now persist their normal bounds together with a maximized-state flag, and startup restores the native maximized state.

Added 1 commit

  • Restore sidebar width before first paint24143ef
apps/web/src/components/threadSidebarWidth.tsline 5Outdated · 24143efOpen thread
……@@ -0,0 +1,9 @@
2+export const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16;
3+export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
4+
5+export function resolveInitialThreadSidebarWidth(storedWidth: number | null): number {

🟡 Medium components/threadSidebarWidth.ts:5

resolveInitialThreadSidebarWidth clamps the persisted width to THREAD_SIDEBAR_MIN_WIDTH but applies no upper bound, so a width saved on a large display is restored verbatim after the window is reopened much smaller. This sets --sidebar-width wider than the available layout width, pushing the main content and resize rail off-screen. The drag path enforces an upper bound via shouldAcceptWidth / THREAD_MAIN_CONTENT_MIN_WIDTH, but startup restoration bypasses that check. Consider clamping the restored value against the current layout width (or passing the available width into resolveInitialThreadSidebarWidth) so the initial width respects the same upper bound as the drag path.

🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/threadSidebarWidth.ts around line 5:
2
3`resolveInitialThreadSidebarWidth` clamps the persisted width to `THREAD_SIDEBAR_MIN_WIDTH` but applies no upper bound, so a width saved on a large display is restored verbatim after the window is reopened much smaller. This sets `--sidebar-width` wider than the available layout width, pushing the main content and resize rail off-screen. The drag path enforces an upper bound via `shouldAcceptWidth` / `THREAD_MAIN_CONTENT_MIN_WIDTH`, but startup restoration bypasses that check. Consider clamping the restored value against the current layout width (or passing the available width into `resolveInitialThreadSidebarWidth`) so the initial width respects the same upper bound as the drag path.

Fixed in f8f445d78f. Initial restoration and the generic hydration path now share a viewport-derived maximum that reserves the same 640px minimum for main content.

apps/web/src/components/ui/sidebar.tsxline 569Outdated · 24143efOpen thread
……@@ -565,6 +565,8 @@ function SidebarRail({
566566 if (storedWidth === null) return;
567567 const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable);
568+ // Hydrate the CSS variable before the browser paints so a restored sidebar
569+ // never flashes at the default width first.

Unchecked storage read still crashes

Medium Severity

readInitialThreadSidebarWidth catches localStorage failures and falls back to the default width, but the parallel useLayoutEffect hydration still calls getLocalStorageItem unguarded. A corrupt chat_thread_sidebar_width value still throws during layout, so the new fallback never reaches the screen and the app shell can crash before paint.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 24143ef3c3284ef5b3eae6b7b939f39d79f179de. Configure here.

Fixed in f8f445d78f. The layout-effect hydration now catches storage read/decode errors and leaves the already-safe initial fallback width in place.

apps/web/src/components/AppSidebarLayout.tsxline 113Outdated · 24143efOpen thread
……@@ -82,16 +98,19 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
110+ ...(isMacosDesktop && !isWindowFullscreen
111+ ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
112+ : {}),
113+ } as CSSProperties;

Stale controlled sidebar width

Medium Severity

initialSidebarWidth is captured once and passed every render as a controlled --sidebar-width on SidebarProvider, but resize only updates the DOM via setProperty and localStorage. That leaves React props stuck at the mount-time width, so a later AppSidebarLayout re-render can reassert the stale value over the user's resized width.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 24143ef3c3284ef5b3eae6b7b939f39d79f179de. Configure here.

Fixed in f8f445d78f. Sidebar width is now React state and the resizable onResize callback synchronizes it after a drag, so later layout rerenders preserve the latest width.

Added 1 commit

  • Address window bounds persistence reviewda1f349
apps/desktop/src/window/DesktopWindow.tsline 346Outdated · da1f349Open thread
……@@ -274,6 +341,96 @@ export const make = Effect.gen(function* () {
276343 }
344+ if (persistedSettings.mainWindowMaximized) {
345+ window.maximize();
346+ }

Maximize shows window before reveal

High Severity

When restoring a maximized window, window.maximize() is called while the window is hidden (show: false). On Windows and macOS, this implicitly shows the window, bypassing the ready-to-show event. This can cause a flash of unrendered content or a blank window, preventing splash screen dismissal.

Reviewed by Cursor Bugbot for commit da1f349db280ac408bc36011a73f856dc58b1e54. Configure here.

Fixed in c28dfce54a. Restored maximize now runs from the existing first-reveal trigger after ready-to-show (or the Linux load fallback), immediately before the normal reveal/splash-dismiss path. The test asserts maximize is not called before ready-to-show.

Added 1 commit

  • Address sidebar persistence reviewf8f445d

Added 1 commit

  • Defer maximized restore until window revealc28dfce
Ccursor[bot]reviewed · 2 inline threadsJul 20, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c28dfce54a780df715eceb23e1f6abba081e68c7. Configure here.

apps/desktop/src/window/DesktopWindow.tsline 367 (old)Outdated · fa69f05Open thread
……@@ -364,6 +517,13 @@ export const make = Effect.gen(function* () {
524+ window.on("close", () => {
525+ runFork(flushBoundsPersist);
526+ });
367527
Ccursor[bot]on line 367 (old)Jul 20, 2026

Maximized fallback races persistence gate

Medium Severity

fallbackWindowMaximized is taken from persisted settings while the deferred maximize() runs only later on reveal, after move/resize/maximize listeners are already registered. Until that restore finishes, live isMaximized() disagrees with the baseline, so scheduleBoundsPersist can treat startup restore events as a user change and overwrite unrestorable saved bounds.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit c28dfce54a780df715eceb23e1f6abba081e68c7. Configure here.

……@@ -131,17 +153,19 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
137159 collapsible="offcanvas"
138160 className="border-r border-border bg-card text-foreground"
139161 resizable={{
162+ maxWidth: sidebarMaximumWidth,

Stale sidebar max width

Medium Severity

The sidebar's maxWidth is calculated once using window.innerWidth but doesn't update when the window resizes. This causes the sidebar to remain capped at an outdated maximum width if the browser window grows, preventing it from being expanded further.

Reviewed by Cursor Bugbot for commit c28dfce54a780df715eceb23e1f6abba081e68c7. Configure here.

Synced from GitHub

@juliusmarminge I don't think this is working as intended. I'm not able to enlarge the sidebar beyond a very small limit even though I'm on an ultrawide...