From d54e36f6fa079044a444826a209ec84594d74566 Mon Sep 17 00:00:00 2001 From: Paul Holstein <44263169+holstein13@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:53:01 -0500 Subject: [PATCH] feat(report): add session report tab and all section components Co-Authored-By: Claude Opus 4.6 --- .../components/report/ReportSection.tsx | 37 ++++ .../components/report/SessionReportTab.tsx | 69 +++++++ .../report/sections/CostSection.tsx | 63 +++++++ .../report/sections/ErrorSection.tsx | 76 ++++++++ .../report/sections/FrictionSection.tsx | 86 +++++++++ .../components/report/sections/GitSection.tsx | 71 +++++++ .../report/sections/OverviewSection.tsx | 78 ++++++++ .../report/sections/QualitySection.tsx | 174 ++++++++++++++++++ .../report/sections/SubagentSection.tsx | 75 ++++++++ .../report/sections/TimelineSection.tsx | 88 +++++++++ .../report/sections/TokenSection.tsx | 92 +++++++++ .../report/sections/ToolSection.tsx | 63 +++++++ 12 files changed, 972 insertions(+) create mode 100644 src/renderer/components/report/ReportSection.tsx create mode 100644 src/renderer/components/report/SessionReportTab.tsx create mode 100644 src/renderer/components/report/sections/CostSection.tsx create mode 100644 src/renderer/components/report/sections/ErrorSection.tsx create mode 100644 src/renderer/components/report/sections/FrictionSection.tsx create mode 100644 src/renderer/components/report/sections/GitSection.tsx create mode 100644 src/renderer/components/report/sections/OverviewSection.tsx create mode 100644 src/renderer/components/report/sections/QualitySection.tsx create mode 100644 src/renderer/components/report/sections/SubagentSection.tsx create mode 100644 src/renderer/components/report/sections/TimelineSection.tsx create mode 100644 src/renderer/components/report/sections/TokenSection.tsx create mode 100644 src/renderer/components/report/sections/ToolSection.tsx diff --git a/src/renderer/components/report/ReportSection.tsx b/src/renderer/components/report/ReportSection.tsx new file mode 100644 index 00000000..a9bd20e0 --- /dev/null +++ b/src/renderer/components/report/ReportSection.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react'; + +import { ChevronDown, ChevronRight } from 'lucide-react'; + +interface ReportSectionProps { + title: string; + icon: React.ComponentType<{ className?: string }>; + children: React.ReactNode; + defaultCollapsed?: boolean; +} + +export const ReportSection = ({ + title, + icon: Icon, + children, + defaultCollapsed = false, +}: ReportSectionProps) => { + const [collapsed, setCollapsed] = useState(defaultCollapsed); + + return ( +
+ + {!collapsed &&
{children}
} +
+ ); +}; diff --git a/src/renderer/components/report/SessionReportTab.tsx b/src/renderer/components/report/SessionReportTab.tsx new file mode 100644 index 00000000..58958caa --- /dev/null +++ b/src/renderer/components/report/SessionReportTab.tsx @@ -0,0 +1,69 @@ +import { useMemo } from 'react'; + +import { useStore } from '@renderer/store'; +import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; + +import { CostSection } from './sections/CostSection'; +import { ErrorSection } from './sections/ErrorSection'; +import { FrictionSection } from './sections/FrictionSection'; +import { GitSection } from './sections/GitSection'; +import { OverviewSection } from './sections/OverviewSection'; +import { QualitySection } from './sections/QualitySection'; +import { SubagentSection } from './sections/SubagentSection'; +import { TimelineSection } from './sections/TimelineSection'; +import { TokenSection } from './sections/TokenSection'; +import { ToolSection } from './sections/ToolSection'; + +import type { Tab } from '@renderer/types/tabs'; + +interface SessionReportTabProps { + tab: Tab; +} + +export const SessionReportTab = ({ tab }: SessionReportTabProps) => { + // Find session data from any session tab with matching sessionId + const sessionDetail = useStore((s) => { + const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs); + const sourceTab = allTabs.find((t) => t.type === 'session' && t.sessionId === tab.sessionId); + return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null; + }); + + const report = useMemo( + () => (sessionDetail ? analyzeSession(sessionDetail) : null), + [sessionDetail] + ); + + if (!report) { + return ( +
+ No session data available. Open the session tab first. +
+ ); + } + + return ( +
+

Session Analysis Report

+
+ + + + + {report.subagentMetrics.count > 0 && } + {report.errors.errors.length > 0 && } + + + + +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/CostSection.tsx b/src/renderer/components/report/sections/CostSection.tsx new file mode 100644 index 00000000..b3400eab --- /dev/null +++ b/src/renderer/components/report/sections/CostSection.tsx @@ -0,0 +1,63 @@ +import { DollarSign } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportCostAnalysis } from '@renderer/types/sessionReport'; + +const fmt = (v: number) => `$${v.toFixed(4)}`; + +interface CostSectionProps { + data: ReportCostAnalysis; +} + +export const CostSection = ({ data }: CostSectionProps) => { + const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]); + + return ( + +
{fmt(data.totalSessionCostUsd)}
+ +
+
+
Parent Cost
+
{fmt(data.parentCostUsd)}
+
+
+
Subagent Cost
+
{fmt(data.subagentCostUsd)}
+
+
+
Per Commit
+
+ {data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'} +
+
+
+
Per Line Changed
+
+ {data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'} +
+
+
+ + {modelEntries.length > 0 && ( + + + + + + + + + {modelEntries.map(([model, cost]) => ( + + + + + ))} + +
ModelCost
{model}{fmt(cost)}
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/ErrorSection.tsx b/src/renderer/components/report/sections/ErrorSection.tsx new file mode 100644 index 00000000..1cd04850 --- /dev/null +++ b/src/renderer/components/report/sections/ErrorSection.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; + +import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportErrors, ToolError } from '@renderer/types/sessionReport'; + +interface ErrorItemProps { + error: ToolError; +} + +const ErrorItem = ({ error }: ErrorItemProps) => { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded && ( +
+ {error.error} +
+ )} +
+ ); +}; + +interface ErrorSectionProps { + data: ReportErrors; +} + +export const ErrorSection = ({ data }: ErrorSectionProps) => { + return ( + +
+ + {data.errors.length} error{data.errors.length !== 1 ? 's' : ''} + + {data.permissionDenials.count > 0 && ( + + {data.permissionDenials.count} permission denial + {data.permissionDenials.count !== 1 ? 's' : ''} + + )} +
+ +
+ {data.errors.map((error, idx) => ( + + ))} +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/FrictionSection.tsx b/src/renderer/components/report/sections/FrictionSection.tsx new file mode 100644 index 00000000..96ea0032 --- /dev/null +++ b/src/renderer/components/report/sections/FrictionSection.tsx @@ -0,0 +1,86 @@ +import { MessageSquareWarning } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport'; + +const frictionColor = (rate: number): string => { + if (rate <= 0.1) return '#4ade80'; + if (rate <= 0.25) return '#fbbf24'; + return '#f87171'; +}; + +interface FrictionSectionProps { + data: ReportFrictionSignals; + thrashing: ReportThrashingSignals; +} + +export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => { + return ( + +
+ + Friction Rate: {(data.frictionRate * 100).toFixed(1)}% + + + {data.correctionCount} correction{data.correctionCount !== 1 ? 's' : ''} + +
+ + {data.corrections.length > 0 && ( +
+
Corrections
+
+ {data.corrections.map((corr, idx) => ( +
+ + {corr.keyword} + + {corr.preview} +
+ ))} +
+
+ )} + + {(thrashing.bashNearDuplicates.length > 0 || thrashing.editReworkFiles.length > 0) && ( +
+
Thrashing Signals
+ + {thrashing.bashNearDuplicates.length > 0 && ( +
+
Repeated Bash Commands
+ {thrashing.bashNearDuplicates.map((dup, idx) => ( +
+ {dup.count}x + {dup.prefix} +
+ ))} +
+ )} + + {thrashing.editReworkFiles.length > 0 && ( +
+
Reworked Files (3+ edits)
+ {thrashing.editReworkFiles.map((file, idx) => ( +
+ {file.editIndices.length}x + {file.filePath} +
+ ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/GitSection.tsx b/src/renderer/components/report/sections/GitSection.tsx new file mode 100644 index 00000000..d8d9823e --- /dev/null +++ b/src/renderer/components/report/sections/GitSection.tsx @@ -0,0 +1,71 @@ +import { GitBranch } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportGitActivity } from '@renderer/types/sessionReport'; + +interface GitSectionProps { + data: ReportGitActivity; +} + +export const GitSection = ({ data }: GitSectionProps) => { + return ( + +
+
+
Commits
+
{data.commitCount}
+
+
+
Pushes
+
{data.pushCount}
+
+
+
Lines Added
+
+ +{data.linesAdded.toLocaleString()} +
+
+
+
Lines Removed
+
+ -{data.linesRemoved.toLocaleString()} +
+
+
+ + {data.commits.length > 0 && ( +
+
Commits
+
+ {data.commits.map((commit, idx) => ( +
+ #{commit.messageIndex} + {commit.messagePreview} +
+ ))} +
+
+ )} + + {data.branchCreations.length > 0 && ( +
+
Branches Created
+
+ {data.branchCreations.map((branch, idx) => ( + + {branch} + + ))} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/OverviewSection.tsx b/src/renderer/components/report/sections/OverviewSection.tsx new file mode 100644 index 00000000..e4027405 --- /dev/null +++ b/src/renderer/components/report/sections/OverviewSection.tsx @@ -0,0 +1,78 @@ +import { Activity } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportOverview } from '@renderer/types/sessionReport'; + +const assessmentColor = (assessment: ReportOverview['contextAssessment']): string => { + switch (assessment) { + case 'healthy': + return '#4ade80'; + case 'moderate': + return '#fbbf24'; + case 'high': + return '#f87171'; + case 'critical': + return '#f87171'; + default: + return '#a1a1aa'; + } +}; + +interface OverviewSectionProps { + data: ReportOverview; +} + +export const OverviewSection = ({ data }: OverviewSectionProps) => { + return ( + +
{data.firstMessage}
+
+
+
Duration
+
{data.durationHuman}
+
+
+
Messages
+
{data.totalMessages.toLocaleString()}
+
+
+
Context Usage
+
+ {data.contextConsumptionPct != null ? `${data.contextConsumptionPct}%` : 'N/A'} + {data.contextAssessment && ( + ({data.contextAssessment}) + )} +
+
+
+
Compactions
+
{data.compactionCount}
+
+
+
Branch
+
{data.gitBranch}
+
+
+
Subagents
+
{data.hasSubagents ? 'Yes' : 'No'}
+
+
+
Project
+
+ {data.projectPath} +
+
+
+
Session ID
+
+ {data.sessionId.slice(0, 12)}... +
+
+
+
+ ); +}; diff --git a/src/renderer/components/report/sections/QualitySection.tsx b/src/renderer/components/report/sections/QualitySection.tsx new file mode 100644 index 00000000..c0dea581 --- /dev/null +++ b/src/renderer/components/report/sections/QualitySection.tsx @@ -0,0 +1,174 @@ +import { BarChart3 } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { + ReportPromptQuality, + ReportStartupOverhead, + ReportTestProgression, +} from '@renderer/types/sessionReport'; + +const assessmentColor = (assessment: ReportPromptQuality['assessment']): string => { + switch (assessment) { + case 'well_specified': + return '#4ade80'; + case 'moderate_friction': + return '#fbbf24'; + case 'underspecified': + return '#f87171'; + case 'verbose_but_unclear': + return '#f87171'; + default: + return '#a1a1aa'; + } +}; + +const assessmentLabel = (assessment: ReportPromptQuality['assessment']): string => { + switch (assessment) { + case 'well_specified': + return 'Well Specified'; + case 'moderate_friction': + return 'Moderate Friction'; + case 'underspecified': + return 'Underspecified'; + case 'verbose_but_unclear': + return 'Verbose but Unclear'; + default: + return assessment; + } +}; + +const trajectoryColor = (trajectory: ReportTestProgression['trajectory']): string => { + switch (trajectory) { + case 'improving': + return '#4ade80'; + case 'regressing': + return '#f87171'; + case 'stable': + return '#fbbf24'; + default: + return '#a1a1aa'; + } +}; + +interface QualitySectionProps { + prompt: ReportPromptQuality; + startup: ReportStartupOverhead; + testProgression: ReportTestProgression; +} + +export const QualitySection = ({ prompt, startup, testProgression }: QualitySectionProps) => { + return ( + + {/* Prompt quality */} +
+
Prompt Quality
+
+ + {assessmentLabel(prompt.assessment)} + +
+
{prompt.note}
+
+
+
First Message
+
+ {prompt.firstMessageLengthChars.toLocaleString()} chars +
+
+
+
User Messages
+
{prompt.userMessageCount}
+
+
+
Corrections
+
{prompt.correctionCount}
+
+
+
Friction Rate
+
+ {(prompt.frictionRate * 100).toFixed(1)}% +
+
+
+
+ + {/* Startup overhead */} +
+
Startup Overhead
+
+
+
Messages Before Work
+
{startup.messagesBeforeFirstWork}
+
+
+
Tokens Before Work
+
+ {startup.tokensBeforeFirstWork.toLocaleString()} +
+
+
+
% of Total
+
{startup.pctOfTotal}%
+
+
+
+ + {/* Test progression */} +
+
Test Progression
+
+ + {testProgression.trajectory === 'insufficient_data' + ? 'Insufficient Data' + : testProgression.trajectory.charAt(0).toUpperCase() + + testProgression.trajectory.slice(1)} + + + {testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''} + +
+ {testProgression.firstSnapshot && testProgression.lastSnapshot && ( +
+
+
First Run
+
+ + {testProgression.firstSnapshot.passed} passed + + {' / '} + + {testProgression.firstSnapshot.failed} failed + +
+
+
+
Last Run
+
+ + {testProgression.lastSnapshot.passed} passed + + {' / '} + + {testProgression.lastSnapshot.failed} failed + +
+
+
+ )} +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/SubagentSection.tsx b/src/renderer/components/report/sections/SubagentSection.tsx new file mode 100644 index 00000000..bfef657d --- /dev/null +++ b/src/renderer/components/report/sections/SubagentSection.tsx @@ -0,0 +1,75 @@ +import { Users } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportSubagentMetrics } from '@renderer/types/sessionReport'; + +const fmtCost = (v: number) => `$${v.toFixed(4)}`; +const fmtDuration = (ms: number) => { + const s = Math.round(ms / 1000); + const m = Math.floor(s / 60); + const sec = s % 60; + return m > 0 ? `${m}m ${sec}s` : `${sec}s`; +}; + +interface SubagentSectionProps { + data: ReportSubagentMetrics; +} + +export const SubagentSection = ({ data }: SubagentSectionProps) => { + return ( + +
+
+
Count
+
{data.count}
+
+
+
Total Tokens
+
{data.totalTokens.toLocaleString()}
+
+
+
Total Duration
+
{fmtDuration(data.totalDurationMs)}
+
+
+
Total Cost
+
{fmtCost(data.totalCostUsd)}
+
+
+ + {data.byAgent.length > 0 && ( +
+ + + + + + + + + + + + {data.byAgent.map((agent, idx) => ( + + + + + + + + ))} + +
DescriptionTypeTokensDurationCost
+ {agent.description} + {agent.subagentType} + {agent.totalTokens.toLocaleString()} + + {fmtDuration(agent.totalDurationMs)} + {fmtCost(agent.costUsd)}
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/TimelineSection.tsx b/src/renderer/components/report/sections/TimelineSection.tsx new file mode 100644 index 00000000..e01718dc --- /dev/null +++ b/src/renderer/components/report/sections/TimelineSection.tsx @@ -0,0 +1,88 @@ +import { Clock } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { + KeyEvent, + ReportIdleAnalysis, + ReportModelSwitches, +} from '@renderer/types/sessionReport'; + +interface TimelineSectionProps { + idle: ReportIdleAnalysis; + modelSwitches: ReportModelSwitches; + keyEvents: KeyEvent[]; +} + +export const TimelineSection = ({ idle, modelSwitches, keyEvents }: TimelineSectionProps) => { + return ( + + {/* Idle stats */} +
+
Idle Analysis
+
+
+
Idle Gaps
+
{idle.idleGapCount}
+
+
+
Total Idle
+
{idle.totalIdleHuman}
+
+
+
Active Time
+
{idle.activeWorkingHuman}
+
+
+
Idle %
+
50 ? '#fbbf24' : '#4ade80' }} + > + {idle.idlePct}% +
+
+
+
+ + {/* Model switches */} + {modelSwitches.count > 0 && ( +
+
+ Model Switches ({modelSwitches.count}) +
+
+ {modelSwitches.switches.map((sw, idx) => ( +
+ {sw.from} + + {sw.to} + msg #{sw.messageIndex} +
+ ))} +
+
+ )} + + {/* Key events */} + {keyEvents.length > 0 && ( +
+
Key Events
+
+ {keyEvents.map((event, idx) => ( +
+ + {event.timestamp.toLocaleTimeString()} + + {event.label} + {event.deltaHuman && ( + +{event.deltaHuman} + )} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/TokenSection.tsx b/src/renderer/components/report/sections/TokenSection.tsx new file mode 100644 index 00000000..9dd1dd0c --- /dev/null +++ b/src/renderer/components/report/sections/TokenSection.tsx @@ -0,0 +1,92 @@ +import { Coins } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport'; + +const fmt = (v: number) => v.toLocaleString(); +const fmtCost = (v: number) => `$${v.toFixed(4)}`; + +interface TokenSectionProps { + data: ReportTokenUsage; + cacheEconomics: ReportCacheEconomics; +} + +export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { + const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd); + + return ( + + {/* By-model table */} +
+ + + + + + + + + + + + + + {modelEntries.map(([model, stats]) => ( + + + + + + + + + + ))} + {/* Totals row */} + + + + + + + + + + +
ModelAPI CallsInputOutputCache ReadCache CreateCost
{model}{fmt(stats.apiCalls)}{fmt(stats.inputTokens)}{fmt(stats.outputTokens)}{fmt(stats.cacheRead)}{fmt(stats.cacheCreation)}{fmtCost(stats.costUsd)}
Total + {fmt(modelEntries.reduce((s, [, st]) => s + st.apiCalls, 0))} + {fmt(data.totals.inputTokens)}{fmt(data.totals.outputTokens)}{fmt(data.totals.cacheRead)}{fmt(data.totals.cacheCreation)} + {fmtCost(modelEntries.reduce((s, [, st]) => s + st.costUsd, 0))} +
+
+ + {/* Cache economics */} +
+
+
Cache Efficiency
+
{cacheEconomics.cacheEfficiencyPct}%
+
+
+
R/W Ratio
+
+ {cacheEconomics.cacheReadToWriteRatio}x +
+
+
+
Cache Read %
+
{data.totals.cacheReadPct}%
+
+
+
Cold Start
+
+ {cacheEconomics.coldStartDetected ? 'Yes' : 'No'} +
+
+
+
+ ); +}; diff --git a/src/renderer/components/report/sections/ToolSection.tsx b/src/renderer/components/report/sections/ToolSection.tsx new file mode 100644 index 00000000..8f8bce2b --- /dev/null +++ b/src/renderer/components/report/sections/ToolSection.tsx @@ -0,0 +1,63 @@ +import { Wrench } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportToolUsage } from '@renderer/types/sessionReport'; + +interface ToolSectionProps { + data: ReportToolUsage; +} + +export const ToolSection = ({ data }: ToolSectionProps) => { + const toolEntries = Object.entries(data.successRates).sort( + (a, b) => b[1].totalCalls - a[1].totalCalls + ); + + return ( + +
+ {data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools +
+
+ + + + + + + + + + + {toolEntries.map(([tool, stats]) => { + const rateColor = + stats.successRatePct < 80 + ? '#f87171' + : stats.successRatePct < 90 + ? '#fbbf24' + : undefined; + + return ( + + + + + + + ); + })} + +
ToolCallsErrorsSuccess %
{tool} + {stats.totalCalls.toLocaleString()} + + {stats.errors.toLocaleString()} + + {stats.successRatePct}% +
+
+
+ ); +};