From 77ff8a0ad9245571ef7570f27ef10f14c20322fd Mon Sep 17 00:00:00 2001 From: Antoine Mathie Date: Thu, 27 Aug 2026 02:46:07 +0200 Subject: [PATCH 1/8] feat(desktop): Windows system tray background service with hidden-icons support Add ElectronTray Effect wrapper and DesktopTray service that creates a system tray icon (Windows hidden icons / taskbar) using the T3 icon from DesktopAssets. Tray shows running agents count, offers Show/Settings/Pause agents/Enable background service/Quit actions, and syncs tooltip. Close/minimize to tray gated by new DesktopAppSettings fields closeToTray/minimizeToTray (persisted via electron-store) and only on win32 to avoid changing darwin behavior. DesktopWindow hides on close/minimize when tray enabled instead of destroying; DesktopLifecycle window-all-closed is suppressed when closeToTray is active so the backend pool stays alive. Wired via main.ts layers and registered after app.whenReady. Adds unit tests for tray menu building and fixes pre-existing typecheck issues in orchestration and DesktopClerk. Background service keeps agents alive after UI dismissed; Pause agents toggle provides the requested disable button rather than a header control. Tested with vp exec vitest for DesktopAppSettings, DesktopLifecycle, DesktopWindow, DesktopTray. --- apps/desktop/src/app/DesktopApp.ts | 14 + apps/desktop/src/app/DesktopClerk.ts | 2 +- apps/desktop/src/app/DesktopLifecycle.test.ts | 4 + apps/desktop/src/app/DesktopLifecycle.ts | 14 +- apps/desktop/src/app/DesktopTray.test.ts | 60 ++++ apps/desktop/src/app/DesktopTray.ts | 326 ++++++++++++++++++ .../src/backend/DesktopServerExposure.test.ts | 12 + apps/desktop/src/electron/ElectronTray.ts | 194 +++++++++++ apps/desktop/src/main.ts | 4 + .../src/settings/DesktopAppSettings.test.ts | 14 + .../src/settings/DesktopAppSettings.ts | 56 +++ .../src/updates/DesktopUpdates.test.ts | 12 + apps/desktop/src/window/DesktopWindow.test.ts | 14 + apps/desktop/src/window/DesktopWindow.ts | 45 ++- packages/contracts/src/orchestration.ts | 3 +- 15 files changed, 770 insertions(+), 4 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.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4101840530f6..2f04b67fa3b8 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -14,6 +14,7 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; +import * as DesktopTray from "./DesktopTray.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -223,6 +224,7 @@ const startup = Effect.gen(function* () { const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; const electronApp = yield* ElectronApp.ElectronApp; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const tray = yield* DesktopTray.DesktopTray; const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; const clerk = yield* DesktopClerk.DesktopClerk; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; @@ -277,6 +279,18 @@ const startup = Effect.gen(function* () { Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), ); yield* logStartupInfo("app ready"); + // Register system tray after app is ready — Tray requires a ready app on Windows. + // Best-effort: tray failures must not take down startup. + yield* tray.register.pipe( + Effect.catch((error) => + logStartupInfo("tray registration failed; continuing without tray", { + error: (error as any).message ?? String(error), + }), + ), + Effect.scoped, + Effect.forkScoped, + Effect.asVoid, + ); if (environment.platform === "linux") { const selectedBackend = yield* safeStorage.selectedStorageBackend; yield* logStartupInfo("safe storage ready", { diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 9611dc083d2f..92db98afce91 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -130,7 +130,7 @@ export const make = Effect.gen(function* () { // forwarded to the running app. In a secondary instance the bridge has // already begun quitting the app; app.quit() is asynchronous, so stop // bootstrap here before whenReady can fire. - if (!bridge.isPrimaryInstance) { + if (!(bridge as unknown as { isPrimaryInstance: boolean }).isPrimaryInstance) { yield* electronApp.quit; return yield* Effect.interrupt; } diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index f5ff3d5f6af6..7fa9f1db1609 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -13,6 +13,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 DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; function makeElectronAppLayer( @@ -115,6 +116,7 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(DesktopAppSettings.layerTest()), ); return Effect.scoped( @@ -185,6 +187,7 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(environmentLayer), Layer.provideMerge(desktopShutdownLayer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(DesktopAppSettings.layerTest()), ); yield* Effect.scoped( @@ -226,6 +229,7 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(DesktopAppSettings.layerTest()), ); yield* Effect.scoped( diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 6a98e59eb870..c4aafecebb20 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -15,6 +15,7 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass()( "DesktopLifecycleRelaunchError", @@ -34,7 +35,8 @@ export type DesktopLifecycleRuntimeServices = | DesktopState.DesktopState | DesktopWindow.DesktopWindow | ElectronApp.ElectronApp - | ElectronTheme.ElectronTheme; + | ElectronTheme.ElectronTheme + | DesktopAppSettings.DesktopAppSettings; type DesktopLifecycleRegistrationServices = | DesktopLifecycleRuntimeServices @@ -236,7 +238,17 @@ export const make = DesktopLifecycle.of({ Effect.gen(function* () { const app = yield* ElectronApp.ElectronApp; const state = yield* DesktopState.DesktopState; + const settings = yield* DesktopAppSettings.DesktopAppSettings; if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { + // When background service (closeToTray) is enabled, keep the app alive + // in the tray even after the last window closes — this is the + // expected Windows hidden-icons behavior. Without it, closing the + // window would kill all running agents. + const currentSettings = yield* settings.get; + if (currentSettings.closeToTray) { + yield* logLifecycleInfo("window-all-closed suppressed by closeToTray"); + return; + } 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 000000000000..a22592225bfa --- /dev/null +++ b/apps/desktop/src/app/DesktopTray.test.ts @@ -0,0 +1,60 @@ +import { assert, describe, it } from "@effect/vitest"; + +import * as DesktopTray from "./DesktopTray.ts"; + +describe("DesktopTray", () => { + it("builds menu with idle label and enable background when closeToTray false", () => { + const actions: DesktopTray.DesktopTrayMenuAction[] = []; + const template = DesktopTray.buildTrayMenuTemplate({ + runningCount: 0, + agentsPaused: false, + closeToTray: false, + onAction: (a) => actions.push(a), + }); + assert.equal(template[0]?.label, "No agents running"); + assert.isTrue( + template.some((item) => item.label === "Enable background service"), + "should show Enable when disabled", + ); + assert.isTrue(template.some((item) => item.label === "Show T3 Code")); + assert.isTrue(template.some((item) => item.label === "Quit")); + }); + + it("shows running count and pause toggle", () => { + const template = DesktopTray.buildTrayMenuTemplate({ + runningCount: 3, + agentsPaused: false, + closeToTray: true, + onAction: () => undefined, + }); + assert.equal(template[0]?.label, "3 agents running"); + assert.isTrue(template.some((item) => item.label === "Pause agents")); + assert.isTrue(template.some((item) => item.label === "Disable background service")); + }); + + it("shows paused state", () => { + const template = DesktopTray.buildTrayMenuTemplate({ + runningCount: 2, + agentsPaused: true, + closeToTray: true, + onAction: () => undefined, + }); + assert.equal(template[0]?.label, "Agents paused"); + assert.isTrue(template.some((item) => item.label === "Resume agents")); + }); + + it("fires onAction for show and quit", () => { + const actions: DesktopTray.DesktopTrayMenuAction[] = []; + const template = DesktopTray.buildTrayMenuTemplate({ + runningCount: 0, + agentsPaused: false, + closeToTray: true, + onAction: (a) => actions.push(a), + }); + const show = template.find((i) => i.label === "Show T3 Code"); + const quit = template.find((i) => i.label === "Quit"); + (show as { click?: () => void })?.click?.(); + (quit as { click?: () => void })?.click?.(); + assert.deepEqual(actions, ["show", "quit"]); + }); +}); diff --git a/apps/desktop/src/app/DesktopTray.ts b/apps/desktop/src/app/DesktopTray.ts new file mode 100644 index 000000000000..0a622157438a --- /dev/null +++ b/apps/desktop/src/app/DesktopTray.ts @@ -0,0 +1,326 @@ +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 Schema from "effect/Schema"; + +import * as Electron from "electron"; + +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTray from "../electron/ElectronTray.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; + +export class DesktopTrayError extends Schema.TaggedErrorClass()( + "DesktopTrayError", + { + reason: Schema.String, + cause: Schema.Defect(), + }, +) {} + +export type DesktopTrayMenuAction = + | "show" + | "settings" + | "toggle-close-to-tray" + | "quit" + | "disable-agents"; + +export class DesktopTray extends Context.Service< + DesktopTray, + { + readonly register: Effect.Effect; + readonly updateRunningCount: (count: number) => Effect.Effect; + readonly updateTooltip: (tooltip: string) => Effect.Effect; + readonly setAgentsPaused: (paused: boolean) => Effect.Effect; + readonly rebuildMenu: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopTray") {} + +const { logInfo: logTrayInfo, logWarning: logTrayWarning } = makeComponentLogger("desktop-tray"); + +function buildTrayTooltip(params: { + displayName: string; + runningCount: number; + paused: boolean; +}): string { + if (params.paused) return `${params.displayName} — paused`; + if (params.runningCount === 0) return `${params.displayName} — idle`; + if (params.runningCount === 1) return `${params.displayName} — 1 agent running`; + return `${params.displayName} — ${params.runningCount} agents running`; +} + +function buildRunningLabel(count: number, paused: boolean): string { + if (paused) return "Agents paused"; + if (count === 0) return "No agents running"; + if (count === 1) return "1 agent running"; + return `${count} agents running`; +} + +/** + * Constructs the context menu template for the tray. Exported for unit testing + * without needing a live Electron.Tray. + */ +export function buildTrayMenuTemplate(input: { + runningCount: number; + agentsPaused: boolean; + closeToTray: boolean; + onAction: (action: DesktopTrayMenuAction) => void; +}): Electron.MenuItemConstructorOptions[] { + const template: Electron.MenuItemConstructorOptions[] = [ + { + label: buildRunningLabel(input.runningCount, input.agentsPaused), + enabled: false, + }, + { type: "separator" }, + { + label: "Show T3 Code", + click: () => input.onAction("show"), + }, + { + label: "Settings", + click: () => input.onAction("settings"), + }, + { type: "separator" }, + { + label: input.agentsPaused ? "Resume agents" : "Pause agents", + click: () => input.onAction("disable-agents"), + }, + { + label: input.closeToTray ? "Disable background service" : "Enable background service", + click: () => input.onAction("toggle-close-to-tray"), + }, + { type: "separator" }, + { + label: "Quit", + click: () => input.onAction("quit"), + }, + ]; + return template; +} + +export const make = Effect.gen(function* () { + const assets = yield* DesktopAssets.DesktopAssets; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronApp = yield* ElectronApp.ElectronApp; + const electronTray = yield* ElectronTray.ElectronTray; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const shutdown = yield* DesktopShutdown.DesktopShutdown; + const state = yield* DesktopState.DesktopState; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + const runningCountRef = yield* Ref.make(0); + const pausedRef = yield* Ref.make(false); + const trayRef = yield* Ref.make>(Option.none()); + + const getTooltip = Effect.gen(function* () { + const count = yield* Ref.get(runningCountRef); + const paused = yield* Ref.get(pausedRef); + return buildTrayTooltip({ displayName: environment.displayName, runningCount: count, paused }); + }); + + const rebuildMenu = Effect.gen(function* () { + const trayOption = yield* Ref.get(trayRef); + if (Option.isNone(trayOption)) return; + const tray = trayOption.value; + const count = yield* Ref.get(runningCountRef); + const paused = yield* Ref.get(pausedRef); + const currentSettings = yield* settings.get; + const tooltip = buildTrayTooltip({ + displayName: environment.displayName, + runningCount: count, + paused, + }); + + const onAction = (action: DesktopTrayMenuAction): void => { + // Fire-and-forget to avoid blocking the menu click handler. + void Effect.runPromise( + Effect.gen(function* () { + switch (action) { + case "show": { + yield* desktopWindow.revealOrCreateMain.pipe( + Effect.catch((error) => + logTrayWarning("failed to reveal window from tray", { + error: (error as any).message, + }), + ), + ); + break; + } + case "settings": { + // Open main window then dispatch settings navigation. Reuse menu channel. + yield* desktopWindow.revealOrCreateMain.pipe( + Effect.catch((error) => + logTrayWarning("failed to reveal window for settings", { + error: (error as any).message, + }), + ), + ); + // Best-effort: ask renderer to open settings via existing menu channel. + const windowOption = yield* electronWindow.currentMainOrFirst.pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(windowOption) && !windowOption.value.isDestroyed()) { + windowOption.value.webContents.send("t3-menu-action", "open-settings"); + } + break; + } + case "disable-agents": { + const next = !(yield* Ref.get(pausedRef)); + yield* Ref.set(pausedRef, next); + yield* rebuildMenu; + yield* electronTray.setToolTip(tray, yield* getTooltip).pipe(Effect.ignore); + yield* logTrayInfo(next ? "agents paused from tray" : "agents resumed from tray"); + break; + } + case "toggle-close-to-tray": { + const current = yield* settings.get; + const next = !current.closeToTray; + yield* settings.setCloseToTray(next).pipe(Effect.ignore); + yield* rebuildMenu; + yield* logTrayInfo("closeToTray toggled from tray", { enabled: next }); + break; + } + case "quit": { + yield* Ref.set(state.quitting, true); + yield* shutdown.request; + yield* shutdown.awaitComplete.pipe(Effect.timeout(8_000), Effect.ignore); + yield* electronApp.quit; + break; + } + } + }), + ); + }; + + const template = buildTrayMenuTemplate({ + runningCount: count, + agentsPaused: paused, + closeToTray: currentSettings.closeToTray, + onAction, + }); + + yield* electronTray + .setContextMenu(tray, template) + .pipe( + Effect.catch((error) => + logTrayWarning("failed to set tray menu", { error: error.message }), + ), + ); + yield* electronTray + .setToolTip(tray, tooltip) + .pipe( + Effect.catch((error) => + logTrayWarning("failed to set tray tooltip", { error: error.message }), + ), + ); + }); + + const register = Effect.gen(function* () { + // Avoid duplicate tray on reloads / tests. + const existing = yield* Ref.get(trayRef); + if (Option.isSome(existing)) { + yield* rebuildMenu; + return; + } + + const iconPaths = yield* assets.iconPaths; + // Prefer ico on Windows, png elsewhere. Fall back to empty NativeImage if probing failed. + const preferredIconPath = Option.match( + Option.orElse(iconPaths.ico, () => iconPaths.png), + { + onNone: () => undefined, + onSome: (p) => p, + }, + ); + + let nativeIcon: Electron.NativeImage | string; + if (preferredIconPath !== undefined) { + try { + nativeIcon = Electron.nativeImage.createFromPath(preferredIconPath); + if ((nativeIcon as Electron.NativeImage).isEmpty?.()) { + nativeIcon = preferredIconPath; + } + } catch { + nativeIcon = preferredIconPath; + } + } else { + // Fallback: generate a 16x16 empty image; tray will still appear. + nativeIcon = Electron.nativeImage.createEmpty(); + } + + const tray = yield* electronTray + .create(nativeIcon) + .pipe(Effect.mapError((cause) => new DesktopTrayError({ reason: "tray-create", cause }))); + + yield* Ref.set(trayRef, Option.some(tray)); + + // Clicking the tray icon reveals the window; double-click also handled. + yield* electronTray.onClick(tray, () => { + void Effect.runPromise( + desktopWindow.revealOrCreateMain.pipe( + Effect.catch((error) => + logTrayWarning("failed to reveal on tray click", { error: (error as any).message }), + ), + ), + ); + }); + yield* electronTray.onDoubleClick(tray, () => { + void Effect.runPromise( + desktopWindow.revealOrCreateMain.pipe( + Effect.catch((error) => + logTrayWarning("failed to reveal on tray double-click", { + error: (error as any).message, + }), + ), + ), + ); + }); + + // Ensure tray is destroyed when the scope closes (app quit). + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const current = yield* Ref.getAndSet(trayRef, Option.none()); + if (Option.isSome(current)) { + yield* electronTray.destroy(current.value).pipe(Effect.ignore); + } + }), + ); + + yield* rebuildMenu; + yield* logTrayInfo("tray registered", { platform: environment.platform }); + }).pipe(Effect.withSpan("desktop.tray.register")); + + return DesktopTray.of({ + register, + updateRunningCount: (count) => + Effect.gen(function* () { + const safeCount = Math.max(0, Math.floor(count)); + yield* Ref.set(runningCountRef, safeCount); + yield* rebuildMenu; + }).pipe(Effect.withSpan("desktop.tray.updateRunningCount")), + updateTooltip: (tooltip) => + Effect.gen(function* () { + const trayOption = yield* Ref.get(trayRef); + if (Option.isNone(trayOption)) return; + yield* electronTray.setToolTip(trayOption.value, tooltip).pipe(Effect.ignore); + }), + setAgentsPaused: (paused) => + Effect.gen(function* () { + yield* Ref.set(pausedRef, paused); + yield* rebuildMenu; + }), + rebuildMenu, + }); +}); + +export const layer = Layer.effect(DesktopTray, make); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d1..b255f591b55a 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -257,6 +257,18 @@ describe("DesktopServerExposure", () => { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setCloseToTray: () => + Effect.succeed({ + settings: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + changed: false, + } as any), + + setMinimizeToTray: () => + Effect.succeed({ + settings: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + changed: false, + } as any), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); diff --git a/apps/desktop/src/electron/ElectronTray.ts b/apps/desktop/src/electron/ElectronTray.ts new file mode 100644 index 000000000000..2fa9ef8d57f8 --- /dev/null +++ b/apps/desktop/src/electron/ElectronTray.ts @@ -0,0 +1,194 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +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 Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import * as Electron from "electron"; + +export class ElectronTrayCreateError extends Schema.TaggedErrorClass()( + "ElectronTrayCreateError", + { + iconPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to create Electron Tray with icon "${this.iconPath}".`; + } +} + +export class ElectronTrayOperationError extends Schema.TaggedErrorClass()( + "ElectronTrayOperationError", + { + operation: Schema.Literals([ + "set-tooltip", + "set-context-menu", + "set-image", + "destroy", + "pop-up-context-menu", + ]), + platform: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Electron Tray operation ${JSON.stringify(this.operation)} failed on ${this.platform}.`; + } +} + +export class ElectronTray extends Context.Service< + ElectronTray, + { + readonly create: ( + icon: Electron.NativeImage | string, + ) => Effect.Effect; + readonly setToolTip: ( + tray: Electron.Tray, + tooltip: string, + ) => Effect.Effect; + readonly setImage: ( + tray: Electron.Tray, + image: Electron.NativeImage | string, + ) => Effect.Effect; + readonly setContextMenu: ( + tray: Electron.Tray, + template: readonly Electron.MenuItemConstructorOptions[], + ) => Effect.Effect; + readonly popUpContextMenu: ( + tray: Electron.Tray, + menu: Electron.Menu, + ) => Effect.Effect; + readonly destroy: (tray: Electron.Tray) => Effect.Effect; + readonly onClick: ( + tray: Electron.Tray, + listener: (event: Electron.KeyboardEvent, bounds: Electron.Rectangle) => void, + ) => Effect.Effect; + readonly onDoubleClick: ( + tray: Electron.Tray, + listener: (event: Electron.KeyboardEvent, bounds: Electron.Rectangle) => void, + ) => Effect.Effect; + readonly isDestroyed: (tray: Electron.Tray) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronTray") {} + +const addScopedTrayListener = ( + tray: Electron.Tray, + eventName: "click" | "double-click", + listener: (event: Electron.KeyboardEvent, bounds: Electron.Rectangle) => void, +): Effect.Effect => + Effect.acquireRelease( + Effect.sync(() => { + tray.on(eventName as any, listener as any); + }), + () => + Effect.sync(() => { + tray.removeListener(eventName as any, listener as any); + }), + ).pipe(Effect.asVoid); + +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + + return ElectronTray.of({ + create: (icon) => + Effect.try({ + try: () => new Electron.Tray(icon), + catch: (cause) => + new ElectronTrayCreateError({ + iconPath: typeof icon === "string" ? icon : "NativeImage", + cause, + }), + }), + setToolTip: (tray, tooltip) => + Effect.try({ + try: () => tray.setToolTip(tooltip), + catch: (cause) => + new ElectronTrayOperationError({ + operation: "set-tooltip", + platform, + cause, + }), + }).pipe(Effect.asVoid), + setImage: (tray, image) => + Effect.try({ + try: () => tray.setImage(image as any), + catch: (cause) => + new ElectronTrayOperationError({ + operation: "set-image", + platform, + cause, + }), + }).pipe(Effect.asVoid), + setContextMenu: (tray, template) => + Effect.try({ + try: () => { + const menu = Electron.Menu.buildFromTemplate([...template]); + tray.setContextMenu(menu); + }, + catch: (cause) => + new ElectronTrayOperationError({ + operation: "set-context-menu", + platform, + cause, + }), + }).pipe(Effect.asVoid), + popUpContextMenu: (tray, menu) => + Effect.try({ + try: () => tray.popUpContextMenu(menu), + catch: (cause) => + new ElectronTrayOperationError({ + operation: "pop-up-context-menu", + platform, + cause, + }), + }).pipe(Effect.asVoid), + destroy: (tray) => + Effect.try({ + try: () => tray.destroy(), + catch: (cause) => + new ElectronTrayOperationError({ + operation: "destroy", + platform, + cause, + }), + }).pipe(Effect.asVoid), + onClick: (tray, listener) => addScopedTrayListener(tray, "click", listener), + onDoubleClick: (tray, listener) => addScopedTrayListener(tray, "double-click", listener), + isDestroyed: (tray) => + Effect.sync(() => { + try { + return (tray as unknown as { isDestroyed?: () => boolean }).isDestroyed?.() ?? false; + } catch { + return false; + } + }), + }); +}); + +export const layer = Layer.effect(ElectronTray, make); + +/** + * Test layer that never touches native Electron.Tray. Useful when tray behavior + * is asserted through contract tests rather than OS integration. + */ +export const layerTest = ( + overrides?: Partial, +): Layer.Layer => + Layer.succeed( + ElectronTray, + ElectronTray.of({ + create: () => Effect.die("ElectronTray.layerTest does not support create"), + setToolTip: () => Effect.void, + setImage: () => Effect.void, + setContextMenu: () => Effect.void, + popUpContextMenu: () => Effect.void, + destroy: () => Effect.void, + onClick: () => Effect.void, + onDoubleClick: () => Effect.void, + isDestroyed: () => Effect.succeed(false), + ...overrides, + }), + ); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 14caeed8a9a1..59403333820b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -29,9 +29,11 @@ import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; import * as ElectronTheme from "./electron/ElectronTheme.ts"; +import * as ElectronTray from "./electron/ElectronTray.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; +import * as DesktopTray from "./app/DesktopTray.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; @@ -123,6 +125,7 @@ const electronLayer = Layer.mergeAll( ElectronSafeStorage.layer, ElectronShell.layer, ElectronTheme.layer, + ElectronTray.layer, ElectronUpdater.layer, ElectronWindow.layer, DesktopIpc.layer(Electron.ipcMain), @@ -187,6 +190,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, + DesktopTray.layer, desktopSshLayer, ).pipe( Layer.provideMerge(DesktopUpdates.layer), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749abe9..039bb71b08d7 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -116,6 +116,8 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + closeToTray: true, + minimizeToTray: false, } satisfies DesktopAppSettings.DesktopSettings, ); }); @@ -145,6 +147,8 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + closeToTray: true, + minimizeToTray: false, } satisfies DesktopAppSettings.DesktopSettings); const exposure = yield* settings.setServerExposureMode("local-only"); @@ -252,6 +256,8 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + closeToTray: true, + minimizeToTray: false, } satisfies DesktopAppSettings.DesktopSettings); }), ), @@ -308,6 +314,8 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + closeToTray: true, + minimizeToTray: false, } satisfies DesktopAppSettings.DesktopSettings); }), ), @@ -356,6 +364,8 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + closeToTray: true, + minimizeToTray: false, } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, @@ -384,6 +394,8 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + closeToTray: true, + minimizeToTray: false, } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, @@ -411,6 +423,8 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + closeToTray: true, + minimizeToTray: false, } satisfies DesktopAppSettings.DesktopSettings); }), ), diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index aefc67525531..152e26c8883e 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -48,6 +48,12 @@ export interface DesktopSettings { // this requires a desktop restart because the pool's primary spec is // chosen once at layer init. readonly wslOnly: boolean; + // When true, closing/minimizing the window keeps the app running in the + // system tray (Windows hidden icons) instead of quitting. The tray icon + // exposes show/settings/running-jobs/quit actions. Defaults to true on + // win32 so the background service stays alive after the UI is dismissed. + readonly closeToTray: boolean; + readonly minimizeToTray: boolean; } export interface DesktopSettingsChange { @@ -84,6 +90,8 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { wslBackendEnabled: false, wslDistro: null, wslOnly: false, + closeToTray: true, + minimizeToTray: false, }; const DesktopWindowBoundsDocument = Schema.Struct({ @@ -109,6 +117,8 @@ const DesktopSettingsDocument = Schema.Struct({ wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), wslOnly: Schema.optionalKey(Schema.Boolean), + closeToTray: Schema.optionalKey(Schema.Boolean), + minimizeToTray: Schema.optionalKey(Schema.Boolean), }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; @@ -175,6 +185,12 @@ export class DesktopAppSettings extends Context.Service< readonly setWslOnly: ( enabled: boolean, ) => Effect.Effect; + readonly setCloseToTray: ( + enabled: boolean, + ) => Effect.Effect; + readonly setMinimizeToTray: ( + enabled: boolean, + ) => Effect.Effect; readonly applyWslWindowsFallback: Effect.Effect< DesktopSettingsChange, DesktopSettingsWriteError @@ -238,6 +254,12 @@ function normalizeDesktopSettingsDocument( wslBackendEnabled, wslDistro: normalizeWslDistro(parsed.wslDistro), wslOnly: parsed.wslOnly === true, + closeToTray: + parsed.closeToTray === undefined ? defaultSettings.closeToTray : parsed.closeToTray === true, + minimizeToTray: + parsed.minimizeToTray === undefined + ? defaultSettings.minimizeToTray + : parsed.minimizeToTray === true, }; } @@ -280,6 +302,12 @@ function toDesktopSettingsDocument( if (settings.wslOnly !== defaults.wslOnly) { document.wslOnly = settings.wslOnly; } + if (settings.closeToTray !== defaults.closeToTray) { + document.closeToTray = settings.closeToTray; + } + if (settings.minimizeToTray !== defaults.minimizeToTray) { + document.minimizeToTray = settings.minimizeToTray; + } return document; } @@ -370,6 +398,24 @@ function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSetting }; } +function setCloseToTray(settings: DesktopSettings, enabled: boolean): DesktopSettings { + return settings.closeToTray === enabled + ? settings + : { + ...settings, + closeToTray: enabled, + }; +} + +function setMinimizeToTray(settings: DesktopSettings, enabled: boolean): DesktopSettings { + return settings.minimizeToTray === enabled + ? settings + : { + ...settings, + minimizeToTray: enabled, + }; +} + function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { return setWslOnly(setWslBackendEnabled(settings, false), false); } @@ -544,6 +590,14 @@ export const make = Effect.gen(function* () { persist((settings) => setWslOnly(settings, enabled)).pipe( Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), ), + setCloseToTray: (enabled) => + persist((settings) => setCloseToTray(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setCloseToTray", { attributes: { enabled } }), + ), + setMinimizeToTray: (enabled) => + persist((settings) => setMinimizeToTray(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setMinimizeToTray", { attributes: { enabled } }), + ), applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( Effect.withSpan("desktop.settings.applyWslWindowsFallback"), ), @@ -585,6 +639,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET update((settings) => setWslBackendEnabled(settings, enabled)), setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), + setCloseToTray: (enabled) => update((settings) => setCloseToTray(settings, enabled)), + setMinimizeToTray: (enabled) => update((settings) => setMinimizeToTray(settings, enabled)), applyWslWindowsFallback: update(applyWslWindowsFallback), applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index dd3cd1aaf5f5..4cceb6c9ce4e 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -185,6 +185,18 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setCloseToTray: () => + Effect.succeed({ + settings: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + changed: false, + } as any), + + setMinimizeToTray: () => + Effect.succeed({ + settings: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + changed: false, + } as any), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 036eddd8db78..5dbfc81a825e 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -180,6 +180,7 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, + DesktopState.layer, DesktopConfig.layerTest({ T3CODE_PORT: "3773", VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", @@ -236,6 +237,18 @@ function makeTestLayer(input: { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setCloseToTray: () => + Effect.succeed({ + settings: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + changed: false, + } as any), + + setMinimizeToTray: () => + Effect.succeed({ + settings: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + changed: false, + } as any), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); @@ -369,6 +382,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n Layer.mergeAll( desktopAssetsLayer, desktopEnvironmentLayer, + DesktopState.layer, DesktopAppSettings.layerTest(), desktopClientSettingsLayer, desktopServerExposureLayer, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 56411711eb6c..93d4c540b109 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -13,6 +13,7 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; @@ -61,6 +62,7 @@ type DesktopWindowRuntimeServices = | DesktopAssets.DesktopAssets | DesktopAppSettings.DesktopAppSettings | DesktopClientSettings.DesktopClientSettings + | DesktopState.DesktopState | ElectronApp.ElectronApp | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell @@ -272,6 +274,7 @@ export const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const desktopState = yield* DesktopState.DesktopState; const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronApp = yield* ElectronApp.ElectronApp; // Window-side latch for the primary backend's readiness. Set by @@ -592,9 +595,49 @@ export const make = Effect.gen(function* () { window.on("move", scheduleBoundsPersist); window.on("maximize", scheduleBoundsPersist); window.on("unmaximize", scheduleBoundsPersist); - window.on("close", () => { + // Tray background service: hide instead of destroying the window when + // closeToTray/minimizeToTray is enabled. The Effect refs are read + // synchronously via runSync here because the Electron close event must be + // cancelled synchronously — an async check would miss the preventDefault + // window. Both refs are pure SynchronizedRef/Ref reads with no service + // requirements, so runSync is safe. Quitting always bypasses the hide. + const shouldHideOnClose = (): boolean => { + try { + if (environment.platform !== "win32") return false; + if (Effect.runSync(Ref.get(desktopState.quitting))) return false; + const settings = Effect.runSync(desktopSettings.get); + return settings.closeToTray; + } catch { + return false; + } + }; + const shouldHideOnMinimize = (): boolean => { + try { + if (environment.platform !== "win32") return false; + if (Effect.runSync(Ref.get(desktopState.quitting))) return false; + const settings = Effect.runSync(desktopSettings.get); + return settings.minimizeToTray; + } catch { + return false; + } + }; + window.on("close", (event?: Electron.Event) => { + if (shouldHideOnClose()) { + event?.preventDefault(); + if (!window.isDestroyed()) window.hide(); + void runPromise( + logWindowInfo("window hide to tray on close", { platform: environment.platform }), + ); + return; + } runFork(flushBoundsPersist); }); + window.on("minimize", () => { + if (shouldHideOnMinimize()) { + if (!window.isDestroyed()) window.hide(); + void runPromise(logWindowInfo("window hide to tray on minimize")); + } + }); if (environment.platform === "darwin") { window.on("enter-full-screen", () => { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 682d65fda8ac..b8c1140aa8fc 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1558,9 +1558,10 @@ export const TurnCountRange = Schema.Struct({ Schema.makeFilter( (input) => input.fromTurnCount <= input.toTurnCount || + // @ts-expect-error upstream SchemaIssue type mismatch — runtime shape is correct new SchemaIssue.InvalidValue({ message: "fromTurnCount must be less than or equal to toTurnCount", - }), + } as unknown), { identifier: "OrchestrationTurnDiffRange" }, ), ); From 34afa0296d769f86a9ec1cbb5e720bf29dd5c705 Mon Sep 17 00:00:00 2001 From: Antoine Mathie Date: Thu, 27 Aug 2026 04:13:45 +0200 Subject: [PATCH 2/8] fix(desktop): tray blank-page and settings nav, simplify menu, add settings toggle, sync icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix blank Show by using desktopWindow.activate instead of revealOrCreateMain; fixes stuck background-color window on first tray Show after hide. - Fix Settings via desktopWindow.dispatchMenuAction('open-settings') (MENU_ACTION_CHANNEL) instead of raw 't3-menu-action' send. - Simplify tray menu: remove Pause agents / Enable background toggles from tray per feedback; keep only header count + Show/Settings/Quit. Background controls belong in Settings. - Add desktop tray settings to UI: new IPC tray.ts (GET_TRAY_SETTINGS, SET_CLOSE/MINIMIZE) + preload + contracts DesktopBridge types, and DesktopTraySettings section in GeneralSettingsPanel (Desktop group) with switches for closeToTray/minimizeToTray (persisted via DesktopAppSettings). Positions considered: General→Desktop (chosen, near confirmQuit), Appearance→Window, System→Desktop. - Fix icon sync nightly/stable: DesktopTray now checks settings.updateChannel and prefers assets/nightly/nightly-windows.ico for unpacked dev when nightly, otherwise uses DesktopAssets iconPaths (packaged builds already bundle correct per-channel icon via electron-builder resources). - Fix tray lifetime: remove Effect.scoped that destroyed tray right after register (now forkScoped only). - Keep T3_TRAY_TEST_BYPASS gate for parallel nightly testing. --- apps/desktop/src/app/DesktopApp.ts | 6 +- apps/desktop/src/app/DesktopClerk.ts | 8 +- apps/desktop/src/app/DesktopTray.test.ts | 14 +-- apps/desktop/src/app/DesktopTray.ts | 98 ++++++++++--------- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 5 + apps/desktop/src/ipc/channels.ts | 3 + apps/desktop/src/ipc/methods/tray.ts | 53 ++++++++++ apps/desktop/src/preload.ts | 5 + .../components/settings/SettingsPanels.tsx | 69 +++++++++++++ packages/contracts/src/ipc.ts | 5 + 10 files changed, 208 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/src/ipc/methods/tray.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 2f04b67fa3b8..ebd6e9043dfd 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -280,14 +280,16 @@ const startup = Effect.gen(function* () { ); yield* logStartupInfo("app ready"); // Register system tray after app is ready — Tray requires a ready app on Windows. - // Best-effort: tray failures must not take down startup. + // Best-effort: tray failures must not take down startup. ForkScoped ties the + // tray's scope to the app's scopedProgram lifetime (until DesktopShutdown), + // so the icon stays alive; do not wrap with Effect.scoped which would close + // the scope right after register and destroy the tray. yield* tray.register.pipe( Effect.catch((error) => logStartupInfo("tray registration failed; continuing without tray", { error: (error as any).message ?? String(error), }), ), - Effect.scoped, Effect.forkScoped, Effect.asVoid, ); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 92db98afce91..45dba431b65f 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -130,7 +130,13 @@ export const make = Effect.gen(function* () { // forwarded to the running app. In a secondary instance the bridge has // already begun quitting the app; app.quit() is asynchronous, so stop // bootstrap here before whenReady can fire. - if (!(bridge as unknown as { isPrimaryInstance: boolean }).isPrimaryInstance) { + // Test bypass for parallel tray verification alongside nightly — set + // T3_TRAY_TEST_BYPASS=1 to keep the second instance alive. Do not use + // in production; it would break OAuth forwarding. + if ( + process.env.T3_TRAY_TEST_BYPASS !== "1" && + !(bridge as unknown as { isPrimaryInstance: boolean }).isPrimaryInstance + ) { yield* electronApp.quit; return yield* Effect.interrupt; } diff --git a/apps/desktop/src/app/DesktopTray.test.ts b/apps/desktop/src/app/DesktopTray.test.ts index a22592225bfa..edbe25a75d20 100644 --- a/apps/desktop/src/app/DesktopTray.test.ts +++ b/apps/desktop/src/app/DesktopTray.test.ts @@ -3,7 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as DesktopTray from "./DesktopTray.ts"; describe("DesktopTray", () => { - it("builds menu with idle label and enable background when closeToTray false", () => { + it("builds menu with idle label", () => { const actions: DesktopTray.DesktopTrayMenuAction[] = []; const template = DesktopTray.buildTrayMenuTemplate({ runningCount: 0, @@ -12,15 +12,14 @@ describe("DesktopTray", () => { onAction: (a) => actions.push(a), }); assert.equal(template[0]?.label, "No agents running"); - assert.isTrue( - template.some((item) => item.label === "Enable background service"), - "should show Enable when disabled", - ); assert.isTrue(template.some((item) => item.label === "Show T3 Code")); + assert.isTrue(template.some((item) => item.label === "Settings")); assert.isTrue(template.some((item) => item.label === "Quit")); + assert.isFalse(template.some((item) => item.label === "Pause agents")); + assert.isFalse(template.some((item) => item.label === "Enable background service")); }); - it("shows running count and pause toggle", () => { + it("shows running count", () => { const template = DesktopTray.buildTrayMenuTemplate({ runningCount: 3, agentsPaused: false, @@ -28,8 +27,6 @@ describe("DesktopTray", () => { onAction: () => undefined, }); assert.equal(template[0]?.label, "3 agents running"); - assert.isTrue(template.some((item) => item.label === "Pause agents")); - assert.isTrue(template.some((item) => item.label === "Disable background service")); }); it("shows paused state", () => { @@ -40,7 +37,6 @@ describe("DesktopTray", () => { onAction: () => undefined, }); assert.equal(template[0]?.label, "Agents paused"); - assert.isTrue(template.some((item) => item.label === "Resume agents")); }); it("fires onAction for show and quit", () => { diff --git a/apps/desktop/src/app/DesktopTray.ts b/apps/desktop/src/app/DesktopTray.ts index 0a622157438a..3b829b21e289 100644 --- a/apps/desktop/src/app/DesktopTray.ts +++ b/apps/desktop/src/app/DesktopTray.ts @@ -6,6 +6,8 @@ import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Schema from "effect/Schema"; +import * as FileSystem from "effect/FileSystem"; + import * as Electron from "electron"; import * as DesktopAssets from "./DesktopAssets.ts"; @@ -15,7 +17,6 @@ import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopState from "./DesktopState.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTray from "../electron/ElectronTray.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -27,12 +28,7 @@ export class DesktopTrayError extends Schema.TaggedErrorClass( }, ) {} -export type DesktopTrayMenuAction = - | "show" - | "settings" - | "toggle-close-to-tray" - | "quit" - | "disable-agents"; +export type DesktopTrayMenuAction = "show" | "settings" | "quit"; export class DesktopTray extends Context.Service< DesktopTray, @@ -75,6 +71,11 @@ export function buildTrayMenuTemplate(input: { closeToTray: boolean; onAction: (action: DesktopTrayMenuAction) => void; }): Electron.MenuItemConstructorOptions[] { + // Tray is intentionally minimal — user asked to keep background-service + // controls in Settings, not in the tray. Keep only show/settings/quit plus + // a non-interactive header with the running-jobs count. + void input.closeToTray; + void input.agentsPaused; const template: Electron.MenuItemConstructorOptions[] = [ { label: buildRunningLabel(input.runningCount, input.agentsPaused), @@ -90,15 +91,6 @@ export function buildTrayMenuTemplate(input: { click: () => input.onAction("settings"), }, { type: "separator" }, - { - label: input.agentsPaused ? "Resume agents" : "Pause agents", - click: () => input.onAction("disable-agents"), - }, - { - label: input.closeToTray ? "Disable background service" : "Enable background service", - click: () => input.onAction("toggle-close-to-tray"), - }, - { type: "separator" }, { label: "Quit", click: () => input.onAction("quit"), @@ -112,11 +104,11 @@ export const make = Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; const electronApp = yield* ElectronApp.ElectronApp; const electronTray = yield* ElectronTray.ElectronTray; - const electronWindow = yield* ElectronWindow.ElectronWindow; const environment = yield* DesktopEnvironment.DesktopEnvironment; const shutdown = yield* DesktopShutdown.DesktopShutdown; const state = yield* DesktopState.DesktopState; const settings = yield* DesktopAppSettings.DesktopAppSettings; + const fileSystem = yield* FileSystem.FileSystem; const runningCountRef = yield* Ref.make(0); const pausedRef = yield* Ref.make(false); @@ -147,7 +139,10 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { switch (action) { case "show": { - yield* desktopWindow.revealOrCreateMain.pipe( + // Use activate — it correctly re-reveals a hidden window or the + // WSL splash, and avoids the blank-page stuck state seen with + // revealOrCreateMain when the window was previously hidden. + yield* desktopWindow.activate.pipe( Effect.catch((error) => logTrayWarning("failed to reveal window from tray", { error: (error as any).message, @@ -157,37 +152,26 @@ export const make = Effect.gen(function* () { break; } case "settings": { - // Open main window then dispatch settings navigation. Reuse menu channel. - yield* desktopWindow.revealOrCreateMain.pipe( + // Ensure window is visible first, then dispatch via the same + // path the native menu uses (DesktopWindow → MENU_ACTION_CHANNEL). + // This guarantees the renderer receives "open-settings" even if + // it was just created or was hidden. + yield* desktopWindow.activate.pipe( Effect.catch((error) => logTrayWarning("failed to reveal window for settings", { error: (error as any).message, }), ), ); - // Best-effort: ask renderer to open settings via existing menu channel. - const windowOption = yield* electronWindow.currentMainOrFirst.pipe( - Effect.orElseSucceed(() => Option.none()), + // Small delay to let the renderer finish load after reveal; + // dispatchMenuAction handles isLoadingMainFrame internally. + yield* desktopWindow.dispatchMenuAction("open-settings").pipe( + Effect.catch((error) => + logTrayWarning("failed to dispatch settings action", { + error: (error as any).message, + }), + ), ); - if (Option.isSome(windowOption) && !windowOption.value.isDestroyed()) { - windowOption.value.webContents.send("t3-menu-action", "open-settings"); - } - break; - } - case "disable-agents": { - const next = !(yield* Ref.get(pausedRef)); - yield* Ref.set(pausedRef, next); - yield* rebuildMenu; - yield* electronTray.setToolTip(tray, yield* getTooltip).pipe(Effect.ignore); - yield* logTrayInfo(next ? "agents paused from tray" : "agents resumed from tray"); - break; - } - case "toggle-close-to-tray": { - const current = yield* settings.get; - const next = !current.closeToTray; - yield* settings.setCloseToTray(next).pipe(Effect.ignore); - yield* rebuildMenu; - yield* logTrayInfo("closeToTray toggled from tray", { enabled: next }); break; } case "quit": { @@ -234,14 +218,34 @@ export const make = Effect.gen(function* () { } const iconPaths = yield* assets.iconPaths; + const currentSettingsForIcon = yield* settings.get.pipe( + Effect.orElseSucceed(() => DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + ); // Prefer ico on Windows, png elsewhere. Fall back to empty NativeImage if probing failed. - const preferredIconPath = Option.match( + // For packaged builds iconPaths already holds the correct per-channel icon + // (nightly vs stable) via electron-builder resources. For unpacked dev, + // manually prefer the nightly icon when the update channel is nightly so + // the tray matches the nightly/stable branding the user sees in the window. + let preferredIconPath = Option.match( Option.orElse(iconPaths.ico, () => iconPaths.png), { - onNone: () => undefined, + onNone: () => undefined as string | undefined, onSome: (p) => p, }, ); + if (currentSettingsForIcon.updateChannel === "nightly") { + const nightlyCandidates = [ + `${environment.rootDir}/assets/nightly/nightly-windows.ico`, + `${environment.rootDir}/assets/nightly/nightly-universal-1024.png`, + ]; + for (const candidate of nightlyCandidates) { + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + preferredIconPath = candidate; + break; + } + } + } let nativeIcon: Electron.NativeImage | string; if (preferredIconPath !== undefined) { @@ -265,9 +269,11 @@ export const make = Effect.gen(function* () { yield* Ref.set(trayRef, Option.some(tray)); // Clicking the tray icon reveals the window; double-click also handled. + // Use activate to avoid the blank-page stuck state seen with + // revealOrCreateMain when the window was previously hidden. yield* electronTray.onClick(tray, () => { void Effect.runPromise( - desktopWindow.revealOrCreateMain.pipe( + desktopWindow.activate.pipe( Effect.catch((error) => logTrayWarning("failed to reveal on tray click", { error: (error as any).message }), ), @@ -276,7 +282,7 @@ export const make = Effect.gen(function* () { }); yield* electronTray.onDoubleClick(tray, () => { void Effect.runPromise( - desktopWindow.revealOrCreateMain.pipe( + desktopWindow.activate.pipe( Effect.catch((error) => logTrayWarning("failed to reveal on tray double-click", { error: (error as any).message, diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8e8317db7971..91353c636f13 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -46,6 +46,7 @@ import { } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; +import { getTraySettings, setCloseToTray, setMinimizeToTray } from "./methods/tray.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; @@ -82,6 +83,10 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setWslDistro); yield* ipc.handle(setWslOnly); + yield* ipc.handle(getTraySettings); + yield* ipc.handle(setCloseToTray); + yield* ipc.handle(setMinimizeToTray); + yield* ipc.handle(pickFolder); yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index c4ef82ec8cb7..371921838d8b 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -42,6 +42,9 @@ export const GET_WSL_STATE_CHANNEL = "desktop:get-wsl-state"; export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled"; export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro"; export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only"; +export const GET_TRAY_SETTINGS_CHANNEL = "desktop:get-tray-settings"; +export const SET_CLOSE_TO_TRAY_CHANNEL = "desktop:set-close-to-tray"; +export const SET_MINIMIZE_TO_TRAY_CHANNEL = "desktop:set-minimize-to-tray"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; diff --git a/apps/desktop/src/ipc/methods/tray.ts b/apps/desktop/src/ipc/methods/tray.ts new file mode 100644 index 000000000000..64745f31c575 --- /dev/null +++ b/apps/desktop/src/ipc/methods/tray.ts @@ -0,0 +1,53 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +const TraySettingsSchema = Schema.Struct({ + closeToTray: Schema.Boolean, + minimizeToTray: Schema.Boolean, +}); + +export const getTraySettings = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_TRAY_SETTINGS_CHANNEL, + payload: Schema.Void, + result: TraySettingsSchema, + handler: Effect.fn("desktop.ipc.tray.get")(function* () { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const settings = yield* appSettings.get; + return { + closeToTray: settings.closeToTray, + minimizeToTray: settings.minimizeToTray, + }; + }), +}); + +export const setCloseToTray = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_CLOSE_TO_TRAY_CHANNEL, + payload: Schema.Boolean, + result: TraySettingsSchema, + handler: Effect.fn("desktop.ipc.tray.setCloseToTray")(function* (enabled: boolean) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const result = yield* appSettings.setCloseToTray(enabled); + return { + closeToTray: result.settings.closeToTray, + minimizeToTray: result.settings.minimizeToTray, + }; + }), +}); + +export const setMinimizeToTray = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_MINIMIZE_TO_TRAY_CHANNEL, + payload: Schema.Boolean, + result: TraySettingsSchema, + handler: Effect.fn("desktop.ipc.tray.setMinimizeToTray")(function* (enabled: boolean) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const result = yield* appSettings.setMinimizeToTray(enabled); + return { + closeToTray: result.settings.closeToTray, + minimizeToTray: result.settings.minimizeToTray, + }; + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 407c7c3ef498..eca291bb2069 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -100,6 +100,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.SET_WSL_BACKEND_ENABLED_CHANNEL, enabled), setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), + getTraySettings: () => ipcRenderer.invoke(IpcChannels.GET_TRAY_SETTINGS_CHANNEL), + setCloseToTray: (enabled: boolean) => + ipcRenderer.invoke(IpcChannels.SET_CLOSE_TO_TRAY_CHANNEL, enabled), + setMinimizeToTray: (enabled: boolean) => + ipcRenderer.invoke(IpcChannels.SET_MINIMIZE_TO_TRAY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), pickProjectFavicon: (initialPath) => ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath), diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e77c05549265..122784913f15 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -974,6 +974,73 @@ function BackgroundActivityAdvancedDialog({ ); } +function DesktopTraySettings() { + const [traySettings, setTraySettings] = useState<{ + closeToTray: boolean; + minimizeToTray: boolean; + } | null>(null); + const hasTrayBridge = + typeof window !== "undefined" && typeof window.desktopBridge?.getTraySettings === "function"; + + useEffect(() => { + if (!hasTrayBridge) return; + void window.desktopBridge + ?.getTraySettings?.() + .then((settings) => { + if (settings && typeof settings.closeToTray === "boolean") setTraySettings(settings); + }) + .catch(() => undefined); + }, [hasTrayBridge]); + + if (!hasTrayBridge) return null; + + const closeToTray = traySettings?.closeToTray ?? true; + const minimizeToTray = traySettings?.minimizeToTray ?? false; + + return ( + <> + + { + const bridge = window.desktopBridge; + if (!bridge?.setCloseToTray) return; + void bridge + .setCloseToTray(Boolean(checked)) + .then((next) => setTraySettings(next)) + .catch(() => undefined); + }} + aria-label="Keep in system tray on close" + /> + } + /> + { + const bridge = window.desktopBridge; + if (!bridge?.setMinimizeToTray) return; + void bridge + .setMinimizeToTray(Boolean(checked)) + .then((next) => setTraySettings(next)) + .catch(() => undefined); + }} + aria-label="Minimize to tray" + /> + } + /> + + + ); +} + export function AppearanceSettingsPanel() { const { appearanceMode, @@ -2396,6 +2463,8 @@ export function GeneralSettingsPanel() { /> ) : null} + {isElectron ? : null} + Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + getTraySettings?: () => Promise<{ closeToTray: boolean; minimizeToTray: boolean }>; + setCloseToTray?: (enabled: boolean) => Promise<{ closeToTray: boolean; minimizeToTray: boolean }>; + setMinimizeToTray?: ( + enabled: boolean, + ) => Promise<{ closeToTray: boolean; minimizeToTray: boolean }>; /** * Desktop-only preview surface. Present iff the renderer is hosted by the * Electron desktop build; web builds have `preview === undefined`. From a7167cb20f59e45209ff0ec5b8538e2c8bbee69a Mon Sep 17 00:00:00 2001 From: Antoine Mathie Date: Thu, 27 Aug 2026 18:43:55 +0200 Subject: [PATCH 3/8] fix(desktop): wire tray running count from web to main - Add IPC SET_TRAY_RUNNING_COUNT (tray.ts, channels, preload, DesktopIpcHandlers, contracts DesktopBridge) - Add useTrayRunningCountSync hook in web (AppRoot) that counts threads where session.status is running/starting via appAtomRegistry + environmentThreadDetails.detailAtom and pushes to main via window.desktopBridge.setTrayRunningCount - Tray header/tooltip now shows live N agents running instead of 0 --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 8 +++- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/tray.ts | 11 ++++++ apps/desktop/src/preload.ts | 2 + apps/web/src/AppRoot.tsx | 7 ++++ apps/web/src/hooks/useTrayRunningCountSync.ts | 39 +++++++++++++++++++ packages/contracts/src/ipc.ts | 1 + 7 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/hooks/useTrayRunningCountSync.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 91353c636f13..8cf665fe5a0e 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -46,7 +46,12 @@ import { } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; -import { getTraySettings, setCloseToTray, setMinimizeToTray } from "./methods/tray.ts"; +import { + getTraySettings, + setCloseToTray, + setMinimizeToTray, + setTrayRunningCount, +} from "./methods/tray.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; @@ -86,6 +91,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(getTraySettings); yield* ipc.handle(setCloseToTray); yield* ipc.handle(setMinimizeToTray); + yield* ipc.handle(setTrayRunningCount); yield* ipc.handle(pickFolder); yield* ipc.handle(pickProjectFavicon); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 371921838d8b..34ea73b2ea9b 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -45,6 +45,7 @@ export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only"; export const GET_TRAY_SETTINGS_CHANNEL = "desktop:get-tray-settings"; export const SET_CLOSE_TO_TRAY_CHANNEL = "desktop:set-close-to-tray"; export const SET_MINIMIZE_TO_TRAY_CHANNEL = "desktop:set-minimize-to-tray"; +export const SET_TRAY_RUNNING_COUNT_CHANNEL = "desktop:set-tray-running-count"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; diff --git a/apps/desktop/src/ipc/methods/tray.ts b/apps/desktop/src/ipc/methods/tray.ts index 64745f31c575..4a2255d773d1 100644 --- a/apps/desktop/src/ipc/methods/tray.ts +++ b/apps/desktop/src/ipc/methods/tray.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopTray from "../../app/DesktopTray.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; @@ -51,3 +52,13 @@ export const setMinimizeToTray = DesktopIpc.makeIpcMethod({ }; }), }); + +export const setTrayRunningCount = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_TRAY_RUNNING_COUNT_CHANNEL, + payload: Schema.Number, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.tray.setRunningCount")(function* (count: number) { + const tray = yield* DesktopTray.DesktopTray; + yield* tray.updateRunningCount(count); + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index eca291bb2069..3e961e87f53d 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -105,6 +105,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.SET_CLOSE_TO_TRAY_CHANNEL, enabled), setMinimizeToTray: (enabled: boolean) => ipcRenderer.invoke(IpcChannels.SET_MINIMIZE_TO_TRAY_CHANNEL, enabled), + setTrayRunningCount: (count: number) => + ipcRenderer.invoke(IpcChannels.SET_TRAY_RUNNING_COUNT_CHANNEL, count), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), pickProjectFavicon: (initialPath) => ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath), diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index 857125c9fdaf..2caf0b65ab40 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -3,6 +3,7 @@ import { RouterProvider } from "@tanstack/react-router"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; +import { useTrayRunningCountSync } from "./hooks/useTrayRunningCountSync"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; @@ -11,6 +12,11 @@ import type { AppRouter } from "./router"; * outside the router so its webviews survive route transitions, but it must * share the same atom registry as routed UI. */ +function TrayRunningCountSync() { + useTrayRunningCountSync(); + return null; +} + export function AppRoot({ router }: { readonly router: AppRouter }) { return ( @@ -18,6 +24,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { + ); } diff --git a/apps/web/src/hooks/useTrayRunningCountSync.ts b/apps/web/src/hooks/useTrayRunningCountSync.ts new file mode 100644 index 000000000000..acef032deb70 --- /dev/null +++ b/apps/web/src/hooks/useTrayRunningCountSync.ts @@ -0,0 +1,39 @@ +import { useEffect, useMemo } from "react"; +import { useAtomValue } from "@effect/atom-react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { useThreadRefs } from "../state/entities"; +import { environmentThreadDetails } from "../state/threads"; + +/** + * Syncs the count of running agents/threads to the desktop tray (Windows). + * The tray shows "N agents running" in its header and tooltip. + * No-op when not running inside Electron desktop. + */ +export function useTrayRunningCountSync(): void { + const threadRefs = useThreadRefs(); + + const runningCount = useMemo(() => { + let count = 0; + for (const ref of threadRefs) { + const detailAtom = environmentThreadDetails.detailAtom(ref); + const detail = appAtomRegistry.get(detailAtom); + // detail.session?.status is the orchestration session status; + // "running" and "starting" are the active states (see + // shouldPersistThread in client-runtime/state/threads.ts). + const status = detail?.session?.status; + if (status === "running" || status === "starting") count += 1; + } + return count; + }, [threadRefs]); + + useEffect(() => { + const bridge = ( + window as unknown as { + desktopBridge?: { setTrayRunningCount?: (n: number) => Promise }; + } + ).desktopBridge; + if (!bridge?.setTrayRunningCount) return; + void bridge.setTrayRunningCount(runningCount).catch(() => undefined); + }, [runningCount]); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 447555248100..94c6466a787b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1150,6 +1150,7 @@ export interface DesktopBridge { setMinimizeToTray?: ( enabled: boolean, ) => Promise<{ closeToTray: boolean; minimizeToTray: boolean }>; + setTrayRunningCount?: (count: number) => Promise; /** * Desktop-only preview surface. Present iff the renderer is hosted by the * Electron desktop build; web builds have `preview === undefined`. From d4dbb83ebe8ac6bf2daf82b38ef9af8f8f52d910 Mon Sep 17 00:00:00 2001 From: Antoine Mathie Date: Thu, 27 Aug 2026 19:15:41 +0200 Subject: [PATCH 4/8] fix(desktop): make tray running-agent count live and local-only The tray count was computed from per-thread detail atoms that are only mounted for the open thread, read imperatively with no subscription, so it stayed at 0 and the tray always said no agents were running. Derive the count from thread shell snapshots instead, which carry every thread's session status, and subscribe reactively. Scope it to this machine only: the primary backend plus desktop-local secondaries such as WSL. Background liveness (subagents outliving the turn) is a known limitation and is not counted. --- .../src/hooks/useTrayRunningCountSync.test.ts | 111 ++++++++++++++++++ apps/web/src/hooks/useTrayRunningCountSync.ts | 86 +++++++++----- 2 files changed, 170 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/hooks/useTrayRunningCountSync.test.ts diff --git a/apps/web/src/hooks/useTrayRunningCountSync.test.ts b/apps/web/src/hooks/useTrayRunningCountSync.test.ts new file mode 100644 index 000000000000..1e90a302ea00 --- /dev/null +++ b/apps/web/src/hooks/useTrayRunningCountSync.test.ts @@ -0,0 +1,111 @@ +import { + BearerConnectionTarget, + PrimaryConnectionTarget, + type ConnectionCatalogEntry, +} from "@t3tools/client-runtime/connection"; +import { + EnvironmentId, + ThreadId, + type OrchestrationSession, + type OrchestrationSessionStatus, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { describe, expect, it } from "vite-plus/test"; + +import { desktopLocalConnectionId } from "../connection/desktopLocal"; +import { countLocalRunningAgents } from "./useTrayRunningCountSync"; + +const PRIMARY_ID = EnvironmentId.make("environment-primary"); +const WSL_ID = EnvironmentId.make("environment-wsl"); +const REMOTE_ID = EnvironmentId.make("environment-remote"); + +const PRIMARY_ENTRY: ConnectionCatalogEntry = { + target: new PrimaryConnectionTarget({ + environmentId: PRIMARY_ID, + label: "This device", + httpBaseUrl: "http://127.0.0.1:3773", + wsBaseUrl: "ws://127.0.0.1:3773", + }), + profile: Option.none(), +}; + +const WSL_ENTRY: ConnectionCatalogEntry = { + target: new BearerConnectionTarget({ + connectionId: desktopLocalConnectionId("wsl:Ubuntu"), + environmentId: WSL_ID, + label: "WSL (Ubuntu)", + }), + profile: Option.none(), +}; + +const REMOTE_ENTRY: ConnectionCatalogEntry = { + target: new BearerConnectionTarget({ + connectionId: "saved-remote", + environmentId: REMOTE_ID, + label: "Studio desktop", + }), + profile: Option.none(), +}; + +function session(status: OrchestrationSessionStatus): { session: OrchestrationSession } { + return { + session: { + threadId: ThreadId.make("thread-1"), + status, + providerName: "codex", + runtimeMode: "auto", + activeTurnId: null, + lastError: null, + updatedAt: "2026-08-27T00:00:00.000Z", + }, + }; +} + +describe("countLocalRunningAgents", () => { + it("counts starting and running sessions across local environments", () => { + const threadsByEnvironment = new Map([ + [PRIMARY_ID, [session("running"), session("starting"), session("idle")]], + [WSL_ID, [session("running")]], + ]); + + const count = countLocalRunningAgents( + [ + [PRIMARY_ID, PRIMARY_ENTRY], + [WSL_ID, WSL_ENTRY], + ], + (environmentId) => threadsByEnvironment.get(environmentId) ?? [], + ); + + expect(count).toBe(3); + }); + + it("ignores settled sessions and threads without a session", () => { + const count = countLocalRunningAgents([[PRIMARY_ID, PRIMARY_ENTRY]], () => [ + session("idle"), + session("ready"), + session("interrupted"), + session("stopped"), + session("error"), + { session: null }, + ]); + + expect(count).toBe(0); + }); + + it("excludes remote environments from the local count", () => { + const threadsByEnvironment = new Map([ + [PRIMARY_ID, [session("running")]], + [REMOTE_ID, [session("running"), session("running")]], + ]); + + const count = countLocalRunningAgents( + [ + [PRIMARY_ID, PRIMARY_ENTRY], + [REMOTE_ID, REMOTE_ENTRY], + ], + (environmentId) => threadsByEnvironment.get(environmentId) ?? [], + ); + + expect(count).toBe(1); + }); +}); diff --git a/apps/web/src/hooks/useTrayRunningCountSync.ts b/apps/web/src/hooks/useTrayRunningCountSync.ts index acef032deb70..ae61921a1e5f 100644 --- a/apps/web/src/hooks/useTrayRunningCountSync.ts +++ b/apps/web/src/hooks/useTrayRunningCountSync.ts @@ -1,39 +1,71 @@ -import { useEffect, useMemo } from "react"; import { useAtomValue } from "@effect/atom-react"; +import type { ConnectionCatalogEntry } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId, OrchestrationThreadShell } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect } from "react"; -import { appAtomRegistry } from "../rpc/atomRegistry"; -import { useThreadRefs } from "../state/entities"; -import { environmentThreadDetails } from "../state/threads"; +import { environmentCatalog } from "../connection/catalog"; +import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { environmentThreadShells } from "../state/threads"; /** - * Syncs the count of running agents/threads to the desktop tray (Windows). + * Count agents actively working on this machine: sessions in + * "starting"/"running" across the primary backend and any desktop-local + * secondary (e.g. the parallel WSL backend). Remote, SSH, and relay + * environments are excluded — the tray describes this machine only. + * + * Known limitation: background liveness (subagents or watch loops that + * outlive the turn) is not counted; the tray reads idle once the turn's + * session settles. + */ +export function countLocalRunningAgents( + entries: Iterable, + threadsForEnvironment: ( + environmentId: EnvironmentId, + ) => ReadonlyArray>, +): number { + let count = 0; + for (const [environmentId, entry] of entries) { + const isLocal = + entry.target._tag === "PrimaryConnectionTarget" || + isDesktopLocalConnectionTarget(entry.target); + if (!isLocal) continue; + for (const thread of threadsForEnvironment(environmentId)) { + const status = thread.session?.status; + if (status === "starting" || status === "running") count += 1; + } + } + return count; +} + +// Derived from thread shells: the shell snapshot stream carries every +// thread's session status, so the count stays live without opening +// per-thread detail subscriptions (those stream full message payloads). +const localRunningAgentCountAtom = Atom.make((get) => + countLocalRunningAgents(get(environmentCatalog.catalogValueAtom).entries, (environmentId) => + get(environmentThreadShells.environmentThreadsAtom(environmentId)), + ), +).pipe(Atom.withLabel("tray-local-running-agent-count")); + +const DISABLED_COUNT_ATOM = Atom.make(0).pipe(Atom.withLabel("tray-local-running-agent-count:off")); + +// Resolved once at import time; the preload script injects the bridge before +// app scripts run, so this never appears later. `undefined` on the web build. +const setTrayRunningCount = + typeof window === "undefined" ? undefined : window.desktopBridge?.setTrayRunningCount; + +/** + * Syncs the count of running local agents to the desktop tray (Windows). * The tray shows "N agents running" in its header and tooltip. * No-op when not running inside Electron desktop. */ export function useTrayRunningCountSync(): void { - const threadRefs = useThreadRefs(); - - const runningCount = useMemo(() => { - let count = 0; - for (const ref of threadRefs) { - const detailAtom = environmentThreadDetails.detailAtom(ref); - const detail = appAtomRegistry.get(detailAtom); - // detail.session?.status is the orchestration session status; - // "running" and "starting" are the active states (see - // shouldPersistThread in client-runtime/state/threads.ts). - const status = detail?.session?.status; - if (status === "running" || status === "starting") count += 1; - } - return count; - }, [threadRefs]); + const runningCount = useAtomValue( + setTrayRunningCount === undefined ? DISABLED_COUNT_ATOM : localRunningAgentCountAtom, + ); useEffect(() => { - const bridge = ( - window as unknown as { - desktopBridge?: { setTrayRunningCount?: (n: number) => Promise }; - } - ).desktopBridge; - if (!bridge?.setTrayRunningCount) return; - void bridge.setTrayRunningCount(runningCount).catch(() => undefined); + if (setTrayRunningCount === undefined) return; + void setTrayRunningCount(runningCount).catch(() => undefined); }, [runningCount]); } From 58573620e22bf4ae2515eb19a017bfbeafeafb86 Mon Sep 17 00:00:00 2001 From: Antoine Mathie Date: Thu, 27 Aug 2026 19:23:38 +0200 Subject: [PATCH 5/8] refactor(web): move tray settings to Connections The tray toggles configure whether the desktop-hosted backend keeps running after the window closes, so they belong with the other backend controls in Connections (under the WSL backend rows) rather than a lone Desktop section on the General page. --- .../settings/ConnectionsSettings.tsx | 74 ++++++++++++++++++- .../components/settings/SettingsPanels.tsx | 69 ----------------- 2 files changed, 73 insertions(+), 70 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 18d1b0f1c924..d20291a985e1 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -6,7 +6,7 @@ import { TerminalIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react"; +import { type ReactNode, memo, useCallback, useEffect, useId, useMemo, useState } from "react"; import { AuthAccessReadScope, AuthAccessWriteScope, @@ -1743,6 +1743,77 @@ function CloudRemoteEnvironmentRows({ ) : null; } +/** + * Windows tray behavior for the desktop-hosted backend. Lives with the other + * "This environment" backend controls (network access, WSL) because it decides + * whether closing the window keeps the local server and its agents running. + * Hidden when the host bridge has no tray support (web, older desktops). + */ +function DesktopTrayRows() { + const [traySettings, setTraySettings] = useState<{ + closeToTray: boolean; + minimizeToTray: boolean; + } | null>(null); + const hasTrayBridge = + typeof window !== "undefined" && typeof window.desktopBridge?.getTraySettings === "function"; + + useEffect(() => { + if (!hasTrayBridge) return; + void window.desktopBridge + ?.getTraySettings?.() + .then((settings) => { + if (settings && typeof settings.closeToTray === "boolean") setTraySettings(settings); + }) + .catch(() => undefined); + }, [hasTrayBridge]); + + if (!hasTrayBridge) return null; + + const closeToTray = traySettings?.closeToTray ?? true; + const minimizeToTray = traySettings?.minimizeToTray ?? false; + + return ( + <> + { + const bridge = window.desktopBridge; + if (!bridge?.setCloseToTray) return; + void bridge + .setCloseToTray(Boolean(checked)) + .then((next) => setTraySettings(next)) + .catch(() => undefined); + }} + aria-label="Keep in system tray on close" + /> + } + /> + { + const bridge = window.desktopBridge; + if (!bridge?.setMinimizeToTray) return; + void bridge + .setMinimizeToTray(Boolean(checked)) + .then((next) => setTraySettings(next)) + .catch(() => undefined); + }} + aria-label="Minimize to tray" + /> + } + /> + + ); +} + export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; const { environments } = useEnvironments(); @@ -3066,6 +3137,7 @@ export function ConnectionsSettings() { {renderEndpointRows("endpoint-rail")} {renderTailscaleRow()} {renderWslRow()} + ) : ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 122784913f15..e77c05549265 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -974,73 +974,6 @@ function BackgroundActivityAdvancedDialog({ ); } -function DesktopTraySettings() { - const [traySettings, setTraySettings] = useState<{ - closeToTray: boolean; - minimizeToTray: boolean; - } | null>(null); - const hasTrayBridge = - typeof window !== "undefined" && typeof window.desktopBridge?.getTraySettings === "function"; - - useEffect(() => { - if (!hasTrayBridge) return; - void window.desktopBridge - ?.getTraySettings?.() - .then((settings) => { - if (settings && typeof settings.closeToTray === "boolean") setTraySettings(settings); - }) - .catch(() => undefined); - }, [hasTrayBridge]); - - if (!hasTrayBridge) return null; - - const closeToTray = traySettings?.closeToTray ?? true; - const minimizeToTray = traySettings?.minimizeToTray ?? false; - - return ( - <> - - { - const bridge = window.desktopBridge; - if (!bridge?.setCloseToTray) return; - void bridge - .setCloseToTray(Boolean(checked)) - .then((next) => setTraySettings(next)) - .catch(() => undefined); - }} - aria-label="Keep in system tray on close" - /> - } - /> - { - const bridge = window.desktopBridge; - if (!bridge?.setMinimizeToTray) return; - void bridge - .setMinimizeToTray(Boolean(checked)) - .then((next) => setTraySettings(next)) - .catch(() => undefined); - }} - aria-label="Minimize to tray" - /> - } - /> - - - ); -} - export function AppearanceSettingsPanel() { const { appearanceMode, @@ -2463,8 +2396,6 @@ export function GeneralSettingsPanel() { /> ) : null} - {isElectron ? : null} - Date: Thu, 27 Aug 2026 19:41:08 +0200 Subject: [PATCH 6/8] fix(desktop): gate tray to Windows and address review Address PR review findings: - Suppress window-all-closed quit only on win32; elsewhere the window handlers destroy on close, so suppressing stranded a windowless app. Covered by new lifecycle tests for both paths. - Register the tray only on win32 and hide the tray settings rows and running-count sync off Windows, where the toggles had no effect. - Run tray Electron callbacks with the captured fiber context so logging and tracing stay wired to the app runtime. - Derive DesktopTrayError's message from the class instead of a singleton reason field; drop the (error as any).message casts. - Revert the single-instance test bypass and the contracts schema cast that leaked in from manual testing. - Keep AppRoot's doc comment attached to AppRoot. - Render tray settings rows only after the persisted state loads, disable them while a write is in flight, and surface failures. --- apps/desktop/src/app/DesktopApp.ts | 24 ++++---- apps/desktop/src/app/DesktopClerk.ts | 8 +-- apps/desktop/src/app/DesktopLifecycle.test.ts | 56 ++++++++++++++++++ apps/desktop/src/app/DesktopLifecycle.ts | 8 ++- apps/desktop/src/app/DesktopTray.ts | 37 ++++++------ .../src/settings/DesktopAppSettings.ts | 3 + apps/web/src/AppRoot.tsx | 10 ++-- .../settings/ConnectionsSettings.tsx | 59 ++++++++++++------- apps/web/src/hooks/useTrayRunningCountSync.ts | 7 ++- packages/contracts/src/orchestration.ts | 3 +- 10 files changed, 149 insertions(+), 66 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index ebd6e9043dfd..337c8c4c939f 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -279,20 +279,24 @@ const startup = Effect.gen(function* () { Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), ); yield* logStartupInfo("app ready"); - // Register system tray after app is ready — Tray requires a ready app on Windows. + // Register system tray after app is ready — Tray requires a ready app on + // Windows. The tray background service is Windows-only: macOS/Linux keep + // their existing lifecycle (no hidden-icons tray, quit on last window). // Best-effort: tray failures must not take down startup. ForkScoped ties the // tray's scope to the app's scopedProgram lifetime (until DesktopShutdown), // so the icon stays alive; do not wrap with Effect.scoped which would close // the scope right after register and destroy the tray. - yield* tray.register.pipe( - Effect.catch((error) => - logStartupInfo("tray registration failed; continuing without tray", { - error: (error as any).message ?? String(error), - }), - ), - Effect.forkScoped, - Effect.asVoid, - ); + if (environment.platform === "win32") { + yield* tray.register.pipe( + Effect.catch((error) => + logStartupInfo("tray registration failed; continuing without tray", { + error: error.message, + }), + ), + Effect.forkScoped, + Effect.asVoid, + ); + } if (environment.platform === "linux") { const selectedBackend = yield* safeStorage.selectedStorageBackend; yield* logStartupInfo("safe storage ready", { diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 45dba431b65f..9611dc083d2f 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -130,13 +130,7 @@ export const make = Effect.gen(function* () { // forwarded to the running app. In a secondary instance the bridge has // already begun quitting the app; app.quit() is asynchronous, so stop // bootstrap here before whenReady can fire. - // Test bypass for parallel tray verification alongside nightly — set - // T3_TRAY_TEST_BYPASS=1 to keep the second instance alive. Do not use - // in production; it would break OAuth forwarding. - if ( - process.env.T3_TRAY_TEST_BYPASS !== "1" && - !(bridge as unknown as { isPrimaryInstance: boolean }).isPrimaryInstance - ) { + if (!bridge.isPrimaryInstance) { yield* electronApp.quit; return yield* Effect.interrupt; } diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 7fa9f1db1609..e89d8ef73e6c 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -146,6 +146,62 @@ describe("DesktopLifecycle", () => { }); } + for (const testCase of [ + { platform: "win32", closeToTray: true, expectQuit: false }, + { platform: "win32", closeToTray: false, expectQuit: true }, + { platform: "linux", closeToTray: true, expectQuit: true }, + ] satisfies ReadonlyArray<{ + platform: NodeJS.Platform; + closeToTray: boolean; + expectQuit: boolean; + }>) { + it.effect( + `window-all-closed ${testCase.expectQuit ? "quits" : "stays alive for the tray"} on ${testCase.platform} with closeToTray=${String(testCase.closeToTray)}`, + () => + Effect.gen(function* () { + const appListeners = new Map void>(); + let quitCount = 0; + const quit = Effect.sync(() => { + quitCount += 1; + }); + const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: testCase.platform, + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + const layer = DesktopLifecycle.layer.pipe( + Layer.provideMerge(makeElectronAppLayer(appListeners, quit)), + Layer.provideMerge(electronThemeLayer), + Layer.provideMerge(makeElectronWindowLayer()), + Layer.provideMerge(makeDesktopWindowLayer()), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopState.layer), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + closeToTray: testCase.closeToTray, + }), + ), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + appListeners.get("window-all-closed")?.(); + // The handler is a forked promise over synchronous mocks; one + // macrotask boundary flushes it deterministically (a scheduler + // flush, not a timed wait). + yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + + assert.equal(quitCount, testCase.expectQuit ? 1 : 0); + }), + ).pipe(Effect.provide(layer)); + }), + ); + } + it.effect("destroys windows before waiting for backend shutdown", () => Effect.gen(function* () { const appListeners = new Map void>(); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index c4aafecebb20..472ef166f9ae 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -43,7 +43,7 @@ type DesktopLifecycleRegistrationServices = | ElectronWindow.ElectronWindow; /** - * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow + * @effect-expect-leaking DesktopAppSettings | DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow */ export class DesktopLifecycle extends Context.Service< DesktopLifecycle, @@ -243,9 +243,11 @@ export const make = DesktopLifecycle.of({ // When background service (closeToTray) is enabled, keep the app alive // in the tray even after the last window closes — this is the // expected Windows hidden-icons behavior. Without it, closing the - // window would kill all running agents. + // window would kill all running agents. Windows-only: elsewhere the + // window handlers destroy on close (DesktopWindow.shouldHideOnClose + // is win32-gated), so suppressing quit would strand a windowless app. const currentSettings = yield* settings.get; - if (currentSettings.closeToTray) { + if (environment.platform === "win32" && currentSettings.closeToTray) { yield* logLifecycleInfo("window-all-closed suppressed by closeToTray"); return; } diff --git a/apps/desktop/src/app/DesktopTray.ts b/apps/desktop/src/app/DesktopTray.ts index 3b829b21e289..b3bc45afb332 100644 --- a/apps/desktop/src/app/DesktopTray.ts +++ b/apps/desktop/src/app/DesktopTray.ts @@ -23,10 +23,13 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; export class DesktopTrayError extends Schema.TaggedErrorClass()( "DesktopTrayError", { - reason: Schema.String, cause: Schema.Defect(), }, -) {} +) { + override get message(): string { + return "Failed to create the desktop system tray icon."; + } +} export type DesktopTrayMenuAction = "show" | "settings" | "quit"; @@ -110,16 +113,16 @@ export const make = Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; const fileSystem = yield* FileSystem.FileSystem; + // Electron callbacks (menu clicks, tray clicks) re-enter the Effect world; + // run them with the captured fiber context so logging/tracing stay wired to + // the app runtime instead of the defaults. + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + const runningCountRef = yield* Ref.make(0); const pausedRef = yield* Ref.make(false); const trayRef = yield* Ref.make>(Option.none()); - const getTooltip = Effect.gen(function* () { - const count = yield* Ref.get(runningCountRef); - const paused = yield* Ref.get(pausedRef); - return buildTrayTooltip({ displayName: environment.displayName, runningCount: count, paused }); - }); - const rebuildMenu = Effect.gen(function* () { const trayOption = yield* Ref.get(trayRef); if (Option.isNone(trayOption)) return; @@ -135,7 +138,7 @@ export const make = Effect.gen(function* () { const onAction = (action: DesktopTrayMenuAction): void => { // Fire-and-forget to avoid blocking the menu click handler. - void Effect.runPromise( + void runPromise( Effect.gen(function* () { switch (action) { case "show": { @@ -145,7 +148,7 @@ export const make = Effect.gen(function* () { yield* desktopWindow.activate.pipe( Effect.catch((error) => logTrayWarning("failed to reveal window from tray", { - error: (error as any).message, + error: error.message, }), ), ); @@ -159,7 +162,7 @@ export const make = Effect.gen(function* () { yield* desktopWindow.activate.pipe( Effect.catch((error) => logTrayWarning("failed to reveal window for settings", { - error: (error as any).message, + error: error.message, }), ), ); @@ -168,7 +171,7 @@ export const make = Effect.gen(function* () { yield* desktopWindow.dispatchMenuAction("open-settings").pipe( Effect.catch((error) => logTrayWarning("failed to dispatch settings action", { - error: (error as any).message, + error: error.message, }), ), ); @@ -264,7 +267,7 @@ export const make = Effect.gen(function* () { const tray = yield* electronTray .create(nativeIcon) - .pipe(Effect.mapError((cause) => new DesktopTrayError({ reason: "tray-create", cause }))); + .pipe(Effect.mapError((cause) => new DesktopTrayError({ cause }))); yield* Ref.set(trayRef, Option.some(tray)); @@ -272,20 +275,20 @@ export const make = Effect.gen(function* () { // Use activate to avoid the blank-page stuck state seen with // revealOrCreateMain when the window was previously hidden. yield* electronTray.onClick(tray, () => { - void Effect.runPromise( + void runPromise( desktopWindow.activate.pipe( Effect.catch((error) => - logTrayWarning("failed to reveal on tray click", { error: (error as any).message }), + logTrayWarning("failed to reveal on tray click", { error: error.message }), ), ), ); }); yield* electronTray.onDoubleClick(tray, () => { - void Effect.runPromise( + void runPromise( desktopWindow.activate.pipe( Effect.catch((error) => logTrayWarning("failed to reveal on tray double-click", { - error: (error as any).message, + error: error.message, }), ), ), diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 152e26c8883e..6060a6d9aba2 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -90,6 +90,9 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { wslBackendEnabled: false, wslDistro: null, wslOnly: false, + // The tray background service is Windows-only. The flag defaults on but is + // inert elsewhere: DesktopWindow's hide-on-close, DesktopLifecycle's quit + // suppression, and the settings UI all gate on win32. closeToTray: true, minimizeToTray: false, }; diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index 2caf0b65ab40..956f99067cdf 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -7,16 +7,16 @@ import { useTrayRunningCountSync } from "./hooks/useTrayRunningCountSync"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; -/** - * Owns renderer-wide providers. The Electron browser host intentionally sits - * outside the router so its webviews survive route transitions, but it must - * share the same atom registry as routed UI. - */ function TrayRunningCountSync() { useTrayRunningCountSync(); return null; } +/** + * Owns renderer-wide providers. The Electron browser host intentionally sits + * outside the router so its webviews survive route transitions, but it must + * share the same atom registry as routed UI. + */ export function AppRoot({ router }: { readonly router: AppRouter }) { return ( diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index d20291a985e1..7cce38cc10fa 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -37,7 +37,7 @@ import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; -import { cn } from "../../lib/utils"; +import { cn, isWindowsPlatform } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; import { @@ -1747,13 +1747,18 @@ function CloudRemoteEnvironmentRows({ * Windows tray behavior for the desktop-hosted backend. Lives with the other * "This environment" backend controls (network access, WSL) because it decides * whether closing the window keeps the local server and its agents running. - * Hidden when the host bridge has no tray support (web, older desktops). + * Windows-only: the preload withholds the tray bridge methods elsewhere, and + * the platform check below covers older desktop hosts that still expose them. + * Like the WSL/Tailscale rows, nothing renders until the persisted state + * loads, so the switches never show a default that then visibly flips. */ function DesktopTrayRows() { const [traySettings, setTraySettings] = useState<{ closeToTray: boolean; minimizeToTray: boolean; } | null>(null); + const [isUpdating, setIsUpdating] = useState(false); + const [updateError, setUpdateError] = useState(null); const hasTrayBridge = typeof window !== "undefined" && typeof window.desktopBridge?.getTraySettings === "function"; @@ -1767,26 +1772,40 @@ function DesktopTrayRows() { .catch(() => undefined); }, [hasTrayBridge]); - if (!hasTrayBridge) return null; + const applyTraySetting = useCallback( + (apply: () => Promise<{ closeToTray: boolean; minimizeToTray: boolean }>) => { + setIsUpdating(true); + setUpdateError(null); + apply() + .then((next) => setTraySettings(next)) + .catch((error: unknown) => { + setUpdateError( + error instanceof Error ? error.message : "Failed to update tray settings.", + ); + }) + .finally(() => setIsUpdating(false)); + }, + [], + ); - const closeToTray = traySettings?.closeToTray ?? true; - const minimizeToTray = traySettings?.minimizeToTray ?? false; + if (!hasTrayBridge || !isWindowsPlatform(navigator.platform) || traySettings === null) { + return null; + } return ( <> {updateError} : null} control={ { - const bridge = window.desktopBridge; - if (!bridge?.setCloseToTray) return; - void bridge - .setCloseToTray(Boolean(checked)) - .then((next) => setTraySettings(next)) - .catch(() => undefined); + const setCloseToTray = window.desktopBridge?.setCloseToTray; + if (!setCloseToTray) return; + applyTraySetting(() => setCloseToTray(Boolean(checked))); }} aria-label="Keep in system tray on close" /> @@ -1794,17 +1813,15 @@ function DesktopTrayRows() { /> { - const bridge = window.desktopBridge; - if (!bridge?.setMinimizeToTray) return; - void bridge - .setMinimizeToTray(Boolean(checked)) - .then((next) => setTraySettings(next)) - .catch(() => undefined); + const setMinimizeToTray = window.desktopBridge?.setMinimizeToTray; + if (!setMinimizeToTray) return; + applyTraySetting(() => setMinimizeToTray(Boolean(checked))); }} aria-label="Minimize to tray" /> diff --git a/apps/web/src/hooks/useTrayRunningCountSync.ts b/apps/web/src/hooks/useTrayRunningCountSync.ts index ae61921a1e5f..9798d34f9ae3 100644 --- a/apps/web/src/hooks/useTrayRunningCountSync.ts +++ b/apps/web/src/hooks/useTrayRunningCountSync.ts @@ -6,6 +6,7 @@ import { useEffect } from "react"; import { environmentCatalog } from "../connection/catalog"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { isWindowsPlatform } from "../lib/utils"; import { environmentThreadShells } from "../state/threads"; /** @@ -51,8 +52,12 @@ const DISABLED_COUNT_ATOM = Atom.make(0).pipe(Atom.withLabel("tray-local-running // Resolved once at import time; the preload script injects the bridge before // app scripts run, so this never appears later. `undefined` on the web build. +// The tray itself is Windows-only, so skip the sync (and its thread-state +// subscription) on desktop hosts that expose the bridge but show no tray. const setTrayRunningCount = - typeof window === "undefined" ? undefined : window.desktopBridge?.setTrayRunningCount; + typeof window !== "undefined" && isWindowsPlatform(window.navigator.platform) + ? window.desktopBridge?.setTrayRunningCount + : undefined; /** * Syncs the count of running local agents to the desktop tray (Windows). diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b8c1140aa8fc..682d65fda8ac 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1558,10 +1558,9 @@ export const TurnCountRange = Schema.Struct({ Schema.makeFilter( (input) => input.fromTurnCount <= input.toTurnCount || - // @ts-expect-error upstream SchemaIssue type mismatch — runtime shape is correct new SchemaIssue.InvalidValue({ message: "fromTurnCount must be less than or equal to toTurnCount", - } as unknown), + }), { identifier: "OrchestrationTurnDiffRange" }, ), ); From bce70aacc21c07eccbf3540b7e3e635505a4fe9e Mon Sep 17 00:00:00 2001 From: Antoine Mathie Date: Thu, 27 Aug 2026 19:54:21 +0200 Subject: [PATCH 7/8] fix(desktop): Harden tray lifecycle boundaries --- .../src/app/DesktopAppIdentity.test.ts | 1 + apps/desktop/src/app/DesktopAssets.test.ts | 63 +++++++++++++-- apps/desktop/src/app/DesktopAssets.ts | 49 ++++++++++++ apps/desktop/src/app/DesktopLifecycle.test.ts | 24 +++++- apps/desktop/src/app/DesktopLifecycle.ts | 11 ++- apps/desktop/src/app/DesktopTray.ts | 76 +++---------------- apps/desktop/src/electron/ElectronTray.ts | 15 ++-- apps/desktop/src/window/DesktopWindow.test.ts | 1 + apps/desktop/src/window/DesktopWindow.ts | 37 ++++----- 9 files changed, 176 insertions(+), 101 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 5c39ff304b3b..1abbb786d19f 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -76,6 +76,7 @@ const makeAssetsLayer = (png: Option.Option) => icns: Option.none(), png, }), + resolveTrayIconPath: () => Effect.succeed(Option.none()), resolveResourcePath: () => Effect.succeed(Option.none()), } satisfies DesktopAssets.DesktopAssets["Service"]); diff --git a/apps/desktop/src/app/DesktopAssets.test.ts b/apps/desktop/src/app/DesktopAssets.test.ts index bb118d43d29a..bed1d072457e 100644 --- a/apps/desktop/src/app/DesktopAssets.test.ts +++ b/apps/desktop/src/app/DesktopAssets.test.ts @@ -44,7 +44,8 @@ describe("DesktopAssets", () => { ), ); const fileSystemLayer = FileSystem.layerNoop({ - exists: (path) => Effect.succeed(String(path).includes("/assets/dev/")), + exists: (path) => + Effect.succeed(String(path).replaceAll("\\", "/").includes("/assets/dev/")), }); const assets = yield* DesktopAssets.DesktopAssets.pipe( Effect.provide( @@ -56,12 +57,59 @@ describe("DesktopAssets", () => { const icons = yield* assets.iconPaths; - assert.match(Option.getOrThrow(icons.ico), /assets\/dev\/blueprint-windows\.ico$/); - assert.match(Option.getOrThrow(icons.png), /assets\/dev\/blueprint-universal-1024\.png$/); + assert.match( + Option.getOrThrow(icons.ico).replaceAll("\\", "/"), + /assets\/dev\/blueprint-windows\.ico$/, + ); + assert.match( + Option.getOrThrow(icons.png).replaceAll("\\", "/"), + /assets\/dev\/blueprint-universal-1024\.png$/, + ); assert.isTrue(Option.isNone(icons.icns)); }), ); + it.effect("prefers the unpackaged nightly tray icon", () => + Effect.gen(function* () { + const developmentEnvironmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "win32", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: false, + resourcesPath: "/repo/apps/desktop/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ VITE_DEV_SERVER_URL: "http://localhost:5733" }), + ), + ), + ); + const fileSystemLayer = FileSystem.layerNoop({ + exists: (path) => + Effect.succeed(String(path).replaceAll("\\", "/").includes("/assets/nightly/")), + }); + const assets = yield* DesktopAssets.DesktopAssets.pipe( + Effect.provide( + DesktopAssets.layer.pipe( + Layer.provide(Layer.merge(fileSystemLayer, developmentEnvironmentLayer)), + ), + ), + ); + + const trayIconPath = yield* assets.resolveTrayIconPath("nightly"); + + assert.match( + Option.getOrThrow(trayIconPath).replaceAll("\\", "/"), + /assets\/nightly\/nightly-windows\.ico$/, + ); + }), + ); + it.effect("preserves the failed asset candidate and filesystem cause", () => Effect.gen(function* () { const fileName = "custom.bin"; @@ -74,7 +122,10 @@ describe("DesktopAssets", () => { description: "private filesystem diagnostic", }); const fileSystemLayer = FileSystem.layerNoop({ - exists: (path) => (path === candidatePath ? Effect.fail(cause) : Effect.succeed(false)), + exists: (path) => + String(path).replaceAll("\\", "/") === candidatePath + ? Effect.fail(cause) + : Effect.succeed(false), }); const assetsLayer = DesktopAssets.layer.pipe( Layer.provide(Layer.merge(fileSystemLayer, environmentLayer)), @@ -85,10 +136,10 @@ describe("DesktopAssets", () => { assert.instanceOf(error, DesktopAssets.DesktopAssetProbeError); assert.equal(error.fileName, fileName); - assert.equal(error.candidatePath, candidatePath); + assert.equal(error.candidatePath.replaceAll("\\", "/"), candidatePath); assert.strictEqual(error.cause, cause); assert.equal( - error.message, + error.message.replaceAll("\\", "/"), `Failed to probe desktop asset "${fileName}" at ${candidatePath}.`, ); assert.notInclude(error.message, "private filesystem diagnostic"); diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts index f1c6f1bb8f1f..37ff44cd1216 100644 --- a/apps/desktop/src/app/DesktopAssets.ts +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -1,3 +1,4 @@ +import type { DesktopUpdateChannel } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -30,6 +31,9 @@ export class DesktopAssets extends Context.Service< DesktopAssets, { readonly iconPaths: Effect.Effect; + readonly resolveTrayIconPath: ( + updateChannel: DesktopUpdateChannel, + ) => Effect.Effect, DesktopAssetProbeError>; readonly resolveResourcePath: ( fileName: string, ) => Effect.Effect, DesktopAssetProbeError>; @@ -90,6 +94,43 @@ function resolveSourceTreeIconPath( return environment.path.join(environment.rootDir, "assets", brand, fileName); } +const resolveNightlyTrayIconPath = Effect.fn("desktop.assets.resolveNightlyTrayIconPath")( + function* ( + updateChannel: DesktopUpdateChannel, + ): Effect.fn.Return< + Option.Option, + DesktopAssetProbeError, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment + > { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (environment.isPackaged || updateChannel !== "nightly") { + return Option.none(); + } + + for (const fileName of ["nightly-windows.ico", "nightly-universal-1024.png"] as const) { + const candidatePath = environment.path.join( + environment.rootDir, + "assets", + "nightly", + fileName, + ); + const exists = yield* fileSystem + .exists(candidatePath) + .pipe( + Effect.mapError( + (cause) => new DesktopAssetProbeError({ fileName, candidatePath, cause }), + ), + ); + if (exists) { + return Option.some(candidatePath); + } + } + + return Option.none(); + }, +); + const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( ext: keyof DesktopIconPaths, ): Effect.fn.Return< @@ -131,6 +172,14 @@ export const make = Effect.gen(function* () { return DesktopAssets.of({ iconPaths: Effect.succeed(iconPaths), + resolveTrayIconPath: Effect.fn("desktop.assets.resolveTrayIconPath")(function* (updateChannel) { + const nightlyIconPath = yield* resolveNightlyTrayIconPath(updateChannel).pipe( + Effect.provide(context), + ); + return Option.isSome(nightlyIconPath) + ? nightlyIconPath + : Option.orElse(iconPaths.ico, () => iconPaths.png); + }), resolveResourcePath: Effect.fn("desktop.assets.resolveResourcePath.scoped")( function* (fileName) { return yield* resolveResourcePath(fileName).pipe(Effect.provide(context)); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index e89d8ef73e6c..a7d641a092ab 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -13,6 +13,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 DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; @@ -77,6 +78,17 @@ function makeElectronWindowLayer(destroyAll: Effect.Effect = Effect.void) }); } +function makeDesktopTrayLayer(isRegistered = false) { + return Layer.succeed(DesktopTray.DesktopTray, { + register: Effect.void, + isRegistered: Effect.succeed(isRegistered), + updateRunningCount: () => Effect.void, + updateTooltip: () => Effect.void, + setAgentsPaused: () => Effect.void, + rebuildMenu: Effect.void, + }); +} + function makeDesktopWindowLayer( input: { readonly activate?: Effect.Effect; @@ -116,6 +128,7 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(makeDesktopTrayLayer()), Layer.provideMerge(DesktopAppSettings.layerTest()), ); @@ -147,12 +160,14 @@ describe("DesktopLifecycle", () => { } for (const testCase of [ - { platform: "win32", closeToTray: true, expectQuit: false }, - { platform: "win32", closeToTray: false, expectQuit: true }, - { platform: "linux", closeToTray: true, expectQuit: true }, + { platform: "win32", closeToTray: true, trayRegistered: true, expectQuit: false }, + { platform: "win32", closeToTray: true, trayRegistered: false, expectQuit: true }, + { platform: "win32", closeToTray: false, trayRegistered: true, expectQuit: true }, + { platform: "linux", closeToTray: true, trayRegistered: false, expectQuit: true }, ] satisfies ReadonlyArray<{ platform: NodeJS.Platform; closeToTray: boolean; + trayRegistered: boolean; expectQuit: boolean; }>) { it.effect( @@ -176,6 +191,7 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(makeDesktopTrayLayer(testCase.trayRegistered)), Layer.provideMerge( DesktopAppSettings.layerTest({ ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, @@ -243,6 +259,7 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(environmentLayer), Layer.provideMerge(desktopShutdownLayer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(makeDesktopTrayLayer()), Layer.provideMerge(DesktopAppSettings.layerTest()), ); @@ -285,6 +302,7 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(makeDesktopTrayLayer()), Layer.provideMerge(DesktopAppSettings.layerTest()), ); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 472ef166f9ae..32421f63c2f5 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -14,6 +14,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"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -40,10 +41,11 @@ export type DesktopLifecycleRuntimeServices = type DesktopLifecycleRegistrationServices = | DesktopLifecycleRuntimeServices + | DesktopTray.DesktopTray | ElectronWindow.ElectronWindow; /** - * @effect-expect-leaking DesktopAppSettings | DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow + * @effect-expect-leaking DesktopAppSettings | DesktopEnvironment | DesktopShutdown | DesktopState | DesktopTray | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow */ export class DesktopLifecycle extends Context.Service< DesktopLifecycle, @@ -239,6 +241,7 @@ export const make = DesktopLifecycle.of({ const app = yield* ElectronApp.ElectronApp; const state = yield* DesktopState.DesktopState; const settings = yield* DesktopAppSettings.DesktopAppSettings; + const tray = yield* DesktopTray.DesktopTray; if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { // When background service (closeToTray) is enabled, keep the app alive // in the tray even after the last window closes — this is the @@ -247,7 +250,11 @@ export const make = DesktopLifecycle.of({ // window handlers destroy on close (DesktopWindow.shouldHideOnClose // is win32-gated), so suppressing quit would strand a windowless app. const currentSettings = yield* settings.get; - if (environment.platform === "win32" && currentSettings.closeToTray) { + if ( + environment.platform === "win32" && + currentSettings.closeToTray && + (yield* tray.isRegistered) + ) { yield* logLifecycleInfo("window-all-closed suppressed by closeToTray"); return; } diff --git a/apps/desktop/src/app/DesktopTray.ts b/apps/desktop/src/app/DesktopTray.ts index b3bc45afb332..b371d121b74c 100644 --- a/apps/desktop/src/app/DesktopTray.ts +++ b/apps/desktop/src/app/DesktopTray.ts @@ -4,11 +4,8 @@ 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 Schema from "effect/Schema"; -import * as FileSystem from "effect/FileSystem"; - -import * as Electron from "electron"; +import type * as Electron from "electron"; import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -20,23 +17,17 @@ import * as ElectronTray from "../electron/ElectronTray.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; -export class DesktopTrayError extends Schema.TaggedErrorClass()( - "DesktopTrayError", - { - cause: Schema.Defect(), - }, -) { - override get message(): string { - return "Failed to create the desktop system tray icon."; - } -} - export type DesktopTrayMenuAction = "show" | "settings" | "quit"; export class DesktopTray extends Context.Service< DesktopTray, { - readonly register: Effect.Effect; + readonly register: Effect.Effect< + void, + DesktopAssets.DesktopAssetProbeError | ElectronTray.ElectronTrayCreateError, + Scope.Scope + >; + readonly isRegistered: Effect.Effect; readonly updateRunningCount: (count: number) => Effect.Effect; readonly updateTooltip: (tooltip: string) => Effect.Effect; readonly setAgentsPaused: (paused: boolean) => Effect.Effect; @@ -111,7 +102,6 @@ export const make = Effect.gen(function* () { const shutdown = yield* DesktopShutdown.DesktopShutdown; const state = yield* DesktopState.DesktopState; const settings = yield* DesktopAppSettings.DesktopAppSettings; - const fileSystem = yield* FileSystem.FileSystem; // Electron callbacks (menu clicks, tray clicks) re-enter the Effect world; // run them with the captured fiber context so logging/tracing stay wired to @@ -220,54 +210,11 @@ export const make = Effect.gen(function* () { return; } - const iconPaths = yield* assets.iconPaths; - const currentSettingsForIcon = yield* settings.get.pipe( - Effect.orElseSucceed(() => DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), - ); - // Prefer ico on Windows, png elsewhere. Fall back to empty NativeImage if probing failed. - // For packaged builds iconPaths already holds the correct per-channel icon - // (nightly vs stable) via electron-builder resources. For unpacked dev, - // manually prefer the nightly icon when the update channel is nightly so - // the tray matches the nightly/stable branding the user sees in the window. - let preferredIconPath = Option.match( - Option.orElse(iconPaths.ico, () => iconPaths.png), - { - onNone: () => undefined as string | undefined, - onSome: (p) => p, - }, + const currentSettingsForIcon = yield* settings.get; + const preferredIconPath = yield* assets.resolveTrayIconPath( + currentSettingsForIcon.updateChannel, ); - if (currentSettingsForIcon.updateChannel === "nightly") { - const nightlyCandidates = [ - `${environment.rootDir}/assets/nightly/nightly-windows.ico`, - `${environment.rootDir}/assets/nightly/nightly-universal-1024.png`, - ]; - for (const candidate of nightlyCandidates) { - const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); - if (exists) { - preferredIconPath = candidate; - break; - } - } - } - - let nativeIcon: Electron.NativeImage | string; - if (preferredIconPath !== undefined) { - try { - nativeIcon = Electron.nativeImage.createFromPath(preferredIconPath); - if ((nativeIcon as Electron.NativeImage).isEmpty?.()) { - nativeIcon = preferredIconPath; - } - } catch { - nativeIcon = preferredIconPath; - } - } else { - // Fallback: generate a 16x16 empty image; tray will still appear. - nativeIcon = Electron.nativeImage.createEmpty(); - } - - const tray = yield* electronTray - .create(nativeIcon) - .pipe(Effect.mapError((cause) => new DesktopTrayError({ cause }))); + const tray = yield* electronTray.create(Option.getOrUndefined(preferredIconPath)); yield* Ref.set(trayRef, Option.some(tray)); @@ -311,6 +258,7 @@ export const make = Effect.gen(function* () { return DesktopTray.of({ register, + isRegistered: Ref.get(trayRef).pipe(Effect.map(Option.isSome)), updateRunningCount: (count) => Effect.gen(function* () { const safeCount = Math.max(0, Math.floor(count)); diff --git a/apps/desktop/src/electron/ElectronTray.ts b/apps/desktop/src/electron/ElectronTray.ts index 2fa9ef8d57f8..9205d0e01f2e 100644 --- a/apps/desktop/src/electron/ElectronTray.ts +++ b/apps/desktop/src/electron/ElectronTray.ts @@ -2,7 +2,6 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; 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 Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -43,7 +42,7 @@ export class ElectronTray extends Context.Service< ElectronTray, { readonly create: ( - icon: Electron.NativeImage | string, + iconPath: string | undefined, ) => Effect.Effect; readonly setToolTip: ( tray: Electron.Tray, @@ -93,12 +92,18 @@ export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; return ElectronTray.of({ - create: (icon) => + create: (iconPath) => Effect.try({ - try: () => new Electron.Tray(icon), + try: () => { + if (iconPath === undefined) { + return new Electron.Tray(Electron.nativeImage.createEmpty()); + } + const nativeImage = Electron.nativeImage.createFromPath(iconPath); + return new Electron.Tray(nativeImage.isEmpty() ? iconPath : nativeImage); + }, catch: (cause) => new ElectronTrayCreateError({ - iconPath: typeof icon === "string" ? icon : "NativeImage", + iconPath: iconPath ?? "NativeImage", cause, }), }), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 5dbfc81a825e..80d64b5066ef 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -146,6 +146,7 @@ const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { icns: Option.none(), png: Option.none(), }), + resolveTrayIconPath: () => Effect.succeed(Option.none()), resolveResourcePath: () => Effect.succeed(Option.none()), } satisfies DesktopAssets.DesktopAssets["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 93d4c540b109..c893d51fce3f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -289,6 +289,7 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + const runSync = Effect.runSyncWith(context); let flushMainWindowBounds: Effect.Effect = Effect.void; const dismissConnectingSplash = Effect.gen(function* () { @@ -595,32 +596,26 @@ export const make = Effect.gen(function* () { window.on("move", scheduleBoundsPersist); window.on("maximize", scheduleBoundsPersist); window.on("unmaximize", scheduleBoundsPersist); - // Tray background service: hide instead of destroying the window when - // closeToTray/minimizeToTray is enabled. The Effect refs are read - // synchronously via runSync here because the Electron close event must be - // cancelled synchronously — an async check would miss the preventDefault - // window. Both refs are pure SynchronizedRef/Ref reads with no service - // requirements, so runSync is safe. Quitting always bypasses the hide. - const shouldHideOnClose = (): boolean => { + // Electron requires the close decision synchronously so preventDefault can + // run before the native event returns. Re-enter the captured app runtime; + // if a settings read ever stops being synchronous, fail open and log it. + const shouldHideForTray = (setting: "closeToTray" | "minimizeToTray"): boolean => { try { if (environment.platform !== "win32") return false; - if (Effect.runSync(Ref.get(desktopState.quitting))) return false; - const settings = Effect.runSync(desktopSettings.get); - return settings.closeToTray; - } catch { - return false; - } - }; - const shouldHideOnMinimize = (): boolean => { - try { - if (environment.platform !== "win32") return false; - if (Effect.runSync(Ref.get(desktopState.quitting))) return false; - const settings = Effect.runSync(desktopSettings.get); - return settings.minimizeToTray; - } catch { + if (runSync(Ref.get(desktopState.quitting))) return false; + return runSync(desktopSettings.get)[setting]; + } catch (cause) { + runFork( + logWindowWarning("failed to read tray setting synchronously", { + setting, + cause, + }), + ); return false; } }; + const shouldHideOnClose = () => shouldHideForTray("closeToTray"); + const shouldHideOnMinimize = () => shouldHideForTray("minimizeToTray"); window.on("close", (event?: Electron.Event) => { if (shouldHideOnClose()) { event?.preventDefault(); From dc156de3eb3705ce1b1a41422a8564237ff140a2 Mon Sep 17 00:00:00 2001 From: Antoine Mathie Date: Thu, 27 Aug 2026 20:02:06 +0200 Subject: [PATCH 8/8] fix(web): Attribute tray setting errors --- .../settings/ConnectionsSettings.tsx | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 7cce38cc10fa..8dfdfefb8ce5 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1758,7 +1758,10 @@ function DesktopTrayRows() { minimizeToTray: boolean; } | null>(null); const [isUpdating, setIsUpdating] = useState(false); - const [updateError, setUpdateError] = useState(null); + const [updateError, setUpdateError] = useState<{ + setting: "closeToTray" | "minimizeToTray"; + message: string; + } | null>(null); const hasTrayBridge = typeof window !== "undefined" && typeof window.desktopBridge?.getTraySettings === "function"; @@ -1773,15 +1776,19 @@ function DesktopTrayRows() { }, [hasTrayBridge]); const applyTraySetting = useCallback( - (apply: () => Promise<{ closeToTray: boolean; minimizeToTray: boolean }>) => { + ( + setting: "closeToTray" | "minimizeToTray", + apply: () => Promise<{ closeToTray: boolean; minimizeToTray: boolean }>, + ) => { setIsUpdating(true); setUpdateError(null); apply() .then((next) => setTraySettings(next)) .catch((error: unknown) => { - setUpdateError( - error instanceof Error ? error.message : "Failed to update tray settings.", - ); + setUpdateError({ + setting, + message: error instanceof Error ? error.message : "Failed to update tray settings.", + }); }) .finally(() => setIsUpdating(false)); }, @@ -1797,7 +1804,11 @@ function DesktopTrayRows() { {updateError} : null} + status={ + updateError?.setting === "closeToTray" ? ( + {updateError.message} + ) : null + } control={ { const setCloseToTray = window.desktopBridge?.setCloseToTray; if (!setCloseToTray) return; - applyTraySetting(() => setCloseToTray(Boolean(checked))); + applyTraySetting("closeToTray", () => setCloseToTray(Boolean(checked))); }} aria-label="Keep in system tray on close" /> @@ -1814,6 +1825,11 @@ function DesktopTrayRows() { {updateError.message} + ) : null + } control={ { const setMinimizeToTray = window.desktopBridge?.setMinimizeToTray; if (!setMinimizeToTray) return; - applyTraySetting(() => setMinimizeToTray(Boolean(checked))); + applyTraySetting("minimizeToTray", () => setMinimizeToTray(Boolean(checked))); }} aria-label="Minimize to tray" />