From 29401916a8a8f1f97b5e2d84c7cf136f4760d748 Mon Sep 17 00:00:00 2001 From: Paul Holstein <44263169+holstein13@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:20:03 -0500 Subject: [PATCH 01/40] docs: add session analysis report design document Co-Authored-By: Claude Opus 4.6 --- ...26-02-21-session-analysis-report-design.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/plans/2026-02-21-session-analysis-report-design.md diff --git a/docs/plans/2026-02-21-session-analysis-report-design.md b/docs/plans/2026-02-21-session-analysis-report-design.md new file mode 100644 index 00000000..62aa64d8 --- /dev/null +++ b/docs/plans/2026-02-21-session-analysis-report-design.md @@ -0,0 +1,107 @@ +# Session Analysis Report — Design Document + +**Date:** 2026-02-21 +**Status:** Approved + +## Overview + +Port the `scripts/analyze-session.py` analysis logic to TypeScript and display results as a beautifully formatted report in a new tab. An Activity icon button in the session toolbar triggers the analysis. + +## Decisions + +- **TypeScript port** — no Python dependency; runs in-process in the renderer +- **New tab** — opens a dedicated report tab (consistent with Settings/Notifications pattern) +- **Activity icon** — in the toolbar next to the Export dropdown +- **Full port** — all ~30 analysis sections from the Python script +- **Renderer-only** — no new IPC; `SessionDetail.messages` already has all raw data + +## Architecture + +``` +TabBar.tsx (Activity button click) + → store action: openSessionReport(sourceTabId) + → creates tab { type: 'report', projectId, sessionId, sourceTabId } + → SessionReportTab mounts + → analyzeSession(sessionDetail) from sessionAnalyzer.ts + → renders report sections +``` + +## New Files + +### Types +- `src/renderer/types/sessionReport.ts` — `SessionReport` interface with all section types + +### Analysis Engine +- `src/renderer/utils/sessionAnalyzer.ts` — `analyzeSession(detail: SessionDetail): SessionReport` + - Single-pass over `detail.messages` (mirrors the Python script's accumulator pattern) + - Post-pass aggregation for derived metrics + - Uses `detail.session` for metadata, `detail.processes` for subagent data + +### Report UI +- `src/renderer/components/report/SessionReportTab.tsx` — Main report tab +- `src/renderer/components/report/sections/` — Section components: + - `OverviewSection` — Session ID, project, duration, message count, context assessment + - `CostSection` — Cost by model, total, per-commit, per-line + - `TokenSection` — Token usage by model, cache economics, density timeline + - `ToolSection` — Tool counts, success rates + - `SubagentSection` — Subagent metrics, cost, token usage + - `ErrorSection` — Tool errors, permission denials + - `GitSection` — Commits, pushes, branches, lines changed + - `FrictionSection` — User corrections, thrashing signals + - `TimelineSection` — Idle gaps, model switches, key events + - `ConversationTreeSection` — Tree depth, branching, sidechains + - `QualitySection` — Prompt quality, startup overhead, test progression + +### Integration Points (modified files) +- `src/renderer/types/tabs.ts` — Add `'report'` tab type +- `src/renderer/store/slices/tabSlice.ts` — Add `openSessionReport` action +- `src/renderer/components/layout/TabBar.tsx` — Add Activity icon button +- `src/renderer/App.tsx` (or routing equivalent) — Route `report` tabs to `SessionReportTab` + +## Report Visual Design + +- Each section is a card with `bg-surface-raised` background and `border-border` border +- Section headers with lucide-react icons and bold titles +- Data in tables and stat grids using theme-aware CSS variables +- Color-coded assessments: green (healthy), amber (warning), red (critical) +- Collapsible detail sections for verbose data (thinking blocks, error details, idle gaps) +- Scrollable report body with sticky section navigation + +## Data Flow + +The `SessionDetail` already contains: +- `messages: ParsedMessage[]` — raw messages with toolCalls, toolResults, usage, model, timestamp, uuid/parentUuid, isMeta, cwd, gitBranch, agentId, isSidechain +- `session: Session` — metadata (contextConsumption, compactionCount, phaseBreakdown, etc.) +- `processes: Process[]` — subagent executions with nested messages and metrics +- `metrics: SessionMetrics` — pre-computed aggregates + +The analyzer works directly with these types — no JSON serialization or IPC needed. + +## Analysis Sections (ported from Python) + +| Section | Key Metrics | +|---------|------------| +| Overview | Duration, message count, context consumption, compaction count | +| Cost Analysis | Parent + subagent cost, cost by model, per-commit, per-line | +| Token Usage | By model (input/output/cache), totals, cache read % | +| Cache Economics | Creation 5m/1h, read/write ratio, cold start, efficiency % | +| Tool Usage | Counts, success rates per tool | +| Subagent Metrics | Count, tokens, duration, cost per agent | +| Errors | Tool errors, permission denials, affected tools | +| Git Activity | Commits, pushes, branch creations, lines changed | +| Friction Signals | Correction count, friction rate, keyword matches | +| Thrashing | Bash near-duplicates, file edit rework | +| Conversation Tree | Max depth, sidechain count, branch points | +| Idle Analysis | Gap count, total idle time, active working time | +| Model Switches | Switch count, models used | +| Working Directories | Unique dirs, change count | +| Test Progression | Snapshots, trajectory (improving/regressing/stable) | +| Startup Overhead | Messages/tokens before first work tool | +| Token Density Timeline | Quartile averages | +| Prompt Quality | First message length, friction rate, assessment | +| Thinking Blocks | Count, signal analysis (alternatives, uncertainty, planning) | +| Key Events | Timestamped skill invocations, task launches | +| Service Tiers | API tier usage distribution | +| File Read Redundancy | Reads per unique file, redundant files | +| Compact Summaries | Count of context compaction events | +| Out-of-scope Findings | Keywords detected in assistant responses | From feb0b66df8cc3dc5d944a938a15f327e42d52f52 Mon Sep 17 00:00:00 2001 From: Paul Holstein <44263169+holstein13@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:24:33 -0500 Subject: [PATCH 02/40] docs: add session analysis report implementation plan 7 tasks with acceptance criteria covering types, analyzer engine, tests, tab integration, UI components, routing, and polish. Co-Authored-By: Claude Opus 4.6 --- .../2026-02-21-session-analysis-report.md | 875 ++++++++++++++++++ 1 file changed, 875 insertions(+) create mode 100644 docs/plans/2026-02-21-session-analysis-report.md diff --git a/docs/plans/2026-02-21-session-analysis-report.md b/docs/plans/2026-02-21-session-analysis-report.md new file mode 100644 index 00000000..9bd87604 --- /dev/null +++ b/docs/plans/2026-02-21-session-analysis-report.md @@ -0,0 +1,875 @@ +# Session Analysis Report Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a toolbar button that runs a full session analysis and displays results in a beautifully formatted report tab. + +**Architecture:** Pure renderer-side analysis engine (`sessionAnalyzer.ts`) processes `SessionDetail.messages` in a single pass. Report opens in a new tab type (`'report'`) rendered by `SessionReportTab`. No new IPC needed — all data is already available from `tabSessionData`. + +**Tech Stack:** React 18, TypeScript, Zustand, Tailwind CSS, lucide-react icons + +--- + +### Task 1: Add report types + +**Acceptance Criteria:** +- [ ] File exists at `src/renderer/types/sessionReport.ts` +- [ ] `SessionReport` interface is exported from `src/renderer/types/sessionReport.ts` +- [ ] `pnpm typecheck` passes with no new errors + +**Files:** +- Create: `src/renderer/types/sessionReport.ts` + +**Step 1: Create the SessionReport type file** + +This file defines all the report section types. The analyzer will return a `SessionReport` object. + +```typescript +/** + * Session analysis report types. + * Output of analyzeSession() — one interface per report section. + */ + +// ============================================================================= +// Pricing +// ============================================================================= + +export interface ModelPricing { + input: number; + output: number; + cache_read: number; + cache_creation: number; +} + +// ============================================================================= +// Report Sections +// ============================================================================= + +export interface ReportOverview { + sessionId: string; + projectId: string; + projectPath: string; + firstMessage: string; + messageCount: number; + hasSubagents: boolean; + contextConsumption: number; + contextConsumptionPct: number | null; + contextAssessment: 'critical' | 'high' | 'moderate' | 'healthy' | null; + compactionCount: number; + gitBranch: string; + startTime: Date | null; + endTime: Date | null; + durationSeconds: number; + durationHuman: string; + totalMessages: number; +} + +export interface ModelTokenStats { + apiCalls: number; + inputTokens: number; + outputTokens: number; + cacheCreation: number; + cacheRead: number; + costUsd: number; +} + +export interface TokenTotals { + inputTokens: number; + outputTokens: number; + cacheCreation: number; + cacheRead: number; + grandTotal: number; + cacheReadPct: number; +} + +export interface ReportTokenUsage { + byModel: Record; + totals: TokenTotals; +} + +export interface ReportCostAnalysis { + parentCostUsd: number; + subagentCostUsd: number; + totalSessionCostUsd: number; + costByModel: Record; + costPerCommit: number | null; + costPerLineChanged: number | null; +} + +export interface ReportCacheEconomics { + cacheCreation5m: number; + cacheCreation1h: number; + cacheRead: number; + cacheEfficiencyPct: number; + coldStartDetected: boolean; + cacheReadToWriteRatio: number; +} + +export interface ToolSuccessRate { + totalCalls: number; + errors: number; + successRatePct: number; +} + +export interface ReportToolUsage { + counts: Record; + totalCalls: number; + successRates: Record; +} + +export interface SubagentEntry { + description: string; + subagentType: string; + model: string; + totalTokens: number; + totalDurationMs: number; + totalToolUseCount: number; + costUsd: number; + costNote?: string; +} + +export interface ReportSubagentMetrics { + count: number; + totalTokens: number; + totalDurationMs: number; + totalToolUseCount: number; + totalCostUsd: number; + byAgent: SubagentEntry[]; +} + +export interface ToolError { + tool: string; + inputPreview: string; + error: string; + messageIndex: number; + isPermissionDenial: boolean; +} + +export interface ReportErrors { + errors: ToolError[]; + permissionDenials: { + count: number; + denials: ToolError[]; + affectedTools: string[]; + }; +} + +export interface GitCommit { + messagePreview: string; + messageIndex: number; +} + +export interface ReportGitActivity { + commitCount: number; + commits: GitCommit[]; + pushCount: number; + branchCreations: string[]; + linesAdded: number; + linesRemoved: number; + linesChanged: number; +} + +export interface FrictionCorrection { + messageIndex: number; + keyword: string; + preview: string; +} + +export interface ReportFrictionSignals { + correctionCount: number; + corrections: FrictionCorrection[]; + frictionRate: number; +} + +export interface ReportThrashingSignals { + bashNearDuplicates: { prefix: string; count: number }[]; + editReworkFiles: { filePath: string; editIndices: number[] }[]; +} + +export interface ReportConversationTree { + totalNodes: number; + maxDepth: number; + sidechainCount: number; + branchPoints: number; + branchDetails: { + parentUuid: string; + childCount: number; + parentMessageIndex: number | undefined; + }[]; +} + +export interface IdleGap { + gapSeconds: number; + gapHuman: string; + afterMessageIndex: number; +} + +export interface ReportIdleAnalysis { + idleThresholdSeconds: number; + idleGapCount: number; + totalIdleSeconds: number; + totalIdleHuman: string; + wallClockSeconds: number; + activeWorkingSeconds: number; + activeWorkingHuman: string; + idlePct: number; + longestGaps: IdleGap[]; +} + +export interface ModelSwitch { + from: string; + to: string; + messageIndex: number; + timestamp: Date | null; +} + +export interface ReportModelSwitches { + count: number; + switches: ModelSwitch[]; + modelsUsed: string[]; +} + +export interface ReportWorkingDirectories { + uniqueDirectories: string[]; + directoryCount: number; + changes: { from: string; to: string; messageIndex: number }[]; + changeCount: number; + isMultiDirectory: boolean; +} + +export interface TestSnapshot { + messageIndex: number; + passed: number; + failed: number; + total: number; + raw: string; +} + +export interface ReportTestProgression { + snapshotCount: number; + snapshots: TestSnapshot[]; + trajectory: 'improving' | 'regressing' | 'stable' | 'insufficient_data'; + firstSnapshot: TestSnapshot | null; + lastSnapshot: TestSnapshot | null; +} + +export interface ReportStartupOverhead { + messagesBeforeFirstWork: number; + tokensBeforeFirstWork: number; + pctOfTotal: number; +} + +export interface ReportTokenDensityTimeline { + quartiles: { q: number; avgTokens: number; messageCount: number }[]; +} + +export interface ReportPromptQuality { + firstMessageLengthChars: number; + userMessageCount: number; + correctionCount: number; + frictionRate: number; + assessment: 'underspecified' | 'verbose_but_unclear' | 'well_specified' | 'moderate_friction'; + note: string; +} + +export interface ThinkingBlockAnalysis { + messageIndex: number; + preview: string; + charLength: number; + signals: Record; +} + +export interface ReportThinkingBlocks { + count: number; + analyzedCount: number; + signalSummary: Record; + notableBlocks: ThinkingBlockAnalysis[]; +} + +export interface KeyEvent { + timestamp: Date; + label: string; + deltaSeconds?: number; + deltaHuman?: string; +} + +export interface ReportFileReadRedundancy { + totalReads: number; + uniqueFiles: number; + readsPerUniqueFile: number; + redundantFiles: Record; +} + +// ============================================================================= +// Combined Report +// ============================================================================= + +export interface SessionReport { + overview: ReportOverview; + tokenUsage: ReportTokenUsage; + costAnalysis: ReportCostAnalysis; + cacheEconomics: ReportCacheEconomics; + toolUsage: ReportToolUsage; + subagentMetrics: ReportSubagentMetrics; + errors: ReportErrors; + gitActivity: ReportGitActivity; + frictionSignals: ReportFrictionSignals; + thrashingSignals: ReportThrashingSignals; + conversationTree: ReportConversationTree; + idleAnalysis: ReportIdleAnalysis; + modelSwitches: ReportModelSwitches; + workingDirectories: ReportWorkingDirectories; + testProgression: ReportTestProgression; + startupOverhead: ReportStartupOverhead; + tokenDensityTimeline: ReportTokenDensityTimeline; + promptQuality: ReportPromptQuality; + thinkingBlocks: ReportThinkingBlocks; + keyEvents: KeyEvent[]; + messageTypes: Record; + serviceTiers: Record; + fileReadRedundancy: ReportFileReadRedundancy; + compactionCount: number; + gitBranches: string[]; +} +``` + +**Step 2: Verify types compile** + +Run: `pnpm typecheck` +Expected: No errors related to sessionReport.ts (file is only types, no imports yet) + +**Step 3: Commit** + +```bash +git add src/renderer/types/sessionReport.ts +git commit -m "feat(report): add session analysis report type definitions" +``` + +--- + +### Task 2: Build the session analyzer + +**Acceptance Criteria:** +- [ ] File exists at `src/renderer/utils/sessionAnalyzer.ts` +- [ ] `analyzeSession` function is exported from `src/renderer/utils/sessionAnalyzer.ts` +- [ ] `pnpm typecheck` passes with no new errors + +**Files:** +- Create: `src/renderer/utils/sessionAnalyzer.ts` + +**Docs to reference:** +- `scripts/analyze-session.py` — the Python script being ported (all logic) +- `src/main/types/messages.ts` — `ParsedMessage`, `ToolCall`, `ToolResult` +- `src/main/types/domain.ts` — `Session`, `SessionMetrics`, `TokenUsage` (= `UsageMetadata`) +- `src/main/types/chunks.ts` — `SessionDetail`, `Process` +- `src/main/types/jsonl.ts` — `UsageMetadata` (input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens) + +**Step 1: Create the analyzer** + +Port all logic from `scripts/analyze-session.py` to TypeScript. The analyzer takes a `SessionDetail` (which has `session`, `messages`, `processes`, `metrics`) and returns a `SessionReport`. + +Key mapping from Python to TS: +- Python `data["messages"]` → `detail.messages: ParsedMessage[]` +- Python `data["session"]` → `detail.session: Session` +- Python `m.get("toolCalls", [])` → `msg.toolCalls: ToolCall[]` +- Python `m.get("toolResults", [])` → `msg.toolResults: ToolResult[]` +- Python `m.get("usage")` → `msg.usage?: TokenUsage` (fields: `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`) +- Python `m.get("model")` → `msg.model?: string` +- Python `m.get("timestamp")` → `msg.timestamp: Date` (already parsed) +- Python `m.get("isMeta")` → `msg.isMeta: boolean` +- Python `m.get("uuid")` → `msg.uuid: string` +- Python `m.get("parentUuid")` → `msg.parentUuid: string | null` +- Python `m.get("cwd")` → `msg.cwd?: string` +- Python `m.get("gitBranch")` → `msg.gitBranch?: string` +- Python `m.get("isSidechain")` → `msg.isSidechain: boolean` +- Python `m.get("isCompactSummary")` → `msg.isCompactSummary?: boolean` +- Python `m.get("agentId")` → `msg.agentId?: string` +- For subagent data, use `detail.processes: Process[]` (already resolved with metrics, duration, description, subagentType) + +The function signature: + +```typescript +import type { SessionDetail } from '@renderer/types/data'; +import type { SessionReport } from '@renderer/types/sessionReport'; + +export function analyzeSession(detail: SessionDetail): SessionReport { ... } +``` + +Follow the Python script's single-pass pattern: +1. Initialize accumulators +2. Loop over `detail.messages` once, extracting all data +3. Post-pass aggregation +4. Return typed `SessionReport` + +For content text extraction, use this helper (mirrors Python's `extract_text_content`): + +```typescript +function extractTextContent(msg: ParsedMessage): string { + const { content } = msg; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join(' '); + } + return ''; +} +``` + +For pricing, port the `MODEL_PRICING` table and `costUsd()` function directly. + +For subagent metrics, use `detail.processes` instead of parsing `` tags — the data is already resolved: +```typescript +const subagentEntries: SubagentEntry[] = detail.processes.map((proc) => ({ + description: proc.description ?? 'unknown', + subagentType: proc.subagentType ?? 'unknown', + model: 'default (inherits parent)', + totalTokens: proc.metrics.totalTokens, + totalDurationMs: proc.durationMs, + totalToolUseCount: proc.messages.reduce((sum, m) => sum + m.toolCalls.length, 0), + costUsd: proc.metrics.costUsd ?? 0, +})); +``` + +Port ALL regex patterns from Python: +- `FRICTION_PATTERNS` — friction keyword detection +- `PERMISSION_PATTERNS` — permission denial detection +- `TEST_PASS_PATTERNS`, `TEST_FAIL_PATTERNS`, `TEST_SUMMARY_PATTERN` — test output parsing +- `THINKING_SIGNALS` — thinking block content analysis + +**Step 2: Verify it compiles** + +Run: `pnpm typecheck` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/renderer/utils/sessionAnalyzer.ts +git commit -m "feat(report): add session analyzer engine (TS port of analyze-session.py)" +``` + +--- + +### Task 3: Write analyzer tests + +**Acceptance Criteria:** +- [ ] File exists at `test/renderer/utils/sessionAnalyzer.test.ts` +- [ ] `pnpm test test/renderer/utils/sessionAnalyzer.test.ts` passes with all tests green + +**Files:** +- Create: `test/renderer/utils/sessionAnalyzer.test.ts` + +**Step 1: Write tests** + +Test the analyzer with mock `SessionDetail` objects. At minimum: + +1. **Empty session** — no messages, returns zeroed report +2. **Basic session** — a few user + assistant messages with usage data, verify overview, token counts, cost +3. **Tool usage** — messages with toolCalls and toolResults, verify tool counts and success rates +4. **Error detection** — toolResults with `isError: true`, verify error list and permission denial detection +5. **Friction detection** — user messages with "no,", "wrong", "actually" keywords +6. **Git activity** — Bash toolCalls containing "git commit", "git push" +7. **Idle gaps** — messages with timestamps >60s apart +8. **Model switches** — assistant messages with different model fields +9. **Conversation tree** — messages with uuid/parentUuid, verify depth and branching + +Create a `createMockMessage()` helper for building `ParsedMessage` objects easily: + +```typescript +function createMockMessage(overrides: Partial = {}): ParsedMessage { + return { + uuid: crypto.randomUUID(), + parentUuid: null, + type: 'assistant', + timestamp: new Date(), + content: '', + isSidechain: false, + isMeta: false, + toolCalls: [], + toolResults: [], + ...overrides, + }; +} +``` + +And a `createMockDetail()` helper: + +```typescript +function createMockDetail(overrides: Partial = {}): SessionDetail { + return { + session: { id: 'test', projectId: 'test', projectPath: '/test', createdAt: Date.now(), hasSubagents: false, messageCount: 0 } as Session, + messages: [], + chunks: [], + processes: [], + metrics: { durationMs: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, messageCount: 0 }, + ...overrides, + }; +} +``` + +**Step 2: Run tests** + +Run: `pnpm test test/renderer/utils/sessionAnalyzer.test.ts` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add test/renderer/utils/sessionAnalyzer.test.ts +git commit -m "test(report): add session analyzer tests" +``` + +--- + +### Task 4: Add 'report' tab type and store action + +**Acceptance Criteria:** +- [ ] `src/renderer/types/tabs.ts` contains `'report'` in the Tab type union +- [ ] `src/renderer/components/layout/SortableTab.tsx` contains `report: Activity` in TAB_ICONS +- [ ] `openSessionReport` is declared in TabSlice interface in `src/renderer/store/slices/tabSlice.ts` +- [ ] `pnpm typecheck` passes with no new errors + +**Files:** +- Modify: `src/renderer/types/tabs.ts:79` — add `'report'` to Tab type union +- Modify: `src/renderer/components/layout/SortableTab.tsx:28-33` — add report icon to TAB_ICONS +- Modify: `src/renderer/store/slices/tabSlice.ts` — add `openSessionReport` action +- Modify: `src/renderer/store/types.ts` (if needed for new slice, but likely just extend tabSlice) + +**Step 1: Add 'report' to Tab type** + +In `src/renderer/types/tabs.ts`, line 79, change: +```typescript +type: 'session' | 'dashboard' | 'notifications' | 'settings'; +``` +to: +```typescript +type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'report'; +``` + +**Step 2: Add report icon to SortableTab** + +In `src/renderer/components/layout/SortableTab.tsx`, add `Activity` to the lucide-react import and to `TAB_ICONS`: + +```typescript +import { Activity, Bell, FileText, LayoutDashboard, Pin, Search, Settings, X } from 'lucide-react'; + +const TAB_ICONS = { + dashboard: LayoutDashboard, + notifications: Bell, + settings: Settings, + session: FileText, + report: Activity, +} as const; +``` + +**Step 3: Add openSessionReport action to tabSlice** + +In `src/renderer/store/slices/tabSlice.ts`, add to the `TabSlice` interface: + +```typescript +openSessionReport: (sourceTabId: string) => void; +``` + +Implement it following the `openNotificationsTab` pattern. It needs to: +1. Get `tabSessionData[sourceTabId]` to find the sessionDetail +2. Extract the session's firstMessage for the tab label +3. Open a new tab with `type: 'report'`, the same `projectId` and `sessionId` as the source tab + +```typescript +openSessionReport: (sourceTabId: string) => { + const state = get(); + const sourceTab = getAllTabs(state.paneLayout).find((t) => t.id === sourceTabId); + if (!sourceTab || sourceTab.type !== 'session') return; + + const tabData = state.tabSessionData[sourceTabId]; + const sessionDetail = tabData?.sessionDetail; + const label = sessionDetail?.session.firstMessage + ? `Report: ${truncateLabel(sessionDetail.session.firstMessage, 30)}` + : 'Session Report'; + + state.openTab({ + type: 'report', + label, + projectId: sourceTab.projectId, + sessionId: sourceTab.sessionId, + }); +}, +``` + +**Step 4: Verify types compile** + +Run: `pnpm typecheck` +Expected: PASS (PaneContent.tsx will have a gap for the `report` type — we'll add it in Task 6) + +**Step 5: Commit** + +```bash +git add src/renderer/types/tabs.ts src/renderer/components/layout/SortableTab.tsx src/renderer/store/slices/tabSlice.ts +git commit -m "feat(report): add 'report' tab type and openSessionReport store action" +``` + +--- + +### Task 5: Build the report UI components + +**Acceptance Criteria:** +- [ ] File exists at `src/renderer/components/report/SessionReportTab.tsx` +- [ ] File exists at `src/renderer/components/report/ReportSection.tsx` +- [ ] Files exist at `src/renderer/components/report/sections/OverviewSection.tsx`, `CostSection.tsx`, `TokenSection.tsx`, `ToolSection.tsx`, `SubagentSection.tsx`, `ErrorSection.tsx`, `GitSection.tsx`, `FrictionSection.tsx`, `TimelineSection.tsx`, `QualitySection.tsx` +- [ ] `pnpm typecheck` passes with no new errors + +**Files:** +- Create: `src/renderer/components/report/SessionReportTab.tsx` +- Create: `src/renderer/components/report/ReportSection.tsx` — reusable section card wrapper +- Create: `src/renderer/components/report/sections/OverviewSection.tsx` +- Create: `src/renderer/components/report/sections/CostSection.tsx` +- Create: `src/renderer/components/report/sections/TokenSection.tsx` +- Create: `src/renderer/components/report/sections/ToolSection.tsx` +- Create: `src/renderer/components/report/sections/SubagentSection.tsx` +- Create: `src/renderer/components/report/sections/ErrorSection.tsx` +- Create: `src/renderer/components/report/sections/GitSection.tsx` +- Create: `src/renderer/components/report/sections/FrictionSection.tsx` +- Create: `src/renderer/components/report/sections/TimelineSection.tsx` +- Create: `src/renderer/components/report/sections/QualitySection.tsx` + +**Docs to reference:** +- `src/renderer/index.css` — CSS variables for theming +- `.claude/rules/tailwind.md` — Theme architecture (use `bg-surface-raised`, `text-text`, `border-border`, etc.) +- `src/renderer/components/common/TokenUsageDisplay.tsx` — Example of formatted token display +- `src/renderer/utils/formatters.ts` — Existing formatting utilities + +**Step 1: Create ReportSection wrapper** + +A reusable card component for each report section: + +```tsx +interface ReportSectionProps { + title: string; + icon: React.ComponentType<{ className?: string }>; + children: React.ReactNode; + defaultCollapsed?: boolean; +} +``` + +Uses `bg-surface-raised`, `border-border`, collapsible with ChevronDown/ChevronRight toggle. + +**Step 2: Create section components** + +Each section receives its typed data from the `SessionReport` and renders it. Design guidelines: + +- **Stat grids**: 2-4 columns of key metrics with label + value +- **Tables**: For lists of items (tools, errors, subagents) using `` with `text-xs` +- **Color coding**: Use inline styles with CSS variables — green for good, amber for warning, red for critical +- **Collapsible details**: For verbose lists (errors, thinking blocks), show count in header and expand for details + +Key section designs: + +**OverviewSection**: Grid of 6-8 stat cards (duration, messages, context %, compaction, branch, cost) + +**CostSection**: Cost by model table + stat cards for total, per-commit, per-line + +**TokenSection**: By-model table (input/output/cache-read/cache-create/cost per model) + totals row + cache economics stats + +**ToolSection**: Sorted table (tool name, calls, errors, success %) — highlight tools with <90% success rate + +**SubagentSection**: Table of subagents (description, type, tokens, duration, cost) + summary stats + +**ErrorSection**: Grouped by tool, expandable error details with input preview + +**GitSection**: Commits list + stat cards (pushes, branches, lines added/removed) + +**FrictionSection**: Friction rate badge + corrections list with message previews + thrashing signals + +**TimelineSection**: Idle gaps table + model switches list + key events timeline + +**QualitySection**: Prompt quality assessment badge + startup overhead stats + test progression + +**Step 3: Create SessionReportTab** + +Main component that: +1. Gets `sessionDetail` from `tabSessionData` using the tab's `sessionId` (find the source session tab's data) +2. Calls `analyzeSession(sessionDetail)` with `useMemo` +3. Renders a scrollable container with all section components +4. Shows loading/error states if session data isn't loaded + +```tsx +import { useMemo } from 'react'; +import { useStore } from '@renderer/store'; +import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; +import type { Tab } from '@renderer/types/tabs'; + +interface SessionReportTabProps { + tab: Tab; +} + +export const SessionReportTab = ({ tab }: SessionReportTabProps) => { + // Find session data from any session tab with matching sessionId + const sessionDetail = useStore((s) => { + const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs); + const sourceTab = allTabs.find( + (t) => t.type === 'session' && t.sessionId === tab.sessionId + ); + return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null; + }); + + const report = useMemo( + () => (sessionDetail ? analyzeSession(sessionDetail) : null), + [sessionDetail] + ); + + if (!report) { + return
No session data available. Open the session first.
; + } + + return ( +
+

+ Session Analysis Report +

+
+ + + + + + + + + + +
+
+ ); +}; +``` + +**Step 4: Verify it compiles** + +Run: `pnpm typecheck` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/renderer/components/report/ +git commit -m "feat(report): add session report tab and all section components" +``` + +--- + +### Task 6: Wire up routing and toolbar button + +**Acceptance Criteria:** +- [ ] `src/renderer/components/layout/PaneContent.tsx` imports and renders `SessionReportTab` for `tab.type === 'report'` +- [ ] `src/renderer/components/layout/TabBar.tsx` contains an Activity button with `onClick` calling `openSessionReport` +- [ ] `pnpm typecheck` passes with no new errors +- [ ] `pnpm test` passes with all existing tests green + +**Files:** +- Modify: `src/renderer/components/layout/PaneContent.tsx:42-49` — add report tab routing +- Modify: `src/renderer/components/layout/TabBar.tsx:17,56,102-107,384-387` — add analyze button + +**Step 1: Add report routing in PaneContent** + +In `src/renderer/components/layout/PaneContent.tsx`, import `SessionReportTab` and add the route: + +```tsx +import { SessionReportTab } from '../report/SessionReportTab'; +``` + +In the tab rendering map (around line 42), add before or after the session case: + +```tsx +{tab.type === 'report' && } +``` + +**Step 2: Add analyze button in TabBar** + +In `src/renderer/components/layout/TabBar.tsx`: + +1. Add `Activity` to the lucide-react import (line 17) +2. Add `openSessionReport` to the store destructure (line 56 area) +3. Add a hover state: `const [analyzeHover, setAnalyzeHover] = useState(false);` +4. Add the button next to ExportDropdown (after line 387): + +```tsx +{/* Analyze button - show only for session tabs with loaded data */} +{activeTab?.type === 'session' && activeTabSessionDetail && activeTabId && ( + +)} +``` + +**Step 3: Verify it compiles** + +Run: `pnpm typecheck` +Expected: PASS + +**Step 4: Run existing tests to verify nothing broke** + +Run: `pnpm test` +Expected: All existing tests still pass + +**Step 5: Commit** + +```bash +git add src/renderer/components/layout/PaneContent.tsx src/renderer/components/layout/TabBar.tsx +git commit -m "feat(report): wire up toolbar button and report tab routing" +``` + +--- + +### Task 7: Manual verification and polish + +**Acceptance Criteria:** +- [ ] [MANUAL] App launches with `pnpm dev` and report tab opens when Activity button is clicked +- [ ] [MANUAL] All report sections render with data from the active session +- [ ] `pnpm test` passes +- [ ] `pnpm typecheck` passes +- [ ] `pnpm lint:fix && pnpm format` passes with no remaining issues + +**Step 1: Run the app** + +Run: `pnpm dev` + +1. Open a session tab +2. Click the Activity (analyze) icon in the toolbar +3. Verify a new "Report: ..." tab opens +4. Verify all sections render with data +5. Check that section cards use correct theme colors +6. Verify collapsible sections work +7. Verify the tab icon shows the Activity icon in the tab bar + +**Step 2: Run full test suite** + +Run: `pnpm test` +Expected: All tests pass + +**Step 3: Run typecheck** + +Run: `pnpm typecheck` +Expected: No errors + +**Step 4: Run lint and format** + +Run: `pnpm lint:fix && pnpm format` + +**Step 5: Final commit if any polish changes** + +```bash +git add -A +git commit -m "feat(report): polish and fix lint issues" +``` From e371ae793f8b9603ba92a2290d9a82763d544300 Mon Sep 17 00:00:00 2001 From: Paul Holstein <44263169+holstein13@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:29:49 -0500 Subject: [PATCH 03/40] feat(report): add session analysis report type definitions Co-Authored-By: Claude Opus 4.6 --- src/renderer/types/sessionReport.ts | 306 ++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 src/renderer/types/sessionReport.ts diff --git a/src/renderer/types/sessionReport.ts b/src/renderer/types/sessionReport.ts new file mode 100644 index 00000000..b43b3894 --- /dev/null +++ b/src/renderer/types/sessionReport.ts @@ -0,0 +1,306 @@ +/** + * Session analysis report types. + * Output of analyzeSession() — one interface per report section. + */ + +// ============================================================================= +// Pricing +// ============================================================================= + +export interface ModelPricing { + input: number; + output: number; + cache_read: number; + cache_creation: number; +} + +// ============================================================================= +// Report Sections +// ============================================================================= + +export interface ReportOverview { + sessionId: string; + projectId: string; + projectPath: string; + firstMessage: string; + messageCount: number; + hasSubagents: boolean; + contextConsumption: number; + contextConsumptionPct: number | null; + contextAssessment: 'critical' | 'high' | 'moderate' | 'healthy' | null; + compactionCount: number; + gitBranch: string; + startTime: Date | null; + endTime: Date | null; + durationSeconds: number; + durationHuman: string; + totalMessages: number; +} + +export interface ModelTokenStats { + apiCalls: number; + inputTokens: number; + outputTokens: number; + cacheCreation: number; + cacheRead: number; + costUsd: number; +} + +export interface TokenTotals { + inputTokens: number; + outputTokens: number; + cacheCreation: number; + cacheRead: number; + grandTotal: number; + cacheReadPct: number; +} + +export interface ReportTokenUsage { + byModel: Record; + totals: TokenTotals; +} + +export interface ReportCostAnalysis { + parentCostUsd: number; + subagentCostUsd: number; + totalSessionCostUsd: number; + costByModel: Record; + costPerCommit: number | null; + costPerLineChanged: number | null; +} + +export interface ReportCacheEconomics { + cacheCreation5m: number; + cacheCreation1h: number; + cacheRead: number; + cacheEfficiencyPct: number; + coldStartDetected: boolean; + cacheReadToWriteRatio: number; +} + +export interface ToolSuccessRate { + totalCalls: number; + errors: number; + successRatePct: number; +} + +export interface ReportToolUsage { + counts: Record; + totalCalls: number; + successRates: Record; +} + +export interface SubagentEntry { + description: string; + subagentType: string; + model: string; + totalTokens: number; + totalDurationMs: number; + totalToolUseCount: number; + costUsd: number; + costNote?: string; +} + +export interface ReportSubagentMetrics { + count: number; + totalTokens: number; + totalDurationMs: number; + totalToolUseCount: number; + totalCostUsd: number; + byAgent: SubagentEntry[]; +} + +export interface ToolError { + tool: string; + inputPreview: string; + error: string; + messageIndex: number; + isPermissionDenial: boolean; +} + +export interface ReportErrors { + errors: ToolError[]; + permissionDenials: { + count: number; + denials: ToolError[]; + affectedTools: string[]; + }; +} + +export interface GitCommit { + messagePreview: string; + messageIndex: number; +} + +export interface ReportGitActivity { + commitCount: number; + commits: GitCommit[]; + pushCount: number; + branchCreations: string[]; + linesAdded: number; + linesRemoved: number; + linesChanged: number; +} + +export interface FrictionCorrection { + messageIndex: number; + keyword: string; + preview: string; +} + +export interface ReportFrictionSignals { + correctionCount: number; + corrections: FrictionCorrection[]; + frictionRate: number; +} + +export interface ReportThrashingSignals { + bashNearDuplicates: { prefix: string; count: number }[]; + editReworkFiles: { filePath: string; editIndices: number[] }[]; +} + +export interface ReportConversationTree { + totalNodes: number; + maxDepth: number; + sidechainCount: number; + branchPoints: number; + branchDetails: { + parentUuid: string; + childCount: number; + parentMessageIndex: number | undefined; + }[]; +} + +export interface IdleGap { + gapSeconds: number; + gapHuman: string; + afterMessageIndex: number; +} + +export interface ReportIdleAnalysis { + idleThresholdSeconds: number; + idleGapCount: number; + totalIdleSeconds: number; + totalIdleHuman: string; + wallClockSeconds: number; + activeWorkingSeconds: number; + activeWorkingHuman: string; + idlePct: number; + longestGaps: IdleGap[]; +} + +export interface ModelSwitch { + from: string; + to: string; + messageIndex: number; + timestamp: Date | null; +} + +export interface ReportModelSwitches { + count: number; + switches: ModelSwitch[]; + modelsUsed: string[]; +} + +export interface ReportWorkingDirectories { + uniqueDirectories: string[]; + directoryCount: number; + changes: { from: string; to: string; messageIndex: number }[]; + changeCount: number; + isMultiDirectory: boolean; +} + +export interface TestSnapshot { + messageIndex: number; + passed: number; + failed: number; + total: number; + raw: string; +} + +export interface ReportTestProgression { + snapshotCount: number; + snapshots: TestSnapshot[]; + trajectory: 'improving' | 'regressing' | 'stable' | 'insufficient_data'; + firstSnapshot: TestSnapshot | null; + lastSnapshot: TestSnapshot | null; +} + +export interface ReportStartupOverhead { + messagesBeforeFirstWork: number; + tokensBeforeFirstWork: number; + pctOfTotal: number; +} + +export interface ReportTokenDensityTimeline { + quartiles: { q: number; avgTokens: number; messageCount: number }[]; +} + +export interface ReportPromptQuality { + firstMessageLengthChars: number; + userMessageCount: number; + correctionCount: number; + frictionRate: number; + assessment: 'underspecified' | 'verbose_but_unclear' | 'well_specified' | 'moderate_friction'; + note: string; +} + +export interface ThinkingBlockAnalysis { + messageIndex: number; + preview: string; + charLength: number; + signals: Record; +} + +export interface ReportThinkingBlocks { + count: number; + analyzedCount: number; + signalSummary: Record; + notableBlocks: ThinkingBlockAnalysis[]; +} + +export interface KeyEvent { + timestamp: Date; + label: string; + deltaSeconds?: number; + deltaHuman?: string; +} + +export interface ReportFileReadRedundancy { + totalReads: number; + uniqueFiles: number; + readsPerUniqueFile: number; + redundantFiles: Record; +} + +// ============================================================================= +// Combined Report +// ============================================================================= + +export interface SessionReport { + overview: ReportOverview; + tokenUsage: ReportTokenUsage; + costAnalysis: ReportCostAnalysis; + cacheEconomics: ReportCacheEconomics; + toolUsage: ReportToolUsage; + subagentMetrics: ReportSubagentMetrics; + errors: ReportErrors; + gitActivity: ReportGitActivity; + frictionSignals: ReportFrictionSignals; + thrashingSignals: ReportThrashingSignals; + conversationTree: ReportConversationTree; + idleAnalysis: ReportIdleAnalysis; + modelSwitches: ReportModelSwitches; + workingDirectories: ReportWorkingDirectories; + testProgression: ReportTestProgression; + startupOverhead: ReportStartupOverhead; + tokenDensityTimeline: ReportTokenDensityTimeline; + promptQuality: ReportPromptQuality; + thinkingBlocks: ReportThinkingBlocks; + keyEvents: KeyEvent[]; + messageTypes: Record; + serviceTiers: Record; + fileReadRedundancy: ReportFileReadRedundancy; + compactionCount: number; + gitBranches: string[]; +} From 465115ee5c11959fd57cb320890a19e2dc2bf06f Mon Sep 17 00:00:00 2001 From: Paul Holstein <44263169+holstein13@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:43:18 -0500 Subject: [PATCH 04/40] feat(report): add session analyzer engine (TS port of analyze-session.py) Port all analysis logic from the Python script to TypeScript, running entirely in the renderer process. Produces a typed SessionReport from a SessionDetail with: token/cost analysis, friction signals, idle gaps, conversation tree metrics, test progression, and thrashing detection. Co-Authored-By: Claude Opus 4.6 --- src/renderer/utils/sessionAnalyzer.ts | 1158 +++++++++++++++++++++++++ 1 file changed, 1158 insertions(+) create mode 100644 src/renderer/utils/sessionAnalyzer.ts diff --git a/src/renderer/utils/sessionAnalyzer.ts b/src/renderer/utils/sessionAnalyzer.ts new file mode 100644 index 00000000..97478e9e --- /dev/null +++ b/src/renderer/utils/sessionAnalyzer.ts @@ -0,0 +1,1158 @@ +/** + * Session analyzer — TypeScript port of scripts/analyze-session.py. + * + * Takes a SessionDetail (already parsed by the main process) and produces + * a SessionReport with structured metrics, cost analysis, friction signals, + * conversation tree analysis, idle gap detection, and more. + * + * Runs entirely in the renderer process — no IPC needed. + */ + +import type { + FrictionCorrection, + GitCommit, + IdleGap, + KeyEvent, + ModelPricing, + ModelSwitch, + ModelTokenStats, + SessionReport, + SubagentEntry, + TestSnapshot, + ThinkingBlockAnalysis, + ToolError, +} from '@renderer/types/sessionReport'; +import type { + ContentBlock, + ParsedMessage, + Process, + SessionDetail, + TextContent, + ThinkingContent, + ToolCall, +} from '@shared/types'; + +// ============================================================================= +// Pricing Table (USD per 1M tokens) +// ============================================================================= + +const MODEL_PRICING: Record = { + 'opus-4': { + input: 15.0, + output: 75.0, + cache_read: 1.5, + cache_creation: 18.75, + }, + 'sonnet-4': { + input: 3.0, + output: 15.0, + cache_read: 0.3, + cache_creation: 3.75, + }, + 'haiku-4': { + input: 0.8, + output: 4.0, + cache_read: 0.08, + cache_creation: 1.0, + }, + 'opus-3': { + input: 15.0, + output: 75.0, + cache_read: 1.5, + cache_creation: 18.75, + }, + 'sonnet-3': { + input: 3.0, + output: 15.0, + cache_read: 0.3, + cache_creation: 3.75, + }, + 'haiku-3': { + input: 0.25, + output: 1.25, + cache_read: 0.03, + cache_creation: 0.3, + }, +}; + +const DEFAULT_PRICING: ModelPricing = { + input: 3.0, + output: 15.0, + cache_read: 0.3, + cache_creation: 3.75, +}; + +function getPricing(modelName: string): ModelPricing { + const name = modelName.toLowerCase(); + for (const [key, pricing] of Object.entries(MODEL_PRICING)) { + if (name.includes(key)) return pricing; + } + return DEFAULT_PRICING; +} + +function costUsd( + modelName: string, + inputTok: number, + outputTok: number, + cacheReadTok: number, + cacheCreationTok: number +): number { + const p = getPricing(modelName); + return ( + (inputTok * p.input + + outputTok * p.output + + cacheReadTok * p.cache_read + + cacheCreationTok * p.cache_creation) / + 1_000_000 + ); +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function isTextBlock(block: ContentBlock): block is TextContent { + return block.type === 'text'; +} + +function isThinkingBlock(block: ContentBlock): block is ThinkingContent { + return block.type === 'thinking'; +} + +function extractTextContent(msg: ParsedMessage): string { + const { content } = msg; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter(isTextBlock) + .map((block) => block.text) + .join(' '); + } + return ''; +} + +function formatDuration(totalSeconds: number): string { + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = Math.floor(totalSeconds % 60); + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${m}:${String(s).padStart(2, '0')}`; +} + +// Friction keyword patterns +const FRICTION_PATTERNS: [RegExp, string][] = [ + [/\bno,/i, 'no,'], + [/\bwrong\b/i, 'wrong'], + [/\bactually\b/i, 'actually'], + [/\bundo\b/i, 'undo'], + [/\brevert\b/i, 'revert'], + [/that's not\b/i, "that's not"], + [/\binstead,/i, 'instead,'], + [/\bwait,/i, 'wait,'], + [/\bnevermind\b/i, 'nevermind'], + [/I don't want\b/i, "I don't want"], +]; + +// Permission denial keywords (case-insensitive substring match) +const PERMISSION_KEYWORDS = [ + 'permission denied', + 'not allowed', + 'requires approval', + 'cannot execute', + 'access denied', + 'operation not permitted', + 'eacces', + 'eperm', + 'user rejected', + 'user denied', + 'needs_user_approval', +]; + +function isPermissionDenial(text: string): boolean { + const lower = text.toLowerCase(); + return PERMISSION_KEYWORDS.some((kw) => lower.includes(kw)); +} + +/** + * Extract a number immediately before a keyword in text. + * E.g., extractNumberBefore("42 passed", "passed") => 42 + */ +function extractNumberBefore(text: string, keyword: string): number | null { + const idx = text.toLowerCase().indexOf(keyword.toLowerCase()); + if (idx <= 0) return null; + const before = text.slice(Math.max(0, idx - 15), idx).trim(); + const parts = before.split(/\s+/); + const numStr = parts[parts.length - 1]; + if (numStr && /^\d+$/.test(numStr)) return parseInt(numStr, 10); + return null; +} + +/** + * Parse test summary from command output. + * Returns [passed, failed] or null if no match. + */ +function parseTestSummary(text: string): [number, number] | null { + // Try "passed"/"failed" keywords + const passed = extractNumberBefore(text, ' passed'); + const failed = extractNumberBefore(text, ' failed'); + if (passed != null && failed != null) return [passed, failed]; + + // Try "passing"/"failing" keywords (mocha-style) + const passing = extractNumberBefore(text, ' passing'); + const failing = extractNumberBefore(text, ' failing'); + if (passing != null && failing != null) return [passing, failing]; + + return null; +} + +// Thinking block analysis signals +const THINKING_SIGNALS: Record = { + alternatives: /\balternative(?:ly|s)?\b|\binstead\b|\bother approach\b|\bcould also\b/i, + uncertainty: /\bnot sure\b|\buncertain\b|\bmight be\b|\bpossibly\b|\bI think\b.*\bbut\b/i, + errors_noticed: /\bbug\b|\berror\b|\bwrong\b|\bincorrect\b|\bfail\b|\bbroken\b/i, + planning: /\bfirst.*then\b|\bstep \d\b|\bplan\b|\bapproach\b|\bstrategy\b/i, + direction_change: /\bwait\b|\bactually\b|\bon second thought\b|\blet me reconsider\b|\bhmm\b/i, +}; + +// "Work" tools (non-Skill) for startup overhead detection +const NON_SKILL_TOOLS = new Set([ + 'Read', + 'Write', + 'Edit', + 'Bash', + 'Grep', + 'Glob', + 'Task', + 'WebFetch', + 'WebSearch', + 'NotebookEdit', +]); + +// ============================================================================= +// Main Analyzer +// ============================================================================= + +export function analyzeSession(detail: SessionDetail): SessionReport { + const { session, messages } = detail; + + // --- Session Overview --- + const timestamps = messages.filter((m) => m.timestamp).map((m) => m.timestamp); + const firstTs = timestamps.length > 0 ? timestamps[0] : null; + const lastTs = timestamps.length > 0 ? timestamps[timestamps.length - 1] : null; + const durationMs = firstTs && lastTs ? lastTs.getTime() - firstTs.getTime() : 0; + const durationSeconds = durationMs / 1000; + + // Context consumption interpretation + const ctxConsumption = session.contextConsumption ?? 0; + let ctxConsumptionPct: number | null = null; + let ctxAssessment: 'critical' | 'high' | 'moderate' | 'healthy' | null = null; + if (ctxConsumption <= 1) { + ctxConsumptionPct = ctxConsumption ? Math.round(ctxConsumption * 1000) / 10 : 0; + if (ctxConsumption > 0.8) ctxAssessment = 'critical'; + else if (ctxConsumption > 0.6) ctxAssessment = 'high'; + else if (ctxConsumption > 0.4) ctxAssessment = 'moderate'; + else ctxAssessment = 'healthy'; + } + + // =================================================================== + // SINGLE-PASS ACCUMULATORS + // =================================================================== + + // Token usage by model + const modelStats = new Map(); + + const getModelStats = (model: string): ModelTokenStats => { + let stats = modelStats.get(model); + if (!stats) { + stats = { + apiCalls: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreation: 0, + cacheRead: 0, + costUsd: 0, + }; + modelStats.set(model, stats); + } + return stats; + }; + + // Cache economics + const cacheCreation5m = 0; + const cacheCreation1h = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + let coldStartDetected = false; + let firstAssistantWithUsageSeen = false; + + // Message type counts + const typeCounts = new Map(); + + // Tool usage counts + const toolCounts = new Map(); + + // Tool call index: toolUseId -> [messageIndex, toolCall] + const toolCallIndex = new Map(); + + // Tool errors + const errors: ToolError[] = []; + const errorsByTool = new Map(); + + // Permission denials + const permissionDenials: ToolError[] = []; + + // Key events + const keyEvents: KeyEvent[] = []; + + // Thinking blocks + let thinkingCount = 0; + const thinkingAnalysis: ThinkingBlockAnalysis[] = []; + + // Git branches + const branches = new Set(); + + // Friction signals + const corrections: FrictionCorrection[] = []; + let userMessageCount = 0; + + // Thrashing detection + const bashPrefixGroups = new Map(); + const fileEditIndices = new Map(); + + // Startup overhead + let firstWorkToolSeen = false; + let startupMessages = 0; + let startupTokens = 0; + + // Token density timeline + const assistantMsgData: [Date, number][] = []; + + // Conversation tree + const uuidToIdx = new Map(); + const parentMap = new Map(); + let sidechainCount = 0; + const childrenMap = new Map(); + + // Idle gap detection + let lastAssistantTs: Date | null = null; + const idleGaps: IdleGap[] = []; + const IDLE_THRESHOLD_SEC = 60; + + // Model switch detection + let lastModel: string | null = null; + const modelSwitches: ModelSwitch[] = []; + + // Working directory tracking + const cwdSet = new Set(); + const cwdChanges: { from: string; to: string; messageIndex: number }[] = []; + let lastCwd: string | null = null; + + // Test progression + const testSnapshots: TestSnapshot[] = []; + + // Cost tracking + let totalSessionCost = 0; + + // Git activity + const gitCommits: GitCommit[] = []; + let gitPushCount = 0; + const gitBranchCreations: string[] = []; + let linesAddedTotal = 0; + let linesRemovedTotal = 0; + + // File read redundancy + const fileReadCounts = new Map(); + + // First user message length + let firstUserMessageLength = 0; + let firstUserSeen = false; + + // =================================================================== + // SINGLE PASS + // =================================================================== + + for (let i = 0; i < messages.length; i++) { + const m = messages[i]; + const msgType = m.type ?? 'unknown'; + typeCounts.set(msgType, (typeCounts.get(msgType) ?? 0) + 1); + const msgUuid = m.uuid ?? ''; + const msgParent = m.parentUuid ?? ''; + const msgTs = m.timestamp; + + // --- Conversation tree --- + if (msgUuid) { + uuidToIdx.set(msgUuid, i); + parentMap.set(msgUuid, msgParent || null); + if (msgParent) { + const children = childrenMap.get(msgParent); + if (children) children.push(msgUuid); + else childrenMap.set(msgParent, [msgUuid]); + } + } + + if (m.isSidechain) sidechainCount++; + + // --- Working directory tracking --- + const msgCwd = m.cwd ?? ''; + if (msgCwd) { + cwdSet.add(msgCwd); + if (lastCwd && msgCwd !== lastCwd) { + cwdChanges.push({ from: lastCwd, to: msgCwd, messageIndex: i }); + } + lastCwd = msgCwd; + } + + // --- Token usage, cache economics, and cost --- + if (m.usage && m.model) { + const model = m.model; + const u = m.usage; + const inpTok = u.input_tokens ?? 0; + const outTok = u.output_tokens ?? 0; + const cc = u.cache_creation_input_tokens ?? 0; + const cr = u.cache_read_input_tokens ?? 0; + + const stats = getModelStats(model); + stats.apiCalls += 1; + stats.inputTokens += inpTok; + stats.outputTokens += outTok; + stats.cacheCreation += cc; + stats.cacheRead += cr; + + const callCost = costUsd(model, inpTok, outTok, cr, cc); + stats.costUsd += callCost; + totalSessionCost += callCost; + + totalCacheCreation += cc; + totalCacheRead += cr; + + // Cold start detection + if (msgType === 'assistant' && !firstAssistantWithUsageSeen) { + firstAssistantWithUsageSeen = true; + if (cc > 0 && cr === 0) coldStartDetected = true; + } + } + + // --- Git branches --- + if (m.gitBranch) branches.add(m.gitBranch); + + // --- Compact summaries (counted but not accumulated separately) --- + + // --- Thinking blocks (with content analysis) --- + if (Array.isArray(m.content)) { + for (const block of m.content) { + if (isThinkingBlock(block)) { + thinkingCount++; + const thinkText = block.thinking ?? ''; + const signalsFound: Record = {}; + for (const [signalName, pattern] of Object.entries(THINKING_SIGNALS)) { + if (pattern.test(thinkText)) signalsFound[signalName] = true; + } + if (Object.keys(signalsFound).length > 0 || thinkingCount <= 5) { + thinkingAnalysis.push({ + messageIndex: i, + preview: thinkText.slice(0, 200).replace(/\n/g, ' ').trim(), + charLength: thinkText.length, + signals: signalsFound, + }); + } + } + } + } + + // --- Model switch detection --- + if (msgType === 'assistant' && m.model) { + const currentModel = m.model; + if (lastModel && currentModel !== lastModel) { + modelSwitches.push({ + from: lastModel, + to: currentModel, + messageIndex: i, + timestamp: msgTs ?? null, + }); + } + lastModel = currentModel; + } + + // --- Idle gap detection --- + if (msgType === 'assistant' && msgTs) { + lastAssistantTs = msgTs; + } + if (msgType === 'user' && msgTs && lastAssistantTs) { + const gap = (msgTs.getTime() - lastAssistantTs.getTime()) / 1000; + if (gap > IDLE_THRESHOLD_SEC) { + idleGaps.push({ + gapSeconds: Math.round(gap * 10) / 10, + gapHuman: formatDuration(Math.floor(gap)), + afterMessageIndex: i, + }); + } + } + + // --- First user message length (prompt quality) --- + if (msgType === 'user' && !firstUserSeen && !m.isMeta) { + const contentText = extractTextContent(m); + if (contentText.trim()) { + firstUserMessageLength = contentText.length; + firstUserSeen = true; + } + } + + // --- Tool calls (assistant messages) --- + for (const tc of m.toolCalls) { + const toolName = tc.name; + toolCounts.set(toolName, (toolCounts.get(toolName) ?? 0) + 1); + toolCallIndex.set(tc.id ?? '', [i, tc]); + const inp = tc.input ?? {}; + + // Bash commands + if (toolName === 'Bash') { + const cmd = typeof inp.command === 'string' ? inp.command : ''; + // Thrashing: bash prefix groups + const prefix = cmd.slice(0, 40); + bashPrefixGroups.set(prefix, (bashPrefixGroups.get(prefix) ?? 0) + 1); + + // Git activity + if (cmd.includes('git commit')) { + const heredocMatch = /cat\s+<<['"]?EOF['"]?\n(.+?)(?:\n|$)/.exec(cmd); + let preview: string; + if (heredocMatch) { + preview = heredocMatch[1].trim().slice(0, 80); + } else { + const msgMatch = /-m\s+["'](.+?)["']/.exec(cmd); + preview = msgMatch ? msgMatch[1].slice(0, 80) : cmd.slice(0, 80); + } + gitCommits.push({ messagePreview: preview, messageIndex: i }); + } + if (cmd.includes('git push')) gitPushCount++; + if (cmd.includes('git checkout -b') || cmd.includes('git switch -c')) { + const branchMatch = /git (?:checkout -b|switch -c)\s+(\S+)/.exec(cmd); + if (branchMatch) gitBranchCreations.push(branchMatch[1]); + } + } + + // File reads + if (toolName === 'Read') { + const filePath = (inp.file_path as string) ?? ''; + if (filePath) { + fileReadCounts.set(filePath, (fileReadCounts.get(filePath) ?? 0) + 1); + } + } + + // Write/Edit for thrashing + if (toolName === 'Write' || toolName === 'Edit') { + const fp = (inp.file_path as string) ?? ''; + if (fp) { + const indices = fileEditIndices.get(fp); + if (indices) indices.push(i); + else fileEditIndices.set(fp, [i]); + } + } + + // Startup overhead: track first non-Skill tool call + if (!firstWorkToolSeen && NON_SKILL_TOOLS.has(toolName)) { + firstWorkToolSeen = true; + } + } + + // --- Startup overhead: count assistant messages before first work tool --- + if (msgType === 'assistant' && !firstWorkToolSeen) { + startupMessages++; + if (m.usage) { + startupTokens += m.usage.output_tokens ?? 0; + startupTokens += m.usage.input_tokens ?? 0; + startupTokens += m.usage.cache_creation_input_tokens ?? 0; + startupTokens += m.usage.cache_read_input_tokens ?? 0; + } + } + + // --- Token density timeline --- + if (msgType === 'assistant' && msgTs && m.usage) { + const totalMsgTokens = + (m.usage.input_tokens ?? 0) + + (m.usage.output_tokens ?? 0) + + (m.usage.cache_creation_input_tokens ?? 0) + + (m.usage.cache_read_input_tokens ?? 0); + assistantMsgData.push([msgTs, totalMsgTokens]); + } + + // --- Timing / key events --- + if (msgTs) { + let label: string | null = null; + if (msgType === 'user' && typeof m.content === 'string') { + const content = m.content; + if (content.includes('start feature')) { + label = `User: ${content.slice(0, 60)}`; + } else if (content.includes('being continued')) { + label = 'Context compaction/continuation'; + } + } + + for (const tc of m.toolCalls) { + if (tc.name === 'Skill') { + label = `Skill: ${(tc.input.skill as string) ?? ''}`; + } else if (tc.name === 'Task') { + const inpTc = tc.input ?? {}; + label = `Task: ${(inpTc.description as string) ?? ''} (${(inpTc.subagent_type as string) ?? ''})`; + } + } + + if (label) { + keyEvents.push({ timestamp: msgTs, label }); + } + } + + // --- Friction signals (user messages) --- + if (msgType === 'user' && !m.isMeta) { + const contentText = extractTextContent(m); + if (contentText.trim()) { + userMessageCount++; + for (const [regex, keyword] of FRICTION_PATTERNS) { + if (regex.test(contentText)) { + corrections.push({ + messageIndex: i, + keyword, + preview: contentText.slice(0, 120).replace(/\n/g, ' '), + }); + break; + } + } + } + } + + // --- Tool results --- + for (const tr of m.toolResults) { + const toolUseId = tr.toolUseId ?? ''; + const contentStr = typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content); + + // Tool errors + if (tr.isError) { + let toolName = 'unknown'; + let toolInput = ''; + const indexed = toolCallIndex.get(toolUseId); + if (indexed) { + const [, tc] = indexed; + toolName = tc.name ?? 'unknown'; + toolInput = JSON.stringify(tc.input ?? {}).slice(0, 300); + } + + const errorEntry: ToolError = { + tool: toolName, + inputPreview: toolInput, + error: contentStr.slice(0, 500), + messageIndex: i, + isPermissionDenial: false, + }; + + if (isPermissionDenial(contentStr)) { + errorEntry.isPermissionDenial = true; + permissionDenials.push(errorEntry); + } + + errors.push(errorEntry); + errorsByTool.set(toolName, (errorsByTool.get(toolName) ?? 0) + 1); + } + + // Bash exit code errors + if ( + !tr.isError && + (contentStr.includes('Exit code 1') || contentStr.includes('Exit code 127')) + ) { + const indexed = toolCallIndex.get(toolUseId); + if (indexed) { + const [, tc] = indexed; + if (tc.name === 'Bash') { + const bashError: ToolError = { + tool: 'Bash (non-zero exit)', + inputPreview: JSON.stringify(tc.input ?? {}).slice(0, 300), + error: contentStr.slice(0, 500), + messageIndex: i, + isPermissionDenial: false, + }; + if (isPermissionDenial(contentStr)) { + bashError.isPermissionDenial = true; + permissionDenials.push(bashError); + } + errors.push(bashError); + errorsByTool.set( + 'Bash (non-zero exit)', + (errorsByTool.get('Bash (non-zero exit)') ?? 0) + 1 + ); + } + } + } + + // --- Test progression: parse test output from bash results --- + const indexedForTest = toolCallIndex.get(toolUseId); + if (indexedForTest) { + const [, tcOrig] = indexedForTest; + if (tcOrig.name === 'Bash') { + const testResult = parseTestSummary(contentStr); + if (testResult) { + const [passed, failed] = testResult; + testSnapshots.push({ + messageIndex: i, + passed, + failed, + total: passed + failed, + raw: contentStr.slice(0, 200).replace(/\n/g, ' '), + }); + } + } + } + + // --- Lines changed: parse git diff --stat output --- + const indexedForDiff = toolCallIndex.get(toolUseId); + if (indexedForDiff) { + const [, tcOrig] = indexedForDiff; + if (tcOrig.name === 'Bash') { + const rawCmd = tcOrig.input?.command; + const cmdText = typeof rawCmd === 'string' ? rawCmd : ''; + if (cmdText.includes('git diff') || cmdText.includes('git show')) { + const insertionIdx = contentStr.indexOf(' insertion'); + const deletionIdx = contentStr.indexOf(' deletion'); + if (insertionIdx > 0) { + const numStr = contentStr + .slice(Math.max(0, insertionIdx - 10), insertionIdx) + .trim() + .split(/\s+/) + .pop(); + if (numStr && /^\d+$/.test(numStr)) linesAddedTotal += parseInt(numStr, 10); + } + if (deletionIdx > 0) { + const numStr = contentStr + .slice(Math.max(0, deletionIdx - 10), deletionIdx) + .trim() + .split(/\s+/) + .pop(); + if (numStr && /^\d+$/.test(numStr)) linesRemovedTotal += parseInt(numStr, 10); + } + } + } + } + } + } + + // =================================================================== + // POST-PASS AGGREGATION + // =================================================================== + + // --- Token usage --- + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheCreationTokens = 0; + let totalCacheReadTokens = 0; + + const byModel: Record = {}; + for (const [model, stats] of modelStats) { + stats.costUsd = Math.round(stats.costUsd * 10000) / 10000; + byModel[model] = stats; + totalInputTokens += stats.inputTokens; + totalOutputTokens += stats.outputTokens; + totalCacheCreationTokens += stats.cacheCreation; + totalCacheReadTokens += stats.cacheRead; + } + + const grandTotal = + totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens; + + // --- Cost analysis --- + const commitCount = gitCommits.length; + const linesChanged = linesAddedTotal + linesRemovedTotal; + + // --- Subagent metrics from detail.processes --- + const subagentEntries: SubagentEntry[] = detail.processes.map((proc: Process) => ({ + description: proc.description ?? 'unknown', + subagentType: proc.subagentType ?? 'unknown', + model: 'default (inherits parent)', + totalTokens: proc.metrics.totalTokens, + totalDurationMs: proc.durationMs, + totalToolUseCount: proc.messages.reduce( + (sum: number, pm: ParsedMessage) => sum + pm.toolCalls.length, + 0 + ), + costUsd: proc.metrics.costUsd ?? 0, + })); + + const saFromProcesses = { + count: subagentEntries.length, + totalTokens: subagentEntries.reduce((sum, a) => sum + a.totalTokens, 0), + totalDurationMs: subagentEntries.reduce((sum, a) => sum + a.totalDurationMs, 0), + totalToolUseCount: subagentEntries.reduce((sum, a) => sum + a.totalToolUseCount, 0), + totalCostUsd: + Math.round(subagentEntries.reduce((sum, a) => sum + a.costUsd, 0) * 10000) / 10000, + byAgent: subagentEntries, + }; + + // --- Tool usage with success rates --- + const toolSuccessRates: Record< + string, + { totalCalls: number; errors: number; successRatePct: number } + > = {}; + const sortedToolCounts = [...toolCounts.entries()].sort((a, b) => b[1] - a[1]); + const countsRecord: Record = {}; + for (const [tool, count] of sortedToolCounts) { + countsRecord[tool] = count; + const errCount = errorsByTool.get(tool) ?? 0; + toolSuccessRates[tool] = { + totalCalls: count, + errors: errCount, + successRatePct: count ? Math.round(((count - errCount) / count) * 1000) / 10 : 0, + }; + } + + // --- Key events timing --- + for (let j = 1; j < keyEvents.length; j++) { + const prevDt = keyEvents[j - 1].timestamp; + const currDt = keyEvents[j].timestamp; + const delta = (currDt.getTime() - prevDt.getTime()) / 1000; + keyEvents[j].deltaSeconds = Math.round(delta * 10) / 10; + keyEvents[j].deltaHuman = formatDuration(Math.floor(delta)); + } + + // --- Thinking blocks signal aggregation --- + const signalTotals: Record = {}; + for (const ta of thinkingAnalysis) { + for (const sig of Object.keys(ta.signals)) { + signalTotals[sig] = (signalTotals[sig] ?? 0) + 1; + } + } + + // --- Cache economics --- + const cacheTotalCreationAndRead = totalCacheCreation + totalCacheRead; + const cacheEfficiency = cacheTotalCreationAndRead + ? Math.round((totalCacheRead / cacheTotalCreationAndRead) * 10000) / 100 + : 0; + const cacheRwRatio = totalCacheCreation + ? Math.round((totalCacheRead / totalCacheCreation) * 10) / 10 + : 0; + + // --- File read redundancy --- + let totalReads = 0; + const redundantFiles: Record = {}; + for (const [path, count] of fileReadCounts) { + totalReads += count; + if (count > 2) redundantFiles[path] = count; + } + const uniqueFiles = fileReadCounts.size; + + // --- Token density timeline --- + const quartiles: { q: number; avgTokens: number; messageCount: number }[] = []; + if (assistantMsgData.length > 0) { + const n = assistantMsgData.length; + const qSize = Math.max(1, Math.floor(n / 4)); + for (let q = 0; q < 4; q++) { + const startIdx = q * qSize; + const endIdx = q === 3 ? n : (q + 1) * qSize; + const chunk = assistantMsgData.slice(startIdx, endIdx); + if (chunk.length > 0) { + const avgTokens = Math.round(chunk.reduce((sum, [, t]) => sum + t, 0) / chunk.length); + quartiles.push({ q: q + 1, avgTokens, messageCount: chunk.length }); + } else { + quartiles.push({ q: q + 1, avgTokens: 0, messageCount: 0 }); + } + } + } else { + for (let q = 0; q < 4; q++) { + quartiles.push({ q: q + 1, avgTokens: 0, messageCount: 0 }); + } + } + + // --- Conversation tree analysis --- + const depthMemo = new Map(); + function getDepth(uuid: string): number { + if (depthMemo.has(uuid)) return depthMemo.get(uuid)!; + const parent = parentMap.get(uuid); + if (!parent) { + depthMemo.set(uuid, 0); + return 0; + } + const depth = 1 + getDepth(parent); + depthMemo.set(uuid, depth); + return depth; + } + + let maxDepth = 0; + for (const uuid of parentMap.keys()) { + const d = getDepth(uuid); + if (d > maxDepth) maxDepth = d; + } + + // Branch points: parents with multiple children + const branchPoints = new Map(); + for (const [parent, children] of childrenMap) { + if (children.length > 1) branchPoints.set(parent, children); + } + + const branchDetails = [...branchPoints.entries()] + .sort((a, b) => b[1].length - a[1].length) + .slice(0, 10) + .map(([p, c]) => ({ + parentUuid: p.slice(0, 12) + '...', + childCount: c.length, + parentMessageIndex: uuidToIdx.get(p), + })); + + // --- Idle gap analysis --- + const totalIdle = idleGaps.reduce((sum, g) => sum + g.gapSeconds, 0); + const wallClock = durationSeconds; + const activeTime = wallClock > 0 ? wallClock - totalIdle : 0; + + // --- Thrashing signals --- + const bashNearDuplicates = [...bashPrefixGroups.entries()] + .filter(([, count]) => count > 2) + .sort((a, b) => b[1] - a[1]) + .map(([prefix, count]) => ({ prefix, count })); + + const editReworkFiles = [...fileEditIndices.entries()] + .filter(([, indices]) => indices.length >= 3) + .map(([filePath, editIndices]) => ({ filePath, editIndices })); + + // --- Model switches --- + const modelsUsed = + modelSwitches.length > 0 + ? [...new Set([...modelSwitches.map((s) => s.from), ...modelSwitches.map((s) => s.to)])] + : [...modelStats.keys()]; + + // --- Test progression trajectory --- + let trajectory: 'improving' | 'regressing' | 'stable' | 'insufficient_data' = 'insufficient_data'; + if (testSnapshots.length >= 2) { + const first = testSnapshots[0]; + const last = testSnapshots[testSnapshots.length - 1]; + if (last.passed > first.passed) trajectory = 'improving'; + else if (last.passed < first.passed) trajectory = 'regressing'; + else trajectory = 'stable'; + } + + // --- Prompt quality assessment --- + const correctionCount = corrections.length; + const frictionRate = userMessageCount + ? Math.round((correctionCount / userMessageCount) * 10000) / 10000 + : 0; + + type PromptAssessment = + | 'underspecified' + | 'verbose_but_unclear' + | 'well_specified' + | 'moderate_friction'; + + let assessment: PromptAssessment; + let promptNote: string; + + if (firstUserMessageLength < 100 && correctionCount >= 2) { + assessment = 'underspecified'; + promptNote = + 'Short initial prompt with multiple corrections suggests the task needed more upfront specification.'; + } else if (firstUserMessageLength > 500 && correctionCount >= 3) { + assessment = 'verbose_but_unclear'; + promptNote = + 'Initial prompt was detailed but still required corrections — consider restructuring for clarity.'; + } else if (correctionCount <= 1) { + assessment = 'well_specified'; + promptNote = 'Low friction — initial prompt effectively communicated intent.'; + } else { + assessment = 'moderate_friction'; + promptNote = + 'Moderate friction detected — review correction patterns for improvement opportunities.'; + } + + // --- Message types --- + const messageTypes: Record = {}; + for (const [type, count] of typeCounts) { + messageTypes[type] = count; + } + + // --- Subagent cost from processes --- + const processSubagentCost = subagentEntries.reduce((sum, a) => sum + a.costUsd, 0); + + // =================================================================== + // BUILD REPORT + // =================================================================== + + const report: SessionReport = { + overview: { + sessionId: session.id, + projectId: session.projectId ?? 'unknown', + projectPath: session.projectPath ?? 'unknown', + firstMessage: session.firstMessage ?? 'unknown', + messageCount: session.messageCount ?? 0, + hasSubagents: session.hasSubagents ?? false, + contextConsumption: ctxConsumption, + contextConsumptionPct: ctxConsumptionPct, + contextAssessment: ctxAssessment, + compactionCount: session.compactionCount ?? 0, + gitBranch: session.gitBranch ?? 'unknown', + startTime: firstTs, + endTime: lastTs, + durationSeconds, + durationHuman: durationSeconds > 0 ? formatDuration(Math.floor(durationSeconds)) : 'unknown', + totalMessages: messages.length, + }, + + tokenUsage: { + byModel, + totals: { + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheCreation: totalCacheCreationTokens, + cacheRead: totalCacheReadTokens, + grandTotal, + cacheReadPct: grandTotal + ? Math.round((totalCacheReadTokens / grandTotal) * 10000) / 100 + : 0, + }, + }, + + costAnalysis: { + parentCostUsd: Math.round(totalSessionCost * 10000) / 10000, + subagentCostUsd: Math.round(processSubagentCost * 10000) / 10000, + totalSessionCostUsd: Math.round((totalSessionCost + processSubagentCost) * 10000) / 10000, + costByModel: Object.fromEntries( + [...modelStats.entries()].map(([model, stats]) => [ + model, + Math.round(stats.costUsd * 10000) / 10000, + ]) + ), + costPerCommit: + commitCount > 0 + ? Math.round(((totalSessionCost + processSubagentCost) / commitCount) * 10000) / 10000 + : null, + costPerLineChanged: + linesChanged > 0 + ? Math.round(((totalSessionCost + processSubagentCost) / linesChanged) * 1000000) / + 1000000 + : null, + }, + + cacheEconomics: { + cacheCreation5m, + cacheCreation1h, + cacheRead: totalCacheRead, + cacheEfficiencyPct: cacheEfficiency, + coldStartDetected, + cacheReadToWriteRatio: cacheRwRatio, + }, + + toolUsage: { + counts: countsRecord, + totalCalls: [...toolCounts.values()].reduce((sum, c) => sum + c, 0), + successRates: toolSuccessRates, + }, + + subagentMetrics: saFromProcesses, + + errors: { + errors, + permissionDenials: { + count: permissionDenials.length, + denials: permissionDenials, + affectedTools: [...new Set(permissionDenials.map((d) => d.tool))], + }, + }, + + gitActivity: { + commitCount: gitCommits.length, + commits: gitCommits, + pushCount: gitPushCount, + branchCreations: gitBranchCreations, + linesAdded: linesAddedTotal, + linesRemoved: linesRemovedTotal, + linesChanged, + }, + + frictionSignals: { + correctionCount, + corrections, + frictionRate, + }, + + thrashingSignals: { + bashNearDuplicates, + editReworkFiles, + }, + + conversationTree: { + totalNodes: uuidToIdx.size, + maxDepth, + sidechainCount, + branchPoints: branchPoints.size, + branchDetails, + }, + + idleAnalysis: { + idleThresholdSeconds: IDLE_THRESHOLD_SEC, + idleGapCount: idleGaps.length, + totalIdleSeconds: Math.round(totalIdle * 10) / 10, + totalIdleHuman: formatDuration(Math.floor(totalIdle)), + wallClockSeconds: Math.round(wallClock * 10) / 10, + activeWorkingSeconds: Math.round(Math.max(activeTime, 0) * 10) / 10, + activeWorkingHuman: formatDuration(Math.floor(Math.max(activeTime, 0))), + idlePct: wallClock > 0 ? Math.round((totalIdle / wallClock) * 1000) / 10 : 0, + longestGaps: [...idleGaps].sort((a, b) => b.gapSeconds - a.gapSeconds).slice(0, 5), + }, + + modelSwitches: { + count: modelSwitches.length, + switches: modelSwitches, + modelsUsed, + }, + + workingDirectories: { + uniqueDirectories: [...cwdSet], + directoryCount: cwdSet.size, + changes: cwdChanges, + changeCount: cwdChanges.length, + isMultiDirectory: cwdSet.size > 1, + }, + + testProgression: { + snapshotCount: testSnapshots.length, + snapshots: testSnapshots, + trajectory, + firstSnapshot: testSnapshots.length > 0 ? testSnapshots[0] : null, + lastSnapshot: testSnapshots.length > 0 ? testSnapshots[testSnapshots.length - 1] : null, + }, + + startupOverhead: { + messagesBeforeFirstWork: startupMessages, + tokensBeforeFirstWork: startupTokens, + pctOfTotal: grandTotal ? Math.round((startupTokens / grandTotal) * 10000) / 100 : 0, + }, + + tokenDensityTimeline: { quartiles }, + + promptQuality: { + firstMessageLengthChars: firstUserMessageLength, + userMessageCount, + correctionCount, + frictionRate, + assessment, + note: promptNote, + }, + + thinkingBlocks: { + count: thinkingCount, + analyzedCount: thinkingAnalysis.length, + signalSummary: signalTotals, + notableBlocks: thinkingAnalysis.slice(0, 20), + }, + + keyEvents, + + messageTypes, + + serviceTiers: {}, + + fileReadRedundancy: { + totalReads, + uniqueFiles, + readsPerUniqueFile: uniqueFiles ? Math.round((totalReads / uniqueFiles) * 100) / 100 : 0, + redundantFiles, + }, + + compactionCount: session.compactionCount ?? 0, + + gitBranches: [...branches], + }; + + return report; +} From 0e3e20c990673198fef4ab0ff08c32b94d59f543 Mon Sep 17 00:00:00 2001 From: Paul Holstein <44263169+holstein13@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:46:32 -0500 Subject: [PATCH 05/40] feat(report): add 'report' tab type and openSessionReport store action Co-Authored-By: Claude Opus 4.6 --- .../components/layout/SortableTab.tsx | 3 ++- src/renderer/store/slices/tabSlice.ts | 22 +++++++++++++++++++ src/renderer/types/tabs.ts | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 1a9758c4..72fd3013 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -8,7 +8,7 @@ import { useCallback, useState } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useStore } from '@renderer/store'; -import { Bell, FileText, LayoutDashboard, Pin, Search, Settings, X } from 'lucide-react'; +import { Activity, Bell, FileText, LayoutDashboard, Pin, Search, Settings, X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import type { Tab } from '@renderer/types/tabs'; @@ -30,6 +30,7 @@ const TAB_ICONS = { notifications: Bell, settings: Settings, session: FileText, + report: Activity, } as const; export const SortableTab = ({ diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index f0d4f3af..1735c03e 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -46,6 +46,7 @@ export interface TabSlice { closeTab: (tabId: string) => void; setActiveTab: (tabId: string) => void; openDashboard: () => void; + openSessionReport: (sourceTabId: string) => void; getActiveTab: () => Tab | null; isSessionOpen: (sessionId: string) => boolean; enqueueTabNavigation: (tabId: string, request: TabNavigationRequest) => void; @@ -380,6 +381,27 @@ export const createTabSlice: StateCreator = (set, ge set(syncFromLayout(newLayout)); }, + // Open a session report tab based on a source session tab + openSessionReport: (sourceTabId: string) => { + const state = get(); + const allTabs = getAllTabs(state.paneLayout); + const sourceTab = allTabs.find((t) => t.id === sourceTabId); + if (sourceTab?.type !== 'session') return; + + const tabData = state.tabSessionData[sourceTabId]; + const firstMsg = tabData?.sessionDetail?.session.firstMessage; + const label = firstMsg + ? `Report: ${firstMsg.slice(0, 30)}${firstMsg.length > 30 ? '…' : ''}` + : 'Session Report'; + + state.openTab({ + type: 'report', + label, + projectId: sourceTab.projectId, + sessionId: sourceTab.sessionId, + }); + }, + // Get the currently active tab (from the focused pane) getActiveTab: () => { const state = get(); diff --git a/src/renderer/types/tabs.ts b/src/renderer/types/tabs.ts index 4e60603e..434b2ff9 100644 --- a/src/renderer/types/tabs.ts +++ b/src/renderer/types/tabs.ts @@ -76,7 +76,7 @@ export interface Tab { id: string; /** Type of content displayed in this tab */ - type: 'session' | 'dashboard' | 'notifications' | 'settings'; + type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'report'; /** Session ID (required when type === 'session') */ sessionId?: string; From d54e36f6fa079044a444826a209ec84594d74566 Mon Sep 17 00:00:00 2001 From: Paul Holstein <44263169+holstein13@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:53:01 -0500 Subject: [PATCH 06/40] feat(report): add session report tab and all section components Co-Authored-By: Claude Opus 4.6 --- .../components/report/ReportSection.tsx | 37 ++++ .../components/report/SessionReportTab.tsx | 69 +++++++ .../report/sections/CostSection.tsx | 63 +++++++ .../report/sections/ErrorSection.tsx | 76 ++++++++ .../report/sections/FrictionSection.tsx | 86 +++++++++ .../components/report/sections/GitSection.tsx | 71 +++++++ .../report/sections/OverviewSection.tsx | 78 ++++++++ .../report/sections/QualitySection.tsx | 174 ++++++++++++++++++ .../report/sections/SubagentSection.tsx | 75 ++++++++ .../report/sections/TimelineSection.tsx | 88 +++++++++ .../report/sections/TokenSection.tsx | 92 +++++++++ .../report/sections/ToolSection.tsx | 63 +++++++ 12 files changed, 972 insertions(+) create mode 100644 src/renderer/components/report/ReportSection.tsx create mode 100644 src/renderer/components/report/SessionReportTab.tsx create mode 100644 src/renderer/components/report/sections/CostSection.tsx create mode 100644 src/renderer/components/report/sections/ErrorSection.tsx create mode 100644 src/renderer/components/report/sections/FrictionSection.tsx create mode 100644 src/renderer/components/report/sections/GitSection.tsx create mode 100644 src/renderer/components/report/sections/OverviewSection.tsx create mode 100644 src/renderer/components/report/sections/QualitySection.tsx create mode 100644 src/renderer/components/report/sections/SubagentSection.tsx create mode 100644 src/renderer/components/report/sections/TimelineSection.tsx create mode 100644 src/renderer/components/report/sections/TokenSection.tsx create mode 100644 src/renderer/components/report/sections/ToolSection.tsx diff --git a/src/renderer/components/report/ReportSection.tsx b/src/renderer/components/report/ReportSection.tsx new file mode 100644 index 00000000..a9bd20e0 --- /dev/null +++ b/src/renderer/components/report/ReportSection.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react'; + +import { ChevronDown, ChevronRight } from 'lucide-react'; + +interface ReportSectionProps { + title: string; + icon: React.ComponentType<{ className?: string }>; + children: React.ReactNode; + defaultCollapsed?: boolean; +} + +export const ReportSection = ({ + title, + icon: Icon, + children, + defaultCollapsed = false, +}: ReportSectionProps) => { + const [collapsed, setCollapsed] = useState(defaultCollapsed); + + return ( +
+ + {!collapsed &&
{children}
} +
+ ); +}; diff --git a/src/renderer/components/report/SessionReportTab.tsx b/src/renderer/components/report/SessionReportTab.tsx new file mode 100644 index 00000000..58958caa --- /dev/null +++ b/src/renderer/components/report/SessionReportTab.tsx @@ -0,0 +1,69 @@ +import { useMemo } from 'react'; + +import { useStore } from '@renderer/store'; +import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; + +import { CostSection } from './sections/CostSection'; +import { ErrorSection } from './sections/ErrorSection'; +import { FrictionSection } from './sections/FrictionSection'; +import { GitSection } from './sections/GitSection'; +import { OverviewSection } from './sections/OverviewSection'; +import { QualitySection } from './sections/QualitySection'; +import { SubagentSection } from './sections/SubagentSection'; +import { TimelineSection } from './sections/TimelineSection'; +import { TokenSection } from './sections/TokenSection'; +import { ToolSection } from './sections/ToolSection'; + +import type { Tab } from '@renderer/types/tabs'; + +interface SessionReportTabProps { + tab: Tab; +} + +export const SessionReportTab = ({ tab }: SessionReportTabProps) => { + // Find session data from any session tab with matching sessionId + const sessionDetail = useStore((s) => { + const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs); + const sourceTab = allTabs.find((t) => t.type === 'session' && t.sessionId === tab.sessionId); + return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null; + }); + + const report = useMemo( + () => (sessionDetail ? analyzeSession(sessionDetail) : null), + [sessionDetail] + ); + + if (!report) { + return ( +
+ No session data available. Open the session tab first. +
+ ); + } + + return ( +
+

Session Analysis Report

+
+ + + + + {report.subagentMetrics.count > 0 && } + {report.errors.errors.length > 0 && } + + + + +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/CostSection.tsx b/src/renderer/components/report/sections/CostSection.tsx new file mode 100644 index 00000000..b3400eab --- /dev/null +++ b/src/renderer/components/report/sections/CostSection.tsx @@ -0,0 +1,63 @@ +import { DollarSign } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportCostAnalysis } from '@renderer/types/sessionReport'; + +const fmt = (v: number) => `$${v.toFixed(4)}`; + +interface CostSectionProps { + data: ReportCostAnalysis; +} + +export const CostSection = ({ data }: CostSectionProps) => { + const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]); + + return ( + +
{fmt(data.totalSessionCostUsd)}
+ +
+
+
Parent Cost
+
{fmt(data.parentCostUsd)}
+
+
+
Subagent Cost
+
{fmt(data.subagentCostUsd)}
+
+
+
Per Commit
+
+ {data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'} +
+
+
+
Per Line Changed
+
+ {data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'} +
+
+
+ + {modelEntries.length > 0 && ( +
+ + + + + + + + {modelEntries.map(([model, cost]) => ( + + + + + ))} + +
ModelCost
{model}{fmt(cost)}
+ )} + + ); +}; diff --git a/src/renderer/components/report/sections/ErrorSection.tsx b/src/renderer/components/report/sections/ErrorSection.tsx new file mode 100644 index 00000000..1cd04850 --- /dev/null +++ b/src/renderer/components/report/sections/ErrorSection.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; + +import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportErrors, ToolError } from '@renderer/types/sessionReport'; + +interface ErrorItemProps { + error: ToolError; +} + +const ErrorItem = ({ error }: ErrorItemProps) => { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded && ( +
+ {error.error} +
+ )} +
+ ); +}; + +interface ErrorSectionProps { + data: ReportErrors; +} + +export const ErrorSection = ({ data }: ErrorSectionProps) => { + return ( + +
+ + {data.errors.length} error{data.errors.length !== 1 ? 's' : ''} + + {data.permissionDenials.count > 0 && ( + + {data.permissionDenials.count} permission denial + {data.permissionDenials.count !== 1 ? 's' : ''} + + )} +
+ +
+ {data.errors.map((error, idx) => ( + + ))} +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/FrictionSection.tsx b/src/renderer/components/report/sections/FrictionSection.tsx new file mode 100644 index 00000000..96ea0032 --- /dev/null +++ b/src/renderer/components/report/sections/FrictionSection.tsx @@ -0,0 +1,86 @@ +import { MessageSquareWarning } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport'; + +const frictionColor = (rate: number): string => { + if (rate <= 0.1) return '#4ade80'; + if (rate <= 0.25) return '#fbbf24'; + return '#f87171'; +}; + +interface FrictionSectionProps { + data: ReportFrictionSignals; + thrashing: ReportThrashingSignals; +} + +export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => { + return ( + +
+ + Friction Rate: {(data.frictionRate * 100).toFixed(1)}% + + + {data.correctionCount} correction{data.correctionCount !== 1 ? 's' : ''} + +
+ + {data.corrections.length > 0 && ( +
+
Corrections
+
+ {data.corrections.map((corr, idx) => ( +
+ + {corr.keyword} + + {corr.preview} +
+ ))} +
+
+ )} + + {(thrashing.bashNearDuplicates.length > 0 || thrashing.editReworkFiles.length > 0) && ( +
+
Thrashing Signals
+ + {thrashing.bashNearDuplicates.length > 0 && ( +
+
Repeated Bash Commands
+ {thrashing.bashNearDuplicates.map((dup, idx) => ( +
+ {dup.count}x + {dup.prefix} +
+ ))} +
+ )} + + {thrashing.editReworkFiles.length > 0 && ( +
+
Reworked Files (3+ edits)
+ {thrashing.editReworkFiles.map((file, idx) => ( +
+ {file.editIndices.length}x + {file.filePath} +
+ ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/GitSection.tsx b/src/renderer/components/report/sections/GitSection.tsx new file mode 100644 index 00000000..d8d9823e --- /dev/null +++ b/src/renderer/components/report/sections/GitSection.tsx @@ -0,0 +1,71 @@ +import { GitBranch } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportGitActivity } from '@renderer/types/sessionReport'; + +interface GitSectionProps { + data: ReportGitActivity; +} + +export const GitSection = ({ data }: GitSectionProps) => { + return ( + +
+
+
Commits
+
{data.commitCount}
+
+
+
Pushes
+
{data.pushCount}
+
+
+
Lines Added
+
+ +{data.linesAdded.toLocaleString()} +
+
+
+
Lines Removed
+
+ -{data.linesRemoved.toLocaleString()} +
+
+
+ + {data.commits.length > 0 && ( +
+
Commits
+
+ {data.commits.map((commit, idx) => ( +
+ #{commit.messageIndex} + {commit.messagePreview} +
+ ))} +
+
+ )} + + {data.branchCreations.length > 0 && ( +
+
Branches Created
+
+ {data.branchCreations.map((branch, idx) => ( + + {branch} + + ))} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/OverviewSection.tsx b/src/renderer/components/report/sections/OverviewSection.tsx new file mode 100644 index 00000000..e4027405 --- /dev/null +++ b/src/renderer/components/report/sections/OverviewSection.tsx @@ -0,0 +1,78 @@ +import { Activity } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportOverview } from '@renderer/types/sessionReport'; + +const assessmentColor = (assessment: ReportOverview['contextAssessment']): string => { + switch (assessment) { + case 'healthy': + return '#4ade80'; + case 'moderate': + return '#fbbf24'; + case 'high': + return '#f87171'; + case 'critical': + return '#f87171'; + default: + return '#a1a1aa'; + } +}; + +interface OverviewSectionProps { + data: ReportOverview; +} + +export const OverviewSection = ({ data }: OverviewSectionProps) => { + return ( + +
{data.firstMessage}
+
+
+
Duration
+
{data.durationHuman}
+
+
+
Messages
+
{data.totalMessages.toLocaleString()}
+
+
+
Context Usage
+
+ {data.contextConsumptionPct != null ? `${data.contextConsumptionPct}%` : 'N/A'} + {data.contextAssessment && ( + ({data.contextAssessment}) + )} +
+
+
+
Compactions
+
{data.compactionCount}
+
+
+
Branch
+
{data.gitBranch}
+
+
+
Subagents
+
{data.hasSubagents ? 'Yes' : 'No'}
+
+
+
Project
+
+ {data.projectPath} +
+
+
+
Session ID
+
+ {data.sessionId.slice(0, 12)}... +
+
+
+
+ ); +}; diff --git a/src/renderer/components/report/sections/QualitySection.tsx b/src/renderer/components/report/sections/QualitySection.tsx new file mode 100644 index 00000000..c0dea581 --- /dev/null +++ b/src/renderer/components/report/sections/QualitySection.tsx @@ -0,0 +1,174 @@ +import { BarChart3 } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { + ReportPromptQuality, + ReportStartupOverhead, + ReportTestProgression, +} from '@renderer/types/sessionReport'; + +const assessmentColor = (assessment: ReportPromptQuality['assessment']): string => { + switch (assessment) { + case 'well_specified': + return '#4ade80'; + case 'moderate_friction': + return '#fbbf24'; + case 'underspecified': + return '#f87171'; + case 'verbose_but_unclear': + return '#f87171'; + default: + return '#a1a1aa'; + } +}; + +const assessmentLabel = (assessment: ReportPromptQuality['assessment']): string => { + switch (assessment) { + case 'well_specified': + return 'Well Specified'; + case 'moderate_friction': + return 'Moderate Friction'; + case 'underspecified': + return 'Underspecified'; + case 'verbose_but_unclear': + return 'Verbose but Unclear'; + default: + return assessment; + } +}; + +const trajectoryColor = (trajectory: ReportTestProgression['trajectory']): string => { + switch (trajectory) { + case 'improving': + return '#4ade80'; + case 'regressing': + return '#f87171'; + case 'stable': + return '#fbbf24'; + default: + return '#a1a1aa'; + } +}; + +interface QualitySectionProps { + prompt: ReportPromptQuality; + startup: ReportStartupOverhead; + testProgression: ReportTestProgression; +} + +export const QualitySection = ({ prompt, startup, testProgression }: QualitySectionProps) => { + return ( + + {/* Prompt quality */} +
+
Prompt Quality
+
+ + {assessmentLabel(prompt.assessment)} + +
+
{prompt.note}
+
+
+
First Message
+
+ {prompt.firstMessageLengthChars.toLocaleString()} chars +
+
+
+
User Messages
+
{prompt.userMessageCount}
+
+
+
Corrections
+
{prompt.correctionCount}
+
+
+
Friction Rate
+
+ {(prompt.frictionRate * 100).toFixed(1)}% +
+
+
+
+ + {/* Startup overhead */} +
+
Startup Overhead
+
+
+
Messages Before Work
+
{startup.messagesBeforeFirstWork}
+
+
+
Tokens Before Work
+
+ {startup.tokensBeforeFirstWork.toLocaleString()} +
+
+
+
% of Total
+
{startup.pctOfTotal}%
+
+
+
+ + {/* Test progression */} +
+
Test Progression
+
+ + {testProgression.trajectory === 'insufficient_data' + ? 'Insufficient Data' + : testProgression.trajectory.charAt(0).toUpperCase() + + testProgression.trajectory.slice(1)} + + + {testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''} + +
+ {testProgression.firstSnapshot && testProgression.lastSnapshot && ( +
+
+
First Run
+
+ + {testProgression.firstSnapshot.passed} passed + + {' / '} + + {testProgression.firstSnapshot.failed} failed + +
+
+
+
Last Run
+
+ + {testProgression.lastSnapshot.passed} passed + + {' / '} + + {testProgression.lastSnapshot.failed} failed + +
+
+
+ )} +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/SubagentSection.tsx b/src/renderer/components/report/sections/SubagentSection.tsx new file mode 100644 index 00000000..bfef657d --- /dev/null +++ b/src/renderer/components/report/sections/SubagentSection.tsx @@ -0,0 +1,75 @@ +import { Users } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportSubagentMetrics } from '@renderer/types/sessionReport'; + +const fmtCost = (v: number) => `$${v.toFixed(4)}`; +const fmtDuration = (ms: number) => { + const s = Math.round(ms / 1000); + const m = Math.floor(s / 60); + const sec = s % 60; + return m > 0 ? `${m}m ${sec}s` : `${sec}s`; +}; + +interface SubagentSectionProps { + data: ReportSubagentMetrics; +} + +export const SubagentSection = ({ data }: SubagentSectionProps) => { + return ( + +
+
+
Count
+
{data.count}
+
+
+
Total Tokens
+
{data.totalTokens.toLocaleString()}
+
+
+
Total Duration
+
{fmtDuration(data.totalDurationMs)}
+
+
+
Total Cost
+
{fmtCost(data.totalCostUsd)}
+
+
+ + {data.byAgent.length > 0 && ( +
+ + + + + + + + + + + + {data.byAgent.map((agent, idx) => ( + + + + + + + + ))} + +
DescriptionTypeTokensDurationCost
+ {agent.description} + {agent.subagentType} + {agent.totalTokens.toLocaleString()} + + {fmtDuration(agent.totalDurationMs)} + {fmtCost(agent.costUsd)}
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/TimelineSection.tsx b/src/renderer/components/report/sections/TimelineSection.tsx new file mode 100644 index 00000000..e01718dc --- /dev/null +++ b/src/renderer/components/report/sections/TimelineSection.tsx @@ -0,0 +1,88 @@ +import { Clock } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { + KeyEvent, + ReportIdleAnalysis, + ReportModelSwitches, +} from '@renderer/types/sessionReport'; + +interface TimelineSectionProps { + idle: ReportIdleAnalysis; + modelSwitches: ReportModelSwitches; + keyEvents: KeyEvent[]; +} + +export const TimelineSection = ({ idle, modelSwitches, keyEvents }: TimelineSectionProps) => { + return ( + + {/* Idle stats */} +
+
Idle Analysis
+
+
+
Idle Gaps
+
{idle.idleGapCount}
+
+
+
Total Idle
+
{idle.totalIdleHuman}
+
+
+
Active Time
+
{idle.activeWorkingHuman}
+
+
+
Idle %
+
50 ? '#fbbf24' : '#4ade80' }} + > + {idle.idlePct}% +
+
+
+
+ + {/* Model switches */} + {modelSwitches.count > 0 && ( +
+
+ Model Switches ({modelSwitches.count}) +
+
+ {modelSwitches.switches.map((sw, idx) => ( +
+ {sw.from} + + {sw.to} + msg #{sw.messageIndex} +
+ ))} +
+
+ )} + + {/* Key events */} + {keyEvents.length > 0 && ( +
+
Key Events
+
+ {keyEvents.map((event, idx) => ( +
+ + {event.timestamp.toLocaleTimeString()} + + {event.label} + {event.deltaHuman && ( + +{event.deltaHuman} + )} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/TokenSection.tsx b/src/renderer/components/report/sections/TokenSection.tsx new file mode 100644 index 00000000..9dd1dd0c --- /dev/null +++ b/src/renderer/components/report/sections/TokenSection.tsx @@ -0,0 +1,92 @@ +import { Coins } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport'; + +const fmt = (v: number) => v.toLocaleString(); +const fmtCost = (v: number) => `$${v.toFixed(4)}`; + +interface TokenSectionProps { + data: ReportTokenUsage; + cacheEconomics: ReportCacheEconomics; +} + +export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { + const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd); + + return ( + + {/* By-model table */} +
+ + + + + + + + + + + + + + {modelEntries.map(([model, stats]) => ( + + + + + + + + + + ))} + {/* Totals row */} + + + + + + + + + + +
ModelAPI CallsInputOutputCache ReadCache CreateCost
{model}{fmt(stats.apiCalls)}{fmt(stats.inputTokens)}{fmt(stats.outputTokens)}{fmt(stats.cacheRead)}{fmt(stats.cacheCreation)}{fmtCost(stats.costUsd)}
Total + {fmt(modelEntries.reduce((s, [, st]) => s + st.apiCalls, 0))} + {fmt(data.totals.inputTokens)}{fmt(data.totals.outputTokens)}{fmt(data.totals.cacheRead)}{fmt(data.totals.cacheCreation)} + {fmtCost(modelEntries.reduce((s, [, st]) => s + st.costUsd, 0))} +
+
+ + {/* Cache economics */} +
+
+
Cache Efficiency
+
{cacheEconomics.cacheEfficiencyPct}%
+
+
+
R/W Ratio
+
+ {cacheEconomics.cacheReadToWriteRatio}x +
+
+
+
Cache Read %
+
{data.totals.cacheReadPct}%
+
+
+
Cold Start
+
+ {cacheEconomics.coldStartDetected ? 'Yes' : 'No'} +
+
+
+
+ ); +}; diff --git a/src/renderer/components/report/sections/ToolSection.tsx b/src/renderer/components/report/sections/ToolSection.tsx new file mode 100644 index 00000000..8f8bce2b --- /dev/null +++ b/src/renderer/components/report/sections/ToolSection.tsx @@ -0,0 +1,63 @@ +import { Wrench } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportToolUsage } from '@renderer/types/sessionReport'; + +interface ToolSectionProps { + data: ReportToolUsage; +} + +export const ToolSection = ({ data }: ToolSectionProps) => { + const toolEntries = Object.entries(data.successRates).sort( + (a, b) => b[1].totalCalls - a[1].totalCalls + ); + + return ( + +
+ {data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools +
+
+ + + + + + + + + + + {toolEntries.map(([tool, stats]) => { + const rateColor = + stats.successRatePct < 80 + ? '#f87171' + : stats.successRatePct < 90 + ? '#fbbf24' + : undefined; + + return ( + + + + + + + ); + })} + +
ToolCallsErrorsSuccess %
{tool} + {stats.totalCalls.toLocaleString()} + + {stats.errors.toLocaleString()} + + {stats.successRatePct}% +
+
+
+ ); +}; From 644d66eae1ca1161ff7c96c65b00545d21b37c5c Mon Sep 17 00:00:00 2001 From: Paul Holstein <44263169+holstein13@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:55:04 -0500 Subject: [PATCH 07/40] feat(report): wire up toolbar button and report tab routing Co-Authored-By: Claude Opus 4.6 --- .../components/layout/PaneContent.tsx | 2 ++ src/renderer/components/layout/TabBar.tsx | 22 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index 8abf16d5..667f7a46 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -7,6 +7,7 @@ import { TabUIProvider } from '@renderer/contexts/TabUIContext'; import { DashboardView } from '../dashboard/DashboardView'; import { NotificationsView } from '../notifications/NotificationsView'; +import { SessionReportTab } from '../report/SessionReportTab'; import { SettingsView } from '../settings/SettingsView'; import { SessionTabContent } from './SessionTabContent'; @@ -47,6 +48,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { )} + {tab.type === 'report' && } ); })} diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 61915d35..9f6dad31 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -14,7 +14,7 @@ import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortabl import { isElectronMode } from '@renderer/api'; import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout'; import { useStore } from '@renderer/store'; -import { Bell, PanelLeft, Plus, RefreshCw, Search, Settings } from 'lucide-react'; +import { Activity, Bell, PanelLeft, Plus, RefreshCw, Search, Settings } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { ExportDropdown } from '../common/ExportDropdown'; @@ -45,6 +45,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { unreadCount, openNotificationsTab, openSettingsTab, + openSessionReport, sidebarCollapsed, toggleSidebar, splitPane, @@ -72,6 +73,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { unreadCount: s.unreadCount, openNotificationsTab: s.openNotificationsTab, openSettingsTab: s.openSettingsTab, + openSessionReport: s.openSessionReport, sidebarCollapsed: s.sidebarCollapsed, toggleSidebar: s.toggleSidebar, splitPane: s.splitPane, @@ -105,6 +107,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { const [searchHover, setSearchHover] = useState(false); const [notificationsHover, setNotificationsHover] = useState(false); const [settingsHover, setSettingsHover] = useState(false); + const [analyzeHover, setAnalyzeHover] = useState(false); // Context menu state const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: string } | null>( @@ -386,6 +389,23 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { )} + {/* Analyze button - show only for session tabs with loaded data */} + {activeTab?.type === 'session' && activeTabSessionDetail && activeTabId && ( + + )} + {/* Notifications bell icon */}