From 81b14c47a9da682834b89854c63c35613f996088 Mon Sep 17 00:00:00 2001 From: Andrea Bergonzo Date: Fri, 4 Sep 2026 19:28:00 +0100 Subject: [PATCH 1/3] fix(desktop): restore macOS fullscreen state Persist native fullscreen alongside the existing normal bounds so update and restart relaunches can return to the same macOS window mode. Keep the behavior gated to macOS and cover persistence, restoration, and lifecycle transitions. --- .../src/settings/DesktopAppSettings.test.ts | 17 ++++- .../src/settings/DesktopAppSettings.ts | 22 +++++-- apps/desktop/src/window/DesktopWindow.test.ts | 65 ++++++++++++++++++- apps/desktop/src/window/DesktopWindow.ts | 49 ++++++++++---- 4 files changed, 131 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749abe9..6f5b41989755 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -24,6 +24,7 @@ const DesktopSettingsPatch = Schema.Struct({ }), ), ), + mainWindowFullscreen: Schema.optionalKey(Schema.Boolean), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), @@ -107,6 +108,7 @@ describe("DesktopSettings", () => { { linuxPasswordStore: "auto", mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, @@ -136,6 +138,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "gnome-libsecret", mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, @@ -243,6 +246,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, @@ -263,12 +267,14 @@ describe("DesktopSettings", () => { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, + mainWindowFullscreen: true, mainWindowMaximized: true, serverExposureMode: "network-accessible", }); const loaded = yield* settings.load; assert.isNull(loaded.mainWindowBounds); + assert.isFalse(loaded.mainWindowFullscreen); assert.isFalse(loaded.mainWindowMaximized); assert.equal(loaded.serverExposureMode, "network-accessible"); }), @@ -299,6 +305,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, @@ -320,7 +327,11 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); + yield* settings.setMainWindowBounds( + { x: -1200, y: 40, width: 1440, height: 960 }, + true, + true, + ); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( @@ -328,6 +339,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(persisted, { mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, + mainWindowFullscreen: true, mainWindowMaximized: true, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); @@ -347,6 +359,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, @@ -375,6 +388,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, @@ -402,6 +416,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: true, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 3bd235018022..c4f76347fa37 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -27,6 +27,7 @@ import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly mainWindowBounds: DesktopWindowBounds | null; + readonly mainWindowFullscreen: boolean; readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; readonly tailscaleServeEnabled: boolean; @@ -75,6 +76,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, @@ -96,6 +98,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ const DesktopSettingsDocument = Schema.Struct({ linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), + mainWindowFullscreen: Schema.optionalKey(Schema.Boolean), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), @@ -155,6 +158,7 @@ export class DesktopAppSettings extends Context.Service< readonly setMainWindowBounds: ( bounds: DesktopWindowBounds, isMaximized: boolean, + isFullscreen: boolean, ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, @@ -226,6 +230,7 @@ function normalizeDesktopSettingsDocument( return { linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, + mainWindowFullscreen: mainWindowBounds !== null && parsed.mainWindowFullscreen === true, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", @@ -253,6 +258,9 @@ function toDesktopSettingsDocument( if (settings.mainWindowBounds !== null) { document.mainWindowBounds = settings.mainWindowBounds; } + if (settings.mainWindowFullscreen) { + document.mainWindowFullscreen = true; + } if (settings.mainWindowMaximized) { document.mainWindowMaximized = true; } @@ -300,14 +308,17 @@ function setMainWindowBounds( settings: DesktopSettings, bounds: DesktopWindowBounds, isMaximized: boolean, + isFullscreen: boolean, ): DesktopSettings { return settings.mainWindowBounds !== null && desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) && - settings.mainWindowMaximized === isMaximized + settings.mainWindowMaximized === isMaximized && + settings.mainWindowFullscreen === isFullscreen ? settings : { ...settings, mainWindowBounds: bounds, + mainWindowFullscreen: isFullscreen, mainWindowMaximized: isMaximized, }; } @@ -507,14 +518,15 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), - setMainWindowBounds: (bounds, isMaximized) => - persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( + setMainWindowBounds: (bounds, isMaximized, isFullscreen) => + persist((settings) => setMainWindowBounds(settings, bounds, isMaximized, isFullscreen)).pipe( Effect.withSpan("desktop.settings.setMainWindowBounds", { attributes: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, + isFullscreen, isMaximized, }, }), @@ -576,8 +588,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), - setMainWindowBounds: (bounds, isMaximized) => - update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), + setMainWindowBounds: (bounds, isMaximized, isFullscreen) => + update((settings) => setMainWindowBounds(settings, bounds, isMaximized, isFullscreen)), 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 7bbb5c1da024..c3174476388a 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -213,6 +213,7 @@ function makeTestLayer(input: { readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; readonly desktopSettings?: DesktopAppSettings.DesktopSettings; readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; + readonly mainWindowFullscreenUpdates?: boolean[]; readonly mainWindowMaximizedUpdates?: boolean[]; readonly beforeMainWindowBoundsUpdate?: ( bounds: DesktopAppSettings.DesktopWindowBounds, @@ -227,7 +228,7 @@ function makeTestLayer(input: { const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.sync(() => desktopSettings), load: Effect.sync(() => desktopSettings), - setMainWindowBounds: (bounds, isMaximized) => + setMainWindowBounds: (bounds, isMaximized, isFullscreen) => Effect.gen(function* () { if (input.beforeMainWindowBoundsUpdate) { yield* input.beforeMainWindowBoundsUpdate(bounds); @@ -235,14 +236,17 @@ function makeTestLayer(input: { const changed = desktopSettings.mainWindowBounds === null || !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds) || + desktopSettings.mainWindowFullscreen !== isFullscreen || desktopSettings.mainWindowMaximized !== isMaximized; if (changed) { desktopSettings = { ...desktopSettings, mainWindowBounds: bounds, + mainWindowFullscreen: isFullscreen, mainWindowMaximized: isMaximized, }; input.mainWindowBoundsUpdates?.push(bounds); + input.mainWindowFullscreenUpdates?.push(isFullscreen); input.mainWindowMaximizedUpdates?.push(isMaximized); } return { settings: desktopSettings, changed }; @@ -620,6 +624,7 @@ describe("DesktopWindow", () => { assert.equal(createdWindowOptions[0]?.height, 780); assert.isUndefined(createdWindowOptions[0]?.x); assert.isUndefined(createdWindowOptions[0]?.y); + assert.isUndefined(createdWindowOptions[0]?.fullscreen); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.isFalse(createdWindowOptions[0]?.webPreferences?.backgroundThrottling); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); @@ -770,6 +775,48 @@ describe("DesktopWindow", () => { }), ); + it.effect("restores the persisted native macOS fullscreen state", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const mainWindowFullscreenUpdates: boolean[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + mainWindowFullscreenUpdates, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + mainWindowFullscreen: true, + mainWindowMaximized: true, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(createdWindowOptions[0]?.fullscreen, true); + assert.equal(fakeWindow.setFullScreen.mock.calls.length, 0); + const resize = fakeWindow.windowListeners.get("resize"); + const readyToShow = fakeWindow.windowListeners.get("ready-to-show"); + if (!resize || !readyToShow) { + return yield* Effect.die("window startup listeners were not registered"); + } + resize(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowFullscreenUpdates, [true]); + readyToShow(); + assert.equal(fakeWindow.maximize.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + // The window boots hidden with throttling disabled so first paint runs at // full speed; the first reveal must hand it back to normal hidden-window // throttling or a minimized window stays expensive forever. @@ -991,11 +1038,13 @@ describe("DesktopWindow", () => { const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowFullscreenUpdates: boolean[] = []; const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow, mainWindowBoundsUpdates, + mainWindowFullscreenUpdates, }); yield* Effect.gen(function* () { @@ -1013,6 +1062,7 @@ describe("DesktopWindow", () => { yield* desktopWindow.flushMainWindowBounds; assert.deepEqual(mainWindowBoundsUpdates, [{ x: 200, y: 130, width: 1400, height: 940 }]); + assert.deepEqual(mainWindowFullscreenUpdates, [true]); assert.equal(fakeWindow.getBounds.mock.calls.length, 0); assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); }).pipe(Effect.provide(layer)); @@ -1160,15 +1210,17 @@ describe("DesktopWindow", () => { }), ); - it.effect("publishes native macOS fullscreen changes to the renderer", () => + it.effect("persists and publishes native macOS fullscreen changes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowFullscreenUpdates: boolean[] = []; const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow, + mainWindowFullscreenUpdates, }); yield* Effect.gen(function* () { @@ -1181,8 +1233,17 @@ describe("DesktopWindow", () => { return yield* Effect.die("fullscreen listeners were not registered"); } + fakeWindow.isFullScreen.mockReturnValue(true); enterFullscreen(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + fakeWindow.isFullScreen.mockReturnValue(false); leaveFullscreen(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowFullscreenUpdates, [true, false]); assert.deepEqual(fakeWindow.send.mock.calls, [ [WINDOW_FULLSCREEN_STATE_CHANNEL, true], [WINDOW_FULLSCREEN_STATE_CHANNEL, false], diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 0a966ec36e4d..e6584f0c959e 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -371,6 +371,8 @@ export const make = Effect.gen(function* () { }).pipe(Effect.as([])); const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); const restoredPersistedBounds = persistedBounds !== null && initialBounds === persistedBounds; + const restoreFullscreen = + persistedSettings.mainWindowFullscreen && environment.platform === "darwin"; if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); } @@ -379,6 +381,7 @@ export const make = Effect.gen(function* () { minWidth: 840, minHeight: 620, show: false, + ...(restoreFullscreen ? { fullscreen: true } : {}), autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), @@ -405,6 +408,7 @@ export const make = Effect.gen(function* () { } let boundsPersistFiber: Fiber.Fiber | undefined; let pendingBoundsPersistFiber: Fiber.Fiber | undefined; + let fullscreenRestorePending = restoreFullscreen; let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { if (window.isDestroyed()) { @@ -422,6 +426,7 @@ export const make = Effect.gen(function* () { }); }; const fallbackWindowBounds = boundsPersistenceEnabled ? null : readPersistableBounds(); + const fallbackWindowFullscreen = persistedSettings.mainWindowFullscreen; const fallbackWindowMaximized = persistedSettings.mainWindowMaximized; const persistCurrentBounds = (): Fiber.Fiber | undefined => { if (!boundsPersistenceEnabled) { @@ -432,14 +437,21 @@ export const make = Effect.gen(function* () { return pendingBoundsPersistFiber; } pendingBoundsPersistFiber = runFork( - desktopSettings.setMainWindowBounds(bounds, window.isMaximized()).pipe( - Effect.asVoid, - Effect.catch((error) => - logWindowWarning("failed to persist main window bounds", { - message: error.message, - }), + desktopSettings + .setMainWindowBounds( + bounds, + window.isMaximized(), + environment.platform === "darwin" && + (fullscreenRestorePending || window.isFullScreen()), + ) + .pipe( + Effect.asVoid, + Effect.catch((error) => + logWindowWarning("failed to persist main window bounds", { + message: error.message, + }), + ), ), - ), ); return pendingBoundsPersistFiber; }; @@ -450,7 +462,9 @@ export const make = Effect.gen(function* () { currentBounds === null || (fallbackWindowBounds !== null && windowBoundsEqual(currentBounds, fallbackWindowBounds) && - window.isMaximized() === fallbackWindowMaximized) + window.isMaximized() === fallbackWindowMaximized && + (environment.platform !== "darwin" || + window.isFullScreen() === fallbackWindowFullscreen)) ) { return; } @@ -657,10 +671,14 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.on("enter-full-screen", () => { + fullscreenRestorePending = false; window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); + scheduleBoundsPersist(); }); window.on("leave-full-screen", () => { + fullscreenRestorePending = false; window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false); + scheduleBoundsPersist(); }); } @@ -795,12 +813,15 @@ export const make = Effect.gen(function* () { if (!window.isDestroyed()) { window.webContents.setBackgroundThrottling(true); } - // 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)); + void runPromise( + Effect.gen(function* () { + if (!restoreFullscreen && persistedSettings.mainWindowMaximized) { + window.maximize(); + } + yield* electronWindow.reveal(window); + yield* dismissConnectingSplash; + }), + ); }); loadApplication(); From 810229865206dd77c65229d1ef7698753351b6e4 Mon Sep 17 00:00:00 2001 From: Andrea Bergonzo Date: Fri, 4 Sep 2026 20:35:22 +0100 Subject: [PATCH 2/3] feat(desktop): add restart command Expose the graceful desktop relaunch lifecycle in the command palette so window-state behavior can be exercised without leaving the app. --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.test.ts | 51 +++++++++++++++++++++ apps/desktop/src/ipc/methods/window.ts | 11 +++++ apps/desktop/src/preload.ts | 1 + apps/web/src/components/CommandPalette.tsx | 14 ++++++ docs/user/keybindings.md | 4 ++ packages/contracts/src/ipc.ts | 2 + 8 files changed, 86 insertions(+) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index dc3769bb814f..ddbd678937eb 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -43,6 +43,7 @@ import { pickFolder, pickProjectFavicon, pickThemeFiles, + restartApp, setTheme, showContextMenu, } from "./methods/window.ts"; @@ -123,6 +124,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openExternal); yield* ipc.handle(openSystemSettings); yield* ipc.handle(probeRemoteEditors); + yield* ipc.handle(restartApp); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 43ecee06c0ca..72824ea86c0c 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -11,6 +11,7 @@ export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; +export const RESTART_APP_CHANNEL = "desktop:restart-app"; export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready"; export const DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL = "desktop:app-activation-complete"; export const DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL = "desktop:app-activation-request"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 203151c2660e..e236cd85d2fd 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -6,14 +6,22 @@ import { vi } from "vite-plus/test"; import type * as Electron from "electron"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; +import * as DesktopState from "../../app/DesktopState.ts"; +import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, pickProjectFavicon, + restartApp, } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { @@ -153,6 +161,49 @@ describe("getWindowFullscreenState", () => { }); }); +describe("restartApp", () => { + it.effect("requests a graceful command-palette relaunch", () => { + const relaunchReasons: string[] = []; + const lifecycleLayer = Layer.succeed( + DesktopLifecycle.DesktopLifecycle, + DesktopLifecycle.DesktopLifecycle.of({ + relaunch: (reason) => + Effect.sync(() => { + relaunchReasons.push(reason); + }), + register: Effect.void, + }), + ); + const unusedRuntimeLayer = Layer.mergeAll( + DesktopShutdown.layer, + DesktopState.layer, + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of( + {} as DesktopEnvironment.DesktopEnvironment["Service"], + ), + ), + Layer.succeed( + DesktopWindow.DesktopWindow, + DesktopWindow.DesktopWindow.of({} as DesktopWindow.DesktopWindow["Service"]), + ), + Layer.succeed( + ElectronApp.ElectronApp, + ElectronApp.ElectronApp.of({} as ElectronApp.ElectronApp["Service"]), + ), + Layer.succeed( + ElectronTheme.ElectronTheme, + ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]), + ), + ); + + return Effect.gen(function* () { + yield* restartApp.handler(undefined); + assert.deepEqual(relaunchReasons, ["command-palette"]); + }).pipe(Effect.provide(Layer.merge(lifecycleLayer, unusedRuntimeLayer))); + }); +}); + describe("pickProjectFavicon", () => { it.effect("opens a single-image picker from the project directory", () => Effect.gen(function* () { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 61de1361a311..480ddb6f7dc7 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -25,6 +25,7 @@ import * as Schema from "effect/Schema"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; @@ -86,6 +87,16 @@ export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ }), }); +export const restartApp = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESTART_APP_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.restartApp")(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.relaunch("command-palette"); + }), +}); + export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7da32d7913ae..2efa93f57c03 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -211,6 +211,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); }; }, + restartApp: () => ipcRenderer.invoke(IpcChannels.RESTART_APP_CHANNEL), getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), setUpdateChannel: (channel) => ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d146120f719d..5302db4a6ea6 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -46,6 +46,7 @@ import { LinkIcon, MessageSquareIcon, PaletteIcon, + RotateCwIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -1689,6 +1690,19 @@ function OpenCommandPaletteDialog(props: { }, }); + const restartApp = window.desktopBridge?.restartApp; + if (restartApp) { + actionItems.push({ + kind: "action", + value: "action:restart-app", + searchTerms: ["restart", "relaunch", "reload", "desktop", "app", "t3 code"], + title: "Restart T3 Code", + description: "Active tasks will be interrupted", + icon: , + run: restartApp, + }); + } + // There is no projects listing page; the action targets the contextual // project (active thread/draft, falling back to the first sidebar group). const contextualProjectGroup = diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 4e46e97a3a56..e017f3fad862 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -60,6 +60,10 @@ shortcut; assign one in **Settings → Keybindings**. `chat.newLocal` skips that chooser. Both use your [new-thread defaults](./thread-sidebar.md#start-a-thread). +Desktop builds include **Restart T3 Code** in the command palette. It gracefully +stops the desktop-managed server before relaunching the app, so saved window state +is flushed first. Active tasks are interrupted. + ## Reserved shortcuts In the desktop app, `mod+w` closes the focused terminal or the active right-panel diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index cd906aecdfce..e56ced594e8c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1316,6 +1316,8 @@ export interface DesktopBridge { onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; + /** Optional while older desktop shells can host a newer web client. */ + restartApp?: () => Promise; getUpdateState: () => Promise; setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; checkForUpdate: () => Promise; From 4c559655d58c0db1e989616abb8828baa28dfb6d Mon Sep 17 00:00:00 2001 From: Andrea Bergonzo Date: Tue, 8 Sep 2026 18:25:06 +0100 Subject: [PATCH 3/3] fix(desktop): preserve bounds during fullscreen restoration --- apps/desktop/src/window/DesktopWindow.test.ts | 59 ++++++++++++++++++- apps/desktop/src/window/DesktopWindow.ts | 9 ++- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index c3174476388a..f6917489a587 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -804,15 +804,22 @@ describe("DesktopWindow", () => { assert.equal(fakeWindow.setFullScreen.mock.calls.length, 0); const resize = fakeWindow.windowListeners.get("resize"); const readyToShow = fakeWindow.windowListeners.get("ready-to-show"); - if (!resize || !readyToShow) { + const enterFullscreen = fakeWindow.windowListeners.get("enter-full-screen"); + if (!resize || !readyToShow || !enterFullscreen) { return yield* Effect.die("window startup listeners were not registered"); } + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); resize(); yield* TestClock.adjust(500); - yield* Effect.promise(() => Promise.resolve()); - assert.deepEqual(mainWindowFullscreenUpdates, [true]); + yield* desktopWindow.flushMainWindowBounds; + assert.deepEqual(mainWindowFullscreenUpdates, []); readyToShow(); assert.equal(fakeWindow.maximize.mock.calls.length, 0); + + fakeWindow.isFullScreen.mockReturnValue(true); + enterFullscreen(); + yield* desktopWindow.flushMainWindowBounds; + assert.deepEqual(mainWindowFullscreenUpdates, [true]); }).pipe(Effect.provide(layer)); }), ); @@ -1030,6 +1037,52 @@ describe("DesktopWindow", () => { }), ); + it.effect("preserves off-display bounds throughout a fullscreen restore", () => + 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 }, + mainWindowFullscreen: true, + }, + }); + + 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"); + const enterFullscreen = fakeWindow.windowListeners.get("enter-full-screen"); + const leaveFullscreen = fakeWindow.windowListeners.get("leave-full-screen"); + if (!resize || !enterFullscreen || !leaveFullscreen) { + return yield* Effect.die("window fullscreen listeners were not registered"); + } + + resize(); + yield* TestClock.adjust(500); + yield* desktopWindow.flushMainWindowBounds; + assert.deepEqual(mainWindowBoundsUpdates, []); + + fakeWindow.isFullScreen.mockReturnValue(true); + enterFullscreen(); + yield* desktopWindow.flushMainWindowBounds; + assert.deepEqual(mainWindowBoundsUpdates, []); + + fakeWindow.isFullScreen.mockReturnValue(false); + leaveFullscreen(); + yield* desktopWindow.flushMainWindowBounds; + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 0, y: 0, width: 1100, height: 780 }]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("flushes normal bounds when fullscreen before the debounce completes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index e6584f0c959e..6efd49692ebe 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -429,7 +429,7 @@ export const make = Effect.gen(function* () { const fallbackWindowFullscreen = persistedSettings.mainWindowFullscreen; const fallbackWindowMaximized = persistedSettings.mainWindowMaximized; const persistCurrentBounds = (): Fiber.Fiber | undefined => { - if (!boundsPersistenceEnabled) { + if (!boundsPersistenceEnabled || fullscreenRestorePending) { return pendingBoundsPersistFiber; } const bounds = readPersistableBounds(); @@ -441,8 +441,7 @@ export const make = Effect.gen(function* () { .setMainWindowBounds( bounds, window.isMaximized(), - environment.platform === "darwin" && - (fullscreenRestorePending || window.isFullScreen()), + environment.platform === "darwin" && window.isFullScreen(), ) .pipe( Effect.asVoid, @@ -456,6 +455,10 @@ export const make = Effect.gen(function* () { return pendingBoundsPersistFiber; }; const scheduleBoundsPersist = () => { + // Native startup transitions do not replace the saved normal window bounds. + if (fullscreenRestorePending) { + return; + } if (!boundsPersistenceEnabled) { const currentBounds = readPersistableBounds(); if (