From f12495082a6b575915dc161c7164430aea316fa5 Mon Sep 17 00:00:00 2001 From: "akashgundapuneni@gmail.com" Date: Fri, 30 Oct 2026 13:03:29 +0100 Subject: [PATCH 1/2] feat(desktop): keep Windows host in tray --- apps/desktop/src/app/DesktopApp.ts | 2 + apps/desktop/src/app/DesktopLifecycle.test.ts | 75 ++++++++++++++- apps/desktop/src/app/DesktopLifecycle.ts | 20 +++- apps/desktop/src/app/DesktopTray.test.ts | 92 +++++++++++++++++++ apps/desktop/src/app/DesktopTray.ts | 64 +++++++++++++ .../desktop/src/electron/ElectronTray.test.ts | 43 +++++++++ apps/desktop/src/electron/ElectronTray.ts | 44 +++++++++ apps/desktop/src/main.ts | 5 + apps/desktop/src/window/DesktopWindow.test.ts | 21 +++++ apps/desktop/src/window/DesktopWindow.ts | 10 ++ docs/user/background-service.md | 8 ++ 11 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/app/DesktopTray.test.ts create mode 100644 apps/desktop/src/app/DesktopTray.ts create mode 100644 apps/desktop/src/electron/ElectronTray.test.ts create mode 100644 apps/desktop/src/electron/ElectronTray.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d49d2cdebb701..7765a05dcb42d 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -22,6 +22,7 @@ import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopTray from "./DesktopTray.ts"; import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; @@ -324,6 +325,7 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* previewPasskeys.configure; yield* applicationMenu.configure; + yield* (yield* DesktopTray.DesktopTray).configure; yield* updates.configure; yield* DesktopRemoteUpdates.listen; yield* DesktopStartupRecovery.listen; diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 6a6e991fc6b4b..34d12f1f3ac69 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -14,6 +14,7 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopState from "./DesktopState.ts"; +import * as DesktopTray from "./DesktopTray.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; function layerElectronApp( @@ -105,6 +106,49 @@ function layerDesktopWindow( } describe("DesktopLifecycle", () => { + it.effect.each([ + { platform: "win32", tray: true, quits: 0 }, + { platform: "win32", tray: false, quits: 1 }, + { platform: "linux", tray: false, quits: 1 }, + { platform: "darwin", tray: false, quits: 0 }, + ] as const)("handles last-window closure on $platform with tray=$tray", (input) => + Effect.gen(function* () { + const listeners = new Map void>(); + const quits = yield* Ref.make(0); + const layer = DesktopLifecycle.layer.pipe( + Layer.provideMerge( + layerElectronApp( + listeners, + Ref.update(quits, (count) => count + 1), + ), + ), + Layer.provideMerge(layerElectronTheme), + Layer.provideMerge(layerElectronWindow()), + Layer.provideMerge(layerDesktopWindow()), + Layer.provideMerge( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: input.platform, + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]), + ), + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopState.layer), + Layer.provideMerge( + Layer.succeed(DesktopTray.DesktopTray, { + configure: Effect.void, + keepsHostAlive: Effect.succeed(input.tray), + }), + ), + ); + yield* Effect.scoped( + Effect.gen(function* () { + yield* (yield* DesktopLifecycle.DesktopLifecycle).register; + listeners.get("window-all-closed")!(); + assert.equal(yield* Ref.get(quits), input.quits); + }), + ).pipe(Effect.provide(layer)); + }), + ); it.effect.each(["darwin", "win32", "linux"] satisfies ReadonlyArray)( "lets the updater's quit event proceed on %s", (platform) => { @@ -129,6 +173,12 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(layerEnvironment), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge( + Layer.succeed(DesktopTray.DesktopTray, { + configure: Effect.void, + keepsHostAlive: Effect.succeed(false), + }), + ), ); return Effect.scoped( @@ -201,6 +251,12 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(layerEnvironment), Layer.provideMerge(layerDesktopShutdown), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge( + Layer.succeed(DesktopTray.DesktopTray, { + configure: Effect.void, + keepsHostAlive: Effect.succeed(false), + }), + ), ); yield* Effect.scoped( @@ -244,6 +300,12 @@ describe("DesktopLifecycle", () => { // Nothing marks this shutdown complete, so it stays pending. Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge( + Layer.succeed(DesktopTray.DesktopTray, { + configure: Effect.void, + keepsHostAlive: Effect.succeed(false), + }), + ), ); yield* Effect.scoped( @@ -296,6 +358,12 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(layerEnvironment), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge( + Layer.succeed(DesktopTray.DesktopTray, { + configure: Effect.void, + keepsHostAlive: Effect.succeed(false), + }), + ), ); yield* Effect.scoped( @@ -303,11 +371,16 @@ describe("DesktopLifecycle", () => { const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const state = yield* DesktopState.DesktopState; yield* lifecycle.register; + appListeners.get("second-instance")?.({}, ["t3code"]); + assert.equal(activationCount, 1); + appListeners.get("second-instance")?.({}, ["t3code", "t3code://app/threads/1"]); + assert.equal(activationCount, 1); yield* Ref.set(state.quitting, true); appListeners.get("activate")?.(); + appListeners.get("second-instance")?.({}, ["t3code"]); - assert.equal(activationCount, 0); + assert.equal(activationCount, 1); }), ).pipe(Effect.provide(layer)); }), diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 93347949e00fa..2beb0ae33dc0c 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -16,6 +16,7 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopState from "./DesktopState.ts"; +import * as DesktopTray from "./DesktopTray.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; export class DesktopLifecycleRelaunchError extends Schema.TaggedError()( @@ -40,10 +41,11 @@ export type DesktopLifecycleRuntimeServices = type DesktopLifecycleRegistrationServices = | DesktopLifecycleRuntimeServices + | DesktopTray.DesktopTray | ElectronWindow.ElectronWindow; /** - * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow + * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow | DesktopTray */ export class DesktopLifecycle extends Context.Service< DesktopLifecycle, @@ -282,12 +284,26 @@ export const make = DesktopLifecycle.of({ }).pipe(Effect.withSpan("desktop.lifecycle.activate")), ); }); + yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { + if (argv.some((argument) => argument.includes("://"))) return; + void runEffect( + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + if (!(yield* Ref.get(state.quitting))) yield* desktopWindow.activate; + }).pipe(Effect.withSpan("desktop.lifecycle.secondInstance")), + ); + }); yield* electronApp.on("window-all-closed", () => { void runEffect( Effect.gen(function* () { const app = yield* ElectronApp.ElectronApp; const state = yield* DesktopState.DesktopState; - if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { + const tray = yield* DesktopTray.DesktopTray; + if ( + environment.platform !== "darwin" && + !(yield* tray.keepsHostAlive) && + !(yield* Ref.get(state.quitting)) + ) { yield* app.quit; } }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), diff --git a/apps/desktop/src/app/DesktopTray.test.ts b/apps/desktop/src/app/DesktopTray.test.ts new file mode 100644 index 0000000000000..2a43fa0baeb09 --- /dev/null +++ b/apps/desktop/src/app/DesktopTray.test.ts @@ -0,0 +1,92 @@ +import { assert, describe, it } from "@effect/vitest"; +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 DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopTray from "./DesktopTray.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTray from "../electron/ElectronTray.ts"; + +describe("DesktopTray", () => { + it.effect.each([ + { platform: "win32", icon: true, native: true, enabled: true, creates: 1 }, + { platform: "win32", icon: false, native: true, enabled: false, creates: 0 }, + { platform: "win32", icon: true, native: false, enabled: false, creates: 2 }, + { platform: "darwin", icon: true, native: true, enabled: false, creates: 0 }, + { platform: "linux", icon: true, native: true, enabled: false, creates: 0 }, + ] as const)("keeps hosting only with a usable Windows tray: $platform/$icon/$native", (input) => + Effect.gen(function* () { + let creates = 0; + let releases = 0; + let opens = 0; + let quits = 0; + let callbacks: Parameters[0] | undefined; + const stateLayer = DesktopState.layer; + const layer = DesktopTray.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + stateLayer, + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: input.platform, + displayName: "T3 Code", + } as DesktopEnvironment.DesktopEnvironment["Service"]), + Layer.mock(DesktopAssets.DesktopAssets)({ + iconPaths: Effect.succeed({ + ico: input.icon ? Option.some("icon.ico") : Option.none(), + png: Option.none(), + icns: Option.none(), + }), + }), + Layer.mock(DesktopWindow.DesktopWindow)({ + activate: Effect.sync(() => { + opens += 1; + }), + }), + Layer.mock(ElectronApp.ElectronApp)({ + quit: Effect.sync(() => { + quits += 1; + }), + }), + Layer.succeed(ElectronTray.ElectronTray, { + create: (value) => + Effect.acquireRelease( + Effect.sync(() => { + creates += 1; + callbacks = value; + return input.native; + }), + (created) => + Effect.sync(() => { + if (created) releases += 1; + }), + ), + }), + ), + ), + ); + yield* Effect.scoped( + Effect.gen(function* () { + const tray = yield* DesktopTray.DesktopTray; + yield* tray.configure; + yield* tray.configure; + assert.equal(yield* tray.keepsHostAlive, input.enabled); + assert.equal(creates, input.creates); + if (input.enabled) { + callbacks!.open(); + assert.equal(opens, 1); + yield* Ref.set((yield* DesktopState.DesktopState).quitting, true); + callbacks!.open(); + assert.equal(opens, 1); + callbacks!.quit(); + assert.equal(quits, 1); + } + }).pipe(Effect.provide(layer)), + ); + assert.equal(releases, input.enabled ? 1 : 0); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopTray.ts b/apps/desktop/src/app/DesktopTray.ts new file mode 100644 index 0000000000000..46a11a4dd8d13 --- /dev/null +++ b/apps/desktop/src/app/DesktopTray.ts @@ -0,0 +1,64 @@ +import * as Context from "effect/Context"; +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 Scope from "effect/Scope"; +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTray from "../electron/ElectronTray.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +export class DesktopTray extends Context.Service< + DesktopTray, + { + readonly configure: Effect.Effect; + readonly keepsHostAlive: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopTray") {} + +const { logWarning } = makeComponentLogger("desktop-tray"); + +export const layer = Layer.effect( + DesktopTray, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const assets = yield* DesktopAssets.DesktopAssets; + const window = yield* DesktopWindow.DesktopWindow; + const app = yield* ElectronApp.ElectronApp; + const state = yield* DesktopState.DesktopState; + const native = yield* ElectronTray.ElectronTray; + const enabled = yield* Ref.make(false); + const runFork = Effect.runForkWith(yield* Effect.context()); + const open = Effect.gen(function* () { + if (!(yield* Ref.get(state.quitting))) yield* window.activate; + }).pipe(Effect.catchCause((cause) => logWarning("failed to open window", { cause }))); + + return DesktopTray.of({ + keepsHostAlive: Ref.get(enabled), + configure: Effect.gen(function* () { + if (environment.platform !== "win32" || (yield* Ref.get(enabled))) return; + const icon = (yield* assets.iconPaths).ico; + if (Option.isNone(icon)) { + yield* logWarning("tray icon unavailable; closing the last window will quit"); + return; + } + const created = yield* native.create({ + icon: icon.value, + name: environment.displayName, + open: () => { + runFork(open); + }, + quit: () => { + runFork(app.quit); + }, + }); + yield* Ref.set(enabled, created); + if (!created) yield* logWarning("tray creation failed; closing the last window will quit"); + }), + }); + }), +); diff --git a/apps/desktop/src/electron/ElectronTray.test.ts b/apps/desktop/src/electron/ElectronTray.test.ts new file mode 100644 index 0000000000000..8e4d1f2c6cb78 --- /dev/null +++ b/apps/desktop/src/electron/ElectronTray.test.ts @@ -0,0 +1,43 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; +import * as Electron from "electron"; +import * as ElectronTray from "./ElectronTray.ts"; + +vi.mock("electron", () => ({ Tray: vi.fn(), Menu: { buildFromTemplate: vi.fn() } })); + +describe("ElectronTray", () => { + it.effect.each(["success", "constructor", "menu"] as const)( + "releases native resources when setup outcome is %s", + (outcome) => + Effect.gen(function* () { + const destroy = vi.fn(); + const setContextMenu = vi.fn(() => { + if (outcome === "menu") throw new Error("menu unavailable"); + }); + vi.mocked(Electron.Tray).mockImplementation(function () { + if (outcome === "constructor") throw new Error("tray unavailable"); + return { + destroy, + setContextMenu, + setToolTip: vi.fn(), + on: vi.fn(), + } as unknown as Electron.Tray; + }); + yield* Effect.scoped( + Effect.gen(function* () { + const tray = yield* ElectronTray.ElectronTray; + const created = yield* tray.create({ + icon: "icon.ico", + name: "T3 Code", + open: () => {}, + quit: () => {}, + }); + assert.equal(created, outcome === "success"); + assert.equal(destroy.mock.calls.length, outcome === "menu" ? 1 : 0); + }).pipe(Effect.provide(ElectronTray.layer)), + ); + assert.equal(destroy.mock.calls.length, outcome === "constructor" ? 0 : 1); + }), + ); +}); diff --git a/apps/desktop/src/electron/ElectronTray.ts b/apps/desktop/src/electron/ElectronTray.ts new file mode 100644 index 0000000000000..8b848562210d2 --- /dev/null +++ b/apps/desktop/src/electron/ElectronTray.ts @@ -0,0 +1,44 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as Electron from "electron"; + +export class ElectronTray extends Context.Service< + ElectronTray, + { + readonly create: (input: { + readonly icon: string; + readonly name: string; + readonly open: () => void; + readonly quit: () => void; + }) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronTray") {} + +export const layer = Layer.succeed(ElectronTray, { + create: Effect.fn("ElectronTray.create")((input) => + Effect.acquireRelease( + Effect.sync(() => { + let tray: Electron.Tray | undefined; + try { + tray = new Electron.Tray(input.icon); + tray.setToolTip(`${input.name}: running in the background`); + tray.setContextMenu( + Electron.Menu.buildFromTemplate([ + { label: `Open ${input.name}`, click: input.open }, + { type: "separator" }, + { label: `Quit ${input.name}`, click: input.quit }, + ]), + ); + tray.on("double-click", input.open); + return tray; + } catch { + tray?.destroy(); + return undefined; + } + }), + (tray) => Effect.sync(() => tray?.destroy()), + ).pipe(Effect.map((tray) => tray !== undefined)), + ), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9a3c327e6b86b..48c79267bcdd7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -26,6 +26,8 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; +import * as ElectronTray from "./electron/ElectronTray.ts"; +import * as DesktopTray from "./app/DesktopTray.ts"; import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; @@ -210,6 +212,9 @@ const layerDesktopApplication = Layer.mergeAll( DesktopCliCommand.layer, layerDesktopSsh, ).pipe( + Layer.provideMerge( + DesktopTray.layer.pipe(Layer.provide(ElectronTray.layer), Layer.provide(layerDesktopWindow)), + ), Layer.provideMerge(layerDesktopSnapShot), Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(layerDesktopWslBackend), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 8b6ab7c7d8a5e..a58b9f169dfbc 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -455,6 +455,27 @@ const captureOne = DesktopSnapShotId.make("11111111-1111-4111-8111-111111111111" const captureTwo = DesktopSnapShotId.make("22222222-2222-4222-8222-222222222222"); describe("DesktopWindow", () => { + it.effect( + "keeps a closed renderer released across backend restarts until explicitly reopened", + () => + 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")); + assert.equal(yield* Ref.get(createCount), 1); + fake.windowListeners.get("closed")!(); + yield* Ref.set(mainWindow, Option.none()); + yield* windows.handleBackendNotReady; + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + yield* windows.activate; + assert.equal(yield* Ref.get(createCount), 2); + }).pipe(Effect.provide(layerTest({ window: fake.window, createCount, mainWindow }))); + }), + ); it("restores bounds only when the window fits within a connected display", () => { const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; const displays = [ diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index ab5fc8be26a92..3c639d9a8727a 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -302,12 +302,15 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + let windowRequested = true; let flushMainWindowBounds: Effect.Effect = Effect.void; const dismissConnectingSplash = Effect.gen(function* () { const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); if (Option.isSome(splash) && !splash.value.isDestroyed()) { + const requested = windowRequested; splash.value.close(); + windowRequested = requested; } }); @@ -820,6 +823,7 @@ export const make = Effect.gen(function* () { } window.on("closed", () => { + windowRequested = false; clearDevelopmentLoadRetry(); clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); @@ -839,6 +843,7 @@ export const make = Effect.gen(function* () { const ensureMain = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return Option.none(); + windowRequested = true; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { return existingWindow; @@ -865,6 +870,7 @@ export const make = Effect.gen(function* () { const createMainIfBackendReady = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return; + if (!windowRequested) return; if (yield* waitingForBackend) return; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) return; @@ -905,6 +911,9 @@ export const make = Effect.gen(function* () { } yield* rendererHistory.register(splash.webContents, { surface: "splash" }); yield* Ref.set(splashWindowRef, Option.some(splash)); + splash.on("close", () => { + windowRequested = false; + }); splash.once("closed", () => { void runPromise(Ref.set(splashWindowRef, Option.none())); }); @@ -959,6 +968,7 @@ export const make = Effect.gen(function* () { }), activate: Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return; + windowRequested = true; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { yield* electronWindow.reveal(existingWindow.value); diff --git a/docs/user/background-service.md b/docs/user/background-service.md index be4ad66ff3ab4..d78e9a80ff2bb 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -3,6 +3,14 @@ On Linux and macOS, T3 Code can run as a service for your user so you do not need to keep a terminal open. +On Windows, closing the desktop window keeps its server and running agents +available to your paired devices. The closed window is destroyed to release +its renderer memory. Reopen it from the system tray or launch T3 Code again. +Choose **Quit T3 Code** in the tray to stop the app and its server completely. +If the tray cannot be created, closing the last window quits the app. +Background hosting requires your PC to remain awake and your Windows session +to stay signed in; it does not start automatically after a reboot. + ## Manage the service Install the `t3` CLI first ([Install T3 Code](./install.md#command-line)), then From 0142f5b505a3f22bccd2795e1cd41392db0d5df9 Mon Sep 17 00:00:00 2001 From: "akashgundapuneni@gmail.com" Date: Fri, 30 Oct 2026 13:23:20 +0100 Subject: [PATCH 2/2] docs: keep background hosting guide user-focused --- docs/user/background-service.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/background-service.md b/docs/user/background-service.md index d78e9a80ff2bb..55b1b89f8b9df 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -4,8 +4,8 @@ On Linux and macOS, T3 Code can run as a service for your user so you do not nee to keep a terminal open. On Windows, closing the desktop window keeps its server and running agents -available to your paired devices. The closed window is destroyed to release -its renderer memory. Reopen it from the system tray or launch T3 Code again. +available to your paired devices. Reopen it from the system tray or launch +T3 Code again. Choose **Quit T3 Code** in the tray to stop the app and its server completely. If the tray cannot be created, closing the last window quits the app. Background hosting requires your PC to remain awake and your Windows session