- 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 | import { ChevronDown, ChevronRight } from 'lucide-react'; | 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 | interface ReportSectionProps { | 8 | interface ReportSectionProps { |
| 6 | title: string; | 9 | title: string; |
| 7 | icon: React.ComponentType<{ className?: string }>; | 10 | icon: React.ComponentType<{ className?: string }>; |
| @@ -16,9 +19,25 @@ export const ReportSection = ({ · 8 unmodified lines | |||
| 16 | defaultCollapsed = false, | 19 | defaultCollapsed = false, |
| 17 | }: ReportSectionProps) => { | 20 | }: ReportSectionProps) => { |
| 18 | const [collapsed, setCollapsed] = useState(defaultCollapsed); | 21 | const [collapsed, setCollapsed] = useState(defaultCollapsed); |
| 22 | const ref = useRef<HTMLDivElement>(null); | ||
| 23 | |||
| 24 | useEffect(() => { | ||
| 25 | const el = ref.current; | ||
| 26 | if (!el) return; | ||
| 27 | const handler = () => { | ||
| 28 | setCollapsed(false); | ||
| 29 | el.scrollIntoView({ behavior: 'smooth', block: 'start' }); | ||
| 30 | }; | ||
| 31 | el.addEventListener('report-section-expand', handler); | ||
| 32 | return () => el.removeEventListener('report-section-expand', handler); | ||
| 33 | }, []); | ||
| 19 | 34 | ||
| 20 | return ( | 35 | return ( |
| 21 | <div className="rounded-lg border border-border bg-surface-raised"> | 36 | <div |
| 37 | ref={ref} | ||
| 38 | id={sectionId(title)} | ||
| 39 | className="rounded-lg border border-border bg-surface-raised" | ||
| 40 | > | ||
| 22 | <button | 41 | <button |
| 23 | onClick={() => setCollapsed(!collapsed)} | 42 | onClick={() => setCollapsed(!collapsed)} |
| 24 | className="flex w-full items-center gap-2 p-4 text-left" | 43 | className="flex w-full items-center gap-2 p-4 text-left" |
| @@ -35,3 +54,5 @@ export const ReportSection = ({ · 10 unmodified lines | |||
| 35 | </div> | 54 | </div> |
| 36 | ); | 55 | ); |
| 37 | }; | 56 | }; |
| 57 | |||
| 58 | export { sectionId }; | ||
| @@ -1,6 +1,7 @@ | |||
| 1 | import { useMemo } from 'react'; | 1 | import { useMemo } from 'react'; |
| 2 | 2 | ||
| 3 | import { useStore } from '@renderer/store'; | 3 | import { useStore } from '@renderer/store'; |
| 4 | import { computeTakeaways } from '@renderer/utils/reportAssessments'; | ||
| 4 | import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; | 5 | import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; |
| 5 | 6 | ||
| 6 | import { CostSection } from './sections/CostSection'; | 7 | import { CostSection } from './sections/CostSection'; |
| @@ -8,6 +9,7 @@ import { ErrorSection } from './sections/ErrorSection'; · 1 unmodified line | |||
| 8 | import { FrictionSection } from './sections/FrictionSection'; | 9 | import { FrictionSection } from './sections/FrictionSection'; |
| 9 | import { GitSection } from './sections/GitSection'; | 10 | import { GitSection } from './sections/GitSection'; |
| 10 | import { InsightsSection } from './sections/InsightsSection'; | 11 | import { InsightsSection } from './sections/InsightsSection'; |
| 12 | import { KeyTakeawaysSection } from './sections/KeyTakeawaysSection'; | ||
| 11 | import { OverviewSection } from './sections/OverviewSection'; | 13 | import { OverviewSection } from './sections/OverviewSection'; |
| 12 | import { QualitySection } from './sections/QualitySection'; | 14 | import { QualitySection } from './sections/QualitySection'; |
| 13 | import { SubagentSection } from './sections/SubagentSection'; | 15 | import { SubagentSection } from './sections/SubagentSection'; |
| @@ -34,6 +36,8 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => { · 20 unmodified lines | |||
| 34 | [sessionDetail] | 36 | [sessionDetail] |
| 35 | ); | 37 | ); |
| 36 | 38 | ||
| 39 | const takeaways = useMemo(() => (report ? computeTakeaways(report) : []), [report]); | ||
| 40 | |||
| 37 | if (!report) { | 41 | if (!report) { |
| 38 | return ( | 42 | return ( |
| 39 | <div className="flex h-full items-center justify-center text-text-muted"> | 43 | <div className="flex h-full items-center justify-center text-text-muted"> |
| @@ -46,24 +50,38 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => { · 6 unmodified lines | |||
| 46 | <div className="h-full overflow-y-auto p-6" style={{ backgroundColor: 'var(--color-surface)' }}> | 50 | <div className="h-full overflow-y-auto p-6" style={{ backgroundColor: 'var(--color-surface)' }}> |
| 47 | <h1 className="mb-6 text-lg font-semibold text-text">Session Analysis Report</h1> | 51 | <h1 className="mb-6 text-lg font-semibold text-text">Session Analysis Report</h1> |
| 48 | <div className="flex flex-col gap-4"> | 52 | <div className="flex flex-col gap-4"> |
| 53 | {takeaways.length > 0 && <KeyTakeawaysSection takeaways={takeaways} />} | ||
| 49 | <OverviewSection data={report.overview} /> | 54 | <OverviewSection data={report.overview} /> |
| 50 | <CostSection data={report.costAnalysis} /> | 55 | <CostSection |
| 56 | data={report.costAnalysis} | ||
| 57 | tokensByModel={report.tokenUsage.byModel} | ||
| 58 | commitCount={report.gitActivity.commitCount} | ||
| 59 | linesChanged={report.gitActivity.linesChanged} | ||
| 60 | /> | ||
| 51 | <TokenSection data={report.tokenUsage} cacheEconomics={report.cacheEconomics} /> | 61 | <TokenSection data={report.tokenUsage} cacheEconomics={report.cacheEconomics} /> |
| 52 | <ToolSection data={report.toolUsage} /> | 62 | <ToolSection data={report.toolUsage} /> |
| 53 | {report.subagentMetrics.count > 0 && <SubagentSection data={report.subagentMetrics} />} | 63 | {report.subagentMetrics.count > 0 && ( |
| 54 | {report.errors.errors.length > 0 && <ErrorSection data={report.errors} />} | 64 | <SubagentSection data={report.subagentMetrics} defaultCollapsed /> |
| 55 | <GitSection data={report.gitActivity} /> | 65 | )} |
| 56 | <FrictionSection data={report.frictionSignals} thrashing={report.thrashingSignals} /> | 66 | {report.errors.errors.length > 0 && <ErrorSection data={report.errors} defaultCollapsed />} |
| 67 | <GitSection data={report.gitActivity} defaultCollapsed /> | ||
| 68 | <FrictionSection | ||
| 69 | data={report.frictionSignals} | ||
| 70 | thrashing={report.thrashingSignals} | ||
| 71 | defaultCollapsed | ||
| 72 | /> | ||
| 57 | <TimelineSection | 73 | <TimelineSection |
| 58 | idle={report.idleAnalysis} | 74 | idle={report.idleAnalysis} |
| 59 | modelSwitches={report.modelSwitches} | 75 | modelSwitches={report.modelSwitches} |
| 60 | keyEvents={report.keyEvents} | 76 | keyEvents={report.keyEvents} |
| 77 | defaultCollapsed | ||
| 61 | /> | 78 | /> |
| 62 | <QualitySection | 79 | <QualitySection |
| 63 | prompt={report.promptQuality} | 80 | prompt={report.promptQuality} |
| 64 | startup={report.startupOverhead} | 81 | startup={report.startupOverhead} |
| 65 | testProgression={report.testProgression} | 82 | testProgression={report.testProgression} |
| 66 | fileReadRedundancy={report.fileReadRedundancy} | 83 | fileReadRedundancy={report.fileReadRedundancy} |
| 84 | defaultCollapsed | ||
| 67 | /> | 85 | /> |
| 68 | <InsightsSection | 86 | <InsightsSection |
| 69 | skills={report.skillsInvoked} | 87 | skills={report.skillsInvoked} |
| @@ -73,6 +91,7 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => { · 3 unmodified lines | |||
| 73 | outOfScope={report.outOfScopeFindings} | 91 | outOfScope={report.outOfScopeFindings} |
| 74 | agentTree={report.agentTree} | 92 | agentTree={report.agentTree} |
| 75 | subagentsList={report.subagentsList} | 93 | subagentsList={report.subagentsList} |
| 94 | defaultCollapsed | ||
| 76 | /> | 95 | /> |
| 77 | </div> | 96 | </div> |
| 78 | </div> | 97 | </div> |
| @@ -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 | import { DollarSign } from 'lucide-react'; | 4 | import { DollarSign } from 'lucide-react'; |
| 3 | 5 | ||
| 6 | import { AssessmentBadge } from '../AssessmentBadge'; | ||
| 4 | import { ReportSection } from '../ReportSection'; | 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 | const fmt = (v: number) => `$${v.toFixed(4)}`; | 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 | interface CostSectionProps { | 17 | interface CostSectionProps { |
| 11 | data: ReportCostAnalysis; | 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 | const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]); | 80 | const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]); |
| 81 | const showStackedBar = data.subagentCostUsd > 0; | ||
| 82 | const parentPct = | ||
| 83 | showStackedBar && data.totalSessionCostUsd > 0 | ||
| 84 | ? (data.parentCostUsd / data.totalSessionCostUsd) * 100 | ||
| 85 | : 100; | ||
| 16 | 86 | ||
| 17 | return ( | 87 | return ( |
| 18 | <ReportSection title="Cost Analysis" icon={DollarSign}> | 88 | <ReportSection title="Cost Analysis" icon={DollarSign} defaultCollapsed={defaultCollapsed}> |
| 19 | <div className="mb-4 text-2xl font-bold text-text">{fmt(data.totalSessionCostUsd)}</div> | 89 | <div className="mb-4 text-2xl font-bold text-text">{fmt(data.totalSessionCostUsd)}</div> |
| 20 | 90 | ||
| 21 | <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4"> | 91 | {/* Parent/Subagent stacked bar */} |
| 22 | <div> | 92 | {showStackedBar && ( |
| 23 | <div className="text-xs text-text-muted">Parent Cost</div> | 93 | <div className="mb-4"> |
| 24 | <div className="text-sm font-medium text-text">{fmt(data.parentCostUsd)}</div> | 94 | <div className="mb-1.5 flex h-3 w-full overflow-hidden rounded-full"> |
| 25 | </div> | 95 | <div |
| 26 | <div> | 96 | className="h-full" |
| 27 | <div className="text-xs text-text-muted">Subagent Cost</div> | 97 | style={{ width: `${parentPct}%`, backgroundColor: '#60a5fa' }} |
| 28 | <div className="text-sm font-medium text-text">{fmt(data.subagentCostUsd)}</div> | 98 | /> |
| 99 | <div | ||
| 100 | className="h-full" | ||
| 101 | style={{ width: `${100 - parentPct}%`, backgroundColor: '#c084fc' }} | ||
| 102 | /> | ||
| 103 | </div> | ||
| 104 | <div className="flex gap-4 text-xs"> | ||
| 105 | <div className="flex items-center gap-1.5"> | ||
| 106 | <span | ||
| 107 | className="inline-block size-2 rounded-full" | ||
| 108 | style={{ backgroundColor: '#60a5fa' }} | ||
| 109 | /> | ||
| 110 | <span className="text-text-secondary">Parent: {fmt(data.parentCostUsd)}</span> | ||
| 111 | </div> | ||
| 112 | <div className="flex items-center gap-1.5"> | ||
| 113 | <span | ||
| 114 | className="inline-block size-2 rounded-full" | ||
| 115 | style={{ backgroundColor: '#c084fc' }} | ||
| 116 | /> | ||
| 117 | <span className="text-text-secondary">Subagent: {fmt(data.subagentCostUsd)}</span> | ||
| 118 | </div> | ||
| 119 | </div> | ||
| 29 | </div> | 120 | </div> |
| 121 | )} | ||
| 122 | |||
| 123 | <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4"> | ||
| 124 | {!showStackedBar && ( | ||
| 125 | <> | ||
| 126 | <div> | ||
| 127 | <div className="text-xs text-text-muted">Parent Cost</div> | ||
| 128 | <div className="text-sm font-medium text-text">{fmt(data.parentCostUsd)}</div> | ||
| 129 | </div> | ||
| 130 | <div> | ||
| 131 | <div className="text-xs text-text-muted">Subagent Cost</div> | ||
| 132 | <div className="text-sm font-medium text-text">{fmt(data.subagentCostUsd)}</div> | ||
| 133 | </div> | ||
| 134 | </> | ||
| 135 | )} | ||
| 30 | <div> | 136 | <div> |
| 31 | <div className="text-xs text-text-muted">Per Commit</div> | 137 | <div className="text-xs text-text-muted">Per Commit</div> |
| 138 | <div className="text-[10px] text-text-muted"> | ||
| 139 | total cost {'\u00F7'} {commitCount} commit{commitCount !== 1 ? 's' : ''} | ||
| 140 | </div> | ||
| 32 | <div className="flex items-center gap-2"> | 141 | <div className="flex items-center gap-2"> |
| 33 | <span className="text-sm font-medium text-text"> | 142 | <span className="text-sm font-medium text-text"> |
| 34 | {data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'} | 143 | {data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'} |
| 35 | </span> | 144 | </span> |
| 36 | {data.costPerCommitAssessment && ( | 145 | {data.costPerCommitAssessment && ( |
| 37 | <span | 146 | <AssessmentBadge |
| 38 | className="rounded px-2 py-0.5 text-xs font-medium" | 147 | assessment={data.costPerCommitAssessment} |
| 39 | style={{ | 148 | metricKey="costPerCommit" |
| 40 | backgroundColor: `${assessmentColor(data.costPerCommitAssessment)}20`, | 149 | /> |
| 41 | color: assessmentColor(data.costPerCommitAssessment), | ||
| 42 | }} | ||
| 43 | > | ||
| 44 | {assessmentLabel(data.costPerCommitAssessment)} | ||
| 45 | </span> | ||
| 46 | )} | 150 | )} |
| 47 | </div> | 151 | </div> |
| 48 | </div> | 152 | </div> |
| 49 | <div> | 153 | <div> |
| 50 | <div className="text-xs text-text-muted">Per Line Changed</div> | 154 | <div className="text-xs text-text-muted">Per Line Changed</div> |
| 155 | <div className="text-[10px] text-text-muted"> | ||
| 156 | total cost {'\u00F7'} {linesChanged.toLocaleString()} line | ||
| 157 | {linesChanged !== 1 ? 's' : ''} | ||
| 158 | </div> | ||
| 51 | <div className="flex items-center gap-2"> | 159 | <div className="flex items-center gap-2"> |
| 52 | <span className="text-sm font-medium text-text"> | 160 | <span className="text-sm font-medium text-text"> |
| 53 | {data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'} | 161 | {data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'} |
| 54 | </span> | 162 | </span> |
| 55 | {data.costPerLineAssessment && ( | 163 | {data.costPerLineAssessment && ( |
| 56 | <span | 164 | <AssessmentBadge assessment={data.costPerLineAssessment} metricKey="costPerLine" /> |
| 57 | className="rounded px-2 py-0.5 text-xs font-medium" | ||
| 58 | style={{ | ||
| 59 | backgroundColor: `${assessmentColor(data.costPerLineAssessment)}20`, | ||
| 60 | color: assessmentColor(data.costPerLineAssessment), | ||
| 61 | }} | ||
| 62 | > | ||
| 63 | {assessmentLabel(data.costPerLineAssessment)} | ||
| 64 | </span> | ||
| 65 | )} | 165 | )} |
| 66 | </div> | 166 | </div> |
| 67 | </div> | 167 | </div> |
| 68 | </div> | 168 | </div> |
| 69 | 169 | ||
| 70 | {data.subagentCostSharePct != null && ( | ||
| 71 | <div className="mb-4 flex items-center gap-2"> | ||
| 72 | <span className="text-xs text-text-muted">Subagent Cost Share:</span> | ||
| 73 | <span className="text-sm font-medium text-text">{data.subagentCostSharePct}%</span> | ||
| 74 | {data.subagentCostShareAssessment && ( | ||
| 75 | <span | ||
| 76 | className="rounded px-2 py-0.5 text-xs font-medium" | ||
| 77 | style={{ | ||
| 78 | backgroundColor: `${assessmentColor(data.subagentCostShareAssessment)}20`, | ||
| 79 | color: assessmentColor(data.subagentCostShareAssessment), | ||
| 80 | }} | ||
| 81 | > | ||
| 82 | {assessmentLabel(data.subagentCostShareAssessment)} | ||
| 83 | </span> | ||
| 84 | )} | ||
| 85 | </div> | ||
| 86 | )} | ||
| 87 | |||
| 88 | {modelEntries.length > 0 && ( | 170 | {modelEntries.length > 0 && ( |
| 89 | <table className="w-full text-xs"> | 171 | <table className="w-full text-xs"> |
| 90 | <thead> | 172 | <thead> |
| 91 | <tr className="border-b border-border text-left text-text-muted"> | 173 | <tr className="border-b border-border text-left text-text-muted"> |
| 92 | <th className="pb-2 pr-4">Model</th> | 174 | <th className="pb-2 pr-4">Model</th> |
| 175 | <th className="pb-2 pr-4 text-right">Input</th> | ||
| 176 | <th className="pb-2 pr-4 text-right">Output</th> | ||
| 177 | <th className="pb-2 pr-4 text-right">Cache Read</th> | ||
| 178 | <th className="pb-2 pr-4 text-right">Cache Write</th> | ||
| 93 | <th className="pb-2 pr-4 text-right">Cost</th> | 179 | <th className="pb-2 pr-4 text-right">Cost</th> |
| 94 | </tr> | 180 | </tr> |
| 95 | </thead> | 181 | </thead> |
| 96 | <tbody> | 182 | <tbody> |
| 97 | {modelEntries.map(([model, cost]) => ( | 183 | {modelEntries.map(([model, cost]) => { |
| 98 | <tr key={model} className="border-border/50 border-b"> | 184 | const stats = tokensByModel[model]; |
| 99 | <td className="py-1.5 pr-4 text-text">{model}</td> | 185 | const isExpanded = expandedModel === model; |
| 100 | <td className="py-1.5 pr-4 text-right text-text">{fmt(cost)}</td> | 186 | const pricing = getPricing(model); |
| 101 | </tr> | 187 | return ( |
| 102 | ))} | 188 | <Fragment key={model}> |
| 189 | <tr | ||
| 190 | className="border-border/50 hover:bg-surface-raised/50 cursor-pointer border-b" | ||
| 191 | onClick={() => setExpandedModel(isExpanded ? null : model)} | ||
| 192 | > | ||
| 193 | <td className="py-1.5 pr-4 text-text"> | ||
| 194 | <span className="mr-1.5 inline-block w-3 text-text-muted"> | ||
| 195 | {isExpanded ? '\u25BC' : '\u25B6'} | ||
| 196 | </span> | ||
| 197 | {model} | ||
| 198 | </td> | ||
| 199 | <td className="py-1.5 pr-4 text-right text-text-secondary"> | ||
| 200 | {stats ? fmtK(stats.inputTokens) : '—'} | ||
| 201 | </td> | ||
| 202 | <td className="py-1.5 pr-4 text-right text-text-secondary"> | ||
| 203 | {stats ? fmtK(stats.outputTokens) : '—'} | ||
| 204 | </td> | ||
| 205 | <td className="py-1.5 pr-4 text-right text-text-secondary"> | ||
| 206 | {stats ? fmtK(stats.cacheRead) : '—'} | ||
| 207 | </td> | ||
| 208 | <td className="py-1.5 pr-4 text-right text-text-secondary"> | ||
| 209 | {stats ? fmtK(stats.cacheCreation) : '—'} | ||
| 210 | </td> | ||
| 211 | <td className="py-1.5 pr-4 text-right font-medium text-text">{fmt(cost)}</td> | ||
| 212 | </tr> | ||
| 213 | {isExpanded && stats && ( | ||
| 214 | <tr> | ||
| 215 | <td colSpan={6} className="px-4 pb-3 pt-1"> | ||
| 216 | <CostBreakdownCard stats={stats} pricing={pricing} /> | ||
| 217 | </td> | ||
| 218 | </tr> | ||
| 219 | )} | ||
| 220 | </Fragment> | ||
| 221 | ); | ||
| 222 | })} | ||
| 103 | </tbody> | 223 | </tbody> |
| 104 | </table> | 224 | </table> |
| 105 | )} | 225 | )} |
| @@ -28,7 +28,10 @@ const ErrorItem = ({ error }: ErrorItemProps) => { · 27 unmodified lines | |||
| 28 | {error.isPermissionDenial && ( | 28 | {error.isPermissionDenial && ( |
| 29 | <span | 29 | <span |
| 30 | className="rounded px-1.5 py-0.5 text-[10px] font-medium" | 30 | className="rounded px-1.5 py-0.5 text-[10px] font-medium" |
| 31 | style={{ backgroundColor: 'rgba(248, 113, 113, 0.15)', color: '#f87171' }} | 31 | style={{ |
| 32 | backgroundColor: 'color-mix(in srgb, var(--assess-danger) 15%, transparent)', | ||
| 33 | color: 'var(--assess-danger)', | ||
| 34 | }} | ||
| 32 | > | 35 | > |
| 33 | Permission Denied | 36 | Permission Denied |
| 34 | </span> | 37 | </span> |
| @@ -36,8 +39,28 @@ const ErrorItem = ({ error }: ErrorItemProps) => { · 1 unmodified line | |||
| 36 | <span className="ml-auto text-text-muted">msg #{error.messageIndex}</span> | 39 | <span className="ml-auto text-text-muted">msg #{error.messageIndex}</span> |
| 37 | </button> | 40 | </button> |
| 38 | {expanded && ( | 41 | {expanded && ( |
| 39 | <div className="mt-2 whitespace-pre-wrap break-words rounded bg-surface-raised p-2 text-xs text-text-secondary"> | 42 | <div className="mt-2 flex flex-col gap-1.5"> |
| 40 | {error.error} | 43 | {error.inputPreview && ( |
| 44 | <div className="rounded bg-surface-raised p-2"> | ||
| 45 | <div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-text-muted"> | ||
| 46 | Input | ||
| 47 | </div> | ||
| 48 | <div className="whitespace-pre-wrap break-words font-mono text-xs text-text-secondary"> | ||
| 49 | {error.inputPreview} | ||
| 50 | </div> | ||
| 51 | </div> | ||
| 52 | )} | ||
| 53 | <div className="rounded bg-surface-raised p-2"> | ||
| 54 | <div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-text-muted"> | ||
| 55 | Error | ||
| 56 | </div> | ||
| 57 | <div | ||
| 58 | className="whitespace-pre-wrap break-words text-xs" | ||
| 59 | style={{ color: 'var(--assess-danger)' }} | ||
| 60 | > | ||
| 61 | {error.error} | ||
| 62 | </div> | ||
| 63 | </div> | ||
| 41 | </div> | 64 | </div> |
| 42 | )} | 65 | )} |
| 43 | </div> | 66 | </div> |
| @@ -46,15 +69,19 @@ const ErrorItem = ({ error }: ErrorItemProps) => { · 2 unmodified lines | |||
| 46 | 69 | ||
| 47 | interface ErrorSectionProps { | 70 | interface ErrorSectionProps { |
| 48 | data: ReportErrors; | 71 | data: ReportErrors; |
| 72 | defaultCollapsed?: boolean; | ||
| 49 | } | 73 | } |
| 50 | 74 | ||
| 51 | export const ErrorSection = ({ data }: ErrorSectionProps) => { | 75 | export const ErrorSection = ({ data, defaultCollapsed }: ErrorSectionProps) => { |
| 52 | return ( | 76 | return ( |
| 53 | <ReportSection title="Errors" icon={AlertTriangle}> | 77 | <ReportSection title="Errors" icon={AlertTriangle} defaultCollapsed={defaultCollapsed}> |
| 54 | <div className="mb-3 flex items-center gap-3"> | 78 | <div className="mb-3 flex items-center gap-3"> |
| 55 | <span | 79 | <span |
| 56 | className="rounded px-2 py-0.5 text-xs font-medium" | 80 | className="rounded px-2 py-0.5 text-xs font-medium" |
| 57 | style={{ backgroundColor: 'rgba(248, 113, 113, 0.15)', color: '#f87171' }} | 81 | style={{ |
| 82 | backgroundColor: 'color-mix(in srgb, var(--assess-danger) 15%, transparent)', | ||
| 83 | color: 'var(--assess-danger)', | ||
| 84 | }} | ||
| 58 | > | 85 | > |
| 59 | {data.errors.length} error{data.errors.length !== 1 ? 's' : ''} | 86 | {data.errors.length} error{data.errors.length !== 1 ? 's' : ''} |
| 60 | </span> | 87 | </span> |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments'; | 1 | import { severityColor } from '@renderer/utils/reportAssessments'; |
| 2 | import { MessageSquareWarning } from 'lucide-react'; | 2 | import { MessageSquareWarning } from 'lucide-react'; |
| 3 | 3 | ||
| 4 | import { AssessmentBadge } from '../AssessmentBadge'; | ||
| 4 | import { ReportSection } from '../ReportSection'; | 5 | import { ReportSection } from '../ReportSection'; |
| 5 | 6 | ||
| 6 | import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport'; | 7 | import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport'; |
| @@ -8,20 +9,25 @@ import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/ty · 1 unmodified line | |||
| 8 | interface FrictionSectionProps { | 9 | interface FrictionSectionProps { |
| 9 | data: ReportFrictionSignals; | 10 | data: ReportFrictionSignals; |
| 10 | thrashing: ReportThrashingSignals; | 11 | thrashing: ReportThrashingSignals; |
| 12 | defaultCollapsed?: boolean; | ||
| 11 | } | 13 | } |
| 12 | 14 | ||
| 13 | export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => { | 15 | export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionSectionProps) => { |
| 14 | const frictionColor = | 16 | const frictionSeverity = |
| 15 | data.frictionRate <= 0.1 ? '#4ade80' : data.frictionRate <= 0.25 ? '#fbbf24' : '#f87171'; | 17 | data.frictionRate <= 0.1 ? 'good' : data.frictionRate <= 0.25 ? 'warning' : 'danger'; |
| 16 | const thrashColor = assessmentColor(thrashing.thrashingAssessment); | 18 | const frictionColor = severityColor(frictionSeverity); |
| 17 | 19 | ||
| 18 | return ( | 20 | return ( |
| 19 | <ReportSection title="Friction Signals" icon={MessageSquareWarning}> | 21 | <ReportSection |
| 22 | title="Friction Signals" | ||
| 23 | icon={MessageSquareWarning} | ||
| 24 | defaultCollapsed={defaultCollapsed} | ||
| 25 | > | ||
| 20 | <div className="mb-4 flex items-center gap-3"> | 26 | <div className="mb-4 flex items-center gap-3"> |
| 21 | <span | 27 | <span |
| 22 | className="rounded px-2 py-0.5 text-xs font-medium" | 28 | className="rounded px-2 py-0.5 text-xs font-medium" |
| 23 | style={{ | 29 | style={{ |
| 24 | backgroundColor: `${frictionColor}20`, | 30 | backgroundColor: `color-mix(in srgb, ${frictionColor} 12%, transparent)`, |
| 25 | color: frictionColor, | 31 | color: frictionColor, |
| 26 | }} | 32 | }} |
| 27 | > | 33 | > |
| @@ -40,7 +46,10 @@ export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => { · 12 unmodified lines | |||
| 40 | <div key={idx} className="flex items-start gap-2 rounded px-2 py-1 text-xs"> | 46 | <div key={idx} className="flex items-start gap-2 rounded px-2 py-1 text-xs"> |
| 41 | <span | 47 | <span |
| 42 | className="shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]" | 48 | className="shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]" |
| 43 | style={{ backgroundColor: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24' }} | 49 | style={{ |
| 50 | backgroundColor: 'color-mix(in srgb, var(--assess-warning) 15%, transparent)', | ||
| 51 | color: 'var(--assess-warning)', | ||
| 52 | }} | ||
| 44 | > | 53 | > |
| 45 | {corr.keyword} | 54 | {corr.keyword} |
| 46 | </span> | 55 | </span> |
| @@ -55,12 +64,7 @@ export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => { · 8 unmodified lines | |||
| 55 | <div> | 64 | <div> |
| 56 | <div className="mb-2 flex items-center gap-2"> | 65 | <div className="mb-2 flex items-center gap-2"> |
| 57 | <span className="text-xs font-medium text-text-muted">Thrashing Signals</span> | 66 | <span className="text-xs font-medium text-text-muted">Thrashing Signals</span> |
| 58 | <span | 67 | <AssessmentBadge assessment={thrashing.thrashingAssessment} metricKey="thrashing" /> |
| 59 | className="rounded px-2 py-0.5 text-xs font-medium" | ||
| 60 | style={{ backgroundColor: `${thrashColor}20`, color: thrashColor }} | ||
| 61 | > | ||
| 62 | {assessmentLabel(thrashing.thrashingAssessment)} | ||
| 63 | </span> | ||
| 64 | </div> | 68 | </div> |
| 65 | 69 | ||
| 66 | {thrashing.bashNearDuplicates.length > 0 && ( | 70 | {thrashing.bashNearDuplicates.length > 0 && ( |
| @@ -6,11 +6,12 @@ import type { ReportGitActivity } from '@renderer/types/sessionReport'; · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | interface GitSectionProps { | 7 | interface GitSectionProps { |
| 8 | data: ReportGitActivity; | 8 | data: ReportGitActivity; |
| 9 | defaultCollapsed?: boolean; | ||
| 9 | } | 10 | } |
| 10 | 11 | ||
| 11 | export const GitSection = ({ data }: GitSectionProps) => { | 12 | export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => { |
| 12 | return ( | 13 | return ( |
| 13 | <ReportSection title="Git Activity" icon={GitBranch}> | 14 | <ReportSection title="Git Activity" icon={GitBranch} defaultCollapsed={defaultCollapsed}> |
| 14 | <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4"> | 15 | <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4"> |
| 15 | <div> | 16 | <div> |
| 16 | <div className="text-xs text-text-muted">Commits</div> | 17 | <div className="text-xs text-text-muted">Commits</div> |
| @@ -22,13 +23,13 @@ export const GitSection = ({ data }: GitSectionProps) => { · 5 unmodified lines | |||
| 22 | </div> | 23 | </div> |
| 23 | <div> | 24 | <div> |
| 24 | <div className="text-xs text-text-muted">Lines Added</div> | 25 | <div className="text-xs text-text-muted">Lines Added</div> |
| 25 | <div className="text-sm font-medium" style={{ color: '#4ade80' }}> | 26 | <div className="text-sm font-medium" style={{ color: 'var(--assess-good)' }}> |
| 26 | +{data.linesAdded.toLocaleString()} | 27 | +{data.linesAdded.toLocaleString()} |
| 27 | </div> | 28 | </div> |
| 28 | </div> | 29 | </div> |
| 29 | <div> | 30 | <div> |
| 30 | <div className="text-xs text-text-muted">Lines Removed</div> | 31 | <div className="text-xs text-text-muted">Lines Removed</div> |
| 31 | <div className="text-sm font-medium" style={{ color: '#f87171' }}> | 32 | <div className="text-sm font-medium" style={{ color: 'var(--assess-danger)' }}> |
| 32 | -{data.linesRemoved.toLocaleString()} | 33 | -{data.linesRemoved.toLocaleString()} |
| 33 | </div> | 34 | </div> |
| 34 | </div> | 35 | </div> |
| @@ -19,6 +19,7 @@ interface InsightsSectionProps { · 18 unmodified lines | |||
| 19 | outOfScope: OutOfScopeFindings[]; | 19 | outOfScope: OutOfScopeFindings[]; |
| 20 | agentTree: ReportAgentTree; | 20 | agentTree: ReportAgentTree; |
| 21 | subagentsList: SubagentBasicEntry[]; | 21 | subagentsList: SubagentBasicEntry[]; |
| 22 | defaultCollapsed?: boolean; | ||
| 22 | } | 23 | } |
| 23 | 24 | ||
| 24 | export const InsightsSection = ({ | 25 | export const InsightsSection = ({ |
| @@ -29,9 +30,10 @@ export const InsightsSection = ({ · 4 unmodified lines | |||
| 29 | outOfScope, | 30 | outOfScope, |
| 30 | agentTree, | 31 | agentTree, |
| 31 | subagentsList, | 32 | subagentsList, |
| 33 | defaultCollapsed, | ||
| 32 | }: InsightsSectionProps) => { | 34 | }: InsightsSectionProps) => { |
| 33 | return ( | 35 | return ( |
| 34 | <ReportSection title="Session Insights" icon={Lightbulb}> | 36 | <ReportSection title="Session Insights" icon={Lightbulb} defaultCollapsed={defaultCollapsed}> |
| 35 | {/* Skills invoked */} | 37 | {/* Skills invoked */} |
| 36 | {skills.length > 0 && ( | 38 | {skills.length > 0 && ( |
| 37 | <div className="mb-4"> | 39 | <div className="mb-4"> |
| @@ -187,7 +189,10 @@ export const InsightsSection = ({ · 149 unmodified lines | |||
| 187 | <div key={idx} className="rounded-md bg-surface-raised px-3 py-2"> | 189 | <div key={idx} className="rounded-md bg-surface-raised px-3 py-2"> |
| 188 | <span | 190 | <span |
| 189 | className="mr-2 rounded px-1.5 py-0.5 text-xs" | 191 | className="mr-2 rounded px-1.5 py-0.5 text-xs" |
| 190 | style={{ backgroundColor: '#fbbf2420', color: '#fbbf24' }} | 192 | style={{ |
| 193 | backgroundColor: 'color-mix(in srgb, var(--assess-warning) 12%, transparent)', | ||
| 194 | color: 'var(--assess-warning)', | ||
| 195 | }} | ||
| 191 | > | 196 | > |
| 192 | {f.keyword} | 197 | {f.keyword} |
| 193 | </span> | 198 | </span> |
| @@ -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 | import { BarChart3 } from 'lucide-react'; | 2 | import { BarChart3 } from 'lucide-react'; |
| 3 | 3 | ||
| 4 | import { AssessmentBadge } from '../AssessmentBadge'; | ||
| 4 | import { ReportSection } from '../ReportSection'; | 5 | import { ReportSection } from '../ReportSection'; |
| 5 | 6 | ||
| 6 | import type { | 7 | import type { |
| @@ -15,6 +16,7 @@ interface QualitySectionProps { · 8 unmodified lines | |||
| 15 | startup: ReportStartupOverhead; | 16 | startup: ReportStartupOverhead; |
| 16 | testProgression: ReportTestProgression; | 17 | testProgression: ReportTestProgression; |
| 17 | fileReadRedundancy: ReportFileReadRedundancy; | 18 | fileReadRedundancy: ReportFileReadRedundancy; |
| 19 | defaultCollapsed?: boolean; | ||
| 18 | } | 20 | } |
| 19 | 21 | ||
| 20 | export const QualitySection = ({ | 22 | export const QualitySection = ({ |
| @@ -22,24 +24,15 @@ export const QualitySection = ({ · 1 unmodified line | |||
| 22 | startup, | 24 | startup, |
| 23 | testProgression, | 25 | testProgression, |
| 24 | fileReadRedundancy, | 26 | fileReadRedundancy, |
| 27 | defaultCollapsed, | ||
| 25 | }: QualitySectionProps) => { | 28 | }: QualitySectionProps) => { |
| 26 | const promptColor = assessmentColor(prompt.assessment); | ||
| 27 | const trajectoryColor = assessmentColor(testProgression.trajectory); | ||
| 28 | const overheadColor = assessmentColor(startup.overheadAssessment); | ||
| 29 | const redundancyColor = assessmentColor(fileReadRedundancy.redundancyAssessment); | ||
| 30 | |||
| 31 | return ( | 29 | return ( |
| 32 | <ReportSection title="Quality Signals" icon={BarChart3}> | 30 | <ReportSection title="Quality Signals" icon={BarChart3} defaultCollapsed={defaultCollapsed}> |
| 33 | {/* Prompt quality */} | 31 | {/* Prompt quality */} |
| 34 | <div className="mb-4"> | 32 | <div className="mb-4"> |
| 35 | <div className="mb-2 text-xs font-medium text-text-muted">Prompt Quality</div> | 33 | <div className="mb-2 text-xs font-medium text-text-muted">Prompt Quality</div> |
| 36 | <div className="mb-2 flex items-center gap-2"> | 34 | <div className="mb-2 flex items-center gap-2"> |
| 37 | <span | 35 | <AssessmentBadge assessment={prompt.assessment} metricKey="promptQuality" /> |
| 38 | className="rounded px-2 py-0.5 text-xs font-medium" | ||
| 39 | style={{ backgroundColor: `${promptColor}20`, color: promptColor }} | ||
| 40 | > | ||
| 41 | {assessmentLabel(prompt.assessment)} | ||
| 42 | </span> | ||
| 43 | </div> | 36 | </div> |
| 44 | <div className="text-xs text-text-secondary">{prompt.note}</div> | 37 | <div className="text-xs text-text-secondary">{prompt.note}</div> |
| 45 | <div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4"> | 38 | <div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4"> |
| @@ -70,12 +63,7 @@ export const QualitySection = ({ · 24 unmodified lines | |||
| 70 | <div className="mb-4"> | 63 | <div className="mb-4"> |
| 71 | <div className="mb-2 flex items-center gap-2"> | 64 | <div className="mb-2 flex items-center gap-2"> |
| 72 | <span className="text-xs font-medium text-text-muted">Startup Overhead</span> | 65 | <span className="text-xs font-medium text-text-muted">Startup Overhead</span> |
| 73 | <span | 66 | <AssessmentBadge assessment={startup.overheadAssessment} metricKey="startup" /> |
| 74 | className="rounded px-2 py-0.5 text-xs font-medium" | ||
| 75 | style={{ backgroundColor: `${overheadColor}20`, color: overheadColor }} | ||
| 76 | > | ||
| 77 | {assessmentLabel(startup.overheadAssessment)} | ||
| 78 | </span> | ||
| 79 | </div> | 67 | </div> |
| 80 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> | 68 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> |
| 81 | <div> | 69 | <div> |
| @@ -99,12 +87,10 @@ export const QualitySection = ({ · 17 unmodified lines | |||
| 99 | <div className="mb-4"> | 87 | <div className="mb-4"> |
| 100 | <div className="mb-2 flex items-center gap-2"> | 88 | <div className="mb-2 flex items-center gap-2"> |
| 101 | <span className="text-xs font-medium text-text-muted">File Read Redundancy</span> | 89 | <span className="text-xs font-medium text-text-muted">File Read Redundancy</span> |
| 102 | <span | 90 | <AssessmentBadge |
| 103 | className="rounded px-2 py-0.5 text-xs font-medium" | 91 | assessment={fileReadRedundancy.redundancyAssessment} |
| 104 | style={{ backgroundColor: `${redundancyColor}20`, color: redundancyColor }} | 92 | metricKey="fileReads" |
| 105 | > | 93 | /> |
| 106 | {assessmentLabel(fileReadRedundancy.redundancyAssessment)} | ||
| 107 | </span> | ||
| 108 | </div> | 94 | </div> |
| 109 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> | 95 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> |
| 110 | <div> | 96 | <div> |
| @@ -128,12 +114,7 @@ export const QualitySection = ({ · 17 unmodified lines | |||
| 128 | <div> | 114 | <div> |
| 129 | <div className="mb-2 text-xs font-medium text-text-muted">Test Progression</div> | 115 | <div className="mb-2 text-xs font-medium text-text-muted">Test Progression</div> |
| 130 | <div className="mb-2 flex items-center gap-2"> | 116 | <div className="mb-2 flex items-center gap-2"> |
| 131 | <span | 117 | <AssessmentBadge assessment={testProgression.trajectory} metricKey="testTrajectory" /> |
| 132 | className="rounded px-2 py-0.5 text-xs font-medium" | ||
| 133 | style={{ backgroundColor: `${trajectoryColor}20`, color: trajectoryColor }} | ||
| 134 | > | ||
| 135 | {assessmentLabel(testProgression.trajectory)} | ||
| 136 | </span> | ||
| 137 | <span className="text-xs text-text-muted"> | 118 | <span className="text-xs text-text-muted"> |
| 138 | {testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''} | 119 | {testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''} |
| 139 | </span> | 120 | </span> |
| @@ -15,11 +15,12 @@ const fmtDuration = (ms: number) => { · 14 unmodified lines | |||
| 15 | 15 | ||
| 16 | interface SubagentSectionProps { | 16 | interface SubagentSectionProps { |
| 17 | data: ReportSubagentMetrics; | 17 | data: ReportSubagentMetrics; |
| 18 | defaultCollapsed?: boolean; | ||
| 18 | } | 19 | } |
| 19 | 20 | ||
| 20 | export const SubagentSection = ({ data }: SubagentSectionProps) => { | 21 | export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps) => { |
| 21 | return ( | 22 | return ( |
| 22 | <ReportSection title="Subagents" icon={Users}> | 23 | <ReportSection title="Subagents" icon={Users} defaultCollapsed={defaultCollapsed}> |
| 23 | <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4"> | 24 | <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4"> |
| 24 | <div> | 25 | <div> |
| 25 | <div className="text-xs text-text-muted">Count</div> | 26 | <div className="text-xs text-text-muted">Count</div> |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments'; | 1 | import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments'; |
| 2 | import { Clock } from 'lucide-react'; | 2 | import { Clock } from 'lucide-react'; |
| 3 | 3 | ||
| 4 | import { AssessmentBadge } from '../AssessmentBadge'; | ||
| 4 | import { ReportSection } from '../ReportSection'; | 5 | import { ReportSection } from '../ReportSection'; |
| 5 | 6 | ||
| 6 | import type { | 7 | import type { |
| @@ -13,23 +14,24 @@ interface TimelineSectionProps { · 6 unmodified lines | |||
| 13 | idle: ReportIdleAnalysis; | 14 | idle: ReportIdleAnalysis; |
| 14 | modelSwitches: ReportModelSwitches; | 15 | modelSwitches: ReportModelSwitches; |
| 15 | keyEvents: KeyEvent[]; | 16 | keyEvents: KeyEvent[]; |
| 17 | defaultCollapsed?: boolean; | ||
| 16 | } | 18 | } |
| 17 | 19 | ||
| 18 | export const TimelineSection = ({ idle, modelSwitches, keyEvents }: TimelineSectionProps) => { | 20 | export const TimelineSection = ({ |
| 21 | idle, | ||
| 22 | modelSwitches, | ||
| 23 | keyEvents, | ||
| 24 | defaultCollapsed, | ||
| 25 | }: TimelineSectionProps) => { | ||
| 19 | const idleColor = assessmentColor(idle.idleAssessment); | 26 | const idleColor = assessmentColor(idle.idleAssessment); |
| 20 | 27 | ||
| 21 | return ( | 28 | return ( |
| 22 | <ReportSection title="Timeline & Activity" icon={Clock}> | 29 | <ReportSection title="Timeline & Activity" icon={Clock} defaultCollapsed={defaultCollapsed}> |
| 23 | {/* Idle stats */} | 30 | {/* Idle stats */} |
| 24 | <div className="mb-4"> | 31 | <div className="mb-4"> |
| 25 | <div className="mb-2 flex items-center gap-2"> | 32 | <div className="mb-2 flex items-center gap-2"> |
| 26 | <span className="text-xs font-medium text-text-muted">Idle Analysis</span> | 33 | <span className="text-xs font-medium text-text-muted">Idle Analysis</span> |
| 27 | <span | 34 | <AssessmentBadge assessment={idle.idleAssessment} metricKey="idle" /> |
| 28 | className="rounded px-2 py-0.5 text-xs font-medium" | ||
| 29 | style={{ backgroundColor: `${idleColor}20`, color: idleColor }} | ||
| 30 | > | ||
| 31 | {assessmentLabel(idle.idleAssessment)} | ||
| 32 | </span> | ||
| 33 | </div> | 35 | </div> |
| 34 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> | 36 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> |
| 35 | <div> | 37 | <div> |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments'; | ||
| 2 | import { Coins } from 'lucide-react'; | 1 | import { Coins } from 'lucide-react'; |
| 3 | 2 | ||
| 3 | import { AssessmentBadge } from '../AssessmentBadge'; | ||
| 4 | import { ReportSection } from '../ReportSection'; | 4 | import { ReportSection } from '../ReportSection'; |
| 5 | 5 | ||
| 6 | import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport'; | 6 | import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport'; |
| @@ -11,13 +11,14 @@ const fmtCost = (v: number) => `$${v.toFixed(4)}`; · 4 unmodified lines | |||
| 11 | interface TokenSectionProps { | 11 | interface TokenSectionProps { |
| 12 | data: ReportTokenUsage; | 12 | data: ReportTokenUsage; |
| 13 | cacheEconomics: ReportCacheEconomics; | 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 | const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd); | 18 | const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd); |
| 18 | 19 | ||
| 19 | return ( | 20 | return ( |
| 20 | <ReportSection title="Token Usage" icon={Coins}> | 21 | <ReportSection title="Token Usage" icon={Coins} defaultCollapsed={defaultCollapsed}> |
| 21 | {/* By-model table */} | 22 | {/* By-model table */} |
| 22 | <div className="mb-4 overflow-x-auto"> | 23 | <div className="mb-4 overflow-x-auto"> |
| 23 | <table className="w-full text-xs"> | 24 | <table className="w-full text-xs"> |
| @@ -71,15 +72,10 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { · 47 unmodified lines | |||
| 71 | {cacheEconomics.cacheEfficiencyPct}% | 72 | {cacheEconomics.cacheEfficiencyPct}% |
| 72 | </span> | 73 | </span> |
| 73 | {cacheEconomics.cacheEfficiencyAssessment && ( | 74 | {cacheEconomics.cacheEfficiencyAssessment && ( |
| 74 | <span | 75 | <AssessmentBadge |
| 75 | className="rounded px-2 py-0.5 text-xs font-medium" | 76 | assessment={cacheEconomics.cacheEfficiencyAssessment} |
| 76 | style={{ | 77 | metricKey="cacheEfficiency" |
| 77 | backgroundColor: `${assessmentColor(cacheEconomics.cacheEfficiencyAssessment)}20`, | 78 | /> |
| 78 | color: assessmentColor(cacheEconomics.cacheEfficiencyAssessment), | ||
| 79 | }} | ||
| 80 | > | ||
| 81 | {assessmentLabel(cacheEconomics.cacheEfficiencyAssessment)} | ||
| 82 | </span> | ||
| 83 | )} | 79 | )} |
| 84 | </div> | 80 | </div> |
| 85 | </div> | 81 | </div> |
| @@ -90,15 +86,10 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { · 4 unmodified lines | |||
| 90 | {cacheEconomics.cacheReadToWriteRatio}x | 86 | {cacheEconomics.cacheReadToWriteRatio}x |
| 91 | </span> | 87 | </span> |
| 92 | {cacheEconomics.cacheRatioAssessment && ( | 88 | {cacheEconomics.cacheRatioAssessment && ( |
| 93 | <span | 89 | <AssessmentBadge |
| 94 | className="rounded px-2 py-0.5 text-xs font-medium" | 90 | assessment={cacheEconomics.cacheRatioAssessment} |
| 95 | style={{ | 91 | metricKey="cacheRatio" |
| 96 | backgroundColor: `${assessmentColor(cacheEconomics.cacheRatioAssessment)}20`, | 92 | /> |
| 97 | color: assessmentColor(cacheEconomics.cacheRatioAssessment), | ||
| 98 | }} | ||
| 99 | > | ||
| 100 | {assessmentLabel(cacheEconomics.cacheRatioAssessment)} | ||
| 101 | </span> | ||
| 102 | )} | 93 | )} |
| 103 | </div> | 94 | </div> |
| 104 | </div> | 95 | </div> |
| @@ -110,7 +101,11 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => { · 5 unmodified lines | |||
| 110 | <div className="text-xs text-text-muted">Cold Start</div> | 101 | <div className="text-xs text-text-muted">Cold Start</div> |
| 111 | <div | 102 | <div |
| 112 | className="text-sm font-medium" | 103 | className="text-sm font-medium" |
| 113 | style={{ color: cacheEconomics.coldStartDetected ? '#fbbf24' : '#4ade80' }} | 104 | style={{ |
| 105 | color: cacheEconomics.coldStartDetected | ||
| 106 | ? 'var(--assess-warning)' | ||
| 107 | : 'var(--assess-good)', | ||
| 108 | }} | ||
| 114 | > | 109 | > |
| 115 | {cacheEconomics.coldStartDetected ? 'Yes' : 'No'} | 110 | {cacheEconomics.coldStartDetected ? 'Yes' : 'No'} |
| 116 | </div> | 111 | </div> |
| @@ -1,36 +1,28 @@ | |||
| 1 | import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments'; | 1 | import { assessmentColor } from '@renderer/utils/reportAssessments'; |
| 2 | import { Wrench } from 'lucide-react'; | 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 | import type { ReportToolUsage } from '@renderer/types/sessionReport'; | 7 | import type { ReportToolUsage } from '@renderer/types/sessionReport'; |
| 7 | 8 | ||
| 8 | interface ToolSectionProps { | 9 | interface ToolSectionProps { |
| 9 | data: ReportToolUsage; | 10 | data: ReportToolUsage; |
| 11 | defaultCollapsed?: boolean; | ||
| 10 | } | 12 | } |
| 11 | 13 | ||
| 12 | export const ToolSection = ({ data }: ToolSectionProps) => { | 14 | export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => { |
| 13 | const toolEntries = Object.entries(data.successRates).sort( | 15 | const toolEntries = Object.entries(data.successRates).sort( |
| 14 | (a, b) => b[1].totalCalls - a[1].totalCalls | 16 | (a, b) => b[1].totalCalls - a[1].totalCalls |
| 15 | ); | 17 | ); |
| 16 | 18 | ||
| 17 | const healthColor = assessmentColor(data.overallToolHealth); | ||
| 18 | |||
| 19 | return ( | 19 | return ( |
| 20 | <ReportSection title="Tool Usage" icon={Wrench}> | 20 | <ReportSection title="Tool Usage" icon={Wrench} defaultCollapsed={defaultCollapsed}> |
| 21 | <div className="mb-2 flex items-center gap-2"> | 21 | <div className="mb-2 flex items-center gap-2"> |
| 22 | <span className="text-xs text-text-muted"> | 22 | <span className="text-xs text-text-muted"> |
| 23 | {data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools | 23 | {data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools |
| 24 | </span> | 24 | </span> |
| 25 | <span | 25 | <AssessmentBadge assessment={data.overallToolHealth} metricKey="toolHealth" /> |
| 26 | className="rounded px-2 py-0.5 text-xs font-medium" | ||
| 27 | style={{ | ||
| 28 | backgroundColor: `${healthColor}20`, | ||
| 29 | color: healthColor, | ||
| 30 | }} | ||
| 31 | > | ||
| 32 | {assessmentLabel(data.overallToolHealth)} | ||
| 33 | </span> | ||
| 34 | </div> | 26 | </div> |
| 35 | <div className="overflow-x-auto"> | 27 | <div className="overflow-x-auto"> |
| 36 | <table className="w-full text-xs"> | 28 | <table className="w-full text-xs"> |
| @@ -53,18 +45,26 @@ export const ToolSection = ({ data }: ToolSectionProps) => { · 16 unmodified lines | |||
| 53 | {stats.totalCalls.toLocaleString()} | 45 | {stats.totalCalls.toLocaleString()} |
| 54 | </td> | 46 | </td> |
| 55 | <td className="py-1.5 pr-4 text-right text-text"> | 47 | <td className="py-1.5 pr-4 text-right text-text"> |
| 56 | {stats.errors.toLocaleString()} | 48 | {stats.errors > 0 ? ( |
| 49 | <button | ||
| 50 | type="button" | ||
| 51 | onClick={() => { | ||
| 52 | const el = document.getElementById(sectionId('Errors')); | ||
| 53 | if (el) el.dispatchEvent(new CustomEvent('report-section-expand')); | ||
| 54 | }} | ||
| 55 | className="text-red-400 underline decoration-red-400/30 underline-offset-2 hover:decoration-red-400" | ||
| 56 | > | ||
| 57 | {stats.errors.toLocaleString()} | ||
| 58 | </button> | ||
| 59 | ) : ( | ||
| 60 | stats.errors.toLocaleString() | ||
| 61 | )} | ||
| 57 | </td> | 62 | </td> |
| 58 | <td className="py-1.5 pr-4 text-right" style={{ color }}> | 63 | <td className="py-1.5 pr-4 text-right" style={{ color }}> |
| 59 | {stats.successRatePct}% | 64 | {stats.successRatePct}% |
| 60 | </td> | 65 | </td> |
| 61 | <td className="py-1.5 text-right"> | 66 | <td className="py-1.5 text-right"> |
| 62 | <span | 67 | <AssessmentBadge assessment={stats.assessment} metricKey="toolHealth" /> |
| 63 | className="rounded px-1.5 py-0.5 text-[10px] font-medium" | ||
| 64 | style={{ backgroundColor: `${color}20`, color }} | ||
| 65 | > | ||
| 66 | {assessmentLabel(stats.assessment)} | ||
| 67 | </span> | ||
| 68 | </td> | 68 | </td> |
| 69 | </tr> | 69 | </tr> |
| 70 | ); | 70 | ); |
| @@ -181,6 +181,12 @@ · 180 unmodified lines | |||
| 181 | --card-text-lighter: #e4e4e7; | 181 | --card-text-lighter: #e4e4e7; |
| 182 | --card-separator: #3f3f46; | 182 | --card-separator: #3f3f46; |
| 183 | 183 | ||
| 184 | /* Assessment severity colors (badges, health indicators) */ | ||
| 185 | --assess-good: #4ade80; | ||
| 186 | --assess-warning: #fbbf24; | ||
| 187 | --assess-danger: #f87171; | ||
| 188 | --assess-neutral: #a1a1aa; | ||
| 189 | |||
| 184 | /* Sticky Context button - transparent glass */ | 190 | /* Sticky Context button - transparent glass */ |
| 185 | --context-btn-bg: rgba(255, 255, 255, 0.08); | 191 | --context-btn-bg: rgba(255, 255, 255, 0.08); |
| 186 | --context-btn-bg-hover: rgba(255, 255, 255, 0.14); | 192 | --context-btn-bg-hover: rgba(255, 255, 255, 0.14); |
| @@ -206,6 +212,12 @@ · 19 unmodified lines | |||
| 206 | --color-text-secondary: #4d4b46; /* Warm secondary text */ | 212 | --color-text-secondary: #4d4b46; /* Warm secondary text */ |
| 207 | --color-text-muted: #6d6b65; /* Warm muted text */ | 213 | --color-text-muted: #6d6b65; /* Warm muted text */ |
| 208 | 214 | ||
| 215 | /* Assessment severity colors - darker for light backgrounds */ | ||
| 216 | --assess-good: #16a34a; | ||
| 217 | --assess-warning: #d97706; | ||
| 218 | --assess-danger: #dc2626; | ||
| 219 | --assess-neutral: #57534e; | ||
| 220 | |||
| 209 | /* Scrollbar colors for light mode */ | 221 | /* Scrollbar colors for light mode */ |
| 210 | --scrollbar-thumb: rgba(0, 0, 0, 0.15); | 222 | --scrollbar-thumb: rgba(0, 0, 0, 0.15); |
| 211 | --scrollbar-thumb-hover: rgba(0, 0, 0, 0.28); | 223 | --scrollbar-thumb-hover: rgba(0, 0, 0, 0.28); |
| @@ -15,7 +15,14 @@ export type Severity = 'good' | 'warning' | 'danger' | 'neutral'; · 14 unmodified lines | |||
| 15 | // Colors | 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 | good: '#4ade80', | 26 | good: '#4ade80', |
| 20 | warning: '#fbbf24', | 27 | warning: '#fbbf24', |
| 21 | danger: '#f87171', | 28 | danger: '#f87171', |
| @@ -23,7 +30,11 @@ const SEVERITY_COLORS: Record<Severity, string> = { · 1 unmodified line | |||
| 23 | }; | 30 | }; |
| 24 | 31 | ||
| 25 | export function severityColor(severity: Severity): string { | 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 | } as const; | 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 | // Assessment Computers | 246 | // Assessment Computers |
| 150 | // ============================================================================= | 247 | // ============================================================================= |
| @@ -268,3 +365,191 @@ export function detectSwitchPattern( · 117 unmodified lines | |||
| 268 | 365 | ||
| 269 | return 'manual_switch'; | 366 | return 'manual_switch'; |
| 270 | } | 367 | } |
| 368 | |||
| 369 | // ============================================================================= | ||
| 370 | // Key Takeaways | ||
| 371 | // ============================================================================= | ||
| 372 | |||
| 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 | cache_creation: 3.75, | 103 | cache_creation: 3.75, |
| 104 | }; | 104 | }; |
| 105 | 105 | ||
| 106 | function getPricing(modelName: string): ModelPricing { | 106 | export function getPricing(modelName: string): ModelPricing { |
| 107 | const name = modelName.toLowerCase(); | 107 | const name = modelName.toLowerCase(); |
| 108 | for (const [key, pricing] of Object.entries(MODEL_PRICING)) { | 108 | for (const [key, pricing] of Object.entries(MODEL_PRICING)) { |
| 109 | if (name.includes(key)) return pricing; | 109 | if (name.includes(key)) return pricing; |
| @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | assessmentColor, | 4 | assessmentColor, |
| 5 | assessmentExplanation, | ||
| 5 | assessmentLabel, | 6 | assessmentLabel, |
| 6 | assessmentSeverity, | 7 | assessmentSeverity, |
| 7 | computeCacheEfficiencyAssessment, | 8 | computeCacheEfficiencyAssessment, |
| @@ -12,6 +13,7 @@ import { · 4 unmodified lines | |||
| 12 | computeOverheadAssessment, | 13 | computeOverheadAssessment, |
| 13 | computeRedundancyAssessment, | 14 | computeRedundancyAssessment, |
| 14 | computeSubagentCostShareAssessment, | 15 | computeSubagentCostShareAssessment, |
| 16 | computeTakeaways, | ||
| 15 | computeThrashingAssessment, | 17 | computeThrashingAssessment, |
| 16 | computeToolHealthAssessment, | 18 | computeToolHealthAssessment, |
| 17 | detectModelMismatch, | 19 | detectModelMismatch, |
| @@ -20,6 +22,8 @@ import { · 2 unmodified lines | |||
| 20 | THRESHOLDS, | 22 | THRESHOLDS, |
| 21 | } from '@renderer/utils/reportAssessments'; | 23 | } from '@renderer/utils/reportAssessments'; |
| 22 | 24 | ||
| 25 | import type { MetricKey } from '@renderer/utils/reportAssessments'; | ||
| 26 | |||
| 23 | describe('reportAssessments', () => { | 27 | describe('reportAssessments', () => { |
| 24 | describe('severityColor', () => { | 28 | describe('severityColor', () => { |
| 25 | it('maps severity to hex color', () => { | 29 | it('maps severity to hex color', () => { |
| @@ -256,4 +260,139 @@ describe('reportAssessments', () => { · 230 unmodified lines | |||
| 256 | ).toBe('manual_switch'); | 260 | ).toBe('manual_switch'); |
| 257 | }); | 261 | }); |
| 258 | }); | 262 | }); |
| 263 | |||
| 264 | describe('assessmentExplanation', () => { | ||
| 265 | const ALL_METRIC_ASSESSMENTS: Record<MetricKey, string[]> = { | ||
| 266 | costPerCommit: ['efficient', 'normal', 'expensive', 'red_flag'], | ||
| 267 | costPerLine: ['efficient', 'normal', 'expensive', 'red_flag'], | ||
| 268 | subagentCostShare: ['normal', 'high', 'very_high', 'red_flag'], | ||
| 269 | cacheEfficiency: ['good', 'concerning'], | ||
| 270 | cacheRatio: ['good', 'concerning'], | ||
| 271 | toolHealth: ['healthy', 'degraded', 'unreliable'], | ||
| 272 | idle: ['efficient', 'moderate', 'high_idle'], | ||
| 273 | fileReads: ['normal', 'wasteful'], | ||
| 274 | startup: ['normal', 'heavy'], | ||
| 275 | thrashing: ['none', 'mild', 'severe'], | ||
| 276 | promptQuality: [ | ||
| 277 | 'well_specified', | ||
| 278 | 'moderate_friction', | ||
| 279 | 'underspecified', | ||
| 280 | 'verbose_but_unclear', | ||
| 281 | ], | ||
| 282 | testTrajectory: ['improving', 'stable', 'regressing', 'insufficient_data'], | ||
| 283 | }; | ||
| 284 | |||
| 285 | it('returns non-empty string for all valid metric/assessment combos', () => { | ||
| 286 | for (const [metricKey, assessments] of Object.entries(ALL_METRIC_ASSESSMENTS)) { | ||
| 287 | for (const assessment of assessments) { | ||
| 288 | const result = assessmentExplanation(metricKey as MetricKey, assessment); | ||
| 289 | expect(result, `${metricKey}/${assessment}`).not.toBe(''); | ||
| 290 | } | ||
| 291 | } | ||
| 292 | }); | ||
| 293 | |||
| 294 | it('returns empty string for unknown combinations', () => { | ||
| 295 | expect(assessmentExplanation('costPerCommit', 'unknown_value')).toBe(''); | ||
| 296 | expect(assessmentExplanation('toolHealth' as MetricKey, 'nonexistent')).toBe(''); | ||
| 297 | }); | ||
| 298 | |||
| 299 | it('includes threshold values in explanations', () => { | ||
| 300 | expect(assessmentExplanation('costPerCommit', 'efficient')).toContain( | ||
| 301 | String(THRESHOLDS.costPerCommit.efficient) | ||
| 302 | ); | ||
| 303 | expect(assessmentExplanation('toolHealth', 'healthy')).toContain( | ||
| 304 | String(THRESHOLDS.toolSuccess.healthy) | ||
| 305 | ); | ||
| 306 | }); | ||
| 307 | }); | ||
| 308 | |||
| 309 | describe('computeTakeaways', () => { | ||
| 310 | const healthyReport = { | ||
| 311 | costAnalysis: { | ||
| 312 | costPerCommitAssessment: 'efficient', | ||
| 313 | costPerLineAssessment: 'efficient', | ||
| 314 | totalSessionCostUsd: 0.5, | ||
| 315 | }, | ||
| 316 | cacheEconomics: { cacheEfficiencyAssessment: 'good', cacheEfficiencyPct: 97 }, | ||
| 317 | toolUsage: { overallToolHealth: 'healthy' }, | ||
| 318 | thrashingSignals: { | ||
| 319 | thrashingAssessment: 'none', | ||
| 320 | bashNearDuplicates: [], | ||
| 321 | editReworkFiles: [], | ||
| 322 | }, | ||
| 323 | idleAnalysis: { idleAssessment: 'efficient', idlePct: 10 }, | ||
| 324 | promptQuality: { assessment: 'well_specified', frictionRate: 0.05 }, | ||
| 325 | overview: { contextAssessment: 'healthy', compactionCount: 0 }, | ||
| 326 | fileReadRedundancy: { redundancyAssessment: 'normal', readsPerUniqueFile: 1.5 }, | ||
| 327 | testProgression: { trajectory: 'improving' }, | ||
| 328 | }; | ||
| 329 | |||
| 330 | it('returns healthy message when all metrics are good', () => { | ||
| 331 | const result = computeTakeaways(healthyReport); | ||
| 332 | expect(result).toHaveLength(1); | ||
| 333 | expect(result[0].severity).toBe('good'); | ||
| 334 | expect(result[0].title).toContain('healthy'); | ||
| 335 | }); | ||
| 336 | |||
| 337 | it('detects cost red flags', () => { | ||
| 338 | const report = { | ||
| 339 | ...healthyReport, | ||
| 340 | costAnalysis: { | ||
| 341 | ...healthyReport.costAnalysis, | ||
| 342 | costPerCommitAssessment: 'red_flag', | ||
| 343 | totalSessionCostUsd: 15, | ||
| 344 | }, | ||
| 345 | }; | ||
| 346 | const result = computeTakeaways(report); | ||
| 347 | expect(result.some((t) => t.severity === 'danger' && t.title.includes('cost'))).toBe(true); | ||
| 348 | }); | ||
| 349 | |||
| 350 | it('detects thrashing', () => { | ||
| 351 | const report = { | ||
| 352 | ...healthyReport, | ||
| 353 | thrashingSignals: { | ||
| 354 | thrashingAssessment: 'severe', | ||
| 355 | bashNearDuplicates: [{}], | ||
| 356 | editReworkFiles: [], | ||
| 357 | }, | ||
| 358 | }; | ||
| 359 | const result = computeTakeaways(report); | ||
| 360 | expect(result.some((t) => t.title.includes('thrashing'))).toBe(true); | ||
| 361 | }); | ||
| 362 | |||
| 363 | it('limits to 4 takeaways', () => { | ||
| 364 | const report = { | ||
| 365 | ...healthyReport, | ||
| 366 | costAnalysis: { | ||
| 367 | ...healthyReport.costAnalysis, | ||
| 368 | costPerCommitAssessment: 'red_flag', | ||
| 369 | totalSessionCostUsd: 15, | ||
| 370 | }, | ||
| 371 | cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 }, | ||
| 372 | toolUsage: { overallToolHealth: 'unreliable' }, | ||
| 373 | thrashingSignals: { | ||
| 374 | thrashingAssessment: 'severe', | ||
| 375 | bashNearDuplicates: [{}], | ||
| 376 | editReworkFiles: [], | ||
| 377 | }, | ||
| 378 | promptQuality: { assessment: 'underspecified', frictionRate: 0.5 }, | ||
| 379 | overview: { contextAssessment: 'critical', compactionCount: 3 }, | ||
| 380 | fileReadRedundancy: { redundancyAssessment: 'wasteful', readsPerUniqueFile: 4 }, | ||
| 381 | testProgression: { trajectory: 'regressing' }, | ||
| 382 | }; | ||
| 383 | const result = computeTakeaways(report); | ||
| 384 | expect(result.length).toBeLessThanOrEqual(4); | ||
| 385 | }); | ||
| 386 | |||
| 387 | it('sorts danger before warning', () => { | ||
| 388 | const report = { | ||
| 389 | ...healthyReport, | ||
| 390 | cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 }, | ||
| 391 | toolUsage: { overallToolHealth: 'unreliable' }, | ||
| 392 | }; | ||
| 393 | const result = computeTakeaways(report); | ||
| 394 | expect(result.length).toBeGreaterThanOrEqual(2); | ||
| 395 | expect(result[0].severity).toBe('danger'); | ||
| 396 | }); | ||
| 397 | }); | ||
| 259 | }); | 398 | }); |