From 341562bb30cca18d930ced17a0926811f6d0a98c Mon Sep 17 00:00:00 2001 From: giuli Date: Wed, 12 Aug 2026 16:28:16 -0400 Subject: [PATCH] feat(desktop): macOS menu bar tray with running agents per project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the new T3 menu bar item fetches a fresh orchestration shell snapshot from the local backend and pops up a menu showing live agents grouped by project (shared agentAwareness phases), plus entries for Open T3 Code, Settings, Usage, Pull Requests, Check for Updates and Quit. Fetch happens at click time only — no background polling — and degrades to the last good snapshot when the backend is unreachable. Settings/Usage/Pull Requests ride the existing desktop:menu-action IPC channel; the renderer handler now also navigates /usage and /pull-requests. The update check handler is shared with the app menu, tagged with a "tray" reason. Done with Claude Code (Claude Fable 5). Co-Authored-By: Claude Fable 5 --- apps/desktop/resources/t3TrayTemplate.png | Bin 0 -> 673 bytes apps/desktop/resources/t3TrayTemplate@2x.png | Bin 0 -> 955 bytes apps/desktop/src/app/DesktopApp.ts | 3 + .../desktop/src/electron/ElectronTray.test.ts | 117 +++++++++ apps/desktop/src/electron/ElectronTray.ts | 95 +++++++ apps/desktop/src/main.ts | 4 + .../src/window/DesktopApplicationMenu.ts | 56 +++-- apps/desktop/src/window/DesktopTray.ts | 237 ++++++++++++++++++ apps/desktop/src/window/trayMenu.test.ts | 145 +++++++++++ apps/desktop/src/window/trayMenu.ts | 89 +++++++ apps/web/src/components/AppSidebarLayout.tsx | 6 + .../2026-08-12-desktop-tray-menu-design.md | 91 +++++++ 12 files changed, 818 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/resources/t3TrayTemplate.png create mode 100644 apps/desktop/resources/t3TrayTemplate@2x.png create mode 100644 apps/desktop/src/electron/ElectronTray.test.ts create mode 100644 apps/desktop/src/electron/ElectronTray.ts create mode 100644 apps/desktop/src/window/DesktopTray.ts create mode 100644 apps/desktop/src/window/trayMenu.test.ts create mode 100644 apps/desktop/src/window/trayMenu.ts create mode 100644 docs/superpowers/specs/2026-08-12-desktop-tray-menu-design.md diff --git a/apps/desktop/resources/t3TrayTemplate.png b/apps/desktop/resources/t3TrayTemplate.png new file mode 100644 index 0000000000000000000000000000000000000000..e749d1a2ea2622a1820eadf5d83e1a5867251ead GIT binary patch literal 673 zcmeAS@N?(olHy`uVBq!ia0vp^5Fdh=ii1Z)$iQX2 z?mM86YKdz^NlIc#s#S7PDv)9@GB7gJHL%b%Gzu{?urjo;GO^G$Ft9Q(_#AQNGKz-W z{FKbJO57TnSab4$8Z_WGlw{_n7MCRE7U0%%Rls)x0|R5Dr;B5V#p$hAZ2hM=3b22; zU#!r?a`YXC%CTcz0TDAC(rYq1r}9p{e}pSjXUj+F6|#y88wH{@1Gu%74JU9&B&)o5 zs(0vaG~8cka*gXU^R&IydELo#f;f&YQXaakB;3vCU2t&);}I$?t*m zD~pb;^P_d+gwGquKW|q4z`N!V*O#_twyYIxN&b6+G{PU>k#@^kailrwSMv^m?_Mut zmt_b%W?b=D>{)@Jc7;rzan8b))dyF*v!yBL#((7ScHO#ja`&^;=~jISmK*Fp80hQB zE}rr{oppzz&C$C%^dFw}l$kTdt@v1CR93_7|EC>!vn$$pR~o8)l)2t5@;>wWgV57L_@zU>d@WoJ%YBC?6=+S2t;JY5Pc yPR=dy%sQx3v?sPf^Y~A7vyH`VTjtOG$F!q3%fip1c@;1M89ZJ6T-G@yGywpCI|5z+ literal 0 HcmV?d00001 diff --git a/apps/desktop/resources/t3TrayTemplate@2x.png b/apps/desktop/resources/t3TrayTemplate@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d7c86771ebc21557554d964ca9d7f57c023fb702 GIT binary patch literal 955 zcmeAS@N?(olHy`uVBq!ia0vp^20*O9!3HEluKbn(agsfPd>I(3)EF2VS{N990fib~ zFffz?6~0PfV6d9Oz#v{QXIG#NP=YPV+ua371Hn({-St3=o%V@7#Ua@T3DHw zYa19?85pn%EU`k-kei>9nO2EgLy50U8&HD=+=i0O+|=Td#M}ZbdMqH8Tov%$z`(%N z=IP=X5^?zL4gZd_fg))#GY=Qew+Qrq|g)o8%sfaNTXZ=d#?a z|IbVfr=;~J39gJu+8cG9%h7$`)so{(g)&rLS8z+)_5_2z7j*oX2r<`FgJFoaKx3lK-l0;q<*bp}?U`@pd3nXG~U) z;Hv{!HYYY;U%BOz$9#tW_ZrXd=Q{7yNK)cp#Hyj<}Y7 z&QZ37j@4>MKAS7vOHYtomUiQ%5LdLlmTLm1nOpJWR)b8vLw?flj?G)ia_!gazY8Dr z2{he(dD3g{0awLs=E;5&cir8g^u<{}DRCpqKJH4ZIki9N$+jnac^A^`{bvc$Zvngy#*(eN;qmyB^dH;7K-jYIb%}IkJ~K;>~Y>p&jj`) cNqk_>-n=?=sgOuDFcmX+y85}Sb4q9e0Qu~K`Tzg` literal 0 HcmV?d00001 diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4101840530f..969c613c61d 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 "../window/DesktopTray.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -221,6 +222,7 @@ const bootstrap = Effect.gen(function* () { const startup = Effect.gen(function* () { const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + const tray = yield* DesktopTray.DesktopTray; const electronApp = yield* ElectronApp.ElectronApp; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; @@ -285,6 +287,7 @@ const startup = Effect.gen(function* () { } yield* appIdentity.configure; yield* applicationMenu.configure; + yield* tray.configure; yield* updates.configure; yield* linuxUrlHandler.register; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); diff --git a/apps/desktop/src/electron/ElectronTray.test.ts b/apps/desktop/src/electron/ElectronTray.test.ts new file mode 100644 index 00000000000..55fc8e33334 --- /dev/null +++ b/apps/desktop/src/electron/ElectronTray.test.ts @@ -0,0 +1,117 @@ +import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +const { buildFromTemplateMock, createFromPathMock, trayInstances, TrayMock } = vi.hoisted(() => { + class FakeTray { + readonly listeners = new Map void>(); + readonly icon: unknown; + tooltip: string | null = null; + destroyed = false; + poppedMenus: unknown[] = []; + constructor(icon: unknown) { + this.icon = icon; + trayInstances.push(this); + } + on(event: string, listener: () => void) { + this.listeners.set(event, listener); + return this; + } + setToolTip(tooltip: string) { + this.tooltip = tooltip; + } + popUpContextMenu(menu: unknown) { + this.poppedMenus.push(menu); + } + isDestroyed() { + return this.destroyed; + } + destroy() { + this.destroyed = true; + } + } + const trayInstances: FakeTray[] = []; + return { + buildFromTemplateMock: vi.fn((template: unknown) => ({ template })), + createFromPathMock: vi.fn((path: string) => ({ + path, + templateImage: false, + setTemplateImage(value: boolean) { + this.templateImage = value; + }, + })), + trayInstances, + TrayMock: FakeTray, + }; +}); + +vi.mock("electron", () => ({ + Tray: TrayMock, + Menu: { + buildFromTemplate: buildFromTemplateMock, + }, + nativeImage: { + createFromPath: createFromPathMock, + }, +})); + +import * as ElectronTray from "./ElectronTray.ts"; + +const TestLayer = ElectronTray.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), +); + +describe("ElectronTray", () => { + beforeEach(() => { + buildFromTemplateMock.mockClear(); + createFromPathMock.mockClear(); + trayInstances.length = 0; + }); + + it.effect("creates a template-image tray and destroys it with the scope", () => + Effect.gen(function* () { + const electronTray = yield* ElectronTray.ElectronTray; + const onClick = vi.fn(); + yield* Effect.scoped( + Effect.gen(function* () { + yield* electronTray.create({ iconPath: "/tray.png", tooltip: "T3 Code", onClick }); + const tray = trayInstances[0]; + assert.isDefined(tray); + assert.equal(tray?.tooltip, "T3 Code"); + assert.isTrue((tray?.icon as { templateImage: boolean } | undefined)?.templateImage); + tray?.listeners.get("click")?.(); + tray?.listeners.get("right-click")?.(); + assert.equal(onClick.mock.calls.length, 2); + assert.isFalse(tray?.destroyed); + }), + ); + assert.isTrue(trayInstances[0]?.destroyed); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("pops up a menu built from the template on the live tray", () => + Effect.gen(function* () { + const electronTray = yield* ElectronTray.ElectronTray; + yield* Effect.scoped( + Effect.gen(function* () { + yield* electronTray.create({ iconPath: "/tray.png", tooltip: "T3 Code", onClick: () => {} }); + yield* electronTray.popUpMenu([{ label: "Open" }]); + const tray = trayInstances[0]; + assert.equal(buildFromTemplateMock.mock.calls.length, 1); + assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0], [{ label: "Open" }]); + assert.equal(tray?.poppedMenus.length, 1); + }), + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("ignores popUpMenu when no tray exists", () => + Effect.gen(function* () { + const electronTray = yield* ElectronTray.ElectronTray; + yield* electronTray.popUpMenu([{ label: "Open" }]); + assert.equal(buildFromTemplateMock.mock.calls.length, 0); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronTray.ts b/apps/desktop/src/electron/ElectronTray.ts new file mode 100644 index 00000000000..6a41dcbc1a7 --- /dev/null +++ b/apps/desktop/src/electron/ElectronTray.ts @@ -0,0 +1,95 @@ +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 type * as Scope from "effect/Scope"; + +import * as Electron from "electron"; + +export interface ElectronTrayCreateInput { + readonly iconPath: string; + readonly tooltip: string; + /** + * Fired on both left and right click. The tray deliberately has no + * persistent context menu: with one set, macOS opens it on mousedown and + * never emits `click`, which would prevent building the menu from fresh + * data at open time. Callers respond by popping a menu via `popUpMenu`. + */ + readonly onClick: () => void; +} + +const ElectronTrayOperation = Schema.Literals(["create", "pop-up-menu"]); + +export class ElectronTrayOperationError extends Schema.TaggedErrorClass()( + "ElectronTrayOperationError", + { + operation: ElectronTrayOperation, + 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: ( + input: ElectronTrayCreateInput, + ) => Effect.Effect; + readonly popUpMenu: ( + template: readonly Electron.MenuItemConstructorOptions[], + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronTray") {} + +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + // The single live tray instance; Electron's Tray must be kept referenced + // or GC destroys the menu bar item silently. + let currentTray: Electron.Tray | null = null; + + return ElectronTray.of({ + create: (input) => + Effect.acquireRelease( + Effect.try({ + try: () => { + const icon = Electron.nativeImage.createFromPath(input.iconPath); + // The `Template` filename suffix already marks the image, but the + // explicit call keeps the behavior when the asset gets renamed. + icon.setTemplateImage(true); + const tray = new Electron.Tray(icon); + tray.setToolTip(input.tooltip); + tray.on("click", input.onClick); + tray.on("right-click", input.onClick); + return tray; + }, + catch: (cause) => + new ElectronTrayOperationError({ operation: "create", platform, cause }), + }).pipe(Effect.tap((tray) => Effect.sync(() => (currentTray = tray)))), + (tray) => + Effect.sync(() => { + if (currentTray === tray) { + currentTray = null; + } + tray.destroy(); + }), + ).pipe(Effect.asVoid), + popUpMenu: (template) => + Effect.try({ + try: () => { + if (currentTray === null || currentTray.isDestroyed()) { + return; + } + currentTray.popUpContextMenu(Electron.Menu.buildFromTemplate([...template])); + }, + catch: (cause) => + new ElectronTrayOperationError({ operation: "pop-up-menu", platform, cause }), + }).pipe(Effect.orDie), + }); +}); + +export const layer = Layer.effect(ElectronTray, make); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0616184ec74..2ab48897722 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -29,6 +29,7 @@ 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"; @@ -36,6 +37,7 @@ import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; +import * as DesktopTray from "./window/DesktopTray.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts"; @@ -122,6 +124,7 @@ const electronLayer = Layer.mergeAll( ElectronSafeStorage.layer, ElectronShell.layer, ElectronTheme.layer, + ElectronTray.layer, ElectronUpdater.layer, ElectronWindow.layer, DesktopIpc.layer(Electron.ipcMain), @@ -183,6 +186,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, + DesktopTray.layer, DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, desktopSshLayer, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 66244534deb..b9596e62100 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -56,10 +56,12 @@ const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( yield* desktopWindow.zoomMain(direction); }); -const checkForUpdatesFromMenu = Effect.gen(function* () { +const checkForUpdatesFromMenu = Effect.fn("desktop.menu.checkForUpdates")(function* ( + reason: string, +) { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; - const result = yield* updates.check("menu"); + const result = yield* updates.check(reason); const updateState = result.state; if (updateState.status === "up-to-date") { @@ -78,30 +80,34 @@ const checkForUpdatesFromMenu = Effect.gen(function* () { buttons: ["OK"], }); } -}).pipe(Effect.withSpan("desktop.menu.checkForUpdates")); +}); -const handleCheckForUpdatesMenuClick = Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - const electronDialog = yield* ElectronDialog.ElectronDialog; - const disabledReason = yield* updates.disabledReason; - if (Option.isSome(disabledReason)) { - yield* logUpdaterInfo("manual update check requested, but updates are disabled", { - disabledReason: disabledReason.value, - }); - yield* electronDialog.showMessageBox({ - type: "info", - title: "Updates unavailable", - message: "Automatic updates are not available right now.", - detail: disabledReason.value, - buttons: ["OK"], - }); - return; - } +// Shared by the application menu and the tray menu; `reason` tags the +// update check's telemetry with which surface triggered it. +export const handleCheckForUpdatesClick = Effect.fn("desktop.menu.handleCheckForUpdatesClick")( + function* (reason: string) { + const updates = yield* DesktopUpdates.DesktopUpdates; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const disabledReason = yield* updates.disabledReason; + if (Option.isSome(disabledReason)) { + yield* logUpdaterInfo("manual update check requested, but updates are disabled", { + disabledReason: disabledReason.value, + }); + yield* electronDialog.showMessageBox({ + type: "info", + title: "Updates unavailable", + message: "Automatic updates are not available right now.", + detail: disabledReason.value, + buttons: ["OK"], + }); + return; + } - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.ensureMain; - yield* checkForUpdatesFromMenu; -}).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.ensureMain; + yield* checkForUpdatesFromMenu(reason); + }, +); export const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; @@ -129,7 +135,7 @@ export const make = Effect.gen(function* () { const configure = Effect.gen(function* () { const checkForUpdatesClick = () => { - runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick); + runMenuEffect("check-for-updates", handleCheckForUpdatesClick("menu")); }; const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); diff --git a/apps/desktop/src/window/DesktopTray.ts b/apps/desktop/src/window/DesktopTray.ts new file mode 100644 index 00000000000..ebadc64a7f6 --- /dev/null +++ b/apps/desktop/src/window/DesktopTray.ts @@ -0,0 +1,237 @@ +import { environmentEndpointUrl } from "@t3tools/client-runtime/environment"; +import { + executeEnvironmentHttpRequest, + makeEnvironmentHttpApiClient, +} from "@t3tools/client-runtime/rpc"; +import { + EnvironmentId, + PRIMARY_LOCAL_ENVIRONMENT_ID, + type OrchestrationShellSnapshot, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +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 Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import type * as Electron from "electron"; + +import * as DesktopAssets from "../app/DesktopAssets.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { makeComponentLogger } from "../app/DesktopObservability.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; +import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronTray from "../electron/ElectronTray.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import { handleCheckForUpdatesClick } from "./DesktopApplicationMenu.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; +import { buildTrayAgentsModel, trayProjectRowLabel, trayThreadRowLabel } from "./trayMenu.ts"; +import type { TrayAgentsModel } from "./trayMenu.ts"; + +const TRAY_ICON_FILE = "t3TrayTemplate.png"; + +// Kept short because the fetch runs between the tray click and the menu +// popup; a slow backend degrades to the cached snapshot instead of a +// noticeably delayed menu. +const TRAY_SNAPSHOT_TIMEOUT_MS = 2_000; + +const PRIMARY_ENVIRONMENT_ID = EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID); + +export class DesktopTrayActionError extends Schema.TaggedErrorClass()( + "DesktopTrayActionError", + { + action: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop tray action "${this.action}" failed.`; + } +} + +export class DesktopTray extends Context.Service< + DesktopTray, + { + readonly configure: Effect.Effect; + } +>()("@t3tools/desktop/window/DesktopTray") {} + +type DesktopTrayRuntimeServices = + | DesktopBackendPool.DesktopBackendPool + | DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth + | DesktopUpdates.DesktopUpdates + | DesktopWindow.DesktopWindow + | ElectronDialog.ElectronDialog; + +const { logError: logTrayError, logWarning: logTrayWarning } = makeComponentLogger("desktop-tray"); + +const dispatchMenuAction = Effect.fn("desktop.tray.dispatchMenuAction")(function* ( + action: string, +): Effect.fn.Return { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.dispatchMenuAction(action); +}); + +const revealMainWindow = Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.revealOrCreateMain; +}).pipe(Effect.asVoid, Effect.withSpan("desktop.tray.revealMainWindow")); + +export const make = Effect.gen(function* () { + const assets = yield* DesktopAssets.DesktopAssets; + const electronApp = yield* ElectronApp.ElectronApp; + const electronTray = yield* ElectronTray.ElectronTray; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const httpClient = yield* HttpClient.HttpClient; + const appName = yield* electronApp.name; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + const cachedSnapshotRef = yield* Ref.make(Option.none()); + + const runTrayEffect = ( + action: string, + effect: Effect.Effect, + ) => { + void runPromise( + effect.pipe( + Effect.annotateLogs({ action }), + Effect.withSpan("desktop.tray.action"), + Effect.catchCause((cause) => { + const error = new DesktopTrayActionError({ action, cause }); + return logTrayError(error.message, { error }); + }), + ), + ); + }; + + /** + * One GET against the local backend's shell endpoint. Deliberately not the + * client-runtime shell state machine: that module drags the whole + * connection-supervisor graph into the main bundle, and the tray only needs + * a point-in-time snapshot at click time. Any failure (backend still + * starting, auth bootstrap, timeout) degrades to the last good snapshot. + */ + const loadShellSnapshot = Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const localAuth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; + const primary = yield* pool.primary; + const config = yield* primary.currentConfig; + if (Option.isNone(config)) { + return yield* Ref.get(cachedSnapshotRef); + } + const token = yield* localAuth.getBearerToken; + const httpBaseUrl = config.value.httpBaseUrl.href; + const client = yield* makeEnvironmentHttpApiClient(httpBaseUrl); + const snapshot = yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(httpBaseUrl, "/api/orchestration/shell"), + TRAY_SNAPSHOT_TIMEOUT_MS, + client.orchestration.shellSnapshot({ headers: { authorization: `Bearer ${token}` } }), + ); + yield* Ref.set(cachedSnapshotRef, Option.some(snapshot)); + return Option.some(snapshot); + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.withSpan("desktop.tray.loadShellSnapshot"), + Effect.catchCause((cause) => + logTrayWarning("could not load the shell snapshot for the tray menu", { + cause: Cause.pretty(cause), + }).pipe(Effect.andThen(Ref.get(cachedSnapshotRef))), + ), + ); + + const buildTrayTemplate = (model: TrayAgentsModel): Electron.MenuItemConstructorOptions[] => { + const agentItems: Electron.MenuItemConstructorOptions[] = []; + if (model.kind === "unavailable") { + agentItems.push({ label: "Agents unavailable", enabled: false }); + } else if (model.kind === "empty") { + agentItems.push({ label: "No agents running", enabled: false }); + } else { + for (const project of model.projects) { + agentItems.push({ + label: trayProjectRowLabel(project), + submenu: project.threads.map((thread) => ({ + label: trayThreadRowLabel(thread), + enabled: false, + })), + }); + } + } + return [ + ...agentItems, + { type: "separator" }, + { + label: `Open ${appName}`, + click: () => runTrayEffect("open-main-window", revealMainWindow), + }, + { type: "separator" }, + { + label: "Settings…", + click: () => runTrayEffect("open-settings", dispatchMenuAction("open-settings")), + }, + { + label: "Usage", + click: () => runTrayEffect("open-usage", dispatchMenuAction("open-usage")), + }, + { + label: "Pull Requests", + click: () => runTrayEffect("open-pull-requests", dispatchMenuAction("open-pull-requests")), + }, + { type: "separator" }, + { + label: "Check for Updates…", + click: () => runTrayEffect("check-for-updates", handleCheckForUpdatesClick("tray")), + }, + { type: "separator" }, + { + label: `Quit ${appName}`, + click: () => runTrayEffect("quit", electronApp.quit), + }, + ]; + }; + + const showTrayMenu = Effect.gen(function* () { + const snapshot = yield* loadShellSnapshot; + const model = buildTrayAgentsModel(PRIMARY_ENVIRONMENT_ID, Option.getOrNull(snapshot)); + yield* electronTray.popUpMenu(buildTrayTemplate(model)); + }).pipe(Effect.withSpan("desktop.tray.showMenu")); + + const configure = Effect.gen(function* () { + // "Menu bar" is the macOS ask; Windows/Linux tray semantics (persistent + // context menus, appindicator quirks) need their own treatment before + // enabling this elsewhere. + if (environment.platform !== "darwin") { + return; + } + const iconPath = yield* assets.resolveResourcePath(TRAY_ICON_FILE); + if (Option.isNone(iconPath)) { + yield* logTrayWarning("tray icon asset is missing; skipping the menu bar item", { + fileName: TRAY_ICON_FILE, + }); + return; + } + yield* electronTray.create({ + iconPath: iconPath.value, + tooltip: appName, + onClick: () => runTrayEffect("show-menu", showTrayMenu), + }); + }).pipe( + Effect.withSpan("desktop.tray.configure"), + // A missing tray must never take down startup; the app is fully usable + // without the menu bar item. + Effect.catchCause((cause) => + logTrayError("could not create the tray menu bar item", { cause: Cause.pretty(cause) }), + ), + ); + + return DesktopTray.of({ + configure, + }); +}); + +export const layer = Layer.effect(DesktopTray, make); diff --git a/apps/desktop/src/window/trayMenu.test.ts b/apps/desktop/src/window/trayMenu.test.ts new file mode 100644 index 00000000000..02173fa3b6f --- /dev/null +++ b/apps/desktop/src/window/trayMenu.test.ts @@ -0,0 +1,145 @@ +import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildTrayAgentsModel, trayProjectRowLabel, trayThreadRowLabel } from "./trayMenu.ts"; + +const decodeSnapshot = Schema.decodeUnknownSync(OrchestrationShellSnapshot); + +const ENVIRONMENT_ID = EnvironmentId.make("primary"); + +const project = (id: string, title: string) => ({ + id, + title, + workspaceRoot: `/home/user/${id}`, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-12T10:00:00.000Z", + updatedAt: "2026-08-12T10:00:00.000Z", +}); + +const thread = (input: { + id: string; + projectId: string; + title: string; + sessionStatus?: string; + hasPendingApprovals?: boolean; + archivedAt?: string; +}) => ({ + id: input.id, + projectId: input.projectId, + title: input.title, + modelSelection: { instanceId: "codex", model: "gpt-5" }, + runtimeMode: "auto", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-08-12T10:00:00.000Z", + updatedAt: "2026-08-12T10:05:00.000Z", + archivedAt: input.archivedAt ?? null, + session: + input.sessionStatus === undefined + ? null + : { + threadId: input.id, + status: input.sessionStatus, + providerName: "Codex", + activeTurnId: null, + lastError: null, + updatedAt: "2026-08-12T10:05:00.000Z", + }, + latestUserMessageAt: null, + hasPendingApprovals: input.hasPendingApprovals ?? false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, +}); + +const snapshot = ( + projects: ReadonlyArray>, + threads: ReadonlyArray>, +) => + decodeSnapshot({ + snapshotSequence: 1, + projects, + threads, + updatedAt: "2026-08-12T10:05:00.000Z", + }); + +describe("buildTrayAgentsModel", () => { + it("reports unavailable when no snapshot could be loaded", () => { + expect(buildTrayAgentsModel(ENVIRONMENT_ID, null)).toEqual({ kind: "unavailable" }); + }); + + it("reports empty when no thread has a live agent", () => { + const model = buildTrayAgentsModel( + ENVIRONMENT_ID, + snapshot( + [project("p1", "Alpha")], + [ + thread({ id: "t1", projectId: "p1", title: "Done work", sessionStatus: "ready" }), + thread({ id: "t2", projectId: "p1", title: "No session" }), + ], + ), + ); + expect(model).toEqual({ kind: "empty" }); + }); + + it("groups running agents by project and sorts projects by title", () => { + const model = buildTrayAgentsModel( + ENVIRONMENT_ID, + snapshot( + [project("p1", "Zulu"), project("p2", "Alpha")], + [ + thread({ id: "t1", projectId: "p1", title: "Fix bug", sessionStatus: "running" }), + thread({ id: "t2", projectId: "p1", title: "Add docs", sessionStatus: "starting" }), + thread({ + id: "t3", + projectId: "p2", + title: "Refactor", + sessionStatus: "running", + hasPendingApprovals: true, + }), + ], + ), + ); + + expect(model.kind).toBe("projects"); + if (model.kind !== "projects") return; + expect(model.projects.map((row) => row.title)).toEqual(["Alpha", "Zulu"]); + expect(model.projects.map((row) => row.activeCount)).toEqual([1, 2]); + // Approval-gated threads still count as live agents and surface why. + expect(model.projects[0]?.threads[0]?.headline).toBe("Approval needed"); + }); + + it("ignores archived threads and threads of unknown projects", () => { + const model = buildTrayAgentsModel( + ENVIRONMENT_ID, + snapshot( + [project("p1", "Alpha")], + [ + thread({ + id: "t1", + projectId: "p1", + title: "Old", + sessionStatus: "running", + archivedAt: "2026-08-12T09:00:00.000Z", + }), + thread({ id: "t2", projectId: "ghost", title: "Orphan", sessionStatus: "running" }), + ], + ), + ); + expect(model).toEqual({ kind: "empty" }); + }); + + it("renders singular and plural row labels", () => { + expect( + trayProjectRowLabel({ title: "Alpha", activeCount: 1, threads: [] }), + ).toBe("Alpha — 1 agent"); + expect( + trayProjectRowLabel({ title: "Alpha", activeCount: 2, threads: [] }), + ).toBe("Alpha — 2 agents"); + expect(trayThreadRowLabel({ title: "Fix bug", headline: "Agent is working" })).toBe( + "Fix bug — Agent is working", + ); + }); +}); diff --git a/apps/desktop/src/window/trayMenu.ts b/apps/desktop/src/window/trayMenu.ts new file mode 100644 index 00000000000..49ac8ff984d --- /dev/null +++ b/apps/desktop/src/window/trayMenu.ts @@ -0,0 +1,89 @@ +import type { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; +import { + projectThreadAwareness, + type AgentAwarenessPhase, +} from "@t3tools/shared/agentAwareness"; + +export interface TrayThreadRow { + readonly title: string; + readonly headline: string; +} + +export interface TrayProjectRow { + readonly title: string; + readonly activeCount: number; + readonly threads: readonly TrayThreadRow[]; +} + +/** + * What the tray menu's agents section shows: "unavailable" when no shell + * snapshot could be loaded (backend down, not yet started, auth failure), + * "empty" when the backend answered but no agent is live, otherwise the + * per-project rows. + */ +export type TrayAgentsModel = + | { readonly kind: "unavailable" } + | { readonly kind: "empty" } + | { readonly kind: "projects"; readonly projects: readonly TrayProjectRow[] }; + +// An agent counts as running while it works or waits on the user — +// the same live set the mobile Live Activity surfaces. +const ACTIVE_PHASES: ReadonlySet = new Set([ + "starting", + "running", + "waiting_for_approval", + "waiting_for_input", +]); + +export function buildTrayAgentsModel( + environmentId: EnvironmentId, + snapshot: OrchestrationShellSnapshot | null, +): TrayAgentsModel { + if (snapshot === null) { + return { kind: "unavailable" }; + } + + const projectById = new Map(snapshot.projects.map((project) => [project.id, project])); + const rowsByProject = new Map(); + + for (const thread of snapshot.threads) { + if (thread.archivedAt !== null) { + continue; + } + const project = projectById.get(thread.projectId); + if (project === undefined) { + continue; + } + const awareness = projectThreadAwareness({ environmentId, project, thread }); + if (awareness === null || !ACTIVE_PHASES.has(awareness.phase)) { + continue; + } + const row = rowsByProject.get(thread.projectId) ?? { title: project.title, threads: [] }; + row.threads.push({ title: awareness.threadTitle, headline: awareness.headline }); + rowsByProject.set(thread.projectId, row); + } + + if (rowsByProject.size === 0) { + return { kind: "empty" }; + } + + const projects = [...rowsByProject.values()] + .map( + (row): TrayProjectRow => ({ + title: row.title, + activeCount: row.threads.length, + threads: row.threads, + }), + ) + .sort((a, b) => a.title.localeCompare(b.title)); + + return { kind: "projects", projects }; +} + +export function trayProjectRowLabel(row: TrayProjectRow): string { + return `${row.title} — ${row.activeCount} ${row.activeCount === 1 ? "agent" : "agents"}`; +} + +export function trayThreadRowLabel(row: TrayThreadRow): string { + return `${row.title} — ${row.headline}`; +} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 5138e84d2f1..83e33b27b2f 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -198,6 +198,12 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { void navigate({ to: "/settings" }); } } + if (action === "open-usage" && pathname !== "/usage") { + void navigate({ to: "/usage" }); + } + if (action === "open-pull-requests" && pathname !== "/pull-requests") { + void navigate({ to: "/pull-requests", search: { involvement: "all", state: "open" } }); + } }); return () => { diff --git a/docs/superpowers/specs/2026-08-12-desktop-tray-menu-design.md b/docs/superpowers/specs/2026-08-12-desktop-tray-menu-design.md new file mode 100644 index 00000000000..ee0a4f0e469 --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-desktop-tray-menu-design.md @@ -0,0 +1,91 @@ +# Desktop tray (macOS menu bar) design + +## Goal + +A T3 mark in the macOS menu bar. Clicking it shows how many agents are +running, grouped by project, plus quick entries for Open T3 Code, Settings, +Usage, Pull Requests, Check for Updates, and Quit. + +## Scope decisions + +- **macOS only (v1).** "Menu bar" is a macOS ask; template images and click + semantics are darwin-specific. Windows/Linux tray support can follow the + same service split later. +- **Local primary environment only.** Remote/SSH/relay environments need + per-connection credentials the main process does not hold; the low-risk v1 + counts agents on the bundled local backend + (`PRIMARY_LOCAL_ENVIRONMENT_ID`). +- **Fetch on click, no background polling.** The ask is "when clicked it + shows"; the menu is built from a fresh shell snapshot fetched at click + time (short timeout, falls back to the last good snapshot). No timers, no + idle network traffic, tray stays correct with every window closed. + +## Components + +1. `apps/desktop/src/electron/ElectronTray.ts` — thin `Context.Service` + wrapper over `Electron.Tray` (`create` (scoped, destroys on release), + `popUpMenu`, `setToolTip`), mirroring `ElectronMenu`'s mockable-surface + pattern. Registered in `electronLayer` (`main.ts`). +2. `apps/desktop/src/window/trayMenu.ts` — pure menu-model builder: + `OrchestrationShellSnapshot → TrayMenuModel` (per-project active-agent + counts + thread rows with phase headlines). Uses the shared + `projectThreadAwareness` predicate from `@t3tools/shared/agentAwareness`; + "active" phases are `starting`, `running`, `waiting_for_approval`, + `waiting_for_input`. Fully unit-tested (`trayMenu.test.ts`). +3. `apps/desktop/src/window/DesktopTray.ts` — orchestrator service + (`configure`, darwin-gated), registered in `desktopApplicationLayer` and + invoked from `DesktopApp.startup` next to `applicationMenu.configure`. + - Icon: `t3TrayTemplate.png` / `@2x` from `apps/desktop/resources/` + (new, rasterized from the brand T3 mark, black on transparent) via + `DesktopAssets.resolveResourcePath`; `Template` suffix + explicit + `setTemplateImage(true)` make macOS recolor it for light/dark bars. + - Click → fetch shell snapshot (`fetchEnvironmentShellSnapshot` with a + `PreparedConnection` built from `DesktopBackendPool` primary config + + `DesktopLocalEnvironmentAuth.getBearerToken`, 2 s timeout) → build + model → pop up native menu. Fetch failure degrades to the cached + snapshot or an "Agents unavailable" row; it never blocks the menu. + - Menu actions reuse existing plumbing: `revealOrCreateMain`, + `dispatchMenuAction("open-settings" | "open-usage" | + "open-pull-requests")`, the exported check-for-updates handler from + `DesktopApplicationMenu` (reason `"tray"`), `electronApp.quit`. +4. `apps/web/src/components/AppSidebarLayout.tsx` — extend the existing + `desktop:menu-action` listener with `open-usage` (`/usage`) and + `open-pull-requests` (`/pull-requests` with + `search: { involvement: "all", state: "open" }`). + +## Menu layout + +``` +Running agents (disabled header; or "No agents running") + + +────────────── +Open T3 Code +────────────── +Settings… +Usage +Pull Requests +────────────── +Check for Updates… +────────────── +Quit T3 Code +``` + +## Error handling + +- Backend not configured / not started / auth failure → "Agents + unavailable" disabled row; the static entries always work. +- Bearer token staleness after a backend restart resolves on the next + successful bootstrap; the tray degrades gracefully in between (known v1 + limitation). +- Tray icon missing → skip tray creation with a logged warning; never + crash startup. + +## Testing + +- `trayMenu.test.ts` — grouping, counting, phase labels, empty/unavailable + states (pure). +- `ElectronTray.test.ts` — wrapper surface with the mocked `electron` + module, mirroring `ElectronMenu.test.ts`. +- Targeted `vp test run` + typecheck for the touched scope only, per + AGENTS.md.