History

feat(report): address PR feedback — threshold tooltips, cost clarity, progressive disclosure

- Add AssessmentBadge component with hover tooltips explaining thresholds - Add Key Takeaways summary section surfacing top actionable findings - Add cost attribution stacked bar and per-token calculation breakdowns - Add clickable navigation from takeaways and tool errors to detail sections - Add theme-aware assessment colors via CSS variables for light/dark mode - Collapse lower-priority sections by default for progressive disclosure - Replace all hardcoded color hex values with CSS variable references - Fix missing Fragment key in CostSection model table - Add defensive division-by-zero guard in stacked bar calculation

Paul Holstein committed 8 months ago

Commit 4fda90b1 parent 1ad2eca18 files · +920 −175Browse files

Unified · Split

18 files
  1. src/renderer/components/report/AssessmentBadge.tsx +78 −0
  2. src/renderer/components/report/ReportSection.tsx +23 −2
  3. src/renderer/components/report/SessionReportTab.tsx +24 −5
  4. src/renderer/components/report/sections/CostSection.tsx +174 −54
  5. src/renderer/components/report/sections/ErrorSection.tsx +33 −6
  6. src/renderer/components/report/sections/FrictionSection.tsx +18 −14
  7. src/renderer/components/report/sections/GitSection.tsx +5 −4
  8. src/renderer/components/report/sections/InsightsSection.tsx +7 −2
  9. src/renderer/components/report/sections/KeyTakeawaysSection.tsx +55 −0
  10. src/renderer/components/report/sections/QualitySection.tsx +12 −31
  11. src/renderer/components/report/sections/SubagentSection.tsx +3 −2
  12. src/renderer/components/report/sections/TimelineSection.tsx +10 −8
  13. src/renderer/components/report/sections/TokenSection.tsx +17 −22
  14. src/renderer/components/report/sections/ToolSection.tsx +22 −22
  15. src/renderer/index.css +12 −0
  16. src/renderer/utils/reportAssessments.ts +287 −2
  17. src/renderer/utils/sessionAnalyzer.ts +1 −1
  18. test/renderer/utils/reportAssessments.test.ts +139 −0
src/renderer/components/report/AssessmentBadge.tsx+78−0
@@ -0,0 +1,78 @@
1import { useCallback, useEffect, useRef, useState } from 'react';
2import { createPortal } from 'react-dom';
3
4import {
5 assessmentColor,
6 assessmentExplanation,
7 assessmentLabel,
8} from '@renderer/utils/reportAssessments';
9
10import type { MetricKey } from '@renderer/utils/reportAssessments';
11
12interface AssessmentBadgeProps {
13 assessment: string;
14 metricKey?: MetricKey;
15}
16
17export const AssessmentBadge = ({ assessment, metricKey }: AssessmentBadgeProps) => {
18 const color = assessmentColor(assessment);
19 const explanation = metricKey ? assessmentExplanation(metricKey, assessment) : '';
20 const [showTooltip, setShowTooltip] = useState(false);
21 const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 });
22 const badgeRef = useRef<HTMLSpanElement>(null);
23 const enterTimer = useRef<ReturnType<typeof setTimeout>>();
24 const leaveTimer = useRef<ReturnType<typeof setTimeout>>();
25
26 const handleMouseEnter = useCallback(() => {
27 if (!explanation) return;
28 clearTimeout(leaveTimer.current);
29 enterTimer.current = setTimeout(() => {
30 if (badgeRef.current) {
31 const rect = badgeRef.current.getBoundingClientRect();
32 setTooltipPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 });
33 }
34 setShowTooltip(true);
35 }, 200);
36 }, [explanation]);
37
38 const handleMouseLeave = useCallback(() => {
39 clearTimeout(enterTimer.current);
40 leaveTimer.current = setTimeout(() => setShowTooltip(false), 150);
41 }, []);
42
43 useEffect(() => {
44 return () => {
45 clearTimeout(enterTimer.current);
46 clearTimeout(leaveTimer.current);
47 };
48 }, []);
49
50 return (
51 <>
52 <span
53 ref={badgeRef}
54 className="rounded px-2 py-0.5 text-xs font-medium"
55 style={{ backgroundColor: `${color}20`, color }}
56 onMouseEnter={handleMouseEnter}
57 onMouseLeave={handleMouseLeave}
58 >
59 {assessmentLabel(assessment)}
60 </span>
61 {showTooltip &&
62 explanation &&
63 createPortal(
64 <div
65 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"
66 style={{
67 top: tooltipPos.top,
68 left: tooltipPos.left,
69 transform: 'translateX(-50%)',
70 }}
71 >
72 {explanation}
73 </div>,
74 document.body
75 )}
76 </>
77 );
78};
src/renderer/components/report/ReportSection.tsx+23−2
@@ -1,7 +1,10 @@
1import { useState } from 'react';1import { useEffect, useRef, useState } from 'react';
2 2
3import { ChevronDown, ChevronRight } from 'lucide-react';3import { ChevronDown, ChevronRight } from 'lucide-react';
4 4
5const sectionId = (title: string) =>
6 `report-section-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
7
5interface ReportSectionProps {8interface ReportSectionProps {
6 title: string;9 title: string;
7 icon: React.ComponentType<{ className?: string }>;10 icon: React.ComponentType<{ className?: string }>;
@@ -16,9 +19,25 @@ export const ReportSection = ({ · 8 unmodified lines
16 defaultCollapsed = false,19 defaultCollapsed = false,
17}: ReportSectionProps) => {20}: ReportSectionProps) => {
18 const [collapsed, setCollapsed] = useState(defaultCollapsed);21 const [collapsed, setCollapsed] = useState(defaultCollapsed);
22 const ref = useRef<HTMLDivElement>(null);
23
24 useEffect(() => {
25 const el = ref.current;
26 if (!el) return;
27 const handler = () => {
28 setCollapsed(false);
29 el.scrollIntoView({ behavior: 'smooth', block: 'start' });
30 };
31 el.addEventListener('report-section-expand', handler);
32 return () => el.removeEventListener('report-section-expand', handler);
33 }, []);
19 34
20 return (35 return (
21 <div className="rounded-lg border border-border bg-surface-raised">36 <div
37 ref={ref}
38 id={sectionId(title)}
39 className="rounded-lg border border-border bg-surface-raised"
40 >
22 <button41 <button
23 onClick={() => setCollapsed(!collapsed)}42 onClick={() => setCollapsed(!collapsed)}
24 className="flex w-full items-center gap-2 p-4 text-left"43 className="flex w-full items-center gap-2 p-4 text-left"
@@ -35,3 +54,5 @@ export const ReportSection = ({ · 10 unmodified lines
35 </div>54 </div>
36 );55 );
37};56};
57
58export { sectionId };
src/renderer/components/report/SessionReportTab.tsx+24−5
@@ -1,6 +1,7 @@
1import { useMemo } from 'react';1import { useMemo } from 'react';
2 2
3import { useStore } from '@renderer/store';3import { useStore } from '@renderer/store';
4import { computeTakeaways } from '@renderer/utils/reportAssessments';
4import { analyzeSession } from '@renderer/utils/sessionAnalyzer';5import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
5 6
6import { CostSection } from './sections/CostSection';7import { CostSection } from './sections/CostSection';
@@ -8,6 +9,7 @@ import { ErrorSection } from './sections/ErrorSection'; · 1 unmodified line
8import { FrictionSection } from './sections/FrictionSection';9import { FrictionSection } from './sections/FrictionSection';
9import { GitSection } from './sections/GitSection';10import { GitSection } from './sections/GitSection';
10import { InsightsSection } from './sections/InsightsSection';11import { InsightsSection } from './sections/InsightsSection';
12import { KeyTakeawaysSection } from './sections/KeyTakeawaysSection';
11import { OverviewSection } from './sections/OverviewSection';13import { OverviewSection } from './sections/OverviewSection';
12import { QualitySection } from './sections/QualitySection';14import { QualitySection } from './sections/QualitySection';
13import { SubagentSection } from './sections/SubagentSection';15import { SubagentSection } from './sections/SubagentSection';
@@ -34,6 +36,8 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => { · 20 unmodified lines
34 [sessionDetail]36 [sessionDetail]
35 );37 );
36 38
39 const takeaways = useMemo(() => (report ? computeTakeaways(report) : []), [report]);
40
37 if (!report) {41 if (!report) {
38 return (42 return (
39 <div className="flex h-full items-center justify-center text-text-muted">43 <div className="flex h-full items-center justify-center text-text-muted">
@@ -46,24 +50,38 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => { · 6 unmodified lines
46 <div className="h-full overflow-y-auto p-6" style={{ backgroundColor: 'var(--color-surface)' }}>50 <div className="h-full overflow-y-auto p-6" style={{ backgroundColor: 'var(--color-surface)' }}>
47 <h1 className="mb-6 text-lg font-semibold text-text">Session Analysis Report</h1>51 <h1 className="mb-6 text-lg font-semibold text-text">Session Analysis Report</h1>
48 <div className="flex flex-col gap-4">52 <div className="flex flex-col gap-4">
53 {takeaways.length > 0 && <KeyTakeawaysSection takeaways={takeaways} />}
49 <OverviewSection data={report.overview} />54 <OverviewSection data={report.overview} />
50 <CostSection data={report.costAnalysis} />55 <CostSection
56 data={report.costAnalysis}
57 tokensByModel={report.tokenUsage.byModel}
58 commitCount={report.gitActivity.commitCount}
59 linesChanged={report.gitActivity.linesChanged}
60 />
51 <TokenSection data={report.tokenUsage} cacheEconomics={report.cacheEconomics} />61 <TokenSection data={report.tokenUsage} cacheEconomics={report.cacheEconomics} />
52 <ToolSection data={report.toolUsage} />62 <ToolSection data={report.toolUsage} />
53 {report.subagentMetrics.count > 0 && <SubagentSection data={report.subagentMetrics} />}63 {report.subagentMetrics.count > 0 && (
54 {report.errors.errors.length > 0 && <ErrorSection data={report.errors} />}64 <SubagentSection data={report.subagentMetrics} defaultCollapsed />
55 <GitSection data={report.gitActivity} />65 )}
56 <FrictionSection data={report.frictionSignals} thrashing={report.thrashingSignals} />66 {report.errors.errors.length > 0 && <ErrorSection data={report.errors} defaultCollapsed />}
67 <GitSection data={report.gitActivity} defaultCollapsed />
68 <FrictionSection
69 data={report.frictionSignals}
70 thrashing={report.thrashingSignals}
71 defaultCollapsed
72 />
57 <TimelineSection73 <TimelineSection
58 idle={report.idleAnalysis}74 idle={report.idleAnalysis}
59 modelSwitches={report.modelSwitches}75 modelSwitches={report.modelSwitches}
60 keyEvents={report.keyEvents}76 keyEvents={report.keyEvents}
77 defaultCollapsed
61 />78 />
62 <QualitySection79 <QualitySection
63 prompt={report.promptQuality}80 prompt={report.promptQuality}
64 startup={report.startupOverhead}81 startup={report.startupOverhead}
65 testProgression={report.testProgression}82 testProgression={report.testProgression}
66 fileReadRedundancy={report.fileReadRedundancy}83 fileReadRedundancy={report.fileReadRedundancy}
84 defaultCollapsed
67 />85 />
68 <InsightsSection86 <InsightsSection
69 skills={report.skillsInvoked}87 skills={report.skillsInvoked}
@@ -73,6 +91,7 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => { · 3 unmodified lines
73 outOfScope={report.outOfScopeFindings}91 outOfScope={report.outOfScopeFindings}
74 agentTree={report.agentTree}92 agentTree={report.agentTree}
75 subagentsList={report.subagentsList}93 subagentsList={report.subagentsList}
94 defaultCollapsed
76 />95 />
77 </div>96 </div>
78 </div>97 </div>
src/renderer/components/report/sections/CostSection.tsx+174−54
@@ -1,105 +1,225 @@
1import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';1import { Fragment, useState } from 'react';
2
3import { getPricing } from '@renderer/utils/sessionAnalyzer';
2import { DollarSign } from 'lucide-react';4import { DollarSign } from 'lucide-react';
3 5
6import { AssessmentBadge } from '../AssessmentBadge';
4import { ReportSection } from '../ReportSection';7import { ReportSection } from '../ReportSection';
5 8
6import type { ReportCostAnalysis } from '@renderer/types/sessionReport';9import type { ModelTokenStats, ReportCostAnalysis } from '@renderer/types/sessionReport';
10import type { ModelPricing } from '@renderer/types/sessionReport';
7 11
8const fmt = (v: number) => `$${v.toFixed(4)}`;12const fmt = (v: number) => `$${v.toFixed(4)}`;
13const fmtK = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v));
14const fmtRate = (v: number) => `$${v}`;
15const lineCost = (tokens: number, ratePerM: number) => (tokens * ratePerM) / 1_000_000;
9 16
10interface CostSectionProps {17interface CostSectionProps {
11 data: ReportCostAnalysis;18 data: ReportCostAnalysis;
19 tokensByModel: Record<string, ModelTokenStats>;
20 commitCount: number;
21 linesChanged: number;
22 defaultCollapsed?: boolean;
23}
24
25interface BreakdownLine {
26 label: string;
27 tokens: number;
28 ratePerM: number;
12}29}
13 30
14export const CostSection = ({ data }: CostSectionProps) => {31const CostBreakdownCard = ({
32 stats,
33 pricing,
34}: {
35 stats: ModelTokenStats;
36 pricing: ModelPricing;
37}) => {
38 const lines: BreakdownLine[] = [
39 { label: 'Input', tokens: stats.inputTokens, ratePerM: pricing.input },
40 { label: 'Output', tokens: stats.outputTokens, ratePerM: pricing.output },
41 { label: 'Cache Read', tokens: stats.cacheRead, ratePerM: pricing.cache_read },
42 { label: 'Cache Write', tokens: stats.cacheCreation, ratePerM: pricing.cache_creation },
43 ];
44 const total = lines.reduce((sum, l) => sum + lineCost(l.tokens, l.ratePerM), 0);
45
46 return (
47 <div className="rounded-md border border-border bg-surface-raised px-4 py-3">
48 <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-text-muted">
49 Cost Breakdown (per 1M tokens)
50 </div>
51 <div className="flex flex-col gap-1.5 font-mono text-xs">
52 {lines.map((l) => {
53 const cost = lineCost(l.tokens, l.ratePerM);
54 return (
55 <div key={l.label} className="flex items-baseline justify-between gap-4">
56 <span className="text-text-muted">{l.label}</span>
57 <span className="text-text-secondary">
58 {l.tokens.toLocaleString()} {'\u00D7'} {fmtRate(l.ratePerM)}/M = {fmt(cost)}
59 </span>
60 </div>
61 );
62 })}
63 <div className="mt-1 flex items-baseline justify-between gap-4 border-t border-border pt-1.5">
64 <span className="font-medium text-text">Total</span>
65 <span className="font-medium text-text">{fmt(total)}</span>
66 </div>
67 </div>
68 </div>
69 );
70};
71
72export const CostSection = ({
73 data,
74 tokensByModel,
75 commitCount,
76 linesChanged,
77 defaultCollapsed,
78}: CostSectionProps) => {
79 const [expandedModel, setExpandedModel] = useState<string | null>(null);
15 const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]);80 const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]);
81 const showStackedBar = data.subagentCostUsd > 0;
82 const parentPct =
83 showStackedBar && data.totalSessionCostUsd > 0
84 ? (data.parentCostUsd / data.totalSessionCostUsd) * 100
85 : 100;
16 86
17 return (87 return (
18 <ReportSection title="Cost Analysis" icon={DollarSign}>88 <ReportSection title="Cost Analysis" icon={DollarSign} defaultCollapsed={defaultCollapsed}>
19 <div className="mb-4 text-2xl font-bold text-text">{fmt(data.totalSessionCostUsd)}</div>89 <div className="mb-4 text-2xl font-bold text-text">{fmt(data.totalSessionCostUsd)}</div>
20 90
21 <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">91 {/* Parent/Subagent stacked bar */}
22 <div>92 {showStackedBar && (
23 <div className="text-xs text-text-muted">Parent Cost</div>93 <div className="mb-4">
24 <div className="text-sm font-medium text-text">{fmt(data.parentCostUsd)}</div>94 <div className="mb-1.5 flex h-3 w-full overflow-hidden rounded-full">
25 </div>95 <div
26 <div>96 className="h-full"
27 <div className="text-xs text-text-muted">Subagent Cost</div>97 style={{ width: `${parentPct}%`, backgroundColor: '#60a5fa' }}
28 <div className="text-sm font-medium text-text">{fmt(data.subagentCostUsd)}</div>98 />
99 <div
100 className="h-full"
101 style={{ width: `${100 - parentPct}%`, backgroundColor: '#c084fc' }}
102 />
103 </div>
104 <div className="flex gap-4 text-xs">
105 <div className="flex items-center gap-1.5">
106 <span
107 className="inline-block size-2 rounded-full"
108 style={{ backgroundColor: '#60a5fa' }}
109 />
110 <span className="text-text-secondary">Parent: {fmt(data.parentCostUsd)}</span>
111 </div>
112 <div className="flex items-center gap-1.5">
113 <span
114 className="inline-block size-2 rounded-full"
115 style={{ backgroundColor: '#c084fc' }}
116 />
117 <span className="text-text-secondary">Subagent: {fmt(data.subagentCostUsd)}</span>
118 </div>
119 </div>
29 </div>120 </div>
121 )}
122
123 <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
124 {!showStackedBar && (
125 <>
126 <div>
127 <div className="text-xs text-text-muted">Parent Cost</div>
128 <div className="text-sm font-medium text-text">{fmt(data.parentCostUsd)}</div>
129 </div>
130 <div>
131 <div className="text-xs text-text-muted">Subagent Cost</div>
132 <div className="text-sm font-medium text-text">{fmt(data.subagentCostUsd)}</div>
133 </div>
134 </>
135 )}
30 <div>136 <div>
31 <div className="text-xs text-text-muted">Per Commit</div>137 <div className="text-xs text-text-muted">Per Commit</div>
138 <div className="text-[10px] text-text-muted">
139 total cost {'\u00F7'} {commitCount} commit{commitCount !== 1 ? 's' : ''}
140 </div>
32 <div className="flex items-center gap-2">141 <div className="flex items-center gap-2">
33 <span className="text-sm font-medium text-text">142 <span className="text-sm font-medium text-text">
34 {data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'}143 {data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'}
35 </span>144 </span>
36 {data.costPerCommitAssessment && (145 {data.costPerCommitAssessment && (
37 <span146 <AssessmentBadge
38 className="rounded px-2 py-0.5 text-xs font-medium"147 assessment={data.costPerCommitAssessment}
39 style={{148 metricKey="costPerCommit"
40 backgroundColor: `${assessmentColor(data.costPerCommitAssessment)}20`,149 />
41 color: assessmentColor(data.costPerCommitAssessment),
42 }}
43 >
44 {assessmentLabel(data.costPerCommitAssessment)}
45 </span>
46 )}150 )}
47 </div>151 </div>
48 </div>152 </div>
49 <div>153 <div>
50 <div className="text-xs text-text-muted">Per Line Changed</div>154 <div className="text-xs text-text-muted">Per Line Changed</div>
155 <div className="text-[10px] text-text-muted">
156 total cost {'\u00F7'} {linesChanged.toLocaleString()} line
157 {linesChanged !== 1 ? 's' : ''}
158 </div>
51 <div className="flex items-center gap-2">159 <div className="flex items-center gap-2">
52 <span className="text-sm font-medium text-text">160 <span className="text-sm font-medium text-text">
53 {data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'}161 {data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'}
54 </span>162 </span>
55 {data.costPerLineAssessment && (163 {data.costPerLineAssessment && (
56 <span164 <AssessmentBadge assessment={data.costPerLineAssessment} metricKey="costPerLine" />
57 className="rounded px-2 py-0.5 text-xs font-medium"
58 style={{
59 backgroundColor: `${assessmentColor(data.costPerLineAssessment)}20`,
60 color: assessmentColor(data.costPerLineAssessment),
61 }}
62 >
63 {assessmentLabel(data.costPerLineAssessment)}
64 </span>
65 )}165 )}
66 </div>166 </div>
67 </div>167 </div>
68 </div>168 </div>
69 169
70 {data.subagentCostSharePct != null && (
71 <div className="mb-4 flex items-center gap-2">
72 <span className="text-xs text-text-muted">Subagent Cost Share:</span>
73 <span className="text-sm font-medium text-text">{data.subagentCostSharePct}%</span>
74 {data.subagentCostShareAssessment && (
75 <span
76 className="rounded px-2 py-0.5 text-xs font-medium"
77 style={{
78 backgroundColor: `${assessmentColor(data.subagentCostShareAssessment)}20`,
79 color: assessmentColor(data.subagentCostShareAssessment),
80 }}
81 >
82 {assessmentLabel(data.subagentCostShareAssessment)}
83 </span>
84 )}
85 </div>
86 )}
87
88 {modelEntries.length > 0 && (170 {modelEntries.length > 0 && (
89 <table className="w-full text-xs">171 <table className="w-full text-xs">
90 <thead>172 <thead>
91 <tr className="border-b border-border text-left text-text-muted">173 <tr className="border-b border-border text-left text-text-muted">
92 <th className="pb-2 pr-4">Model</th>174 <th className="pb-2 pr-4">Model</th>
175 <th className="pb-2 pr-4 text-right">Input</th>
176 <th className="pb-2 pr-4 text-right">Output</th>
177 <th className="pb-2 pr-4 text-right">Cache Read</th>
178 <th className="pb-2 pr-4 text-right">Cache Write</th>
93 <th className="pb-2 pr-4 text-right">Cost</th>179 <th className="pb-2 pr-4 text-right">Cost</th>
94 </tr>180 </tr>
95 </thead>181 </thead>
96 <tbody>182 <tbody>
97 {modelEntries.map(([model, cost]) => (183 {modelEntries.map(([model, cost]) => {
98 <tr key={model} className="border-border/50 border-b">184 const stats = tokensByModel[model];
99 <td className="py-1.5 pr-4 text-text">{model}</td>185 const isExpanded = expandedModel === model;
100 <td className="py-1.5 pr-4 text-right text-text">{fmt(cost)}</td>186 const pricing = getPricing(model);
101 </tr>187 return (
102 ))}188 <Fragment key={model}>
189 <tr
190 className="border-border/50 hover:bg-surface-raised/50 cursor-pointer border-b"
191 onClick={() => setExpandedModel(isExpanded ? null : model)}
192 >
193 <td className="py-1.5 pr-4 text-text">
194 <span className="mr-1.5 inline-block w-3 text-text-muted">
195 {isExpanded ? '\u25BC' : '\u25B6'}
196 </span>
197 {model}
198 </td>
199 <td className="py-1.5 pr-4 text-right text-text-secondary">
200 {stats ? fmtK(stats.inputTokens) : '—'}
201 </td>
202 <td className="py-1.5 pr-4 text-right text-text-secondary">
203 {stats ? fmtK(stats.outputTokens) : '—'}
204 </td>
205 <td className="py-1.5 pr-4 text-right text-text-secondary">
206 {stats ? fmtK(stats.cacheRead) : '—'}
207 </td>
208 <td className="py-1.5 pr-4 text-right text-text-secondary">
209 {stats ? fmtK(stats.cacheCreation) : '—'}
210 </td>
211 <td className="py-1.5 pr-4 text-right font-medium text-text">{fmt(cost)}</td>
212 </tr>
213 {isExpanded && stats && (
214 <tr>
215 <td colSpan={6} className="px-4 pb-3 pt-1">
216 <CostBreakdownCard stats={stats} pricing={pricing} />
217 </td>
218 </tr>
219 )}
220 </Fragment>
221 );
222 })}
103 </tbody>223 </tbody>
104 </table>224 </table>
105 )}225 )}
src/renderer/components/report/sections/ErrorSection.tsx+33−6
@@ -28,7 +28,10 @@ const ErrorItem = ({ error }: ErrorItemProps) => { · 27 unmodified lines
28 {error.isPermissionDenial && (28 {error.isPermissionDenial && (
29 <span29 <span
30 className="rounded px-1.5 py-0.5 text-[10px] font-medium"30 className="rounded px-1.5 py-0.5 text-[10px] font-medium"
31 style={{ backgroundColor: 'rgba(248, 113, 113, 0.15)', color: '#f87171' }}31 style={{
32 backgroundColor: 'color-mix(in srgb, var(--assess-danger) 15%, transparent)',
33 color: 'var(--assess-danger)',
34 }}
32 >35 >
33 Permission Denied36 Permission Denied
34 </span>37 </span>
@@ -36,8 +39,28 @@ const ErrorItem = ({ error }: ErrorItemProps) => { · 1 unmodified line
36 <span className="ml-auto text-text-muted">msg #{error.messageIndex}</span>39 <span className="ml-auto text-text-muted">msg #{error.messageIndex}</span>
37 </button>40 </button>
38 {expanded && (41 {expanded && (
39 <div className="mt-2 whitespace-pre-wrap break-words rounded bg-surface-raised p-2 text-xs text-text-secondary">42 <div className="mt-2 flex flex-col gap-1.5">
40 {error.error}43 {error.inputPreview && (
44 <div className="rounded bg-surface-raised p-2">
45 <div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-text-muted">
46 Input
47 </div>
48 <div className="whitespace-pre-wrap break-words font-mono text-xs text-text-secondary">
49 {error.inputPreview}
50 </div>
51 </div>
52 )}
53 <div className="rounded bg-surface-raised p-2">
54 <div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-text-muted">
55 Error
56 </div>
57 <div
58 className="whitespace-pre-wrap break-words text-xs"
59 style={{ color: 'var(--assess-danger)' }}
60 >
61 {error.error}
62 </div>
63 </div>
41 </div>64 </div>
42 )}65 )}
43 </div>66 </div>
@@ -46,15 +69,19 @@ const ErrorItem = ({ error }: ErrorItemProps) => { · 2 unmodified lines
46 69
47interface ErrorSectionProps {70interface ErrorSectionProps {
48 data: ReportErrors;71 data: ReportErrors;
72 defaultCollapsed?: boolean;
49}73}
50 74
51export const ErrorSection = ({ data }: ErrorSectionProps) => {75export const ErrorSection = ({ data, defaultCollapsed }: ErrorSectionProps) => {
52 return (76 return (
53 <ReportSection title="Errors" icon={AlertTriangle}>77 <ReportSection title="Errors" icon={AlertTriangle} defaultCollapsed={defaultCollapsed}>
54 <div className="mb-3 flex items-center gap-3">78 <div className="mb-3 flex items-center gap-3">
55 <span79 <span
56 className="rounded px-2 py-0.5 text-xs font-medium"80 className="rounded px-2 py-0.5 text-xs font-medium"
57 style={{ backgroundColor: 'rgba(248, 113, 113, 0.15)', color: '#f87171' }}81 style={{
82 backgroundColor: 'color-mix(in srgb, var(--assess-danger) 15%, transparent)',
83 color: 'var(--assess-danger)',
84 }}
58 >85 >
59 {data.errors.length} error{data.errors.length !== 1 ? 's' : ''}86 {data.errors.length} error{data.errors.length !== 1 ? 's' : ''}
60 </span>87 </span>
src/renderer/components/report/sections/FrictionSection.tsx+18−14
@@ -1,6 +1,7 @@
1import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';1import { severityColor } from '@renderer/utils/reportAssessments';
2import { MessageSquareWarning } from 'lucide-react';2import { MessageSquareWarning } from 'lucide-react';
3 3
4import { AssessmentBadge } from '../AssessmentBadge';
4import { ReportSection } from '../ReportSection';5import { ReportSection } from '../ReportSection';
5 6
6import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport';7import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport';
@@ -8,20 +9,25 @@ import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/ty · 1 unmodified line
8interface FrictionSectionProps {9interface FrictionSectionProps {
9 data: ReportFrictionSignals;10 data: ReportFrictionSignals;
10 thrashing: ReportThrashingSignals;11 thrashing: ReportThrashingSignals;
12 defaultCollapsed?: boolean;
11}13}
12 14
13export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => {15export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionSectionProps) => {
14 const frictionColor =16 const frictionSeverity =
15 data.frictionRate <= 0.1 ? '#4ade80' : data.frictionRate <= 0.25 ? '#fbbf24' : '#f87171';17 data.frictionRate <= 0.1 ? 'good' : data.frictionRate <= 0.25 ? 'warning' : 'danger';
16 const thrashColor = assessmentColor(thrashing.thrashingAssessment);18 const frictionColor = severityColor(frictionSeverity);
17 19
18 return (20 return (
19 <ReportSection title="Friction Signals" icon={MessageSquareWarning}>21 <ReportSection
22 title="Friction Signals"
23 icon={MessageSquareWarning}
24 defaultCollapsed={defaultCollapsed}
25 >
20 <div className="mb-4 flex items-center gap-3">26 <div className="mb-4 flex items-center gap-3">
21 <span27 <span
22 className="rounded px-2 py-0.5 text-xs font-medium"28 className="rounded px-2 py-0.5 text-xs font-medium"
23 style={{29 style={{
24 backgroundColor: `${frictionColor}20`,30 backgroundColor: `color-mix(in srgb, ${frictionColor} 12%, transparent)`,
25 color: frictionColor,31 color: frictionColor,
26 }}32 }}
27 >33 >
@@ -40,7 +46,10 @@ export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => { · 12 unmodified lines
40 <div key={idx} className="flex items-start gap-2 rounded px-2 py-1 text-xs">46 <div key={idx} className="flex items-start gap-2 rounded px-2 py-1 text-xs">
41 <span47 <span
42 className="shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]"48 className="shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]"
43 style={{ backgroundColor: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24' }}49 style={{
50 backgroundColor: 'color-mix(in srgb, var(--assess-warning) 15%, transparent)',
51 color: 'var(--assess-warning)',
52 }}
44 >53 >
45 {corr.keyword}54 {corr.keyword}
46 </span>55 </span>
@@ -55,12 +64,7 @@ export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => { · 8 unmodified lines
55 <div>64 <div>
56 <div className="mb-2 flex items-center gap-2">65 <div className="mb-2 flex items-center gap-2">
57 <span className="text-xs font-medium text-text-muted">Thrashing Signals</span>66 <span className="text-xs font-medium text-text-muted">Thrashing Signals</span>
58 <span67 <AssessmentBadge assessment={thrashing.thrashingAssessment} metricKey="thrashing" />
59 className="rounded px-2 py-0.5 text-xs font-medium"
60 style={{ backgroundColor: `${thrashColor}20`, color: thrashColor }}
61 >
62 {assessmentLabel(thrashing.thrashingAssessment)}
63 </span>
64 </div>68 </div>
65 69
66 {thrashing.bashNearDuplicates.length > 0 && (70 {thrashing.bashNearDuplicates.length > 0 && (
src/renderer/components/report/sections/GitSection.tsx+5−4
@@ -6,11 +6,12 @@ import type { ReportGitActivity } from '@renderer/types/sessionReport'; · 5 unmodified lines
6 6
7interface GitSectionProps {7interface GitSectionProps {
8 data: ReportGitActivity;8 data: ReportGitActivity;
9 defaultCollapsed?: boolean;
9}10}
10 11
11export const GitSection = ({ data }: GitSectionProps) => {12export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => {
12 return (13 return (
13 <ReportSection title="Git Activity" icon={GitBranch}>14 <ReportSection title="Git Activity" icon={GitBranch} defaultCollapsed={defaultCollapsed}>
14 <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">15 <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
15 <div>16 <div>
16 <div className="text-xs text-text-muted">Commits</div>17 <div className="text-xs text-text-muted">Commits</div>
@@ -22,13 +23,13 @@ export const GitSection = ({ data }: GitSectionProps) => { · 5 unmodified lines
22 </div>23 </div>
23 <div>24 <div>
24 <div className="text-xs text-text-muted">Lines Added</div>25 <div className="text-xs text-text-muted">Lines Added</div>
25 <div className="text-sm font-medium" style={{ color: '#4ade80' }}>26 <div className="text-sm font-medium" style={{ color: 'var(--assess-good)' }}>
26 +{data.linesAdded.toLocaleString()}27 +{data.linesAdded.toLocaleString()}
27 </div>28 </div>
28 </div>29 </div>
29 <div>30 <div>
30 <div className="text-xs text-text-muted">Lines Removed</div>31 <div className="text-xs text-text-muted">Lines Removed</div>
31 <div className="text-sm font-medium" style={{ color: '#f87171' }}>32 <div className="text-sm font-medium" style={{ color: 'var(--assess-danger)' }}>
32 -{data.linesRemoved.toLocaleString()}33 -{data.linesRemoved.toLocaleString()}
33 </div>34 </div>
34 </div>35 </div>
src/renderer/components/report/sections/InsightsSection.tsx+7−2
@@ -19,6 +19,7 @@ interface InsightsSectionProps { · 18 unmodified lines
19 outOfScope: OutOfScopeFindings[];19 outOfScope: OutOfScopeFindings[];
20 agentTree: ReportAgentTree;20 agentTree: ReportAgentTree;
21 subagentsList: SubagentBasicEntry[];21 subagentsList: SubagentBasicEntry[];
22 defaultCollapsed?: boolean;
22}23}
23 24
24export const InsightsSection = ({25export const InsightsSection = ({
@@ -29,9 +30,10 @@ export const InsightsSection = ({ · 4 unmodified lines
29 outOfScope,30 outOfScope,
30 agentTree,31 agentTree,
31 subagentsList,32 subagentsList,
33 defaultCollapsed,
32}: InsightsSectionProps) => {34}: InsightsSectionProps) => {
33 return (35 return (
34 <ReportSection title="Session Insights" icon={Lightbulb}>36 <ReportSection title="Session Insights" icon={Lightbulb} defaultCollapsed={defaultCollapsed}>
35 {/* Skills invoked */}37 {/* Skills invoked */}
36 {skills.length > 0 && (38 {skills.length > 0 && (
37 <div className="mb-4">39 <div className="mb-4">
@@ -187,7 +189,10 @@ export const InsightsSection = ({ · 149 unmodified lines
187 <div key={idx} className="rounded-md bg-surface-raised px-3 py-2">189 <div key={idx} className="rounded-md bg-surface-raised px-3 py-2">
188 <span190 <span
189 className="mr-2 rounded px-1.5 py-0.5 text-xs"191 className="mr-2 rounded px-1.5 py-0.5 text-xs"
190 style={{ backgroundColor: '#fbbf2420', color: '#fbbf24' }}192 style={{
193 backgroundColor: 'color-mix(in srgb, var(--assess-warning) 12%, transparent)',
194 color: 'var(--assess-warning)',
195 }}
191 >196 >
192 {f.keyword}197 {f.keyword}
193 </span>198 </span>
src/renderer/components/report/sections/KeyTakeawaysSection.tsx+55−0
@@ -0,0 +1,55 @@
1import { severityColor } from '@renderer/utils/reportAssessments';
2import { AlertTriangle, CheckCircle, ChevronRight, Info, XCircle } from 'lucide-react';
3
4import { sectionId } from '../ReportSection';
5
6import type { Severity, Takeaway } from '@renderer/utils/reportAssessments';
7
8const SEVERITY_ICONS: Record<Severity, React.ComponentType<{ className?: string }>> = {
9 danger: XCircle,
10 warning: AlertTriangle,
11 good: CheckCircle,
12 neutral: Info,
13};
14
15const scrollToSection = (sectionTitle: string) => {
16 const el = document.getElementById(sectionId(sectionTitle));
17 if (!el) return;
18 el.dispatchEvent(new CustomEvent('report-section-expand'));
19};
20
21interface KeyTakeawaysSectionProps {
22 takeaways: Takeaway[];
23}
24
25export const KeyTakeawaysSection = ({ takeaways }: KeyTakeawaysSectionProps) => {
26 return (
27 <div className="rounded-lg border border-border bg-surface-raised p-4">
28 <div className="mb-3 text-sm font-semibold text-text">Key Takeaways</div>
29 <div className="flex flex-col gap-2">
30 {takeaways.map((t, idx) => {
31 const Icon = SEVERITY_ICONS[t.severity];
32 const color = severityColor(t.severity);
33 return (
34 <button
35 key={idx}
36 type="button"
37 onClick={() => scrollToSection(t.sectionTitle)}
38 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"
39 style={{ borderLeftColor: color }}
40 >
41 <span className="mt-0.5 shrink-0" style={{ color }}>
42 <Icon className="size-4" />
43 </span>
44 <div className="min-w-0 flex-1">
45 <div className="text-sm font-medium text-text">{t.title}</div>
46 <div className="text-xs text-text-secondary">{t.detail}</div>
47 </div>
48 <ChevronRight className="mt-0.5 size-4 shrink-0 text-text-muted" />
49 </button>
50 );
51 })}
52 </div>
53 </div>
54 );
55};
src/renderer/components/report/sections/QualitySection.tsx+12−31
@@ -1,6 +1,7 @@
1import { assessmentColor, assessmentLabel, severityColor } from '@renderer/utils/reportAssessments';1import { severityColor } from '@renderer/utils/reportAssessments';
2import { BarChart3 } from 'lucide-react';2import { BarChart3 } from 'lucide-react';
3 3
4import { AssessmentBadge } from '../AssessmentBadge';
4import { ReportSection } from '../ReportSection';5import { ReportSection } from '../ReportSection';
5 6
6import type {7import type {
@@ -15,6 +16,7 @@ interface QualitySectionProps { · 8 unmodified lines
15 startup: ReportStartupOverhead;16 startup: ReportStartupOverhead;
16 testProgression: ReportTestProgression;17 testProgression: ReportTestProgression;
17 fileReadRedundancy: ReportFileReadRedundancy;18 fileReadRedundancy: ReportFileReadRedundancy;
19 defaultCollapsed?: boolean;
18}20}
19 21
20export const QualitySection = ({22export const QualitySection = ({
@@ -22,24 +24,15 @@ export const QualitySection = ({ · 1 unmodified line
22 startup,24 startup,
23 testProgression,25 testProgression,
24 fileReadRedundancy,26 fileReadRedundancy,
27 defaultCollapsed,
25}: QualitySectionProps) => {28}: QualitySectionProps) => {
26 const promptColor = assessmentColor(prompt.assessment);
27 const trajectoryColor = assessmentColor(testProgression.trajectory);
28 const overheadColor = assessmentColor(startup.overheadAssessment);
29 const redundancyColor = assessmentColor(fileReadRedundancy.redundancyAssessment);
30
31 return (29 return (
32 <ReportSection title="Quality Signals" icon={BarChart3}>30 <ReportSection title="Quality Signals" icon={BarChart3} defaultCollapsed={defaultCollapsed}>
33 {/* Prompt quality */}31 {/* Prompt quality */}
34 <div className="mb-4">32 <div className="mb-4">
35 <div className="mb-2 text-xs font-medium text-text-muted">Prompt Quality</div>33 <div className="mb-2 text-xs font-medium text-text-muted">Prompt Quality</div>
36 <div className="mb-2 flex items-center gap-2">34 <div className="mb-2 flex items-center gap-2">
37 <span35 <AssessmentBadge assessment={prompt.assessment} metricKey="promptQuality" />
38 className="rounded px-2 py-0.5 text-xs font-medium"
39 style={{ backgroundColor: `${promptColor}20`, color: promptColor }}
40 >
41 {assessmentLabel(prompt.assessment)}
42 </span>
43 </div>36 </div>
44 <div className="text-xs text-text-secondary">{prompt.note}</div>37 <div className="text-xs text-text-secondary">{prompt.note}</div>
45 <div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4">38 <div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -70,12 +63,7 @@ export const QualitySection = ({ · 24 unmodified lines
70 <div className="mb-4">63 <div className="mb-4">
71 <div className="mb-2 flex items-center gap-2">64 <div className="mb-2 flex items-center gap-2">
72 <span className="text-xs font-medium text-text-muted">Startup Overhead</span>65 <span className="text-xs font-medium text-text-muted">Startup Overhead</span>
73 <span66 <AssessmentBadge assessment={startup.overheadAssessment} metricKey="startup" />
74 className="rounded px-2 py-0.5 text-xs font-medium"
75 style={{ backgroundColor: `${overheadColor}20`, color: overheadColor }}
76 >
77 {assessmentLabel(startup.overheadAssessment)}
78 </span>
79 </div>67 </div>
80 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">68 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
81 <div>69 <div>
@@ -99,12 +87,10 @@ export const QualitySection = ({ · 17 unmodified lines
99 <div className="mb-4">87 <div className="mb-4">
100 <div className="mb-2 flex items-center gap-2">88 <div className="mb-2 flex items-center gap-2">
101 <span className="text-xs font-medium text-text-muted">File Read Redundancy</span>89 <span className="text-xs font-medium text-text-muted">File Read Redundancy</span>
102 <span90 <AssessmentBadge
103 className="rounded px-2 py-0.5 text-xs font-medium"91 assessment={fileReadRedundancy.redundancyAssessment}
104 style={{ backgroundColor: `${redundancyColor}20`, color: redundancyColor }}92 metricKey="fileReads"
105 >93 />
106 {assessmentLabel(fileReadRedundancy.redundancyAssessment)}
107 </span>
108 </div>94 </div>
109 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">95 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
110 <div>96 <div>
@@ -128,12 +114,7 @@ export const QualitySection = ({ · 17 unmodified lines
128 <div>114 <div>
129 <div className="mb-2 text-xs font-medium text-text-muted">Test Progression</div>115 <div className="mb-2 text-xs font-medium text-text-muted">Test Progression</div>
130 <div className="mb-2 flex items-center gap-2">116 <div className="mb-2 flex items-center gap-2">
131 <span117 <AssessmentBadge assessment={testProgression.trajectory} metricKey="testTrajectory" />
132 className="rounded px-2 py-0.5 text-xs font-medium"
133 style={{ backgroundColor: `${trajectoryColor}20`, color: trajectoryColor }}
134 >
135 {assessmentLabel(testProgression.trajectory)}
136 </span>
137 <span className="text-xs text-text-muted">118 <span className="text-xs text-text-muted">
138 {testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''}119 {testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''}
139 </span>120 </span>
src/renderer/components/report/sections/SubagentSection.tsx+3−2
@@ -15,11 +15,12 @@ const fmtDuration = (ms: number) => { · 14 unmodified lines
15 15
16interface SubagentSectionProps {16interface SubagentSectionProps {
17 data: ReportSubagentMetrics;17 data: ReportSubagentMetrics;
18 defaultCollapsed?: boolean;
18}19}
19 20
20export const SubagentSection = ({ data }: SubagentSectionProps) => {21export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps) => {
21 return (22 return (
22 <ReportSection title="Subagents" icon={Users}>23 <ReportSection title="Subagents" icon={Users} defaultCollapsed={defaultCollapsed}>
23 <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">24 <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
24 <div>25 <div>
25 <div className="text-xs text-text-muted">Count</div>26 <div className="text-xs text-text-muted">Count</div>
src/renderer/components/report/sections/TimelineSection.tsx+10−8
@@ -1,6 +1,7 @@
1import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';1import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
2import { Clock } from 'lucide-react';2import { Clock } from 'lucide-react';
3 3
4import { AssessmentBadge } from '../AssessmentBadge';
4import { ReportSection } from '../ReportSection';5import { ReportSection } from '../ReportSection';
5 6
6import type {7import type {
@@ -13,23 +14,24 @@ interface TimelineSectionProps { · 6 unmodified lines
13 idle: ReportIdleAnalysis;14 idle: ReportIdleAnalysis;
14 modelSwitches: ReportModelSwitches;15 modelSwitches: ReportModelSwitches;
15 keyEvents: KeyEvent[];16 keyEvents: KeyEvent[];
17 defaultCollapsed?: boolean;
16}18}
17 19
18export const TimelineSection = ({ idle, modelSwitches, keyEvents }: TimelineSectionProps) => {20export const TimelineSection = ({
21 idle,
22 modelSwitches,
23 keyEvents,
24 defaultCollapsed,
25}: TimelineSectionProps) => {
19 const idleColor = assessmentColor(idle.idleAssessment);26 const idleColor = assessmentColor(idle.idleAssessment);
20 27
21 return (28 return (
22 <ReportSection title="Timeline & Activity" icon={Clock}>29 <ReportSection title="Timeline & Activity" icon={Clock} defaultCollapsed={defaultCollapsed}>
23 {/* Idle stats */}30 {/* Idle stats */}
24 <div className="mb-4">31 <div className="mb-4">
25 <div className="mb-2 flex items-center gap-2">32 <div className="mb-2 flex items-center gap-2">
26 <span className="text-xs font-medium text-text-muted">Idle Analysis</span>33 <span className="text-xs font-medium text-text-muted">Idle Analysis</span>
27 <span34 <AssessmentBadge assessment={idle.idleAssessment} metricKey="idle" />
28 className="rounded px-2 py-0.5 text-xs font-medium"
29 style={{ backgroundColor: `${idleColor}20`, color: idleColor }}
30 >
31 {assessmentLabel(idle.idleAssessment)}
32 </span>
33 </div>35 </div>
34 <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">36 <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
35 <div>37 <div>
src/renderer/components/report/sections/TokenSection.tsx+17−22
@@ -1,6 +1,6 @@
1import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
2import { Coins } from 'lucide-react';1import { Coins } from 'lucide-react';
3 2
3import { AssessmentBadge } from '../AssessmentBadge';
4import { ReportSection } from '../ReportSection';4import { ReportSection } from '../ReportSection';
5 5
6import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport';6import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport';
@@ -11,13 +11,14 @@ const fmtCost = (v: number) => `$${v.toFixed(4)}`; · 4 unmodified lines
11interface TokenSectionProps {11interface TokenSectionProps {
12 data: ReportTokenUsage;12 data: ReportTokenUsage;
13 cacheEconomics: ReportCacheEconomics;13 cacheEconomics: ReportCacheEconomics;
14 defaultCollapsed?: boolean;
14}15}
15 16
16export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => {17export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSectionProps) => {
17 const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd);18 const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd);
18 19
19 return (20 return (
20 <ReportSection title="Token Usage" icon={Coins}>21 <ReportSection title="Token Usage" icon={Coins} defaultCollapsed={defaultCollapsed}>
21 {/* By-model table */}22 {/* By-model table */}
22 <div className="mb-4 overflow-x-auto">23 <div className="mb-4 overflow-x-auto">
23 <table className="w-full text-xs">24 <table className="w-full text-xs">
@@ -71,15 +72,10 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { · 47 unmodified lines
71 {cacheEconomics.cacheEfficiencyPct}%72 {cacheEconomics.cacheEfficiencyPct}%
72 </span>73 </span>
73 {cacheEconomics.cacheEfficiencyAssessment && (74 {cacheEconomics.cacheEfficiencyAssessment && (
74 <span75 <AssessmentBadge
75 className="rounded px-2 py-0.5 text-xs font-medium"76 assessment={cacheEconomics.cacheEfficiencyAssessment}
76 style={{77 metricKey="cacheEfficiency"
77 backgroundColor: `${assessmentColor(cacheEconomics.cacheEfficiencyAssessment)}20`,78 />
78 color: assessmentColor(cacheEconomics.cacheEfficiencyAssessment),
79 }}
80 >
81 {assessmentLabel(cacheEconomics.cacheEfficiencyAssessment)}
82 </span>
83 )}79 )}
84 </div>80 </div>
85 </div>81 </div>
@@ -90,15 +86,10 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { · 4 unmodified lines
90 {cacheEconomics.cacheReadToWriteRatio}x86 {cacheEconomics.cacheReadToWriteRatio}x
91 </span>87 </span>
92 {cacheEconomics.cacheRatioAssessment && (88 {cacheEconomics.cacheRatioAssessment && (
93 <span89 <AssessmentBadge
94 className="rounded px-2 py-0.5 text-xs font-medium"90 assessment={cacheEconomics.cacheRatioAssessment}
95 style={{91 metricKey="cacheRatio"
96 backgroundColor: `${assessmentColor(cacheEconomics.cacheRatioAssessment)}20`,92 />
97 color: assessmentColor(cacheEconomics.cacheRatioAssessment),
98 }}
99 >
100 {assessmentLabel(cacheEconomics.cacheRatioAssessment)}
101 </span>
102 )}93 )}
103 </div>94 </div>
104 </div>95 </div>
@@ -110,7 +101,11 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { · 5 unmodified lines
110 <div className="text-xs text-text-muted">Cold Start</div>101 <div className="text-xs text-text-muted">Cold Start</div>
111 <div102 <div
112 className="text-sm font-medium"103 className="text-sm font-medium"
113 style={{ color: cacheEconomics.coldStartDetected ? '#fbbf24' : '#4ade80' }}104 style={{
105 color: cacheEconomics.coldStartDetected
106 ? 'var(--assess-warning)'
107 : 'var(--assess-good)',
108 }}
114 >109 >
115 {cacheEconomics.coldStartDetected ? 'Yes' : 'No'}110 {cacheEconomics.coldStartDetected ? 'Yes' : 'No'}
116 </div>111 </div>
src/renderer/components/report/sections/ToolSection.tsx+22−22
@@ -1,36 +1,28 @@
1import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';1import { assessmentColor } from '@renderer/utils/reportAssessments';
2import { Wrench } from 'lucide-react';2import { Wrench } from 'lucide-react';
3 3
4import { ReportSection } from '../ReportSection';4import { AssessmentBadge } from '../AssessmentBadge';
5import { ReportSection, sectionId } from '../ReportSection';
5 6
6import type { ReportToolUsage } from '@renderer/types/sessionReport';7import type { ReportToolUsage } from '@renderer/types/sessionReport';
7 8
8interface ToolSectionProps {9interface ToolSectionProps {
9 data: ReportToolUsage;10 data: ReportToolUsage;
11 defaultCollapsed?: boolean;
10}12}
11 13
12export const ToolSection = ({ data }: ToolSectionProps) => {14export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => {
13 const toolEntries = Object.entries(data.successRates).sort(15 const toolEntries = Object.entries(data.successRates).sort(
14 (a, b) => b[1].totalCalls - a[1].totalCalls16 (a, b) => b[1].totalCalls - a[1].totalCalls
15 );17 );
16 18
17 const healthColor = assessmentColor(data.overallToolHealth);
18
19 return (19 return (
20 <ReportSection title="Tool Usage" icon={Wrench}>20 <ReportSection title="Tool Usage" icon={Wrench} defaultCollapsed={defaultCollapsed}>
21 <div className="mb-2 flex items-center gap-2">21 <div className="mb-2 flex items-center gap-2">
22 <span className="text-xs text-text-muted">22 <span className="text-xs text-text-muted">
23 {data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools23 {data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools
24 </span>24 </span>
25 <span25 <AssessmentBadge assessment={data.overallToolHealth} metricKey="toolHealth" />
26 className="rounded px-2 py-0.5 text-xs font-medium"
27 style={{
28 backgroundColor: `${healthColor}20`,
29 color: healthColor,
30 }}
31 >
32 {assessmentLabel(data.overallToolHealth)}
33 </span>
34 </div>26 </div>
35 <div className="overflow-x-auto">27 <div className="overflow-x-auto">
36 <table className="w-full text-xs">28 <table className="w-full text-xs">
@@ -53,18 +45,26 @@ export const ToolSection = ({ data }: ToolSectionProps) => { · 16 unmodified lines
53 {stats.totalCalls.toLocaleString()}45 {stats.totalCalls.toLocaleString()}
54 </td>46 </td>
55 <td className="py-1.5 pr-4 text-right text-text">47 <td className="py-1.5 pr-4 text-right text-text">
56 {stats.errors.toLocaleString()}48 {stats.errors > 0 ? (
49 <button
50 type="button"
51 onClick={() => {
52 const el = document.getElementById(sectionId('Errors'));
53 if (el) el.dispatchEvent(new CustomEvent('report-section-expand'));
54 }}
55 className="text-red-400 underline decoration-red-400/30 underline-offset-2 hover:decoration-red-400"
56 >
57 {stats.errors.toLocaleString()}
58 </button>
59 ) : (
60 stats.errors.toLocaleString()
61 )}
57 </td>62 </td>
58 <td className="py-1.5 pr-4 text-right" style={{ color }}>63 <td className="py-1.5 pr-4 text-right" style={{ color }}>
59 {stats.successRatePct}%64 {stats.successRatePct}%
60 </td>65 </td>
61 <td className="py-1.5 text-right">66 <td className="py-1.5 text-right">
62 <span67 <AssessmentBadge assessment={stats.assessment} metricKey="toolHealth" />
63 className="rounded px-1.5 py-0.5 text-[10px] font-medium"
64 style={{ backgroundColor: `${color}20`, color }}
65 >
66 {assessmentLabel(stats.assessment)}
67 </span>
68 </td>68 </td>
69 </tr>69 </tr>
70 );70 );
src/renderer/index.css+12−0
@@ -181,6 +181,12 @@ · 180 unmodified lines
181 --card-text-lighter: #e4e4e7;181 --card-text-lighter: #e4e4e7;
182 --card-separator: #3f3f46;182 --card-separator: #3f3f46;
183 183
184 /* Assessment severity colors (badges, health indicators) */
185 --assess-good: #4ade80;
186 --assess-warning: #fbbf24;
187 --assess-danger: #f87171;
188 --assess-neutral: #a1a1aa;
189
184 /* Sticky Context button - transparent glass */190 /* Sticky Context button - transparent glass */
185 --context-btn-bg: rgba(255, 255, 255, 0.08);191 --context-btn-bg: rgba(255, 255, 255, 0.08);
186 --context-btn-bg-hover: rgba(255, 255, 255, 0.14);192 --context-btn-bg-hover: rgba(255, 255, 255, 0.14);
@@ -206,6 +212,12 @@ · 19 unmodified lines
206 --color-text-secondary: #4d4b46; /* Warm secondary text */212 --color-text-secondary: #4d4b46; /* Warm secondary text */
207 --color-text-muted: #6d6b65; /* Warm muted text */213 --color-text-muted: #6d6b65; /* Warm muted text */
208 214
215 /* Assessment severity colors - darker for light backgrounds */
216 --assess-good: #16a34a;
217 --assess-warning: #d97706;
218 --assess-danger: #dc2626;
219 --assess-neutral: #57534e;
220
209 /* Scrollbar colors for light mode */221 /* Scrollbar colors for light mode */
210 --scrollbar-thumb: rgba(0, 0, 0, 0.15);222 --scrollbar-thumb: rgba(0, 0, 0, 0.15);
211 --scrollbar-thumb-hover: rgba(0, 0, 0, 0.28);223 --scrollbar-thumb-hover: rgba(0, 0, 0, 0.28);
src/renderer/utils/reportAssessments.ts+287−2
@@ -15,7 +15,14 @@ export type Severity = 'good' | 'warning' | 'danger' | 'neutral'; · 14 unmodified lines
15// Colors15// Colors
16// =============================================================================16// =============================================================================
17 17
18const SEVERITY_COLORS: Record<Severity, string> = {18const SEVERITY_CSS_VAR: Record<Severity, string> = {
19 good: '--assess-good',
20 warning: '--assess-warning',
21 danger: '--assess-danger',
22 neutral: '--assess-neutral',
23};
24
25const SEVERITY_FALLBACKS: Record<Severity, string> = {
19 good: '#4ade80',26 good: '#4ade80',
20 warning: '#fbbf24',27 warning: '#fbbf24',
21 danger: '#f87171',28 danger: '#f87171',
@@ -23,7 +30,11 @@ const SEVERITY_COLORS: Record<Severity, string> = { · 1 unmodified line
23};30};
24 31
25export function severityColor(severity: Severity): string {32export function severityColor(severity: Severity): string {
26 return SEVERITY_COLORS[severity];33 if (typeof document === 'undefined') return SEVERITY_FALLBACKS[severity];
34 const value = getComputedStyle(document.documentElement)
35 .getPropertyValue(SEVERITY_CSS_VAR[severity])
36 .trim();
37 return value || SEVERITY_FALLBACKS[severity];
27}38}
28 39
29// =============================================================================40// =============================================================================
@@ -145,6 +156,92 @@ export const THRESHOLDS = { · 115 unmodified lines
145 },156 },
146} as const;157} as const;
147 158
159// =============================================================================
160// Metric Keys & Explanations
161// =============================================================================
162
163export type MetricKey =
164 | 'costPerCommit'
165 | 'costPerLine'
166 | 'subagentCostShare'
167 | 'cacheEfficiency'
168 | 'cacheRatio'
169 | 'toolHealth'
170 | 'idle'
171 | 'fileReads'
172 | 'startup'
173 | 'thrashing'
174 | 'promptQuality'
175 | 'testTrajectory';
176
177const EXPLANATIONS: Record<string, Record<string, string>> = {
178 costPerCommit: {
179 efficient: `Under $${THRESHOLDS.costPerCommit.efficient}/commit`,
180 normal: `$${THRESHOLDS.costPerCommit.efficient}\u2013$${THRESHOLDS.costPerCommit.normal}/commit`,
181 expensive: `$${THRESHOLDS.costPerCommit.normal}\u2013$${THRESHOLDS.costPerCommit.expensive}/commit`,
182 red_flag: `Over $${THRESHOLDS.costPerCommit.expensive}/commit`,
183 },
184 costPerLine: {
185 efficient: `Under $${THRESHOLDS.costPerLine.efficient}/line`,
186 normal: `$${THRESHOLDS.costPerLine.efficient}\u2013$${THRESHOLDS.costPerLine.normal}/line`,
187 expensive: `$${THRESHOLDS.costPerLine.normal}\u2013$${THRESHOLDS.costPerLine.expensive}/line`,
188 red_flag: `Over $${THRESHOLDS.costPerLine.expensive}/line`,
189 },
190 subagentCostShare: {
191 normal: `Under ${THRESHOLDS.subagentCostShare.normal}% of total cost`,
192 high: `${THRESHOLDS.subagentCostShare.normal}\u2013${THRESHOLDS.subagentCostShare.high}% of total cost`,
193 very_high: `${THRESHOLDS.subagentCostShare.high}\u2013${THRESHOLDS.subagentCostShare.veryHigh}% of total cost`,
194 red_flag: `Over ${THRESHOLDS.subagentCostShare.veryHigh}% of total cost`,
195 },
196 cacheEfficiency: {
197 good: `${THRESHOLDS.cacheEfficiency.good}%+ cache hit rate`,
198 concerning: `Below ${THRESHOLDS.cacheEfficiency.good}% cache hit rate`,
199 },
200 cacheRatio: {
201 good: `${THRESHOLDS.cacheRwRatio.good}x+ read-to-write ratio`,
202 concerning: `Below ${THRESHOLDS.cacheRwRatio.good}x read-to-write ratio`,
203 },
204 toolHealth: {
205 healthy: `Over ${THRESHOLDS.toolSuccess.healthy}% success rate`,
206 degraded: `${THRESHOLDS.toolSuccess.degraded}\u2013${THRESHOLDS.toolSuccess.healthy}% success rate`,
207 unreliable: `Below ${THRESHOLDS.toolSuccess.degraded}% success rate`,
208 },
209 idle: {
210 efficient: `Under ${THRESHOLDS.idle.efficient}% idle time`,
211 moderate: `${THRESHOLDS.idle.efficient}\u2013${THRESHOLDS.idle.moderate}% idle time`,
212 high_idle: `Over ${THRESHOLDS.idle.moderate}% idle time`,
213 },
214 fileReads: {
215 normal: `${THRESHOLDS.fileReadsPerUnique.normal}x or fewer reads per unique file`,
216 wasteful: `Over ${THRESHOLDS.fileReadsPerUnique.normal}x reads per unique file`,
217 },
218 startup: {
219 normal: `${THRESHOLDS.startupOverhead.normal}% or less of tokens before first work`,
220 heavy: `Over ${THRESHOLDS.startupOverhead.normal}% of tokens before first work`,
221 },
222 thrashing: {
223 none: 'No repeated commands or reworked files',
224 mild: '1\u20132 thrashing signals detected',
225 severe: '3+ thrashing signals detected',
226 },
227 promptQuality: {
228 well_specified: 'Clear first message with low friction rate',
229 moderate_friction: 'Some corrections needed mid-session',
230 underspecified: 'Short initial prompt led to many corrections',
231 verbose_but_unclear: 'Long initial prompt but still high friction',
232 },
233 testTrajectory: {
234 improving: 'Test failures decreased over the session',
235 stable: 'Test results stayed roughly the same',
236 regressing: 'Test failures increased over the session',
237 insufficient_data: 'Not enough test runs to determine trend',
238 },
239};
240
241export function assessmentExplanation(metricKey: MetricKey, assessment: string): string {
242 return EXPLANATIONS[metricKey]?.[assessment] ?? '';
243}
244
148// =============================================================================245// =============================================================================
149// Assessment Computers246// Assessment Computers
150// =============================================================================247// =============================================================================
@@ -268,3 +365,191 @@ export function detectSwitchPattern( · 117 unmodified lines
268 365
269 return 'manual_switch';366 return 'manual_switch';
270}367}
368
369// =============================================================================
370// Key Takeaways
371// =============================================================================
372
373export interface Takeaway {
374 severity: Severity;
375 title: string;
376 detail: string;
377 sectionTitle: string;
378}
379
380interface TakeawayReport {
381 costAnalysis: {
382 costPerCommitAssessment: string | null;
383 costPerLineAssessment: string | null;
384 totalSessionCostUsd: number;
385 };
386 cacheEconomics: {
387 cacheEfficiencyAssessment: string | null;
388 cacheEfficiencyPct: number;
389 };
390 toolUsage: {
391 overallToolHealth: string;
392 };
393 thrashingSignals: {
394 thrashingAssessment: string;
395 bashNearDuplicates: unknown[];
396 editReworkFiles: unknown[];
397 };
398 idleAnalysis: {
399 idleAssessment: string;
400 idlePct: number;
401 };
402 promptQuality: {
403 assessment: string;
404 frictionRate: number;
405 };
406 overview: {
407 contextAssessment: string | null;
408 compactionCount: number;
409 };
410 fileReadRedundancy: {
411 redundancyAssessment: string;
412 readsPerUniqueFile: number;
413 };
414 testProgression: {
415 trajectory: string;
416 };
417}
418
419export function computeTakeaways(report: TakeawayReport): Takeaway[] {
420 const items: Takeaway[] = [];
421
422 // Cost red flags
423 const costSev = assessmentSeverity(report.costAnalysis.costPerCommitAssessment);
424 if (costSev === 'danger') {
425 items.push({
426 severity: 'danger',
427 title: 'High cost per commit',
428 detail: `$${report.costAnalysis.totalSessionCostUsd.toFixed(2)} total \u2014 consider smaller, focused sessions`,
429 sectionTitle: 'Cost Analysis',
430 });
431 } else if (costSev === 'warning') {
432 items.push({
433 severity: 'warning',
434 title: 'Elevated cost per commit',
435 detail: 'Cost per commit is above typical range',
436 sectionTitle: 'Cost Analysis',
437 });
438 }
439
440 // Cache efficiency
441 if (report.cacheEconomics.cacheEfficiencyAssessment === 'concerning') {
442 items.push({
443 severity: 'warning',
444 title: 'Low cache efficiency',
445 detail: `${report.cacheEconomics.cacheEfficiencyPct}% cache hit rate \u2014 prompt structure may reduce caching`,
446 sectionTitle: 'Token Usage',
447 });
448 }
449
450 // Tool health
451 const toolSev = assessmentSeverity(report.toolUsage.overallToolHealth);
452 if (toolSev === 'danger') {
453 items.push({
454 severity: 'danger',
455 title: 'Tool reliability issues',
456 detail: 'Multiple tool calls are failing \u2014 check error section for details',
457 sectionTitle: 'Tool Usage',
458 });
459 } else if (toolSev === 'warning') {
460 items.push({
461 severity: 'warning',
462 title: 'Degraded tool health',
463 detail: 'Some tools have elevated failure rates',
464 sectionTitle: 'Tool Usage',
465 });
466 }
467
468 // Thrashing
469 if (report.thrashingSignals.thrashingAssessment === 'severe') {
470 items.push({
471 severity: 'danger',
472 title: 'Significant thrashing detected',
473 detail: 'Repeated commands and file rework suggest unclear direction',
474 sectionTitle: 'Friction Signals',
475 });
476 } else if (report.thrashingSignals.thrashingAssessment === 'mild') {
477 items.push({
478 severity: 'warning',
479 title: 'Mild thrashing detected',
480 detail: 'Some repeated commands or file rework occurred',
481 sectionTitle: 'Friction Signals',
482 });
483 }
484
485 // Idle time
486 if (report.idleAnalysis.idleAssessment === 'high_idle') {
487 items.push({
488 severity: 'warning',
489 title: 'High idle time',
490 detail: `${report.idleAnalysis.idlePct}% of wall-clock time was idle`,
491 sectionTitle: 'Timeline & Activity',
492 });
493 }
494
495 // Prompt quality
496 const promptSev = assessmentSeverity(report.promptQuality.assessment);
497 if (promptSev === 'danger') {
498 items.push({
499 severity: 'danger',
500 title: 'Prompt quality issues',
501 detail: `${(report.promptQuality.frictionRate * 100).toFixed(0)}% friction rate \u2014 try more detailed initial prompts`,
502 sectionTitle: 'Quality Signals',
503 });
504 }
505
506 // Context pressure
507 if (
508 report.overview.contextAssessment === 'critical' ||
509 report.overview.contextAssessment === 'high'
510 ) {
511 items.push({
512 severity: report.overview.contextAssessment === 'critical' ? 'danger' : 'warning',
513 title: 'Context window pressure',
514 detail: `${report.overview.compactionCount} compaction${report.overview.compactionCount !== 1 ? 's' : ''} occurred \u2014 session may lose early context`,
515 sectionTitle: 'Overview',
516 });
517 }
518
519 // File read redundancy
520 if (report.fileReadRedundancy.redundancyAssessment === 'wasteful') {
521 items.push({
522 severity: 'warning',
523 title: 'Redundant file reads',
524 detail: `${report.fileReadRedundancy.readsPerUniqueFile}x reads per unique file`,
525 sectionTitle: 'Quality Signals',
526 });
527 }
528
529 // Test regression
530 if (report.testProgression.trajectory === 'regressing') {
531 items.push({
532 severity: 'danger',
533 title: 'Tests regressing',
534 detail: 'Test failures increased over the session',
535 sectionTitle: 'Quality Signals',
536 });
537 }
538
539 // Sort by severity (danger first), then limit to 4
540 const severityOrder: Record<Severity, number> = { danger: 0, warning: 1, neutral: 2, good: 3 };
541 items.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
542
543 if (items.length === 0) {
544 return [
545 {
546 severity: 'good',
547 title: 'Session looks healthy',
548 detail: 'No significant issues detected across all metrics',
549 sectionTitle: 'Overview',
550 },
551 ];
552 }
553
554 return items.slice(0, 4);
555}
src/renderer/utils/sessionAnalyzer.ts+1−1
@@ -103,7 +103,7 @@ const DEFAULT_PRICING: ModelPricing = { · 102 unmodified lines
103 cache_creation: 3.75,103 cache_creation: 3.75,
104};104};
105 105
106function getPricing(modelName: string): ModelPricing {106export function getPricing(modelName: string): ModelPricing {
107 const name = modelName.toLowerCase();107 const name = modelName.toLowerCase();
108 for (const [key, pricing] of Object.entries(MODEL_PRICING)) {108 for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
109 if (name.includes(key)) return pricing;109 if (name.includes(key)) return pricing;
test/renderer/utils/reportAssessments.test.ts+139−0
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; · 1 unmodified line
2 2
3import {3import {
4 assessmentColor,4 assessmentColor,
5 assessmentExplanation,
5 assessmentLabel,6 assessmentLabel,
6 assessmentSeverity,7 assessmentSeverity,
7 computeCacheEfficiencyAssessment,8 computeCacheEfficiencyAssessment,
@@ -12,6 +13,7 @@ import { · 4 unmodified lines
12 computeOverheadAssessment,13 computeOverheadAssessment,
13 computeRedundancyAssessment,14 computeRedundancyAssessment,
14 computeSubagentCostShareAssessment,15 computeSubagentCostShareAssessment,
16 computeTakeaways,
15 computeThrashingAssessment,17 computeThrashingAssessment,
16 computeToolHealthAssessment,18 computeToolHealthAssessment,
17 detectModelMismatch,19 detectModelMismatch,
@@ -20,6 +22,8 @@ import { · 2 unmodified lines
20 THRESHOLDS,22 THRESHOLDS,
21} from '@renderer/utils/reportAssessments';23} from '@renderer/utils/reportAssessments';
22 24
25import type { MetricKey } from '@renderer/utils/reportAssessments';
26
23describe('reportAssessments', () => {27describe('reportAssessments', () => {
24 describe('severityColor', () => {28 describe('severityColor', () => {
25 it('maps severity to hex color', () => {29 it('maps severity to hex color', () => {
@@ -256,4 +260,139 @@ describe('reportAssessments', () => { · 230 unmodified lines
256 ).toBe('manual_switch');260 ).toBe('manual_switch');
257 });261 });
258 });262 });
263
264 describe('assessmentExplanation', () => {
265 const ALL_METRIC_ASSESSMENTS: Record<MetricKey, string[]> = {
266 costPerCommit: ['efficient', 'normal', 'expensive', 'red_flag'],
267 costPerLine: ['efficient', 'normal', 'expensive', 'red_flag'],
268 subagentCostShare: ['normal', 'high', 'very_high', 'red_flag'],
269 cacheEfficiency: ['good', 'concerning'],
270 cacheRatio: ['good', 'concerning'],
271 toolHealth: ['healthy', 'degraded', 'unreliable'],
272 idle: ['efficient', 'moderate', 'high_idle'],
273 fileReads: ['normal', 'wasteful'],
274 startup: ['normal', 'heavy'],
275 thrashing: ['none', 'mild', 'severe'],
276 promptQuality: [
277 'well_specified',
278 'moderate_friction',
279 'underspecified',
280 'verbose_but_unclear',
281 ],
282 testTrajectory: ['improving', 'stable', 'regressing', 'insufficient_data'],
283 };
284
285 it('returns non-empty string for all valid metric/assessment combos', () => {
286 for (const [metricKey, assessments] of Object.entries(ALL_METRIC_ASSESSMENTS)) {
287 for (const assessment of assessments) {
288 const result = assessmentExplanation(metricKey as MetricKey, assessment);
289 expect(result, `${metricKey}/${assessment}`).not.toBe('');
290 }
291 }
292 });
293
294 it('returns empty string for unknown combinations', () => {
295 expect(assessmentExplanation('costPerCommit', 'unknown_value')).toBe('');
296 expect(assessmentExplanation('toolHealth' as MetricKey, 'nonexistent')).toBe('');
297 });
298
299 it('includes threshold values in explanations', () => {
300 expect(assessmentExplanation('costPerCommit', 'efficient')).toContain(
301 String(THRESHOLDS.costPerCommit.efficient)
302 );
303 expect(assessmentExplanation('toolHealth', 'healthy')).toContain(
304 String(THRESHOLDS.toolSuccess.healthy)
305 );
306 });
307 });
308
309 describe('computeTakeaways', () => {
310 const healthyReport = {
311 costAnalysis: {
312 costPerCommitAssessment: 'efficient',
313 costPerLineAssessment: 'efficient',
314 totalSessionCostUsd: 0.5,
315 },
316 cacheEconomics: { cacheEfficiencyAssessment: 'good', cacheEfficiencyPct: 97 },
317 toolUsage: { overallToolHealth: 'healthy' },
318 thrashingSignals: {
319 thrashingAssessment: 'none',
320 bashNearDuplicates: [],
321 editReworkFiles: [],
322 },
323 idleAnalysis: { idleAssessment: 'efficient', idlePct: 10 },
324 promptQuality: { assessment: 'well_specified', frictionRate: 0.05 },
325 overview: { contextAssessment: 'healthy', compactionCount: 0 },
326 fileReadRedundancy: { redundancyAssessment: 'normal', readsPerUniqueFile: 1.5 },
327 testProgression: { trajectory: 'improving' },
328 };
329
330 it('returns healthy message when all metrics are good', () => {
331 const result = computeTakeaways(healthyReport);
332 expect(result).toHaveLength(1);
333 expect(result[0].severity).toBe('good');
334 expect(result[0].title).toContain('healthy');
335 });
336
337 it('detects cost red flags', () => {
338 const report = {
339 ...healthyReport,
340 costAnalysis: {
341 ...healthyReport.costAnalysis,
342 costPerCommitAssessment: 'red_flag',
343 totalSessionCostUsd: 15,
344 },
345 };
346 const result = computeTakeaways(report);
347 expect(result.some((t) => t.severity === 'danger' && t.title.includes('cost'))).toBe(true);
348 });
349
350 it('detects thrashing', () => {
351 const report = {
352 ...healthyReport,
353 thrashingSignals: {
354 thrashingAssessment: 'severe',
355 bashNearDuplicates: [{}],
356 editReworkFiles: [],
357 },
358 };
359 const result = computeTakeaways(report);
360 expect(result.some((t) => t.title.includes('thrashing'))).toBe(true);
361 });
362
363 it('limits to 4 takeaways', () => {
364 const report = {
365 ...healthyReport,
366 costAnalysis: {
367 ...healthyReport.costAnalysis,
368 costPerCommitAssessment: 'red_flag',
369 totalSessionCostUsd: 15,
370 },
371 cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 },
372 toolUsage: { overallToolHealth: 'unreliable' },
373 thrashingSignals: {
374 thrashingAssessment: 'severe',
375 bashNearDuplicates: [{}],
376 editReworkFiles: [],
377 },
378 promptQuality: { assessment: 'underspecified', frictionRate: 0.5 },
379 overview: { contextAssessment: 'critical', compactionCount: 3 },
380 fileReadRedundancy: { redundancyAssessment: 'wasteful', readsPerUniqueFile: 4 },
381 testProgression: { trajectory: 'regressing' },
382 };
383 const result = computeTakeaways(report);
384 expect(result.length).toBeLessThanOrEqual(4);
385 });
386
387 it('sorts danger before warning', () => {
388 const report = {
389 ...healthyReport,
390 cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 },
391 toolUsage: { overallToolHealth: 'unreliable' },
392 };
393 const result = computeTakeaways(report);
394 expect(result.length).toBeGreaterThanOrEqual(2);
395 expect(result[0].severity).toBe('danger');
396 });
397 });
259});398});

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t