diff --git a/apps/code/package.json b/apps/code/package.json index 7c0f9d430..c320e4e87 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -185,6 +185,7 @@ "react-hotkeys-hook": "^4.4.4", "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", + "react-scan": "^0.5.6", "reflect-metadata": "^0.2.2", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 959ea1431..b53697e84 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -11,9 +11,11 @@ import { WorktreeRepository } from "../db/repositories/worktree-repository"; import { DatabaseService } from "../db/service"; import { ElectronAppLifecycle } from "../platform-adapters/electron-app-lifecycle"; import { ElectronAppMeta } from "../platform-adapters/electron-app-meta"; +import { ElectronAppMetrics } from "../platform-adapters/electron-app-metrics"; import { ElectronBundledResources } from "../platform-adapters/electron-bundled-resources"; import { ElectronClipboard } from "../platform-adapters/electron-clipboard"; import { ElectronContextMenu } from "../platform-adapters/electron-context-menu"; +import { ElectronDevHostActions } from "../platform-adapters/electron-dev-host-actions"; import { ElectronDialog } from "../platform-adapters/electron-dialog"; import { ElectronFileIcon } from "../platform-adapters/electron-file-icon"; import { ElectronImageProcessor } from "../platform-adapters/electron-image-processor"; @@ -34,6 +36,11 @@ import { CloudTaskService } from "../services/cloud-task/service"; import { ConnectivityService } from "../services/connectivity/service"; import { ContextMenuService } from "../services/context-menu/service"; import { DeepLinkService } from "../services/deep-link/service"; +import { DevActionsService } from "../services/dev-actions/service"; +import { DevFlagsService } from "../services/dev-flags/service"; +import { DevLogsService } from "../services/dev-logs/service"; +import { DevMetricsService } from "../services/dev-metrics/service"; +import { DevNetworkService } from "../services/dev-network/service"; import { EnrichmentService } from "../services/enrichment/service"; import { EnvironmentService } from "../services/environment/service"; import { ExternalAppsService } from "../services/external-apps/service"; @@ -71,6 +78,18 @@ export const container = new Container({ defaultScope: "Singleton", }); +export function getService(token: symbol): T { + return container.get(token); +} + +export function tryGetService(token: symbol): T | null { + try { + return container.get(token); + } catch { + return null; + } +} + container.bind(MAIN_TOKENS.UrlLauncher).to(ElectronUrlLauncher); container.bind(MAIN_TOKENS.StoragePaths).to(ElectronStoragePaths); container.bind(MAIN_TOKENS.AppMeta).to(ElectronAppMeta); @@ -86,6 +105,8 @@ 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.AppMetrics).to(ElectronAppMetrics); +container.bind(MAIN_TOKENS.DevHostActions).to(ElectronDevHostActions); container.bind(MAIN_TOKENS.DatabaseService).to(DatabaseService); container @@ -144,3 +165,9 @@ container.bind(MAIN_TOKENS.WatcherRegistryService).to(WatcherRegistryService); container.bind(MAIN_TOKENS.WorkspaceService).to(WorkspaceService); container.bind(MAIN_TOKENS.SettingsStore).toConstantValue(settingsStore); + +container.bind(MAIN_TOKENS.DevFlagsService).to(DevFlagsService); +container.bind(MAIN_TOKENS.DevMetricsService).to(DevMetricsService); +container.bind(MAIN_TOKENS.DevNetworkService).to(DevNetworkService); +container.bind(MAIN_TOKENS.DevLogsService).to(DevLogsService); +container.bind(MAIN_TOKENS.DevActionsService).to(DevActionsService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index c8225b2b1..cf75da057 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -21,6 +21,8 @@ export const MAIN_TOKENS = Object.freeze({ ContextMenu: Symbol.for("Platform.ContextMenu"), BundledResources: Symbol.for("Platform.BundledResources"), ImageProcessor: Symbol.for("Platform.ImageProcessor"), + AppMetrics: Symbol.for("Platform.AppMetrics"), + DevHostActions: Symbol.for("Platform.DevHostActions"), // Stores SettingsStore: Symbol.for("Main.SettingsStore"), @@ -77,4 +79,9 @@ export const MAIN_TOKENS = Object.freeze({ ProvisioningService: Symbol.for("Main.ProvisioningService"), WorkspaceService: Symbol.for("Main.WorkspaceService"), EnrichmentService: Symbol.for("Main.EnrichmentService"), + DevFlagsService: Symbol.for("Main.DevFlagsService"), + DevMetricsService: Symbol.for("Main.DevMetricsService"), + DevNetworkService: Symbol.for("Main.DevNetworkService"), + DevLogsService: Symbol.for("Main.DevLogsService"), + DevActionsService: Symbol.for("Main.DevActionsService"), }); diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 29854a8c8..b32211690 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -12,6 +12,7 @@ import { MAIN_TOKENS } from "./di/tokens"; import { registerMcpSandboxProtocol } from "./protocols/mcp-sandbox"; import type { AppLifecycleService } from "./services/app-lifecycle/service"; import type { AuthService } from "./services/auth/service"; +import { initDevToolbar } from "./services/dev-toolbar"; import type { ExternalAppsService } from "./services/external-apps/service"; import type { GitHubIntegrationService } from "./services/github-integration/service"; import type { InboxLinkService } from "./services/inbox-link/service"; @@ -142,6 +143,8 @@ app.on("child-process-gone", (_event, details) => { }); async function initializeServices(): Promise { + initDevToolbar(container); + container.get(MAIN_TOKENS.DatabaseService); container.get(MAIN_TOKENS.OAuthService); const authService = container.get(MAIN_TOKENS.AuthService); diff --git a/apps/code/src/main/platform-adapters/electron-app-metrics.ts b/apps/code/src/main/platform-adapters/electron-app-metrics.ts new file mode 100644 index 000000000..c3394c984 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-app-metrics.ts @@ -0,0 +1,21 @@ +import type { + AppProcessMetric, + IAppMetrics, +} from "@posthog/platform/app-metrics"; +import { app } from "electron"; +import { injectable } from "inversify"; + +@injectable() +export class ElectronAppMetrics implements IAppMetrics { + public getAppMetrics(): AppProcessMetric[] { + return app.getAppMetrics().map((m) => ({ + pid: m.pid, + type: m.type, + name: m.name, + cpu: m.cpu ? { percentCPUUsage: m.cpu.percentCPUUsage } : undefined, + memory: m.memory + ? { workingSetSize: m.memory.workingSetSize } + : undefined, + })); + } +} diff --git a/apps/code/src/main/platform-adapters/electron-dev-host-actions.ts b/apps/code/src/main/platform-adapters/electron-dev-host-actions.ts new file mode 100644 index 000000000..c51bf02cd --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-dev-host-actions.ts @@ -0,0 +1,25 @@ +import type { IDevHostActions } from "@posthog/platform/dev-host-actions"; +import { app, BrowserWindow, shell } from "electron"; +import { injectable } from "inversify"; + +@injectable() +export class ElectronDevHostActions implements IDevHostActions { + public async openPath(path: string): Promise { + await shell.openPath(path); + } + + public reloadAllWindows(): void { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.reload(); + } + } + + public relaunch(): void { + app.relaunch(); + app.exit(0); + } + + public crash(): void { + process.crash(); + } +} diff --git a/apps/code/src/main/preload.ts b/apps/code/src/main/preload.ts index b90c21f89..35106dc4f 100644 --- a/apps/code/src/main/preload.ts +++ b/apps/code/src/main/preload.ts @@ -2,10 +2,28 @@ import { exposeElectronTRPC } from "@posthog/electron-trpc/main"; import { contextBridge, webUtils } from "electron"; import "electron-log/preload"; +const DEV_FLAGS_CLI_PREFIX = "--posthog-code-flags="; + +function readDevFlags(): { devMode: boolean } { + const arg = process.argv.find((a) => a.startsWith(DEV_FLAGS_CLI_PREFIX)); + if (!arg) return { devMode: false }; + try { + const payload = decodeURIComponent(arg.slice(DEV_FLAGS_CLI_PREFIX.length)); + const parsed = JSON.parse(payload); + return { devMode: parsed?.devMode === true }; + } catch { + return { devMode: false }; + } +} + +const devFlags = readDevFlags(); + contextBridge.exposeInMainWorld("electronUtils", { getPathForFile: (file: File) => webUtils.getPathForFile(file), }); +contextBridge.exposeInMainWorld("__posthogCodeDevFlags", devFlags); + if (process.argv.includes("--posthog-code-dev")) { contextBridge.exposeInMainWorld("__posthogCodeTest", { crash: () => { diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 1c0008732..e2b22216c 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -954,6 +954,49 @@ When creating pull requests, add the following footer at the end of the PR descr return this.sessions.get(taskRunId); } + getDebugSnapshot(): { + sessions: Array<{ + taskRunId: string; + taskId: string; + repoPath: string; + adapter: string; + model: string | null; + sessionId: string | null; + channel: string; + createdAt: number; + lastActivityAt: number; + promptPending: boolean; + inFlightToolCalls: number; + idleDeadline: number | null; + }>; + pendingPermissions: Array<{ + taskRunId: string; + toolCallId: string; + }>; + } { + const sessions = [...this.sessions.values()].map((session) => ({ + taskRunId: session.taskRunId, + taskId: session.taskId, + repoPath: session.repoPath, + adapter: session.config.adapter ?? "claude", + model: session.config.model ?? null, + sessionId: session.config.sessionId ?? null, + channel: session.channel, + createdAt: session.createdAt, + lastActivityAt: session.lastActivityAt, + promptPending: session.promptPending, + inFlightToolCalls: session.inFlightMcpToolCalls.size, + idleDeadline: this.idleTimeouts.get(session.taskRunId)?.deadline ?? null, + })); + const pendingPermissions = [...this.pendingPermissions.values()].map( + (perm) => ({ + taskRunId: perm.taskRunId, + toolCallId: perm.toolCallId, + }), + ); + return { sessions, pendingPermissions }; + } + async setSessionConfigOption( sessionId: string, configId: string, diff --git a/apps/code/src/main/services/dev-actions/schemas.ts b/apps/code/src/main/services/dev-actions/schemas.ts new file mode 100644 index 000000000..a2933290d --- /dev/null +++ b/apps/code/src/main/services/dev-actions/schemas.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const devToastInput = z.object({ + variant: z.enum(["info", "error"]), + message: z.string(), +}); + +export const devToastSchema = z.object({ + id: z.number(), + variant: z.enum(["info", "error"]), + message: z.string(), +}); + +export type DevToast = z.infer; + +export const DevActionsEvent = { + Toast: "toast", +} as const; + +export interface DevActionsEvents { + [DevActionsEvent.Toast]: DevToast; +} diff --git a/apps/code/src/main/services/dev-actions/service.ts b/apps/code/src/main/services/dev-actions/service.ts new file mode 100644 index 000000000..66520519f --- /dev/null +++ b/apps/code/src/main/services/dev-actions/service.ts @@ -0,0 +1,68 @@ +import type { IDevHostActions } from "@posthog/platform/dev-host-actions"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { getUserDataDir } from "../../utils/env"; +import { getLogFilePath, logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import type { DevNetworkService } from "../dev-network/service"; +import { + DevActionsEvent, + type DevActionsEvents, + type DevToast, +} from "./schemas"; + +const log = logger.scope("dev-actions"); + +@injectable() +export class DevActionsService extends TypedEventEmitter { + private nextToastId = 1; + + constructor( + @inject(MAIN_TOKENS.DevNetworkService) + private readonly network: DevNetworkService, + @inject(MAIN_TOKENS.DevHostActions) + private readonly host: IDevHostActions, + ) { + super(); + } + + async openUserDataDir(): Promise { + await this.host.openPath(getUserDataDir()); + } + + async openLogFile(): Promise { + await this.host.openPath(getLogFilePath()); + } + + reloadRenderer(): void { + this.host.reloadAllWindows(); + } + + restartMain(): void { + log.warn("Restarting main process from dev toolbar"); + this.host.relaunch(); + } + + crashMain(): void { + log.warn("Crashing main process from dev toolbar"); + this.host.crash(); + } + + triggerToast(variant: "info" | "error", message: string): DevToast { + const toast: DevToast = { + id: this.nextToastId++, + variant, + message, + }; + this.emit(DevActionsEvent.Toast, toast); + return toast; + } + + setOffline(offline: boolean): void { + this.network.setSim({ offline }); + } + + setSlowDelay(slowDelayMs: number): void { + this.network.setSim({ slowDelayMs }); + } +} diff --git a/apps/code/src/main/services/dev-flags/schemas.ts b/apps/code/src/main/services/dev-flags/schemas.ts new file mode 100644 index 000000000..289d7d813 --- /dev/null +++ b/apps/code/src/main/services/dev-flags/schemas.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const devFlagsSchema = z.object({ + devMode: z.boolean(), +}); + +export type DevFlags = z.infer; + +export const DEFAULT_DEV_FLAGS: DevFlags = { + devMode: false, +}; + +export const DevFlagsEvent = { + Changed: "changed", +} as const; + +export interface DevFlagsEvents { + [DevFlagsEvent.Changed]: DevFlags; +} diff --git a/apps/code/src/main/services/dev-flags/service.ts b/apps/code/src/main/services/dev-flags/service.ts new file mode 100644 index 000000000..b3208e208 --- /dev/null +++ b/apps/code/src/main/services/dev-flags/service.ts @@ -0,0 +1,73 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { injectable } from "inversify"; +import { getUserDataDir } from "../../utils/env"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import { + DEFAULT_DEV_FLAGS, + type DevFlags, + DevFlagsEvent, + type DevFlagsEvents, + devFlagsSchema, +} from "./schemas"; + +const log = logger.scope("dev-flags"); + +const FLAGS_FILE_NAME = "dev-flags.json"; +export const DEV_FLAGS_CLI_PREFIX = "--posthog-code-flags="; + +let cachedFlags: DevFlags | null = null; + +function getFlagsFilePath(): string { + return path.join(getUserDataDir(), FLAGS_FILE_NAME); +} + +export function readDevFlagsSync(): DevFlags { + if (cachedFlags) return cachedFlags; + try { + const raw = readFileSync(getFlagsFilePath(), "utf-8"); + const parsed = devFlagsSchema.safeParse(JSON.parse(raw)); + cachedFlags = parsed.success ? parsed.data : { ...DEFAULT_DEV_FLAGS }; + return cachedFlags; + } catch { + cachedFlags = { ...DEFAULT_DEV_FLAGS }; + return cachedFlags; + } +} + +export function encodeDevFlagsForArg(flags: DevFlags): string { + return `${DEV_FLAGS_CLI_PREFIX}${encodeURIComponent(JSON.stringify(flags))}`; +} + +@injectable() +export class DevFlagsService extends TypedEventEmitter { + private flags: DevFlags; + + constructor() { + super(); + this.flags = readDevFlagsSync(); + log.info("Dev flags initialized", this.flags); + } + + getFlags(): DevFlags { + return { ...this.flags }; + } + + setDevMode(enabled: boolean): DevFlags { + return this.update({ devMode: enabled }); + } + + private update(partial: Partial): DevFlags { + const next = { ...this.flags, ...partial }; + this.flags = next; + cachedFlags = next; + try { + writeFileSync(getFlagsFilePath(), JSON.stringify(next, null, 2), "utf-8"); + } catch (error) { + log.warn("Failed to persist dev flags", { error }); + } + this.emit(DevFlagsEvent.Changed, { ...next }); + return { ...next }; + } +} diff --git a/apps/code/src/main/services/dev-logs/schemas.ts b/apps/code/src/main/services/dev-logs/schemas.ts new file mode 100644 index 000000000..af92670e5 --- /dev/null +++ b/apps/code/src/main/services/dev-logs/schemas.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +export const logEntrySchema = z.object({ + id: z.number(), + level: z.string(), + scope: z.string().optional(), + message: z.string(), + capturedAt: z.number(), + source: z.enum(["main", "renderer"]), +}); + +export type LogEntry = z.infer; + +export const logsSnapshotSchema = z.object({ + entries: z.array(logEntrySchema), +}); + +export type LogsSnapshot = z.infer; + +export const DevLogsEvent = { + Entry: "entry", +} as const; + +export interface DevLogsEvents { + [DevLogsEvent.Entry]: LogEntry; +} diff --git a/apps/code/src/main/services/dev-logs/service.ts b/apps/code/src/main/services/dev-logs/service.ts new file mode 100644 index 000000000..f238e974f --- /dev/null +++ b/apps/code/src/main/services/dev-logs/service.ts @@ -0,0 +1,74 @@ +import type ElectronLog from "electron-log"; +import log from "electron-log/main"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import type { DevFlagsService } from "../dev-flags/service"; +import { DevLogsEvent, type DevLogsEvents, type LogEntry } from "./schemas"; + +const RING_BUFFER_SIZE = 1000; + +@injectable() +export class DevLogsService extends TypedEventEmitter { + private entries: LogEntry[] = []; + private nextId = 1; + private installed = false; + + constructor( + @inject(MAIN_TOKENS.DevFlagsService) + private readonly flags: DevFlagsService, + ) { + super(); + } + + install(): void { + if (this.installed) return; + this.installed = true; + + const transport = ((message: ElectronLog.LogMessage) => { + if (!this.flags.getFlags().devMode) return; + const entry: LogEntry = { + id: this.nextId++, + level: message.level ?? "info", + scope: message.scope, + message: formatMessage(message.data), + capturedAt: (message.date ?? new Date()).getTime(), + source: + message.variables?.processType === "renderer" ? "renderer" : "main", + }; + this.entries.push(entry); + if (this.entries.length > RING_BUFFER_SIZE) { + this.entries.splice(0, this.entries.length - RING_BUFFER_SIZE); + } + this.emit(DevLogsEvent.Entry, entry); + }) as ElectronLog.Transport; + transport.level = "silly"; + transport.transforms = []; + + // electron-log allows arbitrary string transport names + (log.transports as Record).devToolbar = + transport; + } + + getSnapshot(): LogEntry[] { + return [...this.entries]; + } + + clear(): void { + this.entries = []; + } +} + +function formatMessage(data: unknown[]): string { + return data + .map((item) => { + if (typeof item === "string") return item; + if (item instanceof Error) return `${item.message}\n${item.stack ?? ""}`; + try { + return JSON.stringify(item); + } catch { + return String(item); + } + }) + .join(" "); +} diff --git a/apps/code/src/main/services/dev-metrics/schemas.ts b/apps/code/src/main/services/dev-metrics/schemas.ts new file mode 100644 index 000000000..69a0c15bc --- /dev/null +++ b/apps/code/src/main/services/dev-metrics/schemas.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +export const processSampleSchema = z.object({ + pid: z.number(), + type: z.string(), + name: z.string().optional(), + cpuPercent: z.number(), + memoryMb: z.number(), +}); + +export const metricsSampleSchema = z.object({ + capturedAt: z.number(), + totalCpuPercent: z.number(), + totalMemoryMb: z.number(), + heapUsedMb: z.number(), + heapTotalMb: z.number(), + loopLagMs: z.number(), + loopLagMaxMs: z.number(), + processes: z.array(processSampleSchema), +}); + +export type ProcessSample = z.infer; +export type MetricsSample = z.infer; + +export const DevMetricsEvent = { + Sample: "sample", +} as const; + +export interface DevMetricsEvents { + [DevMetricsEvent.Sample]: MetricsSample; +} diff --git a/apps/code/src/main/services/dev-metrics/service.ts b/apps/code/src/main/services/dev-metrics/service.ts new file mode 100644 index 000000000..8fb57e650 --- /dev/null +++ b/apps/code/src/main/services/dev-metrics/service.ts @@ -0,0 +1,137 @@ +import type { IAppMetrics } from "@posthog/platform/app-metrics"; +import { inject, injectable, preDestroy } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import { + DevMetricsEvent, + type DevMetricsEvents, + type MetricsSample, + type ProcessSample, +} from "./schemas"; + +const log = logger.scope("dev-metrics"); + +const SAMPLE_INTERVAL_MS = 1000; +const LOOP_LAG_INTERVAL_MS = 250; + +@injectable() +export class DevMetricsService extends TypedEventEmitter { + private pollTimer: ReturnType | null = null; + private loopLagTimer: ReturnType | null = null; + private lastLoopTick = performance.now(); + private loopLagSamples: number[] = []; + private subscriberCount = 0; + private lastSample: MetricsSample | null = null; + + constructor( + @inject(MAIN_TOKENS.AppMetrics) private readonly appMetrics: IAppMetrics, + ) { + super(); + } + + acquireSampler(): void { + this.subscriberCount += 1; + if (this.subscriberCount === 1) { + this.startPolling(); + } + } + + releaseSampler(): void { + this.subscriberCount = Math.max(0, this.subscriberCount - 1); + if (this.subscriberCount === 0) { + this.stopPolling(); + } + } + + getLastSample(): MetricsSample | null { + return this.lastSample; + } + + private startPolling(): void { + if (this.pollTimer) return; + log.info("Starting metrics sampler"); + this.startLoopLagProbe(); + void this.collectSample(); + this.pollTimer = setInterval( + () => void this.collectSample(), + SAMPLE_INTERVAL_MS, + ); + } + + private stopPolling(): void { + if (!this.pollTimer) return; + log.info("Stopping metrics sampler"); + clearInterval(this.pollTimer); + this.pollTimer = null; + this.stopLoopLagProbe(); + } + + private startLoopLagProbe(): void { + this.lastLoopTick = performance.now(); + const tick = () => { + const now = performance.now(); + const lag = Math.max(0, now - this.lastLoopTick - LOOP_LAG_INTERVAL_MS); + this.loopLagSamples.push(lag); + this.lastLoopTick = now; + this.loopLagTimer = setTimeout(tick, LOOP_LAG_INTERVAL_MS); + }; + this.loopLagTimer = setTimeout(tick, LOOP_LAG_INTERVAL_MS); + } + + private stopLoopLagProbe(): void { + if (this.loopLagTimer) { + clearTimeout(this.loopLagTimer); + this.loopLagTimer = null; + } + this.loopLagSamples = []; + } + + private drainLoopLag(): { avg: number; max: number } { + if (this.loopLagSamples.length === 0) return { avg: 0, max: 0 }; + const samples = this.loopLagSamples; + this.loopLagSamples = []; + const max = Math.max(...samples); + const avg = samples.reduce((s, v) => s + v, 0) / samples.length; + return { avg, max }; + } + + private async collectSample(): Promise { + try { + const metrics = this.appMetrics.getAppMetrics(); + const processes: ProcessSample[] = metrics.map((m) => ({ + pid: m.pid, + type: m.type, + name: m.name, + cpuPercent: m.cpu?.percentCPUUsage ?? 0, + memoryMb: (m.memory?.workingSetSize ?? 0) / 1024, + })); + const totalCpuPercent = processes.reduce( + (sum, p) => sum + p.cpuPercent, + 0, + ); + const totalMemoryMb = processes.reduce((sum, p) => sum + p.memoryMb, 0); + const heap = process.memoryUsage(); + const loop = this.drainLoopLag(); + const sample: MetricsSample = { + capturedAt: Date.now(), + totalCpuPercent, + totalMemoryMb, + heapUsedMb: heap.heapUsed / 1024 / 1024, + heapTotalMb: heap.heapTotal / 1024 / 1024, + loopLagMs: loop.avg, + loopLagMaxMs: loop.max, + processes, + }; + this.lastSample = sample; + this.emit(DevMetricsEvent.Sample, sample); + } catch (error) { + log.warn("Failed to collect metrics sample", { error }); + } + } + + @preDestroy() + cleanup(): void { + this.stopPolling(); + } +} diff --git a/apps/code/src/main/services/dev-network/schemas.ts b/apps/code/src/main/services/dev-network/schemas.ts new file mode 100644 index 000000000..0fcc5af3a --- /dev/null +++ b/apps/code/src/main/services/dev-network/schemas.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +export const networkRequestSchema = z.object({ + id: z.number(), + method: z.string(), + url: z.string(), + host: z.string(), + origin: z.enum(["main", "renderer"]), + status: z.number().nullable(), + ok: z.boolean(), + durationMs: z.number(), + startedAt: z.number(), + bytes: z.number().nullable(), + error: z.string().optional(), +}); + +export type NetworkRequest = z.infer; + +export const networkSnapshotSchema = z.object({ + requests: z.array(networkRequestSchema), +}); + +export type NetworkSnapshot = z.infer; + +export const networkSimSchema = z.object({ + offline: z.boolean(), + slowDelayMs: z.number().min(0).max(10_000), +}); + +export type NetworkSim = z.infer; + +export const DevNetworkEvent = { + Request: "request", + SimChanged: "sim-changed", +} as const; + +export interface DevNetworkEvents { + [DevNetworkEvent.Request]: NetworkRequest; + [DevNetworkEvent.SimChanged]: NetworkSim; +} diff --git a/apps/code/src/main/services/dev-network/service.ts b/apps/code/src/main/services/dev-network/service.ts new file mode 100644 index 000000000..ce18d7198 --- /dev/null +++ b/apps/code/src/main/services/dev-network/service.ts @@ -0,0 +1,181 @@ +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import type { DevFlagsService } from "../dev-flags/service"; +import { + DevNetworkEvent, + type DevNetworkEvents, + type NetworkRequest, + type NetworkSim, +} from "./schemas"; + +const log = logger.scope("dev-network"); + +const RING_BUFFER_SIZE = 500; + +@injectable() +export class DevNetworkService extends TypedEventEmitter { + private requests: NetworkRequest[] = []; + private nextId = 1; + private sim: NetworkSim = { offline: false, slowDelayMs: 0 }; + private installed = false; + + constructor( + @inject(MAIN_TOKENS.DevFlagsService) + private readonly flags: DevFlagsService, + ) { + super(); + } + + install(): void { + if (this.installed) return; + this.installed = true; + this.wrapFetch(); + log.info("Network instrumentation installed"); + } + + private capturing(): boolean { + return this.installed && this.flags.getFlags().devMode; + } + + getSnapshot(): NetworkRequest[] { + return [...this.requests]; + } + + clear(): void { + this.requests = []; + } + + getSim(): NetworkSim { + return { ...this.sim }; + } + + setSim(next: Partial): NetworkSim { + this.sim = { ...this.sim, ...next }; + this.emit(DevNetworkEvent.SimChanged, { ...this.sim }); + return { ...this.sim }; + } + + private record(req: NetworkRequest): void { + this.requests.push(req); + if (this.requests.length > RING_BUFFER_SIZE) { + this.requests.splice(0, this.requests.length - RING_BUFFER_SIZE); + } + this.emit(DevNetworkEvent.Request, req); + } + + private wrapFetch(): void { + const original = globalThis.fetch; + if (!original) return; + + const wrapped = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + if (!this.capturing()) { + return original(input, init); + } + const startedAt = Date.now(); + const start = performance.now(); + const method = (init?.method ?? "GET").toUpperCase(); + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + const host = safeHost(url); + const id = this.nextId++; + + if (this.sim.offline) { + const err = new TypeError("Network simulated offline"); + this.record({ + id, + method, + url, + host, + origin: "main", + status: null, + ok: false, + durationMs: performance.now() - start, + startedAt, + bytes: null, + error: err.message, + }); + throw err; + } + + if (this.sim.slowDelayMs > 0) { + await sleep(this.sim.slowDelayMs); + } + + try { + const response = await original(input, init); + const durationMs = performance.now() - start; + const bytes = parseContentLength( + response.headers.get("content-length"), + ); + this.record({ + id, + method, + url, + host, + origin: "main", + status: response.status, + ok: response.ok, + durationMs, + startedAt, + bytes, + }); + return response; + } catch (error) { + const durationMs = performance.now() - start; + const message = error instanceof Error ? error.message : String(error); + this.record({ + id, + method, + url, + host, + origin: "main", + status: null, + ok: false, + durationMs, + startedAt, + bytes: null, + error: message, + }); + throw error; + } + }; + + const preconnect = ( + original as unknown as { + preconnect?: (...args: unknown[]) => unknown; + } + ).preconnect; + Object.defineProperty(wrapped, "preconnect", { + value: preconnect?.bind(original) ?? (() => undefined), + }); + + globalThis.fetch = wrapped as typeof fetch; + } +} + +function safeHost(url: string): string { + try { + return new URL(url).host; + } catch { + return ""; + } +} + +function parseContentLength(value: string | null): number | null { + if (!value) return null; + const n = Number.parseInt(value, 10); + return Number.isFinite(n) ? n : null; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/code/src/main/services/dev-toolbar.ts b/apps/code/src/main/services/dev-toolbar.ts new file mode 100644 index 000000000..61aaa71ed --- /dev/null +++ b/apps/code/src/main/services/dev-toolbar.ts @@ -0,0 +1,25 @@ +import type { Container } from "inversify"; +import { MAIN_TOKENS } from "../di/tokens"; +import { DevFlagsEvent } from "./dev-flags/schemas"; +import type { DevFlagsService } from "./dev-flags/service"; +import type { DevLogsService } from "./dev-logs/service"; +import type { DevNetworkService } from "./dev-network/service"; + +export function initDevToolbar(container: Container): void { + const flags = container.get(MAIN_TOKENS.DevFlagsService); + const network = container.get( + MAIN_TOKENS.DevNetworkService, + ); + const logs = container.get(MAIN_TOKENS.DevLogsService); + + const installCapture = () => { + network.install(); + logs.install(); + }; + + if (flags.getFlags().devMode) installCapture(); + + flags.on(DevFlagsEvent.Changed, (next) => { + if (next.devMode) installCapture(); + }); +} diff --git a/apps/code/src/main/services/index.ts b/apps/code/src/main/services/index.ts index 95a73373e..e3b25f8e5 100644 --- a/apps/code/src/main/services/index.ts +++ b/apps/code/src/main/services/index.ts @@ -3,6 +3,7 @@ * This file is auto-generated by vite-plugin-auto-services.ts */ +import "./dev-toolbar.js"; import "./integration-flow-schemas.js"; import "./posthog-analytics.js"; import "./settingsStore.js"; diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 75a5c85c2..c59bc9dc8 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -6,6 +6,7 @@ import { cloudTaskRouter } from "./routers/cloud-task"; import { connectivityRouter } from "./routers/connectivity"; import { contextMenuRouter } from "./routers/context-menu"; import { deepLinkRouter } from "./routers/deep-link"; +import { devRouter } from "./routers/dev"; import { encryptionRouter } from "./routers/encryption"; import { enrichmentRouter } from "./routers/enrichment"; import { environmentRouter } from "./routers/environment"; @@ -46,6 +47,7 @@ export const trpcRouter = router({ connectivity: connectivityRouter, contextMenu: contextMenuRouter, + dev: devRouter, enrichment: enrichmentRouter, environment: environmentRouter, encryption: encryptionRouter, diff --git a/apps/code/src/main/trpc/routers/dev.ts b/apps/code/src/main/trpc/routers/dev.ts new file mode 100644 index 000000000..a6adeb412 --- /dev/null +++ b/apps/code/src/main/trpc/routers/dev.ts @@ -0,0 +1,219 @@ +import { z } from "zod"; +import { getService } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import type { AgentService } from "../../services/agent/service"; +import { + DevActionsEvent, + type DevActionsEvents, + devToastInput, + devToastSchema, +} from "../../services/dev-actions/schemas"; +import type { DevActionsService } from "../../services/dev-actions/service"; +import { + type DevFlags, + DevFlagsEvent, + type DevFlagsEvents, + devFlagsSchema, +} from "../../services/dev-flags/schemas"; +import type { DevFlagsService } from "../../services/dev-flags/service"; +import { + DevLogsEvent, + type DevLogsEvents, + logsSnapshotSchema, +} from "../../services/dev-logs/schemas"; +import type { DevLogsService } from "../../services/dev-logs/service"; +import { + DevMetricsEvent, + type DevMetricsEvents, + metricsSampleSchema, +} from "../../services/dev-metrics/schemas"; +import type { DevMetricsService } from "../../services/dev-metrics/service"; +import { + DevNetworkEvent, + type DevNetworkEvents, + networkSimSchema, + networkSnapshotSchema, +} from "../../services/dev-network/schemas"; +import type { DevNetworkService } from "../../services/dev-network/service"; +import { publicProcedure, router } from "../trpc"; + +const getFlagsService = () => + getService(MAIN_TOKENS.DevFlagsService); +const getMetricsService = () => + getService(MAIN_TOKENS.DevMetricsService); +const getNetworkService = () => + getService(MAIN_TOKENS.DevNetworkService); +const getLogsService = () => + getService(MAIN_TOKENS.DevLogsService); +const getActionsService = () => + getService(MAIN_TOKENS.DevActionsService); +const getAgentService = () => + getService(MAIN_TOKENS.AgentService); + +const agentSessionSchema = z.object({ + taskRunId: z.string(), + taskId: z.string(), + repoPath: z.string(), + adapter: z.string(), + model: z.string().nullable(), + sessionId: z.string().nullable(), + channel: z.string(), + createdAt: z.number(), + lastActivityAt: z.number(), + promptPending: z.boolean(), + inFlightToolCalls: z.number(), + idleDeadline: z.number().nullable(), +}); + +const agentSnapshotSchema = z.object({ + sessions: z.array(agentSessionSchema), + pendingPermissions: z.array( + z.object({ + taskRunId: z.string(), + toolCallId: z.string(), + }), + ), +}); + +export const devRouter = router({ + getFlags: publicProcedure.output(devFlagsSchema).query((): DevFlags => { + return getFlagsService().getFlags(); + }), + + setDevMode: publicProcedure + .input(z.object({ enabled: z.boolean() })) + .output(devFlagsSchema) + .mutation(({ input }) => getFlagsService().setDevMode(input.enabled)), + + getLastMetrics: publicProcedure + .output(metricsSampleSchema.nullable()) + .query(() => getMetricsService().getLastSample()), + + getNetworkRequests: publicProcedure + .output(networkSnapshotSchema) + .query(() => ({ requests: getNetworkService().getSnapshot() })), + + clearNetworkRequests: publicProcedure.mutation(() => { + getNetworkService().clear(); + return { ok: true }; + }), + + getNetworkSim: publicProcedure + .output(networkSimSchema) + .query(() => getNetworkService().getSim()), + + setNetworkSim: publicProcedure + .input(networkSimSchema.partial()) + .output(networkSimSchema) + .mutation(({ input }) => getNetworkService().setSim(input)), + + getLogs: publicProcedure + .output(logsSnapshotSchema) + .query(() => ({ entries: getLogsService().getSnapshot() })), + + clearLogs: publicProcedure.mutation(() => { + getLogsService().clear(); + return { ok: true }; + }), + + getAgentsSnapshot: publicProcedure + .output(agentSnapshotSchema) + .query(() => getAgentService().getDebugSnapshot()), + + openUserDataDir: publicProcedure.mutation(async () => { + await getActionsService().openUserDataDir(); + return { ok: true }; + }), + + openLogFile: publicProcedure.mutation(async () => { + await getActionsService().openLogFile(); + return { ok: true }; + }), + + reloadRenderer: publicProcedure.mutation(() => { + getActionsService().reloadRenderer(); + return { ok: true }; + }), + + restartMain: publicProcedure.mutation(() => { + getActionsService().restartMain(); + return { ok: true }; + }), + + crashMain: publicProcedure.mutation(() => { + getActionsService().crashMain(); + return { ok: true }; + }), + + triggerToast: publicProcedure + .input(devToastInput) + .output(devToastSchema) + .mutation(({ input }) => + getActionsService().triggerToast(input.variant, input.message), + ), + + onFlagsChanged: publicProcedure.subscription(async function* (opts) { + const service = getFlagsService(); + const event: keyof DevFlagsEvents = DevFlagsEvent.Changed; + for await (const data of service.toIterable(event, { + signal: opts.signal, + })) { + yield data; + } + }), + + onMetrics: publicProcedure.subscription(async function* (opts) { + const service = getMetricsService(); + service.acquireSampler(); + try { + const event: keyof DevMetricsEvents = DevMetricsEvent.Sample; + for await (const data of service.toIterable(event, { + signal: opts.signal, + })) { + yield data; + } + } finally { + service.releaseSampler(); + } + }), + + onNetworkRequest: publicProcedure.subscription(async function* (opts) { + const service = getNetworkService(); + const event: keyof DevNetworkEvents = DevNetworkEvent.Request; + for await (const data of service.toIterable(event, { + signal: opts.signal, + })) { + yield data; + } + }), + + onNetworkSimChanged: publicProcedure.subscription(async function* (opts) { + const service = getNetworkService(); + const event: keyof DevNetworkEvents = DevNetworkEvent.SimChanged; + for await (const data of service.toIterable(event, { + signal: opts.signal, + })) { + yield data; + } + }), + + onLogEntry: publicProcedure.subscription(async function* (opts) { + const service = getLogsService(); + const event: keyof DevLogsEvents = DevLogsEvent.Entry; + for await (const data of service.toIterable(event, { + signal: opts.signal, + })) { + yield data; + } + }), + + onDevToast: publicProcedure.subscription(async function* (opts) { + const service = getActionsService(); + const event: keyof DevActionsEvents = DevActionsEvent.Toast; + for await (const data of service.toIterable(event, { + signal: opts.signal, + })) { + yield data; + } + }), +}); diff --git a/apps/code/src/main/trpc/trpc.ts b/apps/code/src/main/trpc/trpc.ts index 32992a377..1c207aa13 100644 --- a/apps/code/src/main/trpc/trpc.ts +++ b/apps/code/src/main/trpc/trpc.ts @@ -10,16 +10,16 @@ const CALL_RATE_THRESHOLD = 50; const callCounts: Record = {}; -const ipcTimingEnabled = process.env.IPC_TIMINGS === "true"; +const ipcTimingEnvEnabled = process.env.IPC_TIMINGS === "true"; const ipcTimingBootMs = 15_000; const bootTime = Date.now(); const callRateMonitor = trpc.middleware(async ({ path, next, type }) => { - const shouldTime = - ipcTimingEnabled && Date.now() - bootTime < ipcTimingBootMs; - const t = shouldTime ? performance.now() : 0; + const bootWindowOpen = Date.now() - bootTime < ipcTimingBootMs; + const envBootTiming = ipcTimingEnvEnabled && bootWindowOpen; + const start = envBootTiming ? performance.now() : 0; - if (shouldTime) { + if (envBootTiming) { log.info(`[ipc-timing] >> ${type} ${path}`); } @@ -44,15 +44,14 @@ const callRateMonitor = trpc.middleware(async ({ path, next, type }) => { } } - const result = await next(); - - if (shouldTime) { - log.info( - `[ipc-timing] << ${type} ${path}: ${(performance.now() - t).toFixed(0)}ms`, - ); + try { + return await next(); + } finally { + if (envBootTiming) { + const durationMs = performance.now() - start; + log.info(`[ipc-timing] << ${type} ${path}: ${durationMs.toFixed(0)}ms`); + } } - - return result; }); export const router = trpc.router; diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index d5796c939..86b8509fa 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -13,6 +13,10 @@ import { container } from "./di/container"; import { MAIN_TOKENS } from "./di/tokens"; import { buildApplicationMenu } from "./menu"; import type { ElectronMainWindow } from "./platform-adapters/electron-main-window"; +import { + encodeDevFlagsForArg, + readDevFlagsSync, +} from "./services/dev-flags/service"; import { trpcRouter } from "./trpc/router"; import { isDevBuild } from "./utils/env"; import { logger, readChromiumLogTail } from "./utils/logger"; @@ -193,7 +197,10 @@ export function createWindow(): void { preload: path.join(__dirname, "preload.js"), enableBlinkFeatures: "GetDisplayMedia", partition: "persist:main", - additionalArguments: isDev ? ["--posthog-code-dev"] : [], + additionalArguments: [ + ...(isDev ? ["--posthog-code-dev"] : []), + encodeDevFlagsForArg(readDevFlagsSync()), + ], ...(isDev && { webSecurity: false }), }, }); diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index 9ea710796..b8f52a373 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -12,6 +12,8 @@ import { } from "@features/auth/hooks/authQueries"; import { useAuthSession } from "@features/auth/hooks/useAuthSession"; import { useIsOrgAdmin } from "@features/auth/hooks/useOrgRole"; +import { DevToolbar } from "@features/dev-toolbar/components/DevToolbar"; +import { useDevToolbarIntegration } from "@features/dev-toolbar/integration"; import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { Flex, Spinner, Text } from "@radix-ui/themes"; @@ -67,6 +69,8 @@ function App() { return initializeUpdateStore(); }, []); + useDevToolbarIntegration(); + // Dev-only inbox demo command for local QA from the renderer console. useEffect(() => { if (import.meta.env.PROD) { @@ -226,7 +230,11 @@ function App() { const renderContent = () => { if (!hasCompletedOnboarding) { return ( - + ); @@ -234,7 +242,7 @@ function App() { if (!isAuthenticated) { return ( - + ); @@ -242,8 +250,12 @@ function App() { if (isCheckingAccess) { return ( - - + + Checking access... @@ -255,7 +267,11 @@ function App() { if (needsInviteCode) { return ( - + ); @@ -263,7 +279,11 @@ function App() { if (needsAiApproval) { return ( - + @@ -292,18 +313,23 @@ function App() { resetKey={authState.status} shouldSuppress={isNotAuthenticatedError} > - {isAuthenticated ? ( - {content} - ) : ( - content - )} - - - +
+
+ {isAuthenticated ? ( + {content} + ) : ( + content + )} + + + +
+ +
); } diff --git a/apps/code/src/renderer/components/LoginTransition.tsx b/apps/code/src/renderer/components/LoginTransition.tsx index 913bcd65f..96aa1ef6c 100644 --- a/apps/code/src/renderer/components/LoginTransition.tsx +++ b/apps/code/src/renderer/components/LoginTransition.tsx @@ -22,7 +22,7 @@ export function LoginTransition({ animate={{ opacity: 1 }} transition={{ duration: 0.5 }} onAnimationComplete={onComplete} - className="fixed inset-0 bg-(--color-background)" + className="absolute inset-0 bg-(--color-background)" /> ); } diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index 70b0dee87..ff4870a5e 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -135,7 +135,7 @@ export function MainLayout() { }, [toggleCommandMenu]); return ( - + diff --git a/apps/code/src/renderer/features/dev-toolbar/components/AgentsPanel.tsx b/apps/code/src/renderer/features/dev-toolbar/components/AgentsPanel.tsx new file mode 100644 index 000000000..f8a73ff0b --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/components/AgentsPanel.tsx @@ -0,0 +1,189 @@ +import { Button, Flex, Text } from "@radix-ui/themes"; +import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { Activity, Clock, FileQuestion } from "lucide-react"; +import { useEffect, useState } from "react"; + +interface AgentsPanelProps { + enabled: boolean; +} + +const REFRESH_INTERVAL_MS = 1000; + +export function AgentsPanel({ enabled }: AgentsPanelProps) { + const trpcReact = useTRPC(); + const { data, refetch } = useQuery({ + ...trpcReact.dev.getAgentsSnapshot.queryOptions(), + enabled, + refetchInterval: enabled ? REFRESH_INTERVAL_MS : false, + }); + + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + + const sessions = data?.sessions ?? []; + const pending = data?.pendingPermissions ?? []; + + return ( + + + + {sessions.length} session{sessions.length === 1 ? "" : "s"} + + + · + + + {pending.length} pending permission{pending.length === 1 ? "" : "s"} + + + + + {sessions.length === 0 && pending.length === 0 && ( + + No active agent sessions. + + )} + + {sessions.length > 0 && ( + + + Active sessions + +
+
+ Task + Adapter + State + Activity + Idle in +
+
+ {sessions.map((s) => ( + + ))} +
+
+
+ )} + + {pending.length > 0 && ( + + + Pending permissions + +
+ {pending.map((p) => ( +
+ + + task={p.taskRunId.slice(0, 8)}… toolCall={p.toolCallId} + + +
+ ))} +
+
+ )} +
+ ); +} + +interface DevSession { + taskRunId: string; + taskId: string; + repoPath: string; + adapter: string; + model: string | null; + sessionId: string | null; + channel: string; + createdAt: number; + lastActivityAt: number; + promptPending: boolean; + inFlightToolCalls: number; + idleDeadline: number | null; +} + +function SessionRow({ session, now }: { session: DevSession; now: number }) { + const ageMs = now - session.lastActivityAt; + const idleIn = session.idleDeadline ? session.idleDeadline - now : null; + return ( +
+ + + {session.taskId.slice(0, 12)} + + + {session.model ?? "default"} + + + + {session.adapter} + + + {session.promptPending ? ( + <> + + + busy + + + ) : session.inFlightToolCalls > 0 ? ( + + tool×{session.inFlightToolCalls} + + ) : ( + + idle + + )} + + + + + {formatDuration(ageMs)} + + + + {idleIn != null ? formatDuration(idleIn) : "—"} + +
+ ); +} + +function formatDuration(ms: number): string { + if (ms < 0) return "now"; + if (ms < 1000) return "now"; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h`; +} diff --git a/apps/code/src/renderer/features/dev-toolbar/components/CpuPanel.tsx b/apps/code/src/renderer/features/dev-toolbar/components/CpuPanel.tsx new file mode 100644 index 000000000..e52f441f8 --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/components/CpuPanel.tsx @@ -0,0 +1,184 @@ +import { Flex, Text } from "@radix-ui/themes"; +import { Cpu } from "lucide-react"; +import { useMemo } from "react"; +import { + CardSparkline, + InfoStat, + ProcessTable, + ProfilingTip, + StatusBadge, + trendOf, + useMetricsHistory, +} from "./MetricsCommon"; + +interface CpuPanelProps { + enabled: boolean; +} + +type CpuStatus = "idle" | "normal" | "busy" | "critical"; + +function statusFor(cpu: number): CpuStatus { + if (cpu >= 60) return "critical"; + if (cpu >= 30) return "busy"; + if (cpu >= 10) return "normal"; + return "idle"; +} + +const STATUS_META: Record< + CpuStatus, + { + label: string; + level: "ok" | "warn" | "crit"; + valueColor: string; + lineClass: string; + barClass: string; + emphasis?: "red" | "amber"; + hint: string; + } +> = { + idle: { + label: "Idle", + level: "ok", + valueColor: "text-(--gray-12)", + lineClass: "text-(--accent-9)", + barClass: "bg-(--accent-9)", + hint: "Plenty of headroom.", + }, + normal: { + label: "Normal", + level: "ok", + valueColor: "text-(--gray-12)", + lineClass: "text-(--accent-9)", + barClass: "bg-(--accent-9)", + hint: "Healthy steady-state load.", + }, + busy: { + label: "Busy", + level: "warn", + valueColor: "text-(--amber-11)", + lineClass: "text-(--amber-9)", + barClass: "bg-(--amber-9)", + emphasis: "amber", + hint: "Sustained load. Watch for jank.", + }, + critical: { + label: "Critical", + level: "crit", + valueColor: "text-(--red-11)", + lineClass: "text-(--red-9)", + barClass: "bg-(--red-9)", + emphasis: "red", + hint: "Likely freezing UI. Profile now.", + }, +}; + +export function CpuPanel({ enabled }: CpuPanelProps) { + const { sample, history } = useMetricsHistory(enabled); + + const cpuHistory = useMemo( + () => history.map((h) => h.totalCpuPercent), + [history], + ); + const cpuPeak = cpuHistory.length ? Math.max(...cpuHistory) : 0; + const cpuAvg = cpuHistory.length + ? cpuHistory.reduce((a, b) => a + b, 0) / cpuHistory.length + : 0; + const trend = trendOf(cpuHistory); + const busiest = useMemo(() => { + if (!sample || sample.processes.length === 0) return null; + return [...sample.processes].sort((a, b) => b.cpuPercent - a.cpuPercent)[0]; + }, [sample]); + + if (!sample) { + return ( + + + Waiting for CPU samples... + + + ); + } + + const status = statusFor(sample.totalCpuPercent); + const meta = STATUS_META[status]; + const trendLabel = + trend === "up" ? "↑ trending up" : trend === "down" ? "↓ easing" : "→ flat"; + const barWidth = Math.min(100, sample.totalCpuPercent); + + return ( + +
+ + + + + + CPU + + {meta.label} + + {trendLabel} + + +
+ + + {sample.totalCpuPercent.toFixed(1)}% + + + {meta.hint} + + + + +
+
+
+ +
+
+ + + + 25 + ? "red" + : busiest && busiest.cpuPercent > 5 + ? "amber" + : undefined + } + /> +
+
+ + + + + + ); +} diff --git a/apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx b/apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx new file mode 100644 index 000000000..e616f296e --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx @@ -0,0 +1,884 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useLogoutMutation } from "@features/auth/hooks/authMutations"; +import { + useAuthStateValue, + useCurrentUser, +} from "@features/auth/hooks/authQueries"; +import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { useSetupStore } from "@features/setup/stores/setupStore"; +import { useTourStore } from "@features/tour/stores/tourStore"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + Item, + ItemContent, + ItemDescription, + ItemTitle, +} from "@posthog/quill"; +import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; +import { useThemeStore } from "@renderer/stores/themeStore"; +import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { clearApplicationStorage } from "@utils/clearStorage"; +import { + Activity, + AlertTriangle, + Bot, + Bug, + ChevronDown, + Cpu, + FileText, + FolderOpen, + Globe, + MemoryStick, + Moon, + Power, + Radar, + RefreshCw, + RotateCcw, + ScrollText, + Sun, + Timer, + Trash2, + Wrench, + X, + ZapOff, +} from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import type { MetricsSample } from "../../../../main/services/dev-metrics/schemas"; +import { REGION_LABELS } from "../../../../shared/types/regions"; +import { useDevFlagsStore } from "../devFlagsStore"; +import { useIpcMetricsStore } from "../ipcMetricsStore"; +import { useMainThreadHealthStore } from "../mainThreadHealth"; +import { AgentsPanel } from "./AgentsPanel"; +import { CpuPanel } from "./CpuPanel"; +import { HealthPanel } from "./HealthPanel"; +import { IpcTimingsPanel } from "./IpcTimingsPanel"; +import { LogsPanel } from "./LogsPanel"; +import { MemoryPanel } from "./MemoryPanel"; +import { NetworkPanel } from "./NetworkPanel"; + +type DetailPanel = + | "cpu" + | "memory" + | "ipc" + | "network" + | "agents" + | "logs" + | "health" + | null; + +export function DevToolbar() { + const devMode = useDevFlagsStore((s) => s.devMode); + const setDevMode = useDevFlagsStore((s) => s.setDevMode); + const reactScanEnabled = useDevFlagsStore((s) => s.reactScanEnabled); + const setReactScanEnabledState = useDevFlagsStore( + (s) => s.setReactScanEnabled, + ); + + const [openPanel, setOpenPanel] = useState(null); + const [panelHeight, setPanelHeight] = useState(480); + + if (!devMode) return null; + + const togglePanel = (panel: Exclude) => { + setOpenPanel((current) => (current === panel ? null : panel)); + }; + + return ( +
+ {openPanel && ( + setOpenPanel(null)} + devMode={devMode} + height={panelHeight} + onResize={setPanelHeight} + /> + )} + + + + + + + + setReactScanEnabledState(!reactScanEnabled) + } + /> + + + + + + togglePanel("cpu")} + onToggleMemory={() => togglePanel("memory")} + onToggleIpc={() => togglePanel("ipc")} + onToggleHealth={() => togglePanel("health")} + onToggleNetwork={() => togglePanel("network")} + onToggleAgents={() => togglePanel("agents")} + onToggleLogs={() => togglePanel("logs")} + /> + + + + + + +
+ ); +} + +function Divider() { + return
; +} + +const PANEL_HEADERS: Record< + Exclude, + { title: string; subtitle: string } +> = { + cpu: { + title: "CPU", + subtitle: "% · total CPU usage across all Electron processes", + }, + memory: { + title: "Memory", + subtitle: "GB · total working set memory (heap in tooltip)", + }, + ipc: { + title: "IPC traffic", + subtitle: "ms · round-trip time of the most recent renderer→main IPC call", + }, + network: { + title: "Network", + subtitle: "/min · outbound HTTP requests in the last minute", + }, + agents: { + title: "Agent sessions", + subtitle: "count · active agent sessions (amber on pending permissions)", + }, + logs: { + title: "Logs", + subtitle: "count · warn + error log entries since the panel last opened", + }, + health: { + title: "Main-thread health", + subtitle: "ms · current main-thread event loop lag", + }, +}; + +function PanelChrome({ + openPanel, + onClose, + devMode, + height, + onResize, +}: { + openPanel: Exclude; + onClose: () => void; + devMode: boolean; + height: number; + onResize: (next: number) => void; +}) { + return ( +
+ + + + + {PANEL_HEADERS[openPanel].title} + + + {PANEL_HEADERS[openPanel].subtitle} + + + + + + +
+ {openPanel === "cpu" && } + {openPanel === "memory" && } + {openPanel === "ipc" && } + {openPanel === "network" && } + {openPanel === "agents" && } + {openPanel === "logs" && } + {openPanel === "health" && } +
+
+ ); +} + +const MIN_PANEL_HEIGHT = 80; +const MAX_PANEL_INSET = 60; + +function ResizeHandle({ + height, + onResize, +}: { + height: number; + onResize: (next: number) => void; +}) { + const start = useRef<{ y: number; h: number } | null>(null); + + const handlePointerDown = (e: React.PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId); + start.current = { y: e.clientY, h: height }; + document.body.style.cursor = "ns-resize"; + document.body.style.userSelect = "none"; + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!start.current) return; + const delta = start.current.y - e.clientY; + const max = Math.max( + MIN_PANEL_HEIGHT, + window.innerHeight - MAX_PANEL_INSET, + ); + const next = Math.max( + MIN_PANEL_HEIGHT, + Math.min(max, start.current.h + delta), + ); + onResize(next); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (!start.current) return; + e.currentTarget.releasePointerCapture(e.pointerId); + start.current = null; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + return ( +