From b2993f08722400392b126c1a37d4f5fd8052fb8f Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 20 May 2026 11:56:05 -0700 Subject: [PATCH 1/4] dev toolbar --- apps/code/package.json | 1 + apps/code/src/main/di/bindings.ts | 23 + apps/code/src/main/di/container.ts | 19 + apps/code/src/main/di/tokens.ts | 20 + apps/code/src/main/index.ts | 5 + .../platform-adapters/electron-app-metrics.ts | 21 + apps/code/src/main/preload.ts | 18 + .../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 | 139 ++ .../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 | 5 + apps/code/src/renderer/di/bindings.ts | 5 + apps/code/src/renderer/di/container.ts | 14 + .../features/dev-toolbar/DevToolbarHost.tsx | 28 + .../dev-toolbar/components/AgentsPanel.tsx | 189 ++ .../dev-toolbar/components/CpuPanel.tsx | 184 ++ .../dev-toolbar/components/DevToolbar.tsx | 887 +++++++ .../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 + apps/code/src/renderer/main.tsx | 3 +- 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 | 13 + packages/platform/tsup.config.ts | 1 + .../src/features/settings/SettingsDialog.tsx | 2 +- .../ui/src/features/settings/devModeClient.ts | 14 + .../settings/sections/AdvancedSettings.tsx | 35 +- .../ui/src/primitives/LoginTransition.tsx | 2 +- packages/ui/src/router/routes/__root.tsx | 6 +- packages/ui/src/shell/App.tsx | 69 +- .../src/services/agent/agent.ts | 43 + pnpm-lock.yaml | 2031 ++++++++++++++++- 54 files changed, 6184 insertions(+), 126 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/DevToolbarHost.tsx 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 create mode 100644 packages/ui/src/features/settings/devModeClient.ts diff --git a/apps/code/package.json b/apps/code/package.json index bc60ff784..1fe69a07a 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -145,6 +145,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-hotkeys-hook": "^4.4.4", + "react-scan": "^0.5.6", "reflect-metadata": "^0.2.2", "semver": "^7.6.0", "shadcn": "^4.1.2", diff --git a/apps/code/src/main/di/bindings.ts b/apps/code/src/main/di/bindings.ts index 0ad4eccdd..fff236607 100644 --- a/apps/code/src/main/di/bindings.ts +++ b/apps/code/src/main/di/bindings.ts @@ -103,6 +103,7 @@ import type { } from "@posthog/platform/analytics"; import type { APP_LIFECYCLE_SERVICE } from "@posthog/platform/app-lifecycle"; import type { APP_META_SERVICE } from "@posthog/platform/app-meta"; +import type { APP_METRICS_SERVICE } from "@posthog/platform/app-metrics"; import type { BUNDLED_RESOURCES_SERVICE } from "@posthog/platform/bundled-resources"; import type { CLIPBOARD_SERVICE } from "@posthog/platform/clipboard"; import type { CONTEXT_MENU_SERVICE } from "@posthog/platform/context-menu"; @@ -122,11 +123,13 @@ import type { WORKSPACE_SETTINGS_SERVICE } from "@posthog/platform/workspace-set import type { WorkspaceClient } from "@posthog/workspace-client/client"; import type { DatabaseService } from "@posthog/workspace-server/db/service"; import type { GIT_SERVICE as WS_GIT_SERVICE } from "@posthog/workspace-server/di/tokens"; +import type { AgentService } from "@posthog/workspace-server/services/agent/agent"; import type { AGENT_AUTH, AGENT_LOGGER, AGENT_MCP_APPS, AGENT_REPO_FILES, + AGENT_SERVICE, AGENT_SLEEP_COORDINATOR, } from "@posthog/workspace-server/services/agent/identifiers"; import type { @@ -203,6 +206,7 @@ import type { WorkspaceService } from "@posthog/workspace-server/services/worksp import type { FileWatcherBridge } from "../index"; import type { ElectronAppLifecycle } from "../platform-adapters/electron-app-lifecycle"; import type { ElectronAppMeta } from "../platform-adapters/electron-app-meta"; +import type { ElectronAppMetrics } from "../platform-adapters/electron-app-metrics"; import type { ElectronBundledResources } from "../platform-adapters/electron-bundled-resources"; import type { ElectronClipboard } from "../platform-adapters/electron-clipboard"; import type { ElectronContextMenu } from "../platform-adapters/electron-context-menu"; @@ -227,6 +231,11 @@ import type { TokenCipherPortAdapter, } from "../services/auth/port-adapters"; import type { DeepLinkService } from "../services/deep-link/service"; +import type { DevActionsService } from "../services/dev-actions/service"; +import type { DevFlagsService } from "../services/dev-flags/service"; +import type { DevLogsService } from "../services/dev-logs/service"; +import type { DevMetricsService } from "../services/dev-metrics/service"; +import type { DevNetworkService } from "../services/dev-network/service"; import type { EncryptionService } from "../services/encryption/service"; import type { SecureStoreService } from "../services/secure-store/service"; import type { settingsStore } from "../services/settingsStore"; @@ -243,6 +252,11 @@ import type { DATABASE_SERVICE as MAIN_DATABASE_SERVICE, DEEP_LINK_SERVICE as MAIN_DEEP_LINK_SERVICE, DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY as MAIN_DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY, + DEV_ACTIONS_SERVICE as MAIN_DEV_ACTIONS_SERVICE, + DEV_FLAGS_SERVICE as MAIN_DEV_FLAGS_SERVICE, + DEV_LOGS_SERVICE as MAIN_DEV_LOGS_SERVICE, + DEV_METRICS_SERVICE as MAIN_DEV_METRICS_SERVICE, + DEV_NETWORK_SERVICE as MAIN_DEV_NETWORK_SERVICE, ENCRYPTION_SERVICE as MAIN_ENCRYPTION_SERVICE, EXTERNAL_APPS_SERVICE as MAIN_EXTERNAL_APPS_SERVICE, FILE_WATCHER_SERVICE as MAIN_FILE_WATCHER_SERVICE, @@ -292,6 +306,7 @@ export interface MainBindings { [BUNDLED_RESOURCES_SERVICE]: ElectronBundledResources; [IMAGE_PROCESSOR_SERVICE]: ElectronImageProcessor; [WORKSPACE_SETTINGS_SERVICE]: ElectronWorkspaceSettings; + [APP_METRICS_SERVICE]: ElectronAppMetrics; // Database (main aliases + ws-server source tokens via toService) [MAIN_DATABASE_SERVICE]: DatabaseService; @@ -426,6 +441,13 @@ export interface MainBindings { [MAIN_ENCRYPTION_SERVICE]: EncryptionService; [CANVAS_GEN_SERVICE]: CanvasGenService; + // Dev toolbar diagnostics + [MAIN_DEV_FLAGS_SERVICE]: DevFlagsService; + [MAIN_DEV_METRICS_SERVICE]: DevMetricsService; + [MAIN_DEV_NETWORK_SERVICE]: DevNetworkService; + [MAIN_DEV_LOGS_SERVICE]: DevLogsService; + [MAIN_DEV_ACTIONS_SERVICE]: DevActionsService; + // ws-server git service (bound to(GitService)) [WS_GIT_SERVICE]: GitService; @@ -443,6 +465,7 @@ export interface MainBindings { [FS_SERVICE]: FsCapability; // Typed container.get-only tokens (bound via loaded modules) + [AGENT_SERVICE]: AgentService; [OAUTH_SERVICE]: OAuthService; [GITHUB_INTEGRATION_SERVICE]: GitHubIntegrationService; [SLACK_INTEGRATION_SERVICE]: SlackIntegrationService; diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index b1bc3cfaa..b3af23edf 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -95,6 +95,7 @@ import { CanvasGenService } from "@posthog/host-router/services/canvas-gen.servi import { ANALYTICS_SERVICE } from "@posthog/platform/analytics"; import { APP_LIFECYCLE_SERVICE } from "@posthog/platform/app-lifecycle"; import { APP_META_SERVICE } from "@posthog/platform/app-meta"; +import { APP_METRICS_SERVICE } from "@posthog/platform/app-metrics"; import { BUNDLED_RESOURCES_SERVICE } from "@posthog/platform/bundled-resources"; import { CLIPBOARD_SERVICE } from "@posthog/platform/clipboard"; import { CONTEXT_MENU_SERVICE } from "@posthog/platform/context-menu"; @@ -210,6 +211,7 @@ import ExternalAppsStoreImpl from "electron-store"; import type { FileWatcherBridge } from "../index"; 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"; @@ -236,6 +238,11 @@ import { TokenCipherPortAdapter, } from "../services/auth/port-adapters"; 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 { EncryptionService } from "../services/encryption/service"; import { SecureStoreService } from "../services/secure-store/service"; import { settingsStore } from "../services/settingsStore"; @@ -255,6 +262,11 @@ import { DATABASE_SERVICE as MAIN_DATABASE_SERVICE, DEEP_LINK_SERVICE as MAIN_DEEP_LINK_SERVICE, DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY as MAIN_DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY, + DEV_ACTIONS_SERVICE as MAIN_DEV_ACTIONS_SERVICE, + DEV_FLAGS_SERVICE as MAIN_DEV_FLAGS_SERVICE, + DEV_LOGS_SERVICE as MAIN_DEV_LOGS_SERVICE, + DEV_METRICS_SERVICE as MAIN_DEV_METRICS_SERVICE, + DEV_NETWORK_SERVICE as MAIN_DEV_NETWORK_SERVICE, ENCRYPTION_SERVICE as MAIN_ENCRYPTION_SERVICE, EXTERNAL_APPS_SERVICE as MAIN_EXTERNAL_APPS_SERVICE, FS_SERVICE as MAIN_FS_SERVICE, @@ -305,6 +317,7 @@ container.bind(CONTEXT_MENU_SERVICE).to(ElectronContextMenu); container.bind(BUNDLED_RESOURCES_SERVICE).to(ElectronBundledResources); container.bind(IMAGE_PROCESSOR_SERVICE).to(ElectronImageProcessor); container.bind(WORKSPACE_SETTINGS_SERVICE).to(ElectronWorkspaceSettings); +container.bind(APP_METRICS_SERVICE).to(ElectronAppMetrics); container.load(databaseModule); container.load(repositoriesModule); @@ -708,3 +721,9 @@ container.bind(MAIN_ENCRYPTION_SERVICE).to(EncryptionService); // host-router routers. container.load(canvasCoreModule); container.bind(CANVAS_GEN_SERVICE).to(CanvasGenService).inSingletonScope(); + +container.bind(MAIN_DEV_FLAGS_SERVICE).to(DevFlagsService); +container.bind(MAIN_DEV_METRICS_SERVICE).to(DevMetricsService); +container.bind(MAIN_DEV_NETWORK_SERVICE).to(DevNetworkService); +container.bind(MAIN_DEV_LOGS_SERVICE).to(DevLogsService); +container.bind(MAIN_DEV_ACTIONS_SERVICE).to(DevActionsService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 80f0e6c77..f1dbf4e45 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -115,6 +115,21 @@ export const WORKSPACE_SERVICE = Symbol.for( export const WORKSPACE_SERVER_SERVICE = Symbol.for( "posthog.host.main.workspace-server.service", ); +export const DEV_FLAGS_SERVICE = Symbol.for( + "posthog.host.main.dev-flags.service", +); +export const DEV_METRICS_SERVICE = Symbol.for( + "posthog.host.main.dev-metrics.service", +); +export const DEV_NETWORK_SERVICE = Symbol.for( + "posthog.host.main.dev-network.service", +); +export const DEV_LOGS_SERVICE = Symbol.for( + "posthog.host.main.dev-logs.service", +); +export const DEV_ACTIONS_SERVICE = Symbol.for( + "posthog.host.main.dev-actions.service", +); export const MAIN_TOKENS = Object.freeze({ WorkspaceClient: WORKSPACE_CLIENT, @@ -159,4 +174,9 @@ export const MAIN_TOKENS = Object.freeze({ ProvisioningService: PROVISIONING_SERVICE, WorkspaceService: WORKSPACE_SERVICE, WorkspaceServerService: WORKSPACE_SERVER_SERVICE, + DevFlagsService: DEV_FLAGS_SERVICE, + DevMetricsService: DEV_METRICS_SERVICE, + DevNetworkService: DEV_NETWORK_SERVICE, + DevLogsService: DEV_LOGS_SERVICE, + DevActionsService: DEV_ACTIONS_SERVICE, }); diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 4530152d9..fec48694f 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -51,6 +51,8 @@ import { MAIN_TOKENS } from "./di/tokens"; import { posthogNodeAnalytics } from "./platform-adapters/posthog-analytics"; import { registerMcpSandboxProtocol } from "./protocols/mcp-sandbox"; import type { AppLifecycleService } from "./services/app-lifecycle/service"; +import type { DevLogsService } from "./services/dev-logs/service"; +import type { DevNetworkService } from "./services/dev-network/service"; import { focusSessionStore, focusWorktreePaths, @@ -217,6 +219,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(OAUTH_SERVICE); 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 a78c4f20d..b6185c114 100644 --- a/apps/code/src/main/preload.ts +++ b/apps/code/src/main/preload.ts @@ -3,6 +3,22 @@ import { contextBridge, webUtils } from "electron"; import "electron-log/preload"; import { parseSessionIdArg } from "./posthog-session-arg"; +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), }); @@ -11,6 +27,8 @@ contextBridge.exposeInMainWorld("__posthogBootstrap", { sessionId: parseSessionIdArg(process.argv), }); +contextBridge.exposeInMainWorld("__posthogCodeDevFlags", devFlags); + if (process.argv.includes("--posthog-code-dev")) { contextBridge.exposeInMainWorld("__posthogCodeTest", { crash: () => { 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..2f01a03d5 --- /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 "@posthog/shared"; +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..eeae0f77d --- /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 "@posthog/shared"; +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..7cf34624b --- /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 "@posthog/shared"; +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..ff88fbc3d --- /dev/null +++ b/apps/code/src/main/services/dev-metrics/service.ts @@ -0,0 +1,139 @@ +import { + APP_METRICS_SERVICE, + type IAppMetrics, +} from "@posthog/platform/app-metrics"; +import { TypedEventEmitter } from "@posthog/shared"; +import { inject, injectable, preDestroy } from "inversify"; +import { logger } from "../../utils/logger"; +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(APP_METRICS_SERVICE) 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..4327c449b --- /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 "@posthog/shared"; +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 c4e917f30..06688f701 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -41,6 +41,7 @@ import { uiRouter } from "@posthog/host-router/routers/ui.router"; import { updatesRouter } from "@posthog/host-router/routers/updates.router"; import { usageMonitorRouter } from "@posthog/host-router/routers/usage-monitor.router"; import { workspaceRouter } from "@posthog/host-router/routers/workspace.router"; +import { devRouter } from "./routers/dev"; import { encryptionRouter } from "./routers/encryption"; import { workspaceServerRouter } from "./routers/workspace-server"; import { router } from "./trpc"; @@ -58,6 +59,7 @@ export const trpcRouter = router({ cloudTask: cloudTaskRouter, 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..5b8fab345 --- /dev/null +++ b/apps/code/src/main/trpc/routers/dev.ts @@ -0,0 +1,219 @@ +import { container } from "@main/di/container"; +import { MAIN_TOKENS } from "@main/di/tokens"; +import type { AgentService } from "@posthog/workspace-server/services/agent/agent"; +import { AGENT_SERVICE } from "@posthog/workspace-server/services/agent/identifiers"; +import { z } from "zod"; +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 = () => + container.get(MAIN_TOKENS.DevFlagsService); +const getMetricsService = () => + container.get(MAIN_TOKENS.DevMetricsService); +const getNetworkService = () => + container.get(MAIN_TOKENS.DevNetworkService); +const getLogsService = () => + container.get(MAIN_TOKENS.DevLogsService); +const getActionsService = () => + container.get(MAIN_TOKENS.DevActionsService); +const getAgentService = () => container.get(AGENT_SERVICE); + +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 1ed987f5a..3c332fa67 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 = 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 = 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 = baseRouter; diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 530cf95fb..6d69b3ba1 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -15,6 +15,10 @@ import { buildApplicationMenu } from "./menu"; import type { ElectronMainWindow } from "./platform-adapters/electron-main-window"; import { posthogNodeAnalytics } from "./platform-adapters/posthog-analytics"; import { POSTHOG_SESSION_ID_ARG } from "./posthog-session-arg"; +import { + encodeDevFlagsForArg, + readDevFlagsSync, +} from "./services/dev-flags/service"; import { trpcRouter } from "./trpc/router"; import { collectMemorySnapshot } from "./utils/crash-diagnostics"; import { isDevBuild } from "./utils/env"; @@ -208,6 +212,7 @@ export function createWindow(): void { additionalArguments: [ ...(isDev ? ["--posthog-code-dev"] : []), `${POSTHOG_SESSION_ID_ARG}${posthogNodeAnalytics.getOrCreateSessionId()}`, + encodeDevFlagsForArg(readDevFlagsSync()), ], ...(isDev && { webSecurity: false }), }, diff --git a/apps/code/src/renderer/di/bindings.ts b/apps/code/src/renderer/di/bindings.ts index c45a6f338..f399f1641 100644 --- a/apps/code/src/renderer/di/bindings.ts +++ b/apps/code/src/renderer/di/bindings.ts @@ -174,6 +174,10 @@ import { MCP_TOOL_BLOCK_COMPONENT, type McpToolBlockComponent, } from "@posthog/ui/features/sessions/components/session-update/identifiers"; +import { + DEV_MODE_CLIENT, + type DevModeClient, +} from "@posthog/ui/features/settings/devModeClient"; import { SHELL_CLIENT, type ShellClient, @@ -221,6 +225,7 @@ export interface RendererBindings { [TRPC_CLIENT]: TRPCClient; [HOST_TRPC_CLIENT]: HostTrpcClient; [UPDATES_CLIENT]: UpdatesClient; + [DEV_MODE_CLIENT]: DevModeClient; [CONNECTIVITY_CLIENT]: ConnectivityClient; [SHELL_CLIENT]: ShellClient; [FOCUS_CONTROLLER_DEPS]: FocusControllerDeps; diff --git a/apps/code/src/renderer/di/container.ts b/apps/code/src/renderer/di/container.ts index 20138362f..6be65379e 100644 --- a/apps/code/src/renderer/di/container.ts +++ b/apps/code/src/renderer/di/container.ts @@ -111,6 +111,10 @@ import { SHELL_CLIENT, type ShellClient, } from "@posthog/ui/features/terminal/shellClient"; +import { + DEV_MODE_CLIENT, + type DevModeClient, +} from "@posthog/ui/features/settings/devModeClient"; import { updatesClient } from "@posthog/ui/features/updates/updatesAdapter"; import { UPDATES_CLIENT } from "@posthog/ui/features/updates/updatesClient"; import { @@ -120,6 +124,7 @@ import { import { DIFF_WORKER_FACTORY } from "@posthog/ui/shell/diffWorkerHost"; import { HOST_LOGGER } from "@posthog/ui/shell/logger"; import { posthogAnalyticsTracker } from "@posthog/ui/shell/posthogAnalyticsImpl"; +import { useDevFlagsStore } from "@features/dev-toolbar/devFlagsStore"; import { diffWorkerFactory, reviewHost, @@ -153,6 +158,15 @@ container.bind(HOST_TRPC_CLIENT).toConstantValue(hostTrpcClient); container.bind(UPDATES_CLIENT).toConstantValue(updatesClient); +// dev mode client — exposes the dev-toolbar flag store to the shared settings UI +const devModeClient: DevModeClient = { + getDevMode: () => useDevFlagsStore.getState().devMode, + setDevMode: (enabled) => useDevFlagsStore.getState().setDevMode(enabled), + onDevModeChanged: (listener) => + useDevFlagsStore.subscribe((state) => listener(state.devMode)), +}; +container.bind(DEV_MODE_CLIENT).toConstantValue(devModeClient); + // connectivity client — passthrough over the renderer host client const connectivityClient: ConnectivityClient = { getStatus: () => trpcClient.connectivity.getStatus.query(), diff --git a/apps/code/src/renderer/features/dev-toolbar/DevToolbarHost.tsx b/apps/code/src/renderer/features/dev-toolbar/DevToolbarHost.tsx new file mode 100644 index 000000000..e56cdeb98 --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/DevToolbarHost.tsx @@ -0,0 +1,28 @@ +import { toast } from "@posthog/ui/primitives/toast"; +import { useTRPC } from "@renderer/trpc/client"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { useEffect } from "react"; +import { DevToolbar } from "./components/DevToolbar"; +import { installMainThreadHealth } from "./mainThreadHealth"; + +export function DevToolbarHost() { + const trpcReact = useTRPC(); + + // 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); + } + }, + }), + ); + + 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..97bfdb2c4 --- /dev/null +++ b/apps/code/src/renderer/features/dev-toolbar/components/DevToolbar.tsx @@ -0,0 +1,887 @@ +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useLogoutMutation } from "@posthog/ui/features/auth/useAuthMutations"; +import { + useAuthStateValue, + useCurrentUser, +} from "@features/auth/hooks/authQueries"; +import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { useSetupStore } from "@posthog/ui/features/setup/setupStore"; +import { useTourStore } from "@posthog/ui/features/tour/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 "@posthog/ui/shell/themeStore"; +import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { clearApplicationStorage } from "@posthog/ui/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 "@posthog/shared"; +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 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/main.tsx b/apps/code/src/renderer/main.tsx index dfd059c3d..f579b77f0 100644 --- a/apps/code/src/renderer/main.tsx +++ b/apps/code/src/renderer/main.tsx @@ -12,6 +12,7 @@ import "@renderer/platform-adapters/updates"; // Side effect: attaches window focus/visibility listeners so `focused` is accurate before inbox queries mount. import "@posthog/ui/shell/rendererWindowFocusStore"; import { Providers } from "@components/Providers"; +import { DevToolbarHost } from "@features/dev-toolbar/DevToolbarHost"; import { preloadHighlighter } from "@pierre/diffs"; import { boot } from "@posthog/di/contribution"; import { ServiceProvider } from "@posthog/di/react"; @@ -90,7 +91,7 @@ ReactDOM.createRoot(rootElement).render( - + } /> , diff --git a/apps/code/src/renderer/trpc/client.ts b/apps/code/src/renderer/trpc/client.ts index 70f3c8e3c..34151a047 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 type { HostRouter } from "@posthog/host-router/router"; import { createTRPCClient } from "@trpc/client"; @@ -9,7 +10,7 @@ import { queryClient } from "@utils/queryClient"; import type { TrpcRouter } from "../../main/trpc/router"; export const trpcClient = createTRPCClient({ - links: [ipcLink()], + links: [ipcInstrumentationLink(), ipcLink()], }); export const hostTrpcClient = createTRPCClient({ 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 b6f4326c6..3b4c704dc 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -151,8 +151,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 b2e8f0ce1..e276a52aa 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -83,6 +83,10 @@ "./deep-link": { "types": "./dist/deep-link.d.ts", "import": "./dist/deep-link.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..440dab898 --- /dev/null +++ b/packages/platform/src/app-metrics.ts @@ -0,0 +1,13 @@ +export interface AppProcessMetric { + pid: number; + type: string; + name?: string; + cpu?: { percentCPUUsage: number }; + memory?: { workingSetSize: number }; +} + +export interface IAppMetrics { + getAppMetrics(): AppProcessMetric[]; +} + +export const APP_METRICS_SERVICE = Symbol.for("posthog.platform.appMetrics"); diff --git a/packages/platform/tsup.config.ts b/packages/platform/tsup.config.ts index 718e5e444..d92d85462 100644 --- a/packages/platform/tsup.config.ts +++ b/packages/platform/tsup.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ "src/crypto.ts", "src/analytics.ts", "src/deep-link.ts", + "src/app-metrics.ts", ], format: ["esm"], dts: true, diff --git a/packages/ui/src/features/settings/SettingsDialog.tsx b/packages/ui/src/features/settings/SettingsDialog.tsx index c34b64f3c..1fe38177f 100644 --- a/packages/ui/src/features/settings/SettingsDialog.tsx +++ b/packages/ui/src/features/settings/SettingsDialog.tsx @@ -67,7 +67,7 @@ export function SettingsDialog() { return (
; + /** Returns an unsubscribe function. */ + onDevModeChanged(listener: (devMode: boolean) => void): () => void; +} + +export const DEV_MODE_CLIENT = Symbol.for("posthog.ui.DevModeClient"); diff --git a/packages/ui/src/features/settings/sections/AdvancedSettings.tsx b/packages/ui/src/features/settings/sections/AdvancedSettings.tsx index 34f2c21ad..e8e7afb31 100644 --- a/packages/ui/src/features/settings/sections/AdvancedSettings.tsx +++ b/packages/ui/src/features/settings/sections/AdvancedSettings.tsx @@ -1,5 +1,10 @@ +import { useServiceOptional } from "@posthog/di/react"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { + DEV_MODE_CLIENT, + type DevModeClient, +} from "@posthog/ui/features/settings/devModeClient"; import { closeSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; @@ -7,6 +12,7 @@ import { useSetupStore } from "@posthog/ui/features/setup/setupStore"; import { useTourStore } from "@posthog/ui/features/tour/tourStore"; import { clearApplicationStorage } from "@posthog/ui/utils/clearStorage"; import { Button, Flex, Switch } from "@radix-ui/themes"; +import { useSyncExternalStore } from "react"; export function AdvancedSettings() { const showDebugLogsToggle = @@ -15,6 +21,7 @@ export function AdvancedSettings() { const setDebugLogsCloudRuns = useSettingsStore( (s) => s.setDebugLogsCloudRuns, ); + const devModeClient = useServiceOptional(DEV_MODE_CLIENT); return ( @@ -38,7 +45,7 @@ export function AdvancedSettings() {