From ca6ce99c5378bc13621b7dcdc08e4a5cc0568f5a Mon Sep 17 00:00:00 2001 From: Batuhan1999 Date: Fri, 23 Oct 2026 14:02:40 +0200 Subject: [PATCH 1/4] fix(desktop): a server restart no longer reopens a closed window on macOS Backend readiness fires after every automatic restart, not only at launch. On macOS the app keeps running after its last window closes, so a crashed and restarted server created a fresh main window and stole focus from whatever app was in front. A main window the user closed now stays closed until they activate the app (Dock click, second launch, menu); activating during a restart still opens it once the server is back. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/window/DesktopWindow.test.ts | 51 +++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 11 +++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index dbdde033cae7..82601420f91c 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -1083,4 +1083,55 @@ describe("DesktopWindow", () => { }).pipe(Effect.provide(layer)); }), ); + + it.effect("keeps a closed main window closed when the backend restarts", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const onReveal = vi.fn(); + const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow, onReveal }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + + fakeWindow.windowListeners.get("closed")?.(); + assert.isTrue(Option.isNone(yield* Ref.get(mainWindow))); + + yield* desktopWindow.handleBackendNotReady; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + assert.equal(onReveal.mock.calls.length, 0); + + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 2); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect( + "opens the main window when the backend returns after activation during a restart", + () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + fakeWindow.windowListeners.get("closed")?.(); + yield* desktopWindow.handleBackendNotReady; + + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 1); + + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 2); + }).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 297c67ddace0..641cdf18db8d 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -302,6 +302,7 @@ export const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; const backendReadyRef = yield* Ref.make(false); + const mainWindowClosedRef = yield* Ref.make(false); const splashWindowRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); @@ -845,7 +846,11 @@ export const make = Effect.gen(function* () { window.on("closed", () => { clearDevelopmentLoadRetry(); clearBoundsPersist(); - void runPromise(electronWindow.clearMain(Option.some(window))); + void runPromise( + electronWindow + .clearMain(Option.some(window)) + .pipe(Effect.andThen(Ref.set(mainWindowClosedRef, true))), + ); }); return Option.some(window); @@ -996,6 +1001,7 @@ export const make = Effect.gen(function* () { return; } } + yield* Ref.set(mainWindowClosedRef, false); yield* createMainIfBackendReady; }).pipe(Effect.withSpan("desktop.window.activate")), createMainIfBackendReady, @@ -1003,6 +1009,9 @@ export const make = Effect.gen(function* () { handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { yield* Ref.set(backendReadyRef, true); yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); + // Readiness fires again after every backend restart. On macOS the app outlives its + // last window, so a window the user closed waits for activate instead of popping up. + if (yield* Ref.get(mainWindowClosedRef)) return; yield* createMainIfBackendReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( From 7e9df2bb9fe68800cd821b45e9c06cab91668c2e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 22:09:25 +0000 Subject: [PATCH 2/4] fix(desktop): reopen the main window when a restart recovers from a startup failure The recovery window closes only once a visible main window exists. With the main window closed, a restart that failed and later became ready kept the stale recovery window open, because readiness no longer created main. Create it when readiness follows a startup failure, as main does. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/window/DesktopWindow.test.ts | 24 +++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 6 ++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 689d2088b117..76c3db70c2ce 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -9,6 +9,7 @@ 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 SubscriptionRef from "effect/SubscriptionRef"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; @@ -614,6 +615,29 @@ describe("DesktopWindow", () => { }), ); + it.effect("reopens a closed main window when a restart recovers from a startup failure", () => + Effect.gen(function* () { + const fake = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + const state = yield* DesktopState.DesktopState; + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + fake.windowListeners.get("closed")?.(); + yield* windows.handleBackendNotReady; + yield* SubscriptionRef.set(state.primaryBackendStartup, { + _tag: "Failing", + reason: "readiness timed out", + managedPolicy: false, + output: "", + }); + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 2); + }).pipe(Effect.provide(layerTest({ window: fake.window, createCount, mainWindow }))); + }), + ); + it.effect.each(["renderer", "backend"] as const)( "reveals the painted boot shell when %s becomes ready first", (first) => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index f9c9aacfb234..33b98f0c2cc4 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -1161,7 +1162,10 @@ export const make = Effect.gen(function* () { handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { yield* Ref.set(backendReadyRef, true); yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); - if (!mainClosed) yield* createMainIfBackendReady; + // A startup failure surfaced the recovery window, which closes only into a main window. + const recovering = + (yield* SubscriptionRef.get(desktopState.primaryBackendStartup))._tag === "Failing"; + if (!mainClosed || recovering) yield* createMainIfBackendReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), From b4a2b18e20373ca729e7143092a0ff563333b2e0 Mon Sep 17 00:00:00 2001 From: Batuhan1999 Date: Fri, 13 Nov 2026 09:50:10 +0100 Subject: [PATCH 3/4] fix(desktop): a menu action during a restart reopens the closed window A revealing menu action with no window, sent while the backend restarts, returned early without recording the request. mainClosed stayed true, so readiness skipped creating the window and nothing opened. Treat that request like a Dock click: clear mainClosed so the window opens once the backend is back. Non-revealing actions still leave a closed window closed. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/window/DesktopWindow.test.ts | 25 +++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 6 ++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 76c3db70c2ce..d11dad2272fe 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -615,6 +615,31 @@ describe("DesktopWindow", () => { }), ); + it.effect.each([ + ["a revealing menu action", { reveal: true }, 2], + ["a non-revealing menu action", { reveal: false }, 1], + ] as const)( + "after %s during a restart, a closed main window follows the backend", + ([, options, expectedCreates]) => + Effect.gen(function* () { + const fake = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + fake.isDestroyed.mockReturnValue(true); + fake.windowListeners.get("closed")?.(); + yield* windows.handleBackendNotReady; + yield* windows.dispatchMenuAction("open-settings", options); + assert.equal(yield* Ref.get(createCount), 1); + fake.isDestroyed.mockReturnValue(false); + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), expectedCreates); + }).pipe(Effect.provide(layerTest({ window: fake.window, createCount, mainWindow }))); + }), + ); + it.effect("reopens a closed main window when a restart recovers from a startup failure", () => Effect.gen(function* () { const fake = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 33b98f0c2cc4..eb42a2d1f842 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -1108,7 +1108,11 @@ export const make = Effect.gen(function* () { ) { if (yield* Ref.get(desktopState.quitting)) return; const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main; - if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) return; + if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) { + // A revealing request asks for the window, so a closed one opens once the backend is back. + if (reveal) mainClosed = false; + return; + } const window = Option.isSome(existingWindow) ? existingWindow : yield* ensureMain; if (Option.isNone(window) || window.value.isDestroyed()) return; const targetWindow = window.value; From 44b4836c1f7e43f90071446a86cbe61c48c944a4 Mon Sep 17 00:00:00 2001 From: Batuhan1999 Date: Fri, 13 Nov 2026 11:11:28 +0100 Subject: [PATCH 4/4] fix(desktop): keep Dock activation after a menu action during a restart A revealing menu action during a restart cleared mainClosed, so a Dock click before the backend was back no longer opened the window. Track the deferred reveal in its own flag instead. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/window/DesktopWindow.test.ts | 22 +++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 8 ++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index d11dad2272fe..8d1cfb05ccd2 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -640,6 +640,28 @@ describe("DesktopWindow", () => { }), ); + it.effect("a Dock click after a revealing menu action during a restart opens the window", () => + Effect.gen(function* () { + const fake = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + fake.isDestroyed.mockReturnValue(true); + fake.windowListeners.get("closed")?.(); + yield* windows.handleBackendNotReady; + yield* windows.dispatchMenuAction("open-settings", { reveal: true }); + assert.equal(yield* Ref.get(createCount), 1); + fake.isDestroyed.mockReturnValue(false); + yield* windows.activate; + assert.equal(yield* Ref.get(createCount), 2); + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 2); + }).pipe(Effect.provide(layerTest({ window: fake.window, createCount, mainWindow }))); + }), + ); + it.effect("reopens a closed main window when a restart recovers from a startup failure", () => Effect.gen(function* () { const fake = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index eb42a2d1f842..6c5a7f3faaab 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -397,6 +397,8 @@ export const make = Effect.gen(function* () { // Backend readiness repeats after every restart, and on macOS the app outlives its last // window, so a window the user closed stays closed until they activate the app. let mainClosed = false; + // A revealing request that arrived while the backend was down opens the window once it is back. + let revealWhenReady = false; const splashWindowRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); @@ -1013,6 +1015,7 @@ export const make = Effect.gen(function* () { const window = yield* createWindow(); if (Option.isSome(window)) { mainClosed = false; + revealWhenReady = false; yield* logWindowInfo("main window created"); } return window; @@ -1109,8 +1112,7 @@ export const make = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return; const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main; if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) { - // A revealing request asks for the window, so a closed one opens once the backend is back. - if (reveal) mainClosed = false; + if (reveal) revealWhenReady = true; return; } const window = Option.isSome(existingWindow) ? existingWindow : yield* ensureMain; @@ -1169,7 +1171,7 @@ export const make = Effect.gen(function* () { // A startup failure surfaced the recovery window, which closes only into a main window. const recovering = (yield* SubscriptionRef.get(desktopState.primaryBackendStartup))._tag === "Failing"; - if (!mainClosed || recovering) yield* createMainIfBackendReady; + if (!mainClosed || revealWhenReady || recovering) yield* createMainIfBackendReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"),