From 3991c8f4b47ad48d436a122ca9dbf3000df6f9b2 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 20 May 2026 11:56:05 -0700 Subject: [PATCH 1/3] dev toolbar --- apps/code/package.json | 1 + apps/code/src/main/di/container.ts | 25 + apps/code/src/main/di/tokens.ts | 6 + apps/code/src/main/index.ts | 5 + .../platform-adapters/electron-app-metrics.ts | 21 + apps/code/src/main/preload.ts | 18 + apps/code/src/main/services/agent/service.ts | 43 + .../src/main/services/dev-actions/schemas.ts | 22 + .../src/main/services/dev-actions/service.ts | 69 ++ .../src/main/services/dev-flags/schemas.ts | 19 + .../src/main/services/dev-flags/service.ts | 73 ++ .../src/main/services/dev-logs/schemas.ts | 26 + .../src/main/services/dev-logs/service.ts | 69 ++ .../src/main/services/dev-metrics/schemas.ts | 31 + .../src/main/services/dev-metrics/service.ts | 137 +++ .../src/main/services/dev-network/schemas.ts | 40 + .../src/main/services/dev-network/service.ts | 160 ++++ apps/code/src/main/trpc/router.ts | 2 + apps/code/src/main/trpc/routers/dev.ts | 219 +++++ apps/code/src/main/trpc/trpc.ts | 25 +- apps/code/src/main/window.ts | 9 +- apps/code/src/renderer/App.tsx | 76 +- .../renderer/components/LoginTransition.tsx | 2 +- .../src/renderer/components/MainLayout.tsx | 2 +- .../dev-toolbar/components/AgentsPanel.tsx | 189 ++++ .../dev-toolbar/components/CpuPanel.tsx | 184 ++++ .../dev-toolbar/components/DevToolbar.tsx | 888 ++++++++++++++++++ .../dev-toolbar/components/HealthPanel.tsx | 256 +++++ .../components/IpcTimingsPanel.tsx | 249 +++++ .../dev-toolbar/components/LogsPanel.tsx | 194 ++++ .../dev-toolbar/components/MemoryPanel.tsx | 198 ++++ .../dev-toolbar/components/MetricsCommon.tsx | 298 ++++++ .../dev-toolbar/components/NetworkPanel.tsx | 240 +++++ .../features/dev-toolbar/devFlagsStore.ts | 45 + .../features/dev-toolbar/devModeBoot.ts | 16 + .../dev-toolbar/ipcInstrumentationLink.ts | 84 ++ .../features/dev-toolbar/ipcMetricsStore.ts | 61 ++ .../features/dev-toolbar/mainThreadHealth.ts | 94 ++ .../features/dev-toolbar/reactScan.ts | 27 + .../settings/components/SettingsDialog.tsx | 2 +- .../components/sections/AdvancedSettings.tsx | 17 +- apps/code/src/renderer/trpc/client.ts | 3 +- apps/code/src/vite-env.d.ts | 6 + packages/agent/package.json | 3 +- packages/platform/package.json | 4 + packages/platform/src/app-metrics.ts | 11 + packages/platform/tsup.config.ts | 1 + pnpm-lock.yaml | 191 +++- 48 files changed, 4311 insertions(+), 50 deletions(-) create mode 100644 apps/code/src/main/platform-adapters/electron-app-metrics.ts create mode 100644 apps/code/src/main/services/dev-actions/schemas.ts create mode 100644 apps/code/src/main/services/dev-actions/service.ts create mode 100644 apps/code/src/main/services/dev-flags/schemas.ts create mode 100644 apps/code/src/main/services/dev-flags/service.ts create mode 100644 apps/code/src/main/services/dev-logs/schemas.ts create mode 100644 apps/code/src/main/services/dev-logs/service.ts create mode 100644 apps/code/src/main/services/dev-metrics/schemas.ts create mode 100644 apps/code/src/main/services/dev-metrics/service.ts create mode 100644 apps/code/src/main/services/dev-network/schemas.ts create mode 100644 apps/code/src/main/services/dev-network/service.ts create mode 100644 apps/code/src/main/trpc/routers/dev.ts create mode 100644 apps/code/src/renderer/features/dev-toolbar/components/AgentsPanel.tsx create mode 100644 apps/code/src/renderer/features/dev-toolbar/components/CpuPanel.tsx create mode 100644 apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx create mode 100644 apps/code/src/renderer/features/dev-toolbar/components/HealthPanel.tsx create mode 100644 apps/code/src/renderer/features/dev-toolbar/components/IpcTimingsPanel.tsx create mode 100644 apps/code/src/renderer/features/dev-toolbar/components/LogsPanel.tsx create mode 100644 apps/code/src/renderer/features/dev-toolbar/components/MemoryPanel.tsx create mode 100644 apps/code/src/renderer/features/dev-toolbar/components/MetricsCommon.tsx create mode 100644 apps/code/src/renderer/features/dev-toolbar/components/NetworkPanel.tsx create mode 100644 apps/code/src/renderer/features/dev-toolbar/devFlagsStore.ts create mode 100644 apps/code/src/renderer/features/dev-toolbar/devModeBoot.ts create mode 100644 apps/code/src/renderer/features/dev-toolbar/ipcInstrumentationLink.ts create mode 100644 apps/code/src/renderer/features/dev-toolbar/ipcMetricsStore.ts create mode 100644 apps/code/src/renderer/features/dev-toolbar/mainThreadHealth.ts create mode 100644 apps/code/src/renderer/features/dev-toolbar/reactScan.ts create mode 100644 packages/platform/src/app-metrics.ts 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..a3d1cc707 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -11,6 +11,7 @@ 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"; @@ -34,6 +35,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 +77,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 +104,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.AppMetrics).to(ElectronAppMetrics); container.bind(MAIN_TOKENS.DatabaseService).to(DatabaseService); container @@ -144,3 +163,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..ec8cc9a7f 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"), + AppMetrics: Symbol.for("Platform.AppMetrics"), // Stores SettingsStore: Symbol.for("Main.SettingsStore"), @@ -77,4 +78,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..7faf4fb6c 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -12,6 +12,8 @@ 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 type { DevLogsService } from "./services/dev-logs/service"; +import type { DevNetworkService } from "./services/dev-network/service"; 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 +144,9 @@ app.on("child-process-gone", (_event, details) => { }); async function initializeServices(): Promise { + container.get(MAIN_TOKENS.DevNetworkService).install(); + container.get(MAIN_TOKENS.DevLogsService).install(); + 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/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..19d2478c2 --- /dev/null +++ b/apps/code/src/main/services/dev-actions/service.ts @@ -0,0 +1,69 @@ +import { app, BrowserWindow, shell } from "electron"; +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, + ) { + super(); + } + + async openUserDataDir(): Promise { + await shell.openPath(getUserDataDir()); + } + + async openLogFile(): Promise { + await shell.openPath(getLogFilePath()); + } + + reloadRenderer(): void { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.reload(); + } + } + + restartMain(): void { + log.warn("Restarting main process from dev toolbar"); + app.relaunch(); + app.exit(0); + } + + crashMain(): void { + log.warn("Crashing main process from dev toolbar"); + process.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..b8e0b2cea --- /dev/null +++ b/apps/code/src/main/services/dev-logs/service.ts @@ -0,0 +1,69 @@ +import type ElectronLog from "electron-log"; +import log from "electron-log/main"; +import { injectable } from "inversify"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +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; + + install(): void { + if (this.installed) return; + this.installed = true; + + const transport = ((message: ElectronLog.LogMessage) => { + 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..8fbbc7890 --- /dev/null +++ b/apps/code/src/main/services/dev-network/service.ts @@ -0,0 +1,160 @@ +import { injectable } from "inversify"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +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; + + install(): void { + if (this.installed) return; + this.installed = true; + this.wrapFetch(); + log.info("Network instrumentation installed"); + } + + 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 => { + 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; + } + }; + + Object.defineProperty(wrapped, "preconnect", { + value: original.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/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..3f4882c74 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 { installMainThreadHealth } from "@features/dev-toolbar/mainThreadHealth"; 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,22 @@ function App() { return initializeUpdateStore(); }, []); + // Install main-thread health observers (longtasks + FPS) for the dev toolbar. + useEffect(() => installMainThreadHealth(), []); + + // Surface dev-toolbar triggered toasts (e.g. quick actions test toasts). + useSubscription( + trpcReact.dev.onDevToast.subscriptionOptions(undefined, { + onData: (data) => { + if (data.variant === "error") { + toast.error(data.message); + } else { + toast.info(data.message); + } + }, + }), + ); + // Dev-only inbox demo command for local QA from the renderer console. useEffect(() => { if (import.meta.env.PROD) { @@ -226,7 +244,11 @@ function App() { const renderContent = () => { if (!hasCompletedOnboarding) { return ( - + ); @@ -234,7 +256,7 @@ function App() { if (!isAuthenticated) { return ( - + ); @@ -242,8 +264,12 @@ function App() { if (isCheckingAccess) { return ( - - + + Checking access... @@ -255,7 +281,11 @@ function App() { if (needsInviteCode) { return ( - + ); @@ -263,7 +293,11 @@ function App() { if (needsAiApproval) { return ( - + @@ -292,18 +327,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..0a5298781 --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx @@ -0,0 +1,888 @@ +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 { useEffect, useMemo, useRef, useState } from "react"; +import type { MetricsSample } from "../../../../main/services/dev-metrics/schemas"; +import { REGION_LABELS } from "../../../../shared/types/regions"; +import { subscribeDevFlagsFromMain, 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); + + useEffect(() => subscribeDevFlagsFromMain(), []); + + 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 ( +
+ ); +} + +function EnvironmentBadge() { + const isDev = import.meta.env.DEV; + const label = isDev ? "dev" : "prod"; + const dot = isDev ? "bg-(--green-9)" : "bg-(--red-9)"; + return ( + + + + {label} + + + ); +} + +function RegionBadge() { + const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + if (!cloudRegion) return null; + const entry = REGION_LABELS[cloudRegion]; + return ( + + {entry.flag} + + {cloudRegion.toUpperCase()} + + + ); +} + +function UserMenu() { + const isAuthenticated = useAuthStateValue( + (s) => s.status === "authenticated", + ); + const client = useOptionalAuthenticatedClient(); + const { data: user } = useCurrentUser({ client, enabled: isAuthenticated }); + const logoutMutation = useLogoutMutation(); + const openSettings = useSettingsDialogStore((s) => s.open); + + const handleResetOnboarding = () => { + useOnboardingStore.getState().resetOnboarding(); + useSetupStore.getState().resetSetup(); + }; + + const handleResetTours = () => { + useTourStore.getState().resetTours(); + }; + + const handleSignOut = () => { + logoutMutation.mutate(); + }; + + const emailShort = user?.email + ? user.email.split("@")[0] + : isAuthenticated + ? "user" + : "anon"; + + return ( + + + {emailShort} + + + } + /> + + {isAuthenticated && user && ( + + + + {(user.first_name || user.last_name) && ( + + {[user.first_name, user.last_name] + .filter(Boolean) + .join(" ")} + + )} + + {user.email} + + + + + )} + + openSettings("advanced")}> + + Open advanced settings + + + + Reset onboarding + + + + Reset product tours + + + + + + + Clear application storage + + {isAuthenticated && ( + + + Sign out + + )} + + + + ); +} + +interface DevGadgetsProps { + reactScanEnabled: boolean; + onToggleReactScan: () => void; +} + +function DevGadgets({ reactScanEnabled, onToggleReactScan }: DevGadgetsProps) { + const isDarkMode = useThemeStore((s) => s.isDarkMode); + const setTheme = useThemeStore((s) => s.setTheme); + + return ( + + setTheme(isDarkMode ? "light" : "dark")} + active={false} + > + {isDarkMode ? : } + + + + + + ); +} + +function GadgetButton({ + label, + onClick, + active, + children, +}: { + label: string; + onClick: () => void; + active: boolean; + children: React.ReactNode; +}) { + return ( + + + + ); +} + +interface LiveStatsProps { + openPanel: DetailPanel; + onToggleCpu: () => void; + onToggleMemory: () => void; + onToggleIpc: () => void; + onToggleHealth: () => void; + onToggleNetwork: () => void; + onToggleAgents: () => void; + onToggleLogs: () => void; +} + +const NETWORK_PILL_WINDOW_MS = 60_000; + +function LiveStats({ + openPanel, + onToggleCpu, + onToggleMemory, + onToggleIpc, + onToggleHealth, + onToggleNetwork, + onToggleAgents, + onToggleLogs, +}: LiveStatsProps) { + const trpcReact = useTRPC(); + const devMode = useDevFlagsStore((s) => s.devMode); + const reactScanEnabled = useDevFlagsStore((s) => s.reactScanEnabled); + const updatesEnabled = devMode && !reactScanEnabled; + const [sample, setSample] = useState(null); + const [netTimestamps, setNetTimestamps] = useState([]); + const [logWarnings, setLogWarnings] = useState(0); + const fps = useMainThreadHealthStore((s) => s.fps); + const longTaskCount = useMainThreadHealthStore((s) => s.longTaskCount); + const ipcEntries = useIpcMetricsStore((s) => s.entries); + const ipcInFlight = useIpcMetricsStore((s) => s.inFlight); + + const { data: agentsData } = useQuery({ + ...trpcReact.dev.getAgentsSnapshot.queryOptions(), + enabled: devMode, + refetchInterval: devMode ? 2000 : false, + }); + const activeAgents = agentsData?.sessions.length ?? 0; + const pendingPerms = agentsData?.pendingPermissions.length ?? 0; + + useSubscription( + trpcReact.dev.onMetrics.subscriptionOptions(undefined, { + enabled: updatesEnabled, + onData: setSample, + }), + ); + + useSubscription( + trpcReact.dev.onNetworkRequest.subscriptionOptions(undefined, { + enabled: devMode, + onData: () => { + const now = Date.now(); + setNetTimestamps((prev) => { + const next = [...prev, now]; + const cutoff = now - NETWORK_PILL_WINDOW_MS; + return next.filter((t) => t >= cutoff); + }); + }, + }), + ); + + useSubscription( + trpcReact.dev.onLogEntry.subscriptionOptions(undefined, { + enabled: devMode, + onData: (entry) => { + if (entry.level === "error" || entry.level === "warn") { + setLogWarnings((n) => n + 1); + } + }, + }), + ); + + const ipcRecentAvg = useMemo(() => { + if (ipcEntries.length === 0) return null; + const cutoff = Date.now() - 5000; + const recent = ipcEntries.filter((e) => e.startedAt >= cutoff); + if (recent.length === 0) return null; + const total = recent.reduce((sum, e) => sum + e.rttMs, 0); + return total / recent.length; + }, [ipcEntries]); + + const memoryGb = sample ? sample.totalMemoryMb / 1024 : null; + const ipcLastColor = + ipcRecentAvg == null + ? undefined + : ipcRecentAvg > 100 + ? ("red" as const) + : ipcRecentAvg > 30 + ? ("amber" as const) + : undefined; + + const memoryDisplay = + memoryGb != null + ? memoryGb >= 1 + ? `${memoryGb.toFixed(2)}GB` + : `${(memoryGb * 1024).toFixed(0)}MB` + : "—"; + const heapDisplay = sample ? `${sample.heapUsedMb.toFixed(0)}MB heap` : null; + + const loopLagMs = sample?.loopLagMs ?? null; + const loopColor = + loopLagMs == null + ? undefined + : loopLagMs > 50 + ? ("red" as const) + : loopLagMs > 20 + ? ("amber" as const) + : fps < 30 + ? ("red" as const) + : fps < 50 + ? ("amber" as const) + : undefined; + + const netCount = netTimestamps.length; + const agentsValue = + activeAgents === 0 && pendingPerms === 0 + ? "0" + : pendingPerms > 0 + ? `${activeAgents} · ${pendingPerms}!` + : `${activeAgents}`; + const agentsEmphasis = pendingPerms > 0 ? ("amber" as const) : undefined; + const logsEmphasis = logWarnings > 0 ? ("amber" as const) : undefined; + + return ( + + } + active={openPanel === "cpu"} + onClick={onToggleCpu} + emphasis={ + sample && sample.totalCpuPercent > 50 + ? "red" + : sample && sample.totalCpuPercent > 20 + ? "amber" + : undefined + } + /> + } + active={openPanel === "memory"} + onClick={onToggleMemory} + /> + 0 + ? `${ipcInFlight} in flight · 5s avg RTT` + : "5s avg RTT" + } + icon={} + active={openPanel === "ipc"} + onClick={onToggleIpc} + emphasis={ipcLastColor} + /> + } + active={openPanel === "health"} + onClick={onToggleHealth} + emphasis={loopColor} + /> + } + active={openPanel === "network"} + onClick={onToggleNetwork} + /> + 0 + ? `${pendingPerms} pending permission${pendingPerms === 1 ? "" : "s"}` + : undefined + } + icon={} + active={openPanel === "agents"} + onClick={onToggleAgents} + emphasis={agentsEmphasis} + /> + 0 ? formatCompact(logWarnings) : "0"} + tooltip="warn + error since panel opened" + icon={} + active={openPanel === "logs"} + onClick={onToggleLogs} + emphasis={logsEmphasis} + /> + + ); +} + +function StatPill({ + label, + value, + icon, + active, + onClick, + emphasis, + tooltip, +}: { + label: string; + value: string; + icon: React.ReactNode; + active: boolean; + onClick: () => void; + emphasis?: "red" | "amber"; + tooltip?: string; +}) { + const valueColor = + emphasis === "red" + ? "text-(--red-11)" + : emphasis === "amber" + ? "text-(--amber-11)" + : "text-(--gray-12)"; + const pill = ( + + ); + return tooltip ? {pill} : pill; +} + +function formatCompact(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; + return String(value); +} + +function formatRttCompact(ms: number): string { + if (ms < 1) return `${(ms * 1000).toFixed(0)}μs`; + if (ms < 10) return `${ms.toFixed(1)}ms`; + return `${ms.toFixed(0)}ms`; +} + +const SLOW_PRESETS_MS = [0, 250, 1000, 3000] as const; + +function QuickActionsMenu() { + const trpcReact = useTRPC(); + const { data: sim, refetch: refetchSim } = useQuery({ + ...trpcReact.dev.getNetworkSim.queryOptions(), + }); + useSubscription( + trpcReact.dev.onNetworkSimChanged.subscriptionOptions(undefined, { + onData: () => void refetchSim(), + }), + ); + + const offline = sim?.offline ?? false; + const slowMs = sim?.slowDelayMs ?? 0; + + const setOffline = (next: boolean) => + void trpcClient.dev.setNetworkSim.mutate({ offline: next }); + const setSlow = (ms: number) => + void trpcClient.dev.setNetworkSim.mutate({ slowDelayMs: ms }); + + const triggerInfoToast = () => + void trpcClient.dev.triggerToast.mutate({ + variant: "info", + message: "Dev toast (info) from quick actions", + }); + const triggerErrorToast = () => + void trpcClient.dev.triggerToast.mutate({ + variant: "error", + message: "Dev toast (error) from quick actions", + }); + + const handleCrash = () => { + const ok = window.confirm( + "Crash the main process? This will exit the app without saving in-flight work.", + ); + if (ok) void trpcClient.dev.crashMain.mutate(); + }; + + const handleRestart = () => { + const ok = window.confirm("Restart the main process now?"); + if (ok) void trpcClient.dev.restartMain.mutate(); + }; + + return ( + + + + + + } + /> + + + Open + void trpcClient.dev.openUserDataDir.mutate()} + > + + Open user data dir + + void trpcClient.dev.openLogFile.mutate()} + > + + Open log file + + + + + Process + void trpcClient.dev.reloadRenderer.mutate()} + > + + Reload renderer + + + + Restart main process + + + + Crash main (test crash reporting) + + + + + Simulate + setOffline(!offline)}> + + {offline ? "Disable offline mode" : "Simulate offline"} + + {SLOW_PRESETS_MS.map((ms) => ( + setSlow(ms)}> + + {ms === 0 ? "Disable network delay" : `Add ${ms}ms network delay`} + {ms === slowMs && ( + + active + + )} + + ))} + + + + Toasts + + + Trigger info toast + + + + Trigger error toast + + + + + ); +} diff --git a/apps/code/src/renderer/features/dev-toolbar/components/HealthPanel.tsx b/apps/code/src/renderer/features/dev-toolbar/components/HealthPanel.tsx new file mode 100644 index 000000000..5cfc905a4 --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/components/HealthPanel.tsx @@ -0,0 +1,256 @@ +import { Button, Flex, Text } from "@radix-ui/themes"; +import { useTRPC } from "@renderer/trpc/client"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { Activity, Gauge, Timer } from "lucide-react"; +import { useMemo, useState } from "react"; +import type { MetricsSample } from "../../../../main/services/dev-metrics/schemas"; +import { useMainThreadHealthStore } from "../mainThreadHealth"; + +const HISTORY_LENGTH = 60; + +interface HealthPanelProps { + enabled: boolean; +} + +export function HealthPanel({ enabled }: HealthPanelProps) { + const trpcReact = useTRPC(); + const [loopLagHistory, setLoopLagHistory] = useState([]); + const [loopLagMaxHistory, setLoopLagMaxHistory] = useState([]); + const fps = useMainThreadHealthStore((s) => s.fps); + const longTasks = useMainThreadHealthStore((s) => s.longTasks); + const longTaskCount = useMainThreadHealthStore((s) => s.longTaskCount); + const resetLongTasks = useMainThreadHealthStore((s) => s.reset); + + useSubscription( + trpcReact.dev.onMetrics.subscriptionOptions(undefined, { + enabled, + onData: (sample: MetricsSample) => { + setLoopLagHistory((prev) => + appendHistory(prev, sample.loopLagMs, HISTORY_LENGTH), + ); + setLoopLagMaxHistory((prev) => + appendHistory(prev, sample.loopLagMaxMs, HISTORY_LENGTH), + ); + }, + }), + ); + + const loopLagCurrent = loopLagHistory[loopLagHistory.length - 1] ?? 0; + const loopLagPeak = loopLagMaxHistory.length + ? Math.max(...loopLagMaxHistory) + : 0; + + const recentLongTasks = useMemo( + () => [...longTasks].reverse().slice(0, 20), + [longTasks], + ); + + return ( + +
+ } + title="Main loop lag" + value={`${loopLagCurrent.toFixed(0)}ms`} + accent={ + loopLagCurrent > 50 + ? "red" + : loopLagCurrent > 20 + ? "amber" + : undefined + } + subline={`peak ${loopLagPeak.toFixed(0)}ms`} + history={loopLagHistory} + ymax={Math.max(20, loopLagPeak)} + /> + } + title="Renderer FPS" + value={`${fps}`} + accent={fps < 30 ? "red" : fps < 50 ? "amber" : undefined} + subline="last second" + history={null} + ymax={60} + /> + } + title="Long tasks" + value={`${longTaskCount}`} + accent={longTaskCount > 0 ? "amber" : undefined} + subline="> 50ms blocking" + history={null} + ymax={1} + /> +
+ + + + + Recent long tasks (renderer) + + + + {recentLongTasks.length === 0 ? ( + + None captured. Long tasks are renderer-blocking work over 50ms. + + ) : ( +
+ + Time + + + Name + + + Duration + + {recentLongTasks.map((t) => ( + + ))} +
+ )} +
+
+ ); +} + +function LongTaskRow({ + durationMs, + name, + startedAt, +}: { + durationMs: number; + name: string; + startedAt: number; +}) { + const date = new Date(startedAt); + const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad( + date.getSeconds(), + )}`; + const color = + durationMs > 200 ? "red" : durationMs > 100 ? "amber" : undefined; + return ( + <> + + {time} + + + {name} + + + {durationMs.toFixed(0)}ms + + + ); +} + +interface HealthCardProps { + icon: React.ReactNode; + title: string; + value: string; + accent: "red" | "amber" | undefined; + subline: string; + history: number[] | null; + ymax: number; +} + +function HealthCard({ + icon, + title, + value, + accent, + subline, + history, + ymax, +}: HealthCardProps) { + const valueColor = + accent === "red" + ? "text-(--red-11)" + : accent === "amber" + ? "text-(--amber-11)" + : "text-(--gray-12)"; + const lineClass = + accent === "red" + ? "text-(--red-9)" + : accent === "amber" + ? "text-(--amber-9)" + : "text-(--accent-9)"; + return ( +
+ + {icon} + + {title} + + + + + {value} + + {history && history.length > 0 && ( + + )} + + {subline} + + +
+ ); +} + +function Sparkline({ + history, + ymax, + lineClass, +}: { + history: number[]; + ymax: number; + lineClass: string; +}) { + const width = 200; + const height = 36; + const max = Math.max(1, ymax); + const step = history.length > 1 ? width / (history.length - 1) : width; + const points = history + .map((v, i) => `${i * step},${height - (v / max) * (height - 4) - 2}`) + .join(" "); + return ( + + history + + + ); +} + +function appendHistory(prev: number[], value: number, max: number): number[] { + const next = [...prev, value]; + return next.length > max ? next.slice(next.length - max) : next; +} + +function pad(n: number): string { + return n.toString().padStart(2, "0"); +} diff --git a/apps/code/src/renderer/features/dev-toolbar/components/IpcTimingsPanel.tsx b/apps/code/src/renderer/features/dev-toolbar/components/IpcTimingsPanel.tsx new file mode 100644 index 000000000..c1beecddc --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/components/IpcTimingsPanel.tsx @@ -0,0 +1,249 @@ +import { Button, Flex, Text, TextField } from "@radix-ui/themes"; +import { useMemo, useState } from "react"; +import { type IpcTimingEntry, useIpcMetricsStore } from "../ipcMetricsStore"; + +const MAX_DISPLAY = 400; + +interface IpcTimingsPanelProps { + enabled: boolean; +} + +export function IpcTimingsPanel({ enabled }: IpcTimingsPanelProps) { + const entries = useIpcMetricsStore((s) => s.entries); + const inFlight = useIpcMetricsStore((s) => s.inFlight); + const peakInFlight = useIpcMetricsStore((s) => s.peakInFlight); + const clear = useIpcMetricsStore((s) => s.clear); + const [filter, setFilter] = useState(""); + + const filtered = useMemo(() => { + const lower = filter.trim().toLowerCase(); + const rows = lower + ? entries.filter((t) => t.path.toLowerCase().includes(lower)) + : entries; + return rows.slice(-MAX_DISPLAY).reverse(); + }, [entries, filter]); + + const aggregates = useMemo(() => { + const map = new Map< + string, + { + count: number; + totalRtt: number; + maxRtt: number; + totalBytes: number; + } + >(); + for (const t of entries) { + const cur = map.get(t.path) ?? { + count: 0, + totalRtt: 0, + maxRtt: 0, + totalBytes: 0, + }; + cur.count += 1; + cur.totalRtt += t.rttMs; + cur.maxRtt = Math.max(cur.maxRtt, t.rttMs); + cur.totalBytes += t.inputBytes + t.outputBytes; + map.set(t.path, cur); + } + return [...map.entries()] + .map(([path, v]) => ({ + path, + ...v, + avgRtt: v.totalRtt / v.count, + })) + .sort((a, b) => b.totalRtt - a.totalRtt) + .slice(0, 12); + }, [entries]); + + if (!enabled) { + return ( + + + Enable dev mode to capture IPC traffic. + + + ); + } + + return ( + + + setFilter(e.target.value)} + className="flex-1" + /> + + + 5 ? "amber" : undefined} + /> + 10 ? "amber" : undefined} + /> + + + + + + Recent + +
+ + Path + + + Type + + + RTT + + + Payload + + {filtered.map((t) => ( + + ))} +
+
+ + + + Top by total RTT + +
+ + Path + + + Count + + + Avg + + + Max + + + Bytes + + {aggregates.map((a) => ( + + ))} +
+
+
+
+ ); +} + +function TimingRow({ timing }: { timing: IpcTimingEntry }) { + const tone = rttTone(timing.rttMs); + return ( + <> + + {timing.path} + + + {abbreviateType(timing.type)} + + + {formatRtt(timing.rttMs)} + + + {formatBytes(timing.inputBytes + timing.outputBytes)} + + + ); +} + +function AggregateRow({ + path, + count, + avgRtt, + maxRtt, + totalBytes, +}: { + path: string; + count: number; + avgRtt: number; + maxRtt: number; + totalBytes: number; +}) { + return ( + <> + + {path} + + {count} + + {formatRtt(avgRtt)} + + + {formatRtt(maxRtt)} + + + {formatBytes(totalBytes)} + + + ); +} + +function StatChip({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: "amber" | "red"; +}) { + const color = + tone === "red" + ? "text-(--red-11) bg-(--red-3)" + : tone === "amber" + ? "text-(--amber-11) bg-(--amber-3)" + : "text-(--gray-11) bg-(--gray-3)"; + return ( + + {label} + {value} + + ); +} + +function formatRtt(ms: number): string { + if (ms < 1) return `${(ms * 1000).toFixed(0)}μs`; + if (ms < 10) return `${ms.toFixed(2)}ms`; + if (ms < 100) return `${ms.toFixed(1)}ms`; + return `${ms.toFixed(0)}ms`; +} + +function rttTone(ms: number): "red" | "amber" | undefined { + if (ms > 200) return "red"; + if (ms > 50) return "amber"; + return undefined; +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n}B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`; + return `${(n / 1024 / 1024).toFixed(2)}MB`; +} + +function abbreviateType(t: IpcTimingEntry["type"]): string { + if (t === "query") return "q"; + if (t === "mutation") return "m"; + return "sub"; +} diff --git a/apps/code/src/renderer/features/dev-toolbar/components/LogsPanel.tsx b/apps/code/src/renderer/features/dev-toolbar/components/LogsPanel.tsx new file mode 100644 index 000000000..38e635903 --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/components/LogsPanel.tsx @@ -0,0 +1,194 @@ +import { + Button, + Flex, + Select, + Switch, + Text, + TextField, +} from "@radix-ui/themes"; +import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { Copy, Pause, Play } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { LogEntry } from "../../../../main/services/dev-logs/schemas"; + +const MAX_DISPLAY = 1000; +const LEVELS = ["error", "warn", "info", "debug", "verbose", "silly"] as const; +type LevelFilter = "all" | (typeof LEVELS)[number]; + +interface LogsPanelProps { + enabled: boolean; +} + +export function LogsPanel({ enabled }: LogsPanelProps) { + const trpcReact = useTRPC(); + const [entries, setEntries] = useState([]); + const [filter, setFilter] = useState(""); + const [levelFilter, setLevelFilter] = useState("all"); + const [paused, setPaused] = useState(false); + const [autoScroll, setAutoScroll] = useState(true); + const scrollRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + void trpcClient.dev.getLogs.query().then((snap) => { + if (!cancelled) setEntries(snap.entries); + }); + return () => { + cancelled = true; + }; + }, [enabled]); + + useSubscription( + trpcReact.dev.onLogEntry.subscriptionOptions(undefined, { + enabled: enabled && !paused, + onData: (entry) => { + setEntries((prev) => { + const next = [...prev, entry]; + return next.length > MAX_DISPLAY + ? next.slice(next.length - MAX_DISPLAY) + : next; + }); + }, + }), + ); + + const filtered = useMemo(() => { + const lower = filter.trim().toLowerCase(); + return entries.filter((e) => { + if (levelFilter !== "all" && e.level !== levelFilter) return false; + if (lower) { + if ( + !e.message.toLowerCase().includes(lower) && + !(e.scope?.toLowerCase().includes(lower) ?? false) + ) { + return false; + } + } + return true; + }); + }, [entries, filter, levelFilter]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new entries + useEffect(() => { + if (!autoScroll || !scrollRef.current) return; + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + }, [filtered.length, autoScroll]); + + const copyAsJsonl = () => { + const jsonl = filtered.map((e) => JSON.stringify(e)).join("\n"); + void navigator.clipboard.writeText(jsonl); + }; + + return ( + + + setFilter(e.target.value)} + className="flex-1 min-w-[180px]" + /> + setLevelFilter(v as LevelFilter)} + > + + + All levels + {LEVELS.map((l) => ( + + {l} + + ))} + + + + + + Follow + + + + + + {filtered.length}/{entries.length} + + + +
+
+ {filtered.map((entry) => ( + + ))} +
+
+
+ ); +} + +function LogRow({ entry }: { entry: LogEntry }) { + const date = new Date(entry.capturedAt); + const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad( + date.getSeconds(), + )}`; + const levelColor = + entry.level === "error" + ? "text-(--red-11)" + : entry.level === "warn" + ? "text-(--amber-11)" + : entry.level === "debug" || entry.level === "verbose" + ? "text-(--gray-10)" + : "text-(--gray-12)"; + return ( + <> + {time} + {entry.level} + + {entry.scope ?? "—"} + + + {entry.message} + + + ); +} + +function pad(n: number): string { + return n.toString().padStart(2, "0"); +} diff --git a/apps/code/src/renderer/features/dev-toolbar/components/MemoryPanel.tsx b/apps/code/src/renderer/features/dev-toolbar/components/MemoryPanel.tsx new file mode 100644 index 000000000..3f14b6541 --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/components/MemoryPanel.tsx @@ -0,0 +1,198 @@ +import { Flex, Text } from "@radix-ui/themes"; +import { MemoryStick } from "lucide-react"; +import { useMemo } from "react"; +import { + CardSparkline, + formatMemory, + InfoStat, + ProcessTable, + ProfilingTip, + StatusBadge, + trendOf, + useMetricsHistory, +} from "./MetricsCommon"; + +interface MemoryPanelProps { + enabled: boolean; +} + +type MemStatus = "healthy" | "warm" | "tight" | "pressure"; + +function statusFor(heapPct: number): MemStatus { + if (heapPct >= 90) return "pressure"; + if (heapPct >= 75) return "tight"; + if (heapPct >= 50) return "warm"; + return "healthy"; +} + +const STATUS_META: Record< + MemStatus, + { + label: string; + level: "ok" | "warn" | "crit"; + valueColor: string; + lineClass: string; + barClass: string; + emphasis?: "red" | "amber"; + hint: string; + } +> = { + healthy: { + label: "Healthy", + level: "ok", + valueColor: "text-(--gray-12)", + lineClass: "text-(--accent-9)", + barClass: "bg-(--accent-9)", + hint: "Heap has room to grow.", + }, + warm: { + label: "Warm", + level: "ok", + valueColor: "text-(--gray-12)", + lineClass: "text-(--accent-9)", + barClass: "bg-(--accent-9)", + hint: "Half the heap in use. Normal for active sessions.", + }, + tight: { + label: "Tight", + level: "warn", + valueColor: "text-(--amber-11)", + lineClass: "text-(--amber-9)", + barClass: "bg-(--amber-9)", + emphasis: "amber", + hint: "Heap getting full. GC pressure rising.", + }, + pressure: { + label: "Pressure", + level: "crit", + valueColor: "text-(--red-11)", + lineClass: "text-(--red-9)", + barClass: "bg-(--red-9)", + emphasis: "red", + hint: "Near heap limit. Snapshot now.", + }, +}; + +export function MemoryPanel({ enabled }: MemoryPanelProps) { + const { sample, history } = useMetricsHistory(enabled); + + const memHistory = useMemo( + () => history.map((h) => h.totalMemoryMb), + [history], + ); + const heapHistory = useMemo( + () => history.map((h) => h.heapUsedMb), + [history], + ); + const memPeak = memHistory.length ? Math.max(...memHistory) : 0; + const memAvg = memHistory.length + ? memHistory.reduce((a, b) => a + b, 0) / memHistory.length + : 0; + const heapTrend = trendOf(heapHistory); + const biggest = useMemo(() => { + if (!sample || sample.processes.length === 0) return null; + return [...sample.processes].sort((a, b) => b.memoryMb - a.memoryMb)[0]; + }, [sample]); + + if (!sample) { + return ( + + + Waiting for memory samples... + + + ); + } + + const heapPct = + sample.heapTotalMb > 0 ? (sample.heapUsedMb / sample.heapTotalMb) * 100 : 0; + const headroomMb = Math.max(0, sample.heapTotalMb - sample.heapUsedMb); + const status = statusFor(heapPct); + const meta = STATUS_META[status]; + const trendLabel = + heapTrend === "up" + ? "↑ heap growing" + : heapTrend === "down" + ? "↓ heap shrinking" + : "→ heap stable"; + + return ( + +
+ + + + + + Memory + + {meta.label} + + {trendLabel} + + +
+ + + {formatMemory(sample.totalMemoryMb)} + + + {meta.hint} + + + + + +
+
+
+ + {heapPct.toFixed(0)}% heap + + + +
+
+ + + + +
+
+ + + + + + ); +} diff --git a/apps/code/src/renderer/features/dev-toolbar/components/MetricsCommon.tsx b/apps/code/src/renderer/features/dev-toolbar/components/MetricsCommon.tsx new file mode 100644 index 000000000..183448ce5 --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/components/MetricsCommon.tsx @@ -0,0 +1,298 @@ +import { Flex, Text } from "@radix-ui/themes"; +import { useTRPC } from "@renderer/trpc/client"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { Lightbulb } from "lucide-react"; +import { useState } from "react"; +import type { + MetricsSample, + ProcessSample, +} from "../../../../main/services/dev-metrics/schemas"; + +interface TipItem { + label: string; + detail: string; +} + +const CPU_TIPS: TipItem[] = [ + { + label: "Renderer profile", + detail: "Cmd+Opt+I → Performance → Record while reproducing", + }, + { + label: "Main process", + detail: "ELECTRON_RUN_AS_NODE=1 electron --inspect, then chrome://inspect", + }, + { + label: "System sample", + detail: "Activity Monitor → Sample Process, or sample 10", + }, +]; + +const MEMORY_TIPS: TipItem[] = [ + { + label: "Heap snapshot", + detail: "Cmd+Opt+I → Memory → Heap snapshot, diff two to find growth", + }, + { + label: "Detached DOM", + detail: 'In Heap, filter by "Detached" to find leaked nodes', + }, + { + label: "System view", + detail: "Activity Monitor → Memory, compare Memory vs Compressed", + }, +]; + +export const HISTORY_LENGTH = 60; + +export function useMetricsHistory(enabled: boolean) { + const trpcReact = useTRPC(); + const [sample, setSample] = useState(null); + const [history, setHistory] = useState([]); + + useSubscription( + trpcReact.dev.onMetrics.subscriptionOptions(undefined, { + enabled, + onData: (data) => { + setSample(data); + setHistory((prev) => { + const next = [...prev, data]; + return next.length > HISTORY_LENGTH + ? next.slice(next.length - HISTORY_LENGTH) + : next; + }); + }, + }), + ); + + return { sample, history }; +} + +export function formatMemory(mb: number): string { + if (mb >= 1024) return `${(mb / 1024).toFixed(2)}GB`; + return `${mb.toFixed(0)}MB`; +} + +export function trendOf(values: number[]): "up" | "down" | "flat" { + if (values.length < 8) return "flat"; + const half = Math.floor(values.length / 2); + const earlier = values.slice(0, half); + const later = values.slice(half); + const avg = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length; + const diff = avg(later) - avg(earlier); + const base = Math.max(1, avg(earlier)); + const pct = diff / base; + if (pct > 0.15) return "up"; + if (pct < -0.15) return "down"; + return "flat"; +} + +interface StatusBadgeProps { + level: "ok" | "warn" | "crit"; + children: React.ReactNode; +} + +export function StatusBadge({ level, children }: StatusBadgeProps) { + const styles = + level === "crit" + ? "bg-(--red-3) text-(--red-11)" + : level === "warn" + ? "bg-(--amber-3) text-(--amber-11)" + : "bg-(--green-3) text-(--green-11)"; + return ( + + {children} + + ); +} + +interface InfoStatProps { + label: string; + value: string; + hint?: string; + emphasis?: "red" | "amber"; +} + +export function InfoStat({ label, value, hint, emphasis }: InfoStatProps) { + const valueColor = + emphasis === "red" + ? "text-(--red-11)" + : emphasis === "amber" + ? "text-(--amber-11)" + : "text-(--gray-12)"; + return ( + + + {label} + + + {value} + + {hint && ( + + {hint} + + )} + + ); +} + +interface CardSparklineProps { + history: number[]; + secondaryHistory?: number[]; + ymax: number; + lineClass: string; + unit: string; + height?: number; +} + +export function CardSparkline({ + history, + secondaryHistory, + ymax, + lineClass, + unit, + height = 64, +}: CardSparklineProps) { + const width = 320; + const max = Math.max(1, ymax); + const step = history.length > 1 ? width / (history.length - 1) : width; + + const linePoints = history + .map((v, i) => `${i * step},${height - (v / max) * (height - 4) - 2}`) + .join(" "); + const areaPoints = `0,${height} ${linePoints} ${(history.length - 1) * step},${height}`; + const secondaryPoints = secondaryHistory + ? secondaryHistory + .map((v, i) => `${i * step},${height - (v / max) * (height - 4) - 2}`) + .join(" ") + : null; + + return ( + + {`${unit} history`} + + + {secondaryPoints && ( + + )} + + ); +} + +interface ProcessTableProps { + processes: ProcessSample[]; + sortBy: "cpu" | "memory"; +} + +export function ProcessTable({ processes, sortBy }: ProcessTableProps) { + const ranked = [...processes].sort((a, b) => + sortBy === "cpu" ? b.cpuPercent - a.cpuPercent : b.memoryMb - a.memoryMb, + ); + return ( + + + Processes + +
+
+ Process + PID + Memory + CPU +
+
+ {ranked.map((p) => ( + + ))} +
+
+
+ ); +} + +function ProcessRow({ pid, type, name, cpuPercent, memoryMb }: ProcessSample) { + return ( +
+ + {name ? `${type}: ${name}` : type} + + + {pid} + + + {memoryMb.toFixed(0)} MB + + 25 ? "red" : cpuPercent > 5 ? "amber" : undefined} + > + {cpuPercent.toFixed(1)}% + +
+ ); +} + +interface ProfilingTipProps { + topic: "cpu" | "memory"; +} + +export function ProfilingTip({ topic }: ProfilingTipProps) { + const tips = topic === "cpu" ? CPU_TIPS : MEMORY_TIPS; + const heading = + topic === "cpu" ? "Dig into CPU hotspots" : "Dig into memory pressure"; + + return ( +
+ + + + + + {heading} + + +
+ {tips.map((tip) => ( +
+
{tip.label}
+
{tip.detail}
+
+ ))} +
+
+ ); +} diff --git a/apps/code/src/renderer/features/dev-toolbar/components/NetworkPanel.tsx b/apps/code/src/renderer/features/dev-toolbar/components/NetworkPanel.tsx new file mode 100644 index 000000000..3f4331de6 --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/components/NetworkPanel.tsx @@ -0,0 +1,240 @@ +import { Button, Flex, Switch, Text, TextField } from "@radix-ui/themes"; +import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { useEffect, useMemo, useState } from "react"; +import type { + NetworkRequest, + NetworkSim, +} from "../../../../main/services/dev-network/schemas"; + +const MAX_DISPLAY = 400; + +interface NetworkPanelProps { + enabled: boolean; +} + +export function NetworkPanel({ enabled }: NetworkPanelProps) { + const trpcReact = useTRPC(); + const [requests, setRequests] = useState([]); + const [filter, setFilter] = useState(""); + const [sim, setSim] = useState({ + offline: false, + slowDelayMs: 0, + }); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + void trpcClient.dev.getNetworkRequests.query().then((snap) => { + if (!cancelled) setRequests(snap.requests); + }); + void trpcClient.dev.getNetworkSim.query().then((s) => { + if (!cancelled) setSim(s); + }); + return () => { + cancelled = true; + }; + }, [enabled]); + + useSubscription( + trpcReact.dev.onNetworkRequest.subscriptionOptions(undefined, { + enabled, + onData: (req) => { + setRequests((prev) => { + const next = [...prev, req]; + return next.length > MAX_DISPLAY + ? next.slice(next.length - MAX_DISPLAY) + : next; + }); + }, + }), + ); + + useSubscription( + trpcReact.dev.onNetworkSimChanged.subscriptionOptions(undefined, { + enabled, + onData: (s) => setSim(s), + }), + ); + + const filtered = useMemo(() => { + const lower = filter.trim().toLowerCase(); + const rows = lower + ? requests.filter( + (r) => + r.url.toLowerCase().includes(lower) || + r.host.toLowerCase().includes(lower) || + String(r.status ?? "").includes(lower), + ) + : requests; + return [...rows].reverse(); + }, [requests, filter]); + + const byHost = useMemo(() => { + const map = new Map< + string, + { count: number; total: number; errors: number } + >(); + for (const r of requests) { + const cur = map.get(r.host) ?? { count: 0, total: 0, errors: 0 }; + cur.count += 1; + cur.total += r.durationMs; + if (!r.ok) cur.errors += 1; + map.set(r.host, cur); + } + return [...map.entries()] + .map(([host, v]) => ({ host, ...v, avg: v.total / v.count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + }, [requests]); + + return ( + + + setFilter(e.target.value)} + className="flex-1 min-w-[180px]" + /> + + { + void trpcClient.dev.setNetworkSim.mutate({ offline: checked }); + }} + /> + Offline + + + + Delay + + { + const ms = Math.max(0, Number(e.target.value) || 0); + void trpcClient.dev.setNetworkSim.mutate({ slowDelayMs: ms }); + }} + style={{ width: 60 }} + /> + + ms + + + + + {requests.length} captured + + + + + + + Recent + +
+ {filtered.map((r) => ( + + ))} +
+
+ + + + Top hosts + +
+ + Host + + + Count + + + Avg + + + Err + + {byHost.map((h) => ( + + ))} +
+
+
+
+ ); +} + +function RequestRow({ req }: { req: NetworkRequest }) { + const statusColor = + req.status == null + ? "red" + : req.status >= 500 + ? "red" + : req.status >= 400 + ? "amber" + : undefined; + const durColor = + req.durationMs > 1000 ? "red" : req.durationMs > 300 ? "amber" : undefined; + return ( + <> + + {req.method} + + + {req.status ?? "ERR"} + + + {req.host || req.url} + + + {req.durationMs.toFixed(0)}ms + + + ); +} + +function HostRow({ + host, + count, + avg, + errors, +}: { + host: string; + count: number; + avg: number; + errors: number; +}) { + return ( + <> + + {host || "(unknown)"} + + {count} + 1000 ? "red" : avg > 300 ? "amber" : undefined} + > + {avg.toFixed(0)}ms + + 0 ? "red" : undefined}> + {errors} + + + ); +} diff --git a/apps/code/src/renderer/features/dev-toolbar/devFlagsStore.ts b/apps/code/src/renderer/features/dev-toolbar/devFlagsStore.ts new file mode 100644 index 000000000..88e0ab46d --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/devFlagsStore.ts @@ -0,0 +1,45 @@ +import { trpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; +import { create } from "zustand"; +import { BOOT_DEV_FLAGS } from "./devModeBoot"; +import { setReactScanEnabled } from "./reactScan"; + +const log = logger.scope("dev-flags-store"); + +interface DevFlagsState { + devMode: boolean; + reactScanEnabled: boolean; + setDevMode: (enabled: boolean) => Promise; + setReactScanEnabled: (enabled: boolean) => void; +} + +export const useDevFlagsStore = create()((set) => ({ + devMode: BOOT_DEV_FLAGS.devMode, + reactScanEnabled: false, + + setDevMode: async (enabled) => { + try { + const updated = await trpcClient.dev.setDevMode.mutate({ enabled }); + set({ devMode: updated.devMode }); + } catch (error) { + log.warn("Failed to set dev mode", { error }); + } + }, + + setReactScanEnabled: (enabled) => { + set({ reactScanEnabled: enabled }); + void setReactScanEnabled(enabled); + }, +})); + +export function subscribeDevFlagsFromMain(): () => void { + const subscription = trpcClient.dev.onFlagsChanged.subscribe(undefined, { + onData: (flags) => { + useDevFlagsStore.setState({ devMode: flags.devMode }); + }, + onError: (error) => { + log.warn("Dev flags subscription error", { error }); + }, + }); + return () => subscription.unsubscribe(); +} diff --git a/apps/code/src/renderer/features/dev-toolbar/devModeBoot.ts b/apps/code/src/renderer/features/dev-toolbar/devModeBoot.ts new file mode 100644 index 000000000..a77c2288b --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/devModeBoot.ts @@ -0,0 +1,16 @@ +interface DevFlagsShape { + devMode: boolean; +} + +function readBootFlags(): DevFlagsShape { + if (typeof window === "undefined") return { devMode: false }; + const raw = window.__posthogCodeDevFlags; + if (!raw || typeof raw !== "object") return { devMode: false }; + return { devMode: raw.devMode === true }; +} + +export const BOOT_DEV_FLAGS: DevFlagsShape = readBootFlags(); + +export function isDevModeAtBoot(): boolean { + return BOOT_DEV_FLAGS.devMode; +} diff --git a/apps/code/src/renderer/features/dev-toolbar/ipcInstrumentationLink.ts b/apps/code/src/renderer/features/dev-toolbar/ipcInstrumentationLink.ts new file mode 100644 index 000000000..a53f2cef9 --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/ipcInstrumentationLink.ts @@ -0,0 +1,84 @@ +import type { TRPCLink } from "@trpc/client"; +import { observable, tap } from "@trpc/server/observable"; +import type { AnyRouter } from "@trpc/server/unstable-core-do-not-import"; +import { type IpcOpType, useIpcMetricsStore } from "./ipcMetricsStore"; + +function byteLength(value: unknown): number { + if (value === undefined) return 0; + try { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; + } catch { + return 0; + } +} + +export function ipcInstrumentationLink< + TRouter extends AnyRouter = AnyRouter, +>(): TRPCLink { + return () => + ({ op, next }) => + observable((observer) => { + const start = performance.now(); + const startedAt = Date.now(); + const inputBytes = byteLength(op.input); + let outputBytes = 0; + let ended = false; + const store = useIpcMetricsStore.getState(); + store.recordStart(); + + function finalize(ok: boolean) { + if (ended) return; + ended = true; + useIpcMetricsStore.getState().recordEnd({ + path: op.path, + type: op.type as IpcOpType, + rttMs: performance.now() - start, + inputBytes, + outputBytes, + ok, + startedAt, + }); + } + + const subscription = next(op) + .pipe( + tap({ + next(envelope) { + if ( + envelope && + typeof envelope === "object" && + "result" in envelope && + envelope.result && + typeof envelope.result === "object" && + "data" in envelope.result + ) { + outputBytes += byteLength( + (envelope.result as { data: unknown }).data, + ); + } + }, + }), + ) + .subscribe({ + next(value) { + observer.next(value); + if (op.type !== "subscription") { + finalize(true); + } + }, + error(err) { + finalize(false); + observer.error(err); + }, + complete() { + finalize(true); + observer.complete(); + }, + }); + + return () => { + finalize(true); + subscription.unsubscribe(); + }; + }); +} diff --git a/apps/code/src/renderer/features/dev-toolbar/ipcMetricsStore.ts b/apps/code/src/renderer/features/dev-toolbar/ipcMetricsStore.ts new file mode 100644 index 000000000..379b17098 --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/ipcMetricsStore.ts @@ -0,0 +1,61 @@ +import { create } from "zustand"; + +export type IpcOpType = "query" | "mutation" | "subscription"; + +export interface IpcTimingEntry { + id: number; + path: string; + type: IpcOpType; + rttMs: number; + inputBytes: number; + outputBytes: number; + ok: boolean; + startedAt: number; +} + +const RING_BUFFER_SIZE = 1000; + +interface IpcMetricsState { + entries: IpcTimingEntry[]; + inFlight: number; + peakInFlight: number; + recordStart: () => void; + recordEnd: (entry: Omit) => void; + clear: () => void; +} + +let nextId = 1; + +export const useIpcMetricsStore = create()((set) => ({ + entries: [], + inFlight: 0, + peakInFlight: 0, + + recordStart: () => + set((state) => { + const inFlight = state.inFlight + 1; + return { + inFlight, + peakInFlight: Math.max(state.peakInFlight, inFlight), + }; + }), + + recordEnd: (entry) => + set((state) => { + const next = [...state.entries, { id: nextId++, ...entry }]; + const trimmed = + next.length > RING_BUFFER_SIZE + ? next.slice(next.length - RING_BUFFER_SIZE) + : next; + return { + entries: trimmed, + inFlight: Math.max(0, state.inFlight - 1), + }; + }), + + clear: () => + set({ + entries: [], + peakInFlight: 0, + }), +})); diff --git a/apps/code/src/renderer/features/dev-toolbar/mainThreadHealth.ts b/apps/code/src/renderer/features/dev-toolbar/mainThreadHealth.ts new file mode 100644 index 000000000..f38e72002 --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/mainThreadHealth.ts @@ -0,0 +1,94 @@ +import { create } from "zustand"; + +interface LongTask { + id: number; + durationMs: number; + name: string; + startedAt: number; +} + +interface MainThreadHealthState { + fps: number; + longTasks: LongTask[]; + longTaskCount: number; + recordLongTask: (durationMs: number, name: string) => void; + setFps: (fps: number) => void; + reset: () => void; +} + +const LONG_TASK_BUFFER = 50; + +export const useMainThreadHealthStore = create()( + (set) => ({ + fps: 60, + longTasks: [], + longTaskCount: 0, + recordLongTask: (durationMs, name) => + set((state) => { + const task: LongTask = { + id: state.longTaskCount + 1, + durationMs, + name, + startedAt: Date.now(), + }; + const next = [...state.longTasks, task]; + return { + longTasks: + next.length > LONG_TASK_BUFFER + ? next.slice(next.length - LONG_TASK_BUFFER) + : next, + longTaskCount: state.longTaskCount + 1, + }; + }), + setFps: (fps) => set({ fps }), + reset: () => set({ longTasks: [], longTaskCount: 0, fps: 60 }), + }), +); + +let installed = false; +let stopFpsLoop: (() => void) | null = null; +let observer: PerformanceObserver | null = null; + +export function installMainThreadHealth(): () => void { + if (installed) return () => undefined; + installed = true; + + try { + observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + useMainThreadHealthStore + .getState() + .recordLongTask(entry.duration, entry.name || "longtask"); + } + }); + observer.observe({ entryTypes: ["longtask"] }); + } catch { + // Browsers without longtask support — silently ignore + } + + let frames = 0; + let lastSecond = performance.now(); + let raf = 0; + const tick = () => { + frames++; + const now = performance.now(); + if (now - lastSecond >= 1000) { + useMainThreadHealthStore + .getState() + .setFps(Math.round((frames * 1000) / (now - lastSecond))); + frames = 0; + lastSecond = now; + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + stopFpsLoop = () => cancelAnimationFrame(raf); + + return () => { + installed = false; + observer?.disconnect(); + observer = null; + stopFpsLoop?.(); + stopFpsLoop = null; + }; +} diff --git a/apps/code/src/renderer/features/dev-toolbar/reactScan.ts b/apps/code/src/renderer/features/dev-toolbar/reactScan.ts new file mode 100644 index 000000000..20ada427a --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/reactScan.ts @@ -0,0 +1,27 @@ +import { logger } from "@utils/logger"; + +const log = logger.scope("react-scan"); + +let initialized = false; +let loadPromise: Promise | null = null; + +async function loadReactScan() { + if (!loadPromise) { + loadPromise = import("react-scan"); + } + return loadPromise; +} + +export async function setReactScanEnabled(enabled: boolean): Promise { + try { + const mod = await loadReactScan(); + if (!initialized) { + mod.scan({ enabled }); + initialized = true; + return; + } + mod.setOptions({ enabled }); + } catch (error) { + log.warn("Failed to toggle react-scan", { error }); + } +} diff --git a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx index 606229da7..55c44c52c 100644 --- a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx +++ b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx @@ -172,7 +172,7 @@ export function SettingsDialog() { return (
diff --git a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx index d2dd8d256..18c596b84 100644 --- a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx @@ -1,3 +1,4 @@ +import { useDevFlagsStore } from "@features/dev-toolbar/devFlagsStore"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { SettingRow } from "@features/settings/components/SettingRow"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; @@ -15,6 +16,8 @@ export function AdvancedSettings() { const setDebugLogsCloudRuns = useSettingsStore( (s) => s.setDebugLogsCloudRuns, ); + const devMode = useDevFlagsStore((s) => s.devMode); + const setDevMode = useDevFlagsStore((s) => s.setDevMode); return ( @@ -64,7 +67,6 @@ export function AdvancedSettings() { )} + + { + void setDevMode(checked); + }} + size="1" + /> + ); } diff --git a/apps/code/src/renderer/trpc/client.ts b/apps/code/src/renderer/trpc/client.ts index 3cd3152c8..1b0e2c5f7 100644 --- a/apps/code/src/renderer/trpc/client.ts +++ b/apps/code/src/renderer/trpc/client.ts @@ -1,3 +1,4 @@ +import { ipcInstrumentationLink } from "@features/dev-toolbar/ipcInstrumentationLink"; import { ipcLink } from "@posthog/electron-trpc/renderer"; import { createTRPCClient } from "@trpc/client"; import { @@ -8,7 +9,7 @@ import { queryClient } from "@utils/queryClient"; import type { TrpcRouter } from "../../main/trpc/router"; export const trpcClient = createTRPCClient({ - links: [ipcLink()], + links: [ipcInstrumentationLink(), ipcLink()], }); const context = createTRPCContext(); diff --git a/apps/code/src/vite-env.d.ts b/apps/code/src/vite-env.d.ts index eb0d376a3..1876183b5 100644 --- a/apps/code/src/vite-env.d.ts +++ b/apps/code/src/vite-env.d.ts @@ -16,3 +16,9 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +interface Window { + readonly __posthogCodeDevFlags?: { + readonly devMode: boolean; + }; +} diff --git a/packages/agent/package.json b/packages/agent/package.json index d836afe26..d8a1c04eb 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -143,8 +143,7 @@ "files": [ "dist/**/*", "src/**/*", - "README.md", - "CLAUDE.md" + "README.md" ], "publishConfig": { "access": "public" diff --git a/packages/platform/package.json b/packages/platform/package.json index 68916d1e2..2f9fbb640 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" + }, + "./app-metrics": { + "types": "./dist/app-metrics.d.ts", + "import": "./dist/app-metrics.js" } }, "scripts": { diff --git a/packages/platform/src/app-metrics.ts b/packages/platform/src/app-metrics.ts new file mode 100644 index 000000000..9cae0108c --- /dev/null +++ b/packages/platform/src/app-metrics.ts @@ -0,0 +1,11 @@ +export interface AppProcessMetric { + pid: number; + type: string; + name?: string; + cpu?: { percentCPUUsage: number }; + memory?: { workingSetSize: number }; +} + +export interface IAppMetrics { + getAppMetrics(): AppProcessMetric[]; +} diff --git a/packages/platform/tsup.config.ts b/packages/platform/tsup.config.ts index 20fd8b446..edc638132 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/app-metrics.ts", ], format: ["esm"], dts: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 164f5ef0a..0034ced52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,7 +261,7 @@ importers: version: 17.2.3 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.8.0)(bun-types@1.3.13) + version: 0.45.1(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.8.0)(bun-types@1.3.14) electron-log: specifier: ^5.4.3 version: 5.4.3 @@ -331,6 +331,9 @@ importers: react-resizable-panels: specifier: ^3.0.6 version: 3.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-scan: + specifier: ^0.5.6 + version: 0.5.6(esbuild@0.25.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1) reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -729,7 +732,7 @@ importers: version: link:../shared '@types/bun': specifier: latest - version: 1.3.13 + version: 1.3.14 '@types/tar': specifier: ^6.1.13 version: 6.1.13 @@ -3660,6 +3663,11 @@ packages: '@preact/signals-core@1.13.0': resolution: {integrity: sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==} + '@preact/signals@1.3.4': + resolution: {integrity: sha512-TPMkStdT0QpSc8FpB63aOwXoSiZyIrPsP9Uj347KopdS6olZdAYeeird/5FZv/M1Yc1ge5qstub2o8VDbvkT4g==} + peerDependencies: + preact: 10.x + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -4432,6 +4440,10 @@ packages: '@types/react-dom': optional: true + '@react-grab/cli@0.1.34': + resolution: {integrity: sha512-L2eAxN46Vq2Ss3nDegrH7wQVMeWH03ahawp+OdzUtQWqL3cq6Bt149q9XhY3cWc9fJsxuWjLfCn+3T9uApIlBA==} + hasBin: true + '@react-native-async-storage/async-storage@2.2.0': resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==} peerDependencies: @@ -5191,8 +5203,8 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - '@types/bun@1.3.13': - resolution: {integrity: sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==} + '@types/bun@1.3.14': + resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -5857,6 +5869,11 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bippy@0.5.40: + resolution: {integrity: sha512-3QPSDG5tgd7FCIkcKsUqmbGdlHxBxP7II5drXPNp1I0rpA9+4+/VjQOigDTbzeqTi6Xkaf1Dq7x4E8vbOuwOkA==} + peerDependencies: + react: '>=17.0.1' + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -5931,8 +5948,8 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bun-types@1.3.13: - resolution: {integrity: sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==} + bun-types@1.3.14: + resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -6112,6 +6129,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + cli-truncate@3.1.0: resolution: {integrity: sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -7461,6 +7482,10 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -8167,6 +8192,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -8533,6 +8561,10 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + log-update@5.0.1: resolution: {integrity: sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -9305,6 +9337,10 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + ora@9.4.0: + resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} + engines: {node: '>=20'} + orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} @@ -9377,6 +9413,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -9960,6 +9999,15 @@ packages: peerDependencies: react: '>=17.0.0' + react-grab@0.1.34: + resolution: {integrity: sha512-jtdOdv0kb90oqL+pMszSh9DOLgVRaX4ZE6XN4GkDEpNNUqveQfZT014+EeJraljpqQfuWKW+96NrrRqUD93D2g==} + hasBin: true + peerDependencies: + react: '>=17.0.0' + peerDependenciesMeta: + react: + optional: true + react-hotkeys-hook@4.6.2: resolution: {integrity: sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q==} peerDependencies: @@ -10103,6 +10151,17 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + react-scan@0.5.6: + resolution: {integrity: sha512-FrZ75yWjabNQmBbN9wxP+FgfgcPYoOFFWXYBhi6OnUbF/DyJDwOEfV2RRs9IWVfPVjhsku2CPK3oohYC5GEHpg==} + hasBin: true + peerDependencies: + esbuild: '>=0.18.0' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + esbuild: + optional: true + react-shadow@20.6.0: resolution: {integrity: sha512-kY+w4OMNZ8Nj9YI9eiTgvvJ/wYO7XyX1D/LYhvwQZv5vw69iCiDtGB0BX/2U8gLUuZAMN+x/7rHJKqHh8wXFHQ==} peerDependencies: @@ -10552,6 +10611,10 @@ packages: resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} engines: {node: '>= 18'} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + socks-proxy-agent@7.0.0: resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==} engines: {node: '>= 10'} @@ -10651,6 +10714,10 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} + engines: {node: '>=18'} + storybook@10.2.0: resolution: {integrity: sha512-fIQnFtpksRRgHR1CO1onGX3djaog4qsW/c5U8arqYTkUEr2TaWpn05mIJDOBoPJFlOdqFrB4Ttv0PZJxV7avhw==} hasBin: true @@ -10687,6 +10754,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -10930,6 +11001,10 @@ packages: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -11300,6 +11375,10 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unplugin@2.1.0: + resolution: {integrity: sha512-us4j03/499KhbGP8BU7Hrzrgseo+KdfJYWcbcajCOqsAyb8Gk0Yn2kiUIcZISYCb1JFaZfIuG3b42HmguVOKCQ==} + engines: {node: '>=18.12.0'} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -15312,6 +15391,11 @@ snapshots: '@preact/signals-core@1.13.0': {} + '@preact/signals@1.3.4(preact@10.28.3)': + dependencies: + '@preact/signals-core': 1.13.0 + preact: 10.28.3 + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -16123,6 +16207,18 @@ snapshots: '@types/react': 19.2.11 '@types/react-dom': 19.2.3(@types/react@19.2.11) + '@react-grab/cli@0.1.34': + dependencies: + commander: 14.0.3 + ignore: 7.0.5 + jsonc-parser: 3.3.1 + ora: 9.4.0 + package-manager-detector: 1.6.0 + picocolors: 1.1.1 + prompts: 2.4.2 + smol-toml: 1.6.1 + tinyexec: 1.1.2 + '@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))': dependencies: merge-options: 3.0.4 @@ -16939,9 +17035,9 @@ snapshots: dependencies: '@types/node': 24.12.0 - '@types/bun@1.3.13': + '@types/bun@1.3.14': dependencies: - bun-types: 1.3.13 + bun-types: 1.3.14 '@types/cacheable-request@6.0.3': dependencies: @@ -17734,6 +17830,10 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 + bippy@0.5.40(react@19.1.0): + dependencies: + react: 19.1.0 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -17827,7 +17927,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bun-types@1.3.13: + bun-types@1.3.14: dependencies: '@types/node': 24.12.0 @@ -18031,6 +18131,8 @@ snapshots: cli-spinners@2.9.2: {} + cli-spinners@3.4.0: {} + cli-truncate@3.1.0: dependencies: slice-ansi: 5.0.0 @@ -18426,12 +18528,12 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.8.0)(bun-types@1.3.13): + drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.8.0)(bun-types@1.3.14): optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/better-sqlite3': 7.6.13 better-sqlite3: 12.8.0 - bun-types: 1.3.13 + bun-types: 1.3.14 ds-store@0.1.6: dependencies: @@ -19419,6 +19521,8 @@ snapshots: get-east-asian-width@1.4.0: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -20229,6 +20333,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -20550,6 +20656,11 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + log-update@5.0.1: dependencies: ansi-escapes: 5.0.0 @@ -21721,6 +21832,17 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + ora@9.4.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.1 + orderedmap@2.1.1: {} os-tmpdir@1.0.2: {} @@ -21794,6 +21916,8 @@ snapshots: package-json-from-dist@1.0.1: {} + package-manager-detector@1.6.0: {} + pako@1.0.11: {} parent-module@1.0.1: @@ -22449,6 +22573,13 @@ snapshots: dependencies: react: 19.1.0 + react-grab@0.1.34(react@19.1.0): + dependencies: + '@react-grab/cli': 0.1.34 + bippy: 0.5.40(react@19.1.0) + optionalDependencies: + react: 19.1.0 + react-hotkeys-hook@4.6.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 @@ -22646,6 +22777,27 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + react-scan@0.5.6(esbuild@0.25.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1): + dependencies: + '@babel/core': 7.29.0 + '@babel/types': 7.29.0 + '@preact/signals': 1.3.4(preact@10.28.3) + '@rollup/pluginutils': 5.3.0(rollup@4.57.1) + bippy: 0.5.40(react@19.1.0) + commander: 14.0.3 + picocolors: 1.1.1 + preact: 10.28.3 + prompts: 2.4.2 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-grab: 0.1.34(react@19.1.0) + optionalDependencies: + esbuild: 0.25.12 + unplugin: 2.1.0 + transitivePeerDependencies: + - rollup + - supports-color + react-shadow@20.6.0(prop-types@15.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: humps: 2.0.1 @@ -23226,6 +23378,8 @@ snapshots: smol-toml@1.6.0: {} + smol-toml@1.6.1: {} + socks-proxy-agent@7.0.0: dependencies: agent-base: 6.0.2 @@ -23316,6 +23470,8 @@ snapshots: stdin-discarder@0.2.2: {} + stdin-discarder@0.3.2: {} + storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@storybook/global': 5.0.0 @@ -23365,6 +23521,11 @@ snapshots: get-east-asian-width: 1.4.0 strip-ansi: 7.1.2 + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -23621,6 +23782,8 @@ snapshots: tinyexec@1.0.2: {} + tinyexec@1.1.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -23947,6 +24110,12 @@ snapshots: unpipe@1.0.0: {} + unplugin@2.1.0: + dependencies: + acorn: 8.15.0 + webpack-virtual-modules: 0.6.2 + optional: true + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 From 05757dd3e2626d9ad3f28891d4e578e7ec2912be Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 20 May 2026 14:34:10 -0700 Subject: [PATCH 2/3] dev bar --- apps/code/src/main/index.ts | 6 ++-- .../src/main/services/dev-logs/service.ts | 23 ++++++++----- .../src/main/services/dev-network/service.ts | 18 +++++++++- apps/code/src/main/services/dev-toolbar.ts | 25 ++++++++++++++ apps/code/src/main/services/index.ts | 1 + apps/code/src/renderer/App.tsx | 18 ++-------- .../dev-toolbar/components/DevToolbar.tsx | 6 ++-- .../features/dev-toolbar/integration.tsx | 34 +++++++++++++++++++ .../dev-toolbar/ipcInstrumentationLink.ts | 9 +++-- 9 files changed, 104 insertions(+), 36 deletions(-) create mode 100644 apps/code/src/main/services/dev-toolbar.ts create mode 100644 apps/code/src/renderer/features/dev-toolbar/integration.tsx diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 7faf4fb6c..b32211690 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -12,8 +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 type { DevLogsService } from "./services/dev-logs/service"; -import type { DevNetworkService } from "./services/dev-network/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"; @@ -144,8 +143,7 @@ app.on("child-process-gone", (_event, details) => { }); async function initializeServices(): Promise { - container.get(MAIN_TOKENS.DevNetworkService).install(); - container.get(MAIN_TOKENS.DevLogsService).install(); + initDevToolbar(container); container.get(MAIN_TOKENS.DatabaseService); container.get(MAIN_TOKENS.OAuthService); diff --git a/apps/code/src/main/services/dev-logs/service.ts b/apps/code/src/main/services/dev-logs/service.ts index b8e0b2cea..f238e974f 100644 --- a/apps/code/src/main/services/dev-logs/service.ts +++ b/apps/code/src/main/services/dev-logs/service.ts @@ -1,12 +1,10 @@ import type ElectronLog from "electron-log"; import log from "electron-log/main"; -import { injectable } from "inversify"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import { - DevLogsEvent, - type DevLogsEvents, - type LogEntry, -} from "./schemas"; +import type { DevFlagsService } from "../dev-flags/service"; +import { DevLogsEvent, type DevLogsEvents, type LogEntry } from "./schemas"; const RING_BUFFER_SIZE = 1000; @@ -16,20 +14,27 @@ export class DevLogsService extends TypedEventEmitter { 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", + source: + message.variables?.processType === "renderer" ? "renderer" : "main", }; this.entries.push(entry); if (this.entries.length > RING_BUFFER_SIZE) { diff --git a/apps/code/src/main/services/dev-network/service.ts b/apps/code/src/main/services/dev-network/service.ts index 8fbbc7890..43e4935a8 100644 --- a/apps/code/src/main/services/dev-network/service.ts +++ b/apps/code/src/main/services/dev-network/service.ts @@ -1,6 +1,8 @@ -import { injectable } from "inversify"; +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, @@ -19,6 +21,13 @@ export class DevNetworkService extends TypedEventEmitter { 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; @@ -26,6 +35,10 @@ export class DevNetworkService extends TypedEventEmitter { log.info("Network instrumentation installed"); } + private capturing(): boolean { + return this.installed && this.flags.getFlags().devMode; + } + getSnapshot(): NetworkRequest[] { return [...this.requests]; } @@ -60,6 +73,9 @@ export class DevNetworkService extends TypedEventEmitter { 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(); 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/renderer/App.tsx b/apps/code/src/renderer/App.tsx index 3f4882c74..b8f52a373 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -13,7 +13,7 @@ import { import { useAuthSession } from "@features/auth/hooks/useAuthSession"; import { useIsOrgAdmin } from "@features/auth/hooks/useOrgRole"; import { DevToolbar } from "@features/dev-toolbar/components/DevToolbar"; -import { installMainThreadHealth } from "@features/dev-toolbar/mainThreadHealth"; +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"; @@ -69,21 +69,7 @@ function App() { return initializeUpdateStore(); }, []); - // Install main-thread health observers (longtasks + FPS) for the dev toolbar. - useEffect(() => installMainThreadHealth(), []); - - // Surface dev-toolbar triggered toasts (e.g. quick actions test toasts). - useSubscription( - trpcReact.dev.onDevToast.subscriptionOptions(undefined, { - onData: (data) => { - if (data.variant === "error") { - toast.error(data.message); - } else { - toast.info(data.message); - } - }, - }), - ); + useDevToolbarIntegration(); // Dev-only inbox demo command for local QA from the renderer console. useEffect(() => { diff --git a/apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx b/apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx index 0a5298781..de83600fa 100644 --- a/apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx +++ b/apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx @@ -51,10 +51,10 @@ import { X, ZapOff, } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import type { MetricsSample } from "../../../../main/services/dev-metrics/schemas"; import { REGION_LABELS } from "../../../../shared/types/regions"; -import { subscribeDevFlagsFromMain, useDevFlagsStore } from "../devFlagsStore"; +import { useDevFlagsStore } from "../devFlagsStore"; import { useIpcMetricsStore } from "../ipcMetricsStore"; import { useMainThreadHealthStore } from "../mainThreadHealth"; import { AgentsPanel } from "./AgentsPanel"; @@ -86,8 +86,6 @@ export function DevToolbar() { const [openPanel, setOpenPanel] = useState(null); const [panelHeight, setPanelHeight] = useState(480); - useEffect(() => subscribeDevFlagsFromMain(), []); - if (!devMode) return null; const togglePanel = (panel: Exclude) => { diff --git a/apps/code/src/renderer/features/dev-toolbar/integration.tsx b/apps/code/src/renderer/features/dev-toolbar/integration.tsx new file mode 100644 index 000000000..e7478cc70 --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/integration.tsx @@ -0,0 +1,34 @@ +import { useTRPC } from "@renderer/trpc/client"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { toast } from "@utils/toast"; +import { useEffect } from "react"; +import { subscribeDevFlagsFromMain, useDevFlagsStore } from "./devFlagsStore"; +import { installMainThreadHealth } from "./mainThreadHealth"; + +export function useDevToolbarIntegration(): void { + const trpcReact = useTRPC(); + const devMode = useDevFlagsStore((s) => s.devMode); + + useEffect(() => { + if (!devMode) return; + const stopHealth = installMainThreadHealth(); + const stopFlags = subscribeDevFlagsFromMain(); + return () => { + stopHealth(); + stopFlags(); + }; + }, [devMode]); + + useSubscription( + trpcReact.dev.onDevToast.subscriptionOptions(undefined, { + enabled: devMode, + onData: (data) => { + if (data.variant === "error") { + toast.error(data.message); + } else { + toast.info(data.message); + } + }, + }), + ); +} diff --git a/apps/code/src/renderer/features/dev-toolbar/ipcInstrumentationLink.ts b/apps/code/src/renderer/features/dev-toolbar/ipcInstrumentationLink.ts index a53f2cef9..b13776ef9 100644 --- a/apps/code/src/renderer/features/dev-toolbar/ipcInstrumentationLink.ts +++ b/apps/code/src/renderer/features/dev-toolbar/ipcInstrumentationLink.ts @@ -1,6 +1,7 @@ import type { TRPCLink } from "@trpc/client"; import { observable, tap } from "@trpc/server/observable"; import type { AnyRouter } from "@trpc/server/unstable-core-do-not-import"; +import { useDevFlagsStore } from "./devFlagsStore"; import { type IpcOpType, useIpcMetricsStore } from "./ipcMetricsStore"; function byteLength(value: unknown): number { @@ -16,8 +17,11 @@ export function ipcInstrumentationLink< TRouter extends AnyRouter = AnyRouter, >(): TRPCLink { return () => - ({ op, next }) => - observable((observer) => { + ({ op, next }) => { + if (!useDevFlagsStore.getState().devMode) { + return next(op); + } + return observable((observer) => { const start = performance.now(); const startedAt = Date.now(); const inputBytes = byteLength(op.input); @@ -81,4 +85,5 @@ export function ipcInstrumentationLink< subscription.unsubscribe(); }; }); + }; } From 6cbe8f1540fa333fb30fa80f0dfc9c6a2ecf88c9 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 20 May 2026 22:09:24 -0700 Subject: [PATCH 3/3] lint --- apps/code/src/main/di/container.ts | 2 ++ apps/code/src/main/di/tokens.ts | 1 + .../electron-dev-host-actions.ts | 25 +++++++++++++++++++ .../src/main/services/dev-actions/service.ts | 17 ++++++------- .../src/main/services/dev-network/service.ts | 7 +++++- .../dev-toolbar/components/DevToolbar.tsx | 4 +-- .../dev-toolbar/components/LogsPanel.tsx | 2 +- .../dev-toolbar/components/NetworkPanel.tsx | 2 +- packages/platform/package.json | 4 +++ packages/platform/src/dev-host-actions.ts | 6 +++++ packages/platform/tsup.config.ts | 1 + 11 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 apps/code/src/main/platform-adapters/electron-dev-host-actions.ts create mode 100644 packages/platform/src/dev-host-actions.ts diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index a3d1cc707..b53697e84 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -15,6 +15,7 @@ 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"; @@ -105,6 +106,7 @@ 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 diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index ec8cc9a7f..cf75da057 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -22,6 +22,7 @@ export const MAIN_TOKENS = Object.freeze({ 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"), 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/services/dev-actions/service.ts b/apps/code/src/main/services/dev-actions/service.ts index 19d2478c2..66520519f 100644 --- a/apps/code/src/main/services/dev-actions/service.ts +++ b/apps/code/src/main/services/dev-actions/service.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, shell } from "electron"; +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"; @@ -20,33 +20,32 @@ export class DevActionsService extends TypedEventEmitter { constructor( @inject(MAIN_TOKENS.DevNetworkService) private readonly network: DevNetworkService, + @inject(MAIN_TOKENS.DevHostActions) + private readonly host: IDevHostActions, ) { super(); } async openUserDataDir(): Promise { - await shell.openPath(getUserDataDir()); + await this.host.openPath(getUserDataDir()); } async openLogFile(): Promise { - await shell.openPath(getLogFilePath()); + await this.host.openPath(getLogFilePath()); } reloadRenderer(): void { - for (const window of BrowserWindow.getAllWindows()) { - window.webContents.reload(); - } + this.host.reloadAllWindows(); } restartMain(): void { log.warn("Restarting main process from dev toolbar"); - app.relaunch(); - app.exit(0); + this.host.relaunch(); } crashMain(): void { log.warn("Crashing main process from dev toolbar"); - process.crash(); + this.host.crash(); } triggerToast(variant: "info" | "error", message: string): DevToast { diff --git a/apps/code/src/main/services/dev-network/service.ts b/apps/code/src/main/services/dev-network/service.ts index 43e4935a8..ce18d7198 100644 --- a/apps/code/src/main/services/dev-network/service.ts +++ b/apps/code/src/main/services/dev-network/service.ts @@ -149,8 +149,13 @@ export class DevNetworkService extends TypedEventEmitter { } }; + const preconnect = ( + original as unknown as { + preconnect?: (...args: unknown[]) => unknown; + } + ).preconnect; Object.defineProperty(wrapped, "preconnect", { - value: original.preconnect?.bind(original) ?? (() => undefined), + value: preconnect?.bind(original) ?? (() => undefined), }); globalThis.fetch = wrapped as typeof fetch; diff --git a/apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx b/apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx index de83600fa..e616f296e 100644 --- a/apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx +++ b/apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx @@ -291,9 +291,7 @@ function ResizeHandle({ return (