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 @@
1+import { useCallback, useEffect, useRef, useState } from 'react';
2+import { createPortal } from 'react-dom';
3+
4+import {
5+ assessmentColor,
6+ assessmentExplanation,
7+ assessmentLabel,
8+} from '@renderer/utils/reportAssessments';
9+
10+import type { MetricKey } from '@renderer/utils/reportAssessments';
11+
12+interface AssessmentBadgeProps {
13+ assessment: string;
14+ metricKey?: MetricKey;
15+}
16+
17+export 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 @@
1−import { useState } from 'react';
1+import { useEffect, useRef, useState } from 'react';
22
33 import { ChevronDown, ChevronRight } from 'lucide-react';
44
5+const sectionId = (title: string) =>
6+ `report-section-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
7+
58 interface ReportSectionProps {
69 title: string;
710 icon: React.ComponentType<{ className?: string }>;
@@ -16,9 +19,25 @@ export const ReportSection = ({ · 8 unmodified lines
1619 defaultCollapsed = false,
1720 }: ReportSectionProps) => {
1821 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+ }, []);
1934
2035 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+ >
2241 <button
2342 onClick={() => setCollapsed(!collapsed)}
2443 className="flex w-full items-center gap-2 p-4 text-left"
@@ -35,3 +54,5 @@ export const ReportSection = ({ · 10 unmodified lines
3554 </div>
3655 );
3756 };
57+
58+export { sectionId };
src/renderer/components/report/SessionReportTab.tsx+24−5
@@ -1,6 +1,7 @@
11 import { useMemo } from 'react';
22
33 import { useStore } from '@renderer/store';
4+import { computeTakeaways } from '@renderer/utils/reportAssessments';
45 import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
56
67 import { CostSection } from './sections/CostSection';
@@ -8,6 +9,7 @@ import { ErrorSection } from './sections/ErrorSection'; · 1 unmodified line
89 import { FrictionSection } from './sections/FrictionSection';
910 import { GitSection } from './sections/GitSection';
1011 import { InsightsSection } from './sections/InsightsSection';
12+import { KeyTakeawaysSection } from './sections/KeyTakeawaysSection';
1113 import { OverviewSection } from './sections/OverviewSection';
1214 import { QualitySection } from './sections/QualitySection';
1315 import { SubagentSection } from './sections/SubagentSection';
@@ -34,6 +36,8 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => { · 20 unmodified lines
3436 [sessionDetail]
3537 );
3638
39+ const takeaways = useMemo(() => (report ? computeTakeaways(report) : []), [report]);
40+
3741 if (!report) {
3842 return (
3943 <div className="flex h-full items-center justify-center text-text-muted">
@@ -46,24 +50,38 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => { · 6 unmodified lines
4650 <div className="h-full overflow-y-auto p-6" style={{ backgroundColor: 'var(--color-surface)' }}>
4751 <h1 className="mb-6 text-lg font-semibold text-text">Session Analysis Report</h1>
4852 <div className="flex flex-col gap-4">
53+ {takeaways.length > 0 && <KeyTakeawaysSection takeaways={takeaways} />}
4954 <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+ />
5161 <TokenSection data={report.tokenUsage} cacheEconomics={report.cacheEconomics} />
5262 <ToolSection data={report.toolUsage} />
53− {report.subagentMetrics.count > 0 && <SubagentSection data={report.subagentMetrics} />}
54− {report.errors.errors.length > 0 && <ErrorSection data={report.errors} />}
55− <GitSection data={report.gitActivity} />
56− <FrictionSection data={report.frictionSignals} thrashing={report.thrashingSignals} />
63+ {report.subagentMetrics.count > 0 && (
64+ <SubagentSection data={report.subagentMetrics} defaultCollapsed />
65+ )}
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+ />
5773 <TimelineSection
5874 idle={report.idleAnalysis}
5975 modelSwitches={report.modelSwitches}
6076 keyEvents={report.keyEvents}
77+ defaultCollapsed
6178 />
6279 <QualitySection
6380 prompt={report.promptQuality}
6481 startup={report.startupOverhead}
6582 testProgression={report.testProgression}
6683 fileReadRedundancy={report.fileReadRedundancy}
84+ defaultCollapsed
6785 />
6886 <InsightsSection
6987 skills={report.skillsInvoked}
@@ -73,6 +91,7 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => { · 3 unmodified lines
7391 outOfScope={report.outOfScopeFindings}
7492 agentTree={report.agentTree}
7593 subagentsList={report.subagentsList}
94+ defaultCollapsed
7695 />
7796 </div>
7897 </div>
src/renderer/components/report/sections/CostSection.tsx+174−54
@@ -1,105 +1,225 @@
1−import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
1+import { Fragment, useState } from 'react';
2+
3+import { getPricing } from '@renderer/utils/sessionAnalyzer';
24 import { DollarSign } from 'lucide-react';
35
6+import { AssessmentBadge } from '../AssessmentBadge';
47 import { ReportSection } from '../ReportSection';
58
6−import type { ReportCostAnalysis } from '@renderer/types/sessionReport';
9+import type { ModelTokenStats, ReportCostAnalysis } from '@renderer/types/sessionReport';
10+import type { ModelPricing } from '@renderer/types/sessionReport';
711
812 const fmt = (v: number) => `$${v.toFixed(4)}`;
13+const fmtK = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v));
14+const fmtRate = (v: number) => `$${v}`;
15+const lineCost = (tokens: number, ratePerM: number) => (tokens * ratePerM) / 1_000_000;
916
1017 interface CostSectionProps {
1118 data: ReportCostAnalysis;
19+ tokensByModel: Record<string, ModelTokenStats>;
20+ commitCount: number;
21+ linesChanged: number;
22+ defaultCollapsed?: boolean;
23+}
24+
25+interface BreakdownLine {
26+ label: string;
27+ tokens: number;
28+ ratePerM: number;
1229 }
1330
14−export const CostSection = ({ data }: CostSectionProps) => {
31+const 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+
72+export const CostSection = ({
73+ data,
74+ tokensByModel,
75+ commitCount,
76+ linesChanged,
77+ defaultCollapsed,
78+}: CostSectionProps) => {
79+ const [expandedModel, setExpandedModel] = useState<string | null>(null);
1580 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;
1686
1787 return (
18− <ReportSection title="Cost Analysis" icon={DollarSign}>
88+ <ReportSection title="Cost Analysis" icon={DollarSign} defaultCollapsed={defaultCollapsed}>
1989 <div className="mb-4 text-2xl font-bold text-text">{fmt(data.totalSessionCostUsd)}</div>
2090
21− <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
22− <div>
23− <div className="text-xs text-text-muted">Parent Cost</div>
24− <div className="text-sm font-medium text-text">{fmt(data.parentCostUsd)}</div>
25− </div>
26− <div>
27− <div className="text-xs text-text-muted">Subagent Cost</div>
28− <div className="text-sm font-medium text-text">{fmt(data.subagentCostUsd)}</div>
91+ {/* Parent/Subagent stacked bar */}
92+ {showStackedBar && (
93+ <div className="mb-4">
94+ <div className="mb-1.5 flex h-3 w-full overflow-hidden rounded-full">
95+ <div
96+ className="h-full"
97+ style={{ width: `${parentPct}%`, backgroundColor: '#60a5fa' }}
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>
29120 </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+ )}
30136 <div>
31137 <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>
32141 <div className="flex items-center gap-2">
33142 <span className="text-sm font-medium text-text">
34143 {data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'}
35144 </span>
36145 {data.costPerCommitAssessment && (
37− <span
38− className="rounded px-2 py-0.5 text-xs font-medium"
39− style={{
40− backgroundColor: `${assessmentColor(data.costPerCommitAssessment)}20`,
41− color: assessmentColor(data.costPerCommitAssessment),
42− }}
43− >
44− {assessmentLabel(data.costPerCommitAssessment)}
45− </span>
146+ <AssessmentBadge
147+ assessment={data.costPerCommitAssessment}
148+ metricKey="costPerCommit"
149+ />
46150 )}
47151 </div>
48152 </div>
49153 <div>
50154 <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>
51159 <div className="flex items-center gap-2">
52160 <span className="text-sm font-medium text-text">
53161 {data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'}
54162 </span>
55163 {data.costPerLineAssessment && (
56− <span
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>
164+ <AssessmentBadge assessment={data.costPerLineAssessment} metricKey="costPerLine" />
65165 )}
66166 </div>
67167 </div>
68168 </div>
69169
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−
88170 {modelEntries.length > 0 && (
89171 <table className="w-full text-xs">
90172 <thead>
91173 <tr className="border-b border-border text-left text-text-muted">
92174 <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>
93179 <th className="pb-2 pr-4 text-right">Cost</th>
94180 </tr>
95181 </thead>
96182 <tbody>
97− {modelEntries.map(([model, cost]) => (
98− <tr key={model} className="border-border/50 border-b">
99− <td className="py-1.5 pr-4 text-text">{model}</td>
100− <td className="py-1.5 pr-4 text-right text-text">{fmt(cost)}</td>
101− </tr>
102− ))}
183+ {modelEntries.map(([model, cost]) => {
184+ const stats = tokensByModel[model];
185+ const isExpanded = expandedModel === model;
186+ const pricing = getPricing(model);
187+ return (
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+ })}
103223 </tbody>
104224 </table>
105225 )}
src/renderer/components/report/sections/ErrorSection.tsx+33−6
@@ -28,7 +28,10 @@ const ErrorItem = ({ error }: ErrorItemProps) => { · 27 unmodified lines
2828 {error.isPermissionDenial && (
2929 <span
3030 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+ }}
3235 >
3336 Permission Denied
3437 </span>
@@ -36,8 +39,28 @@ const ErrorItem = ({ error }: ErrorItemProps) => { · 1 unmodified line
3639 <span className="ml-auto text-text-muted">msg #{error.messageIndex}</span>
3740 </button>
3841 {expanded && (
39− <div className="mt-2 whitespace-pre-wrap break-words rounded bg-surface-raised p-2 text-xs text-text-secondary">
40− {error.error}
42+ <div className="mt-2 flex flex-col gap-1.5">
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>
4164 </div>
4265 )}
4366 </div>
@@ -46,15 +69,19 @@ const ErrorItem = ({ error }: ErrorItemProps) => { · 2 unmodified lines
4669
4770 interface ErrorSectionProps {
4871 data: ReportErrors;
72+ defaultCollapsed?: boolean;
4973 }
5074
51−export const ErrorSection = ({ data }: ErrorSectionProps) => {
75+export const ErrorSection = ({ data, defaultCollapsed }: ErrorSectionProps) => {
5276 return (
53− <ReportSection title="Errors" icon={AlertTriangle}>
77+ <ReportSection title="Errors" icon={AlertTriangle} defaultCollapsed={defaultCollapsed}>
5478 <div className="mb-3 flex items-center gap-3">
5579 <span
5680 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+ }}
5885 >
5986 {data.errors.length} error{data.errors.length !== 1 ? 's' : ''}
6087 </span>
src/renderer/components/report/sections/FrictionSection.tsx+18−14
@@ -1,6 +1,7 @@
1−import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
1+import { severityColor } from '@renderer/utils/reportAssessments';
22 import { MessageSquareWarning } from 'lucide-react';
33
4+import { AssessmentBadge } from '../AssessmentBadge';
45 import { ReportSection } from '../ReportSection';
56
67 import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport';
@@ -8,20 +9,25 @@ import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/ty · 1 unmodified line
89 interface FrictionSectionProps {
910 data: ReportFrictionSignals;
1011 thrashing: ReportThrashingSignals;
12+ defaultCollapsed?: boolean;
1113 }
1214
13−export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => {
14− const frictionColor =
15− data.frictionRate <= 0.1 ? '#4ade80' : data.frictionRate <= 0.25 ? '#fbbf24' : '#f87171';
16− const thrashColor = assessmentColor(thrashing.thrashingAssessment);
15+export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionSectionProps) => {
16+ const frictionSeverity =
17+ data.frictionRate <= 0.1 ? 'good' : data.frictionRate <= 0.25 ? 'warning' : 'danger';
18+ const frictionColor = severityColor(frictionSeverity);
1719
1820 return (
19− <ReportSection title="Friction Signals" icon={MessageSquareWarning}>
21+ <ReportSection
22+ title="Friction Signals"
23+ icon={MessageSquareWarning}
24+ defaultCollapsed={defaultCollapsed}
25+ >
2026 <div className="mb-4 flex items-center gap-3">
2127 <span
2228 className="rounded px-2 py-0.5 text-xs font-medium"
2329 style={{
24− backgroundColor: `${frictionColor}20`,
30+ backgroundColor: `color-mix(in srgb, ${frictionColor} 12%, transparent)`,
2531 color: frictionColor,
2632 }}
2733 >
@@ -40,7 +46,10 @@ export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => { · 12 unmodified lines
4046 <div key={idx} className="flex items-start gap-2 rounded px-2 py-1 text-xs">
4147 <span
4248 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+ }}
4453 >
4554 {corr.keyword}
4655 </span>
@@ -55,12 +64,7 @@ export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => { · 8 unmodified lines
5564 <div>
5665 <div className="mb-2 flex items-center gap-2">
5766 <span className="text-xs font-medium text-text-muted">Thrashing Signals</span>
58− <span
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>
67+ <AssessmentBadge assessment={thrashing.thrashingAssessment} metricKey="thrashing" />
6468 </div>
6569
6670 {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
66
77 interface GitSectionProps {
88 data: ReportGitActivity;
9+ defaultCollapsed?: boolean;
910 }
1011
11−export const GitSection = ({ data }: GitSectionProps) => {
12+export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => {
1213 return (
13− <ReportSection title="Git Activity" icon={GitBranch}>
14+ <ReportSection title="Git Activity" icon={GitBranch} defaultCollapsed={defaultCollapsed}>
1415 <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
1516 <div>
1617 <div className="text-xs text-text-muted">Commits</div>
@@ -22,13 +23,13 @@ export const GitSection = ({ data }: GitSectionProps) => { · 5 unmodified lines
2223 </div>
2324 <div>
2425 <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)' }}>
2627 +{data.linesAdded.toLocaleString()}
2728 </div>
2829 </div>
2930 <div>
3031 <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)' }}>
3233 -{data.linesRemoved.toLocaleString()}
3334 </div>
3435 </div>
src/renderer/components/report/sections/InsightsSection.tsx+7−2
@@ -19,6 +19,7 @@ interface InsightsSectionProps { · 18 unmodified lines
1919 outOfScope: OutOfScopeFindings[];
2020 agentTree: ReportAgentTree;
2121 subagentsList: SubagentBasicEntry[];
22+ defaultCollapsed?: boolean;
2223 }
2324
2425 export const InsightsSection = ({
@@ -29,9 +30,10 @@ export const InsightsSection = ({ · 4 unmodified lines
2930 outOfScope,
3031 agentTree,
3132 subagentsList,
33+ defaultCollapsed,
3234 }: InsightsSectionProps) => {
3335 return (
34− <ReportSection title="Session Insights" icon={Lightbulb}>
36+ <ReportSection title="Session Insights" icon={Lightbulb} defaultCollapsed={defaultCollapsed}>
3537 {/* Skills invoked */}
3638 {skills.length > 0 && (
3739 <div className="mb-4">
@@ -187,7 +189,10 @@ export const InsightsSection = ({ · 149 unmodified lines
187189 <div key={idx} className="rounded-md bg-surface-raised px-3 py-2">
188190 <span
189191 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+ }}
191196 >
192197 {f.keyword}
193198 </span>
src/renderer/components/report/sections/KeyTakeawaysSection.tsx+55−0
@@ -0,0 +1,55 @@
1+import { severityColor } from '@renderer/utils/reportAssessments';
2+import { AlertTriangle, CheckCircle, ChevronRight, Info, XCircle } from 'lucide-react';
3+
4+import { sectionId } from '../ReportSection';
5+
6+import type { Severity, Takeaway } from '@renderer/utils/reportAssessments';
7+
8+const SEVERITY_ICONS: Record<Severity, React.ComponentType<{ className?: string }>> = {
9+ danger: XCircle,
10+ warning: AlertTriangle,
11+ good: CheckCircle,
12+ neutral: Info,
13+};
14+
15+const scrollToSection = (sectionTitle: string) => {
16+ const el = document.getElementById(sectionId(sectionTitle));
17+ if (!el) return;
18+ el.dispatchEvent(new CustomEvent('report-section-expand'));
19+};
20+
21+interface KeyTakeawaysSectionProps {
22+ takeaways: Takeaway[];
23+}
24+
25+export 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 @@
1−import { assessmentColor, assessmentLabel, severityColor } from '@renderer/utils/reportAssessments';
1+import { severityColor } from '@renderer/utils/reportAssessments';
22 import { BarChart3 } from 'lucide-react';
33
4+import { AssessmentBadge } from '../AssessmentBadge';
45 import { ReportSection } from '../ReportSection';
56
67 import type {
@@ -15,6 +16,7 @@ interface QualitySectionProps { · 8 unmodified lines
1516 startup: ReportStartupOverhead;
1617 testProgression: ReportTestProgression;
1718 fileReadRedundancy: ReportFileReadRedundancy;
19+ defaultCollapsed?: boolean;
1820 }
1921
2022 export const QualitySection = ({
@@ -22,24 +24,15 @@ export const QualitySection = ({ · 1 unmodified line
2224 startup,
2325 testProgression,
2426 fileReadRedundancy,
27+ defaultCollapsed,
2528 }: 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−
3129 return (
32− <ReportSection title="Quality Signals" icon={BarChart3}>
30+ <ReportSection title="Quality Signals" icon={BarChart3} defaultCollapsed={defaultCollapsed}>
3331 {/* Prompt quality */}
3432 <div className="mb-4">
3533 <div className="mb-2 text-xs font-medium text-text-muted">Prompt Quality</div>
3634 <div className="mb-2 flex items-center gap-2">
37− <span
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>
35+ <AssessmentBadge assessment={prompt.assessment} metricKey="promptQuality" />
4336 </div>
4437 <div className="text-xs text-text-secondary">{prompt.note}</div>
4538 <div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -70,12 +63,7 @@ export const QualitySection = ({ · 24 unmodified lines
7063 <div className="mb-4">
7164 <div className="mb-2 flex items-center gap-2">
7265 <span className="text-xs font-medium text-text-muted">Startup Overhead</span>
73− <span
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>
66+ <AssessmentBadge assessment={startup.overheadAssessment} metricKey="startup" />
7967 </div>
8068 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
8169 <div>
@@ -99,12 +87,10 @@ export const QualitySection = ({ · 17 unmodified lines
9987 <div className="mb-4">
10088 <div className="mb-2 flex items-center gap-2">
10189 <span className="text-xs font-medium text-text-muted">File Read Redundancy</span>
102− <span
103− className="rounded px-2 py-0.5 text-xs font-medium"
104− style={{ backgroundColor: `${redundancyColor}20`, color: redundancyColor }}
105− >
106− {assessmentLabel(fileReadRedundancy.redundancyAssessment)}
107− </span>
90+ <AssessmentBadge
91+ assessment={fileReadRedundancy.redundancyAssessment}
92+ metricKey="fileReads"
93+ />
10894 </div>
10995 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
11096 <div>
@@ -128,12 +114,7 @@ export const QualitySection = ({ · 17 unmodified lines
128114 <div>
129115 <div className="mb-2 text-xs font-medium text-text-muted">Test Progression</div>
130116 <div className="mb-2 flex items-center gap-2">
131− <span
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>
117+ <AssessmentBadge assessment={testProgression.trajectory} metricKey="testTrajectory" />
137118 <span className="text-xs text-text-muted">
138119 {testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''}
139120 </span>
src/renderer/components/report/sections/SubagentSection.tsx+3−2
@@ -15,11 +15,12 @@ const fmtDuration = (ms: number) => { · 14 unmodified lines
1515
1616 interface SubagentSectionProps {
1717 data: ReportSubagentMetrics;
18+ defaultCollapsed?: boolean;
1819 }
1920
20−export const SubagentSection = ({ data }: SubagentSectionProps) => {
21+export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps) => {
2122 return (
22− <ReportSection title="Subagents" icon={Users}>
23+ <ReportSection title="Subagents" icon={Users} defaultCollapsed={defaultCollapsed}>
2324 <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
2425 <div>
2526 <div className="text-xs text-text-muted">Count</div>
src/renderer/components/report/sections/TimelineSection.tsx+10−8
@@ -1,6 +1,7 @@
11 import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
22 import { Clock } from 'lucide-react';
33
4+import { AssessmentBadge } from '../AssessmentBadge';
45 import { ReportSection } from '../ReportSection';
56
67 import type {
@@ -13,23 +14,24 @@ interface TimelineSectionProps { · 6 unmodified lines
1314 idle: ReportIdleAnalysis;
1415 modelSwitches: ReportModelSwitches;
1516 keyEvents: KeyEvent[];
17+ defaultCollapsed?: boolean;
1618 }
1719
18−export const TimelineSection = ({ idle, modelSwitches, keyEvents }: TimelineSectionProps) => {
20+export const TimelineSection = ({
21+ idle,
22+ modelSwitches,
23+ keyEvents,
24+ defaultCollapsed,
25+}: TimelineSectionProps) => {
1926 const idleColor = assessmentColor(idle.idleAssessment);
2027
2128 return (
22− <ReportSection title="Timeline & Activity" icon={Clock}>
29+ <ReportSection title="Timeline & Activity" icon={Clock} defaultCollapsed={defaultCollapsed}>
2330 {/* Idle stats */}
2431 <div className="mb-4">
2532 <div className="mb-2 flex items-center gap-2">
2633 <span className="text-xs font-medium text-text-muted">Idle Analysis</span>
27− <span
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>
34+ <AssessmentBadge assessment={idle.idleAssessment} metricKey="idle" />
3335 </div>
3436 <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
3537 <div>
src/renderer/components/report/sections/TokenSection.tsx+17−22
@@ -1,6 +1,6 @@
1−import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
21 import { Coins } from 'lucide-react';
32
3+import { AssessmentBadge } from '../AssessmentBadge';
44 import { ReportSection } from '../ReportSection';
55
66 import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport';
@@ -11,13 +11,14 @@ const fmtCost = (v: number) => `$${v.toFixed(4)}`; · 4 unmodified lines
1111 interface TokenSectionProps {
1212 data: ReportTokenUsage;
1313 cacheEconomics: ReportCacheEconomics;
14+ defaultCollapsed?: boolean;
1415 }
1516
16−export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => {
17+export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSectionProps) => {
1718 const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd);
1819
1920 return (
20− <ReportSection title="Token Usage" icon={Coins}>
21+ <ReportSection title="Token Usage" icon={Coins} defaultCollapsed={defaultCollapsed}>
2122 {/* By-model table */}
2223 <div className="mb-4 overflow-x-auto">
2324 <table className="w-full text-xs">
@@ -71,15 +72,10 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { · 47 unmodified lines
7172 {cacheEconomics.cacheEfficiencyPct}%
7273 </span>
7374 {cacheEconomics.cacheEfficiencyAssessment && (
74− <span
75− className="rounded px-2 py-0.5 text-xs font-medium"
76− style={{
77− backgroundColor: `${assessmentColor(cacheEconomics.cacheEfficiencyAssessment)}20`,
78− color: assessmentColor(cacheEconomics.cacheEfficiencyAssessment),
79− }}
80− >
81− {assessmentLabel(cacheEconomics.cacheEfficiencyAssessment)}
82− </span>
75+ <AssessmentBadge
76+ assessment={cacheEconomics.cacheEfficiencyAssessment}
77+ metricKey="cacheEfficiency"
78+ />
8379 )}
8480 </div>
8581 </div>
@@ -90,15 +86,10 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { · 4 unmodified lines
9086 {cacheEconomics.cacheReadToWriteRatio}x
9187 </span>
9288 {cacheEconomics.cacheRatioAssessment && (
93− <span
94− className="rounded px-2 py-0.5 text-xs font-medium"
95− style={{
96− backgroundColor: `${assessmentColor(cacheEconomics.cacheRatioAssessment)}20`,
97− color: assessmentColor(cacheEconomics.cacheRatioAssessment),
98− }}
99− >
100− {assessmentLabel(cacheEconomics.cacheRatioAssessment)}
101− </span>
89+ <AssessmentBadge
90+ assessment={cacheEconomics.cacheRatioAssessment}
91+ metricKey="cacheRatio"
92+ />
10293 )}
10394 </div>
10495 </div>
@@ -110,7 +101,11 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { · 5 unmodified lines
110101 <div className="text-xs text-text-muted">Cold Start</div>
111102 <div
112103 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+ }}
114109 >
115110 {cacheEconomics.coldStartDetected ? 'Yes' : 'No'}
116111 </div>
src/renderer/components/report/sections/ToolSection.tsx+22−22
@@ -1,36 +1,28 @@
1−import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
1+import { assessmentColor } from '@renderer/utils/reportAssessments';
22 import { Wrench } from 'lucide-react';
33
4−import { ReportSection } from '../ReportSection';
4+import { AssessmentBadge } from '../AssessmentBadge';
5+import { ReportSection, sectionId } from '../ReportSection';
56
67 import type { ReportToolUsage } from '@renderer/types/sessionReport';
78
89 interface ToolSectionProps {
910 data: ReportToolUsage;
11+ defaultCollapsed?: boolean;
1012 }
1113
12−export const ToolSection = ({ data }: ToolSectionProps) => {
14+export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => {
1315 const toolEntries = Object.entries(data.successRates).sort(
1416 (a, b) => b[1].totalCalls - a[1].totalCalls
1517 );
1618
17− const healthColor = assessmentColor(data.overallToolHealth);
18−
1919 return (
20− <ReportSection title="Tool Usage" icon={Wrench}>
20+ <ReportSection title="Tool Usage" icon={Wrench} defaultCollapsed={defaultCollapsed}>
2121 <div className="mb-2 flex items-center gap-2">
2222 <span className="text-xs text-text-muted">
2323 {data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools
2424 </span>
25− <span
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>
25+ <AssessmentBadge assessment={data.overallToolHealth} metricKey="toolHealth" />
3426 </div>
3527 <div className="overflow-x-auto">
3628 <table className="w-full text-xs">
@@ -53,18 +45,26 @@ export const ToolSection = ({ data }: ToolSectionProps) => { · 16 unmodified lines
5345 {stats.totalCalls.toLocaleString()}
5446 </td>
5547 <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+ )}
5762 </td>
5863 <td className="py-1.5 pr-4 text-right" style={{ color }}>
5964 {stats.successRatePct}%
6065 </td>
6166 <td className="py-1.5 text-right">
62− <span
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>
67+ <AssessmentBadge assessment={stats.assessment} metricKey="toolHealth" />
6868 </td>
6969 </tr>
7070 );
src/renderer/index.css+12−0
@@ -181,6 +181,12 @@ · 180 unmodified lines
181181 --card-text-lighter: #e4e4e7;
182182 --card-separator: #3f3f46;
183183
184+ /* Assessment severity colors (badges, health indicators) */
185+ --assess-good: #4ade80;
186+ --assess-warning: #fbbf24;
187+ --assess-danger: #f87171;
188+ --assess-neutral: #a1a1aa;
189+
184190 /* Sticky Context button - transparent glass */
185191 --context-btn-bg: rgba(255, 255, 255, 0.08);
186192 --context-btn-bg-hover: rgba(255, 255, 255, 0.14);
@@ -206,6 +212,12 @@ · 19 unmodified lines
206212 --color-text-secondary: #4d4b46; /* Warm secondary text */
207213 --color-text-muted: #6d6b65; /* Warm muted text */
208214
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+
209221 /* Scrollbar colors for light mode */
210222 --scrollbar-thumb: rgba(0, 0, 0, 0.15);
211223 --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
1515 // Colors
1616 // =============================================================================
1717
18−const SEVERITY_COLORS: Record<Severity, string> = {
18+const SEVERITY_CSS_VAR: Record<Severity, string> = {
19+ good: '--assess-good',
20+ warning: '--assess-warning',
21+ danger: '--assess-danger',
22+ neutral: '--assess-neutral',
23+};
24+
25+const SEVERITY_FALLBACKS: Record<Severity, string> = {
1926 good: '#4ade80',
2027 warning: '#fbbf24',
2128 danger: '#f87171',
@@ -23,7 +30,11 @@ const SEVERITY_COLORS: Record<Severity, string> = { · 1 unmodified line
2330 };
2431
2532 export 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];
2738 }
2839
2940 // =============================================================================
@@ -145,6 +156,92 @@ export const THRESHOLDS = { · 115 unmodified lines
145156 },
146157 } as const;
147158
159+// =============================================================================
160+// Metric Keys & Explanations
161+// =============================================================================
162+
163+export 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+
177+const 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+
241+export function assessmentExplanation(metricKey: MetricKey, assessment: string): string {
242+ return EXPLANATIONS[metricKey]?.[assessment] ?? '';
243+}
244+
148245 // =============================================================================
149246 // Assessment Computers
150247 // =============================================================================
@@ -268,3 +365,191 @@ export function detectSwitchPattern( · 117 unmodified lines
268365
269366 return 'manual_switch';
270367 }
368+
369+// =============================================================================
370+// Key Takeaways
371+// =============================================================================
372+
373+export interface Takeaway {
374+ severity: Severity;
375+ title: string;
376+ detail: string;
377+ sectionTitle: string;
378+}
379+
380+interface 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+
419+export 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
103103 cache_creation: 3.75,
104104 };
105105
106−function getPricing(modelName: string): ModelPricing {
106+export function getPricing(modelName: string): ModelPricing {
107107 const name = modelName.toLowerCase();
108108 for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
109109 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
22
33 import {
44 assessmentColor,
5+ assessmentExplanation,
56 assessmentLabel,
67 assessmentSeverity,
78 computeCacheEfficiencyAssessment,
@@ -12,6 +13,7 @@ import { · 4 unmodified lines
1213 computeOverheadAssessment,
1314 computeRedundancyAssessment,
1415 computeSubagentCostShareAssessment,
16+ computeTakeaways,
1517 computeThrashingAssessment,
1618 computeToolHealthAssessment,
1719 detectModelMismatch,
@@ -20,6 +22,8 @@ import { · 2 unmodified lines
2022 THRESHOLDS,
2123 } from '@renderer/utils/reportAssessments';
2224
25+import type { MetricKey } from '@renderer/utils/reportAssessments';
26+
2327 describe('reportAssessments', () => {
2428 describe('severityColor', () => {
2529 it('maps severity to hex color', () => {
@@ -256,4 +260,139 @@ describe('reportAssessments', () => { · 230 unmodified lines
256260 ).toBe('manual_switch');
257261 });
258262 });
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+ });
259398 });

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