Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/code/build/tray/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added apps/code/build/tray/badge-0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/code/build/tray/icon.template.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/code/build/tray/icon.template@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/code/build/tray/icon.template@3x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion apps/code/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
});
3 changes: 3 additions & 0 deletions apps/code/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -170,6 +171,8 @@ async function initializeServices(): Promise<void> {
);
suspensionService.startInactivityChecker();

container.get<TrayService>(MAIN_TOKENS.TrayService).initialize();

// Track app started event
trackAppEvent(ANALYTICS_EVENTS.APP_STARTED);
}
Expand Down
114 changes: 114 additions & 0 deletions apps/code/src/main/platform-adapters/electron-tray.ts
Original file line number Diff line number Diff line change
@@ -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<string, Electron.NativeImage>();

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;
}
}
6 changes: 6 additions & 0 deletions apps/code/src/main/services/agent/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -236,6 +241,7 @@ export interface AgentServiceEvents {
[AgentServiceEvent.SessionIdleKilled]: SessionIdleKilledPayload;
[AgentServiceEvent.AgentFileActivity]: AgentFileActivityPayload;
[AgentServiceEvent.LlmActivity]: undefined;
[AgentServiceEvent.RunningCountChanged]: RunningCountChangedPayload;
}

// Permission response input for tRPC
Expand Down
13 changes: 13 additions & 0 deletions apps/code/src/main/services/agent/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,16 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
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;

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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(() => {
Expand Down
8 changes: 8 additions & 0 deletions apps/code/src/main/services/app-lifecycle/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -110,6 +111,13 @@ export class AppLifecycleService {
log.warn("Failed to stop inactivity checker during shutdown", error);
}

try {
const trayService = container.get<TrayService>(MAIN_TOKENS.TrayService);
trayService.dispose();
} catch (error) {
log.warn("Failed to dispose tray during shutdown", error);
}

try {
const db = container.get<DatabaseService>(MAIN_TOKENS.DatabaseService);
db.close();
Expand Down
64 changes: 64 additions & 0 deletions apps/code/src/main/services/tray/service.ts
Original file line number Diff line number Diff line change
@@ -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"}`);
}
}
4 changes: 4 additions & 0 deletions packages/platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions packages/platform/src/tray.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/platform/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading