- 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
Unified · Split
| @@ -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 | +}; | |
| @@ -1,7 +1,10 @@ | ||
| 1 | −import { useState } from 'react'; | |
| 1 | +import { useEffect, useRef, useState } from 'react'; | |
| 2 | 2 | |
| 3 | 3 | import { ChevronDown, ChevronRight } from 'lucide-react'; |
| 4 | 4 | |
| 5 | +const sectionId = (title: string) => | |
| 6 | + `report-section-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; | |
| 7 | + | |
| 5 | 8 | interface ReportSectionProps { |
| 6 | 9 | title: string; |
| 7 | 10 | icon: React.ComponentType<{ className?: string }>; |
| @@ -16,9 +19,25 @@ export const ReportSection = ({ · 8 unmodified lines | ||
| 16 | 19 | defaultCollapsed = false, |
| 17 | 20 | }: ReportSectionProps) => { |
| 18 | 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 | 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 | 41 | <button |
| 23 | 42 | onClick={() => setCollapsed(!collapsed)} |
| 24 | 43 | className="flex w-full items-center gap-2 p-4 text-left" |
| @@ -35,3 +54,5 @@ export const ReportSection = ({ · 10 unmodified lines | ||
| 35 | 54 | </div> |
| 36 | 55 | ); |
| 37 | 56 | }; |
| 57 | + | |
| 58 | +export { sectionId }; | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { useMemo } from 'react'; |
| 2 | 2 | |
| 3 | 3 | import { useStore } from '@renderer/store'; |
| 4 | +import { computeTakeaways } from '@renderer/utils/reportAssessments'; | |
| 4 | 5 | import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; |
| 5 | 6 | |
| 6 | 7 | import { CostSection } from './sections/CostSection'; |
| @@ -8,6 +9,7 @@ import { ErrorSection } from './sections/ErrorSection'; · 1 unmodified line | ||
| 8 | 9 | import { FrictionSection } from './sections/FrictionSection'; |
| 9 | 10 | import { GitSection } from './sections/GitSection'; |
| 10 | 11 | import { InsightsSection } from './sections/InsightsSection'; |
| 12 | +import { KeyTakeawaysSection } from './sections/KeyTakeawaysSection'; | |
| 11 | 13 | import { OverviewSection } from './sections/OverviewSection'; |
| 12 | 14 | import { QualitySection } from './sections/QualitySection'; |
| 13 | 15 | import { SubagentSection } from './sections/SubagentSection'; |
| @@ -34,6 +36,8 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => { · 20 unmodified lines | ||
| 34 | 36 | [sessionDetail] |
| 35 | 37 | ); |
| 36 | 38 | |
| 39 | + const takeaways = useMemo(() => (report ? computeTakeaways(report) : []), [report]); | |
| 40 | + | |
| 37 | 41 | if (!report) { |
| 38 | 42 | return ( |
| 39 | 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 | 50 | <div className="h-full overflow-y-auto p-6" style={{ backgroundColor: 'var(--color-surface)' }}> |
| 47 | 51 | <h1 className="mb-6 text-lg font-semibold text-text">Session Analysis Report</h1> |
| 48 | 52 | <div className="flex flex-col gap-4"> |
| 53 | + {takeaways.length > 0 && <KeyTakeawaysSection takeaways={takeaways} />} | |
| 49 | 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 | 61 | <TokenSection data={report.tokenUsage} cacheEconomics={report.cacheEconomics} /> |
| 52 | 62 | <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 | + /> | |
| 57 | 73 | <TimelineSection |
| 58 | 74 | idle={report.idleAnalysis} |
| 59 | 75 | modelSwitches={report.modelSwitches} |
| 60 | 76 | keyEvents={report.keyEvents} |
| 77 | + defaultCollapsed | |
| 61 | 78 | /> |
| 62 | 79 | <QualitySection |
| 63 | 80 | prompt={report.promptQuality} |
| 64 | 81 | startup={report.startupOverhead} |
| 65 | 82 | testProgression={report.testProgression} |
| 66 | 83 | fileReadRedundancy={report.fileReadRedundancy} |
| 84 | + defaultCollapsed | |
| 67 | 85 | /> |
| 68 | 86 | <InsightsSection |
| 69 | 87 | skills={report.skillsInvoked} |
| @@ -73,6 +91,7 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => { · 3 unmodified lines | ||
| 73 | 91 | outOfScope={report.outOfScopeFindings} |
| 74 | 92 | agentTree={report.agentTree} |
| 75 | 93 | subagentsList={report.subagentsList} |
| 94 | + defaultCollapsed | |
| 76 | 95 | /> |
| 77 | 96 | </div> |
| 78 | 97 | </div> |
| @@ -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'; | |
| 2 | 4 | import { DollarSign } from 'lucide-react'; |
| 3 | 5 | |
| 6 | +import { AssessmentBadge } from '../AssessmentBadge'; | |
| 4 | 7 | import { ReportSection } from '../ReportSection'; |
| 5 | 8 | |
| 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'; | |
| 7 | 11 | |
| 8 | 12 | 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; | |
| 9 | 16 | |
| 10 | 17 | interface CostSectionProps { |
| 11 | 18 | 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; | |
| 12 | 29 | } |
| 13 | 30 | |
| 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); | |
| 15 | 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 | 87 | return ( |
| 18 | − <ReportSection title="Cost Analysis" icon={DollarSign}> | |
| 88 | + <ReportSection title="Cost Analysis" icon={DollarSign} defaultCollapsed={defaultCollapsed}> | |
| 19 | 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"> | |
| 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> | |
| 29 | 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 | 136 | <div> |
| 31 | 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 | 141 | <div className="flex items-center gap-2"> |
| 33 | 142 | <span className="text-sm font-medium text-text"> |
| 34 | 143 | {data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'} |
| 35 | 144 | </span> |
| 36 | 145 | {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 | + /> | |
| 46 | 150 | )} |
| 47 | 151 | </div> |
| 48 | 152 | </div> |
| 49 | 153 | <div> |
| 50 | 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 | 159 | <div className="flex items-center gap-2"> |
| 52 | 160 | <span className="text-sm font-medium text-text"> |
| 53 | 161 | {data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'} |
| 54 | 162 | </span> |
| 55 | 163 | {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" /> | |
| 65 | 165 | )} |
| 66 | 166 | </div> |
| 67 | 167 | </div> |
| 68 | 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 | 170 | {modelEntries.length > 0 && ( |
| 89 | 171 | <table className="w-full text-xs"> |
| 90 | 172 | <thead> |
| 91 | 173 | <tr className="border-b border-border text-left text-text-muted"> |
| 92 | 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 | 179 | <th className="pb-2 pr-4 text-right">Cost</th> |
| 94 | 180 | </tr> |
| 95 | 181 | </thead> |
| 96 | 182 | <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 | + })} | |
| 103 | 223 | </tbody> |
| 104 | 224 | </table> |
| 105 | 225 | )} |
| @@ -28,7 +28,10 @@ const ErrorItem = ({ error }: ErrorItemProps) => { · 27 unmodified lines | ||
| 28 | 28 | {error.isPermissionDenial && ( |
| 29 | 29 | <span |
| 30 | 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 | 36 | Permission Denied |
| 34 | 37 | </span> |
| @@ -36,8 +39,28 @@ const ErrorItem = ({ error }: ErrorItemProps) => { · 1 unmodified line | ||
| 36 | 39 | <span className="ml-auto text-text-muted">msg #{error.messageIndex}</span> |
| 37 | 40 | </button> |
| 38 | 41 | {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> | |
| 41 | 64 | </div> |
| 42 | 65 | )} |
| 43 | 66 | </div> |
| @@ -46,15 +69,19 @@ const ErrorItem = ({ error }: ErrorItemProps) => { · 2 unmodified lines | ||
| 46 | 69 | |
| 47 | 70 | interface ErrorSectionProps { |
| 48 | 71 | data: ReportErrors; |
| 72 | + defaultCollapsed?: boolean; | |
| 49 | 73 | } |
| 50 | 74 | |
| 51 | −export const ErrorSection = ({ data }: ErrorSectionProps) => { | |
| 75 | +export const ErrorSection = ({ data, defaultCollapsed }: ErrorSectionProps) => { | |
| 52 | 76 | return ( |
| 53 | − <ReportSection title="Errors" icon={AlertTriangle}> | |
| 77 | + <ReportSection title="Errors" icon={AlertTriangle} defaultCollapsed={defaultCollapsed}> | |
| 54 | 78 | <div className="mb-3 flex items-center gap-3"> |
| 55 | 79 | <span |
| 56 | 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 | 86 | {data.errors.length} error{data.errors.length !== 1 ? 's' : ''} |
| 60 | 87 | </span> |
| @@ -1,6 +1,7 @@ | ||
| 1 | −import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments'; | |
| 1 | +import { severityColor } from '@renderer/utils/reportAssessments'; | |
| 2 | 2 | import { MessageSquareWarning } from 'lucide-react'; |
| 3 | 3 | |
| 4 | +import { AssessmentBadge } from '../AssessmentBadge'; | |
| 4 | 5 | import { ReportSection } from '../ReportSection'; |
| 5 | 6 | |
| 6 | 7 | import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport'; |
| @@ -8,20 +9,25 @@ import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/ty · 1 unmodified line | ||
| 8 | 9 | interface FrictionSectionProps { |
| 9 | 10 | data: ReportFrictionSignals; |
| 10 | 11 | thrashing: ReportThrashingSignals; |
| 12 | + defaultCollapsed?: boolean; | |
| 11 | 13 | } |
| 12 | 14 | |
| 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); | |
| 17 | 19 | |
| 18 | 20 | return ( |
| 19 | − <ReportSection title="Friction Signals" icon={MessageSquareWarning}> | |
| 21 | + <ReportSection | |
| 22 | + title="Friction Signals" | |
| 23 | + icon={MessageSquareWarning} | |
| 24 | + defaultCollapsed={defaultCollapsed} | |
| 25 | + > | |
| 20 | 26 | <div className="mb-4 flex items-center gap-3"> |
| 21 | 27 | <span |
| 22 | 28 | className="rounded px-2 py-0.5 text-xs font-medium" |
| 23 | 29 | style={{ |
| 24 | − backgroundColor: `${frictionColor}20`, | |
| 30 | + backgroundColor: `color-mix(in srgb, ${frictionColor} 12%, transparent)`, | |
| 25 | 31 | color: frictionColor, |
| 26 | 32 | }} |
| 27 | 33 | > |
| @@ -40,7 +46,10 @@ export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => { · 12 unmodified lines | ||
| 40 | 46 | <div key={idx} className="flex items-start gap-2 rounded px-2 py-1 text-xs"> |
| 41 | 47 | <span |
| 42 | 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 | 54 | {corr.keyword} |
| 46 | 55 | </span> |
| @@ -55,12 +64,7 @@ export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => { · 8 unmodified lines | ||
| 55 | 64 | <div> |
| 56 | 65 | <div className="mb-2 flex items-center gap-2"> |
| 57 | 66 | <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" /> | |
| 64 | 68 | </div> |
| 65 | 69 | |
| 66 | 70 | {thrashing.bashNearDuplicates.length > 0 && ( |
| @@ -6,11 +6,12 @@ import type { ReportGitActivity } from '@renderer/types/sessionReport'; · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | interface GitSectionProps { |
| 8 | 8 | data: ReportGitActivity; |
| 9 | + defaultCollapsed?: boolean; | |
| 9 | 10 | } |
| 10 | 11 | |
| 11 | −export const GitSection = ({ data }: GitSectionProps) => { | |
| 12 | +export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => { | |
| 12 | 13 | return ( |
| 13 | − <ReportSection title="Git Activity" icon={GitBranch}> | |
| 14 | + <ReportSection title="Git Activity" icon={GitBranch} defaultCollapsed={defaultCollapsed}> | |
| 14 | 15 | <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4"> |
| 15 | 16 | <div> |
| 16 | 17 | <div className="text-xs text-text-muted">Commits</div> |
| @@ -22,13 +23,13 @@ export const GitSection = ({ data }: GitSectionProps) => { · 5 unmodified lines | ||
| 22 | 23 | </div> |
| 23 | 24 | <div> |
| 24 | 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 | 27 | +{data.linesAdded.toLocaleString()} |
| 27 | 28 | </div> |
| 28 | 29 | </div> |
| 29 | 30 | <div> |
| 30 | 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 | 33 | -{data.linesRemoved.toLocaleString()} |
| 33 | 34 | </div> |
| 34 | 35 | </div> |
| @@ -19,6 +19,7 @@ interface InsightsSectionProps { · 18 unmodified lines | ||
| 19 | 19 | outOfScope: OutOfScopeFindings[]; |
| 20 | 20 | agentTree: ReportAgentTree; |
| 21 | 21 | subagentsList: SubagentBasicEntry[]; |
| 22 | + defaultCollapsed?: boolean; | |
| 22 | 23 | } |
| 23 | 24 | |
| 24 | 25 | export const InsightsSection = ({ |
| @@ -29,9 +30,10 @@ export const InsightsSection = ({ · 4 unmodified lines | ||
| 29 | 30 | outOfScope, |
| 30 | 31 | agentTree, |
| 31 | 32 | subagentsList, |
| 33 | + defaultCollapsed, | |
| 32 | 34 | }: InsightsSectionProps) => { |
| 33 | 35 | return ( |
| 34 | − <ReportSection title="Session Insights" icon={Lightbulb}> | |
| 36 | + <ReportSection title="Session Insights" icon={Lightbulb} defaultCollapsed={defaultCollapsed}> | |
| 35 | 37 | {/* Skills invoked */} |
| 36 | 38 | {skills.length > 0 && ( |
| 37 | 39 | <div className="mb-4"> |
| @@ -187,7 +189,10 @@ export const InsightsSection = ({ · 149 unmodified lines | ||
| 187 | 189 | <div key={idx} className="rounded-md bg-surface-raised px-3 py-2"> |
| 188 | 190 | <span |
| 189 | 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 | 197 | {f.keyword} |
| 193 | 198 | </span> |
| @@ -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 | +}; | |
| @@ -1,6 +1,7 @@ | ||
| 1 | −import { assessmentColor, assessmentLabel, severityColor } from '@renderer/utils/reportAssessments'; | |
| 1 | +import { severityColor } from '@renderer/utils/reportAssessments'; | |
| 2 | 2 | import { BarChart3 } from 'lucide-react'; |
| 3 | 3 | |
| 4 | +import { AssessmentBadge } from '../AssessmentBadge'; | |
| 4 | 5 | import { ReportSection } from '../ReportSection'; |
| 5 | 6 | |
| 6 | 7 | import type { |
| @@ -15,6 +16,7 @@ interface QualitySectionProps { · 8 unmodified lines | ||
| 15 | 16 | startup: ReportStartupOverhead; |
| 16 | 17 | testProgression: ReportTestProgression; |
| 17 | 18 | fileReadRedundancy: ReportFileReadRedundancy; |
| 19 | + defaultCollapsed?: boolean; | |
| 18 | 20 | } |
| 19 | 21 | |
| 20 | 22 | export const QualitySection = ({ |
| @@ -22,24 +24,15 @@ export const QualitySection = ({ · 1 unmodified line | ||
| 22 | 24 | startup, |
| 23 | 25 | testProgression, |
| 24 | 26 | fileReadRedundancy, |
| 27 | + defaultCollapsed, | |
| 25 | 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 | 29 | return ( |
| 32 | − <ReportSection title="Quality Signals" icon={BarChart3}> | |
| 30 | + <ReportSection title="Quality Signals" icon={BarChart3} defaultCollapsed={defaultCollapsed}> | |
| 33 | 31 | {/* Prompt quality */} |
| 34 | 32 | <div className="mb-4"> |
| 35 | 33 | <div className="mb-2 text-xs font-medium text-text-muted">Prompt Quality</div> |
| 36 | 34 | <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" /> | |
| 43 | 36 | </div> |
| 44 | 37 | <div className="text-xs text-text-secondary">{prompt.note}</div> |
| 45 | 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 | 63 | <div className="mb-4"> |
| 71 | 64 | <div className="mb-2 flex items-center gap-2"> |
| 72 | 65 | <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" /> | |
| 79 | 67 | </div> |
| 80 | 68 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> |
| 81 | 69 | <div> |
| @@ -99,12 +87,10 @@ export const QualitySection = ({ · 17 unmodified lines | ||
| 99 | 87 | <div className="mb-4"> |
| 100 | 88 | <div className="mb-2 flex items-center gap-2"> |
| 101 | 89 | <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 | + /> | |
| 108 | 94 | </div> |
| 109 | 95 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> |
| 110 | 96 | <div> |
| @@ -128,12 +114,7 @@ export const QualitySection = ({ · 17 unmodified lines | ||
| 128 | 114 | <div> |
| 129 | 115 | <div className="mb-2 text-xs font-medium text-text-muted">Test Progression</div> |
| 130 | 116 | <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" /> | |
| 137 | 118 | <span className="text-xs text-text-muted"> |
| 138 | 119 | {testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''} |
| 139 | 120 | </span> |
| @@ -15,11 +15,12 @@ const fmtDuration = (ms: number) => { · 14 unmodified lines | ||
| 15 | 15 | |
| 16 | 16 | interface SubagentSectionProps { |
| 17 | 17 | data: ReportSubagentMetrics; |
| 18 | + defaultCollapsed?: boolean; | |
| 18 | 19 | } |
| 19 | 20 | |
| 20 | −export const SubagentSection = ({ data }: SubagentSectionProps) => { | |
| 21 | +export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps) => { | |
| 21 | 22 | return ( |
| 22 | − <ReportSection title="Subagents" icon={Users}> | |
| 23 | + <ReportSection title="Subagents" icon={Users} defaultCollapsed={defaultCollapsed}> | |
| 23 | 24 | <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4"> |
| 24 | 25 | <div> |
| 25 | 26 | <div className="text-xs text-text-muted">Count</div> |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments'; |
| 2 | 2 | import { Clock } from 'lucide-react'; |
| 3 | 3 | |
| 4 | +import { AssessmentBadge } from '../AssessmentBadge'; | |
| 4 | 5 | import { ReportSection } from '../ReportSection'; |
| 5 | 6 | |
| 6 | 7 | import type { |
| @@ -13,23 +14,24 @@ interface TimelineSectionProps { · 6 unmodified lines | ||
| 13 | 14 | idle: ReportIdleAnalysis; |
| 14 | 15 | modelSwitches: ReportModelSwitches; |
| 15 | 16 | keyEvents: KeyEvent[]; |
| 17 | + defaultCollapsed?: boolean; | |
| 16 | 18 | } |
| 17 | 19 | |
| 18 | −export const TimelineSection = ({ idle, modelSwitches, keyEvents }: TimelineSectionProps) => { | |
| 20 | +export const TimelineSection = ({ | |
| 21 | + idle, | |
| 22 | + modelSwitches, | |
| 23 | + keyEvents, | |
| 24 | + defaultCollapsed, | |
| 25 | +}: TimelineSectionProps) => { | |
| 19 | 26 | const idleColor = assessmentColor(idle.idleAssessment); |
| 20 | 27 | |
| 21 | 28 | return ( |
| 22 | − <ReportSection title="Timeline & Activity" icon={Clock}> | |
| 29 | + <ReportSection title="Timeline & Activity" icon={Clock} defaultCollapsed={defaultCollapsed}> | |
| 23 | 30 | {/* Idle stats */} |
| 24 | 31 | <div className="mb-4"> |
| 25 | 32 | <div className="mb-2 flex items-center gap-2"> |
| 26 | 33 | <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" /> | |
| 33 | 35 | </div> |
| 34 | 36 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> |
| 35 | 37 | <div> |
| @@ -1,6 +1,6 @@ | ||
| 1 | −import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments'; | |
| 2 | 1 | import { Coins } from 'lucide-react'; |
| 3 | 2 | |
| 3 | +import { AssessmentBadge } from '../AssessmentBadge'; | |
| 4 | 4 | import { ReportSection } from '../ReportSection'; |
| 5 | 5 | |
| 6 | 6 | import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport'; |
| @@ -11,13 +11,14 @@ const fmtCost = (v: number) => `$${v.toFixed(4)}`; · 4 unmodified lines | ||
| 11 | 11 | interface TokenSectionProps { |
| 12 | 12 | data: ReportTokenUsage; |
| 13 | 13 | cacheEconomics: ReportCacheEconomics; |
| 14 | + defaultCollapsed?: boolean; | |
| 14 | 15 | } |
| 15 | 16 | |
| 16 | −export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { | |
| 17 | +export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSectionProps) => { | |
| 17 | 18 | const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd); |
| 18 | 19 | |
| 19 | 20 | return ( |
| 20 | − <ReportSection title="Token Usage" icon={Coins}> | |
| 21 | + <ReportSection title="Token Usage" icon={Coins} defaultCollapsed={defaultCollapsed}> | |
| 21 | 22 | {/* By-model table */} |
| 22 | 23 | <div className="mb-4 overflow-x-auto"> |
| 23 | 24 | <table className="w-full text-xs"> |
| @@ -71,15 +72,10 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { · 47 unmodified lines | ||
| 71 | 72 | {cacheEconomics.cacheEfficiencyPct}% |
| 72 | 73 | </span> |
| 73 | 74 | {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 | + /> | |
| 83 | 79 | )} |
| 84 | 80 | </div> |
| 85 | 81 | </div> |
| @@ -90,15 +86,10 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { · 4 unmodified lines | ||
| 90 | 86 | {cacheEconomics.cacheReadToWriteRatio}x |
| 91 | 87 | </span> |
| 92 | 88 | {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 | + /> | |
| 102 | 93 | )} |
| 103 | 94 | </div> |
| 104 | 95 | </div> |
| @@ -110,7 +101,11 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { · 5 unmodified lines | ||
| 110 | 101 | <div className="text-xs text-text-muted">Cold Start</div> |
| 111 | 102 | <div |
| 112 | 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 | 110 | {cacheEconomics.coldStartDetected ? 'Yes' : 'No'} |
| 116 | 111 | </div> |
| @@ -1,36 +1,28 @@ | ||
| 1 | −import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments'; | |
| 1 | +import { assessmentColor } from '@renderer/utils/reportAssessments'; | |
| 2 | 2 | import { Wrench } from 'lucide-react'; |
| 3 | 3 | |
| 4 | −import { ReportSection } from '../ReportSection'; | |
| 4 | +import { AssessmentBadge } from '../AssessmentBadge'; | |
| 5 | +import { ReportSection, sectionId } from '../ReportSection'; | |
| 5 | 6 | |
| 6 | 7 | import type { ReportToolUsage } from '@renderer/types/sessionReport'; |
| 7 | 8 | |
| 8 | 9 | interface ToolSectionProps { |
| 9 | 10 | data: ReportToolUsage; |
| 11 | + defaultCollapsed?: boolean; | |
| 10 | 12 | } |
| 11 | 13 | |
| 12 | −export const ToolSection = ({ data }: ToolSectionProps) => { | |
| 14 | +export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => { | |
| 13 | 15 | const toolEntries = Object.entries(data.successRates).sort( |
| 14 | 16 | (a, b) => b[1].totalCalls - a[1].totalCalls |
| 15 | 17 | ); |
| 16 | 18 | |
| 17 | − const healthColor = assessmentColor(data.overallToolHealth); | |
| 18 | − | |
| 19 | 19 | return ( |
| 20 | − <ReportSection title="Tool Usage" icon={Wrench}> | |
| 20 | + <ReportSection title="Tool Usage" icon={Wrench} defaultCollapsed={defaultCollapsed}> | |
| 21 | 21 | <div className="mb-2 flex items-center gap-2"> |
| 22 | 22 | <span className="text-xs text-text-muted"> |
| 23 | 23 | {data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools |
| 24 | 24 | </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" /> | |
| 34 | 26 | </div> |
| 35 | 27 | <div className="overflow-x-auto"> |
| 36 | 28 | <table className="w-full text-xs"> |
| @@ -53,18 +45,26 @@ export const ToolSection = ({ data }: ToolSectionProps) => { · 16 unmodified lines | ||
| 53 | 45 | {stats.totalCalls.toLocaleString()} |
| 54 | 46 | </td> |
| 55 | 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 | 62 | </td> |
| 58 | 63 | <td className="py-1.5 pr-4 text-right" style={{ color }}> |
| 59 | 64 | {stats.successRatePct}% |
| 60 | 65 | </td> |
| 61 | 66 | <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" /> | |
| 68 | 68 | </td> |
| 69 | 69 | </tr> |
| 70 | 70 | ); |
| @@ -181,6 +181,12 @@ · 180 unmodified lines | ||
| 181 | 181 | --card-text-lighter: #e4e4e7; |
| 182 | 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 | 190 | /* Sticky Context button - transparent glass */ |
| 185 | 191 | --context-btn-bg: rgba(255, 255, 255, 0.08); |
| 186 | 192 | --context-btn-bg-hover: rgba(255, 255, 255, 0.14); |
| @@ -206,6 +212,12 @@ · 19 unmodified lines | ||
| 206 | 212 | --color-text-secondary: #4d4b46; /* Warm secondary text */ |
| 207 | 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 | 221 | /* Scrollbar colors for light mode */ |
| 210 | 222 | --scrollbar-thumb: rgba(0, 0, 0, 0.15); |
| 211 | 223 | --scrollbar-thumb-hover: rgba(0, 0, 0, 0.28); |
| @@ -15,7 +15,14 @@ export type Severity = 'good' | 'warning' | 'danger' | 'neutral'; · 14 unmodified lines | ||
| 15 | 15 | // Colors |
| 16 | 16 | // ============================================================================= |
| 17 | 17 | |
| 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> = { | |
| 19 | 26 | good: '#4ade80', |
| 20 | 27 | warning: '#fbbf24', |
| 21 | 28 | danger: '#f87171', |
| @@ -23,7 +30,11 @@ const SEVERITY_COLORS: Record<Severity, string> = { · 1 unmodified line | ||
| 23 | 30 | }; |
| 24 | 31 | |
| 25 | 32 | 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]; | |
| 27 | 38 | } |
| 28 | 39 | |
| 29 | 40 | // ============================================================================= |
| @@ -145,6 +156,92 @@ export const THRESHOLDS = { · 115 unmodified lines | ||
| 145 | 156 | }, |
| 146 | 157 | } as const; |
| 147 | 158 | |
| 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 | + | |
| 148 | 245 | // ============================================================================= |
| 149 | 246 | // Assessment Computers |
| 150 | 247 | // ============================================================================= |
| @@ -268,3 +365,191 @@ export function detectSwitchPattern( · 117 unmodified lines | ||
| 268 | 365 | |
| 269 | 366 | return 'manual_switch'; |
| 270 | 367 | } |
| 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 | +} | |
| @@ -103,7 +103,7 @@ const DEFAULT_PRICING: ModelPricing = { · 102 unmodified lines | ||
| 103 | 103 | cache_creation: 3.75, |
| 104 | 104 | }; |
| 105 | 105 | |
| 106 | −function getPricing(modelName: string): ModelPricing { | |
| 106 | +export function getPricing(modelName: string): ModelPricing { | |
| 107 | 107 | const name = modelName.toLowerCase(); |
| 108 | 108 | for (const [key, pricing] of Object.entries(MODEL_PRICING)) { |
| 109 | 109 | if (name.includes(key)) return pricing; |
| @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | assessmentColor, |
| 5 | + assessmentExplanation, | |
| 5 | 6 | assessmentLabel, |
| 6 | 7 | assessmentSeverity, |
| 7 | 8 | computeCacheEfficiencyAssessment, |
| @@ -12,6 +13,7 @@ import { · 4 unmodified lines | ||
| 12 | 13 | computeOverheadAssessment, |
| 13 | 14 | computeRedundancyAssessment, |
| 14 | 15 | computeSubagentCostShareAssessment, |
| 16 | + computeTakeaways, | |
| 15 | 17 | computeThrashingAssessment, |
| 16 | 18 | computeToolHealthAssessment, |
| 17 | 19 | detectModelMismatch, |
| @@ -20,6 +22,8 @@ import { · 2 unmodified lines | ||
| 20 | 22 | THRESHOLDS, |
| 21 | 23 | } from '@renderer/utils/reportAssessments'; |
| 22 | 24 | |
| 25 | +import type { MetricKey } from '@renderer/utils/reportAssessments'; | |
| 26 | + | |
| 23 | 27 | describe('reportAssessments', () => { |
| 24 | 28 | describe('severityColor', () => { |
| 25 | 29 | it('maps severity to hex color', () => { |
| @@ -256,4 +260,139 @@ describe('reportAssessments', () => { · 230 unmodified lines | ||
| 256 | 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 | }); |