Merge pull request #60 from holstein13/feat/session-analysis-report
feat: session analysis report with assessment badges
This commit is contained in:
commit
6264ec52bf
31 changed files with 6956 additions and 9 deletions
107
docs/plans/2026-02-21-session-analysis-report-design.md
Normal file
107
docs/plans/2026-02-21-session-analysis-report-design.md
Normal 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 |
|
||||
875
docs/plans/2026-02-21-session-analysis-report.md
Normal file
875
docs/plans/2026-02-21-session-analysis-report.md
Normal 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"
|
||||
```
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
78
src/renderer/components/report/AssessmentBadge.tsx
Normal file
78
src/renderer/components/report/AssessmentBadge.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
58
src/renderer/components/report/ReportSection.tsx
Normal file
58
src/renderer/components/report/ReportSection.tsx
Normal 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 };
|
||||
99
src/renderer/components/report/SessionReportTab.tsx
Normal file
99
src/renderer/components/report/SessionReportTab.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
260
src/renderer/components/report/sections/CostSection.tsx
Normal file
260
src/renderer/components/report/sections/CostSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
103
src/renderer/components/report/sections/ErrorSection.tsx
Normal file
103
src/renderer/components/report/sections/ErrorSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
97
src/renderer/components/report/sections/FrictionSection.tsx
Normal file
97
src/renderer/components/report/sections/FrictionSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
72
src/renderer/components/report/sections/GitSection.tsx
Normal file
72
src/renderer/components/report/sections/GitSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
207
src/renderer/components/report/sections/InsightsSection.tsx
Normal file
207
src/renderer/components/report/sections/InsightsSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
64
src/renderer/components/report/sections/OverviewSection.tsx
Normal file
64
src/renderer/components/report/sections/OverviewSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
153
src/renderer/components/report/sections/QualitySection.tsx
Normal file
153
src/renderer/components/report/sections/QualitySection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
88
src/renderer/components/report/sections/SubagentSection.tsx
Normal file
88
src/renderer/components/report/sections/SubagentSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
111
src/renderer/components/report/sections/TimelineSection.tsx
Normal file
111
src/renderer/components/report/sections/TimelineSection.tsx
Normal 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">→</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>
|
||||
);
|
||||
};
|
||||
116
src/renderer/components/report/sections/TokenSection.tsx
Normal file
116
src/renderer/components/report/sections/TokenSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
src/renderer/components/report/sections/ToolSection.tsx
Normal file
77
src/renderer/components/report/sections/ToolSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
391
src/renderer/types/sessionReport.ts
Normal file
391
src/renderer/types/sessionReport.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
555
src/renderer/utils/reportAssessments.ts
Normal file
555
src/renderer/utils/reportAssessments.ts
Normal 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);
|
||||
}
|
||||
1419
src/renderer/utils/sessionAnalyzer.ts
Normal file
1419
src/renderer/utils/sessionAnalyzer.ts
Normal file
File diff suppressed because it is too large
Load diff
398
test/renderer/utils/reportAssessments.test.ts
Normal file
398
test/renderer/utils/reportAssessments.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
1509
test/renderer/utils/sessionAnalyzer.test.ts
Normal file
1509
test/renderer/utils/sessionAnalyzer.test.ts
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue