From f5f564a07bbfee72270745f2ff735fefc8b2ba75 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 24 Apr 2026 18:40:51 +0200 Subject: [PATCH] Add performance telemetry and pane stats --- package-lock.json | 281 +++++++++---- package.json | 6 + src/main/index.ts | 28 ++ src/main/ipc/index.ts | 4 + src/main/ipc/performance.ts | 27 ++ .../performance/LocalJsonlSpanExporter.ts | 64 +++ src/main/performance/PerformanceService.ts | 371 ++++++++++++++++++ src/main/performance/ProcessTelemetry.ts | 94 +++++ src/main/performance/instrumentIpc.ts | 41 ++ src/main/sessionIndex.ts | 60 ++- src/main/sessionManager.ts | 166 +++++++- src/main/sessions/historyLoader.ts | 102 +++-- src/main/storage/paths.ts | 4 + src/main/storage/performanceLog.ts | 62 +++ src/preload/api/index.ts | 2 + src/preload/api/performance.ts | 25 ++ src/providers/claude/runtime/claudeSession.ts | 4 + src/providers/claude/runtime/sessionList.ts | 21 + src/providers/codex/runtime/codexSession.ts | 4 + src/providers/codex/runtime/sessionList.ts | 10 + src/renderer/src/app-state/types.ts | 1 + src/renderer/src/app-state/uiShell/slice.ts | 7 + src/renderer/src/app-state/uiShell/types.ts | 1 + src/renderer/src/app/App.tsx | 20 + src/renderer/src/app/main.tsx | 5 + .../src/features/command-palette/types.ts | 2 + .../command-palette/ui/CommandPalette.tsx | 8 + .../src/features/debug/saveDebugBundle.ts | 3 + src/renderer/src/features/feed/ui/Feed.tsx | 43 +- .../performance/ui/PerformancePanel.tsx | 133 +++++++ .../workspace/commands/layoutCommands.ts | 10 + src/renderer/src/performance/client.ts | 335 ++++++++++++++++ .../src/workspace/hook/actions/history.ts | 28 +- .../workspace/hook/ipc/useIpcSubscriptions.ts | 68 +++- .../workspace/hook/persistence/rehydrate.ts | 17 + .../workspace/hook/persistence/useAutoSave.ts | 28 +- .../hook/persistence/useBootstrap.ts | 44 ++- .../src/workspace/tile-tree/TerminalLeaf.tsx | 11 +- .../src/workspace/tile-tree/TileLeaf.tsx | 3 + .../tile-tree/TileLeaf/PaneHeader.tsx | 15 +- .../src/workspace/tile-tree/TileTree.tsx | 4 + .../src/workspace/tile-tree/paneLabels.ts | 25 ++ src/shared/performance/types.ts | 72 ++++ src/shared/runtime/terminalSession.ts | 4 + src/shared/types/providerConfig.ts | 1 + tsconfig.web.json | 1 + 46 files changed, 2110 insertions(+), 155 deletions(-) create mode 100644 src/main/ipc/performance.ts create mode 100644 src/main/performance/LocalJsonlSpanExporter.ts create mode 100644 src/main/performance/PerformanceService.ts create mode 100644 src/main/performance/ProcessTelemetry.ts create mode 100644 src/main/performance/instrumentIpc.ts create mode 100644 src/main/storage/performanceLog.ts create mode 100644 src/preload/api/performance.ts create mode 100644 src/renderer/src/features/performance/ui/PerformancePanel.tsx create mode 100644 src/renderer/src/performance/client.ts create mode 100644 src/renderer/src/workspace/tile-tree/paneLabels.ts create mode 100644 src/shared/performance/types.ts diff --git a/package-lock.json b/package-lock.json index d8fb475c4..290b1f27f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,20 @@ "version": "0.0.1", "hasInstallScript": true, "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/resources": "^2.7.0", + "@opentelemetry/sdk-trace-base": "^2.7.0", + "@opentelemetry/sdk-trace-node": "^2.7.0", "@xterm/addon-fit": "^0.11.0", "@xterm/headless": "^5.5.0", "@xterm/xterm": "^6.0.0", - "agent-transcript-parser": "file:./agent-transcript-parser", + "agent-transcript-parser": "file:./packages/agent-transcript-parser", "chokidar": "^5.0.0", "highlight.js": "^11.11.1", "monaco-editor": "^0.52.2", "node-pty": "^1.0.0", + "pidtree": "^0.6.0", + "pidusage": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -45,6 +51,7 @@ }, "agent-transcript-parser": { "version": "0.1.0", + "extraneous": true, "license": "MIT", "devDependencies": { "node-pty": "^1.1.0", @@ -1042,6 +1049,101 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.0.tgz", + "integrity": "sha512-MWXggArM+Y11mPS8VOrqxOj+YMGQSRuvhM91eSBX4xFpJa05mpkeVvM8pPux5ElkEjV5RMgrkisrlP/R83SpBQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.0.tgz", + "integrity": "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.0.tgz", + "integrity": "sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.0.tgz", + "integrity": "sha512-Yg9zEXJB50DLVLpsKPk7NmNqlPlS+OvqhJGh0A8oawIOTPOwlm4eXs9BMJV7L79lvEwI+dWtAj+YjTyddV336A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.0.tgz", + "integrity": "sha512-RrFHOXw0IYp/OThew6QORdybnnLitUAUMCJKcQNBYS0hDkCYarO2vTkVxfrGxCIqd5XHSMvbCpBd/T8ZMw8oSg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.7.0", + "@opentelemetry/core": "2.7.0", + "@opentelemetry/sdk-trace-base": "2.7.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1141,9 +1243,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1158,9 +1257,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1175,9 +1271,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1192,9 +1285,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1209,9 +1299,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1226,9 +1313,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1243,9 +1327,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1260,9 +1341,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1277,9 +1355,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1294,9 +1369,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1311,9 +1383,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1328,9 +1397,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1345,9 +1411,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1597,9 +1660,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1617,9 +1677,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1637,9 +1694,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1657,9 +1711,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2013,7 +2064,7 @@ } }, "node_modules/agent-transcript-parser": { - "resolved": "agent-transcript-parser", + "resolved": "packages/agent-transcript-parser", "link": true }, "node_modules/agentkeepalive": { @@ -2043,6 +2094,41 @@ "node": ">=8" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2963,6 +3049,30 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -3585,6 +3695,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -3772,9 +3889,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3796,9 +3910,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3820,9 +3931,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3844,9 +3952,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5326,6 +5431,30 @@ "dev": true, "license": "ISC" }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pidusage": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz", + "integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postcss": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", @@ -5641,6 +5770,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -5826,7 +5965,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -7126,6 +7264,17 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/agent-transcript-parser": { + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", + "node-pty": "^1.1.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } } } } diff --git a/package.json b/package.json index 177fcc52f..f9f84ae48 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "postinstall": "electron-rebuild -f -w node-pty" }, "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/resources": "^2.7.0", + "@opentelemetry/sdk-trace-base": "^2.7.0", + "@opentelemetry/sdk-trace-node": "^2.7.0", "@xterm/addon-fit": "^0.11.0", "@xterm/headless": "^5.5.0", "@xterm/xterm": "^6.0.0", @@ -24,6 +28,8 @@ "highlight.js": "^11.11.1", "monaco-editor": "^0.52.2", "node-pty": "^1.0.0", + "pidtree": "^0.6.0", + "pidusage": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", diff --git a/src/main/index.ts b/src/main/index.ts index 22bd337ea..acb33637f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow } from 'electron' import { readFile } from 'fs/promises' +import { performance } from 'perf_hooks' import { SessionManager } from '@main/sessionManager.js' import { LspManager } from '@main/lspManager.js' @@ -12,6 +13,7 @@ import { cleanupClaudeImageCacheDir } from '@main/storage/claudeImageCache.js' import { createMainWindow } from '@main/window/mainWindow.js' import { wireSessionForwarder } from '@main/sessions/forwarder.js' import { registerAllIpc } from '@main/ipc/index.js' +import { performanceService } from '@main/performance/PerformanceService.js' // Main process — thin Electron host. // @@ -52,18 +54,33 @@ const ghostJournals = new GhostJournalRegistry() let manager: SessionManager = null as unknown as SessionManager let tmuxRegistry: TmuxRegistry | null = null +void performanceService.start().catch(err => { + console.warn('[performance] failed to start:', err) +}) + // ---------- App lifecycle ---------- app.whenReady().then(async () => { + performanceService.mark('app.main.whenReady.start') await cleanupClaudeImageCacheDir().catch(err => { console.warn('[images] failed to clean Claude image cache:', err) + performanceService.error('app.main.imageCache.cleanup.error', err) }) // Tmux availability is checked once at startup. The cost is a // child-process roundtrip on `tmux -V` — cheap enough to await // before any IPC is wired. Result is cached on the registry; call // sites use isAvailable() synchronously thereafter. tmuxRegistry = new TmuxRegistry() + const tmuxDetectStarted = performance.now() const tmuxAvailable = await tmuxRegistry.detectAvailability() + performanceService.record({ + kind: 'span_end', + process: 'main', + area: 'app.tmux', + name: 'app.tmux.detect', + durationMs: performance.now() - tmuxDetectStarted, + data: { available: tmuxAvailable }, + }) console.log( tmuxAvailable ? '[tmux] available — terminals will persist across restarts' @@ -95,6 +112,11 @@ app.whenReady().then(async () => { .filter(([, meta]) => meta?.kind === 'terminal' && typeof meta?.tmuxName === 'string') .map(([sessionId, meta]) => ({ sessionId, tmuxName: meta!.tmuxName! })) const recoveryReport = await reconcile(tmuxRegistry, persisted) + performanceService.mark('app.tmux.recovery.complete', { + recoverable: recoveryReport.recoverable.length, + lost: recoveryReport.lost.length, + orphans: recoveryReport.orphans.length, + }) console.log( `[tmux] recovery: ${recoveryReport.recoverable.length} recoverable, ${recoveryReport.lost.length} lost, ${recoveryReport.orphans.length} orphans cleaned`, ) @@ -103,15 +125,19 @@ app.whenReady().then(async () => { // through with empty buckets. Log so a real failure is visible. if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.warn('[tmux] recovery failed (treating all sessions as fresh):', err) + performanceService.error('app.tmux.recovery.error', err) } } } manager = new SessionManager(tmuxAvailable ? tmuxRegistry : null) + performanceService.mark('app.main.sessionManager.created') wireSessionForwarder(manager, lspManager) registerAllIpc({ manager, lspManager, ghostJournals }) + performanceService.mark('app.main.ipc.registered') createMainWindow() + performanceService.mark('app.main.window.created') app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createMainWindow() @@ -125,6 +151,7 @@ app.on('window-all-closed', () => { }) app.on('before-quit', () => { + performanceService.mark('app.main.beforeQuit') void manager.killAll() void lspManager.dispose() // Flush pending ghost writes. Fire-and-forget is fine — Electron's @@ -132,4 +159,5 @@ app.on('before-quit', () => { // worst-case; in practice drains are empty at quit time because // streaming is idle. void ghostJournals.flushAll() + performanceService.stop() }) diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index e8b5a1ee2..891c368ed 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -11,6 +11,8 @@ import { registerWorkspaceIpc } from '@main/ipc/workspace.js' import { registerGhostIpc } from '@main/ipc/ghost.js' import { registerDebugIpc } from '@main/ipc/debug.js' import { registerGitIpc } from '@main/ipc/git.js' +import { registerPerformanceIpc } from '@main/ipc/performance.js' +import { installPerformanceIpcInstrumentation } from '@main/performance/instrumentIpc.js' // IPC registration aggregator. // @@ -28,6 +30,8 @@ export type IpcDeps = { } export function registerAllIpc(deps: IpcDeps): void { + registerPerformanceIpc(deps.manager) + installPerformanceIpcInstrumentation() registerSessionIpc(deps.manager) registerProviderIpc() registerLspIpc(deps.lspManager) diff --git a/src/main/ipc/performance.ts b/src/main/ipc/performance.ts new file mode 100644 index 000000000..95bfe3caa --- /dev/null +++ b/src/main/ipc/performance.ts @@ -0,0 +1,27 @@ +import { ipcMain } from 'electron' + +import { performanceService } from '@main/performance/PerformanceService.js' +import { ProcessTelemetry } from '@main/performance/ProcessTelemetry.js' +import type { SessionManager } from '@main/sessionManager.js' +import type { PerformanceRecord } from '@shared/performance/types.js' + +export function registerPerformanceIpc(manager: SessionManager): void { + const processTelemetry = new ProcessTelemetry(manager) + + ipcMain.handle('performance:get-config', () => performanceService.getConfig()) + + ipcMain.handle('performance:batch', async (_evt, records: PerformanceRecord[]) => { + if (!Array.isArray(records) || records.length === 0) return + performanceService.recordBatch(records) + }) + + ipcMain.handle('performance:flush', async () => { + await performanceService.flush() + }) + + ipcMain.handle('performance:snapshot', async () => performanceService.snapshot()) + + ipcMain.handle('performance:pane-stats', async (_evt, sessionIds?: string[]) => + processTelemetry.snapshot(Array.isArray(sessionIds) ? sessionIds : undefined), + ) +} diff --git a/src/main/performance/LocalJsonlSpanExporter.ts b/src/main/performance/LocalJsonlSpanExporter.ts new file mode 100644 index 000000000..d40f71681 --- /dev/null +++ b/src/main/performance/LocalJsonlSpanExporter.ts @@ -0,0 +1,64 @@ +import { ExportResultCode } from '@opentelemetry/core' +import type { SpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base' + +import { queuePerformanceAppend } from '@main/storage/performanceLog.js' + +function hrTimeToUnixMs(hrTime: readonly [number, number]): number { + return (hrTime[0] * 1000) + (hrTime[1] / 1e6) +} + +function hrDurationToMs(hrTime: readonly [number, number]): number { + return (hrTime[0] * 1000) + (hrTime[1] / 1e6) +} + +export class LocalJsonlSpanExporter implements SpanExporter { + constructor( + private readonly runDir: string, + private readonly slowSpanMs: number, + ) {} + + export(spans: ReadableSpan[], resultCallback: (result: { code: ExportResultCode; error?: Error }) => void): void { + const spanLines: string[] = [] + const slowLines: string[] = [] + + for (const span of spans) { + const startMs = hrTimeToUnixMs(span.startTime) + const durationMs = hrDurationToMs(span.duration) + const payload = { + kind: 'otel_span', + traceId: span.spanContext().traceId, + spanId: span.spanContext().spanId, + parentSpanId: span.parentSpanContext?.spanId, + name: span.name, + status: span.status, + attributes: span.attributes, + events: span.events.map(event => ({ + name: event.name, + ts: hrTimeToUnixMs(event.time), + tsIso: new Date(hrTimeToUnixMs(event.time)).toISOString(), + attributes: event.attributes, + })), + startedAt: startMs, + startedAtIso: new Date(startMs).toISOString(), + endedAt: hrTimeToUnixMs(span.endTime), + durationMs, + resource: span.resource.attributes, + instrumentationScope: span.instrumentationScope, + } + const line = JSON.stringify(payload) + spanLines.push(line) + if (durationMs >= this.slowSpanMs) slowLines.push(line) + } + + Promise.all([ + queuePerformanceAppend(this.runDir, 'spans', spanLines), + queuePerformanceAppend(this.runDir, 'slow', slowLines), + ]) + .then(() => resultCallback({ code: ExportResultCode.SUCCESS })) + .catch(error => resultCallback({ code: ExportResultCode.FAILED, error })) + } + + async shutdown(): Promise {} + + async forceFlush(): Promise {} +} diff --git a/src/main/performance/PerformanceService.ts b/src/main/performance/PerformanceService.ts new file mode 100644 index 000000000..f51a1c45a --- /dev/null +++ b/src/main/performance/PerformanceService.ts @@ -0,0 +1,371 @@ +import { SpanStatusCode, trace, type Attributes } from '@opentelemetry/api' +import { resourceFromAttributes } from '@opentelemetry/resources' +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base' +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node' +import { monitorEventLoopDelay, performance } from 'perf_hooks' +import { arch, platform } from 'os' +import { pid, versions } from 'process' + +import type { + PerformanceConfig, + PanePerformanceStats, + PerformanceRecord, + PerformanceSnapshot, +} from '@shared/performance/types.js' +import { + ensurePerformanceRunDir, + queuePerformanceAppend, + readPerformanceTail, + writePerformanceManifest, + type PerformanceLogFile, +} from '@main/storage/performanceLog.js' +import { LocalJsonlSpanExporter } from '@main/performance/LocalJsonlSpanExporter.js' + +const DEFAULT_SLOW_SPAN_MS = 50 +const FLUSH_INTERVAL_MS = 500 +const SAMPLE_INTERVAL_MS = 5000 + +function envFlag(name: string): boolean { + const value = process.env[name] + return value === '1' || value === 'true' || value === 'yes' +} + +function envNumber(name: string, fallback: number): number { + const raw = process.env[name] + if (!raw) return fallback + const parsed = Number(raw) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback +} + +function runFolderName(now: Date, pidValue: number): string { + const stamp = now.toISOString().replace(/\..+$/, '').replace(/:/g, '-') + return `${stamp}-main-${pidValue}` +} + +function serializeError(error: unknown): PerformanceRecord['error'] { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + } + } + return { message: String(error) } +} + +function recordFile(record: PerformanceRecord): PerformanceLogFile { + if (record.kind === 'metric') return 'metrics' + if (record.kind === 'error') return 'errors' + return 'events' +} + +export class PerformanceService { + private readonly enabled = envFlag('CC_SHELL_PERF') + private readonly verbose = envFlag('CC_SHELL_PERF_VERBOSE') + private readonly slowSpanMs = envNumber('CC_SHELL_PERF_SLOW_MS', DEFAULT_SLOW_SPAN_MS) + private readonly runId = this.enabled ? `${Date.now()}-${pid}` : null + private runDir: string | null = null + private tracerProvider: NodeTracerProvider | null = null + private tracer = trace.getTracer('cc-shell-main') + private flushTimer: ReturnType | null = null + private sampleTimer: ReturnType | null = null + private eventLoopDelay: ReturnType | null = null + private pending: PerformanceRecord[] = [] + + getConfig(): PerformanceConfig { + return { + enabled: this.enabled, + verbose: this.verbose, + slowSpanMs: this.slowSpanMs, + runId: this.runId, + runDir: this.runDir, + } + } + + async start(): Promise { + if (!this.enabled || this.runDir) return + const startedAt = new Date() + this.runDir = await ensurePerformanceRunDir(runFolderName(startedAt, pid)) + this.tracerProvider = new NodeTracerProvider({ + resource: resourceFromAttributes({ + 'service.name': 'cc-shell', + 'service.namespace': 'desktop', + 'process.runtime.name': 'electron-main', + 'process.pid': pid, + }), + spanProcessors: [ + new BatchSpanProcessor( + new LocalJsonlSpanExporter(this.runDir, this.slowSpanMs), + { + scheduledDelayMillis: FLUSH_INTERVAL_MS, + maxExportBatchSize: 128, + maxQueueSize: 2048, + }, + ), + ], + }) + this.tracerProvider.register() + this.tracer = trace.getTracer('cc-shell-main') + + await writePerformanceManifest(this.runDir, { + schemaVersion: 2, + tracing: 'opentelemetry', + runId: this.runId, + startedAt: startedAt.getTime(), + startedAtIso: startedAt.toISOString(), + pid, + platform: platform(), + arch: arch(), + node: versions.node, + electron: versions.electron, + chrome: versions.chrome, + env: { + CC_SHELL_PERF: process.env.CC_SHELL_PERF ?? null, + CC_SHELL_PERF_VERBOSE: process.env.CC_SHELL_PERF_VERBOSE ?? null, + CC_SHELL_PERF_SLOW_MS: process.env.CC_SHELL_PERF_SLOW_MS ?? null, + }, + }) + this.startFlushTimer() + this.startProbes() + this.mark('app.main.performance.started') + } + + stop(): void { + if (this.flushTimer) clearInterval(this.flushTimer) + if (this.sampleTimer) clearInterval(this.sampleTimer) + this.flushTimer = null + this.sampleTimer = null + this.eventLoopDelay?.disable() + this.eventLoopDelay = null + void this.flush() + void this.tracerProvider?.shutdown() + } + + mark(name: string, data?: Record): void { + this.record({ + kind: 'mark', + process: 'main', + area: areaFromName(name), + name, + data, + }) + } + + metric( + name: string, + value: number, + metricType: 'counter' | 'gauge' | 'sample' = 'sample', + data?: Record, + ): void { + this.record({ + kind: 'metric', + process: 'main', + area: areaFromName(name), + name, + metricType, + value, + data, + }) + } + + error(name: string, error: unknown, data?: Record): void { + this.record({ + kind: 'error', + process: 'main', + area: areaFromName(name), + name, + level: 'error', + error: serializeError(error), + data, + }) + } + + recordBatch(records: PerformanceRecord[]): void { + if (!this.enabled || records.length === 0) return + for (const record of records) this.record(record) + } + + record(input: PerformanceRecord): void { + if (!this.enabled) return + if (input.kind === 'span_end') { + this.recordSpan(input) + return + } + if (input.kind === 'span_start') return + const ts = input.ts ?? Date.now() + const record: PerformanceRecord = { + ...input, + ts, + tsIso: input.tsIso ?? new Date(ts).toISOString(), + monotonicMs: input.monotonicMs ?? performance.now(), + runId: input.runId ?? this.runId ?? undefined, + data: sanitizeData(input.data, this.verbose), + } + this.pending.push(record) + } + + span(name: string, attributes?: Record) { + if (!this.enabled) { + return { + end() {}, + fail() {}, + } + } + const span = this.tracer.startSpan(name, { + attributes: toAttributes(sanitizeData(attributes, this.verbose)), + }) + return { + end: (endAttributes?: Record) => { + const attrs = toAttributes(sanitizeData(endAttributes, this.verbose)) + if (attrs) span.setAttributes(attrs) + span.end() + }, + fail: (err: unknown, endAttributes?: Record) => { + const attrs = toAttributes(sanitizeData(endAttributes, this.verbose)) + if (attrs) span.setAttributes(attrs) + span.recordException(err instanceof Error ? err : String(err)) + span.setStatus({ code: SpanStatusCode.ERROR, message: String((err as Error)?.message ?? err) }) + span.end() + }, + } + } + + async flush(): Promise { + if (!this.enabled || !this.runDir) return + const batch = this.pending + this.pending = [] + const grouped = new Map() + for (const record of batch) { + const file = recordFile(record) + const lines = grouped.get(file) ?? [] + lines.push(JSON.stringify(record)) + grouped.set(file, lines) + } + await Promise.all([ + ...[...grouped.entries()].map(([file, lines]) => + queuePerformanceAppend(this.runDir!, file, lines), + ), + this.tracerProvider?.forceFlush() ?? Promise.resolve(), + ]) + } + + async snapshot(): Promise { + if (!this.enabled || !this.runDir) { + return { runId: this.runId, runDir: this.runDir, files: [] } + } + await this.flush() + const files = await Promise.all( + (['events', 'spans', 'metrics', 'errors', 'slow', 'pane-process'] as PerformanceLogFile[]).map( + async file => ({ + name: `performance/${file}-tail.jsonl`, + content: await readPerformanceTail(this.runDir!, file), + }), + ), + ) + return { + runId: this.runId, + runDir: this.runDir, + files, + } + } + + async recordPaneProcessStats(stats: PanePerformanceStats[]): Promise { + if (!this.enabled || !this.runDir || stats.length === 0) return + await queuePerformanceAppend( + this.runDir, + 'pane-process', + stats.map(stat => JSON.stringify(stat)), + ) + } + + private recordSpan(input: PerformanceRecord): void { + const durationMs = input.durationMs ?? 0 + const endTime = input.ts ?? Date.now() + const startTime = Math.max(0, endTime - durationMs) + const span = this.tracer.startSpan(input.name, { + startTime, + attributes: toAttributes({ + ...sanitizeData(input.data, this.verbose), + 'cc.process': input.process, + 'cc.area': input.area, + ...(input.sessionId ? { 'cc.session_id': input.sessionId } : {}), + ...(input.tabId ? { 'cc.tab_id': input.tabId } : {}), + ...(input.provider ? { 'cc.provider': input.provider } : {}), + }), + }) + if (input.error) { + span.recordException(input.error.message) + span.setStatus({ code: SpanStatusCode.ERROR, message: input.error.message }) + } + span.end(endTime) + } + + private startFlushTimer(): void { + this.flushTimer = setInterval(() => { + void this.flush().catch(err => { + console.warn('[performance] flush failed:', err) + }) + }, FLUSH_INTERVAL_MS) + this.flushTimer.unref?.() + } + + private startProbes(): void { + this.eventLoopDelay = monitorEventLoopDelay({ resolution: 20 }) + this.eventLoopDelay.enable() + this.sampleTimer = setInterval(() => { + const memory = process.memoryUsage() + this.metric('main.memory.rss', memory.rss, 'gauge', { heapUsed: memory.heapUsed }) + if (this.eventLoopDelay) { + this.metric('main.eventLoop.delay.mean', this.eventLoopDelay.mean / 1e6, 'sample', { + maxMs: this.eventLoopDelay.max / 1e6, + p99Ms: this.eventLoopDelay.percentile(99) / 1e6, + }) + this.eventLoopDelay.reset() + } + }, SAMPLE_INTERVAL_MS) + this.sampleTimer.unref?.() + } +} + +function areaFromName(name: string): string { + const parts = name.split('.') + return parts.length >= 2 ? `${parts[0]}.${parts[1]}` : parts[0] || 'app' +} + +function sanitizeData( + data: Record | undefined, + verbose: boolean, +): Record | undefined { + if (!data) return undefined + const out: Record = {} + for (const [key, value] of Object.entries(data)) { + if (!verbose && /prompt|content|text|env|token|secret|key/i.test(key)) continue + if (typeof value === 'string' && value.length > (verbose ? 2000 : 300)) { + out[key] = `${value.slice(0, verbose ? 2000 : 300)}...` + } else { + out[key] = value + } + } + return out +} + +function toAttributes(data: Record | undefined): Attributes | undefined { + if (!data) return undefined + const out: Attributes = {} + for (const [key, value] of Object.entries(data)) { + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + out[key] = value + } else if (value == null) { + continue + } else { + out[key] = JSON.stringify(value) + } + } + return out +} + +export const performanceService = new PerformanceService() diff --git a/src/main/performance/ProcessTelemetry.ts b/src/main/performance/ProcessTelemetry.ts new file mode 100644 index 000000000..814e44588 --- /dev/null +++ b/src/main/performance/ProcessTelemetry.ts @@ -0,0 +1,94 @@ +import pidtree from 'pidtree' +import pidusage from 'pidusage' + +import type { SessionManager } from '@main/sessionManager.js' +import { performanceService } from '@main/performance/PerformanceService.js' +import type { + PanePerformanceSnapshot, + PanePerformanceStats, +} from '@shared/performance/types.js' + +type UsageStat = { + cpu: number + memory: number +} + +export class ProcessTelemetry { + constructor(private readonly manager: SessionManager) {} + + async snapshot(sessionIds?: string[]): Promise { + const sampledAt = Date.now() + const enabled = performanceService.getConfig().enabled + if (!enabled) { + return { enabled: false, sampledAt, panes: [] } + } + + const targets = this.manager.getProcessTelemetryTargets(sessionIds) + const panes = await Promise.all( + targets.map(async target => { + if (!target.pid || target.exited) { + return { + sessionId: target.sessionId, + kind: target.kind, + status: target.exited ? 'exited' : 'unknown', + rootPid: target.pid, + cpuPercent: null, + memoryBytes: null, + childCount: 0, + lastActivityAt: target.lastActivityAt, + sampledAt, + } satisfies PanePerformanceStats + } + + try { + const pids = await pidtree(target.pid, { root: true }) + const usage = await pidusage(pids) + const stats = Array.isArray(pids) + ? pids.map(pid => usage[String(pid)]).filter(Boolean) + : [] + const totals = stats.reduce( + (acc, stat) => ({ + cpu: acc.cpu + stat.cpu, + memory: acc.memory + stat.memory, + }), + { cpu: 0, memory: 0 }, + ) + return { + sessionId: target.sessionId, + kind: target.kind, + status: target.exited ? 'exited' : activityStatus(target.lastActivityAt, sampledAt), + rootPid: target.pid, + cpuPercent: totals.cpu, + memoryBytes: totals.memory, + childCount: Math.max(0, pids.length - 1), + lastActivityAt: target.lastActivityAt, + sampledAt, + } satisfies PanePerformanceStats + } catch { + return { + sessionId: target.sessionId, + kind: target.kind, + status: target.exited ? 'exited' : 'unknown', + rootPid: target.pid, + cpuPercent: null, + memoryBytes: null, + childCount: 0, + lastActivityAt: target.lastActivityAt, + sampledAt, + } satisfies PanePerformanceStats + } + }), + ) + + await performanceService.recordPaneProcessStats(panes) + return { enabled: true, sampledAt, panes } + } +} + +function activityStatus( + lastActivityAt: number | null, + sampledAt: number, +): PanePerformanceStats['status'] { + if (!lastActivityAt) return 'unknown' + return sampledAt - lastActivityAt > 30_000 ? 'idle' : 'running' +} diff --git a/src/main/performance/instrumentIpc.ts b/src/main/performance/instrumentIpc.ts new file mode 100644 index 000000000..d0d88922a --- /dev/null +++ b/src/main/performance/instrumentIpc.ts @@ -0,0 +1,41 @@ +import { ipcMain } from 'electron' +import { performance } from 'perf_hooks' + +import { performanceService } from '@main/performance/PerformanceService.js' + +let installed = false + +export function installPerformanceIpcInstrumentation(): void { + if (installed || !performanceService.getConfig().enabled) return + installed = true + const originalHandle = ipcMain.handle.bind(ipcMain) + + ipcMain.handle = ((channel: string, listener: (...args: unknown[]) => unknown) => + originalHandle(channel, async (...args: unknown[]) => { + const startedAt = performance.now() + try { + const result = await listener(...args) + performanceService.record({ + kind: 'span_end', + process: 'main', + area: 'ipc.handle', + name: `ipc.handle.${channel}`, + durationMs: performance.now() - startedAt, + data: { channel }, + }) + return result + } catch (err) { + performanceService.record({ + kind: 'span_end', + process: 'main', + area: 'ipc.handle', + name: `ipc.handle.${channel}`, + level: 'error', + durationMs: performance.now() - startedAt, + data: { channel }, + }) + performanceService.error(`ipc.handle.${channel}.error`, err) + throw err + } + })) as typeof ipcMain.handle +} diff --git a/src/main/sessionIndex.ts b/src/main/sessionIndex.ts index 221119ff9..5028ad348 100644 --- a/src/main/sessionIndex.ts +++ b/src/main/sessionIndex.ts @@ -4,6 +4,7 @@ import { join } from 'path' import { listSessionsForCwd } from '@providers/claude/runtime/sessionList.js' import { getProjectDirForCwd } from '@shared/runtime/projectDir.js' import { getCodexSessionsDir } from '@providers/codex/runtime/projectDir.js' +import { performanceService } from '@main/performance/PerformanceService.js' // Session Prompt Index — power source for the "Search Conversation // Prompts" command. @@ -262,15 +263,22 @@ async function extractPromptsFromFile( sessionId: string, file: string, ): Promise<{ prompts: SessionIndexPrompt[]; cwd: string }> { + const span = performanceService.span('sessionIndex.extractPrompts', { + kind, + sessionId, + file, + }) const cached = promptCache.get(cacheKey(kind, sessionId)) let mtime: number try { const st = await stat(file) mtime = st.mtime.getTime() } catch { + span.end({ result: 'stat-failed' }) return { prompts: [], cwd: '' } } if (cached && cached.mtime === mtime) { + span.end({ result: 'cache-hit', prompts: cached.prompts.length }) return { prompts: cached.prompts, cwd: cached.cwd } } @@ -278,6 +286,7 @@ async function extractPromptsFromFile( try { text = await readFile(file, 'utf-8') } catch { + span.end({ result: 'read-failed' }) return { prompts: [], cwd: '' } } @@ -291,6 +300,12 @@ async function extractPromptsFromFile( prompts: parsed.prompts, cwd: parsed.cwd, }) + span.end({ + result: 'parsed', + bytes: text.length, + prompts: parsed.prompts.length, + hasCwd: parsed.cwd.length > 0, + }) return parsed } @@ -525,12 +540,18 @@ async function claudeCwdFromProjectDir(file: string): Promise { export async function listRecentSessionsWithPrompts( options: ListRecentOptions = {}, ): Promise { + const span = performanceService.span('sessionIndex.listRecent', { + limit: options.limit ?? 10, + promptsPerSession: options.promptsPerSession ?? 4, + cwdScoped: Boolean(options.cwd), + }) const limit = options.limit ?? 10 const promptsPerSession = options.promptsPerSession ?? 4 const cwd = options.cwd ?? null - const claude = await discoverClaudeSessions(cwd) - const codexFiles = await discoverCodexSessions() + try { + const claude = await discoverClaudeSessions(cwd) + const codexFiles = await discoverCodexSessions() // Unify into one discovery list with provider tagged. const candidates: Array<{ @@ -598,7 +619,16 @@ export async function listRecentSessionsWithPrompts( matchCount: 0, }) } - return results + span.end({ + claudeCandidates: claude.length, + codexCandidates: codexFiles.length, + results: results.length, + }) + return results + } catch (err) { + span.fail(err) + throw err + } } /** Search every session's prompts for the query. Matching sessions @@ -611,12 +641,19 @@ export async function searchSessionPrompts( const q = options.query.trim() if (!q) return listRecentSessionsWithPrompts(options) + const span = performanceService.span('sessionIndex.search', { + limit: options.limit ?? 20, + promptsPerSession: options.promptsPerSession ?? 8, + cwdScoped: Boolean(options.cwd), + queryLength: q.length, + }) const limit = options.limit ?? 20 const promptsPerSession = options.promptsPerSession ?? 8 const cwd = options.cwd ?? null - const claude = await discoverClaudeSessions(cwd) - const codex = await discoverCodexSessions() + try { + const claude = await discoverClaudeSessions(cwd) + const codex = await discoverCodexSessions() const candidates: Array<{ kind: 'claude' | 'codex' providerSessionId: string @@ -719,7 +756,18 @@ export async function searchSessionPrompts( return b.entry.lastModified - a.entry.lastModified }) - return scored.slice(0, limit).map(s => s.entry) + const results = scored.slice(0, limit).map(s => s.entry) + span.end({ + claudeCandidates: claude.length, + codexCandidates: codex.length, + scored: scored.length, + results: results.length, + }) + return results + } catch (err) { + span.fail(err) + throw err + } } function escapeRegex(s: string): string { diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index d06548018..f9160a2d1 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -1,11 +1,13 @@ import { randomUUID } from 'crypto' import { EventEmitter } from 'events' +import { performance } from 'perf_hooks' import { getMainProvider } from '@providers/registry.main.js' import type { ScreenSnapshot } from '@providers/claude/runtime/claudeSession.js' import { TerminalSession } from '@shared/runtime/terminalSession.js' import type { JsonlEntry } from 'claude-code-headless' import { TmuxRegistry } from '@main/tmux/TmuxRegistry.js' +import { performanceService } from '@main/performance/PerformanceService.js' // SessionManager: a thin registry on top of ClaudeSession / TerminalSession // that lets the main process run N sessions in parallel. Every event @@ -169,6 +171,8 @@ interface AgentSessionLike { resize(cols: number, rows: number): void stop(): Promise start(): Promise + getProcessPid?(): number | null + isExited?(): boolean removeAllListeners?(event?: string): this } @@ -190,6 +194,7 @@ const TERMINAL_BUFFER_CAP = 256 * 1024 export class SessionManager extends EventEmitter { private readonly sessions = new Map() + private readonly lastActivityAt = new Map() // Optional tmux backing for terminal sessions. Constructed by the // app entrypoint AFTER detectAvailability() has resolved — we only @@ -231,6 +236,10 @@ export class SessionManager extends EventEmitter { private readonly terminalBuffers = new Map() private readonly terminalAttached = new Set() + private markActivity(sessionId: string): void { + this.lastActivityAt.set(sessionId, Date.now()) + } + /** * Spawn a new session and return its sessionId. Blocks until the PTY * is spawned — after this resolves the caller can immediately start @@ -242,6 +251,13 @@ export class SessionManager extends EventEmitter { async spawn(options: SpawnOptions): Promise { const kind: SessionKind = options.kind ?? 'claude' const sessionId = randomUUID() + const spawnStartedAt = performance.now() + performanceService.mark('session.spawn.start', { + sessionId, + kind, + resume: Boolean(options.resumeSessionId), + useProxy: Boolean(options.useProxy), + }) // Agent providers (claude, codex) — dispatched through the registry. // Both providers emit the same event shape (started, pty-data, @@ -251,6 +267,7 @@ export class SessionManager extends EventEmitter { // cross-provider breakage when editing one provider's spawn logic. if (kind === 'claude' || kind === 'codex') { const provider = getMainProvider(kind) + const createStartedAt = performance.now() // `session` here structurally conforms to AgentSessionLike — // every provider that registers through the registry is // contracted to expose start/stop/write/resize + the standard @@ -274,24 +291,43 @@ export class SessionManager extends EventEmitter { // `openai_base_url`. useProxy: options.useProxy, }) as unknown as AgentSessionLike + performanceService.record({ + kind: 'span_end', + process: 'main', + area: 'session.spawn', + name: 'session.spawn.providerCreate', + durationMs: performance.now() - createStartedAt, + sessionId, + provider: kind, + }) session.on('started', ({ projectDir }: { projectDir: string }) => - this.emit('started', { sessionId, kind, projectDir }), - ) - session.on('pty-data', (data: string) => - this.emit('pty-data', { sessionId, data }), - ) - session.on('screen', (snap: ScreenSnapshot) => - this.emit('screen', { sessionId, ...snap }), - ) - session.on('jsonl-entry', (entry: JsonlEntry, file: string) => - this.emit('jsonl-entry', { sessionId, entry, file }), - ) - session.on('jsonl-error', (error: Error) => - this.emit('jsonl-error', { sessionId, error }), + { + this.markActivity(sessionId) + this.emit('started', { sessionId, kind, projectDir }) + }, ) + session.on('pty-data', (data: string) => { + this.markActivity(sessionId) + this.emit('pty-data', { sessionId, data }) + }) + session.on('screen', (snap: ScreenSnapshot) => { + this.markActivity(sessionId) + this.emit('screen', { sessionId, ...snap }) + }) + session.on('jsonl-entry', (entry: JsonlEntry, file: string) => { + this.markActivity(sessionId) + this.emit('jsonl-entry', { sessionId, entry, file }) + }) + session.on('jsonl-error', (error: Error) => { + this.markActivity(sessionId) + this.emit('jsonl-error', { sessionId, error }) + }) session.on('process-state', (state: { active: boolean; status?: string }) => - this.emit('process-state', { sessionId, ...state }), + { + this.markActivity(sessionId) + this.emit('process-state', { sessionId, ...state }) + }, ) session.on('trust-dialog', (state: { visible: boolean; workspace?: string }) => this.emit('trust-dialog', { sessionId, ...state }), @@ -317,17 +353,29 @@ export class SessionManager extends EventEmitter { statusText?: string errorText?: string }) => this.emit('compaction-state', { sessionId, ...state })) - session.on('semantic-event', (event: unknown) => - this.emit('semantic-event', { sessionId, event }), - ) + session.on('semantic-event', (event: unknown) => { + this.markActivity(sessionId) + this.emit('semantic-event', { sessionId, event }) + }) session.on('exit', ({ exitCode, signal }: { exitCode: number; signal?: number }) => { + this.markActivity(sessionId) this.emit('exit', { sessionId, exitCode, signal }) this.sessions.delete(sessionId) }) this.sessions.set(sessionId, { kind, session }) try { + const startStartedAt = performance.now() await session.start() + performanceService.record({ + kind: 'span_end', + process: 'main', + area: 'session.spawn', + name: 'session.spawn.providerStart', + durationMs: performance.now() - startStartedAt, + sessionId, + provider: kind, + }) } catch (err) { // Same shape as the terminal path below: start() failure must // not leave a dead entry in the registry. The listeners we @@ -337,8 +385,21 @@ export class SessionManager extends EventEmitter { // on `session` because the wrapper already owns its own // EventEmitter — nothing outside the registry subscribed. this.sessions.delete(sessionId) + performanceService.error('session.spawn.providerStart.error', err, { + sessionId, + kind, + }) throw err } + performanceService.record({ + kind: 'span_end', + process: 'main', + area: 'session.spawn', + name: 'session.spawn.total', + durationMs: performance.now() - spawnStartedAt, + sessionId, + provider: kind, + }) return { sessionId } } @@ -352,6 +413,7 @@ export class SessionManager extends EventEmitter { const useTmux = this.tmuxRegistry?.isAvailable() === true let tmuxSessionName: string | null = null if (useTmux) { + const tmuxStartedAt = performance.now() const reg = this.tmuxRegistry! if ( options.recoverTmuxName && @@ -369,6 +431,16 @@ export class SessionManager extends EventEmitter { cwd: options.cwd, }) } + performanceService.record({ + kind: 'span_end', + process: 'main', + area: 'session.spawn', + name: 'session.spawn.tmuxPrepare', + durationMs: performance.now() - tmuxStartedAt, + sessionId, + provider: 'terminal', + data: { recovered: Boolean(options.recoverTmuxName && tmuxSessionName) }, + }) } const session = new TerminalSession({ @@ -391,9 +463,13 @@ export class SessionManager extends EventEmitter { // renderer can route it straight to xterm without the code path // for Claude's structured events getting involved. session.on('started', () => - this.emit('started', { sessionId, kind, projectDir: undefined }), + { + this.markActivity(sessionId) + this.emit('started', { sessionId, kind, projectDir: undefined }) + }, ) session.on('data', data => { + this.markActivity(sessionId) // Always append to the rolling buffer so a later attach can // replay the full history. Cap at TERMINAL_BUFFER_CAP — // longer sessions just lose the oldest bytes, which is the @@ -431,6 +507,7 @@ export class SessionManager extends EventEmitter { } }) session.on('exit', ({ exitCode, signal }) => { + this.markActivity(sessionId) this.emit('exit', { sessionId, exitCode, signal }) this.sessions.delete(sessionId) this.terminalBuffers.delete(sessionId) @@ -439,7 +516,17 @@ export class SessionManager extends EventEmitter { this.sessions.set(sessionId, { kind: 'terminal', session, tmuxName: tmuxSessionName }) try { + const terminalStartStartedAt = performance.now() await session.start() + performanceService.record({ + kind: 'span_end', + process: 'main', + area: 'session.spawn', + name: 'session.spawn.terminalStart', + durationMs: performance.now() - terminalStartStartedAt, + sessionId, + provider: 'terminal', + }) } catch (err) { // start() can fail if the PTY refuses to spawn or (on the tmux // path) the tmux server dies between createSession and attach. @@ -452,8 +539,18 @@ export class SessionManager extends EventEmitter { this.sessions.delete(sessionId) this.terminalBuffers.delete(sessionId) this.terminalAttached.delete(sessionId) + performanceService.error('session.spawn.terminalStart.error', err, { sessionId }) throw err } + performanceService.record({ + kind: 'span_end', + process: 'main', + area: 'session.spawn', + name: 'session.spawn.total', + durationMs: performance.now() - spawnStartedAt, + sessionId, + provider: 'terminal', + }) return { sessionId, tmuxName: tmuxSessionName ?? undefined } } @@ -583,6 +680,39 @@ export class SessionManager extends EventEmitter { return true } + getProcessTelemetryTargets(sessionIds?: string[]): Array<{ + sessionId: string + kind: SessionKind + pid: number | null + exited: boolean + lastActivityAt: number | null + }> { + const ids = sessionIds ?? Array.from(this.sessions.keys()) + return ids.map(sessionId => { + const entry = this.sessions.get(sessionId) + if (!entry) { + return { + sessionId, + kind: 'terminal' as SessionKind, + pid: null, + exited: true, + lastActivityAt: this.lastActivityAt.get(sessionId) ?? null, + } + } + const maybe = entry.session as { + getProcessPid?: () => number | null + isExited?: () => boolean + } + return { + sessionId, + kind: entry.kind, + pid: maybe.getProcessPid?.() ?? null, + exited: maybe.isExited?.() === true, + lastActivityAt: this.lastActivityAt.get(sessionId) ?? null, + } + }) + } + /** List all live session ids. Used for state save / debug. */ list(): string[] { return Array.from(this.sessions.keys()) diff --git a/src/main/sessions/historyLoader.ts b/src/main/sessions/historyLoader.ts index b3290799a..e80ee2859 100644 --- a/src/main/sessions/historyLoader.ts +++ b/src/main/sessions/historyLoader.ts @@ -2,6 +2,7 @@ import { join } from 'path' import { readFile, readdir, stat } from 'fs/promises' import { getMainProvider } from '@providers/registry.main.js' +import { performanceService } from '@main/performance/PerformanceService.js' // Loader for older history chunks. // @@ -94,47 +95,82 @@ async function findCodexRolloutPathByThreadId( export async function loadOlderHistoryChunk( params: HistoryChunkRequest, ): Promise { + const span = performanceService.span('historyLoader.loadOlderChunk', { + kind: params.kind, + limit: params.limit, + hasBeforeMarker: params.beforeMarker.length > 0, + }) const provider = getMainProvider(params.kind) let filePath: string | null = null - if (params.kind === 'claude') { - const projectDir = await provider.getProjectDir(params.cwd) - filePath = join(projectDir, `${params.providerSessionId}.jsonl`) - } else { - const sessionsDir = await provider.getProjectDir(params.cwd) - filePath = await findCodexRolloutPathByThreadId(sessionsDir, params.providerSessionId) - } + try { + if (params.kind === 'claude') { + const projectDir = await provider.getProjectDir(params.cwd) + filePath = join(projectDir, `${params.providerSessionId}.jsonl`) + } else { + const sessionsDir = await provider.getProjectDir(params.cwd) + filePath = await findCodexRolloutPathByThreadId(sessionsDir, params.providerSessionId) + } - if (!filePath) return { entries: [], hasMore: false } + if (!filePath) { + span.end({ result: 'missing-file' }) + return { entries: [], hasMore: false } + } - const text = await readFile(filePath, 'utf8').catch(() => null) - if (!text) return { entries: [], hasMore: false } + const text = await readFile(filePath, 'utf8').catch(() => null) + if (!text) { + span.end({ result: 'read-failed', filePath }) + return { entries: [], hasMore: false } + } - const parsed = text - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0) - .map(line => { - try { - return JSON.parse(line) as Record - } catch { - return null - } - }) - .filter((entry): entry is Record => entry !== null) + let parseErrors = 0 + const parsed = text + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .map(line => { + try { + return JSON.parse(line) as Record + } catch { + parseErrors++ + return null + } + }) + .filter((entry): entry is Record => entry !== null) - const markerOf = params.kind === 'claude' - ? extractClaudeHistoryMarker - : extractCodexHistoryMarker + const markerOf = params.kind === 'claude' + ? extractClaudeHistoryMarker + : extractCodexHistoryMarker - const anchorIndex = parsed.findIndex(entry => markerOf(entry) === params.beforeMarker) - const cutoff = anchorIndex === -1 ? parsed.length : anchorIndex - const older = parsed.slice(0, cutoff) - if (older.length === 0) return { entries: [], hasMore: false } + const anchorIndex = parsed.findIndex(entry => markerOf(entry) === params.beforeMarker) + const cutoff = anchorIndex === -1 ? parsed.length : anchorIndex + const older = parsed.slice(0, cutoff) + if (older.length === 0) { + span.end({ + result: 'no-older', + bytes: text.length, + parsed: parsed.length, + parseErrors, + }) + return { entries: [], hasMore: false } + } - const start = Math.max(0, older.length - params.limit) - return { - entries: older.slice(start), - hasMore: start > 0, + const start = Math.max(0, older.length - params.limit) + const entries = older.slice(start) + span.end({ + result: 'loaded', + bytes: text.length, + parsed: parsed.length, + parseErrors, + returned: entries.length, + hasMore: start > 0, + }) + return { + entries, + hasMore: start > 0, + } + } catch (err) { + span.fail(err) + throw err } } diff --git a/src/main/storage/paths.ts b/src/main/storage/paths.ts index a0af3b702..8396ee588 100644 --- a/src/main/storage/paths.ts +++ b/src/main/storage/paths.ts @@ -30,3 +30,7 @@ export const FEED_DEBUG_DIR = join(STATE_DIR, 'feed-debug') // path in a toast AND copies it to the clipboard, so discoverability // doesn't depend on the user knowing the filesystem layout. export const DEBUG_BUNDLE_DIR = join(STATE_DIR, 'debug-bundles') + +// Environment-gated app performance traces. One folder per app run, +// written only when CC_SHELL_PERF=1. +export const PERFORMANCE_RUNS_DIR = join(STATE_DIR, 'performance', 'runs') diff --git a/src/main/storage/performanceLog.ts b/src/main/storage/performanceLog.ts new file mode 100644 index 000000000..54c443408 --- /dev/null +++ b/src/main/storage/performanceLog.ts @@ -0,0 +1,62 @@ +import { mkdir, readFile, writeFile } from 'fs/promises' +import { join } from 'path' + +import { PERFORMANCE_RUNS_DIR } from '@main/storage/paths.js' + +const TAIL_BYTES = 256 * 1024 + +export type PerformanceLogFile = + | 'events' + | 'spans' + | 'metrics' + | 'errors' + | 'slow' + | 'pane-process' + +const writeQueues = new Map>() + +export async function ensurePerformanceRunDir(runFolderName: string): Promise { + const runDir = join(PERFORMANCE_RUNS_DIR, runFolderName) + await mkdir(runDir, { recursive: true }) + return runDir +} + +export function queuePerformanceAppend( + runDir: string, + file: PerformanceLogFile, + lines: string[], +): Promise { + const key = join(runDir, `${file}.jsonl`) + const previous = writeQueues.get(key) ?? Promise.resolve() + const next = previous + .catch(() => {}) + .then(async () => { + if (lines.length === 0) return + await mkdir(runDir, { recursive: true }) + await writeFile(key, lines.join('\n') + '\n', { encoding: 'utf8', flag: 'a' }) + }) + writeQueues.set(key, next) + return next +} + +export async function writePerformanceManifest( + runDir: string, + manifest: Record, +): Promise { + await mkdir(runDir, { recursive: true }) + await writeFile(join(runDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8') +} + +export async function readPerformanceTail( + runDir: string, + file: PerformanceLogFile, +): Promise { + try { + const path = join(runDir, `${file}.jsonl`) + const content = await readFile(path, 'utf8') + if (content.length <= TAIL_BYTES) return content + return content.slice(content.length - TAIL_BYTES) + } catch { + return '' + } +} diff --git a/src/preload/api/index.ts b/src/preload/api/index.ts index fda94747b..67f8e0942 100644 --- a/src/preload/api/index.ts +++ b/src/preload/api/index.ts @@ -8,6 +8,7 @@ import { debugApi } from '@preload/api/debug.js' import { systemApi } from '@preload/api/system.js' import { gitApi } from '@preload/api/git.js' import { ghostApi } from '@preload/api/ghost.js' +import { performanceApi } from '@preload/api/performance.js' // Composed preload API surface. // @@ -35,6 +36,7 @@ export const api = { ...systemApi, ...gitApi, ...ghostApi, + ...performanceApi, } export type Api = typeof api diff --git a/src/preload/api/performance.ts b/src/preload/api/performance.ts new file mode 100644 index 000000000..06dce6cff --- /dev/null +++ b/src/preload/api/performance.ts @@ -0,0 +1,25 @@ +import { ipcRenderer } from 'electron' + +import type { + PerformanceConfig, + PanePerformanceSnapshot, + PerformanceRecord, + PerformanceSnapshot, +} from '@shared/performance/types.js' + +export const performanceApi = { + getPerformanceConfig: (): Promise => + ipcRenderer.invoke('performance:get-config'), + + appendPerformanceRecords: (records: PerformanceRecord[]): Promise => + ipcRenderer.invoke('performance:batch', records), + + flushPerformance: (): Promise => + ipcRenderer.invoke('performance:flush'), + + getPerformanceSnapshot: (): Promise => + ipcRenderer.invoke('performance:snapshot'), + + getPanePerformanceStats: (sessionIds: string[]): Promise => + ipcRenderer.invoke('performance:pane-stats', sessionIds), +} diff --git a/src/providers/claude/runtime/claudeSession.ts b/src/providers/claude/runtime/claudeSession.ts index b4e95fa92..b0a7ddff5 100644 --- a/src/providers/claude/runtime/claudeSession.ts +++ b/src/providers/claude/runtime/claudeSession.ts @@ -445,6 +445,10 @@ export class ClaudeSession extends EventEmitter { return this.exited } + getProcessPid(): number | null { + return this.pty?.pid ?? null + } + /** * Reset the live-bridge quiet-window timer. Called once at the end * of the constructor (so fresh sessions that never replay anything diff --git a/src/providers/claude/runtime/sessionList.ts b/src/providers/claude/runtime/sessionList.ts index 638ae8cfb..31700ec20 100644 --- a/src/providers/claude/runtime/sessionList.ts +++ b/src/providers/claude/runtime/sessionList.ts @@ -2,6 +2,7 @@ import { readdir, stat, open } from 'fs/promises' import { basename, join } from 'path' import { getProjectDirForCwd, getProjectsDir } from '@shared/runtime/projectDir.js' +import { performanceService } from '@main/performance/PerformanceService.js' // Session lister — reimplements the minimal subset of CC's // utils/listSessionsImpl.ts needed to power the cc-shell resume UI. @@ -207,6 +208,9 @@ export async function listSessionsForCwd( cwd: string, options: ListSessionsOptions = {}, ): Promise { + const span = performanceService.span('providers.claude.listSessionsForCwd', { + limit: options.limit ?? 20, + }) const limit = options.limit ?? 20 const projectDir = await getProjectDirForCwd(cwd) @@ -214,6 +218,7 @@ export async function listSessionsForCwd( try { names = await readdir(projectDir) } catch { + span.end({ result: 'missing-project-dir' }) return [] } @@ -250,6 +255,12 @@ export async function listSessionsForCwd( const info = await parseSession(c) if (info) sessions.push(info) } + span.end({ + result: 'listed', + names: names.length, + candidates: candidates.length, + sessions: sessions.length, + }) return sessions } @@ -263,6 +274,9 @@ export async function listSessionsForCwd( export async function listAllClaudeSessions( options: ListSessionsOptions = {}, ): Promise { + const span = performanceService.span('providers.claude.listAllSessions', { + limit: options.limit ?? 200, + }) const limit = options.limit ?? 200 const projectsDir = getProjectsDir() @@ -270,6 +284,7 @@ export async function listAllClaudeSessions( try { projectNames = await readdir(projectsDir) } catch { + span.end({ result: 'missing-projects-dir' }) return [] } @@ -314,6 +329,12 @@ export async function listAllClaudeSessions( const info = await parseSession(c) if (info) sessions.push(info) } + span.end({ + result: 'listed', + projects: projectNames.length, + candidates: candidates.length, + sessions: sessions.length, + }) return sessions } diff --git a/src/providers/codex/runtime/codexSession.ts b/src/providers/codex/runtime/codexSession.ts index 8fc09bf06..222ef4912 100644 --- a/src/providers/codex/runtime/codexSession.ts +++ b/src/providers/codex/runtime/codexSession.ts @@ -310,6 +310,10 @@ export class CodexSession extends EventEmitter { return this.exited } + getProcessPid(): number | null { + return this.pty?.pid ?? null + } + async stop(): Promise { // Teardown order matters. The adapter is a listener on the proxy // that writes into `this.headless.semantic`. If we stop() the diff --git a/src/providers/codex/runtime/sessionList.ts b/src/providers/codex/runtime/sessionList.ts index d40f77dd6..7de4ac99b 100644 --- a/src/providers/codex/runtime/sessionList.ts +++ b/src/providers/codex/runtime/sessionList.ts @@ -2,6 +2,7 @@ import { readdir, stat, open } from 'fs/promises' import { join } from 'path' import { getCodexSessionsDir } from '@providers/codex/runtime/projectDir.js' +import { performanceService } from '@main/performance/PerformanceService.js' // Codex session lister — walks ~/.codex/sessions/YYYY/MM/DD/ for // rollout-*.jsonl files and extracts summary metadata for the resume @@ -48,6 +49,9 @@ const ROLLOUT_RE = /^rollout-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{ export async function listCodexSessions( options: ListCodexSessionsOptions = {}, ): Promise { + const span = performanceService.span('providers.codex.listSessions', { + limit: options.limit ?? 20, + }) const limit = options.limit ?? 20 const sessionsDir = getCodexSessionsDir() @@ -57,6 +61,7 @@ export async function listCodexSessions( await walkForRollouts(sessionsDir, files) } catch { // Sessions dir doesn't exist yet — no codex sessions recorded. + span.end({ result: 'missing-sessions-dir' }) return [] } @@ -69,6 +74,11 @@ export async function listCodexSessions( const info = await parseCodexSession(f) if (info) sessions.push(info) } + span.end({ + result: 'listed', + candidates: files.length, + sessions: sessions.length, + }) return sessions } diff --git a/src/renderer/src/app-state/types.ts b/src/renderer/src/app-state/types.ts index d76adcbf9..391e83471 100644 --- a/src/renderer/src/app-state/types.ts +++ b/src/renderer/src/app-state/types.ts @@ -38,6 +38,7 @@ export type UiShellSlice = UiShellState & { toggleFeedDebugPanel: () => void toggleProxyDebugPanel: () => void toggleHtmlDebugPanel: () => void + togglePerformancePanel: () => void openPromptSearch: () => void closePromptSearch: () => void openAgentActivity: () => void diff --git a/src/renderer/src/app-state/uiShell/slice.ts b/src/renderer/src/app-state/uiShell/slice.ts index 1b70f4789..424fb9c8a 100644 --- a/src/renderer/src/app-state/uiShell/slice.ts +++ b/src/renderer/src/app-state/uiShell/slice.ts @@ -22,6 +22,7 @@ export const createUiShellSlice: StateCreator< feedDebugPanelOpen: false, proxyDebugPanelOpen: false, htmlDebugPanelOpen: false, + performancePanelOpen: false, promptSearchOpen: false, agentActivityOpen: false, rewindPromptSessionId: null, @@ -93,6 +94,12 @@ export const createUiShellSlice: StateCreator< false, 'uiShell/toggleHtmlDebugPanel', ), + togglePerformancePanel: () => + set( + state => ({ performancePanelOpen: !state.performancePanelOpen }), + false, + 'uiShell/togglePerformancePanel', + ), openPromptSearch: () => set({ promptSearchOpen: true }, false, 'uiShell/openPromptSearch'), diff --git a/src/renderer/src/app-state/uiShell/types.ts b/src/renderer/src/app-state/uiShell/types.ts index 65e992eb0..0a8950975 100644 --- a/src/renderer/src/app-state/uiShell/types.ts +++ b/src/renderer/src/app-state/uiShell/types.ts @@ -28,6 +28,7 @@ export type UiShellState = { * Lives on the uiShell slice alongside the other three debug panels * and follows the same toggle pattern. */ htmlDebugPanelOpen: boolean + performancePanelOpen: boolean /** When true, the Search Conversation Prompts modal is open. Lives * on the uiShell slice (not the workspace slice) because it's * a cross-session concern: the modal reads prompts from ALL diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 1158fcc38..362f9b558 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -19,6 +19,7 @@ import { ViewPromptsModal } from '@renderer/features/workspace/ui/ViewPromptsMod import { GitBar } from '@renderer/features/git/ui/GitBar' import { AppearanceMenu } from '@renderer/features/feed/AppearanceMenu' import { PathPickerModal } from '@renderer/features/path-picker/ui/PathPickerModal' +import { PerformancePanel } from '@renderer/features/performance/ui/PerformancePanel' import { TabBar } from '@renderer/workspace/tile-tree/TabBar' import { TileTree } from '@renderer/workspace/tile-tree/TileTree' import { useAppStore } from '@renderer/app-state/hooks' @@ -58,6 +59,7 @@ export default function App() { const feedDebugPanelOpen = useAppStore(state => state.feedDebugPanelOpen) const proxyDebugPanelOpen = useAppStore(state => state.proxyDebugPanelOpen) const htmlDebugPanelOpen = useAppStore(state => state.htmlDebugPanelOpen) + const performancePanelOpen = useAppStore(state => state.performancePanelOpen) const dangerousAgentsEnabled = settings.dangerousAgentsEnabled const useProxyStreaming = settings.useProxyStreaming const openPathPicker = useAppStore(state => state.openPathPicker) @@ -79,6 +81,7 @@ export default function App() { const toggleFeedDebugPanel = useAppStore(state => state.toggleFeedDebugPanel) const toggleProxyDebugPanel = useAppStore(state => state.toggleProxyDebugPanel) const toggleHtmlDebugPanel = useAppStore(state => state.toggleHtmlDebugPanel) + const togglePerformancePanel = useAppStore(state => state.togglePerformancePanel) const promptSearchOpen = useAppStore(state => state.promptSearchOpen) const openPromptSearch = useAppStore(state => state.openPromptSearch) const closePromptSearch = useAppStore(state => state.closePromptSearch) @@ -182,6 +185,21 @@ export default function App() { >
+ +
@@ -285,6 +303,7 @@ export default function App() { toggleFeedDebugPanel={toggleFeedDebugPanel} toggleProxyDebugPanel={toggleProxyDebugPanel} toggleHtmlDebugPanel={toggleHtmlDebugPanel} + togglePerformancePanel={togglePerformancePanel} onTileTabsRequest={onTileTabsRequest} onSettingsRequest={openSettingsPage} openViewPrompts={openViewPrompts} @@ -299,6 +318,7 @@ export default function App() { feedDebugPanelOpen={feedDebugPanelOpen} proxyDebugPanelOpen={proxyDebugPanelOpen} htmlDebugPanelOpen={htmlDebugPanelOpen} + performancePanelOpen={performancePanelOpen} setDangerousAgentsEnabled={enabled => setSettings({ dangerousAgentsEnabled: enabled })} /> diff --git a/src/renderer/src/app/main.tsx b/src/renderer/src/app/main.tsx index d875745eb..0925d7870 100644 --- a/src/renderer/src/app/main.tsx +++ b/src/renderer/src/app/main.tsx @@ -9,6 +9,11 @@ import 'monaco-editor/min/vs/editor/editor.main.css' // its cells with correct geometry. xterm.js uses absolute-positioned // rows and explicit cell widths, none of which work without this file. import '@xterm/xterm/css/xterm.css' +import { initializePerformance, mark } from '@renderer/performance/client' + +void initializePerformance().then(() => { + mark('app.renderer.reactRenderCalled') +}) createRoot(document.getElementById('root')!).render( diff --git a/src/renderer/src/features/command-palette/types.ts b/src/renderer/src/features/command-palette/types.ts index 4ee8cc31d..79db8f807 100644 --- a/src/renderer/src/features/command-palette/types.ts +++ b/src/renderer/src/features/command-palette/types.ts @@ -21,6 +21,7 @@ export type CommandContext = { toggleFeedDebugPanel: () => void toggleProxyDebugPanel: () => void toggleHtmlDebugPanel: () => void + togglePerformancePanel: () => void toggleCustomRendering: () => void setDangerousAgentsEnabled: (enabled: boolean) => void enterResumeMode: () => void @@ -35,6 +36,7 @@ export type CommandContext = { feedDebugPanelOpen: boolean proxyDebugPanelOpen: boolean htmlDebugPanelOpen: boolean + performancePanelOpen: boolean } } diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index f93c1ae62..358b61cb2 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -48,6 +48,7 @@ type Props = { toggleFeedDebugPanel: () => void toggleProxyDebugPanel: () => void toggleHtmlDebugPanel: () => void + togglePerformancePanel: () => void toggleCustomRendering: () => void customRenderingEnabled: boolean dangerousAgentsEnabled: boolean @@ -56,6 +57,7 @@ type Props = { feedDebugPanelOpen: boolean proxyDebugPanelOpen: boolean htmlDebugPanelOpen: boolean + performancePanelOpen: boolean setDangerousAgentsEnabled: (enabled: boolean) => void } @@ -86,6 +88,7 @@ export function CommandPalette({ toggleFeedDebugPanel, toggleProxyDebugPanel, toggleHtmlDebugPanel, + togglePerformancePanel, toggleCustomRendering, customRenderingEnabled, dangerousAgentsEnabled, @@ -94,6 +97,7 @@ export function CommandPalette({ feedDebugPanelOpen, proxyDebugPanelOpen, htmlDebugPanelOpen, + performancePanelOpen, setDangerousAgentsEnabled, }: Props) { const [query, setQuery] = useState('') @@ -171,6 +175,7 @@ export function CommandPalette({ toggleFeedDebugPanel, toggleProxyDebugPanel, toggleHtmlDebugPanel, + togglePerformancePanel, toggleCustomRendering, setDangerousAgentsEnabled, enterResumeMode, @@ -185,6 +190,7 @@ export function CommandPalette({ feedDebugPanelOpen, proxyDebugPanelOpen, htmlDebugPanelOpen, + performancePanelOpen, }, }), [ @@ -202,6 +208,7 @@ export function CommandPalette({ toggleFeedDebugPanel, toggleProxyDebugPanel, toggleHtmlDebugPanel, + togglePerformancePanel, toggleCustomRendering, setDangerousAgentsEnabled, enterResumeMode, @@ -214,6 +221,7 @@ export function CommandPalette({ feedDebugPanelOpen, proxyDebugPanelOpen, htmlDebugPanelOpen, + performancePanelOpen, ], ) diff --git a/src/renderer/src/features/debug/saveDebugBundle.ts b/src/renderer/src/features/debug/saveDebugBundle.ts index 7c045f49d..4a52c697e 100644 --- a/src/renderer/src/features/debug/saveDebugBundle.ts +++ b/src/renderer/src/features/debug/saveDebugBundle.ts @@ -237,6 +237,8 @@ export async function assembleAndSaveDebugBundle(params: { }): Promise<{ bundlePath: string }> { const { sessionId, runtime, kind } = params const capturedAt = Date.now() + await window.api.flushPerformance().catch(() => {}) + const performanceSnapshot = await window.api.getPerformanceSnapshot().catch(() => null) const html = capturePaneHtml(sessionId) recordHtmlTraceSnapshot(sessionId, html.raw, 'manual') @@ -262,6 +264,7 @@ export async function assembleAndSaveDebugBundle(params: { { name: FILE_NAMES.htmlRaw, content: html.raw }, { name: FILE_NAMES.htmlClean, content: html.clean }, ...exportDebugTraceFiles(sessionId), + ...(performanceSnapshot?.files ?? []), ] files[0] = { diff --git a/src/renderer/src/features/feed/ui/Feed.tsx b/src/renderer/src/features/feed/ui/Feed.tsx index 1fe494fe1..79d9936a8 100644 --- a/src/renderer/src/features/feed/ui/Feed.tsx +++ b/src/renderer/src/features/feed/ui/Feed.tsx @@ -46,6 +46,7 @@ import { LazyEntry, } from '@renderer/features/feed/ui/rows' import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' +import * as perf from '@renderer/performance/client' // Re-export — many external callers import these types from Feed // directly rather than reaching into ../types/../context. Keep the @@ -604,9 +605,9 @@ function FeedImpl({ // completion notification would render as a full UserBand in the // feed with raw XML visible — exactly the bug // the user reported. - const visibleDecisions = useMemo( - () => - entries.map((entry, index) => { + const visibleDecisions = useMemo(() => { + const startedAt = performance.now() + const decisions = entries.map((entry, index) => { if (isCompactBoundaryEntry(entry)) { return { key: debugKeyForEntry(entry, index), @@ -645,9 +646,17 @@ function FeedImpl({ visible: true, reason: 'conversation', } - }), - [entries], - ) + }) + const durationMs = performance.now() - startedAt + if (durationMs >= 10 || entries.length >= 500) { + perf.metric('feed.visibleDecisions.build', durationMs, 'sample', { + sessionId, + entries: entries.length, + decisions: decisions.length, + }) + } + return decisions + }, [entries, sessionId]) const visible = useMemo( () => visibleDecisions.filter(item => item.visible).map(item => item.entry), @@ -682,12 +691,20 @@ function FeedImpl({ const shouldShowWorkIndicator = streamPhase !== 'idle' const renderedRows = useMemo(() => { + const startedAt = performance.now() if (visible.length === 0 && !hasSemanticStreaming) { - return [{ + const rows: DebugVisibleRow[] = [{ key: 'empty', slot: 'empty', label: provider === 'codex' ? 'waiting for Codex…' : 'waiting for Claude Code…', }] + perf.metric('feed.renderedRows.build', performance.now() - startedAt, 'sample', { + sessionId, + entries: entries.length, + visible: visible.length, + rows: rows.length, + }) + return rows } const rows: DebugVisibleRow[] = visibleDecisions .filter(item => item.visible) @@ -717,11 +734,23 @@ function FeedImpl({ : `work ${streamPhase}`, }) } + const durationMs = performance.now() - startedAt + if (durationMs >= 10 || entries.length >= 500) { + perf.metric('feed.renderedRows.build', durationMs, 'sample', { + sessionId, + entries: entries.length, + visible: visible.length, + rows: rows.length, + hasSemanticStreaming, + }) + } return rows }, [ + entries.length, hasSemanticStreaming, provider, renderedSemanticTurn, + sessionId, shouldShowWorkIndicator, streamPhase, streamPhasePendingToolName, diff --git a/src/renderer/src/features/performance/ui/PerformancePanel.tsx b/src/renderer/src/features/performance/ui/PerformancePanel.tsx new file mode 100644 index 000000000..8dfa3e836 --- /dev/null +++ b/src/renderer/src/features/performance/ui/PerformancePanel.tsx @@ -0,0 +1,133 @@ +import { useEffect, useMemo, useState } from 'react' + +import type { PanePerformanceStats } from '@shared/performance/types' +import type { Workspace } from '@renderer/workspace/workspaceStore' +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' +import { paneLabelForSession } from '@renderer/workspace/tile-tree/paneLabels' + +type Props = { + open: boolean + workspace: Workspace +} + +export function PerformancePanel({ open, workspace }: Props) { + const visible = useMemo( + () => + workspace.tileTabs + ? workspace.tileTabs.tabIds + .map(id => workspace.state.tabs.find(tab => tab.id === id)) + .filter((tab): tab is NonNullable => Boolean(tab)) + : workspace.activeTab + ? [workspace.activeTab] + : [], + [workspace.activeTab, workspace.state.tabs, workspace.tileTabs], + ) + const visibleIds = useMemo( + () => visible.flatMap(tab => collectLeaves(tab.root)), + [visible], + ) + const [enabled, setEnabled] = useState(null) + const [stats, setStats] = useState([]) + + useEffect(() => { + if (!open || visibleIds.length === 0) { + setStats([]) + return + } + let cancelled = false + const tick = async () => { + try { + const snapshot = await window.api.getPanePerformanceStats(visibleIds) + if (cancelled) return + setEnabled(snapshot.enabled) + setStats(snapshot.panes) + } catch { + if (!cancelled) { + setEnabled(false) + setStats([]) + } + } + } + void tick() + const timer = window.setInterval(() => void tick(), 2000) + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [open, visibleIds]) + + if (!open) return null + + const bySession = new Map(stats.map(stat => [stat.sessionId, stat])) + + return ( +
+
+
+ pane + cpu + memory + status + last +
+ {enabled === false ? ( +
+ performance telemetry off +
+ ) : visibleIds.length === 0 ? ( +
no visible panes
+ ) : ( +
+ {visible.flatMap(tab => + collectLeaves(tab.root).map(sessionId => { + const stat = bySession.get(sessionId) + return ( +
+ + {paneLabelForSession(workspace.state.tabs, tab.id, sessionId)} + + {formatCpu(stat?.cpuPercent)} + {formatMemory(stat?.memoryBytes)} + + {stat?.status ?? 'unknown'} + + {formatLastActivity(stat?.lastActivityAt, stat?.sampledAt)} +
+ ) + }), + )} +
+ )} +
+
+ ) +} + +function formatCpu(value: number | null | undefined): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return '—' + return `${value.toFixed(value >= 10 ? 0 : 1)}%` +} + +function formatMemory(value: number | null | undefined): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return '—' + return `${Math.round(value / (1024 * 1024))}MB` +} + +function formatLastActivity( + lastActivityAt: number | null | undefined, + sampledAt: number | undefined, +): string { + if (!lastActivityAt || !sampledAt) return '—' + const seconds = Math.max(0, Math.round((sampledAt - lastActivityAt) / 1000)) + return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m` +} + +function statusClass(status: PanePerformanceStats['status'] | undefined): string { + if (status === 'running') return 'text-green-400' + if (status === 'idle') return 'text-yellow-300' + if (status === 'exited') return 'text-red-400' + return 'text-muted' +} diff --git a/src/renderer/src/features/workspace/commands/layoutCommands.ts b/src/renderer/src/features/workspace/commands/layoutCommands.ts index 75273f198..19b8c2e15 100644 --- a/src/renderer/src/features/workspace/commands/layoutCommands.ts +++ b/src/renderer/src/features/workspace/commands/layoutCommands.ts @@ -25,4 +25,14 @@ export const layoutCommands: CommandDef[] = [ }), run: ({ workspace }) => workspace.toggleStatusMode(), }, + { + id: 'toggle-performance-panel', + title: 'Performance Stats', + keywords: ['performance', 'stats', 'cpu', 'memory', 'panes'], + getState: ({ flags }) => ({ + label: flags.performancePanelOpen ? 'On' : 'Off', + tone: flags.performancePanelOpen ? 'accent' : 'neutral', + }), + run: ({ ui }) => ui.togglePerformancePanel(), + }, ] diff --git a/src/renderer/src/performance/client.ts b/src/renderer/src/performance/client.ts new file mode 100644 index 000000000..ed5377c13 --- /dev/null +++ b/src/renderer/src/performance/client.ts @@ -0,0 +1,335 @@ +import { SpanStatusCode, trace, type Attributes } from '@opentelemetry/api' +import { + BasicTracerProvider, + BatchSpanProcessor, + type ReadableSpan, + type SpanExporter, +} from '@opentelemetry/sdk-trace-base' +import { ExportResultCode } from '@opentelemetry/core' + +import type { + PerformanceConfig, + PerformanceMetricType, + PerformanceRecord, +} from '@shared/performance/types' + +const FLUSH_INTERVAL_MS = 500 +const MAX_BATCH = 200 + +let config: PerformanceConfig = { + enabled: false, + verbose: false, + slowSpanMs: 50, + runId: null, + runDir: null, +} +let initialized = false +let tracerProvider: BasicTracerProvider | null = null +let tracer = trace.getTracer('cc-shell-renderer') +let pending: PerformanceRecord[] = [] +let flushTimer: ReturnType | null = null + +class RendererIpcSpanExporter implements SpanExporter { + export(spans: ReadableSpan[], resultCallback: (result: { code: ExportResultCode; error?: Error }) => void): void { + const records = spans.map(spanToRecord) + window.api.appendPerformanceRecords(records) + .then(() => resultCallback({ code: ExportResultCode.SUCCESS })) + .catch(error => resultCallback({ code: ExportResultCode.FAILED, error })) + } + + async shutdown(): Promise {} + + async forceFlush(): Promise {} +} + +function spanToRecord(span: ReadableSpan): PerformanceRecord { + const startMs = hrTimeToUnixMs(span.startTime) + const durationMs = hrDurationToMs(span.duration) + return { + kind: 'span_end', + process: 'renderer', + area: String(span.attributes['cc.area'] ?? areaFromName(span.name)), + name: span.name, + ts: startMs + durationMs, + tsIso: new Date(startMs + durationMs).toISOString(), + monotonicMs: performance.now(), + runId: config.runId ?? undefined, + spanId: span.spanContext().spanId, + durationMs, + data: { + traceId: span.spanContext().traceId, + parentSpanId: span.parentSpanContext?.spanId, + attributes: span.attributes, + events: span.events.map(event => ({ + name: event.name, + ts: hrTimeToUnixMs(event.time), + attributes: event.attributes, + })), + status: span.status, + }, + } +} + +function hrTimeToUnixMs(hrTime: readonly [number, number]): number { + return (hrTime[0] * 1000) + (hrTime[1] / 1e6) +} + +function hrDurationToMs(hrTime: readonly [number, number]): number { + return (hrTime[0] * 1000) + (hrTime[1] / 1e6) +} + +function nowRecordBase(): Pick { + const ts = Date.now() + return { + ts, + tsIso: new Date(ts).toISOString(), + monotonicMs: performance.now(), + runId: config.runId ?? undefined, + } +} + +function areaFromName(name: string): string { + const parts = name.split('.') + return parts.length >= 2 ? `${parts[0]}.${parts[1]}` : parts[0] || 'renderer' +} + +function sanitizeData(data?: Record): Record | undefined { + if (!data) return undefined + const out: Record = {} + for (const [key, value] of Object.entries(data)) { + if (!config.verbose && /prompt|content|text|env|token|secret|key/i.test(key)) continue + if (typeof value === 'string' && value.length > (config.verbose ? 2000 : 300)) { + out[key] = `${value.slice(0, config.verbose ? 2000 : 300)}...` + } else { + out[key] = value + } + } + return out +} + +function enqueue(record: PerformanceRecord): void { + if (!initialized || !config.enabled) return + pending.push({ + ...nowRecordBase(), + ...record, + process: 'renderer', + area: record.area || areaFromName(record.name), + data: sanitizeData(record.data), + }) + if (pending.length >= MAX_BATCH) void flushPerformance() +} + +export async function initializePerformance(): Promise { + if (initialized) return config + try { + config = await window.api.getPerformanceConfig() + } catch { + config = { ...config, enabled: false } + } + initialized = true + if (config.enabled) { + tracerProvider = new BasicTracerProvider({ + spanProcessors: [ + new BatchSpanProcessor(new RendererIpcSpanExporter(), { + scheduledDelayMillis: FLUSH_INTERVAL_MS, + maxExportBatchSize: 128, + maxQueueSize: 2048, + }), + ], + }) + trace.setGlobalTracerProvider(tracerProvider) + tracer = trace.getTracer('cc-shell-renderer') + flushTimer = setInterval(() => { + void flushPerformance() + }, FLUSH_INTERVAL_MS) + setupRendererPerformanceProbes() + mark('app.renderer.performance.initialized') + } else { + pending = [] + } + return config +} + +export function getPerformanceConfig(): PerformanceConfig { + return config +} + +export function mark(name: string, data?: Record): void { + enqueue({ + kind: 'mark', + process: 'renderer', + area: areaFromName(name), + name, + data, + }) +} + +export function metric( + name: string, + value: number, + metricType: PerformanceMetricType = 'sample', + data?: Record, +): void { + enqueue({ + kind: 'metric', + process: 'renderer', + area: areaFromName(name), + name, + metricType, + value, + data, + }) +} + +export function count(name: string, value = 1, data?: Record): void { + metric(name, value, 'counter', data) +} + +export function gauge(name: string, value: number, data?: Record): void { + metric(name, value, 'gauge', data) +} + +export function error(name: string, err: unknown, data?: Record): void { + enqueue({ + kind: 'error', + process: 'renderer', + area: areaFromName(name), + name, + level: 'error', + error: serializeError(err), + data, + }) +} + +export function span(name: string, data?: Record) { + if (!initialized || !config.enabled) { + return { + end() {}, + fail() {}, + } + } + const active = tracer.startSpan(name, { + attributes: { + ...toAttributes(sanitizeData(data)), + 'cc.area': areaFromName(name), + 'cc.process': 'renderer', + }, + }) + return { + end(endData?: Record) { + const attrs = toAttributes(sanitizeData(endData)) + if (attrs) active.setAttributes(attrs) + active.end() + }, + fail(err: unknown, endData?: Record) { + const attrs = toAttributes(sanitizeData(endData)) + if (attrs) active.setAttributes(attrs) + active.recordException(err instanceof Error ? err : String(err)) + active.setStatus({ code: SpanStatusCode.ERROR, message: String((err as Error)?.message ?? err) }) + active.end() + }, + } +} + +export async function measure( + name: string, + fn: () => T | Promise, + data?: Record, +): Promise { + const active = span(name, data) + try { + const result = await fn() + active.end() + return result + } catch (err) { + active.fail(err) + throw err + } +} + +export async function flushPerformance(): Promise { + if (!config.enabled) return + const batch = pending + pending = [] + try { + if (batch.length > 0) await window.api.appendPerformanceRecords(batch) + await tracerProvider?.forceFlush() + } catch (err) { + pending = batch.concat(pending).slice(-MAX_BATCH) + // eslint-disable-next-line no-console + console.warn('[performance] append failed', err) + } +} + +export async function shutdownPerformance(): Promise { + if (flushTimer) clearInterval(flushTimer) + flushTimer = null + await flushPerformance() + await tracerProvider?.shutdown() +} + +function setupRendererPerformanceProbes(): void { + try { + const observer = new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + mark('renderer.longtask', { + durationMs: entry.duration, + startTime: entry.startTime, + entryType: entry.entryType, + name: entry.name, + }) + } + }) + observer.observe({ entryTypes: ['longtask'] }) + } catch { + // Long Task API is Chromium-only and may not be exposed in every context. + } + + setInterval(() => { + const memory = performanceMemory() + if (!memory) return + gauge('renderer.memory.usedJSHeapSize', memory.usedJSHeapSize, { + totalJSHeapSize: memory.totalJSHeapSize, + jsHeapSizeLimit: memory.jsHeapSizeLimit, + }) + }, 5000) +} + +function performanceMemory(): + | { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number } + | null { + const maybePerformance = performance as Performance & { + memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number } + } + return maybePerformance.memory ?? null +} + +function serializeError(err: unknown): PerformanceRecord['error'] { + if (err instanceof Error) { + return { + name: err.name, + message: err.message, + stack: err.stack, + } + } + return { message: String(err) } +} + +function toAttributes(data: Record | undefined): Attributes | undefined { + if (!data) return undefined + const out: Attributes = {} + for (const [key, value] of Object.entries(data)) { + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + out[key] = value + } else if (value == null) { + continue + } else { + out[key] = JSON.stringify(value) + } + } + return out +} diff --git a/src/renderer/src/workspace/hook/actions/history.ts b/src/renderer/src/workspace/hook/actions/history.ts index 0978666e6..fdfff31fb 100644 --- a/src/renderer/src/workspace/hook/actions/history.ts +++ b/src/renderer/src/workspace/hook/actions/history.ts @@ -21,6 +21,7 @@ import { import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' +import * as perf from '@renderer/performance/client' // Older history loader — called by Feed's scroll handler when the // user scrolls near the top. @@ -38,16 +39,30 @@ export function useHistoryActions( } { const loadOlderHistory = useCallback( async (sessionId: SessionId) => { + const span = perf.span('workspace.history.loadOlder', { sessionId }) const currentState = refs.stateRef.current const meta = currentState.sessions[sessionId] const runtime = refs.latestRuntimesRef.current[sessionId] ?? emptyRuntime() - if (!meta) return + if (!meta) { + span.end({ skipped: 'missing-meta' }) + return + } const kind = meta.kind ?? 'claude' - if ((kind !== 'claude' && kind !== 'codex') || !meta.providerSessionId) return - if (!runtime.hasOlderHistory || runtime.loadingOlderHistory) return + if ((kind !== 'claude' && kind !== 'codex') || !meta.providerSessionId) { + span.end({ skipped: 'unsupported-or-missing-provider-session', kind }) + return + } + if (!runtime.hasOlderHistory || runtime.loadingOlderHistory) { + span.end({ + skipped: runtime.loadingOlderHistory ? 'already-loading' : 'no-older-history', + kind, + }) + return + } if (!runtime.historyOldestMarker) { updateRuntime(sessionId, { hasOlderHistory: false, loadingOlderHistory: false }) + span.end({ skipped: 'missing-marker', kind }) return } @@ -137,7 +152,14 @@ export function useHistoryActions( }, } }) + span.end({ + kind, + fetched: chunk.entries.length, + prepended: prepend.length, + hasMore: chunk.hasMore, + }) } catch (err) { + span.fail(err, { kind }) console.warn('[history] load older failed', err) updateRuntime(sessionId, { loadingOlderHistory: false }) } diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts index b340cc783..d8e8e9bbb 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -50,6 +50,7 @@ import type { WorkspaceSetState, } from '@renderer/workspace/hook/context' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' +import * as perf from '@renderer/performance/client' // ----------------------------------------------------------------------------- // useIpcSubscriptions — the one big side-effect that wires every @@ -89,6 +90,7 @@ export function useIpcSubscriptions( const offScreen = window.api.onSessionScreen( ({ sessionId, plain, markdown, recent, recentMarkdown, picker }) => { + const startedAt = performance.now() // latestScreenRef is the synchronous source of truth for // the Enter-baseline capture in TileLeaf — always update // it, even when we bail on React state below. @@ -121,6 +123,13 @@ export function useIpcSubscriptions( current.recentScreenMarkdown === recentMarkdown && pickerEqual(current.picker, picker) ) { + const durationMs = performance.now() - startedAt + if (durationMs > 8) { + perf.metric('workspace.ipc.screen.noop.slow', durationMs, 'sample', { + sessionId, + recentLength: recent.length, + }) + } return prev } const changed: string[] = [] @@ -231,6 +240,15 @@ export function useIpcSubscriptions( if (!chromeTickOnly) { recordScreenTailSnapshot(sessionId, recent) } + const durationMs = performance.now() - startedAt + if (durationMs > 8) { + perf.metric('workspace.ipc.screen.apply.slow', durationMs, 'sample', { + sessionId, + changed, + recentLength: recent.length, + pickerVisible: picker.visible, + }) + } return { ...prev, [sessionId]: nextCurrent, @@ -350,6 +368,13 @@ export function useIpcSubscriptions( ) const offSemantic = window.api.onSessionSemanticEvent(({ sessionId, event }) => { + const span = perf.span('workspace.ipc.semantic.fold', { sessionId }) + let spanClosed = false + const closeSpan = (data: Record) => { + if (spanClosed) return + spanClosed = true + span.end(data) + } const semanticEvent = event as Record setRuntimes(prev => { const current = prev[sessionId] ?? emptyRuntime() @@ -503,6 +528,11 @@ export function useIpcSubscriptions( ? current.awaitingAssistant === false : true if (semanticUnchanged && phaseUnchanged && ghostsUnchanged && awaitingUnchanged) { + closeSpan({ + sessionId, + eventType: eventType || 'semantic', + changed: false, + }) return prev } @@ -533,6 +563,11 @@ export function useIpcSubscriptions( [sessionId]: nextCurrent, } }) + closeSpan({ + sessionId, + eventType: typeof semanticEvent.type === 'string' ? semanticEvent.type : 'semantic', + scheduled: true, + }) }) const offTrustDialog = window.api.onSessionTrustDialog( @@ -606,6 +641,16 @@ export function useIpcSubscriptions( // 6. Optimistic-Codex-user reconciliation against the head row. const offEntries = window.api.onSessionJsonlEntries(({ sessionId, entries }) => { if (!entries || entries.length === 0) return + const span = perf.span('workspace.ipc.jsonl.bulk', { + sessionId, + burstSize: entries.length, + }) + let spanClosed = false + const closeSpan = (data: Record) => { + if (spanClosed) return + spanClosed = true + span.end(data) + } // Two passes per burst: // A — accumulate workspace-state captures (providerSessionId). @@ -899,7 +944,15 @@ export function useIpcSubscriptions( queuedMessages === current.queuedMessages && awaitingAssistant === current.awaitingAssistant && !ghostsChanged - if (noChange) return prev + if (noChange) { + closeSpan({ + sessionId, + burstSize: entries.length, + appendedCount: 0, + changed: false, + }) + return prev + } const nextRuntime = withDerivedSessionStatus( appendFeedDebugLog( @@ -941,6 +994,14 @@ export function useIpcSubscriptions( }, ), ) + closeSpan({ + sessionId, + burstSize: entries.length, + appendedCount: appended.length, + droppedOptimisticHead: dropOptimisticHead, + ghostsChanged, + queuedMessages: queuedMessages.length, + }) return { ...prev, [sessionId]: nextRuntime, @@ -972,6 +1033,11 @@ export function useIpcSubscriptions( }) }, 150) refs.bootstrapTimersRef.current.set(sessionId, timer) + closeSpan({ + sessionId, + burstSize: entries.length, + scheduled: true, + }) }) return () => { diff --git a/src/renderer/src/workspace/hook/persistence/rehydrate.ts b/src/renderer/src/workspace/hook/persistence/rehydrate.ts index 9252ea986..14047325d 100644 --- a/src/renderer/src/workspace/hook/persistence/rehydrate.ts +++ b/src/renderer/src/workspace/hook/persistence/rehydrate.ts @@ -17,6 +17,7 @@ import type { WorkspaceSetTileTabs, } from '@renderer/workspace/hook/context' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' +import * as perf from '@renderer/performance/client' // Remap a persisted tree by replacing every sessionId with a freshly // spawned one (spawn happens as we walk). Returns the remapped tree @@ -53,6 +54,11 @@ export async function rehydrateWorkspace( setTileTabs: WorkspaceSetTileTabs, newTab: (cwd: string) => Promise, ): Promise { + perf.mark('workspace.rehydrate.start', { + tabs: persisted.tabs.length, + sessions: Object.keys(persisted.sessions).length, + buried: persisted.buried?.length ?? 0, + }) const idMap = new Map() const freshSessions: Record = {} @@ -184,6 +190,12 @@ export async function rehydrateWorkspace( // back. await Promise.all( Object.entries(persisted.sessions).map(async ([oldId, meta]) => { + const restoreSpan = perf.span('workspace.rehydrate.session', { + oldId, + kind: meta.kind ?? 'claude', + hasProviderSessionId: Boolean(meta.providerSessionId), + hasTmuxName: Boolean(meta.tmuxName), + }) try { const kind: SessionKind = meta.kind ?? 'claude' // For terminal sessions with a persisted tmuxName, pass it @@ -209,7 +221,9 @@ export async function rehydrateWorkspace( ...(nextTmuxName ? { tmuxName: nextTmuxName } : {}), } commitRehydratedState() + restoreSpan.end({ newId }) } catch (err) { + restoreSpan.fail(err) // eslint-disable-next-line no-console console.warn(`[workspace] failed to respawn ${meta.cwd}:`, err) } @@ -220,4 +234,7 @@ export async function rehydrateWorkspace( const cwd = await window.api.defaultCwd() await newTab(cwd) } + perf.mark('workspace.rehydrate.complete', { + restoredSessions: Object.keys(freshSessions).length, + }) } diff --git a/src/renderer/src/workspace/hook/persistence/useAutoSave.ts b/src/renderer/src/workspace/hook/persistence/useAutoSave.ts index dbfcf1995..701b2018e 100644 --- a/src/renderer/src/workspace/hook/persistence/useAutoSave.ts +++ b/src/renderer/src/workspace/hook/persistence/useAutoSave.ts @@ -4,6 +4,7 @@ import type { PersistedWorkspace } from '@renderer/workspace/persistence' import type { SessionId, WorkspaceState } from '@renderer/workspace/types' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' +import * as perf from '@renderer/performance/client' // Debounced workspace-save + beforeunload flush. // @@ -29,6 +30,7 @@ export function useAutoSave( refs: WorkspaceRefs, ): void { const flushSave = useCallback(() => { + const saveSpan = perf.span('workspace.autosave.flush') const s = refs.latestStateRef.current // Collect non-empty drafts so in-progress prompts survive crashes. const drafts: Record = {} @@ -48,11 +50,27 @@ export function useAutoSave( tileTabs: refs.latestTileTabsRef.current, drafts: Object.keys(drafts).length > 0 ? drafts : undefined, } - const json = JSON.stringify({ workspace: persisted }, null, 2) - void window.api.saveWorkspace(json).catch(err => { - // eslint-disable-next-line no-console - console.warn('[workspace] save failed:', err) - }) + let json = '' + try { + json = JSON.stringify({ workspace: persisted }, null, 2) + } catch (err) { + saveSpan.fail(err) + throw err + } + void window.api.saveWorkspace(json) + .then(() => { + saveSpan.end({ + tabs: persisted.tabs.length, + sessions: Object.keys(persisted.sessions).length, + tileTabs: persisted.tileTabs.length, + bytes: json.length, + }) + }) + .catch(err => { + saveSpan.fail(err, { bytes: json.length }) + // eslint-disable-next-line no-console + console.warn('[workspace] save failed:', err) + }) }, [refs.latestRuntimesRef, refs.latestStateRef, refs.latestTileTabsRef]) useEffect(() => { diff --git a/src/renderer/src/workspace/hook/persistence/useBootstrap.ts b/src/renderer/src/workspace/hook/persistence/useBootstrap.ts index cbd54eefd..e8584ac82 100644 --- a/src/renderer/src/workspace/hook/persistence/useBootstrap.ts +++ b/src/renderer/src/workspace/hook/persistence/useBootstrap.ts @@ -10,6 +10,7 @@ import type { import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' import { rehydrateWorkspace } from '@renderer/workspace/hook/persistence/rehydrate' +import * as perf from '@renderer/performance/client' // Once-only mount effect. // @@ -32,13 +33,20 @@ export function useBootstrap( if (refs.bootRef.current) return refs.bootRef.current = true void (async () => { - const json = await window.api.loadWorkspace() + const bootstrapSpan = perf.span('workspace.bootstrap') + const json = await perf.measure('workspace.bootstrap.loadWorkspace', () => + window.api.loadWorkspace(), + ) if (!json) { // Fresh install — create one default tab. - const cwd = await window.api.defaultCwd() + const cwd = await perf.measure('workspace.bootstrap.defaultCwd', () => + window.api.defaultCwd(), + ) try { - await newTab(cwd) + await perf.measure('workspace.bootstrap.initialNewTab', () => newTab(cwd)) + bootstrapSpan.end({ mode: 'fresh' }) } catch (err) { + bootstrapSpan.fail(err, { mode: 'fresh' }) console.warn('[workspace] initial session spawn failed:', err) } return @@ -50,20 +58,32 @@ export function useBootstrap( // catch below and start fresh. No migrations, no version // gates. const parsed = JSON.parse(json) as { workspace: PersistedWorkspace } - await rehydrateWorkspace( - parsed.workspace, - refs, - setState, - setRuntimes, - setTileTabs, - newTab, + await perf.measure( + 'workspace.bootstrap.rehydrate', + () => + rehydrateWorkspace( + parsed.workspace, + refs, + setState, + setRuntimes, + setTileTabs, + newTab, + ), + { + tabs: parsed.workspace.tabs.length, + sessions: Object.keys(parsed.workspace.sessions).length, + }, ) + bootstrapSpan.end({ mode: 'rehydrate' }) } catch (err) { + bootstrapSpan.fail(err, { mode: 'rehydrate' }) // eslint-disable-next-line no-console console.warn('[workspace] load failed, starting fresh:', err) - const cwd = await window.api.defaultCwd() + const cwd = await perf.measure('workspace.bootstrap.fallbackDefaultCwd', () => + window.api.defaultCwd(), + ) try { - await newTab(cwd) + await perf.measure('workspace.bootstrap.fallbackNewTab', () => newTab(cwd)) } catch (spawnErr) { console.warn('[workspace] fallback session spawn failed:', spawnErr) } diff --git a/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx index db8113634..7137737d4 100644 --- a/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx @@ -42,6 +42,7 @@ import type { Workspace } from '@renderer/workspace/workspaceStore' type Props = { sessionId: SessionId + paneLabel?: string focused: boolean onFocusRequest: () => void workspace: Workspace @@ -49,6 +50,7 @@ type Props = { export function TerminalLeaf({ sessionId, + paneLabel, focused, onFocusRequest, workspace: _workspace, @@ -315,7 +317,14 @@ export function TerminalLeaf({ mixed layout doesn't look ragged. We don't have CC-style live state to show, so just a static "terminal" label. */}
- terminal +
+ {paneLabel && ( + + {paneLabel} + + )} + terminal +
$
diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx index 2b3f9b948..736d4c190 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx @@ -67,6 +67,7 @@ import { recordHtmlTraceSnapshot } from '@renderer/features/debug/renderTrace' type Props = { sessionId: SessionId runtime: SessionRuntime + paneLabel?: string focused: boolean onFocusRequest: () => void workspace: Workspace @@ -75,6 +76,7 @@ type Props = { export function TileLeaf({ sessionId, runtime, + paneLabel, focused, onFocusRequest, workspace, @@ -238,6 +240,7 @@ export function TileLeaf({ onMouseDown={onFocusRequest} > - - {shortenCwd(projectDir)} - +
+ {paneLabel && ( + + {paneLabel} + + )} + + {shortenCwd(projectDir)} + +
) } diff --git a/src/renderer/src/workspace/tile-tree/TileTree.tsx b/src/renderer/src/workspace/tile-tree/TileTree.tsx index 470ba315c..1f61fa8d9 100644 --- a/src/renderer/src/workspace/tile-tree/TileTree.tsx +++ b/src/renderer/src/workspace/tile-tree/TileTree.tsx @@ -5,6 +5,7 @@ import { TerminalLeaf } from '@renderer/workspace/tile-tree/TerminalLeaf' import type { Workspace } from '@renderer/workspace/workspaceStore' import type { SessionId, TabId, TileNode } from '@renderer/workspace/types' import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' +import { paneLabelForSession } from '@renderer/workspace/tile-tree/paneLabels' // TileTree — recursive renderer for a tab's binary-split tree. // @@ -63,11 +64,13 @@ export function renderWorkspaceLeaf( ) { const meta = workspace.state.sessions[sessionId] const kind = meta?.kind ?? 'claude' + const paneLabel = paneLabelForSession(workspace.state.tabs, tabId, sessionId) if (kind === 'terminal') { return ( workspace.focusSessionInTab(tabId, sessionId)} workspace={workspace} @@ -82,6 +85,7 @@ export function renderWorkspaceLeaf( workspace.focusSessionInTab(tabId, sessionId)} workspace={workspace} diff --git a/src/renderer/src/workspace/tile-tree/paneLabels.ts b/src/renderer/src/workspace/tile-tree/paneLabels.ts new file mode 100644 index 000000000..a6c8eb5e1 --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/paneLabels.ts @@ -0,0 +1,25 @@ +import type { SessionId, Tab, TabId } from '@renderer/workspace/types' +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' + +export function tabIndexLabel(index: number): string { + if (index < 0) return '?' + let n = index + let label = '' + do { + label = String.fromCharCode(65 + (n % 26)) + label + n = Math.floor(n / 26) - 1 + } while (n >= 0) + return label +} + +export function paneLabelForSession( + tabs: Tab[], + tabId: TabId, + sessionId: SessionId, +): string { + const tabIndex = tabs.findIndex(tab => tab.id === tabId) + const tab = tabIndex >= 0 ? tabs[tabIndex] : null + if (!tab) return '?' + const paneIndex = collectLeaves(tab.root).indexOf(sessionId) + return `${tabIndexLabel(tabIndex)}${paneIndex >= 0 ? paneIndex + 1 : '?'}` +} diff --git a/src/shared/performance/types.ts b/src/shared/performance/types.ts new file mode 100644 index 000000000..034a0d3f6 --- /dev/null +++ b/src/shared/performance/types.ts @@ -0,0 +1,72 @@ +export type PerformanceProcess = 'main' | 'renderer' | 'preload' + +export type PerformanceRecordKind = + | 'mark' + | 'span_start' + | 'span_end' + | 'metric' + | 'error' + +export type PerformanceLevel = 'info' | 'debug' | 'warn' | 'error' + +export type PerformanceMetricType = 'counter' | 'gauge' | 'sample' + +export type PerformanceConfig = { + enabled: boolean + verbose: boolean + slowSpanMs: number + runId: string | null + runDir: string | null +} + +export type PerformanceRecord = { + kind: PerformanceRecordKind + process: PerformanceProcess + area: string + name: string + ts?: number + tsIso?: string + monotonicMs?: number + runId?: string + level?: PerformanceLevel + spanId?: string + parentSpanId?: string + durationMs?: number + metricType?: PerformanceMetricType + value?: number + unit?: string + sessionId?: string + tabId?: string + provider?: string + count?: number + data?: Record + error?: { + name?: string + message: string + stack?: string + } +} + +export type PerformanceSnapshot = { + runId: string | null + runDir: string | null + files: Array<{ name: string; content: string }> +} + +export type PanePerformanceStats = { + sessionId: string + kind: 'claude' | 'codex' | 'terminal' + status: 'running' | 'idle' | 'exited' | 'unknown' + rootPid: number | null + cpuPercent: number | null + memoryBytes: number | null + childCount: number + lastActivityAt: number | null + sampledAt: number +} + +export type PanePerformanceSnapshot = { + enabled: boolean + sampledAt: number + panes: PanePerformanceStats[] +} diff --git a/src/shared/runtime/terminalSession.ts b/src/shared/runtime/terminalSession.ts index 903cf30bc..1f922ab87 100644 --- a/src/shared/runtime/terminalSession.ts +++ b/src/shared/runtime/terminalSession.ts @@ -215,6 +215,10 @@ export class TerminalSession extends EventEmitter { } } + getProcessPid(): number | null { + return this.pty?.pid ?? null + } + /** True if the PTY has exited. */ isExited(): boolean { return this.exited diff --git a/src/shared/types/providerConfig.ts b/src/shared/types/providerConfig.ts index ec6279532..ac205fcae 100644 --- a/src/shared/types/providerConfig.ts +++ b/src/shared/types/providerConfig.ts @@ -21,6 +21,7 @@ export type TileLeafProps = { sessionId: string runtime: unknown focused: boolean + paneLabel?: string onFocusRequest: () => void workspace: unknown } diff --git a/tsconfig.web.json b/tsconfig.web.json index 8c8d2cf34..63986a788 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -44,6 +44,7 @@ "src/preload/index.d.ts", "src/shared/code/**/*", "src/shared/types/**/*", + "src/shared/performance/**/*", "src/shared/parsers/**/*", "src/providers/*/parsers/**/*", "src/providers/*/renderer/**/*",