diff --git a/apps/desktop/resources/t3TrayTemplate.png b/apps/desktop/resources/t3TrayTemplate.png new file mode 100644 index 000000000000..e749d1a2ea26 Binary files /dev/null and b/apps/desktop/resources/t3TrayTemplate.png differ diff --git a/apps/desktop/resources/t3TrayTemplate@2x.png b/apps/desktop/resources/t3TrayTemplate@2x.png new file mode 100644 index 000000000000..d7c86771ebc2 Binary files /dev/null and b/apps/desktop/resources/t3TrayTemplate@2x.png differ diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4101840530f6..969c613c61da 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 000000000000..55fc8e33334e --- /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 000000000000..6a41dcbc1a72 --- /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 0616184ec74d..2ab488977229 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 66244534debf..b9596e62100b 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 000000000000..ebadc64a7f69 --- /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 000000000000..02173fa3b6f0 --- /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 000000000000..49ac8ff984d8 --- /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 5138e84d2f15..83e33b27b2f2 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 000000000000..ee0a4f0e4696 --- /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.