diff --git a/apps/code/build/tray/README.md b/apps/code/build/tray/README.md new file mode 100644 index 0000000000..4656caa0ad --- /dev/null +++ b/apps/code/build/tray/README.md @@ -0,0 +1,14 @@ +# Tray icons + +Pre-rendered icons used by the system tray on Windows and Linux. On macOS the +count is shown via `Tray.setTitle()` so only `badge-0.png` is needed. + +Files: + +- `badge-0.png` — base icon, shown when no agents are running. +- `badge-1.png` … `badge-9.png` — base icon with the digit overlaid in a badge. +- `badge-9plus.png` — base icon with "9+" overlaid for ten or more. + +If a specific badge variant is missing the tray falls back to `badge-0.png` so +the count is still discoverable via the tooltip while the assets are being +designed. diff --git a/apps/code/build/tray/badge-0.png b/apps/code/build/tray/badge-0.png new file mode 100644 index 0000000000..41f42876eb Binary files /dev/null and b/apps/code/build/tray/badge-0.png differ diff --git a/apps/code/build/tray/icon.template.png b/apps/code/build/tray/icon.template.png new file mode 100644 index 0000000000..bdb41d5c90 Binary files /dev/null and b/apps/code/build/tray/icon.template.png differ diff --git a/apps/code/build/tray/icon.template@2x.png b/apps/code/build/tray/icon.template@2x.png new file mode 100644 index 0000000000..5bbf0608e5 Binary files /dev/null and b/apps/code/build/tray/icon.template@2x.png differ diff --git a/apps/code/build/tray/icon.template@3x.png b/apps/code/build/tray/icon.template@3x.png new file mode 100644 index 0000000000..1cbb855764 Binary files /dev/null and b/apps/code/build/tray/icon.template@3x.png differ diff --git a/apps/code/forge.config.ts b/apps/code/forge.config.ts index f52736bf2e..22dc64d55a 100644 --- a/apps/code/forge.config.ts +++ b/apps/code/forge.config.ts @@ -151,7 +151,11 @@ const config: ForgeConfig = { icon: "./build/app-icon", // Forge adds .icns/.ico/.png based on platform appBundleId: "com.posthog.array", appCategoryType: "public.app-category.productivity", - extraResource: existsSync("build/Assets.car") ? ["build/Assets.car"] : [], + extraResource: [ + "build/tray", + "build/app-icon.png", + ...(existsSync("build/Assets.car") ? ["build/Assets.car"] : []), + ], extendInfo: existsSync("build/Assets.car") ? { CFBundleIconName: "Icon", diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index b2e2379419..5a8c963309 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -23,6 +23,7 @@ import { ElectronNotifier } from "../platform-adapters/electron-notifier"; import { ElectronPowerManager } from "../platform-adapters/electron-power-manager"; import { ElectronSecureStorage } from "../platform-adapters/electron-secure-storage"; import { ElectronStoragePaths } from "../platform-adapters/electron-storage-paths"; +import { ElectronTray } from "../platform-adapters/electron-tray"; import { ElectronUpdater } from "../platform-adapters/electron-updater"; import { ElectronUrlLauncher } from "../platform-adapters/electron-url-launcher"; import { AgentAuthAdapter } from "../services/agent/auth-adapter"; @@ -65,6 +66,7 @@ import { SlackIntegrationService } from "../services/slack-integration/service"; import { SleepService } from "../services/sleep/service"; import { SuspensionService } from "../services/suspension/service"; import { TaskLinkService } from "../services/task-link/service"; +import { TrayService } from "../services/tray/service"; import { UIService } from "../services/ui/service"; import { UpdatesService } from "../services/updates/service"; import { UsageMonitorService } from "../services/usage-monitor/service"; @@ -91,6 +93,7 @@ container.bind(MAIN_TOKENS.Notifier).to(ElectronNotifier); container.bind(MAIN_TOKENS.ContextMenu).to(ElectronContextMenu); container.bind(MAIN_TOKENS.BundledResources).to(ElectronBundledResources); container.bind(MAIN_TOKENS.ImageProcessor).to(ElectronImageProcessor); +container.bind(MAIN_TOKENS.Tray).to(ElectronTray); container.bind(MAIN_TOKENS.DatabaseService).to(DatabaseService); container @@ -150,6 +153,7 @@ container.bind(MAIN_TOKENS.UIService).to(UIService); container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService); container.bind(MAIN_TOKENS.UsageMonitorService).to(UsageMonitorService); container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService); +container.bind(MAIN_TOKENS.TrayService).to(TrayService); container.bind(MAIN_TOKENS.InboxLinkService).to(InboxLinkService); container.bind(MAIN_TOKENS.NewTaskLinkService).to(NewTaskLinkService); container.bind(MAIN_TOKENS.WatcherRegistryService).to(WatcherRegistryService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 69ea894b37..aa607c458c 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -21,6 +21,7 @@ export const MAIN_TOKENS = Object.freeze({ ContextMenu: Symbol.for("Platform.ContextMenu"), BundledResources: Symbol.for("Platform.BundledResources"), ImageProcessor: Symbol.for("Platform.ImageProcessor"), + Tray: Symbol.for("Platform.Tray"), // Stores SettingsStore: Symbol.for("Main.SettingsStore"), @@ -84,4 +85,5 @@ export const MAIN_TOKENS = Object.freeze({ WorkspaceService: Symbol.for("Main.WorkspaceService"), EnrichmentService: Symbol.for("Main.EnrichmentService"), UsageMonitorService: Symbol.for("Main.UsageMonitorService"), + TrayService: Symbol.for("Main.TrayService"), }); diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 6a005d365e..b329a173d2 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -28,6 +28,7 @@ import type { PosthogPluginService } from "./services/posthog-plugin/service"; import type { SlackIntegrationService } from "./services/slack-integration/service"; import type { SuspensionService } from "./services/suspension/service"; import type { TaskLinkService } from "./services/task-link/service"; +import type { TrayService } from "./services/tray/service"; import type { UpdatesService } from "./services/updates/service"; import type { WorkspaceService } from "./services/workspace/service"; import { ensureClaudeConfigDir } from "./utils/env"; @@ -170,6 +171,8 @@ async function initializeServices(): Promise { ); suspensionService.startInactivityChecker(); + container.get(MAIN_TOKENS.TrayService).initialize(); + // Track app started event trackAppEvent(ANALYTICS_EVENTS.APP_STARTED); } diff --git a/apps/code/src/main/platform-adapters/electron-tray.ts b/apps/code/src/main/platform-adapters/electron-tray.ts new file mode 100644 index 0000000000..1b0f883d93 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-tray.ts @@ -0,0 +1,114 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import type { ITray } from "@posthog/platform/tray"; +import { app, nativeImage, Tray } from "electron"; +import { injectable } from "inversify"; + +// macOS renders tray icons in points, and Electron auto-discovers @2x/@3x +// variants. The template PNGs are pre-rendered at 22/44/66 px, so on macOS we +// don't resize. Windows/Linux trays render around 16px and use the colored +// badge-N.png set, which are resized down from the 1024×1024 brand icon. +const NON_MAC_TRAY_ICON_SIZE = 16; + +@injectable() +export class ElectronTray implements ITray { + private tray: Tray | null = null; + private clickHandler: (() => void) | null = null; + private readonly imageCache = new Map(); + + public isSupported(): boolean { + return true; + } + + public show(): void { + if (this.tray) return; + + const baseImage = this.loadImage(this.resolveBadgePath(0)); + this.tray = new Tray(baseImage); + this.tray.on("click", () => this.clickHandler?.()); + if (process.platform === "darwin") { + this.tray.on("right-click", () => this.clickHandler?.()); + } + } + + public hide(): void { + if (!this.tray) return; + this.tray.destroy(); + this.tray = null; + this.imageCache.clear(); + } + + public setBadgeCount(count: number): void { + if (!this.tray) return; + + if (process.platform === "darwin") { + this.tray.setTitle(count > 0 ? String(count) : ""); + return; + } + + const iconPath = this.resolveBadgePath(count); + this.tray.setImage(this.loadImage(iconPath)); + } + + public setTooltip(text: string): void { + this.tray?.setToolTip(text); + } + + public onClick(handler: () => void): void { + this.clickHandler = handler; + } + + private resolveBadgePath(count: number): string { + const dir = this.trayAssetDir(); + + if (process.platform === "darwin") { + // Monochrome silhouette that adapts to light/dark menu bar. The macOS + // count is rendered via setTitle, so a single template suffices. + const template = path.join(dir, "icon.template.png"); + if (existsSync(template)) return template; + } + + const bucket = + count <= 0 ? "0" : count >= 10 ? "9plus" : String(Math.floor(count)); + const candidate = path.join(dir, `badge-${bucket}.png`); + if (existsSync(candidate)) return candidate; + + // Fall back to the base app icon until designed badge overlays land. + const base = path.join(dir, "badge-0.png"); + if (existsSync(base)) return base; + + return this.appIconFallback(); + } + + private trayAssetDir(): string { + if (app.isPackaged) { + return path.join(process.resourcesPath, "tray"); + } + return path.join(app.getAppPath(), "build", "tray"); + } + + private appIconFallback(): string { + if (app.isPackaged) { + return path.join(process.resourcesPath, "app-icon.png"); + } + return path.join(app.getAppPath(), "build", "app-icon.png"); + } + + private loadImage(filePath: string): Electron.NativeImage { + const cached = this.imageCache.get(filePath); + if (cached) return cached; + + const isMacTemplate = + process.platform === "darwin" && filePath.endsWith(".template.png"); + let image = nativeImage.createFromPath(filePath); + if (!isMacTemplate) { + image = image.resize({ + height: NON_MAC_TRAY_ICON_SIZE, + quality: "best", + }); + } + if (isMacTemplate) image.setTemplateImage(true); + this.imageCache.set(filePath, image); + return image; + } +} diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index 410d77ea59..4c37642661 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -204,8 +204,13 @@ export const AgentServiceEvent = { SessionIdleKilled: "session-idle-killed", AgentFileActivity: "agent-file-activity", LlmActivity: "llm-activity", + RunningCountChanged: "running-count-changed", } as const; +export interface RunningCountChangedPayload { + count: number; +} + export interface AgentSessionEventPayload { taskRunId: string; payload: unknown; @@ -236,6 +241,7 @@ export interface AgentServiceEvents { [AgentServiceEvent.SessionIdleKilled]: SessionIdleKilledPayload; [AgentServiceEvent.AgentFileActivity]: AgentFileActivityPayload; [AgentServiceEvent.LlmActivity]: undefined; + [AgentServiceEvent.RunningCountChanged]: RunningCountChangedPayload; } // Permission response input for tRPC diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 1596f9ff5b..5b405deb1b 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -426,6 +426,16 @@ export class AgentService extends TypedEventEmitter { return false; } + public getRunningSessionCount(): number { + return this.sessions.size; + } + + private emitRunningCountChanged(): void { + this.emit(AgentServiceEvent.RunningCountChanged, { + count: this.getRunningSessionCount(), + }); + } + public recordActivity(taskRunId: string): void { if (!this.sessions.has(taskRunId)) return; @@ -851,6 +861,7 @@ When creating pull requests, add the following footer at the end of the PR descr this.sessions.set(taskRunId, session); this.recordActivity(taskRunId); + this.emitRunningCountChanged(); if (isRetry) { log.info("Session created after auth retry", { taskRunId }); @@ -1234,6 +1245,8 @@ For git operations while detached: this.idleTimeouts.delete(taskRunId); } + this.emitRunningCountChanged(); + // When no sessions remain, tear down MCP Apps connections and cached resources if (this.sessions.size === 0) { this.mcpAppsService.cleanup().catch(() => { diff --git a/apps/code/src/main/services/app-lifecycle/service.ts b/apps/code/src/main/services/app-lifecycle/service.ts index 53f9c4f1dd..041df7be3a 100644 --- a/apps/code/src/main/services/app-lifecycle/service.ts +++ b/apps/code/src/main/services/app-lifecycle/service.ts @@ -10,6 +10,7 @@ import { shutdownOtelTransport } from "../../utils/otel-log-transport"; import { shutdownPostHog, trackAppEvent } from "../posthog-analytics"; import type { ProcessTrackingService } from "../process-tracking/service"; import type { SuspensionService } from "../suspension/service.js"; +import type { TrayService } from "../tray/service"; import type { WatcherRegistryService } from "../watcher-registry/service"; const log = logger.scope("app-lifecycle"); @@ -110,6 +111,13 @@ export class AppLifecycleService { log.warn("Failed to stop inactivity checker during shutdown", error); } + try { + const trayService = container.get(MAIN_TOKENS.TrayService); + trayService.dispose(); + } catch (error) { + log.warn("Failed to dispose tray during shutdown", error); + } + try { const db = container.get(MAIN_TOKENS.DatabaseService); db.close(); diff --git a/apps/code/src/main/services/tray/service.ts b/apps/code/src/main/services/tray/service.ts new file mode 100644 index 0000000000..689a14c3c9 --- /dev/null +++ b/apps/code/src/main/services/tray/service.ts @@ -0,0 +1,64 @@ +import type { IMainWindow } from "@posthog/platform/main-window"; +import type { ITray } from "@posthog/platform/tray"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { + AgentServiceEvent, + type RunningCountChangedPayload, +} from "../agent/schemas"; +import type { AgentService } from "../agent/service"; + +const log = logger.scope("tray"); + +@injectable() +export class TrayService { + private initialized = false; + private readonly onRunningCountChanged = ( + payload: RunningCountChangedPayload, + ) => this.refresh(payload.count); + + constructor( + @inject(MAIN_TOKENS.Tray) private readonly tray: ITray, + @inject(MAIN_TOKENS.AgentService) private readonly agents: AgentService, + @inject(MAIN_TOKENS.MainWindow) private readonly window: IMainWindow, + ) {} + + public initialize(): void { + if (this.initialized) return; + if (!this.tray.isSupported()) { + log.info("Tray not supported on this platform; skipping"); + return; + } + + this.tray.show(); + this.tray.onClick(() => this.handleClick()); + this.agents.on( + AgentServiceEvent.RunningCountChanged, + this.onRunningCountChanged, + ); + this.refresh(this.agents.getRunningSessionCount()); + this.initialized = true; + log.info("Tray initialized"); + } + + public dispose(): void { + if (!this.initialized) return; + this.agents.off( + AgentServiceEvent.RunningCountChanged, + this.onRunningCountChanged, + ); + this.tray.hide(); + this.initialized = false; + } + + private handleClick(): void { + if (this.window.isMinimized()) this.window.restore(); + this.window.focus(); + } + + private refresh(count: number): void { + this.tray.setBadgeCount(count); + this.tray.setTooltip(`${count} running agent${count === 1 ? "" : "s"}`); + } +} diff --git a/packages/platform/package.json b/packages/platform/package.json index 68916d1e20..9935ac81d9 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -63,6 +63,10 @@ "./image-processor": { "types": "./dist/image-processor.d.ts", "import": "./dist/image-processor.js" + }, + "./tray": { + "types": "./dist/tray.d.ts", + "import": "./dist/tray.js" } }, "scripts": { diff --git a/packages/platform/src/tray.ts b/packages/platform/src/tray.ts new file mode 100644 index 0000000000..bc6d28d4c4 --- /dev/null +++ b/packages/platform/src/tray.ts @@ -0,0 +1,8 @@ +export interface ITray { + isSupported(): boolean; + show(): void; + hide(): void; + setBadgeCount(count: number): void; + setTooltip(text: string): void; + onClick(handler: () => void): void; +} diff --git a/packages/platform/tsup.config.ts b/packages/platform/tsup.config.ts index 20fd8b4461..1cd9b5b92b 100644 --- a/packages/platform/tsup.config.ts +++ b/packages/platform/tsup.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ "src/context-menu.ts", "src/bundled-resources.ts", "src/image-processor.ts", + "src/tray.ts", ], format: ["esm"], dts: true,