diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4101840530f6..337c8c4c939f 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,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. 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. + 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/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 f5ff3d5f6af6..a7d641a092ab 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -13,6 +13,8 @@ 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"; function makeElectronAppLayer( @@ -76,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; @@ -115,6 +128,8 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(makeDesktopTrayLayer()), + Layer.provideMerge(DesktopAppSettings.layerTest()), ); return Effect.scoped( @@ -144,6 +159,65 @@ describe("DesktopLifecycle", () => { }); } + for (const testCase of [ + { 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( + `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(makeDesktopTrayLayer(testCase.trayRegistered)), + 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>(); @@ -185,6 +259,8 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(environmentLayer), Layer.provideMerge(desktopShutdownLayer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(makeDesktopTrayLayer()), + Layer.provideMerge(DesktopAppSettings.layerTest()), ); yield* Effect.scoped( @@ -226,6 +302,8 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(makeDesktopTrayLayer()), + 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..32421f63c2f5 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -14,7 +14,9 @@ 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"; export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass()( "DesktopLifecycleRelaunchError", @@ -34,14 +36,16 @@ export type DesktopLifecycleRuntimeServices = | DesktopState.DesktopState | DesktopWindow.DesktopWindow | ElectronApp.ElectronApp - | ElectronTheme.ElectronTheme; + | ElectronTheme.ElectronTheme + | DesktopAppSettings.DesktopAppSettings; type DesktopLifecycleRegistrationServices = | DesktopLifecycleRuntimeServices + | DesktopTray.DesktopTray | ElectronWindow.ElectronWindow; /** - * @effect-expect-leaking 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, @@ -236,7 +240,24 @@ export const make = DesktopLifecycle.of({ Effect.gen(function* () { 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 + // expected Windows hidden-icons behavior. Without it, closing the + // 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 ( + environment.platform === "win32" && + currentSettings.closeToTray && + (yield* tray.isRegistered) + ) { + 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..edbe25a75d20 --- /dev/null +++ b/apps/desktop/src/app/DesktopTray.test.ts @@ -0,0 +1,56 @@ +import { assert, describe, it } from "@effect/vitest"; + +import * as DesktopTray from "./DesktopTray.ts"; + +describe("DesktopTray", () => { + it("builds menu with idle label", () => { + 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 === "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", () => { + const template = DesktopTray.buildTrayMenuTemplate({ + runningCount: 3, + agentsPaused: false, + closeToTray: true, + onAction: () => undefined, + }); + assert.equal(template[0]?.label, "3 agents running"); + }); + + it("shows paused state", () => { + const template = DesktopTray.buildTrayMenuTemplate({ + runningCount: 2, + agentsPaused: true, + closeToTray: true, + onAction: () => undefined, + }); + assert.equal(template[0]?.label, "Agents paused"); + }); + + 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..b371d121b74c --- /dev/null +++ b/apps/desktop/src/app/DesktopTray.ts @@ -0,0 +1,283 @@ +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 type * 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 DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; + +export type DesktopTrayMenuAction = "show" | "settings" | "quit"; + +export class DesktopTray extends Context.Service< + DesktopTray, + { + 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; + 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[] { + // 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), + enabled: false, + }, + { type: "separator" }, + { + label: "Show T3 Code", + click: () => input.onAction("show"), + }, + { + label: "Settings", + click: () => input.onAction("settings"), + }, + { 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 environment = yield* DesktopEnvironment.DesktopEnvironment; + const shutdown = yield* DesktopShutdown.DesktopShutdown; + const state = yield* DesktopState.DesktopState; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + // 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 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 runPromise( + Effect.gen(function* () { + switch (action) { + case "show": { + // 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.message, + }), + ), + ); + break; + } + case "settings": { + // 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.message, + }), + ), + ); + // 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.message, + }), + ), + ); + 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 currentSettingsForIcon = yield* settings.get; + const preferredIconPath = yield* assets.resolveTrayIconPath( + currentSettingsForIcon.updateChannel, + ); + const tray = yield* electronTray.create(Option.getOrUndefined(preferredIconPath)); + + 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 runPromise( + desktopWindow.activate.pipe( + Effect.catch((error) => + logTrayWarning("failed to reveal on tray click", { error: error.message }), + ), + ), + ); + }); + yield* electronTray.onDoubleClick(tray, () => { + void runPromise( + desktopWindow.activate.pipe( + Effect.catch((error) => + logTrayWarning("failed to reveal on tray double-click", { + error: error.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, + isRegistered: Ref.get(trayRef).pipe(Effect.map(Option.isSome)), + 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..9205d0e01f2e --- /dev/null +++ b/apps/desktop/src/electron/ElectronTray.ts @@ -0,0 +1,199 @@ +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 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: ( + iconPath: string | undefined, + ) => 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: (iconPath) => + Effect.try({ + 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: iconPath ?? "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/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8e8317db7971..8cf665fe5a0e 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -46,6 +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, + setTrayRunningCount, +} from "./methods/tray.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; @@ -82,6 +88,11 @@ 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(setTrayRunningCount); + 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..34ea73b2ea9b 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -42,6 +42,10 @@ 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 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 new file mode 100644 index 000000000000..4a2255d773d1 --- /dev/null +++ b/apps/desktop/src/ipc/methods/tray.ts @@ -0,0 +1,64 @@ +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"; + +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, + }; + }), +}); + +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/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/preload.ts b/apps/desktop/src/preload.ts index d1313ff2e767..f39f0cc1fee9 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -104,6 +104,13 @@ 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), + 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/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..6060a6d9aba2 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,11 @@ 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, }; const DesktopWindowBoundsDocument = Schema.Struct({ @@ -109,6 +120,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 +188,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 +257,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 +305,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 +401,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 +593,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 +642,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..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"]); @@ -180,6 +181,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 +238,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 +383,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..c893d51fce3f 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 @@ -286,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* () { @@ -592,9 +596,43 @@ export const make = Effect.gen(function* () { window.on("move", scheduleBoundsPersist); window.on("maximize", scheduleBoundsPersist); window.on("unmaximize", scheduleBoundsPersist); - window.on("close", () => { + // 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 (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(); + 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/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index 857125c9fdaf..956f99067cdf 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -3,9 +3,15 @@ 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"; +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 @@ -18,6 +24,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { + ); } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 18d1b0f1c924..8dfdfefb8ce5 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, @@ -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 { @@ -1743,6 +1743,110 @@ 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. + * 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<{ + setting: "closeToTray" | "minimizeToTray"; + message: string; + } | 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]); + + const applyTraySetting = useCallback( + ( + setting: "closeToTray" | "minimizeToTray", + apply: () => Promise<{ closeToTray: boolean; minimizeToTray: boolean }>, + ) => { + setIsUpdating(true); + setUpdateError(null); + apply() + .then((next) => setTraySettings(next)) + .catch((error: unknown) => { + setUpdateError({ + setting, + message: error instanceof Error ? error.message : "Failed to update tray settings.", + }); + }) + .finally(() => setIsUpdating(false)); + }, + [], + ); + + if (!hasTrayBridge || !isWindowsPlatform(navigator.platform) || traySettings === null) { + return null; + } + + return ( + <> + {updateError.message} + ) : null + } + control={ + { + const setCloseToTray = window.desktopBridge?.setCloseToTray; + if (!setCloseToTray) return; + applyTraySetting("closeToTray", () => setCloseToTray(Boolean(checked))); + }} + aria-label="Keep in system tray on close" + /> + } + /> + {updateError.message} + ) : null + } + control={ + { + const setMinimizeToTray = window.desktopBridge?.setMinimizeToTray; + if (!setMinimizeToTray) return; + applyTraySetting("minimizeToTray", () => setMinimizeToTray(Boolean(checked))); + }} + aria-label="Minimize to tray" + /> + } + /> + + ); +} + export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; const { environments } = useEnvironments(); @@ -3066,6 +3170,7 @@ export function ConnectionsSettings() { {renderEndpointRows("endpoint-rail")} {renderTailscaleRow()} {renderWslRow()} + ) : ( 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 new file mode 100644 index 000000000000..9798d34f9ae3 --- /dev/null +++ b/apps/web/src/hooks/useTrayRunningCountSync.ts @@ -0,0 +1,76 @@ +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 { environmentCatalog } from "../connection/catalog"; +import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { isWindowsPlatform } from "../lib/utils"; +import { environmentThreadShells } from "../state/threads"; + +/** + * 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. +// 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" && isWindowsPlatform(window.navigator.platform) + ? window.desktopBridge?.setTrayRunningCount + : undefined; + +/** + * 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 runningCount = useAtomValue( + setTrayRunningCount === undefined ? DISABLED_COUNT_ATOM : localRunningAgentCountAtom, + ); + + useEffect(() => { + if (setTrayRunningCount === undefined) return; + void setTrayRunningCount(runningCount).catch(() => undefined); + }, [runningCount]); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 19300aac328d..984de41c4a01 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1153,6 +1153,12 @@ export interface DesktopBridge { downloadUpdate: () => 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 }>; + setTrayRunningCount?: (count: number) => Promise; /** * Desktop-only preview surface. Present iff the renderer is hosted by the * Electron desktop build; web builds have `preview === undefined`.