Merge pull request #60 from holstein13/feat/session-analysis-report

feat: session analysis report with assessment badges
This commit is contained in:
matt 2026-02-23 22:28:03 +08:00 committed by GitHub
commit 6264ec52bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 6956 additions and 9 deletions

View file

@ -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 |

View file

@ -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<string, ModelTokenStats>;
totals: TokenTotals;
}
export interface ReportCostAnalysis {
parentCostUsd: number;
subagentCostUsd: number;
totalSessionCostUsd: number;
costByModel: Record<string, number>;
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<string, number>;
totalCalls: number;
successRates: Record<string, ToolSuccessRate>;
}
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<string, boolean>;
}
export interface ReportThinkingBlocks {
count: number;
analyzedCount: number;
signalSummary: Record<string, number>;
notableBlocks: ThinkingBlockAnalysis[];
}
export interface KeyEvent {
timestamp: Date;
label: string;
deltaSeconds?: number;
deltaHuman?: string;
}
export interface ReportFileReadRedundancy {
totalReads: number;
uniqueFiles: number;
readsPerUniqueFile: number;
redundantFiles: Record<string, number>;
}
// =============================================================================
// 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<string, number>;
serviceTiers: Record<string, number>;
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 `<usage>` 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> = {}): 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> = {}): 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 `<table>` 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 <div>No session data available. Open the session first.</div>;
}
return (
<div className="h-full overflow-y-auto p-6" style={{ backgroundColor: 'var(--color-surface)' }}>
<h1 className="mb-6 text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
Session Analysis Report
</h1>
<div className="flex flex-col gap-4">
<OverviewSection data={report.overview} />
<CostSection data={report.costAnalysis} />
<TokenSection data={report.tokenUsage} cacheEconomics={report.cacheEconomics} />
<ToolSection data={report.toolUsage} />
<SubagentSection data={report.subagentMetrics} />
<ErrorSection data={report.errors} />
<GitSection data={report.gitActivity} />
<FrictionSection data={report.frictionSignals} thrashing={report.thrashingSignals} />
<TimelineSection idle={report.idleAnalysis} modelSwitches={report.modelSwitches} keyEvents={report.keyEvents} />
<QualitySection prompt={report.promptQuality} startup={report.startupOverhead} testProgression={report.testProgression} />
</div>
</div>
);
};
```
**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' && <SessionReportTab tab={tab} />}
```
**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 && (
<button
onClick={() => openSessionReport(activeTabId)}
onMouseEnter={() => setAnalyzeHover(true)}
onMouseLeave={() => setAnalyzeHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: analyzeHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: analyzeHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Analyze Session"
>
<Activity className="size-4" />
</button>
)}
```
**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"
```

View file

@ -191,9 +191,7 @@ export const FlatInjectionList = ({
}
};
const displayText = row.description
? `${row.label} \u2014 ${row.description}`
: row.label;
const displayText = row.description ? `${row.label} \u2014 ${row.description}` : row.label;
return (
<div key={row.key} className="flex items-center gap-0.5">

View file

@ -152,7 +152,7 @@ export function extractOutputText(content: string | unknown[]): string {
.map((block) =>
typeof block === 'object' && block !== null && 'text' in block
? (block as { text: string }).text
: JSON.stringify(block, null, 2),
: JSON.stringify(block, null, 2)
)
.join('\n');
} else {

View file

@ -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 => {
<SessionTabContent tab={tab} isActive={isActive} />
</TabUIProvider>
)}
{tab.type === 'report' && <SessionReportTab tab={tab} />}
</div>
);
})}

View file

@ -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 = ({

View file

@ -15,7 +15,7 @@ import { isElectronMode } from '@renderer/api';
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
import { useStore } from '@renderer/store';
import { formatShortcut } from '@renderer/utils/stringUtils';
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';
@ -46,6 +46,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
unreadCount,
openNotificationsTab,
openSettingsTab,
openSessionReport,
sidebarCollapsed,
toggleSidebar,
splitPane,
@ -73,6 +74,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,
@ -106,6 +108,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>(
@ -400,6 +403,23 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
<ExportDropdown sessionDetail={activeTabSessionDetail} />
)}
{/* Analyze button - show only for session tabs with loaded data */}
{activeTab?.type === 'session' && activeTabSessionDetail && activeTabId && (
<button
onClick={() => openSessionReport(activeTabId)}
onMouseEnter={() => setAnalyzeHover(true)}
onMouseLeave={() => setAnalyzeHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: analyzeHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: analyzeHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Analyze Session"
>
<Activity className="size-4" />
</button>
)}
{/* Notifications bell icon */}
<button
onClick={openNotificationsTab}

View file

@ -0,0 +1,78 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
assessmentColor,
assessmentExplanation,
assessmentLabel,
} from '@renderer/utils/reportAssessments';
import type { MetricKey } from '@renderer/utils/reportAssessments';
interface AssessmentBadgeProps {
assessment: string;
metricKey?: MetricKey;
}
export const AssessmentBadge = ({ assessment, metricKey }: AssessmentBadgeProps) => {
const color = assessmentColor(assessment);
const explanation = metricKey ? assessmentExplanation(metricKey, assessment) : '';
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 });
const badgeRef = useRef<HTMLSpanElement>(null);
const enterTimer = useRef<ReturnType<typeof setTimeout>>();
const leaveTimer = useRef<ReturnType<typeof setTimeout>>();
const handleMouseEnter = useCallback(() => {
if (!explanation) return;
clearTimeout(leaveTimer.current);
enterTimer.current = setTimeout(() => {
if (badgeRef.current) {
const rect = badgeRef.current.getBoundingClientRect();
setTooltipPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 });
}
setShowTooltip(true);
}, 200);
}, [explanation]);
const handleMouseLeave = useCallback(() => {
clearTimeout(enterTimer.current);
leaveTimer.current = setTimeout(() => setShowTooltip(false), 150);
}, []);
useEffect(() => {
return () => {
clearTimeout(enterTimer.current);
clearTimeout(leaveTimer.current);
};
}, []);
return (
<>
<span
ref={badgeRef}
className="rounded px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${color}20`, color }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{assessmentLabel(assessment)}
</span>
{showTooltip &&
explanation &&
createPortal(
<div
className="pointer-events-none fixed z-50 max-w-60 rounded border border-border bg-surface-raised px-2.5 py-1.5 text-xs text-text-secondary shadow-lg"
style={{
top: tooltipPos.top,
left: tooltipPos.left,
transform: 'translateX(-50%)',
}}
>
{explanation}
</div>,
document.body
)}
</>
);
};

View file

@ -0,0 +1,58 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
const sectionId = (title: string) =>
`report-section-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
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);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const handler = () => {
setCollapsed(false);
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
el.addEventListener('report-section-expand', handler);
return () => el.removeEventListener('report-section-expand', handler);
}, []);
return (
<div
ref={ref}
id={sectionId(title)}
className="rounded-lg border border-border bg-surface-raised"
>
<button
onClick={() => setCollapsed(!collapsed)}
className="flex w-full items-center gap-2 p-4 text-left"
>
{collapsed ? (
<ChevronRight className="size-4 text-text-muted" />
) : (
<ChevronDown className="size-4 text-text-muted" />
)}
<Icon className="size-4 text-text-secondary" />
<span className="text-sm font-semibold text-text">{title}</span>
</button>
{!collapsed && <div className="border-t border-border px-4 pb-4 pt-3">{children}</div>}
</div>
);
};
export { sectionId };

View file

@ -0,0 +1,99 @@
import { useMemo } from 'react';
import { useStore } from '@renderer/store';
import { computeTakeaways } from '@renderer/utils/reportAssessments';
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 { InsightsSection } from './sections/InsightsSection';
import { KeyTakeawaysSection } from './sections/KeyTakeawaysSection';
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]
);
const takeaways = useMemo(() => (report ? computeTakeaways(report) : []), [report]);
if (!report) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
No session data available. Open the session tab first.
</div>
);
}
return (
<div className="h-full overflow-y-auto p-6" style={{ backgroundColor: 'var(--color-surface)' }}>
<h1 className="mb-6 text-lg font-semibold text-text">Session Analysis Report</h1>
<div className="flex flex-col gap-4">
{takeaways.length > 0 && <KeyTakeawaysSection takeaways={takeaways} />}
<OverviewSection data={report.overview} />
<CostSection
data={report.costAnalysis}
tokensByModel={report.tokenUsage.byModel}
commitCount={report.gitActivity.commitCount}
linesChanged={report.gitActivity.linesChanged}
/>
<TokenSection data={report.tokenUsage} cacheEconomics={report.cacheEconomics} />
<ToolSection data={report.toolUsage} />
{report.subagentMetrics.count > 0 && (
<SubagentSection data={report.subagentMetrics} defaultCollapsed />
)}
{report.errors.errors.length > 0 && <ErrorSection data={report.errors} defaultCollapsed />}
<GitSection data={report.gitActivity} defaultCollapsed />
<FrictionSection
data={report.frictionSignals}
thrashing={report.thrashingSignals}
defaultCollapsed
/>
<TimelineSection
idle={report.idleAnalysis}
modelSwitches={report.modelSwitches}
keyEvents={report.keyEvents}
defaultCollapsed
/>
<QualitySection
prompt={report.promptQuality}
startup={report.startupOverhead}
testProgression={report.testProgression}
fileReadRedundancy={report.fileReadRedundancy}
defaultCollapsed
/>
<InsightsSection
skills={report.skillsInvoked}
bash={report.bashCommands}
lifecycleTasks={report.lifecycleTasks}
userQuestions={report.userQuestions}
outOfScope={report.outOfScopeFindings}
agentTree={report.agentTree}
subagentsList={report.subagentsList}
defaultCollapsed
/>
</div>
</div>
);
};

View file

@ -0,0 +1,260 @@
import { Fragment, useState } from 'react';
import { getPricing } from '@renderer/utils/sessionAnalyzer';
import { DollarSign } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection, sectionId } from '../ReportSection';
import type {
ModelPricing,
ModelTokenStats,
ReportCostAnalysis,
} from '@renderer/types/sessionReport';
const fmt = (v: number) => `$${v.toFixed(4)}`;
const fmtK = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v));
const fmtRate = (v: number) => `$${v}`;
const lineCost = (tokens: number, ratePerM: number) => (tokens * ratePerM) / 1_000_000;
interface CostSectionProps {
data: ReportCostAnalysis;
tokensByModel: Record<string, ModelTokenStats>;
commitCount: number;
linesChanged: number;
defaultCollapsed?: boolean;
}
interface BreakdownLine {
label: string;
tokens: number;
ratePerM: number;
}
const CostBreakdownCard = ({
stats,
pricing,
}: {
stats: ModelTokenStats;
pricing: ModelPricing;
}) => {
const lines: BreakdownLine[] = [
{ label: 'Input', tokens: stats.inputTokens, ratePerM: pricing.input },
{ label: 'Output', tokens: stats.outputTokens, ratePerM: pricing.output },
{ label: 'Cache Read', tokens: stats.cacheRead, ratePerM: pricing.cache_read },
{ label: 'Cache Write', tokens: stats.cacheCreation, ratePerM: pricing.cache_creation },
];
const total = lines.reduce((sum, l) => sum + lineCost(l.tokens, l.ratePerM), 0);
return (
<div className="rounded-md border border-border bg-surface-raised px-4 py-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-text-muted">
Cost Breakdown (per 1M tokens)
</div>
<div className="flex flex-col gap-1.5 font-mono text-xs">
{lines.map((l) => {
const cost = lineCost(l.tokens, l.ratePerM);
return (
<div key={l.label} className="flex items-baseline justify-between gap-4">
<span className="text-text-muted">{l.label}</span>
<span className="text-text-secondary">
{l.tokens.toLocaleString()} {'\u00D7'} {fmtRate(l.ratePerM)}/M = {fmt(cost)}
</span>
</div>
);
})}
<div className="mt-1 flex items-baseline justify-between gap-4 border-t border-border pt-1.5">
<span className="font-medium text-text">Total</span>
<span className="font-medium text-text">{fmt(total)}</span>
</div>
</div>
</div>
);
};
export const CostSection = ({
data,
tokensByModel,
commitCount,
linesChanged,
defaultCollapsed,
}: CostSectionProps) => {
const [expandedModel, setExpandedModel] = useState<string | null>(null);
const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]);
const showStackedBar = data.subagentCostUsd > 0;
const parentPct =
showStackedBar && data.totalSessionCostUsd > 0
? (data.parentCostUsd / data.totalSessionCostUsd) * 100
: 100;
return (
<ReportSection title="Cost Analysis" icon={DollarSign} defaultCollapsed={defaultCollapsed}>
<div className="mb-4 text-2xl font-bold text-text">{fmt(data.totalSessionCostUsd)}</div>
{/* Parent/Subagent stacked bar */}
{showStackedBar && (
<div className="mb-4">
<div className="mb-1.5 flex h-3 w-full overflow-hidden rounded-full">
<div
className="h-full"
style={{ width: `${parentPct}%`, backgroundColor: '#60a5fa' }}
/>
<div
className="h-full"
style={{ width: `${100 - parentPct}%`, backgroundColor: '#c084fc' }}
/>
</div>
<div className="flex gap-4 text-xs">
<div className="flex items-center gap-1.5">
<span
className="inline-block size-2 rounded-full"
style={{ backgroundColor: '#60a5fa' }}
/>
<span className="text-text-secondary">Parent: {fmt(data.parentCostUsd)}</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="inline-block size-2 rounded-full"
style={{ backgroundColor: '#c084fc' }}
/>
<span className="text-text-secondary">Subagent: {fmt(data.subagentCostUsd)}</span>
</div>
</div>
</div>
)}
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
{!showStackedBar && (
<>
<div>
<div className="text-xs text-text-muted">Parent Cost</div>
<div className="text-sm font-medium text-text">{fmt(data.parentCostUsd)}</div>
</div>
<div>
<div className="text-xs text-text-muted">Subagent Cost</div>
<div className="text-sm font-medium text-text">{fmt(data.subagentCostUsd)}</div>
</div>
</>
)}
<div>
<div className="text-xs text-text-muted">Per Commit</div>
<div className="text-[10px] text-text-muted">
{commitCount > 0 ? (
<>
total cost {'\u00F7'} {commitCount} commit{commitCount !== 1 ? 's' : ''}
</>
) : (
'no commits'
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text">
{data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'}
</span>
{data.costPerCommitAssessment && (
<AssessmentBadge
assessment={data.costPerCommitAssessment}
metricKey="costPerCommit"
/>
)}
</div>
</div>
<div>
<div className="text-xs text-text-muted">Per Line Changed</div>
<div className="text-[10px] text-text-muted">
{linesChanged > 0 ? (
<>
total cost {'\u00F7'} {linesChanged.toLocaleString()} line
{linesChanged !== 1 ? 's' : ''}
</>
) : (
'no lines changed'
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text">
{data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'}
</span>
{data.costPerLineAssessment && (
<AssessmentBadge assessment={data.costPerLineAssessment} metricKey="costPerLine" />
)}
</div>
</div>
</div>
{modelEntries.length > 0 && (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-left text-text-muted">
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4 text-right">Input</th>
<th className="pb-2 pr-4 text-right">Output</th>
<th className="pb-2 pr-4 text-right">Cache Read</th>
<th className="pb-2 pr-4 text-right">Cache Write</th>
<th className="pb-2 pr-4 text-right">Cost</th>
</tr>
</thead>
<tbody>
{modelEntries.map(([model, cost]) => {
const stats = tokensByModel[model];
// Don't allow expansion for the synthetic aggregated row — getPricing
// would return wrong default rates for a non-model label.
const isAggregateRow = model === 'Subagents (combined)';
const isExpanded = expandedModel === model && !!stats && !isAggregateRow;
const pricing = isAggregateRow ? null : getPricing(model);
return (
<Fragment key={model}>
<tr
className={`border-border/50 border-b ${stats ? 'hover:bg-surface-raised/50 cursor-pointer' : ''}`}
onClick={() => {
if (isAggregateRow) {
const el = document.getElementById(sectionId('Subagents'));
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
el.dispatchEvent(new CustomEvent('report-section-expand'));
}
} else if (stats) {
setExpandedModel(isExpanded ? null : model);
}
}}
>
<td className="py-1.5 pr-4 text-text">
{isAggregateRow ? (
<span className="mr-1.5 inline-block w-3 text-text-muted">{'\u2192'}</span>
) : (
<span className="mr-1.5 inline-block w-3 text-text-muted">
{stats ? (isExpanded ? '\u25BC' : '\u25B6') : ''}
</span>
)}
{model}
</td>
<td className="py-1.5 pr-4 text-right text-text-secondary">
{stats ? fmtK(stats.inputTokens) : '—'}
</td>
<td className="py-1.5 pr-4 text-right text-text-secondary">
{stats ? fmtK(stats.outputTokens) : '—'}
</td>
<td className="py-1.5 pr-4 text-right text-text-secondary">
{stats ? fmtK(stats.cacheRead) : '—'}
</td>
<td className="py-1.5 pr-4 text-right text-text-secondary">
{stats ? fmtK(stats.cacheCreation) : '—'}
</td>
<td className="py-1.5 pr-4 text-right font-medium text-text">{fmt(cost)}</td>
</tr>
{isExpanded && stats && pricing && (
<tr>
<td colSpan={6} className="px-4 pb-3 pt-1">
<CostBreakdownCard stats={stats} pricing={pricing} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
)}
</ReportSection>
);
};

View file

@ -0,0 +1,103 @@
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 (
<div className="border-border/50 rounded border bg-surface p-2">
<button
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center gap-2 text-left text-xs"
>
{expanded ? (
<ChevronDown className="size-3 text-text-muted" />
) : (
<ChevronRight className="size-3 text-text-muted" />
)}
<span className="font-medium text-text">{error.tool}</span>
{error.isPermissionDenial && (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: 'color-mix(in srgb, var(--assess-danger) 15%, transparent)',
color: 'var(--assess-danger)',
}}
>
Permission Denied
</span>
)}
<span className="ml-auto text-text-muted">msg #{error.messageIndex}</span>
</button>
{expanded && (
<div className="mt-2 flex flex-col gap-1.5">
{error.inputPreview && (
<div className="rounded bg-surface-raised p-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-text-muted">
Input
</div>
<div className="whitespace-pre-wrap break-words font-mono text-xs text-text-secondary">
{error.inputPreview}
</div>
</div>
)}
<div className="rounded bg-surface-raised p-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-text-muted">
Error
</div>
<div
className="whitespace-pre-wrap break-words text-xs"
style={{ color: 'var(--assess-danger)' }}
>
{error.error}
</div>
</div>
</div>
)}
</div>
);
};
interface ErrorSectionProps {
data: ReportErrors;
defaultCollapsed?: boolean;
}
export const ErrorSection = ({ data, defaultCollapsed }: ErrorSectionProps) => {
return (
<ReportSection title="Errors" icon={AlertTriangle} defaultCollapsed={defaultCollapsed}>
<div className="mb-3 flex items-center gap-3">
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: 'color-mix(in srgb, var(--assess-danger) 15%, transparent)',
color: 'var(--assess-danger)',
}}
>
{data.errors.length} error{data.errors.length !== 1 ? 's' : ''}
</span>
{data.permissionDenials.count > 0 && (
<span className="text-xs text-text-muted">
{data.permissionDenials.count} permission denial
{data.permissionDenials.count !== 1 ? 's' : ''}
</span>
)}
</div>
<div className="flex flex-col gap-2">
{data.errors.map((error, idx) => (
<ErrorItem key={idx} error={error} />
))}
</div>
</ReportSection>
);
};

View file

@ -0,0 +1,97 @@
import { severityColor } from '@renderer/utils/reportAssessments';
import { MessageSquareWarning } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection } from '../ReportSection';
import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport';
interface FrictionSectionProps {
data: ReportFrictionSignals;
thrashing: ReportThrashingSignals;
defaultCollapsed?: boolean;
}
export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionSectionProps) => {
const frictionSeverity =
data.frictionRate <= 0.1 ? 'good' : data.frictionRate <= 0.25 ? 'warning' : 'danger';
const frictionColor = severityColor(frictionSeverity);
return (
<ReportSection
title="Friction Signals"
icon={MessageSquareWarning}
defaultCollapsed={defaultCollapsed}
>
<div className="mb-4 flex items-center gap-3">
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `color-mix(in srgb, ${frictionColor} 12%, transparent)`,
color: frictionColor,
}}
>
Friction Rate: {(data.frictionRate * 100).toFixed(1)}%
</span>
<span className="text-xs text-text-muted">
{data.correctionCount} correction{data.correctionCount !== 1 ? 's' : ''}
</span>
</div>
{data.corrections.length > 0 && (
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">Corrections</div>
<div className="flex flex-col gap-1">
{data.corrections.map((corr, idx) => (
<div key={idx} className="flex items-start gap-2 rounded px-2 py-1 text-xs">
<span
className="shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]"
style={{
backgroundColor: 'color-mix(in srgb, var(--assess-warning) 15%, transparent)',
color: 'var(--assess-warning)',
}}
>
{corr.keyword}
</span>
<span className="truncate text-text-secondary">{corr.preview}</span>
</div>
))}
</div>
</div>
)}
{(thrashing.bashNearDuplicates.length > 0 || thrashing.editReworkFiles.length > 0) && (
<div>
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">Thrashing Signals</span>
<AssessmentBadge assessment={thrashing.thrashingAssessment} metricKey="thrashing" />
</div>
{thrashing.bashNearDuplicates.length > 0 && (
<div className="mb-2">
<div className="mb-1 text-xs text-text-muted">Repeated Bash Commands</div>
{thrashing.bashNearDuplicates.map((dup, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="text-text-muted">{dup.count}x</span>
<code className="truncate text-text-secondary">{dup.prefix}</code>
</div>
))}
</div>
)}
{thrashing.editReworkFiles.length > 0 && (
<div>
<div className="mb-1 text-xs text-text-muted">Reworked Files (3+ edits)</div>
{thrashing.editReworkFiles.map((file, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="text-text-muted">{file.editIndices.length}x</span>
<span className="truncate text-text-secondary">{file.filePath}</span>
</div>
))}
</div>
)}
</div>
)}
</ReportSection>
);
};

View file

@ -0,0 +1,72 @@
import { GitBranch } from 'lucide-react';
import { ReportSection } from '../ReportSection';
import type { ReportGitActivity } from '@renderer/types/sessionReport';
interface GitSectionProps {
data: ReportGitActivity;
defaultCollapsed?: boolean;
}
export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => {
return (
<ReportSection title="Git Activity" icon={GitBranch} defaultCollapsed={defaultCollapsed}>
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">Commits</div>
<div className="text-sm font-medium text-text">{data.commitCount}</div>
</div>
<div>
<div className="text-xs text-text-muted">Pushes</div>
<div className="text-sm font-medium text-text">{data.pushCount}</div>
</div>
<div>
<div className="text-xs text-text-muted">Lines Added</div>
<div className="text-sm font-medium" style={{ color: 'var(--assess-good)' }}>
+{data.linesAdded.toLocaleString()}
</div>
</div>
<div>
<div className="text-xs text-text-muted">Lines Removed</div>
<div className="text-sm font-medium" style={{ color: 'var(--assess-danger)' }}>
-{data.linesRemoved.toLocaleString()}
</div>
</div>
</div>
{data.commits.length > 0 && (
<div>
<div className="mb-2 text-xs font-medium text-text-muted">Commits</div>
<div className="flex flex-col gap-1">
{data.commits.map((commit, idx) => (
<div
key={idx}
className="flex items-center gap-2 rounded px-2 py-1 text-xs text-text"
>
<span className="text-text-muted">#{commit.messageIndex}</span>
<span className="truncate">{commit.messagePreview}</span>
</div>
))}
</div>
</div>
)}
{data.branchCreations.length > 0 && (
<div className="mt-3">
<div className="mb-1 text-xs font-medium text-text-muted">Branches Created</div>
<div className="flex flex-wrap gap-1">
{data.branchCreations.map((branch, idx) => (
<span
key={idx}
className="rounded bg-surface px-2 py-0.5 text-xs text-text-secondary"
>
{branch}
</span>
))}
</div>
</div>
)}
</ReportSection>
);
};

View file

@ -0,0 +1,207 @@
import { Lightbulb } from 'lucide-react';
import { ReportSection } from '../ReportSection';
import type {
OutOfScopeFindings,
ReportAgentTree,
ReportBashCommands,
SkillInvocation,
SubagentBasicEntry,
UserQuestion,
} from '@renderer/types/sessionReport';
interface InsightsSectionProps {
skills: SkillInvocation[];
bash: ReportBashCommands;
lifecycleTasks: string[];
userQuestions: UserQuestion[];
outOfScope: OutOfScopeFindings[];
agentTree: ReportAgentTree;
subagentsList: SubagentBasicEntry[];
defaultCollapsed?: boolean;
}
export const InsightsSection = ({
skills,
bash,
lifecycleTasks,
userQuestions,
outOfScope,
agentTree,
subagentsList,
defaultCollapsed,
}: InsightsSectionProps) => {
return (
<ReportSection title="Session Insights" icon={Lightbulb} defaultCollapsed={defaultCollapsed}>
{/* Skills invoked */}
{skills.length > 0 && (
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">
Skills Invoked ({skills.length})
</div>
<div className="flex flex-col gap-1">
{skills.map((s, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="font-mono text-text">{s.skill}</span>
{s.argsPreview && <span className="truncate text-text-muted">{s.argsPreview}</span>}
</div>
))}
</div>
</div>
)}
{/* Bash commands */}
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">Bash Commands</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div>
<div className="text-xs text-text-muted">Total</div>
<div className="text-sm font-medium text-text">{bash.total}</div>
</div>
<div>
<div className="text-xs text-text-muted">Unique</div>
<div className="text-sm font-medium text-text">{bash.unique}</div>
</div>
<div>
<div className="text-xs text-text-muted">Repeated</div>
<div className="text-sm font-medium text-text">{Object.keys(bash.repeated).length}</div>
</div>
</div>
{Object.keys(bash.repeated).length > 0 && (
<div className="mt-2 flex flex-col gap-1">
{Object.entries(bash.repeated)
.slice(0, 10)
.map(([cmd, count], idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="font-mono text-text-muted">{count}x</span>
<span className="truncate font-mono text-text-secondary">{cmd}</span>
</div>
))}
</div>
)}
</div>
{/* Task tool subagent list */}
{subagentsList.length > 0 && (
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">
Task Dispatches ({subagentsList.length})
</div>
<div className="flex flex-col gap-1">
{subagentsList.map((s, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-text-muted">
{s.subagentType}
</span>
<span className="truncate text-text">{s.description}</span>
{s.runInBackground && <span className="text-text-muted">(background)</span>}
</div>
))}
</div>
</div>
)}
{/* Lifecycle tasks */}
{lifecycleTasks.length > 0 && (
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">
Tasks Created ({lifecycleTasks.length})
</div>
<div className="flex flex-col gap-1">
{lifecycleTasks.map((task, idx) => (
<div key={idx} className="px-2 py-0.5 text-xs text-text-secondary">
{task}
</div>
))}
</div>
</div>
)}
{/* User questions */}
{userQuestions.length > 0 && (
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">
Questions Asked ({userQuestions.length})
</div>
<div className="flex flex-col gap-2">
{userQuestions.map((q, idx) => (
<div key={idx} className="rounded-md bg-surface-raised px-3 py-2">
<div className="text-xs text-text">{q.question}</div>
{q.options.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{q.options.map((opt, optIdx) => (
<span
key={optIdx}
className="rounded px-1.5 py-0.5 text-xs text-text-muted"
style={{ backgroundColor: 'var(--color-surface-overlay)' }}
>
{opt}
</span>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Agent tree */}
{agentTree.agentCount > 0 && (
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">
Agent Tree ({agentTree.agentCount} agent{agentTree.agentCount !== 1 ? 's' : ''})
{agentTree.hasTeamMode && (
<span className="ml-2 rounded px-1.5 py-0.5 text-xs" style={{ color: '#60a5fa' }}>
Team Mode
</span>
)}
</div>
{agentTree.teamNames.length > 0 && (
<div className="mb-2 text-xs text-text-muted">
Teams: {agentTree.teamNames.join(', ')}
</div>
)}
<div className="flex flex-col gap-1">
{agentTree.agents.map((agent, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-text-muted">
{agent.agentType}
</span>
<span className="truncate font-mono text-text-secondary">
{agent.agentId.slice(0, 12)}...
</span>
</div>
))}
</div>
</div>
)}
{/* Out-of-scope findings */}
{outOfScope.length > 0 && (
<div>
<div className="mb-2 text-xs font-medium text-text-muted">
Out-of-Scope Findings ({outOfScope.length})
</div>
<div className="flex flex-col gap-2">
{outOfScope.map((f, idx) => (
<div key={idx} className="rounded-md bg-surface-raised px-3 py-2">
<span
className="mr-2 rounded px-1.5 py-0.5 text-xs"
style={{
backgroundColor: 'color-mix(in srgb, var(--assess-warning) 12%, transparent)',
color: 'var(--assess-warning)',
}}
>
{f.keyword}
</span>
<span className="text-xs text-text-secondary">{f.snippet}</span>
</div>
))}
</div>
</div>
)}
</ReportSection>
);
};

View file

@ -0,0 +1,55 @@
import { severityColor } from '@renderer/utils/reportAssessments';
import { AlertTriangle, CheckCircle, ChevronRight, Info, XCircle } from 'lucide-react';
import { sectionId } from '../ReportSection';
import type { Severity, Takeaway } from '@renderer/utils/reportAssessments';
const SEVERITY_ICONS: Record<Severity, React.ComponentType<{ className?: string }>> = {
danger: XCircle,
warning: AlertTriangle,
good: CheckCircle,
neutral: Info,
};
const scrollToSection = (sectionTitle: string) => {
const el = document.getElementById(sectionId(sectionTitle));
if (!el) return;
el.dispatchEvent(new CustomEvent('report-section-expand'));
};
interface KeyTakeawaysSectionProps {
takeaways: Takeaway[];
}
export const KeyTakeawaysSection = ({ takeaways }: KeyTakeawaysSectionProps) => {
return (
<div className="rounded-lg border border-border bg-surface-raised p-4">
<div className="mb-3 text-sm font-semibold text-text">Key Takeaways</div>
<div className="flex flex-col gap-2">
{takeaways.map((t, idx) => {
const Icon = SEVERITY_ICONS[t.severity];
const color = severityColor(t.severity);
return (
<button
key={idx}
type="button"
onClick={() => scrollToSection(t.sectionTitle)}
className="flex w-full items-start gap-3 rounded-md border-l-2 bg-surface px-3 py-2 text-left transition-colors hover:bg-surface-raised"
style={{ borderLeftColor: color }}
>
<span className="mt-0.5 shrink-0" style={{ color }}>
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-text">{t.title}</div>
<div className="text-xs text-text-secondary">{t.detail}</div>
</div>
<ChevronRight className="mt-0.5 size-4 shrink-0 text-text-muted" />
</button>
);
})}
</div>
</div>
);
};

View file

@ -0,0 +1,64 @@
import { assessmentColor } from '@renderer/utils/reportAssessments';
import { Activity } from 'lucide-react';
import { ReportSection } from '../ReportSection';
import type { ReportOverview } from '@renderer/types/sessionReport';
interface OverviewSectionProps {
data: ReportOverview;
}
export const OverviewSection = ({ data }: OverviewSectionProps) => {
return (
<ReportSection title="Overview" icon={Activity}>
<div className="mb-3 truncate text-xs text-text-muted">{data.firstMessage}</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">Duration</div>
<div className="text-sm font-medium text-text">{data.durationHuman}</div>
</div>
<div>
<div className="text-xs text-text-muted">Messages</div>
<div className="text-sm font-medium text-text">{data.totalMessages.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-text-muted">Context Usage</div>
<div
className="text-sm font-medium"
style={{ color: assessmentColor(data.contextAssessment) }}
>
{data.contextConsumptionPct != null ? `${data.contextConsumptionPct}%` : 'N/A'}
{data.contextAssessment && (
<span className="ml-1 text-xs">({data.contextAssessment})</span>
)}
</div>
</div>
<div>
<div className="text-xs text-text-muted">Compactions</div>
<div className="text-sm font-medium text-text">{data.compactionCount}</div>
</div>
<div>
<div className="text-xs text-text-muted">Branch</div>
<div className="truncate text-sm font-medium text-text">{data.gitBranch}</div>
</div>
<div>
<div className="text-xs text-text-muted">Subagents</div>
<div className="text-sm font-medium text-text">{data.hasSubagents ? 'Yes' : 'No'}</div>
</div>
<div>
<div className="text-xs text-text-muted">Project</div>
<div className="truncate text-sm font-medium text-text" title={data.projectPath}>
{data.projectPath}
</div>
</div>
<div>
<div className="text-xs text-text-muted">Session ID</div>
<div className="truncate text-sm font-medium text-text" title={data.sessionId}>
{data.sessionId.slice(0, 12)}...
</div>
</div>
</div>
</ReportSection>
);
};

View file

@ -0,0 +1,153 @@
import { severityColor } from '@renderer/utils/reportAssessments';
import { BarChart3 } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection } from '../ReportSection';
import type {
ReportFileReadRedundancy,
ReportPromptQuality,
ReportStartupOverhead,
ReportTestProgression,
} from '@renderer/types/sessionReport';
interface QualitySectionProps {
prompt: ReportPromptQuality;
startup: ReportStartupOverhead;
testProgression: ReportTestProgression;
fileReadRedundancy: ReportFileReadRedundancy;
defaultCollapsed?: boolean;
}
export const QualitySection = ({
prompt,
startup,
testProgression,
fileReadRedundancy,
defaultCollapsed,
}: QualitySectionProps) => {
return (
<ReportSection title="Quality Signals" icon={BarChart3} defaultCollapsed={defaultCollapsed}>
{/* Prompt quality */}
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">Prompt Quality</div>
<div className="mb-2 flex items-center gap-2">
<AssessmentBadge assessment={prompt.assessment} metricKey="promptQuality" />
</div>
<div className="text-xs text-text-secondary">{prompt.note}</div>
<div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">First Message</div>
<div className="text-sm font-medium text-text">
{prompt.firstMessageLengthChars.toLocaleString()} chars
</div>
</div>
<div>
<div className="text-xs text-text-muted">User Messages</div>
<div className="text-sm font-medium text-text">{prompt.userMessageCount}</div>
</div>
<div>
<div className="text-xs text-text-muted">Corrections</div>
<div className="text-sm font-medium text-text">{prompt.correctionCount}</div>
</div>
<div>
<div className="text-xs text-text-muted">Friction Rate</div>
<div className="text-sm font-medium text-text">
{(prompt.frictionRate * 100).toFixed(1)}%
</div>
</div>
</div>
</div>
{/* Startup overhead */}
<div className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">Startup Overhead</span>
<AssessmentBadge assessment={startup.overheadAssessment} metricKey="startup" />
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div>
<div className="text-xs text-text-muted">Messages Before Work</div>
<div className="text-sm font-medium text-text">{startup.messagesBeforeFirstWork}</div>
</div>
<div>
<div className="text-xs text-text-muted">Tokens Before Work</div>
<div className="text-sm font-medium text-text">
{startup.tokensBeforeFirstWork.toLocaleString()}
</div>
</div>
<div>
<div className="text-xs text-text-muted">% of Total</div>
<div className="text-sm font-medium text-text">{startup.pctOfTotal}%</div>
</div>
</div>
</div>
{/* File read redundancy */}
<div className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">File Read Redundancy</span>
<AssessmentBadge
assessment={fileReadRedundancy.redundancyAssessment}
metricKey="fileReads"
/>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div>
<div className="text-xs text-text-muted">Total Reads</div>
<div className="text-sm font-medium text-text">{fileReadRedundancy.totalReads}</div>
</div>
<div>
<div className="text-xs text-text-muted">Unique Files</div>
<div className="text-sm font-medium text-text">{fileReadRedundancy.uniqueFiles}</div>
</div>
<div>
<div className="text-xs text-text-muted">Reads/Unique File</div>
<div className="text-sm font-medium text-text">
{fileReadRedundancy.readsPerUniqueFile}x
</div>
</div>
</div>
</div>
{/* Test progression */}
<div>
<div className="mb-2 text-xs font-medium text-text-muted">Test Progression</div>
<div className="mb-2 flex items-center gap-2">
<AssessmentBadge assessment={testProgression.trajectory} metricKey="testTrajectory" />
<span className="text-xs text-text-muted">
{testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''}
</span>
</div>
{testProgression.firstSnapshot && testProgression.lastSnapshot && (
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-text-muted">First Run</div>
<div className="text-sm text-text">
<span style={{ color: severityColor('good') }}>
{testProgression.firstSnapshot.passed} passed
</span>
{' / '}
<span style={{ color: severityColor('danger') }}>
{testProgression.firstSnapshot.failed} failed
</span>
</div>
</div>
<div>
<div className="text-xs text-text-muted">Last Run</div>
<div className="text-sm text-text">
<span style={{ color: severityColor('good') }}>
{testProgression.lastSnapshot.passed} passed
</span>
{' / '}
<span style={{ color: severityColor('danger') }}>
{testProgression.lastSnapshot.failed} failed
</span>
</div>
</div>
</div>
)}
</div>
</ReportSection>
);
};

View file

@ -0,0 +1,88 @@
import { severityColor } from '@renderer/utils/reportAssessments';
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;
defaultCollapsed?: boolean;
}
export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps) => {
return (
<ReportSection title="Subagents" icon={Users} defaultCollapsed={defaultCollapsed}>
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">Count</div>
<div className="text-sm font-medium text-text">{data.count}</div>
</div>
<div>
<div className="text-xs text-text-muted">Total Tokens</div>
<div className="text-sm font-medium text-text">{data.totalTokens.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-text-muted">Total Duration</div>
<div className="text-sm font-medium text-text">{fmtDuration(data.totalDurationMs)}</div>
</div>
<div>
<div className="text-xs text-text-muted">Total Cost</div>
<div className="text-sm font-medium text-text">{fmtCost(data.totalCostUsd)}</div>
</div>
</div>
{data.byAgent.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-left text-text-muted">
<th className="pb-2 pr-4">Description</th>
<th className="pb-2 pr-4">Type</th>
<th className="pb-2 pr-4 text-right">Tokens</th>
<th className="pb-2 pr-4 text-right">Duration</th>
<th className="pb-2 text-right">Cost</th>
</tr>
</thead>
<tbody>
{data.byAgent.map((agent, idx) => (
<tr key={idx} className="border-border/50 border-b">
<td className="max-w-48 py-1.5 pr-4 text-text">
<div className="truncate" title={agent.description}>
{agent.description}
</div>
{agent.modelMismatch && (
<div
className="mt-0.5 truncate text-[10px]"
style={{ color: severityColor('warning') }}
title={agent.modelMismatch.recommendation}
>
{agent.modelMismatch.recommendation}
</div>
)}
</td>
<td className="py-1.5 pr-4 text-text-secondary">{agent.subagentType}</td>
<td className="py-1.5 pr-4 text-right text-text">
{agent.totalTokens.toLocaleString()}
</td>
<td className="py-1.5 pr-4 text-right text-text">
{fmtDuration(agent.totalDurationMs)}
</td>
<td className="py-1.5 text-right text-text">{fmtCost(agent.costUsd)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ReportSection>
);
};

View file

@ -0,0 +1,111 @@
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
import { Clock } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection } from '../ReportSection';
import type {
KeyEvent,
ReportIdleAnalysis,
ReportModelSwitches,
} from '@renderer/types/sessionReport';
interface TimelineSectionProps {
idle: ReportIdleAnalysis;
modelSwitches: ReportModelSwitches;
keyEvents: KeyEvent[];
defaultCollapsed?: boolean;
}
export const TimelineSection = ({
idle,
modelSwitches,
keyEvents,
defaultCollapsed,
}: TimelineSectionProps) => {
const idleColor = assessmentColor(idle.idleAssessment);
return (
<ReportSection title="Timeline & Activity" icon={Clock} defaultCollapsed={defaultCollapsed}>
{/* Idle stats */}
<div className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">Idle Analysis</span>
<AssessmentBadge assessment={idle.idleAssessment} metricKey="idle" />
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">Idle Gaps</div>
<div className="text-sm font-medium text-text">{idle.idleGapCount}</div>
</div>
<div>
<div className="text-xs text-text-muted">Total Idle</div>
<div className="text-sm font-medium text-text">{idle.totalIdleHuman}</div>
</div>
<div>
<div className="text-xs text-text-muted">Active Time</div>
<div className="text-sm font-medium text-text">{idle.activeWorkingHuman}</div>
</div>
<div>
<div className="text-xs text-text-muted">Idle %</div>
<div className="text-sm font-medium" style={{ color: idleColor }}>
{idle.idlePct}%
</div>
</div>
</div>
</div>
{/* Model switches */}
{modelSwitches.count > 0 && (
<div className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">
Model Switches ({modelSwitches.count})
</span>
{modelSwitches.switchPattern && (
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${assessmentColor(modelSwitches.switchPattern)}20`,
color: assessmentColor(modelSwitches.switchPattern),
}}
>
{assessmentLabel(modelSwitches.switchPattern)}
</span>
)}
</div>
<div className="flex flex-col gap-1">
{modelSwitches.switches.map((sw, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="text-text-secondary">{sw.from}</span>
<span className="text-text-muted">&rarr;</span>
<span className="text-text">{sw.to}</span>
<span className="ml-auto text-text-muted">msg #{sw.messageIndex}</span>
</div>
))}
</div>
</div>
)}
{/* Key events */}
{keyEvents.length > 0 && (
<div>
<div className="mb-2 text-xs font-medium text-text-muted">Key Events</div>
<div className="flex flex-col gap-1">
{keyEvents.map((event, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="shrink-0 text-text-muted">
{event.timestamp.toLocaleTimeString()}
</span>
<span className="truncate text-text">{event.label}</span>
{event.deltaHuman && (
<span className="ml-auto shrink-0 text-text-muted">+{event.deltaHuman}</span>
)}
</div>
))}
</div>
</div>
)}
</ReportSection>
);
};

View file

@ -0,0 +1,116 @@
import { Coins } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
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;
defaultCollapsed?: boolean;
}
export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSectionProps) => {
const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd);
return (
<ReportSection title="Token Usage" icon={Coins} defaultCollapsed={defaultCollapsed}>
{/* By-model table */}
<div className="mb-4 overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-left text-text-muted">
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4 text-right">API Calls</th>
<th className="pb-2 pr-4 text-right">Input</th>
<th className="pb-2 pr-4 text-right">Output</th>
<th className="pb-2 pr-4 text-right">Cache Read</th>
<th className="pb-2 pr-4 text-right">Cache Create</th>
<th className="pb-2 text-right">Cost</th>
</tr>
</thead>
<tbody>
{modelEntries.map(([model, stats]) => (
<tr key={model} className="border-border/50 border-b">
<td className="py-1.5 pr-4 text-text">{model}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(stats.apiCalls)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(stats.inputTokens)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(stats.outputTokens)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(stats.cacheRead)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(stats.cacheCreation)}</td>
<td className="py-1.5 text-right text-text">{fmtCost(stats.costUsd)}</td>
</tr>
))}
{/* Totals row */}
<tr className="border-t border-border font-medium">
<td className="py-1.5 pr-4 text-text">Total</td>
<td className="py-1.5 pr-4 text-right text-text">
{fmt(modelEntries.reduce((s, [, st]) => s + st.apiCalls, 0))}
</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(data.totals.inputTokens)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(data.totals.outputTokens)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(data.totals.cacheRead)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(data.totals.cacheCreation)}</td>
<td className="py-1.5 text-right text-text">
{fmtCost(modelEntries.reduce((s, [, st]) => s + st.costUsd, 0))}
</td>
</tr>
</tbody>
</table>
</div>
{/* Cache economics */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">Cache Efficiency</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text">
{cacheEconomics.cacheEfficiencyPct}%
</span>
{cacheEconomics.cacheEfficiencyAssessment && (
<AssessmentBadge
assessment={cacheEconomics.cacheEfficiencyAssessment}
metricKey="cacheEfficiency"
/>
)}
</div>
</div>
<div>
<div className="text-xs text-text-muted">R/W Ratio</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text">
{cacheEconomics.cacheReadToWriteRatio}x
</span>
{cacheEconomics.cacheRatioAssessment && (
<AssessmentBadge
assessment={cacheEconomics.cacheRatioAssessment}
metricKey="cacheRatio"
/>
)}
</div>
</div>
<div>
<div className="text-xs text-text-muted">Cache Read %</div>
<div className="text-sm font-medium text-text">{data.totals.cacheReadPct}%</div>
</div>
<div>
<div className="text-xs text-text-muted">Cold Start</div>
<div
className="text-sm font-medium"
style={{
color: cacheEconomics.coldStartDetected
? 'var(--assess-warning)'
: 'var(--assess-good)',
}}
>
{cacheEconomics.coldStartDetected ? 'Yes' : 'No'}
</div>
</div>
</div>
</ReportSection>
);
};

View file

@ -0,0 +1,77 @@
import { assessmentColor } from '@renderer/utils/reportAssessments';
import { Wrench } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection, sectionId } from '../ReportSection';
import type { ReportToolUsage } from '@renderer/types/sessionReport';
interface ToolSectionProps {
data: ReportToolUsage;
defaultCollapsed?: boolean;
}
export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => {
const toolEntries = Object.entries(data.successRates).sort(
(a, b) => b[1].totalCalls - a[1].totalCalls
);
return (
<ReportSection title="Tool Usage" icon={Wrench} defaultCollapsed={defaultCollapsed}>
<div className="mb-2 flex items-center gap-2">
<span className="text-xs text-text-muted">
{data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools
</span>
<AssessmentBadge assessment={data.overallToolHealth} metricKey="toolHealth" />
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-left text-text-muted">
<th className="pb-2 pr-4">Tool</th>
<th className="pb-2 pr-4 text-right">Calls</th>
<th className="pb-2 pr-4 text-right">Errors</th>
<th className="pb-2 pr-4 text-right">Success %</th>
<th className="pb-2 text-right">Health</th>
</tr>
</thead>
<tbody>
{toolEntries.map(([tool, stats]) => {
const color = assessmentColor(stats.assessment);
return (
<tr key={tool} className="border-border/50 border-b">
<td className="py-1.5 pr-4 text-text">{tool}</td>
<td className="py-1.5 pr-4 text-right text-text">
{stats.totalCalls.toLocaleString()}
</td>
<td className="py-1.5 pr-4 text-right text-text">
{stats.errors > 0 ? (
<button
type="button"
onClick={() => {
const el = document.getElementById(sectionId('Errors'));
if (el) el.dispatchEvent(new CustomEvent('report-section-expand'));
}}
className="text-red-400 underline decoration-red-400/30 underline-offset-2 hover:decoration-red-400"
>
{stats.errors.toLocaleString()}
</button>
) : (
stats.errors.toLocaleString()
)}
</td>
<td className="py-1.5 pr-4 text-right" style={{ color }}>
{stats.successRatePct}%
</td>
<td className="py-1.5 text-right">
<AssessmentBadge assessment={stats.assessment} metricKey="toolHealth" />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</ReportSection>
);
};

View file

@ -181,6 +181,12 @@
--card-text-lighter: #e4e4e7;
--card-separator: #3f3f46;
/* Assessment severity colors (badges, health indicators) */
--assess-good: #4ade80;
--assess-warning: #fbbf24;
--assess-danger: #f87171;
--assess-neutral: #a1a1aa;
/* Sticky Context button - transparent glass */
--context-btn-bg: rgba(255, 255, 255, 0.08);
--context-btn-bg-hover: rgba(255, 255, 255, 0.14);
@ -206,6 +212,12 @@
--color-text-secondary: #4d4b46; /* Warm secondary text */
--color-text-muted: #6d6b65; /* Warm muted text */
/* Assessment severity colors - darker for light backgrounds */
--assess-good: #16a34a;
--assess-warning: #d97706;
--assess-danger: #dc2626;
--assess-neutral: #57534e;
/* Scrollbar colors for light mode */
--scrollbar-thumb: rgba(0, 0, 0, 0.15);
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.28);

View file

@ -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,28 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (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;
if (!sourceTab.sessionId || !sourceTab.projectId) 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();

View file

@ -0,0 +1,391 @@
/**
* Session analysis report types.
* Output of analyzeSession() one interface per report section.
*/
import type {
CacheAssessment,
CostAssessment,
IdleAssessment,
ModelMismatch,
OverheadAssessment,
RedundancyAssessment,
SubagentCostShareAssessment,
SwitchPattern,
ThrashingAssessment,
ToolHealthAssessment,
} from '@renderer/utils/reportAssessments';
// =============================================================================
// 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<string, ModelTokenStats>;
totals: TokenTotals;
}
export interface ReportCostAnalysis {
parentCostUsd: number;
subagentCostUsd: number;
totalSessionCostUsd: number;
costByModel: Record<string, number>;
costPerCommit: number | null;
costPerLineChanged: number | null;
costPerCommitAssessment: CostAssessment | null;
costPerLineAssessment: CostAssessment | null;
subagentCostSharePct: number | null;
subagentCostShareAssessment: SubagentCostShareAssessment | null;
}
export interface ReportCacheEconomics {
cacheRead: number;
cacheEfficiencyPct: number;
coldStartDetected: boolean;
cacheReadToWriteRatio: number;
cacheEfficiencyAssessment: CacheAssessment | null;
cacheRatioAssessment: CacheAssessment | null;
}
export interface ToolSuccessRate {
totalCalls: number;
errors: number;
successRatePct: number;
assessment: ToolHealthAssessment;
}
export interface ReportToolUsage {
counts: Record<string, number>;
totalCalls: number;
successRates: Record<string, ToolSuccessRate>;
overallToolHealth: ToolHealthAssessment;
}
export interface SubagentEntry {
description: string;
subagentType: string;
model: string;
totalTokens: number;
totalDurationMs: number;
totalToolUseCount: number;
costUsd: number;
costNote?: string;
modelMismatch: ModelMismatch | null;
}
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[] }[];
thrashingAssessment: ThrashingAssessment;
}
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[];
idleAssessment: IdleAssessment;
}
export interface ModelSwitch {
from: string;
to: string;
messageIndex: number;
timestamp: Date | null;
}
export interface ReportModelSwitches {
count: number;
switches: ModelSwitch[];
modelsUsed: string[];
switchPattern: SwitchPattern | null;
}
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;
overheadAssessment: OverheadAssessment;
}
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<string, boolean>;
}
export interface ReportThinkingBlocks {
count: number;
analyzedCount: number;
signalSummary: Record<string, number>;
notableBlocks: ThinkingBlockAnalysis[];
}
export interface KeyEvent {
timestamp: Date;
label: string;
deltaSeconds?: number;
deltaHuman?: string;
}
export interface ReportFileReadRedundancy {
totalReads: number;
uniqueFiles: number;
readsPerUniqueFile: number;
redundantFiles: Record<string, number>;
redundancyAssessment: RedundancyAssessment;
}
// =============================================================================
// Missing Sections (ported from Python analyzer)
// =============================================================================
export interface SkillInvocation {
skill: string;
argsPreview: string;
}
export interface ReportBashCommands {
total: number;
unique: number;
repeated: Record<string, number>;
}
export interface UserQuestion {
question: string;
options: string[];
}
export interface OutOfScopeFindings {
keyword: string;
messageIndex: number;
snippet: string;
}
export interface AgentTreeNode {
agentId: string;
agentType: string;
teamName: string;
parentToolUseId: string;
messageIndex: number;
}
export interface ReportAgentTree {
agentCount: number;
agents: AgentTreeNode[];
hasTeamMode: boolean;
teamNames: string[];
}
export interface ReportCompaction {
count: number;
compactSummaryCount: number;
note: string;
}
export interface SubagentBasicEntry {
description: string;
subagentType: string;
model: string;
runInBackground: boolean;
}
// =============================================================================
// Combined Report
// =============================================================================
export interface SessionReport {
overview: ReportOverview;
tokenUsage: ReportTokenUsage;
costAnalysis: ReportCostAnalysis;
cacheEconomics: ReportCacheEconomics;
toolUsage: ReportToolUsage;
subagentMetrics: ReportSubagentMetrics;
subagentsList: SubagentBasicEntry[];
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<string, number>;
fileReadRedundancy: ReportFileReadRedundancy;
compaction: ReportCompaction;
gitBranches: string[];
skillsInvoked: SkillInvocation[];
bashCommands: ReportBashCommands;
lifecycleTasks: string[];
userQuestions: UserQuestion[];
outOfScopeFindings: OutOfScopeFindings[];
agentTree: ReportAgentTree;
}

View file

@ -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;

View file

@ -425,8 +425,7 @@ export function buildDisplayItemsFromMessages(
}
// Only treat as subagent input if there are NO tool_result blocks in this message
const hasToolResults =
Array.isArray(msg.content) &&
msg.content.some((b) => b.type === 'tool_result');
Array.isArray(msg.content) && msg.content.some((b) => b.type === 'tool_result');
if (rawText.trim() && !hasToolResults) {
displayItems.push({
type: 'subagent_input',

View file

@ -0,0 +1,555 @@
/**
* Centralized assessment severity/color utilities for session reports.
*
* Maps raw assessment values to severity levels and colors,
* replacing duplicated assessmentColor() functions across report sections.
*/
// =============================================================================
// Types
// =============================================================================
export type Severity = 'good' | 'warning' | 'danger' | 'neutral';
// =============================================================================
// Colors
// =============================================================================
const SEVERITY_CSS_VAR: Record<Severity, string> = {
good: '--assess-good',
warning: '--assess-warning',
danger: '--assess-danger',
neutral: '--assess-neutral',
};
const SEVERITY_FALLBACKS: Record<Severity, string> = {
good: '#4ade80',
warning: '#fbbf24',
danger: '#f87171',
neutral: '#a1a1aa',
};
export function severityColor(severity: Severity): string {
if (typeof document === 'undefined') return SEVERITY_FALLBACKS[severity];
const value = getComputedStyle(document.documentElement)
.getPropertyValue(SEVERITY_CSS_VAR[severity])
.trim();
return value || SEVERITY_FALLBACKS[severity];
}
// =============================================================================
// Assessment → Severity Mapping
// =============================================================================
const ASSESSMENT_SEVERITY: Record<string, Severity> = {
// Context
healthy: 'good',
moderate: 'warning',
high: 'danger',
critical: 'danger',
// Cost / subagent share
efficient: 'good',
normal: 'good',
expensive: 'warning',
red_flag: 'danger',
very_high: 'danger',
// Cache
good: 'good',
concerning: 'warning',
// Tool health
degraded: 'warning',
unreliable: 'danger',
// Idle ('moderate' already mapped above under Context)
high_idle: 'danger',
// File read
wasteful: 'warning',
// Startup
heavy: 'warning',
// Thrashing
none: 'good',
mild: 'warning',
severe: 'danger',
// Prompt quality
well_specified: 'good',
moderate_friction: 'warning',
underspecified: 'danger',
verbose_but_unclear: 'danger',
// Test trajectory
improving: 'good',
stable: 'warning',
regressing: 'danger',
insufficient_data: 'neutral',
// Model switch
opus_plan_mode: 'good',
manual_switch: 'neutral',
};
export function assessmentSeverity(assessment: string | null | undefined): Severity {
if (!assessment) return 'neutral';
return ASSESSMENT_SEVERITY[assessment] ?? 'neutral';
}
export function assessmentColor(assessment: string | null | undefined): string {
return severityColor(assessmentSeverity(assessment));
}
// =============================================================================
// Label Formatting
// =============================================================================
export function assessmentLabel(value: string): string {
return value
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
// =============================================================================
// Threshold Constants
// =============================================================================
export const THRESHOLDS = {
costPerCommit: {
efficient: 0.5,
normal: 2,
expensive: 5,
},
costPerLine: {
efficient: 0.01,
normal: 0.05,
expensive: 0.2,
},
subagentCostShare: {
normal: 30,
high: 60,
veryHigh: 80,
},
cacheEfficiency: {
good: 95,
},
cacheRwRatio: {
good: 20,
},
toolSuccess: {
healthy: 95,
degraded: 80,
},
idle: {
efficient: 20,
moderate: 50,
},
fileReadsPerUnique: {
normal: 2.0,
},
startupOverhead: {
normal: 5,
},
} as const;
// =============================================================================
// Metric Keys & Explanations
// =============================================================================
export type MetricKey =
| 'costPerCommit'
| 'costPerLine'
| 'subagentCostShare'
| 'cacheEfficiency'
| 'cacheRatio'
| 'toolHealth'
| 'idle'
| 'fileReads'
| 'startup'
| 'thrashing'
| 'promptQuality'
| 'testTrajectory';
const EXPLANATIONS: Record<string, Record<string, string>> = {
costPerCommit: {
efficient: `Under $${THRESHOLDS.costPerCommit.efficient}/commit`,
normal: `$${THRESHOLDS.costPerCommit.efficient}\u2013$${THRESHOLDS.costPerCommit.normal}/commit`,
expensive: `$${THRESHOLDS.costPerCommit.normal}\u2013$${THRESHOLDS.costPerCommit.expensive}/commit`,
red_flag: `Over $${THRESHOLDS.costPerCommit.expensive}/commit`,
},
costPerLine: {
efficient: `Under $${THRESHOLDS.costPerLine.efficient}/line`,
normal: `$${THRESHOLDS.costPerLine.efficient}\u2013$${THRESHOLDS.costPerLine.normal}/line`,
expensive: `$${THRESHOLDS.costPerLine.normal}\u2013$${THRESHOLDS.costPerLine.expensive}/line`,
red_flag: `Over $${THRESHOLDS.costPerLine.expensive}/line`,
},
subagentCostShare: {
normal: `Under ${THRESHOLDS.subagentCostShare.normal}% of total cost`,
high: `${THRESHOLDS.subagentCostShare.normal}\u2013${THRESHOLDS.subagentCostShare.high}% of total cost`,
very_high: `${THRESHOLDS.subagentCostShare.high}\u2013${THRESHOLDS.subagentCostShare.veryHigh}% of total cost`,
red_flag: `Over ${THRESHOLDS.subagentCostShare.veryHigh}% of total cost`,
},
cacheEfficiency: {
good: `${THRESHOLDS.cacheEfficiency.good}%+ cache hit rate`,
concerning: `Below ${THRESHOLDS.cacheEfficiency.good}% cache hit rate`,
},
cacheRatio: {
good: `${THRESHOLDS.cacheRwRatio.good}x+ read-to-write ratio`,
concerning: `Below ${THRESHOLDS.cacheRwRatio.good}x read-to-write ratio`,
},
toolHealth: {
healthy: `Over ${THRESHOLDS.toolSuccess.healthy}% success rate`,
degraded: `${THRESHOLDS.toolSuccess.degraded}\u2013${THRESHOLDS.toolSuccess.healthy}% success rate`,
unreliable: `Below ${THRESHOLDS.toolSuccess.degraded}% success rate`,
},
idle: {
efficient: `Under ${THRESHOLDS.idle.efficient}% idle time`,
moderate: `${THRESHOLDS.idle.efficient}\u2013${THRESHOLDS.idle.moderate}% idle time`,
high_idle: `Over ${THRESHOLDS.idle.moderate}% idle time`,
},
fileReads: {
normal: `${THRESHOLDS.fileReadsPerUnique.normal}x or fewer reads per unique file`,
wasteful: `Over ${THRESHOLDS.fileReadsPerUnique.normal}x reads per unique file`,
},
startup: {
normal: `${THRESHOLDS.startupOverhead.normal}% or less of tokens before first work`,
heavy: `Over ${THRESHOLDS.startupOverhead.normal}% of tokens before first work`,
},
thrashing: {
none: 'No repeated commands or reworked files',
mild: '1\u20132 thrashing signals detected',
severe: '3+ thrashing signals detected',
},
promptQuality: {
well_specified: 'Clear first message with low friction rate',
moderate_friction: 'Some corrections needed mid-session',
underspecified: 'Short initial prompt led to many corrections',
verbose_but_unclear: 'Long initial prompt but still high friction',
},
testTrajectory: {
improving: 'Test failures decreased over the session',
stable: 'Test results stayed roughly the same',
regressing: 'Test failures increased over the session',
insufficient_data: 'Not enough test runs to determine trend',
},
};
export function assessmentExplanation(metricKey: MetricKey, assessment: string): string {
return EXPLANATIONS[metricKey]?.[assessment] ?? '';
}
// =============================================================================
// Assessment Computers
// =============================================================================
export type CostAssessment = 'efficient' | 'normal' | 'expensive' | 'red_flag';
export type CacheAssessment = 'good' | 'concerning';
export type ToolHealthAssessment = 'healthy' | 'degraded' | 'unreliable';
export type IdleAssessment = 'efficient' | 'moderate' | 'high_idle';
export type RedundancyAssessment = 'normal' | 'wasteful';
export type OverheadAssessment = 'normal' | 'heavy';
export type ThrashingAssessment = 'none' | 'mild' | 'severe';
export type SubagentCostShareAssessment = 'normal' | 'high' | 'very_high' | 'red_flag';
export type SwitchPattern = 'opus_plan_mode' | 'manual_switch' | 'none';
export function computeCostPerCommitAssessment(costPerCommit: number): CostAssessment {
if (costPerCommit < THRESHOLDS.costPerCommit.efficient) return 'efficient';
if (costPerCommit < THRESHOLDS.costPerCommit.normal) return 'normal';
if (costPerCommit < THRESHOLDS.costPerCommit.expensive) return 'expensive';
return 'red_flag';
}
export function computeCostPerLineAssessment(costPerLine: number): CostAssessment {
if (costPerLine < THRESHOLDS.costPerLine.efficient) return 'efficient';
if (costPerLine < THRESHOLDS.costPerLine.normal) return 'normal';
if (costPerLine < THRESHOLDS.costPerLine.expensive) return 'expensive';
return 'red_flag';
}
export function computeSubagentCostShareAssessment(pct: number): SubagentCostShareAssessment {
if (pct < THRESHOLDS.subagentCostShare.normal) return 'normal';
if (pct < THRESHOLDS.subagentCostShare.high) return 'high';
if (pct < THRESHOLDS.subagentCostShare.veryHigh) return 'very_high';
return 'red_flag';
}
export function computeCacheEfficiencyAssessment(pct: number): CacheAssessment {
return pct >= THRESHOLDS.cacheEfficiency.good ? 'good' : 'concerning';
}
export function computeCacheRatioAssessment(ratio: number): CacheAssessment {
return ratio >= THRESHOLDS.cacheRwRatio.good ? 'good' : 'concerning';
}
export function computeToolHealthAssessment(successPct: number): ToolHealthAssessment {
if (successPct > THRESHOLDS.toolSuccess.healthy) return 'healthy';
if (successPct >= THRESHOLDS.toolSuccess.degraded) return 'degraded';
return 'unreliable';
}
export function computeIdleAssessment(idlePct: number): IdleAssessment {
if (idlePct < THRESHOLDS.idle.efficient) return 'efficient';
if (idlePct < THRESHOLDS.idle.moderate) return 'moderate';
return 'high_idle';
}
export function computeRedundancyAssessment(readsPerUnique: number): RedundancyAssessment {
return readsPerUnique <= THRESHOLDS.fileReadsPerUnique.normal ? 'normal' : 'wasteful';
}
export function computeOverheadAssessment(pctOfTotal: number): OverheadAssessment {
return pctOfTotal <= THRESHOLDS.startupOverhead.normal ? 'normal' : 'heavy';
}
export function computeThrashingAssessment(signalCount: number): ThrashingAssessment {
if (signalCount === 0) return 'none';
if (signalCount <= 2) return 'mild';
return 'severe';
}
export interface ModelMismatch {
description: string;
expectedComplexity: 'mechanical' | 'read_only';
recommendation: string;
}
const MECHANICAL_PATTERNS = /\b(rename|move|lint|format|delete|remove|copy|replace)\b/i;
const READ_ONLY_PATTERNS = /\b(explore|search|find|verify|check|scan|discover|list|read)\b/i;
export function detectModelMismatch(description: string, model: string): ModelMismatch | null {
const isOpus = model.toLowerCase().includes('opus');
if (!isOpus) return null;
if (MECHANICAL_PATTERNS.test(description)) {
return {
description,
expectedComplexity: 'mechanical',
recommendation: 'Consider using Haiku for mechanical tasks to reduce cost.',
};
}
if (READ_ONLY_PATTERNS.test(description)) {
return {
description,
expectedComplexity: 'read_only',
recommendation: 'Consider using Haiku or Sonnet for read-only exploration tasks.',
};
}
return null;
}
export function detectSwitchPattern(
switches: { from: string; to: string }[]
): SwitchPattern | null {
if (switches.length === 0) return null;
if (switches.length < 2) return 'manual_switch';
// Look for Sonnet→Opus→Sonnet pattern (plan mode)
for (let i = 0; i < switches.length - 1; i++) {
const s1 = switches[i];
const s2 = switches[i + 1];
if (
s1.from.toLowerCase().includes('sonnet') &&
s1.to.toLowerCase().includes('opus') &&
s2.from.toLowerCase().includes('opus') &&
s2.to.toLowerCase().includes('sonnet')
) {
return 'opus_plan_mode';
}
}
return 'manual_switch';
}
// =============================================================================
// Key Takeaways
// =============================================================================
export interface Takeaway {
severity: Severity;
title: string;
detail: string;
sectionTitle: string;
}
interface TakeawayReport {
costAnalysis: {
costPerCommitAssessment: string | null;
costPerLineAssessment: string | null;
totalSessionCostUsd: number;
};
cacheEconomics: {
cacheEfficiencyAssessment: string | null;
cacheEfficiencyPct: number;
};
toolUsage: {
overallToolHealth: string;
};
thrashingSignals: {
thrashingAssessment: string;
bashNearDuplicates: unknown[];
editReworkFiles: unknown[];
};
idleAnalysis: {
idleAssessment: string;
idlePct: number;
};
promptQuality: {
assessment: string;
frictionRate: number;
};
overview: {
contextAssessment: string | null;
compactionCount: number;
};
fileReadRedundancy: {
redundancyAssessment: string;
readsPerUniqueFile: number;
};
testProgression: {
trajectory: string;
};
}
export function computeTakeaways(report: TakeawayReport): Takeaway[] {
const items: Takeaway[] = [];
// Cost red flags
const costSev = assessmentSeverity(report.costAnalysis.costPerCommitAssessment);
if (costSev === 'danger') {
items.push({
severity: 'danger',
title: 'High cost per commit',
detail: `$${report.costAnalysis.totalSessionCostUsd.toFixed(2)} total \u2014 consider smaller, focused sessions`,
sectionTitle: 'Cost Analysis',
});
} else if (costSev === 'warning') {
items.push({
severity: 'warning',
title: 'Elevated cost per commit',
detail: 'Cost per commit is above typical range',
sectionTitle: 'Cost Analysis',
});
}
// Cache efficiency
if (report.cacheEconomics.cacheEfficiencyAssessment === 'concerning') {
items.push({
severity: 'warning',
title: 'Low cache efficiency',
detail: `${report.cacheEconomics.cacheEfficiencyPct}% cache hit rate \u2014 prompt structure may reduce caching`,
sectionTitle: 'Token Usage',
});
}
// Tool health
const toolSev = assessmentSeverity(report.toolUsage.overallToolHealth);
if (toolSev === 'danger') {
items.push({
severity: 'danger',
title: 'Tool reliability issues',
detail: 'Multiple tool calls are failing \u2014 check error section for details',
sectionTitle: 'Tool Usage',
});
} else if (toolSev === 'warning') {
items.push({
severity: 'warning',
title: 'Degraded tool health',
detail: 'Some tools have elevated failure rates',
sectionTitle: 'Tool Usage',
});
}
// Thrashing
if (report.thrashingSignals.thrashingAssessment === 'severe') {
items.push({
severity: 'danger',
title: 'Significant thrashing detected',
detail: 'Repeated commands and file rework suggest unclear direction',
sectionTitle: 'Friction Signals',
});
} else if (report.thrashingSignals.thrashingAssessment === 'mild') {
items.push({
severity: 'warning',
title: 'Mild thrashing detected',
detail: 'Some repeated commands or file rework occurred',
sectionTitle: 'Friction Signals',
});
}
// Idle time
if (report.idleAnalysis.idleAssessment === 'high_idle') {
items.push({
severity: 'warning',
title: 'High idle time',
detail: `${report.idleAnalysis.idlePct}% of wall-clock time was idle`,
sectionTitle: 'Timeline & Activity',
});
}
// Prompt quality
const promptSev = assessmentSeverity(report.promptQuality.assessment);
if (promptSev === 'danger') {
items.push({
severity: 'danger',
title: 'Prompt quality issues',
detail: `${(report.promptQuality.frictionRate * 100).toFixed(0)}% friction rate \u2014 try more detailed initial prompts`,
sectionTitle: 'Quality Signals',
});
}
// Context pressure
if (
report.overview.contextAssessment === 'critical' ||
report.overview.contextAssessment === 'high'
) {
items.push({
severity: report.overview.contextAssessment === 'critical' ? 'danger' : 'warning',
title: 'Context window pressure',
detail: `${report.overview.compactionCount} compaction${report.overview.compactionCount !== 1 ? 's' : ''} occurred \u2014 session may lose early context`,
sectionTitle: 'Overview',
});
}
// File read redundancy
if (report.fileReadRedundancy.redundancyAssessment === 'wasteful') {
items.push({
severity: 'warning',
title: 'Redundant file reads',
detail: `${report.fileReadRedundancy.readsPerUniqueFile}x reads per unique file`,
sectionTitle: 'Quality Signals',
});
}
// Test regression
if (report.testProgression.trajectory === 'regressing') {
items.push({
severity: 'danger',
title: 'Tests regressing',
detail: 'Test failures increased over the session',
sectionTitle: 'Quality Signals',
});
}
// Sort by severity (danger first), then limit to 4
const severityOrder: Record<Severity, number> = { danger: 0, warning: 1, neutral: 2, good: 3 };
items.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
if (items.length === 0) {
return [
{
severity: 'good',
title: 'Session looks healthy',
detail: 'No significant issues detected across all metrics',
sectionTitle: 'Overview',
},
];
}
return items.slice(0, 4);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,398 @@
import { describe, it, expect } from 'vitest';
import {
assessmentColor,
assessmentExplanation,
assessmentLabel,
assessmentSeverity,
computeCacheEfficiencyAssessment,
computeCacheRatioAssessment,
computeCostPerCommitAssessment,
computeCostPerLineAssessment,
computeIdleAssessment,
computeOverheadAssessment,
computeRedundancyAssessment,
computeSubagentCostShareAssessment,
computeTakeaways,
computeThrashingAssessment,
computeToolHealthAssessment,
detectModelMismatch,
detectSwitchPattern,
severityColor,
THRESHOLDS,
} from '@renderer/utils/reportAssessments';
import type { MetricKey } from '@renderer/utils/reportAssessments';
describe('reportAssessments', () => {
describe('severityColor', () => {
it('maps severity to hex color', () => {
expect(severityColor('good')).toBe('#4ade80');
expect(severityColor('warning')).toBe('#fbbf24');
expect(severityColor('danger')).toBe('#f87171');
expect(severityColor('neutral')).toBe('#a1a1aa');
});
});
describe('assessmentSeverity', () => {
it('maps known assessments to severity', () => {
expect(assessmentSeverity('healthy')).toBe('good');
expect(assessmentSeverity('efficient')).toBe('good');
expect(assessmentSeverity('expensive')).toBe('warning');
expect(assessmentSeverity('red_flag')).toBe('danger');
expect(assessmentSeverity('very_high')).toBe('danger');
expect(assessmentSeverity('degraded')).toBe('warning');
expect(assessmentSeverity('unreliable')).toBe('danger');
expect(assessmentSeverity('high_idle')).toBe('danger');
expect(assessmentSeverity('moderate')).toBe('warning');
});
it('returns neutral for null/undefined/unknown', () => {
expect(assessmentSeverity(null)).toBe('neutral');
expect(assessmentSeverity(undefined)).toBe('neutral');
expect(assessmentSeverity('unknown_value')).toBe('neutral');
});
});
describe('assessmentColor', () => {
it('returns correct color for assessment string', () => {
expect(assessmentColor('healthy')).toBe('#4ade80');
expect(assessmentColor('red_flag')).toBe('#f87171');
expect(assessmentColor(null)).toBe('#a1a1aa');
});
});
describe('assessmentLabel', () => {
it('converts snake_case to Title Case', () => {
expect(assessmentLabel('red_flag')).toBe('Red Flag');
expect(assessmentLabel('well_specified')).toBe('Well Specified');
expect(assessmentLabel('healthy')).toBe('Healthy');
expect(assessmentLabel('high_idle')).toBe('High Idle');
expect(assessmentLabel('opus_plan_mode')).toBe('Opus Plan Mode');
});
});
describe('computeCostPerCommitAssessment', () => {
it('returns efficient below threshold', () => {
expect(computeCostPerCommitAssessment(0.3)).toBe('efficient');
});
it('returns normal in range', () => {
expect(computeCostPerCommitAssessment(1.0)).toBe('normal');
});
it('returns expensive in range', () => {
expect(computeCostPerCommitAssessment(3.0)).toBe('expensive');
});
it('returns red_flag above threshold', () => {
expect(computeCostPerCommitAssessment(10.0)).toBe('red_flag');
});
it('respects threshold boundaries', () => {
expect(computeCostPerCommitAssessment(THRESHOLDS.costPerCommit.efficient - 0.01)).toBe(
'efficient'
);
expect(computeCostPerCommitAssessment(THRESHOLDS.costPerCommit.efficient)).toBe('normal');
});
});
describe('computeCostPerLineAssessment', () => {
it('returns efficient below threshold', () => {
expect(computeCostPerLineAssessment(0.005)).toBe('efficient');
});
it('returns red_flag above threshold', () => {
expect(computeCostPerLineAssessment(0.5)).toBe('red_flag');
});
});
describe('computeSubagentCostShareAssessment', () => {
it('returns normal below 30%', () => {
expect(computeSubagentCostShareAssessment(20)).toBe('normal');
});
it('returns high in range', () => {
expect(computeSubagentCostShareAssessment(45)).toBe('high');
});
it('returns very_high in range', () => {
expect(computeSubagentCostShareAssessment(70)).toBe('very_high');
});
it('returns red_flag above 80%', () => {
expect(computeSubagentCostShareAssessment(90)).toBe('red_flag');
});
});
describe('computeCacheEfficiencyAssessment', () => {
it('returns good above 95%', () => {
expect(computeCacheEfficiencyAssessment(96)).toBe('good');
});
it('returns concerning below 95%', () => {
expect(computeCacheEfficiencyAssessment(90)).toBe('concerning');
});
});
describe('computeCacheRatioAssessment', () => {
it('returns good above 20', () => {
expect(computeCacheRatioAssessment(25)).toBe('good');
});
it('returns concerning below 20', () => {
expect(computeCacheRatioAssessment(10)).toBe('concerning');
});
});
describe('computeToolHealthAssessment', () => {
it('returns healthy above 95%', () => {
expect(computeToolHealthAssessment(98)).toBe('healthy');
});
it('returns degraded between 80-95%', () => {
expect(computeToolHealthAssessment(85)).toBe('degraded');
});
it('returns unreliable below 80%', () => {
expect(computeToolHealthAssessment(70)).toBe('unreliable');
});
it('boundary: 95 is degraded, 95.1 is healthy', () => {
expect(computeToolHealthAssessment(95)).toBe('degraded');
expect(computeToolHealthAssessment(95.1)).toBe('healthy');
});
});
describe('computeIdleAssessment', () => {
it('returns efficient below 20%', () => {
expect(computeIdleAssessment(10)).toBe('efficient');
});
it('returns moderate between 20-50%', () => {
expect(computeIdleAssessment(35)).toBe('moderate');
});
it('returns high_idle above 50%', () => {
expect(computeIdleAssessment(60)).toBe('high_idle');
});
});
describe('computeRedundancyAssessment', () => {
it('returns normal at or below 2.0', () => {
expect(computeRedundancyAssessment(1.5)).toBe('normal');
expect(computeRedundancyAssessment(2.0)).toBe('normal');
});
it('returns wasteful above 2.0', () => {
expect(computeRedundancyAssessment(3.0)).toBe('wasteful');
});
});
describe('computeOverheadAssessment', () => {
it('returns normal at or below 5%', () => {
expect(computeOverheadAssessment(3)).toBe('normal');
expect(computeOverheadAssessment(5)).toBe('normal');
});
it('returns heavy above 5%', () => {
expect(computeOverheadAssessment(10)).toBe('heavy');
});
});
describe('computeThrashingAssessment', () => {
it('returns none for 0 signals', () => {
expect(computeThrashingAssessment(0)).toBe('none');
});
it('returns mild for 1-2 signals', () => {
expect(computeThrashingAssessment(1)).toBe('mild');
expect(computeThrashingAssessment(2)).toBe('mild');
});
it('returns severe for 3+ signals', () => {
expect(computeThrashingAssessment(3)).toBe('severe');
expect(computeThrashingAssessment(5)).toBe('severe');
});
});
describe('detectModelMismatch', () => {
it('returns null for non-opus models', () => {
expect(detectModelMismatch('rename files', 'claude-sonnet-4')).toBeNull();
});
it('detects mechanical tasks on opus', () => {
const result = detectModelMismatch('rename all variables', 'claude-opus-4');
expect(result).not.toBeNull();
expect(result!.expectedComplexity).toBe('mechanical');
});
it('detects read-only tasks on opus', () => {
const result = detectModelMismatch('explore the codebase', 'claude-opus-4');
expect(result).not.toBeNull();
expect(result!.expectedComplexity).toBe('read_only');
});
it('returns null for complex tasks on opus', () => {
expect(detectModelMismatch('implement authentication system', 'claude-opus-4')).toBeNull();
});
it('detects various mechanical keywords', () => {
for (const kw of ['lint', 'format', 'delete', 'move', 'copy', 'replace']) {
expect(detectModelMismatch(`${kw} the code`, 'opus')).not.toBeNull();
}
});
it('detects various read-only keywords', () => {
for (const kw of ['search', 'find', 'verify', 'check', 'scan', 'discover']) {
expect(detectModelMismatch(`${kw} for errors`, 'opus')).not.toBeNull();
}
});
});
describe('detectSwitchPattern', () => {
it('returns null for no switches', () => {
expect(detectSwitchPattern([])).toBeNull();
});
it('returns manual_switch for single switch', () => {
expect(detectSwitchPattern([{ from: 'claude-sonnet-4', to: 'claude-haiku-4' }])).toBe(
'manual_switch'
);
});
it('detects opus_plan_mode pattern', () => {
expect(
detectSwitchPattern([
{ from: 'claude-sonnet-4', to: 'claude-opus-4' },
{ from: 'claude-opus-4', to: 'claude-sonnet-4' },
])
).toBe('opus_plan_mode');
});
it('returns manual_switch for non-plan-mode switches', () => {
expect(
detectSwitchPattern([
{ from: 'claude-sonnet-4', to: 'claude-haiku-4' },
{ from: 'claude-haiku-4', to: 'claude-sonnet-4' },
])
).toBe('manual_switch');
});
});
describe('assessmentExplanation', () => {
const ALL_METRIC_ASSESSMENTS: Record<MetricKey, string[]> = {
costPerCommit: ['efficient', 'normal', 'expensive', 'red_flag'],
costPerLine: ['efficient', 'normal', 'expensive', 'red_flag'],
subagentCostShare: ['normal', 'high', 'very_high', 'red_flag'],
cacheEfficiency: ['good', 'concerning'],
cacheRatio: ['good', 'concerning'],
toolHealth: ['healthy', 'degraded', 'unreliable'],
idle: ['efficient', 'moderate', 'high_idle'],
fileReads: ['normal', 'wasteful'],
startup: ['normal', 'heavy'],
thrashing: ['none', 'mild', 'severe'],
promptQuality: [
'well_specified',
'moderate_friction',
'underspecified',
'verbose_but_unclear',
],
testTrajectory: ['improving', 'stable', 'regressing', 'insufficient_data'],
};
it('returns non-empty string for all valid metric/assessment combos', () => {
for (const [metricKey, assessments] of Object.entries(ALL_METRIC_ASSESSMENTS)) {
for (const assessment of assessments) {
const result = assessmentExplanation(metricKey as MetricKey, assessment);
expect(result, `${metricKey}/${assessment}`).not.toBe('');
}
}
});
it('returns empty string for unknown combinations', () => {
expect(assessmentExplanation('costPerCommit', 'unknown_value')).toBe('');
expect(assessmentExplanation('toolHealth' as MetricKey, 'nonexistent')).toBe('');
});
it('includes threshold values in explanations', () => {
expect(assessmentExplanation('costPerCommit', 'efficient')).toContain(
String(THRESHOLDS.costPerCommit.efficient)
);
expect(assessmentExplanation('toolHealth', 'healthy')).toContain(
String(THRESHOLDS.toolSuccess.healthy)
);
});
});
describe('computeTakeaways', () => {
const healthyReport = {
costAnalysis: {
costPerCommitAssessment: 'efficient',
costPerLineAssessment: 'efficient',
totalSessionCostUsd: 0.5,
},
cacheEconomics: { cacheEfficiencyAssessment: 'good', cacheEfficiencyPct: 97 },
toolUsage: { overallToolHealth: 'healthy' },
thrashingSignals: {
thrashingAssessment: 'none',
bashNearDuplicates: [],
editReworkFiles: [],
},
idleAnalysis: { idleAssessment: 'efficient', idlePct: 10 },
promptQuality: { assessment: 'well_specified', frictionRate: 0.05 },
overview: { contextAssessment: 'healthy', compactionCount: 0 },
fileReadRedundancy: { redundancyAssessment: 'normal', readsPerUniqueFile: 1.5 },
testProgression: { trajectory: 'improving' },
};
it('returns healthy message when all metrics are good', () => {
const result = computeTakeaways(healthyReport);
expect(result).toHaveLength(1);
expect(result[0].severity).toBe('good');
expect(result[0].title).toContain('healthy');
});
it('detects cost red flags', () => {
const report = {
...healthyReport,
costAnalysis: {
...healthyReport.costAnalysis,
costPerCommitAssessment: 'red_flag',
totalSessionCostUsd: 15,
},
};
const result = computeTakeaways(report);
expect(result.some((t) => t.severity === 'danger' && t.title.includes('cost'))).toBe(true);
});
it('detects thrashing', () => {
const report = {
...healthyReport,
thrashingSignals: {
thrashingAssessment: 'severe',
bashNearDuplicates: [{}],
editReworkFiles: [],
},
};
const result = computeTakeaways(report);
expect(result.some((t) => t.title.includes('thrashing'))).toBe(true);
});
it('limits to 4 takeaways', () => {
const report = {
...healthyReport,
costAnalysis: {
...healthyReport.costAnalysis,
costPerCommitAssessment: 'red_flag',
totalSessionCostUsd: 15,
},
cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 },
toolUsage: { overallToolHealth: 'unreliable' },
thrashingSignals: {
thrashingAssessment: 'severe',
bashNearDuplicates: [{}],
editReworkFiles: [],
},
promptQuality: { assessment: 'underspecified', frictionRate: 0.5 },
overview: { contextAssessment: 'critical', compactionCount: 3 },
fileReadRedundancy: { redundancyAssessment: 'wasteful', readsPerUniqueFile: 4 },
testProgression: { trajectory: 'regressing' },
};
const result = computeTakeaways(report);
expect(result.length).toBeLessThanOrEqual(4);
});
it('sorts danger before warning', () => {
const report = {
...healthyReport,
cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 },
toolUsage: { overallToolHealth: 'unreliable' },
};
const result = computeTakeaways(report);
expect(result.length).toBeGreaterThanOrEqual(2);
expect(result[0].severity).toBe('danger');
});
});
});

File diff suppressed because it is too large Load diff