From 93dfb6314d0f6a3f0ab25aabf8adefa10e4253de Mon Sep 17 00:00:00 2001 From: Anirudh Coontoor Date: Fri, 10 Jul 2026 14:06:25 +0530 Subject: [PATCH 01/10] fix(desktop): preserve main window bounds --- .../src/backend/DesktopServerExposure.test.ts | 1 + .../src/settings/DesktopAppSettings.test.ts | 35 +++++ .../src/settings/DesktopAppSettings.ts | 66 ++++++++ .../src/updates/DesktopUpdates.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 141 +++++++++++++++++- apps/desktop/src/window/DesktopWindow.ts | 112 +++++++++++++- 6 files changed, 352 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 1a107c5c856..dcfee93778d 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -250,6 +250,7 @@ describe("DesktopServerExposure", () => { const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 70b26798266..13960d07982 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,16 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + mainWindowBounds: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + }), + ), + ), serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -91,6 +101,7 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + mainWindowBounds: null, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -116,6 +127,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -215,10 +227,12 @@ describe("DesktopSettings", () => { "serverExposureMode": "network-accessible", "tailscaleServeEnabled": true, "tailscaleServePort": 8443, + "mainWindowBounds": { "x": 120, "y": 80, "width": 1280, "height": 900 }, }\n`, ); assert.deepEqual(yield* settings.load, { + mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -232,6 +246,22 @@ describe("DesktopSettings", () => { ), ); + it.effect("rejects window bounds that do not satisfy the domain schema", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, + serverExposureMode: "network-accessible", + }); + + const loaded = yield* settings.load; + assert.isNull(loaded.mainWindowBounds); + assert.equal(loaded.serverExposureMode, "network-accessible"); + }), + ), + ); + it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -239,12 +269,14 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { + mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); }), @@ -261,6 +293,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -286,6 +319,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -310,6 +344,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, serverExposureMode: "local-only", tailscaleServeEnabled: true, tailscaleServePort: 443, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 6a26bf5a6e2..b4262eee484 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -20,6 +20,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly mainWindowBounds: DesktopWindowBounds | null; readonly serverExposureMode: DesktopServerExposureMode; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; @@ -48,8 +49,26 @@ export interface DesktopSettingsChange { } export const DEFAULT_TAILSCALE_SERVE_PORT = 443; +const MIN_MAIN_WINDOW_SIZE = { + width: 840, + height: 620, +} as const; +export const DesktopWindowBoundsSchema = Schema.Struct({ + x: Schema.Int, + y: Schema.Int, + width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.width)), + height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)), +}); +export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type; +export const DEFAULT_MAIN_WINDOW_BOUNDS: DesktopWindowBounds = { + x: 0, + y: 0, + width: 1100, + height: 780, +}; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + mainWindowBounds: null, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, @@ -60,7 +79,15 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { wslOnly: false, }; +const DesktopWindowBoundsDocument = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); + const DesktopSettingsDocument = Schema.Struct({ + mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -81,6 +108,8 @@ type Mutable = { -readonly [K in keyof T]: T[K] }; const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument); const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); +const decodeDesktopWindowBounds = Schema.decodeUnknownOption(DesktopWindowBoundsSchema); +const desktopWindowBoundsEquivalence = Schema.toEquivalence(DesktopWindowBoundsSchema); const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ settings, @@ -114,6 +143,9 @@ export class DesktopAppSettings extends Context.Service< { readonly load: Effect.Effect; readonly get: Effect.Effect; + readonly setMainWindowBounds: ( + bounds: DesktopWindowBounds, + ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; @@ -158,6 +190,10 @@ function normalizeWslDistro(value: unknown): string | null { return typeof value === "string" && isValidDistroName(value) ? value : null; } +function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null { + return Option.getOrNull(decodeDesktopWindowBounds(value)); +} + function normalizeDesktopSettingsDocument( parsed: DesktopSettingsDocument, appVersion: string, @@ -177,6 +213,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + mainWindowBounds: normalizeMainWindowBounds(parsed.mainWindowBounds), serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, @@ -197,6 +234,9 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.mainWindowBounds !== null) { + document.mainWindowBounds = settings.mainWindowBounds; + } if (settings.serverExposureMode !== defaults.serverExposureMode) { document.serverExposureMode = settings.serverExposureMode; } @@ -237,6 +277,19 @@ function setServerExposureMode( }; } +function setMainWindowBounds( + settings: DesktopSettings, + bounds: DesktopWindowBounds, +): DesktopSettings { + return settings.mainWindowBounds !== null && + desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) + ? settings + : { + ...settings, + mainWindowBounds: bounds, + }; +} + function setTailscaleServe( settings: DesktopSettings, input: { readonly enabled: boolean; readonly port: Option.Option }, @@ -431,6 +484,17 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), + setMainWindowBounds: (bounds) => + persist((settings) => setMainWindowBounds(settings, bounds)).pipe( + Effect.withSpan("desktop.settings.setMainWindowBounds", { + attributes: { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }, + }), + ), setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), @@ -488,6 +552,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), + setMainWindowBounds: (bounds) => + update((settings) => setMainWindowBounds(settings, bounds)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 696bd755506..6521e671e70 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -153,6 +153,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.fail(setUpdateChannelError), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 280f2109fec..c96ee99b501 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; @@ -18,12 +19,20 @@ vi.mock("electron", async (importOriginal) => ({ setUserAgent: vi.fn(), })), }, + screen: { + getAllDisplays: vi.fn(() => [ + { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + }, + ]), + }, })); import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -47,6 +56,7 @@ const environmentInput = { function makeFakeBrowserWindow() { const webContentsListeners = new Map void>(); + const windowListeners = new Map void>(); const webContents = { copyImageAt: vi.fn(), getURL: vi.fn(() => "t3code-dev://app/"), @@ -65,11 +75,17 @@ function makeFakeBrowserWindow() { const window = { close: vi.fn(), focus: vi.fn(), + getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), + getNormalBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), isDestroyed: vi.fn(() => false), + isFullScreen: vi.fn(() => false), + isMaximized: vi.fn(() => false), isMinimized: vi.fn(() => false), isVisible: vi.fn(() => true), loadURL: vi.fn(() => Promise.resolve()), - on: vi.fn(), + on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), once: vi.fn(), restore: vi.fn(), setBackgroundColor: vi.fn(), @@ -83,11 +99,13 @@ function makeFakeBrowserWindow() { return { window: window as unknown as Electron.BrowserWindow, loadURL: window.loadURL, + getBounds: window.getBounds, openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, + windowListeners, }; } @@ -139,13 +157,47 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( ), ); +const desktopWindowBoundsEquivalence = Schema.toEquivalence( + DesktopAppSettings.DesktopWindowBoundsSchema, +); + function makeTestLayer(input: { readonly window: Electron.BrowserWindow; readonly createCount: Ref.Ref; readonly mainWindow: Ref.Ref>; readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; + readonly desktopSettings?: DesktopAppSettings.DesktopSettings; + readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; readonly openedExternalUrls?: unknown[]; }) { + let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; + const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.sync(() => desktopSettings), + load: Effect.sync(() => desktopSettings), + setMainWindowBounds: (bounds) => + Effect.sync(() => { + const changed = + desktopSettings.mainWindowBounds === null || + !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds); + if (changed) { + desktopSettings = { + ...desktopSettings, + mainWindowBounds: bounds, + }; + input.mainWindowBoundsUpdates?.push(bounds); + } + return { settings: desktopSettings, changed }; + }), + setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setUpdateChannel: () => Effect.die("unexpected update channel change"), + setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), + setWslDistro: () => Effect.die("unexpected WSL distro change"), + setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), + applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: (options) => Effect.sync(() => { @@ -170,6 +222,7 @@ function makeTestLayer(input: { Layer.mergeAll( desktopAssetsLayer, desktopEnvironmentLayer, + desktopAppSettingsLayer, desktopServerExposureLayer, DesktopState.layer, electronMenuLayer, @@ -267,6 +320,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n Layer.mergeAll( desktopAssetsLayer, desktopEnvironmentLayer, + DesktopAppSettings.layerTest(), desktopServerExposureLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { @@ -289,6 +343,23 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n }); describe("DesktopWindow", () => { + it("restores bounds only when the window fits within a connected display", () => { + const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; + const displays = [ + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 1920, y: 0, width: 2560, height: 1440 }, + ]; + + assert.deepEqual( + DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, displays), + persistedBounds, + ); + assert.deepEqual( + DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, [displays[0]!]), + DesktopAppSettings.DEFAULT_MAIN_WINDOW_BOUNDS, + ); + }); + it("recognizes only same-origin renderer navigations", () => { assert.isTrue( DesktopWindow.isSameOriginRendererNavigation({ @@ -330,6 +401,8 @@ describe("DesktopWindow", () => { yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); assert.equal(yield* Ref.get(createCount), 1); + assert.equal(createdWindowOptions[0]?.width, 1100); + assert.equal(createdWindowOptions[0]?.height, 780); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); @@ -338,6 +411,72 @@ describe("DesktopWindow", () => { }), ); + it.effect("uses the persisted main window bounds when opening the window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(createdWindowOptions[0]?.width, 1320); + assert.equal(createdWindowOptions[0]?.height, 880); + assert.equal(createdWindowOptions[0]?.x, 120); + assert.equal(createdWindowOptions[0]?.y, 80); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("persists the current main window bounds before the window closes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 240, y: 160, width: 1410, height: 930 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, [ + { + x: 240, + y: 160, + width: 1410, + height: 930, + }, + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("recovers when the development renderer is temporarily unreachable", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b1dfabe7ab4..a9fbab69efd 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -17,11 +17,13 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; +const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ -2, // ERR_FAILED @@ -41,6 +43,7 @@ type WindowTitleBarOptions = Pick< type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets + | DesktopAppSettings.DesktopAppSettings | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -99,6 +102,33 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; } +type DisplayBounds = Pick; + +function windowFitsWithinDisplay( + windowBounds: DesktopAppSettings.DesktopWindowBounds, + displayBounds: DisplayBounds, +): boolean { + return ( + windowBounds.x >= displayBounds.x && + windowBounds.y >= displayBounds.y && + windowBounds.x + windowBounds.width <= displayBounds.x + displayBounds.width && + windowBounds.y + windowBounds.height <= displayBounds.y + displayBounds.height + ); +} + +export function resolveInitialMainWindowBounds( + persistedBounds: DesktopAppSettings.DesktopWindowBounds | null, + displays: readonly DisplayBounds[], +): DesktopAppSettings.DesktopWindowBounds { + if ( + persistedBounds !== null && + displays.some((display) => windowFitsWithinDisplay(persistedBounds, display)) + ) { + return persistedBounds; + } + return DesktopAppSettings.DEFAULT_MAIN_WINDOW_BOUNDS; +} + // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only // mode while the WSL backend (which serves the renderer) cold-boots. Inlined as // a data URL so it needs no bundled asset and no backend — pure CSS, no JS. @@ -202,6 +232,7 @@ export const make = Effect.gen(function* () { const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -250,9 +281,23 @@ export const make = Effect.gen(function* () { const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const persistedBounds = (yield* desktopSettings.get).mainWindowBounds; + const displayBounds = yield* Effect.sync(() => { + try { + return Electron.screen.getAllDisplays().map((display) => display.bounds); + } catch { + return []; + } + }); + const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); + if ( + persistedBounds !== null && + initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_BOUNDS + ) { + yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); + } const window = yield* electronWindow.create({ - width: 1100, - height: 780, + ...initialBounds, minWidth: 840, minHeight: 620, show: false, @@ -275,6 +320,60 @@ export const make = Effect.gen(function* () { window.setAutoHideCursor(false); } + let boundsPersistFiber: Fiber.Fiber | undefined; + const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { + if (window.isDestroyed() || window.isFullScreen()) { + return null; + } + const bounds = window.isMaximized() ? window.getNormalBounds() : window.getBounds(); + const x = Math.round(bounds.x); + const y = Math.round(bounds.y); + const width = Math.round(bounds.width); + const height = Math.round(bounds.height); + return { x, y, width, height }; + }; + const persistCurrentBounds = () => { + const bounds = readPersistableBounds(); + if (bounds === null) { + return; + } + void runPromise( + desktopSettings.setMainWindowBounds(bounds).pipe( + Effect.asVoid, + Effect.catch((error) => + logWindowWarning("failed to persist main window bounds", { + message: error.message, + }), + ), + ), + ); + }; + const scheduleBoundsPersist = () => { + if (boundsPersistFiber !== undefined) { + const fiber = boundsPersistFiber; + boundsPersistFiber = undefined; + runFork(Fiber.interrupt(fiber)); + } + boundsPersistFiber = runFork( + Effect.sleep(MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS).pipe( + Effect.andThen( + Effect.sync(() => { + boundsPersistFiber = undefined; + persistCurrentBounds(); + }), + ), + ), + ); + }; + const clearBoundsPersist = () => { + if (boundsPersistFiber === undefined) { + return; + } + const fiber = boundsPersistFiber; + boundsPersistFiber = undefined; + runFork(Fiber.interrupt(fiber)); + }; + yield* previewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if ( @@ -364,6 +463,12 @@ export const make = Effect.gen(function* () { event.preventDefault(); window.setTitle(environment.displayName); }); + window.on("resize", scheduleBoundsPersist); + window.on("move", scheduleBoundsPersist); + window.on("close", () => { + clearBoundsPersist(); + persistCurrentBounds(); + }); let developmentLoadRetryIndex = 0; let developmentLoadRetryFiber: Fiber.Fiber | undefined; @@ -473,6 +578,7 @@ export const make = Effect.gen(function* () { window.on("closed", () => { clearDevelopmentLoadRetry(); + clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); }); From 271e8bb40ca5c3bdab79f52cb767212835564b98 Mon Sep 17 00:00:00 2001 From: Anirudh Coontoor Date: Fri, 10 Jul 2026 15:08:02 +0530 Subject: [PATCH 02/10] fix(desktop): refine window bounds fallback --- .../src/settings/DesktopAppSettings.ts | 6 +- apps/desktop/src/window/DesktopWindow.test.ts | 166 +++++++++++++++++- apps/desktop/src/window/DesktopWindow.ts | 26 +-- 3 files changed, 181 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index b4262eee484..c518db61937 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -60,12 +60,10 @@ export const DesktopWindowBoundsSchema = Schema.Struct({ height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)), }); export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type; -export const DEFAULT_MAIN_WINDOW_BOUNDS: DesktopWindowBounds = { - x: 0, - y: 0, +export const DEFAULT_MAIN_WINDOW_SIZE = { width: 1100, height: 780, -}; +} as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { mainWindowBounds: null, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index c96ee99b501..cdad83b8d89 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -2,12 +2,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as References from "effect/References"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import { vi } from "vite-plus/test"; vi.mock("electron", async (importOriginal) => ({ @@ -98,8 +100,11 @@ function makeFakeBrowserWindow() { return { window: window as unknown as Electron.BrowserWindow, - loadURL: window.loadURL, getBounds: window.getBounds, + getNormalBounds: window.getNormalBounds, + isFullScreen: window.isFullScreen, + isMaximized: window.isMaximized, + loadURL: window.loadURL, openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, @@ -356,7 +361,7 @@ describe("DesktopWindow", () => { ); assert.deepEqual( DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, [displays[0]!]), - DesktopAppSettings.DEFAULT_MAIN_WINDOW_BOUNDS, + DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE, ); }); @@ -403,6 +408,8 @@ describe("DesktopWindow", () => { assert.equal(yield* Ref.get(createCount), 1); assert.equal(createdWindowOptions[0]?.width, 1100); assert.equal(createdWindowOptions[0]?.height, 780); + assert.isUndefined(createdWindowOptions[0]?.x); + assert.isUndefined(createdWindowOptions[0]?.y); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); @@ -440,6 +447,159 @@ describe("DesktopWindow", () => { }), ); + it.effect("debounces move and resize bounds updates", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const move = fakeWindow.windowListeners.get("move"); + const resize = fakeWindow.windowListeners.get("resize"); + if (!move || !resize) { + return yield* Effect.die("window bounds listeners were not registered"); + } + + fakeWindow.getBounds.mockReturnValue({ x: 120, y: 80, width: 1280, height: 840 }); + move(); + yield* TestClock.adjust(250); + + fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 1360, height: 900 }); + resize(); + yield* TestClock.adjust(499); + assert.deepEqual(mainWindowBoundsUpdates, []); + + yield* TestClock.adjust(1); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 1360, height: 900 }]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("persists normal bounds for a maximized window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.isMaximized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("does not persist fullscreen bounds", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.isFullScreen.mockReturnValue(true); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, []); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("logs display lookup failures before falling back to the default size", () => + Effect.gen(function* () { + const displayLookupFailure = new Error("screen API unavailable"); + vi.mocked(Electron.screen.getAllDisplays).mockImplementationOnce(() => { + throw displayLookupFailure; + }); + const logRecords: Array<{ + readonly message: unknown; + readonly annotations: Readonly>; + }> = []; + const logger = Logger.make(({ fiber, message }) => { + logRecords.push({ + message, + annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, + }); + }); + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + }).pipe( + Effect.provide(Layer.mergeAll(layer, Logger.layer([logger], { mergeWithExisting: false }))), + ); + + const warning = logRecords.find( + (record) => + Array.isArray(record.message) && + record.message[0] === "failed to read connected displays; using defaults", + ); + assert.isDefined(warning); + assert.strictEqual(warning.annotations.cause, displayLookupFailure); + assert.equal(createdWindowOptions[0]?.width, 1100); + assert.equal(createdWindowOptions[0]?.height, 780); + assert.isUndefined(createdWindowOptions[0]?.x); + assert.isUndefined(createdWindowOptions[0]?.y); + }), + ); + it.effect("persists the current main window bounds before the window closes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index a9fbab69efd..7eb749c53ba 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -119,14 +119,14 @@ function windowFitsWithinDisplay( export function resolveInitialMainWindowBounds( persistedBounds: DesktopAppSettings.DesktopWindowBounds | null, displays: readonly DisplayBounds[], -): DesktopAppSettings.DesktopWindowBounds { +): DesktopAppSettings.DesktopWindowBounds | typeof DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE { if ( persistedBounds !== null && displays.some((display) => windowFitsWithinDisplay(persistedBounds, display)) ) { return persistedBounds; } - return DesktopAppSettings.DEFAULT_MAIN_WINDOW_BOUNDS; + return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; } // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only @@ -282,18 +282,24 @@ export const make = Effect.gen(function* () { const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; const persistedBounds = (yield* desktopSettings.get).mainWindowBounds; - const displayBounds = yield* Effect.sync(() => { + const displayBoundsResult = yield* Effect.sync(() => { try { - return Electron.screen.getAllDisplays().map((display) => display.bounds); - } catch { - return []; + return { + _tag: "Success" as const, + bounds: Electron.screen.getAllDisplays().map((display) => display.bounds), + }; + } catch (cause) { + return { _tag: "Failure" as const, cause }; } }); + const displayBounds = + displayBoundsResult._tag === "Success" + ? displayBoundsResult.bounds + : yield* logWindowWarning("failed to read connected displays; using defaults", { + cause: displayBoundsResult.cause, + }).pipe(Effect.as([])); const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); - if ( - persistedBounds !== null && - initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_BOUNDS - ) { + if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); } const window = yield* electronWindow.create({ From 3e81c69d92cb48fc5a8d48ade9127899ef4bee21 Mon Sep 17 00:00:00 2001 From: Anirudh Coontoor Date: Fri, 10 Jul 2026 16:28:11 +0530 Subject: [PATCH 03/10] fix(desktop): handle bounds persistence on shutdown --- apps/desktop/src/app/DesktopLifecycle.ts | 8 ++- .../src/backend/DesktopBackendPool.test.ts | 1 + .../src/window/DesktopApplicationMenu.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 67 ++++++++++++++++++- apps/desktop/src/window/DesktopWindow.ts | 29 ++++++-- 5 files changed, 96 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index c5264332b66..f8e05915718 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -73,8 +73,14 @@ function addScopedListener>( } const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( - function* (): Effect.fn.Return { + function* (): Effect.fn.Return< + void, + never, + DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow + > { const shutdown = yield* DesktopShutdown.DesktopShutdown; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.flushMainWindowBounds; yield* shutdown.request; yield* shutdown.awaitComplete; }, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 5e6a3f5164d..fa0811d5df7 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -76,6 +76,7 @@ function makePoolLayer( showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index ba77292fdc8..168846466ed 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -76,6 +76,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index cdad83b8d89..e2e89563d5a 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -1,6 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; @@ -102,8 +104,10 @@ function makeFakeBrowserWindow() { window: window as unknown as Electron.BrowserWindow, getBounds: window.getBounds, getNormalBounds: window.getNormalBounds, + isDestroyed: window.isDestroyed, isFullScreen: window.isFullScreen, isMaximized: window.isMaximized, + isMinimized: window.isMinimized, loadURL: window.loadURL, openDevTools: webContents.openDevTools, reload: webContents.reload, @@ -173,6 +177,9 @@ function makeTestLayer(input: { readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; readonly desktopSettings?: DesktopAppSettings.DesktopSettings; readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; + readonly beforeMainWindowBoundsUpdate?: ( + bounds: DesktopAppSettings.DesktopWindowBounds, + ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; @@ -180,7 +187,10 @@ function makeTestLayer(input: { get: Effect.sync(() => desktopSettings), load: Effect.sync(() => desktopSettings), setMainWindowBounds: (bounds) => - Effect.sync(() => { + Effect.gen(function* () { + if (input.beforeMainWindowBoundsUpdate) { + yield* input.beforeMainWindowBoundsUpdate(bounds); + } const changed = desktopSettings.mainWindowBounds === null || !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds); @@ -552,6 +562,39 @@ describe("DesktopWindow", () => { }), ); + it.effect("does not persist minimized window bounds", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.isMinimized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: -32_000, y: -32_000, width: 160, height: 28 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* desktopWindow.flushMainWindowBounds; + + assert.deepEqual(mainWindowBoundsUpdates, []); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("logs display lookup failures before falling back to the default size", () => Effect.gen(function* () { const displayLookupFailure = new Error("screen API unavailable"); @@ -607,11 +650,19 @@ describe("DesktopWindow", () => { const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const writeStarted = yield* Deferred.make(); + const allowWrite = yield* Deferred.make(); + const flushCompleted = yield* Deferred.make(); const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow, mainWindowBoundsUpdates, + beforeMainWindowBoundsUpdate: () => + Deferred.succeed(writeStarted, undefined).pipe( + Effect.andThen(Deferred.await(allowWrite)), + Effect.asVoid, + ), }); yield* Effect.gen(function* () { @@ -623,7 +674,19 @@ describe("DesktopWindow", () => { return yield* Effect.die("window close listener was not registered"); } close(); - yield* Effect.promise(() => Promise.resolve()); + yield* Deferred.await(writeStarted); + fakeWindow.isDestroyed.mockReturnValue(true); + + const flushFiber = yield* desktopWindow.flushMainWindowBounds.pipe( + Effect.andThen(Deferred.succeed(flushCompleted, undefined)), + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + assert.isFalse(yield* Deferred.isDone(flushCompleted)); + + yield* Deferred.succeed(allowWrite, undefined); + yield* Fiber.join(flushFiber); + assert.isTrue(yield* Deferred.isDone(flushCompleted)); assert.deepEqual(mainWindowBoundsUpdates, [ { diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 7eb749c53ba..2e98d0e7204 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -78,6 +78,7 @@ export class DesktopWindow extends Context.Service< // window so a "macOS dock click" while the backend is down doesn't // produce a stranded window pointing at nothing. readonly handleBackendNotReady: Effect.Effect; + readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; } @@ -245,6 +246,7 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + let flushMainWindowBounds: Effect.Effect = Effect.void; const dismissConnectingSplash = Effect.gen(function* () { const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); @@ -327,8 +329,9 @@ export const make = Effect.gen(function* () { } let boundsPersistFiber: Fiber.Fiber | undefined; + let pendingBoundsPersistFiber: Fiber.Fiber | undefined; const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { - if (window.isDestroyed() || window.isFullScreen()) { + if (window.isDestroyed() || window.isFullScreen() || window.isMinimized()) { return null; } const bounds = window.isMaximized() ? window.getNormalBounds() : window.getBounds(); @@ -338,12 +341,12 @@ export const make = Effect.gen(function* () { const height = Math.round(bounds.height); return { x, y, width, height }; }; - const persistCurrentBounds = () => { + const persistCurrentBounds = (): Fiber.Fiber | undefined => { const bounds = readPersistableBounds(); if (bounds === null) { - return; + return pendingBoundsPersistFiber; } - void runPromise( + pendingBoundsPersistFiber = runFork( desktopSettings.setMainWindowBounds(bounds).pipe( Effect.asVoid, Effect.catch((error) => @@ -353,6 +356,7 @@ export const make = Effect.gen(function* () { ), ), ); + return pendingBoundsPersistFiber; }; const scheduleBoundsPersist = () => { if (boundsPersistFiber !== undefined) { @@ -365,7 +369,7 @@ export const make = Effect.gen(function* () { Effect.andThen( Effect.sync(() => { boundsPersistFiber = undefined; - persistCurrentBounds(); + void persistCurrentBounds(); }), ), ), @@ -379,6 +383,15 @@ export const make = Effect.gen(function* () { boundsPersistFiber = undefined; runFork(Fiber.interrupt(fiber)); }; + const flushBoundsPersist = Effect.sync(() => { + clearBoundsPersist(); + return persistCurrentBounds(); + }).pipe( + Effect.flatMap((fiber) => + fiber === undefined ? Effect.void : Fiber.join(fiber).pipe(Effect.asVoid), + ), + ); + flushMainWindowBounds = flushBoundsPersist; yield* previewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { @@ -472,8 +485,7 @@ export const make = Effect.gen(function* () { window.on("resize", scheduleBoundsPersist); window.on("move", scheduleBoundsPersist); window.on("close", () => { - clearBoundsPersist(); - persistCurrentBounds(); + runFork(flushBoundsPersist); }); let developmentLoadRetryIndex = 0; @@ -701,6 +713,9 @@ export const make = Effect.gen(function* () { handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), ), + flushMainWindowBounds: Effect.suspend(() => flushMainWindowBounds).pipe( + Effect.withSpan("desktop.window.flushMainWindowBounds"), + ), dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { yield* Effect.annotateCurrentSpan({ action }); const existingWindow = yield* focusedMainWindow; From b11feec4abf8a69c1dfcf66907963de4e6ff836c Mon Sep 17 00:00:00 2001 From: Anirudh Coontoor Date: Fri, 10 Jul 2026 17:14:15 +0530 Subject: [PATCH 04/10] fix(desktop): preserve minimized window bounds --- apps/desktop/src/window/DesktopWindow.test.ts | 19 +++++++++++-------- apps/desktop/src/window/DesktopWindow.ts | 7 +++++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index e2e89563d5a..e4e64b0eeb4 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -562,11 +562,11 @@ describe("DesktopWindow", () => { }), ); - it.effect("does not persist minimized window bounds", () => + it.effect("flushes normal bounds when minimized before the debounce completes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); - fakeWindow.isMinimized.mockReturnValue(true); fakeWindow.getBounds.mockReturnValue({ x: -32_000, y: -32_000, width: 160, height: 28 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 180, y: 120, width: 1440, height: 960 }); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; @@ -581,16 +581,19 @@ describe("DesktopWindow", () => { const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - const close = fakeWindow.windowListeners.get("close"); - if (!close) { - return yield* Effect.die("window close listener was not registered"); + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); } - close(); + resize(); + yield* TestClock.adjust(250); + fakeWindow.isMinimized.mockReturnValue(true); + yield* desktopWindow.flushMainWindowBounds; - assert.deepEqual(mainWindowBoundsUpdates, []); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 180, y: 120, width: 1440, height: 960 }]); assert.equal(fakeWindow.getBounds.mock.calls.length, 0); - assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 2e98d0e7204..4bfa380d002 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -331,10 +331,13 @@ export const make = Effect.gen(function* () { let boundsPersistFiber: Fiber.Fiber | undefined; let pendingBoundsPersistFiber: Fiber.Fiber | undefined; const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { - if (window.isDestroyed() || window.isFullScreen() || window.isMinimized()) { + if (window.isDestroyed() || window.isFullScreen()) { return null; } - const bounds = window.isMaximized() ? window.getNormalBounds() : window.getBounds(); + const bounds = + window.isMaximized() || window.isMinimized() + ? window.getNormalBounds() + : window.getBounds(); const x = Math.round(bounds.x); const y = Math.round(bounds.y); const width = Math.round(bounds.width); From 10f5dcb931637705c80a1c42d1228f1c2edc0aa9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Jul 2026 11:25:47 +0200 Subject: [PATCH 05/10] fix(desktop): preserve normal bounds while fullscreen Use BrowserWindow normal bounds during fullscreen persistence so an in-flight resize is not discarded on quit. Co-authored-by: codex --- apps/desktop/src/window/DesktopWindow.test.ts | 22 +++++++++++-------- apps/desktop/src/window/DesktopWindow.ts | 4 ++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index de65b677379..45f85989414 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -530,10 +530,11 @@ describe("DesktopWindow", () => { }), ); - it.effect("does not persist fullscreen bounds", () => + it.effect("flushes normal bounds when fullscreen before the debounce completes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); - fakeWindow.isFullScreen.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 200, y: 130, width: 1400, height: 940 }); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; @@ -548,16 +549,19 @@ describe("DesktopWindow", () => { const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - const close = fakeWindow.windowListeners.get("close"); - if (!close) { - return yield* Effect.die("window close listener was not registered"); + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); } - close(); - yield* Effect.promise(() => Promise.resolve()); + resize(); + yield* TestClock.adjust(250); + fakeWindow.isFullScreen.mockReturnValue(true); - assert.deepEqual(mainWindowBoundsUpdates, []); + yield* desktopWindow.flushMainWindowBounds; + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 200, y: 130, width: 1400, height: 940 }]); assert.equal(fakeWindow.getBounds.mock.calls.length, 0); - assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index f31c0845d32..8e850604092 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -331,11 +331,11 @@ export const make = Effect.gen(function* () { let boundsPersistFiber: Fiber.Fiber | undefined; let pendingBoundsPersistFiber: Fiber.Fiber | undefined; const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { - if (window.isDestroyed() || window.isFullScreen()) { + if (window.isDestroyed()) { return null; } const bounds = - window.isMaximized() || window.isMinimized() + window.isFullScreen() || window.isMaximized() || window.isMinimized() ? window.getNormalBounds() : window.getBounds(); const x = Math.round(bounds.x); From 261b057455f844c539d0430acbb258a9aaa09349 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Jul 2026 14:00:20 +0200 Subject: [PATCH 06/10] Persist current bounds for maximized desktop windows - Save bounds on native maximize and unmaximize events - Update coverage for maximized window persistence --- apps/desktop/src/window/DesktopWindow.test.ts | 41 +++++++++++++++++-- apps/desktop/src/window/DesktopWindow.ts | 4 +- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 45f85989414..5bb2fc1a036 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -496,7 +496,7 @@ describe("DesktopWindow", () => { }), ); - it.effect("persists normal bounds for a maximized window", () => + it.effect("persists current bounds for a maximized window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); fakeWindow.isMaximized.mockReturnValue(true); @@ -523,9 +523,42 @@ describe("DesktopWindow", () => { close(); yield* Effect.promise(() => Promise.resolve()); - assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); - assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); - assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 0, y: 0, width: 1920, height: 1080 }]); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getBounds.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("persists maximized bounds from the native maximize event", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const maximize = fakeWindow.windowListeners.get("maximize"); + if (!maximize) { + return yield* Effect.die("window maximize listener was not registered"); + } + + fakeWindow.isMaximized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + maximize(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 0, y: 0, width: 1920, height: 1080 }]); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 8e850604092..d0734e23208 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -335,7 +335,7 @@ export const make = Effect.gen(function* () { return null; } const bounds = - window.isFullScreen() || window.isMaximized() || window.isMinimized() + window.isFullScreen() || window.isMinimized() ? window.getNormalBounds() : window.getBounds(); const x = Math.round(bounds.x); @@ -487,6 +487,8 @@ export const make = Effect.gen(function* () { }); window.on("resize", scheduleBoundsPersist); window.on("move", scheduleBoundsPersist); + window.on("maximize", scheduleBoundsPersist); + window.on("unmaximize", scheduleBoundsPersist); window.on("close", () => { runFork(flushBoundsPersist); }); From 24143ef3c3284ef5b3eae6b7b939f39d79f179de Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Jul 2026 14:17:54 +0200 Subject: [PATCH 07/10] Restore sidebar width before first paint Co-authored-by: codex --- apps/web/src/components/AppSidebarLayout.tsx | 33 +++++++++++++++---- .../src/components/threadSidebarWidth.test.ts | 21 ++++++++++++ apps/web/src/components/threadSidebarWidth.ts | 9 +++++ apps/web/src/components/ui/sidebar.tsx | 4 ++- 4 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/threadSidebarWidth.test.ts create mode 100644 apps/web/src/components/threadSidebarWidth.ts diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index ee56858bc58..51a00a22692 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,13 +1,20 @@ import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; +import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import ThreadSidebar from "./Sidebar"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; +import { + resolveInitialThreadSidebarWidth, + THREAD_SIDEBAR_MIN_WIDTH, + THREAD_SIDEBAR_WIDTH_STORAGE_KEY, +} from "./threadSidebarWidth"; import { Sidebar, SidebarProvider, @@ -18,11 +25,20 @@ import { } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; -const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; +function readInitialThreadSidebarWidth(): number { + try { + return resolveInitialThreadSidebarWidth( + getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite), + ); + } catch (error) { + console.error("Could not read persisted thread sidebar width.", error); + return resolveInitialThreadSidebarWidth(null); + } +} + function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); @@ -82,16 +98,19 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); + const [initialSidebarWidth] = useState(readInitialThreadSidebarWidth); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; return isMacosDesktop && typeof getWindowFullscreenState === "function" ? getWindowFullscreenState() : false; }); - const macosWindowControlsStyle = - isMacosDesktop && !isWindowFullscreen - ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) - : undefined; + const sidebarProviderStyle = { + "--sidebar-width": `${initialSidebarWidth}px`, + ...(isMacosDesktop && !isWindowFullscreen + ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } + : {}), + } as CSSProperties; useEffect(() => { if (!isMacosDesktop) return; @@ -131,7 +150,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate, pathname]); return ( - + { + it("uses the default width when no preference is stored", () => { + expect(resolveInitialThreadSidebarWidth(null)).toBe(THREAD_SIDEBAR_DEFAULT_WIDTH); + }); + + it("uses a stored width in the initial render", () => { + expect(resolveInitialThreadSidebarWidth(360)).toBe(360); + }); + + it("clamps a stored width to the sidebar minimum", () => { + expect(resolveInitialThreadSidebarWidth(120)).toBe(THREAD_SIDEBAR_MIN_WIDTH); + }); +}); diff --git a/apps/web/src/components/threadSidebarWidth.ts b/apps/web/src/components/threadSidebarWidth.ts new file mode 100644 index 00000000000..bc6ee46a518 --- /dev/null +++ b/apps/web/src/components/threadSidebarWidth.ts @@ -0,0 +1,9 @@ +export const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; +export const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16; +export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; + +export function resolveInitialThreadSidebarWidth(storedWidth: number | null): number { + return storedWidth === null + ? THREAD_SIDEBAR_DEFAULT_WIDTH + : Math.max(THREAD_SIDEBAR_MIN_WIDTH, storedWidth); +} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 50fb652a495..10e5135a824 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -555,7 +555,7 @@ function SidebarRail({ [onClick, open, resolvedResizable, toggleSidebar], ); - React.useEffect(() => { + React.useLayoutEffect(() => { if (!resolvedResizable?.storageKey || typeof window === "undefined") return; const rail = railRef.current; if (!rail) return; @@ -565,6 +565,8 @@ function SidebarRail({ const storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite); if (storedWidth === null) return; const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable); + // Hydrate the CSS variable before the browser paints so a restored sidebar + // never flashes at the default width first. wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`); resolvedResizable.onResize?.(clampedWidth); }, [resolvedResizable]); From da1f349db280ac408bc36011a73f856dc58b1e54 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Jul 2026 14:27:14 +0200 Subject: [PATCH 08/10] Address window bounds persistence review Co-authored-by: codex --- .../src/settings/DesktopAppSettings.test.ts | 12 +- .../src/settings/DesktopAppSettings.ts | 27 +++- apps/desktop/src/window/DesktopWindow.test.ts | 125 ++++++++++++++++-- apps/desktop/src/window/DesktopWindow.ts | 52 ++++++-- 4 files changed, 192 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 13960d07982..3878b0e36ad 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -21,6 +21,7 @@ const DesktopSettingsPatch = Schema.Struct({ }), ), ), + mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -102,6 +103,7 @@ describe("DesktopSettings", () => { DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -128,6 +130,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -233,6 +236,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, + mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -252,11 +256,13 @@ describe("DesktopSettings", () => { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, + mainWindowMaximized: true, serverExposureMode: "network-accessible", }); const loaded = yield* settings.load; assert.isNull(loaded.mainWindowBounds); + assert.isFalse(loaded.mainWindowMaximized); assert.equal(loaded.serverExposureMode, "network-accessible"); }), ), @@ -269,7 +275,7 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }); + yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( @@ -277,6 +283,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(persisted, { mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, + mainWindowMaximized: true, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); }), @@ -294,6 +301,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -320,6 +328,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -345,6 +354,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: true, tailscaleServePort: 443, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index c518db61937..466c9a9b5f8 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -21,6 +21,7 @@ import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { readonly mainWindowBounds: DesktopWindowBounds | null; + readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; @@ -67,6 +68,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, @@ -86,6 +88,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ const DesktopSettingsDocument = Schema.Struct({ mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), + mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -143,6 +146,7 @@ export class DesktopAppSettings extends Context.Service< readonly get: Effect.Effect; readonly setMainWindowBounds: ( bounds: DesktopWindowBounds, + isMaximized: boolean, ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, @@ -188,7 +192,7 @@ function normalizeWslDistro(value: unknown): string | null { return typeof value === "string" && isValidDistroName(value) ? value : null; } -function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null { +export function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null { return Option.getOrNull(decodeDesktopWindowBounds(value)); } @@ -197,6 +201,7 @@ function normalizeDesktopSettingsDocument( appVersion: string, ): DesktopSettings { const defaultSettings = resolveDefaultDesktopSettings(appVersion); + const mainWindowBounds = normalizeMainWindowBounds(parsed.mainWindowBounds); const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; const updateChannelConfiguredByUser = @@ -211,7 +216,8 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { - mainWindowBounds: normalizeMainWindowBounds(parsed.mainWindowBounds), + mainWindowBounds, + mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, @@ -235,6 +241,9 @@ function toDesktopSettingsDocument( if (settings.mainWindowBounds !== null) { document.mainWindowBounds = settings.mainWindowBounds; } + if (settings.mainWindowMaximized) { + document.mainWindowMaximized = true; + } if (settings.serverExposureMode !== defaults.serverExposureMode) { document.serverExposureMode = settings.serverExposureMode; } @@ -278,13 +287,16 @@ function setServerExposureMode( function setMainWindowBounds( settings: DesktopSettings, bounds: DesktopWindowBounds, + isMaximized: boolean, ): DesktopSettings { return settings.mainWindowBounds !== null && - desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) + desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) && + settings.mainWindowMaximized === isMaximized ? settings : { ...settings, mainWindowBounds: bounds, + mainWindowMaximized: isMaximized, }; } @@ -482,14 +494,15 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), - setMainWindowBounds: (bounds) => - persist((settings) => setMainWindowBounds(settings, bounds)).pipe( + setMainWindowBounds: (bounds, isMaximized) => + persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( Effect.withSpan("desktop.settings.setMainWindowBounds", { attributes: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, + isMaximized, }, }), ), @@ -550,8 +563,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), - setMainWindowBounds: (bounds) => - update((settings) => setMainWindowBounds(settings, bounds)), + setMainWindowBounds: (bounds, isMaximized) => + update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 5bb2fc1a036..a14244002c7 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -87,6 +87,7 @@ function makeFakeBrowserWindow() { isMinimized: vi.fn(() => false), isVisible: vi.fn(() => true), loadURL: vi.fn(() => Promise.resolve()), + maximize: vi.fn(), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { windowListeners.set(eventName, listener); }), @@ -109,6 +110,7 @@ function makeFakeBrowserWindow() { isMaximized: window.isMaximized, isMinimized: window.isMinimized, loadURL: window.loadURL, + maximize: window.maximize, openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, @@ -177,6 +179,7 @@ function makeTestLayer(input: { readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; readonly desktopSettings?: DesktopAppSettings.DesktopSettings; readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; + readonly mainWindowMaximizedUpdates?: boolean[]; readonly beforeMainWindowBoundsUpdate?: ( bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; @@ -186,20 +189,23 @@ function makeTestLayer(input: { const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.sync(() => desktopSettings), load: Effect.sync(() => desktopSettings), - setMainWindowBounds: (bounds) => + setMainWindowBounds: (bounds, isMaximized) => Effect.gen(function* () { if (input.beforeMainWindowBoundsUpdate) { yield* input.beforeMainWindowBoundsUpdate(bounds); } const changed = desktopSettings.mainWindowBounds === null || - !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds); + !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds) || + desktopSettings.mainWindowMaximized !== isMaximized; if (changed) { desktopSettings = { ...desktopSettings, mainWindowBounds: bounds, + mainWindowMaximized: isMaximized, }; input.mainWindowBoundsUpdates?.push(bounds); + input.mainWindowMaximizedUpdates?.push(isMaximized); } return { settings: desktopSettings, changed }; }), @@ -457,6 +463,31 @@ describe("DesktopWindow", () => { }), ); + it.effect("restores the persisted maximized state", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + mainWindowMaximized: true, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(fakeWindow.maximize.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("debounces move and resize bounds updates", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -496,7 +527,7 @@ describe("DesktopWindow", () => { }), ); - it.effect("persists current bounds for a maximized window", () => + it.effect("persists normal bounds and state for a maximized window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); fakeWindow.isMaximized.mockReturnValue(true); @@ -505,11 +536,13 @@ describe("DesktopWindow", () => { const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowMaximizedUpdates: boolean[] = []; const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow, mainWindowBoundsUpdates, + mainWindowMaximizedUpdates, }); yield* Effect.gen(function* () { @@ -523,24 +556,27 @@ describe("DesktopWindow", () => { close(); yield* Effect.promise(() => Promise.resolve()); - assert.deepEqual(mainWindowBoundsUpdates, [{ x: 0, y: 0, width: 1920, height: 1080 }]); - assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 0); - assert.equal(fakeWindow.getBounds.mock.calls.length, 1); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); + assert.deepEqual(mainWindowMaximizedUpdates, [true]); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); }).pipe(Effect.provide(layer)); }), ); - it.effect("persists maximized bounds from the native maximize event", () => + it.effect("persists normal bounds and state from the native maximize event", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowMaximizedUpdates: boolean[] = []; const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow, mainWindowBoundsUpdates, + mainWindowMaximizedUpdates, }); yield* Effect.gen(function* () { @@ -554,11 +590,84 @@ describe("DesktopWindow", () => { fakeWindow.isMaximized.mockReturnValue(true); fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); maximize(); yield* TestClock.adjust(500); yield* Effect.promise(() => Promise.resolve()); - assert.deepEqual(mainWindowBoundsUpdates, [{ x: 0, y: 0, width: 1920, height: 1080 }]); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); + assert.deepEqual(mainWindowMaximizedUpdates, [true]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("does not persist bounds that fail the domain schema", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 100.4, y: 80.2, width: 839.4, height: 619.4 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); + } + resize(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, []); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("preserves unrestorable bounds until the user changes the window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 2040, y: 80, width: 1320, height: 880 }, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + const move = fakeWindow.windowListeners.get("move"); + if (!close || !move) { + return yield* Effect.die("window lifecycle listeners were not registered"); + } + + close(); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, []); + + fakeWindow.getBounds.mockReturnValue({ x: 80, y: 60, width: 1280, height: 840 }); + move(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 80, y: 60, width: 1280, height: 840 }]); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index d0734e23208..62c70937768 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -117,6 +117,18 @@ function windowFitsWithinDisplay( ); } +function windowBoundsEqual( + left: DesktopAppSettings.DesktopWindowBounds, + right: DesktopAppSettings.DesktopWindowBounds, +): boolean { + return ( + left.x === right.x && + left.y === right.y && + left.width === right.width && + left.height === right.height + ); +} + export function resolveInitialMainWindowBounds( persistedBounds: DesktopAppSettings.DesktopWindowBounds | null, displays: readonly DisplayBounds[], @@ -283,7 +295,8 @@ export const make = Effect.gen(function* () { const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; - const persistedBounds = (yield* desktopSettings.get).mainWindowBounds; + const persistedSettings = yield* desktopSettings.get; + const persistedBounds = persistedSettings.mainWindowBounds; const displayBoundsResult = yield* Effect.sync(() => { try { return { @@ -301,6 +314,7 @@ export const make = Effect.gen(function* () { cause: displayBoundsResult.cause, }).pipe(Effect.as([])); const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); + const restoredPersistedBounds = persistedBounds !== null && initialBounds === persistedBounds; if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); } @@ -327,30 +341,40 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.setAutoHideCursor(false); } + if (persistedSettings.mainWindowMaximized) { + window.maximize(); + } let boundsPersistFiber: Fiber.Fiber | undefined; let pendingBoundsPersistFiber: Fiber.Fiber | undefined; + let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { if (window.isDestroyed()) { return null; } const bounds = - window.isFullScreen() || window.isMinimized() + window.isFullScreen() || window.isMaximized() || window.isMinimized() ? window.getNormalBounds() : window.getBounds(); - const x = Math.round(bounds.x); - const y = Math.round(bounds.y); - const width = Math.round(bounds.width); - const height = Math.round(bounds.height); - return { x, y, width, height }; + return DesktopAppSettings.normalizeMainWindowBounds({ + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.round(bounds.width), + height: Math.round(bounds.height), + }); }; + const fallbackWindowBounds = boundsPersistenceEnabled ? null : readPersistableBounds(); + const fallbackWindowMaximized = window.isMaximized(); const persistCurrentBounds = (): Fiber.Fiber | undefined => { + if (!boundsPersistenceEnabled) { + return pendingBoundsPersistFiber; + } const bounds = readPersistableBounds(); if (bounds === null) { return pendingBoundsPersistFiber; } pendingBoundsPersistFiber = runFork( - desktopSettings.setMainWindowBounds(bounds).pipe( + desktopSettings.setMainWindowBounds(bounds, window.isMaximized()).pipe( Effect.asVoid, Effect.catch((error) => logWindowWarning("failed to persist main window bounds", { @@ -362,6 +386,18 @@ export const make = Effect.gen(function* () { return pendingBoundsPersistFiber; }; const scheduleBoundsPersist = () => { + if (!boundsPersistenceEnabled) { + const currentBounds = readPersistableBounds(); + if ( + currentBounds === null || + (fallbackWindowBounds !== null && + windowBoundsEqual(currentBounds, fallbackWindowBounds) && + window.isMaximized() === fallbackWindowMaximized) + ) { + return; + } + } + boundsPersistenceEnabled = true; if (boundsPersistFiber !== undefined) { const fiber = boundsPersistFiber; boundsPersistFiber = undefined; From f8f445d78f051209068c4c114d851079608280b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Jul 2026 14:34:28 +0200 Subject: [PATCH 09/10] Address sidebar persistence review Co-authored-by: codex --- apps/web/src/components/AppSidebarLayout.tsx | 13 ++++++++---- .../src/components/threadSidebarWidth.test.ts | 19 ++++++++++++++--- apps/web/src/components/threadSidebarWidth.ts | 21 +++++++++++++++---- apps/web/src/components/ui/sidebar.tsx | 8 ++++++- 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 51a00a22692..6c692dc3de8 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -12,6 +12,8 @@ import ThreadSidebar from "./Sidebar"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { resolveInitialThreadSidebarWidth, + resolveThreadSidebarMaximumWidth, + THREAD_MAIN_CONTENT_MIN_WIDTH, THREAD_SIDEBAR_MIN_WIDTH, THREAD_SIDEBAR_WIDTH_STORAGE_KEY, } from "./threadSidebarWidth"; @@ -25,17 +27,17 @@ import { } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; function readInitialThreadSidebarWidth(): number { try { return resolveInitialThreadSidebarWidth( getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite), + window.innerWidth, ); } catch (error) { console.error("Could not read persisted thread sidebar width.", error); - return resolveInitialThreadSidebarWidth(null); + return resolveInitialThreadSidebarWidth(null, window.innerWidth); } } @@ -98,7 +100,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); - const [initialSidebarWidth] = useState(readInitialThreadSidebarWidth); + const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); + const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; return isMacosDesktop && typeof getWindowFullscreenState === "function" @@ -106,7 +109,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { : false; }); const sidebarProviderStyle = { - "--sidebar-width": `${initialSidebarWidth}px`, + "--sidebar-width": `${sidebarWidth}px`, ...(isMacosDesktop && !isWindowFullscreen ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } : {}), @@ -156,11 +159,13 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { collapsible="offcanvas" className="border-r border-border bg-card text-foreground" resizable={{ + maxWidth: sidebarMaximumWidth, minWidth: THREAD_SIDEBAR_MIN_WIDTH, shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => nextWidth <= currentWidth || wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + onResize: setSidebarWidth, }} > diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts index a06ddf8d0f4..12aef63bd5e 100644 --- a/apps/web/src/components/threadSidebarWidth.test.ts +++ b/apps/web/src/components/threadSidebarWidth.test.ts @@ -2,20 +2,33 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveInitialThreadSidebarWidth, + THREAD_MAIN_CONTENT_MIN_WIDTH, THREAD_SIDEBAR_DEFAULT_WIDTH, THREAD_SIDEBAR_MIN_WIDTH, } from "./threadSidebarWidth"; describe("thread sidebar width", () => { it("uses the default width when no preference is stored", () => { - expect(resolveInitialThreadSidebarWidth(null)).toBe(THREAD_SIDEBAR_DEFAULT_WIDTH); + expect(resolveInitialThreadSidebarWidth(null, 1200)).toBe(THREAD_SIDEBAR_DEFAULT_WIDTH); }); it("uses a stored width in the initial render", () => { - expect(resolveInitialThreadSidebarWidth(360)).toBe(360); + expect(resolveInitialThreadSidebarWidth(360, 1200)).toBe(360); }); it("clamps a stored width to the sidebar minimum", () => { - expect(resolveInitialThreadSidebarWidth(120)).toBe(THREAD_SIDEBAR_MIN_WIDTH); + expect(resolveInitialThreadSidebarWidth(120, 1200)).toBe(THREAD_SIDEBAR_MIN_WIDTH); + }); + + it("leaves enough room for the main content on a smaller window", () => { + const viewportWidth = 1000; + + expect(resolveInitialThreadSidebarWidth(900, viewportWidth)).toBe( + viewportWidth - THREAD_MAIN_CONTENT_MIN_WIDTH, + ); + }); + + it("keeps the sidebar minimum when the whole layout is narrower than its minimums", () => { + expect(resolveInitialThreadSidebarWidth(900, 700)).toBe(THREAD_SIDEBAR_MIN_WIDTH); }); }); diff --git a/apps/web/src/components/threadSidebarWidth.ts b/apps/web/src/components/threadSidebarWidth.ts index bc6ee46a518..93dd196e84d 100644 --- a/apps/web/src/components/threadSidebarWidth.ts +++ b/apps/web/src/components/threadSidebarWidth.ts @@ -1,9 +1,22 @@ export const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; export const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16; export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; +export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; -export function resolveInitialThreadSidebarWidth(storedWidth: number | null): number { - return storedWidth === null - ? THREAD_SIDEBAR_DEFAULT_WIDTH - : Math.max(THREAD_SIDEBAR_MIN_WIDTH, storedWidth); +export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number { + return Math.max( + THREAD_SIDEBAR_MIN_WIDTH, + Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH, + ); +} + +export function resolveInitialThreadSidebarWidth( + storedWidth: number | null, + viewportWidth: number, +): number { + const preferredWidth = + storedWidth === null + ? THREAD_SIDEBAR_DEFAULT_WIDTH + : Math.max(THREAD_SIDEBAR_MIN_WIDTH, storedWidth); + return Math.min(preferredWidth, resolveThreadSidebarMaximumWidth(viewportWidth)); } diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 10e5135a824..51335d1a6f8 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -562,7 +562,13 @@ function SidebarRail({ const wrapper = rail.closest("[data-slot='sidebar-wrapper']"); if (!wrapper) return; - const storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite); + let storedWidth: number | null; + try { + storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite); + } catch (error) { + console.error("Could not restore persisted sidebar width.", error); + return; + } if (storedWidth === null) return; const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable); // Hydrate the CSS variable before the browser paints so a restored sidebar From c28dfce54a780df715eceb23e1f6abba081e68c7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Jul 2026 14:36:08 +0200 Subject: [PATCH 10/10] Defer maximized restore until window reveal Co-authored-by: codex --- apps/desktop/src/window/DesktopWindow.test.ts | 10 +++++++++- apps/desktop/src/window/DesktopWindow.ts | 9 ++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index a14244002c7..587da8d4431 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -91,7 +91,9 @@ function makeFakeBrowserWindow() { on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { windowListeners.set(eventName, listener); }), - once: vi.fn(), + once: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), restore: vi.fn(), setBackgroundColor: vi.fn(), setAutoHideCursor: vi.fn(), @@ -483,6 +485,12 @@ describe("DesktopWindow", () => { const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(fakeWindow.maximize.mock.calls.length, 0); + const readyToShow = fakeWindow.windowListeners.get("ready-to-show"); + if (!readyToShow) { + return yield* Effect.die("window ready-to-show listener was not registered"); + } + readyToShow(); assert.equal(fakeWindow.maximize.mock.calls.length, 1); }).pipe(Effect.provide(layer)); }), diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 62c70937768..db4b698434d 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -341,10 +341,6 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.setAutoHideCursor(false); } - if (persistedSettings.mainWindowMaximized) { - window.maximize(); - } - let boundsPersistFiber: Fiber.Fiber | undefined; let pendingBoundsPersistFiber: Fiber.Fiber | undefined; let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; @@ -364,7 +360,7 @@ export const make = Effect.gen(function* () { }); }; const fallbackWindowBounds = boundsPersistenceEnabled ? null : readPersistableBounds(); - const fallbackWindowMaximized = window.isMaximized(); + const fallbackWindowMaximized = persistedSettings.mainWindowMaximized; const persistCurrentBounds = (): Fiber.Fiber | undefined => { if (!boundsPersistenceEnabled) { return pendingBoundsPersistFiber; @@ -636,6 +632,9 @@ export const make = Effect.gen(function* () { bindFirstRevealTrigger(revealSubscribers, () => { // Reveal the real window, then close the connecting splash (if any) so the // two don't overlap and there's no blank gap between them. + if (persistedSettings.mainWindowMaximized) { + window.maximize(); + } void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); });