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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Holstein 2026-02-22 08:29:08 -05:00
parent 1ad2eca8f0
commit 4fda90bc3e
18 changed files with 921 additions and 176 deletions

View file

@ -0,0 +1,78 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
assessmentColor,
assessmentExplanation,
assessmentLabel,
} from '@renderer/utils/reportAssessments';
import type { MetricKey } from '@renderer/utils/reportAssessments';
interface AssessmentBadgeProps {
assessment: string;
metricKey?: MetricKey;
}
export const AssessmentBadge = ({ assessment, metricKey }: AssessmentBadgeProps) => {
const color = assessmentColor(assessment);
const explanation = metricKey ? assessmentExplanation(metricKey, assessment) : '';
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 });
const badgeRef = useRef<HTMLSpanElement>(null);
const enterTimer = useRef<ReturnType<typeof setTimeout>>();
const leaveTimer = useRef<ReturnType<typeof setTimeout>>();
const handleMouseEnter = useCallback(() => {
if (!explanation) return;
clearTimeout(leaveTimer.current);
enterTimer.current = setTimeout(() => {
if (badgeRef.current) {
const rect = badgeRef.current.getBoundingClientRect();
setTooltipPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 });
}
setShowTooltip(true);
}, 200);
}, [explanation]);
const handleMouseLeave = useCallback(() => {
clearTimeout(enterTimer.current);
leaveTimer.current = setTimeout(() => setShowTooltip(false), 150);
}, []);
useEffect(() => {
return () => {
clearTimeout(enterTimer.current);
clearTimeout(leaveTimer.current);
};
}, []);
return (
<>
<span
ref={badgeRef}
className="rounded px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${color}20`, color }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{assessmentLabel(assessment)}
</span>
{showTooltip &&
explanation &&
createPortal(
<div
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"
style={{
top: tooltipPos.top,
left: tooltipPos.left,
transform: 'translateX(-50%)',
}}
>
{explanation}
</div>,
document.body
)}
</>
);
};

View file

@ -1,7 +1,10 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
const sectionId = (title: string) =>
`report-section-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
interface ReportSectionProps {
title: string;
icon: React.ComponentType<{ className?: string }>;
@ -16,9 +19,25 @@ export const ReportSection = ({
defaultCollapsed = false,
}: ReportSectionProps) => {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const handler = () => {
setCollapsed(false);
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
el.addEventListener('report-section-expand', handler);
return () => el.removeEventListener('report-section-expand', handler);
}, []);
return (
<div className="rounded-lg border border-border bg-surface-raised">
<div
ref={ref}
id={sectionId(title)}
className="rounded-lg border border-border bg-surface-raised"
>
<button
onClick={() => setCollapsed(!collapsed)}
className="flex w-full items-center gap-2 p-4 text-left"
@ -35,3 +54,5 @@ export const ReportSection = ({
</div>
);
};
export { sectionId };

View file

@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useStore } from '@renderer/store';
import { computeTakeaways } from '@renderer/utils/reportAssessments';
import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
import { CostSection } from './sections/CostSection';
@ -8,6 +9,7 @@ import { ErrorSection } from './sections/ErrorSection';
import { FrictionSection } from './sections/FrictionSection';
import { GitSection } from './sections/GitSection';
import { InsightsSection } from './sections/InsightsSection';
import { KeyTakeawaysSection } from './sections/KeyTakeawaysSection';
import { OverviewSection } from './sections/OverviewSection';
import { QualitySection } from './sections/QualitySection';
import { SubagentSection } from './sections/SubagentSection';
@ -34,6 +36,8 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
[sessionDetail]
);
const takeaways = useMemo(() => (report ? computeTakeaways(report) : []), [report]);
if (!report) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
@ -46,24 +50,38 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
<div className="h-full overflow-y-auto p-6" style={{ backgroundColor: 'var(--color-surface)' }}>
<h1 className="mb-6 text-lg font-semibold text-text">Session Analysis Report</h1>
<div className="flex flex-col gap-4">
{takeaways.length > 0 && <KeyTakeawaysSection takeaways={takeaways} />}
<OverviewSection data={report.overview} />
<CostSection data={report.costAnalysis} />
<CostSection
data={report.costAnalysis}
tokensByModel={report.tokenUsage.byModel}
commitCount={report.gitActivity.commitCount}
linesChanged={report.gitActivity.linesChanged}
/>
<TokenSection data={report.tokenUsage} cacheEconomics={report.cacheEconomics} />
<ToolSection data={report.toolUsage} />
{report.subagentMetrics.count > 0 && <SubagentSection data={report.subagentMetrics} />}
{report.errors.errors.length > 0 && <ErrorSection data={report.errors} />}
<GitSection data={report.gitActivity} />
<FrictionSection data={report.frictionSignals} thrashing={report.thrashingSignals} />
{report.subagentMetrics.count > 0 && (
<SubagentSection data={report.subagentMetrics} defaultCollapsed />
)}
{report.errors.errors.length > 0 && <ErrorSection data={report.errors} defaultCollapsed />}
<GitSection data={report.gitActivity} defaultCollapsed />
<FrictionSection
data={report.frictionSignals}
thrashing={report.thrashingSignals}
defaultCollapsed
/>
<TimelineSection
idle={report.idleAnalysis}
modelSwitches={report.modelSwitches}
keyEvents={report.keyEvents}
defaultCollapsed
/>
<QualitySection
prompt={report.promptQuality}
startup={report.startupOverhead}
testProgression={report.testProgression}
fileReadRedundancy={report.fileReadRedundancy}
defaultCollapsed
/>
<InsightsSection
skills={report.skillsInvoked}
@ -73,6 +91,7 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
outOfScope={report.outOfScopeFindings}
agentTree={report.agentTree}
subagentsList={report.subagentsList}
defaultCollapsed
/>
</div>
</div>

View file

@ -1,105 +1,225 @@
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
import { Fragment, useState } from 'react';
import { getPricing } from '@renderer/utils/sessionAnalyzer';
import { DollarSign } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection } from '../ReportSection';
import type { ReportCostAnalysis } from '@renderer/types/sessionReport';
import type { ModelTokenStats, ReportCostAnalysis } from '@renderer/types/sessionReport';
import type { ModelPricing } from '@renderer/types/sessionReport';
const fmt = (v: number) => `$${v.toFixed(4)}`;
const fmtK = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v));
const fmtRate = (v: number) => `$${v}`;
const lineCost = (tokens: number, ratePerM: number) => (tokens * ratePerM) / 1_000_000;
interface CostSectionProps {
data: ReportCostAnalysis;
tokensByModel: Record<string, ModelTokenStats>;
commitCount: number;
linesChanged: number;
defaultCollapsed?: boolean;
}
export const CostSection = ({ data }: CostSectionProps) => {
const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]);
interface BreakdownLine {
label: string;
tokens: number;
ratePerM: number;
}
const CostBreakdownCard = ({
stats,
pricing,
}: {
stats: ModelTokenStats;
pricing: ModelPricing;
}) => {
const lines: BreakdownLine[] = [
{ label: 'Input', tokens: stats.inputTokens, ratePerM: pricing.input },
{ label: 'Output', tokens: stats.outputTokens, ratePerM: pricing.output },
{ label: 'Cache Read', tokens: stats.cacheRead, ratePerM: pricing.cache_read },
{ label: 'Cache Write', tokens: stats.cacheCreation, ratePerM: pricing.cache_creation },
];
const total = lines.reduce((sum, l) => sum + lineCost(l.tokens, l.ratePerM), 0);
return (
<ReportSection title="Cost Analysis" icon={DollarSign}>
<div className="rounded-md border border-border bg-surface-raised px-4 py-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-text-muted">
Cost Breakdown (per 1M tokens)
</div>
<div className="flex flex-col gap-1.5 font-mono text-xs">
{lines.map((l) => {
const cost = lineCost(l.tokens, l.ratePerM);
return (
<div key={l.label} className="flex items-baseline justify-between gap-4">
<span className="text-text-muted">{l.label}</span>
<span className="text-text-secondary">
{l.tokens.toLocaleString()} {'\u00D7'} {fmtRate(l.ratePerM)}/M = {fmt(cost)}
</span>
</div>
);
})}
<div className="mt-1 flex items-baseline justify-between gap-4 border-t border-border pt-1.5">
<span className="font-medium text-text">Total</span>
<span className="font-medium text-text">{fmt(total)}</span>
</div>
</div>
</div>
);
};
export const CostSection = ({
data,
tokensByModel,
commitCount,
linesChanged,
defaultCollapsed,
}: CostSectionProps) => {
const [expandedModel, setExpandedModel] = useState<string | null>(null);
const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]);
const showStackedBar = data.subagentCostUsd > 0;
const parentPct =
showStackedBar && data.totalSessionCostUsd > 0
? (data.parentCostUsd / data.totalSessionCostUsd) * 100
: 100;
return (
<ReportSection title="Cost Analysis" icon={DollarSign} defaultCollapsed={defaultCollapsed}>
<div className="mb-4 text-2xl font-bold text-text">{fmt(data.totalSessionCostUsd)}</div>
{/* Parent/Subagent stacked bar */}
{showStackedBar && (
<div className="mb-4">
<div className="mb-1.5 flex h-3 w-full overflow-hidden rounded-full">
<div
className="h-full"
style={{ width: `${parentPct}%`, backgroundColor: '#60a5fa' }}
/>
<div
className="h-full"
style={{ width: `${100 - parentPct}%`, backgroundColor: '#c084fc' }}
/>
</div>
<div className="flex gap-4 text-xs">
<div className="flex items-center gap-1.5">
<span
className="inline-block size-2 rounded-full"
style={{ backgroundColor: '#60a5fa' }}
/>
<span className="text-text-secondary">Parent: {fmt(data.parentCostUsd)}</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="inline-block size-2 rounded-full"
style={{ backgroundColor: '#c084fc' }}
/>
<span className="text-text-secondary">Subagent: {fmt(data.subagentCostUsd)}</span>
</div>
</div>
</div>
)}
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">Parent Cost</div>
<div className="text-sm font-medium text-text">{fmt(data.parentCostUsd)}</div>
</div>
<div>
<div className="text-xs text-text-muted">Subagent Cost</div>
<div className="text-sm font-medium text-text">{fmt(data.subagentCostUsd)}</div>
</div>
{!showStackedBar && (
<>
<div>
<div className="text-xs text-text-muted">Parent Cost</div>
<div className="text-sm font-medium text-text">{fmt(data.parentCostUsd)}</div>
</div>
<div>
<div className="text-xs text-text-muted">Subagent Cost</div>
<div className="text-sm font-medium text-text">{fmt(data.subagentCostUsd)}</div>
</div>
</>
)}
<div>
<div className="text-xs text-text-muted">Per Commit</div>
<div className="text-[10px] text-text-muted">
total cost {'\u00F7'} {commitCount} commit{commitCount !== 1 ? 's' : ''}
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text">
{data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'}
</span>
{data.costPerCommitAssessment && (
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${assessmentColor(data.costPerCommitAssessment)}20`,
color: assessmentColor(data.costPerCommitAssessment),
}}
>
{assessmentLabel(data.costPerCommitAssessment)}
</span>
<AssessmentBadge
assessment={data.costPerCommitAssessment}
metricKey="costPerCommit"
/>
)}
</div>
</div>
<div>
<div className="text-xs text-text-muted">Per Line Changed</div>
<div className="text-[10px] text-text-muted">
total cost {'\u00F7'} {linesChanged.toLocaleString()} line
{linesChanged !== 1 ? 's' : ''}
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text">
{data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'}
</span>
{data.costPerLineAssessment && (
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${assessmentColor(data.costPerLineAssessment)}20`,
color: assessmentColor(data.costPerLineAssessment),
}}
>
{assessmentLabel(data.costPerLineAssessment)}
</span>
<AssessmentBadge assessment={data.costPerLineAssessment} metricKey="costPerLine" />
)}
</div>
</div>
</div>
{data.subagentCostSharePct != null && (
<div className="mb-4 flex items-center gap-2">
<span className="text-xs text-text-muted">Subagent Cost Share:</span>
<span className="text-sm font-medium text-text">{data.subagentCostSharePct}%</span>
{data.subagentCostShareAssessment && (
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${assessmentColor(data.subagentCostShareAssessment)}20`,
color: assessmentColor(data.subagentCostShareAssessment),
}}
>
{assessmentLabel(data.subagentCostShareAssessment)}
</span>
)}
</div>
)}
{modelEntries.length > 0 && (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-left text-text-muted">
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4 text-right">Input</th>
<th className="pb-2 pr-4 text-right">Output</th>
<th className="pb-2 pr-4 text-right">Cache Read</th>
<th className="pb-2 pr-4 text-right">Cache Write</th>
<th className="pb-2 pr-4 text-right">Cost</th>
</tr>
</thead>
<tbody>
{modelEntries.map(([model, cost]) => (
<tr key={model} className="border-border/50 border-b">
<td className="py-1.5 pr-4 text-text">{model}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(cost)}</td>
</tr>
))}
{modelEntries.map(([model, cost]) => {
const stats = tokensByModel[model];
const isExpanded = expandedModel === model;
const pricing = getPricing(model);
return (
<Fragment key={model}>
<tr
className="border-border/50 hover:bg-surface-raised/50 cursor-pointer border-b"
onClick={() => setExpandedModel(isExpanded ? null : model)}
>
<td className="py-1.5 pr-4 text-text">
<span className="mr-1.5 inline-block w-3 text-text-muted">
{isExpanded ? '\u25BC' : '\u25B6'}
</span>
{model}
</td>
<td className="py-1.5 pr-4 text-right text-text-secondary">
{stats ? fmtK(stats.inputTokens) : '—'}
</td>
<td className="py-1.5 pr-4 text-right text-text-secondary">
{stats ? fmtK(stats.outputTokens) : '—'}
</td>
<td className="py-1.5 pr-4 text-right text-text-secondary">
{stats ? fmtK(stats.cacheRead) : '—'}
</td>
<td className="py-1.5 pr-4 text-right text-text-secondary">
{stats ? fmtK(stats.cacheCreation) : '—'}
</td>
<td className="py-1.5 pr-4 text-right font-medium text-text">{fmt(cost)}</td>
</tr>
{isExpanded && stats && (
<tr>
<td colSpan={6} className="px-4 pb-3 pt-1">
<CostBreakdownCard stats={stats} pricing={pricing} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
)}

View file

@ -28,7 +28,10 @@ const ErrorItem = ({ error }: ErrorItemProps) => {
{error.isPermissionDenial && (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium"
style={{ backgroundColor: 'rgba(248, 113, 113, 0.15)', color: '#f87171' }}
style={{
backgroundColor: 'color-mix(in srgb, var(--assess-danger) 15%, transparent)',
color: 'var(--assess-danger)',
}}
>
Permission Denied
</span>
@ -36,8 +39,28 @@ const ErrorItem = ({ error }: ErrorItemProps) => {
<span className="ml-auto text-text-muted">msg #{error.messageIndex}</span>
</button>
{expanded && (
<div className="mt-2 whitespace-pre-wrap break-words rounded bg-surface-raised p-2 text-xs text-text-secondary">
{error.error}
<div className="mt-2 flex flex-col gap-1.5">
{error.inputPreview && (
<div className="rounded bg-surface-raised p-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-text-muted">
Input
</div>
<div className="whitespace-pre-wrap break-words font-mono text-xs text-text-secondary">
{error.inputPreview}
</div>
</div>
)}
<div className="rounded bg-surface-raised p-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-text-muted">
Error
</div>
<div
className="whitespace-pre-wrap break-words text-xs"
style={{ color: 'var(--assess-danger)' }}
>
{error.error}
</div>
</div>
</div>
)}
</div>
@ -46,15 +69,19 @@ const ErrorItem = ({ error }: ErrorItemProps) => {
interface ErrorSectionProps {
data: ReportErrors;
defaultCollapsed?: boolean;
}
export const ErrorSection = ({ data }: ErrorSectionProps) => {
export const ErrorSection = ({ data, defaultCollapsed }: ErrorSectionProps) => {
return (
<ReportSection title="Errors" icon={AlertTriangle}>
<ReportSection title="Errors" icon={AlertTriangle} defaultCollapsed={defaultCollapsed}>
<div className="mb-3 flex items-center gap-3">
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: 'rgba(248, 113, 113, 0.15)', color: '#f87171' }}
style={{
backgroundColor: 'color-mix(in srgb, var(--assess-danger) 15%, transparent)',
color: 'var(--assess-danger)',
}}
>
{data.errors.length} error{data.errors.length !== 1 ? 's' : ''}
</span>

View file

@ -1,6 +1,7 @@
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
import { severityColor } from '@renderer/utils/reportAssessments';
import { MessageSquareWarning } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection } from '../ReportSection';
import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport';
@ -8,20 +9,25 @@ import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/ty
interface FrictionSectionProps {
data: ReportFrictionSignals;
thrashing: ReportThrashingSignals;
defaultCollapsed?: boolean;
}
export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => {
const frictionColor =
data.frictionRate <= 0.1 ? '#4ade80' : data.frictionRate <= 0.25 ? '#fbbf24' : '#f87171';
const thrashColor = assessmentColor(thrashing.thrashingAssessment);
export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionSectionProps) => {
const frictionSeverity =
data.frictionRate <= 0.1 ? 'good' : data.frictionRate <= 0.25 ? 'warning' : 'danger';
const frictionColor = severityColor(frictionSeverity);
return (
<ReportSection title="Friction Signals" icon={MessageSquareWarning}>
<ReportSection
title="Friction Signals"
icon={MessageSquareWarning}
defaultCollapsed={defaultCollapsed}
>
<div className="mb-4 flex items-center gap-3">
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${frictionColor}20`,
backgroundColor: `color-mix(in srgb, ${frictionColor} 12%, transparent)`,
color: frictionColor,
}}
>
@ -40,7 +46,10 @@ export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => {
<div key={idx} className="flex items-start gap-2 rounded px-2 py-1 text-xs">
<span
className="shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]"
style={{ backgroundColor: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24' }}
style={{
backgroundColor: 'color-mix(in srgb, var(--assess-warning) 15%, transparent)',
color: 'var(--assess-warning)',
}}
>
{corr.keyword}
</span>
@ -55,12 +64,7 @@ export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => {
<div>
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">Thrashing Signals</span>
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${thrashColor}20`, color: thrashColor }}
>
{assessmentLabel(thrashing.thrashingAssessment)}
</span>
<AssessmentBadge assessment={thrashing.thrashingAssessment} metricKey="thrashing" />
</div>
{thrashing.bashNearDuplicates.length > 0 && (

View file

@ -6,11 +6,12 @@ import type { ReportGitActivity } from '@renderer/types/sessionReport';
interface GitSectionProps {
data: ReportGitActivity;
defaultCollapsed?: boolean;
}
export const GitSection = ({ data }: GitSectionProps) => {
export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => {
return (
<ReportSection title="Git Activity" icon={GitBranch}>
<ReportSection title="Git Activity" icon={GitBranch} defaultCollapsed={defaultCollapsed}>
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">Commits</div>
@ -22,13 +23,13 @@ export const GitSection = ({ data }: GitSectionProps) => {
</div>
<div>
<div className="text-xs text-text-muted">Lines Added</div>
<div className="text-sm font-medium" style={{ color: '#4ade80' }}>
<div className="text-sm font-medium" style={{ color: 'var(--assess-good)' }}>
+{data.linesAdded.toLocaleString()}
</div>
</div>
<div>
<div className="text-xs text-text-muted">Lines Removed</div>
<div className="text-sm font-medium" style={{ color: '#f87171' }}>
<div className="text-sm font-medium" style={{ color: 'var(--assess-danger)' }}>
-{data.linesRemoved.toLocaleString()}
</div>
</div>

View file

@ -19,6 +19,7 @@ interface InsightsSectionProps {
outOfScope: OutOfScopeFindings[];
agentTree: ReportAgentTree;
subagentsList: SubagentBasicEntry[];
defaultCollapsed?: boolean;
}
export const InsightsSection = ({
@ -29,9 +30,10 @@ export const InsightsSection = ({
outOfScope,
agentTree,
subagentsList,
defaultCollapsed,
}: InsightsSectionProps) => {
return (
<ReportSection title="Session Insights" icon={Lightbulb}>
<ReportSection title="Session Insights" icon={Lightbulb} defaultCollapsed={defaultCollapsed}>
{/* Skills invoked */}
{skills.length > 0 && (
<div className="mb-4">
@ -187,7 +189,10 @@ export const InsightsSection = ({
<div key={idx} className="rounded-md bg-surface-raised px-3 py-2">
<span
className="mr-2 rounded px-1.5 py-0.5 text-xs"
style={{ backgroundColor: '#fbbf2420', color: '#fbbf24' }}
style={{
backgroundColor: 'color-mix(in srgb, var(--assess-warning) 12%, transparent)',
color: 'var(--assess-warning)',
}}
>
{f.keyword}
</span>

View file

@ -0,0 +1,55 @@
import { severityColor } from '@renderer/utils/reportAssessments';
import { AlertTriangle, CheckCircle, ChevronRight, Info, XCircle } from 'lucide-react';
import { sectionId } from '../ReportSection';
import type { Severity, Takeaway } from '@renderer/utils/reportAssessments';
const SEVERITY_ICONS: Record<Severity, React.ComponentType<{ className?: string }>> = {
danger: XCircle,
warning: AlertTriangle,
good: CheckCircle,
neutral: Info,
};
const scrollToSection = (sectionTitle: string) => {
const el = document.getElementById(sectionId(sectionTitle));
if (!el) return;
el.dispatchEvent(new CustomEvent('report-section-expand'));
};
interface KeyTakeawaysSectionProps {
takeaways: Takeaway[];
}
export const KeyTakeawaysSection = ({ takeaways }: KeyTakeawaysSectionProps) => {
return (
<div className="rounded-lg border border-border bg-surface-raised p-4">
<div className="mb-3 text-sm font-semibold text-text">Key Takeaways</div>
<div className="flex flex-col gap-2">
{takeaways.map((t, idx) => {
const Icon = SEVERITY_ICONS[t.severity];
const color = severityColor(t.severity);
return (
<button
key={idx}
type="button"
onClick={() => scrollToSection(t.sectionTitle)}
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"
style={{ borderLeftColor: color }}
>
<span className="mt-0.5 shrink-0" style={{ color }}>
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-text">{t.title}</div>
<div className="text-xs text-text-secondary">{t.detail}</div>
</div>
<ChevronRight className="mt-0.5 size-4 shrink-0 text-text-muted" />
</button>
);
})}
</div>
</div>
);
};

View file

@ -1,6 +1,7 @@
import { assessmentColor, assessmentLabel, severityColor } from '@renderer/utils/reportAssessments';
import { severityColor } from '@renderer/utils/reportAssessments';
import { BarChart3 } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection } from '../ReportSection';
import type {
@ -15,6 +16,7 @@ interface QualitySectionProps {
startup: ReportStartupOverhead;
testProgression: ReportTestProgression;
fileReadRedundancy: ReportFileReadRedundancy;
defaultCollapsed?: boolean;
}
export const QualitySection = ({
@ -22,24 +24,15 @@ export const QualitySection = ({
startup,
testProgression,
fileReadRedundancy,
defaultCollapsed,
}: QualitySectionProps) => {
const promptColor = assessmentColor(prompt.assessment);
const trajectoryColor = assessmentColor(testProgression.trajectory);
const overheadColor = assessmentColor(startup.overheadAssessment);
const redundancyColor = assessmentColor(fileReadRedundancy.redundancyAssessment);
return (
<ReportSection title="Quality Signals" icon={BarChart3}>
<ReportSection title="Quality Signals" icon={BarChart3} defaultCollapsed={defaultCollapsed}>
{/* Prompt quality */}
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">Prompt Quality</div>
<div className="mb-2 flex items-center gap-2">
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${promptColor}20`, color: promptColor }}
>
{assessmentLabel(prompt.assessment)}
</span>
<AssessmentBadge assessment={prompt.assessment} metricKey="promptQuality" />
</div>
<div className="text-xs text-text-secondary">{prompt.note}</div>
<div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4">
@ -70,12 +63,7 @@ export const QualitySection = ({
<div className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">Startup Overhead</span>
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${overheadColor}20`, color: overheadColor }}
>
{assessmentLabel(startup.overheadAssessment)}
</span>
<AssessmentBadge assessment={startup.overheadAssessment} metricKey="startup" />
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div>
@ -99,12 +87,10 @@ export const QualitySection = ({
<div className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">File Read Redundancy</span>
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${redundancyColor}20`, color: redundancyColor }}
>
{assessmentLabel(fileReadRedundancy.redundancyAssessment)}
</span>
<AssessmentBadge
assessment={fileReadRedundancy.redundancyAssessment}
metricKey="fileReads"
/>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div>
@ -128,12 +114,7 @@ export const QualitySection = ({
<div>
<div className="mb-2 text-xs font-medium text-text-muted">Test Progression</div>
<div className="mb-2 flex items-center gap-2">
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${trajectoryColor}20`, color: trajectoryColor }}
>
{assessmentLabel(testProgression.trajectory)}
</span>
<AssessmentBadge assessment={testProgression.trajectory} metricKey="testTrajectory" />
<span className="text-xs text-text-muted">
{testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''}
</span>

View file

@ -15,11 +15,12 @@ const fmtDuration = (ms: number) => {
interface SubagentSectionProps {
data: ReportSubagentMetrics;
defaultCollapsed?: boolean;
}
export const SubagentSection = ({ data }: SubagentSectionProps) => {
export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps) => {
return (
<ReportSection title="Subagents" icon={Users}>
<ReportSection title="Subagents" icon={Users} defaultCollapsed={defaultCollapsed}>
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">Count</div>

View file

@ -1,6 +1,7 @@
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
import { Clock } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection } from '../ReportSection';
import type {
@ -13,23 +14,24 @@ interface TimelineSectionProps {
idle: ReportIdleAnalysis;
modelSwitches: ReportModelSwitches;
keyEvents: KeyEvent[];
defaultCollapsed?: boolean;
}
export const TimelineSection = ({ idle, modelSwitches, keyEvents }: TimelineSectionProps) => {
export const TimelineSection = ({
idle,
modelSwitches,
keyEvents,
defaultCollapsed,
}: TimelineSectionProps) => {
const idleColor = assessmentColor(idle.idleAssessment);
return (
<ReportSection title="Timeline & Activity" icon={Clock}>
<ReportSection title="Timeline & Activity" icon={Clock} defaultCollapsed={defaultCollapsed}>
{/* Idle stats */}
<div className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">Idle Analysis</span>
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${idleColor}20`, color: idleColor }}
>
{assessmentLabel(idle.idleAssessment)}
</span>
<AssessmentBadge assessment={idle.idleAssessment} metricKey="idle" />
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>

View file

@ -1,6 +1,6 @@
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
import { Coins } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection } from '../ReportSection';
import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport';
@ -11,13 +11,14 @@ const fmtCost = (v: number) => `$${v.toFixed(4)}`;
interface TokenSectionProps {
data: ReportTokenUsage;
cacheEconomics: ReportCacheEconomics;
defaultCollapsed?: boolean;
}
export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => {
export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSectionProps) => {
const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd);
return (
<ReportSection title="Token Usage" icon={Coins}>
<ReportSection title="Token Usage" icon={Coins} defaultCollapsed={defaultCollapsed}>
{/* By-model table */}
<div className="mb-4 overflow-x-auto">
<table className="w-full text-xs">
@ -71,15 +72,10 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => {
{cacheEconomics.cacheEfficiencyPct}%
</span>
{cacheEconomics.cacheEfficiencyAssessment && (
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${assessmentColor(cacheEconomics.cacheEfficiencyAssessment)}20`,
color: assessmentColor(cacheEconomics.cacheEfficiencyAssessment),
}}
>
{assessmentLabel(cacheEconomics.cacheEfficiencyAssessment)}
</span>
<AssessmentBadge
assessment={cacheEconomics.cacheEfficiencyAssessment}
metricKey="cacheEfficiency"
/>
)}
</div>
</div>
@ -90,15 +86,10 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => {
{cacheEconomics.cacheReadToWriteRatio}x
</span>
{cacheEconomics.cacheRatioAssessment && (
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${assessmentColor(cacheEconomics.cacheRatioAssessment)}20`,
color: assessmentColor(cacheEconomics.cacheRatioAssessment),
}}
>
{assessmentLabel(cacheEconomics.cacheRatioAssessment)}
</span>
<AssessmentBadge
assessment={cacheEconomics.cacheRatioAssessment}
metricKey="cacheRatio"
/>
)}
</div>
</div>
@ -110,7 +101,11 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => {
<div className="text-xs text-text-muted">Cold Start</div>
<div
className="text-sm font-medium"
style={{ color: cacheEconomics.coldStartDetected ? '#fbbf24' : '#4ade80' }}
style={{
color: cacheEconomics.coldStartDetected
? 'var(--assess-warning)'
: 'var(--assess-good)',
}}
>
{cacheEconomics.coldStartDetected ? 'Yes' : 'No'}
</div>

View file

@ -1,36 +1,28 @@
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
import { assessmentColor } from '@renderer/utils/reportAssessments';
import { Wrench } from 'lucide-react';
import { ReportSection } from '../ReportSection';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection, sectionId } from '../ReportSection';
import type { ReportToolUsage } from '@renderer/types/sessionReport';
interface ToolSectionProps {
data: ReportToolUsage;
defaultCollapsed?: boolean;
}
export const ToolSection = ({ data }: ToolSectionProps) => {
export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => {
const toolEntries = Object.entries(data.successRates).sort(
(a, b) => b[1].totalCalls - a[1].totalCalls
);
const healthColor = assessmentColor(data.overallToolHealth);
return (
<ReportSection title="Tool Usage" icon={Wrench}>
<ReportSection title="Tool Usage" icon={Wrench} defaultCollapsed={defaultCollapsed}>
<div className="mb-2 flex items-center gap-2">
<span className="text-xs text-text-muted">
{data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools
</span>
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${healthColor}20`,
color: healthColor,
}}
>
{assessmentLabel(data.overallToolHealth)}
</span>
<AssessmentBadge assessment={data.overallToolHealth} metricKey="toolHealth" />
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
@ -53,18 +45,26 @@ export const ToolSection = ({ data }: ToolSectionProps) => {
{stats.totalCalls.toLocaleString()}
</td>
<td className="py-1.5 pr-4 text-right text-text">
{stats.errors.toLocaleString()}
{stats.errors > 0 ? (
<button
type="button"
onClick={() => {
const el = document.getElementById(sectionId('Errors'));
if (el) el.dispatchEvent(new CustomEvent('report-section-expand'));
}}
className="text-red-400 underline decoration-red-400/30 underline-offset-2 hover:decoration-red-400"
>
{stats.errors.toLocaleString()}
</button>
) : (
stats.errors.toLocaleString()
)}
</td>
<td className="py-1.5 pr-4 text-right" style={{ color }}>
{stats.successRatePct}%
</td>
<td className="py-1.5 text-right">
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium"
style={{ backgroundColor: `${color}20`, color }}
>
{assessmentLabel(stats.assessment)}
</span>
<AssessmentBadge assessment={stats.assessment} metricKey="toolHealth" />
</td>
</tr>
);

View file

@ -181,6 +181,12 @@
--card-text-lighter: #e4e4e7;
--card-separator: #3f3f46;
/* Assessment severity colors (badges, health indicators) */
--assess-good: #4ade80;
--assess-warning: #fbbf24;
--assess-danger: #f87171;
--assess-neutral: #a1a1aa;
/* Sticky Context button - transparent glass */
--context-btn-bg: rgba(255, 255, 255, 0.08);
--context-btn-bg-hover: rgba(255, 255, 255, 0.14);
@ -206,6 +212,12 @@
--color-text-secondary: #4d4b46; /* Warm secondary text */
--color-text-muted: #6d6b65; /* Warm muted text */
/* Assessment severity colors - darker for light backgrounds */
--assess-good: #16a34a;
--assess-warning: #d97706;
--assess-danger: #dc2626;
--assess-neutral: #57534e;
/* Scrollbar colors for light mode */
--scrollbar-thumb: rgba(0, 0, 0, 0.15);
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.28);

View file

@ -15,7 +15,14 @@ export type Severity = 'good' | 'warning' | 'danger' | 'neutral';
// Colors
// =============================================================================
const SEVERITY_COLORS: Record<Severity, string> = {
const SEVERITY_CSS_VAR: Record<Severity, string> = {
good: '--assess-good',
warning: '--assess-warning',
danger: '--assess-danger',
neutral: '--assess-neutral',
};
const SEVERITY_FALLBACKS: Record<Severity, string> = {
good: '#4ade80',
warning: '#fbbf24',
danger: '#f87171',
@ -23,7 +30,11 @@ const SEVERITY_COLORS: Record<Severity, string> = {
};
export function severityColor(severity: Severity): string {
return SEVERITY_COLORS[severity];
if (typeof document === 'undefined') return SEVERITY_FALLBACKS[severity];
const value = getComputedStyle(document.documentElement)
.getPropertyValue(SEVERITY_CSS_VAR[severity])
.trim();
return value || SEVERITY_FALLBACKS[severity];
}
// =============================================================================
@ -145,6 +156,92 @@ export const THRESHOLDS = {
},
} as const;
// =============================================================================
// Metric Keys & Explanations
// =============================================================================
export type MetricKey =
| 'costPerCommit'
| 'costPerLine'
| 'subagentCostShare'
| 'cacheEfficiency'
| 'cacheRatio'
| 'toolHealth'
| 'idle'
| 'fileReads'
| 'startup'
| 'thrashing'
| 'promptQuality'
| 'testTrajectory';
const EXPLANATIONS: Record<string, Record<string, string>> = {
costPerCommit: {
efficient: `Under $${THRESHOLDS.costPerCommit.efficient}/commit`,
normal: `$${THRESHOLDS.costPerCommit.efficient}\u2013$${THRESHOLDS.costPerCommit.normal}/commit`,
expensive: `$${THRESHOLDS.costPerCommit.normal}\u2013$${THRESHOLDS.costPerCommit.expensive}/commit`,
red_flag: `Over $${THRESHOLDS.costPerCommit.expensive}/commit`,
},
costPerLine: {
efficient: `Under $${THRESHOLDS.costPerLine.efficient}/line`,
normal: `$${THRESHOLDS.costPerLine.efficient}\u2013$${THRESHOLDS.costPerLine.normal}/line`,
expensive: `$${THRESHOLDS.costPerLine.normal}\u2013$${THRESHOLDS.costPerLine.expensive}/line`,
red_flag: `Over $${THRESHOLDS.costPerLine.expensive}/line`,
},
subagentCostShare: {
normal: `Under ${THRESHOLDS.subagentCostShare.normal}% of total cost`,
high: `${THRESHOLDS.subagentCostShare.normal}\u2013${THRESHOLDS.subagentCostShare.high}% of total cost`,
very_high: `${THRESHOLDS.subagentCostShare.high}\u2013${THRESHOLDS.subagentCostShare.veryHigh}% of total cost`,
red_flag: `Over ${THRESHOLDS.subagentCostShare.veryHigh}% of total cost`,
},
cacheEfficiency: {
good: `${THRESHOLDS.cacheEfficiency.good}%+ cache hit rate`,
concerning: `Below ${THRESHOLDS.cacheEfficiency.good}% cache hit rate`,
},
cacheRatio: {
good: `${THRESHOLDS.cacheRwRatio.good}x+ read-to-write ratio`,
concerning: `Below ${THRESHOLDS.cacheRwRatio.good}x read-to-write ratio`,
},
toolHealth: {
healthy: `Over ${THRESHOLDS.toolSuccess.healthy}% success rate`,
degraded: `${THRESHOLDS.toolSuccess.degraded}\u2013${THRESHOLDS.toolSuccess.healthy}% success rate`,
unreliable: `Below ${THRESHOLDS.toolSuccess.degraded}% success rate`,
},
idle: {
efficient: `Under ${THRESHOLDS.idle.efficient}% idle time`,
moderate: `${THRESHOLDS.idle.efficient}\u2013${THRESHOLDS.idle.moderate}% idle time`,
high_idle: `Over ${THRESHOLDS.idle.moderate}% idle time`,
},
fileReads: {
normal: `${THRESHOLDS.fileReadsPerUnique.normal}x or fewer reads per unique file`,
wasteful: `Over ${THRESHOLDS.fileReadsPerUnique.normal}x reads per unique file`,
},
startup: {
normal: `${THRESHOLDS.startupOverhead.normal}% or less of tokens before first work`,
heavy: `Over ${THRESHOLDS.startupOverhead.normal}% of tokens before first work`,
},
thrashing: {
none: 'No repeated commands or reworked files',
mild: '1\u20132 thrashing signals detected',
severe: '3+ thrashing signals detected',
},
promptQuality: {
well_specified: 'Clear first message with low friction rate',
moderate_friction: 'Some corrections needed mid-session',
underspecified: 'Short initial prompt led to many corrections',
verbose_but_unclear: 'Long initial prompt but still high friction',
},
testTrajectory: {
improving: 'Test failures decreased over the session',
stable: 'Test results stayed roughly the same',
regressing: 'Test failures increased over the session',
insufficient_data: 'Not enough test runs to determine trend',
},
};
export function assessmentExplanation(metricKey: MetricKey, assessment: string): string {
return EXPLANATIONS[metricKey]?.[assessment] ?? '';
}
// =============================================================================
// Assessment Computers
// =============================================================================
@ -268,3 +365,191 @@ export function detectSwitchPattern(
return 'manual_switch';
}
// =============================================================================
// Key Takeaways
// =============================================================================
export interface Takeaway {
severity: Severity;
title: string;
detail: string;
sectionTitle: string;
}
interface TakeawayReport {
costAnalysis: {
costPerCommitAssessment: string | null;
costPerLineAssessment: string | null;
totalSessionCostUsd: number;
};
cacheEconomics: {
cacheEfficiencyAssessment: string | null;
cacheEfficiencyPct: number;
};
toolUsage: {
overallToolHealth: string;
};
thrashingSignals: {
thrashingAssessment: string;
bashNearDuplicates: unknown[];
editReworkFiles: unknown[];
};
idleAnalysis: {
idleAssessment: string;
idlePct: number;
};
promptQuality: {
assessment: string;
frictionRate: number;
};
overview: {
contextAssessment: string | null;
compactionCount: number;
};
fileReadRedundancy: {
redundancyAssessment: string;
readsPerUniqueFile: number;
};
testProgression: {
trajectory: string;
};
}
export function computeTakeaways(report: TakeawayReport): Takeaway[] {
const items: Takeaway[] = [];
// Cost red flags
const costSev = assessmentSeverity(report.costAnalysis.costPerCommitAssessment);
if (costSev === 'danger') {
items.push({
severity: 'danger',
title: 'High cost per commit',
detail: `$${report.costAnalysis.totalSessionCostUsd.toFixed(2)} total \u2014 consider smaller, focused sessions`,
sectionTitle: 'Cost Analysis',
});
} else if (costSev === 'warning') {
items.push({
severity: 'warning',
title: 'Elevated cost per commit',
detail: 'Cost per commit is above typical range',
sectionTitle: 'Cost Analysis',
});
}
// Cache efficiency
if (report.cacheEconomics.cacheEfficiencyAssessment === 'concerning') {
items.push({
severity: 'warning',
title: 'Low cache efficiency',
detail: `${report.cacheEconomics.cacheEfficiencyPct}% cache hit rate \u2014 prompt structure may reduce caching`,
sectionTitle: 'Token Usage',
});
}
// Tool health
const toolSev = assessmentSeverity(report.toolUsage.overallToolHealth);
if (toolSev === 'danger') {
items.push({
severity: 'danger',
title: 'Tool reliability issues',
detail: 'Multiple tool calls are failing \u2014 check error section for details',
sectionTitle: 'Tool Usage',
});
} else if (toolSev === 'warning') {
items.push({
severity: 'warning',
title: 'Degraded tool health',
detail: 'Some tools have elevated failure rates',
sectionTitle: 'Tool Usage',
});
}
// Thrashing
if (report.thrashingSignals.thrashingAssessment === 'severe') {
items.push({
severity: 'danger',
title: 'Significant thrashing detected',
detail: 'Repeated commands and file rework suggest unclear direction',
sectionTitle: 'Friction Signals',
});
} else if (report.thrashingSignals.thrashingAssessment === 'mild') {
items.push({
severity: 'warning',
title: 'Mild thrashing detected',
detail: 'Some repeated commands or file rework occurred',
sectionTitle: 'Friction Signals',
});
}
// Idle time
if (report.idleAnalysis.idleAssessment === 'high_idle') {
items.push({
severity: 'warning',
title: 'High idle time',
detail: `${report.idleAnalysis.idlePct}% of wall-clock time was idle`,
sectionTitle: 'Timeline & Activity',
});
}
// Prompt quality
const promptSev = assessmentSeverity(report.promptQuality.assessment);
if (promptSev === 'danger') {
items.push({
severity: 'danger',
title: 'Prompt quality issues',
detail: `${(report.promptQuality.frictionRate * 100).toFixed(0)}% friction rate \u2014 try more detailed initial prompts`,
sectionTitle: 'Quality Signals',
});
}
// Context pressure
if (
report.overview.contextAssessment === 'critical' ||
report.overview.contextAssessment === 'high'
) {
items.push({
severity: report.overview.contextAssessment === 'critical' ? 'danger' : 'warning',
title: 'Context window pressure',
detail: `${report.overview.compactionCount} compaction${report.overview.compactionCount !== 1 ? 's' : ''} occurred \u2014 session may lose early context`,
sectionTitle: 'Overview',
});
}
// File read redundancy
if (report.fileReadRedundancy.redundancyAssessment === 'wasteful') {
items.push({
severity: 'warning',
title: 'Redundant file reads',
detail: `${report.fileReadRedundancy.readsPerUniqueFile}x reads per unique file`,
sectionTitle: 'Quality Signals',
});
}
// Test regression
if (report.testProgression.trajectory === 'regressing') {
items.push({
severity: 'danger',
title: 'Tests regressing',
detail: 'Test failures increased over the session',
sectionTitle: 'Quality Signals',
});
}
// Sort by severity (danger first), then limit to 4
const severityOrder: Record<Severity, number> = { danger: 0, warning: 1, neutral: 2, good: 3 };
items.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
if (items.length === 0) {
return [
{
severity: 'good',
title: 'Session looks healthy',
detail: 'No significant issues detected across all metrics',
sectionTitle: 'Overview',
},
];
}
return items.slice(0, 4);
}

View file

@ -103,7 +103,7 @@ const DEFAULT_PRICING: ModelPricing = {
cache_creation: 3.75,
};
function getPricing(modelName: string): ModelPricing {
export function getPricing(modelName: string): ModelPricing {
const name = modelName.toLowerCase();
for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
if (name.includes(key)) return pricing;

View file

@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
assessmentColor,
assessmentExplanation,
assessmentLabel,
assessmentSeverity,
computeCacheEfficiencyAssessment,
@ -12,6 +13,7 @@ import {
computeOverheadAssessment,
computeRedundancyAssessment,
computeSubagentCostShareAssessment,
computeTakeaways,
computeThrashingAssessment,
computeToolHealthAssessment,
detectModelMismatch,
@ -20,6 +22,8 @@ import {
THRESHOLDS,
} from '@renderer/utils/reportAssessments';
import type { MetricKey } from '@renderer/utils/reportAssessments';
describe('reportAssessments', () => {
describe('severityColor', () => {
it('maps severity to hex color', () => {
@ -256,4 +260,139 @@ describe('reportAssessments', () => {
).toBe('manual_switch');
});
});
describe('assessmentExplanation', () => {
const ALL_METRIC_ASSESSMENTS: Record<MetricKey, string[]> = {
costPerCommit: ['efficient', 'normal', 'expensive', 'red_flag'],
costPerLine: ['efficient', 'normal', 'expensive', 'red_flag'],
subagentCostShare: ['normal', 'high', 'very_high', 'red_flag'],
cacheEfficiency: ['good', 'concerning'],
cacheRatio: ['good', 'concerning'],
toolHealth: ['healthy', 'degraded', 'unreliable'],
idle: ['efficient', 'moderate', 'high_idle'],
fileReads: ['normal', 'wasteful'],
startup: ['normal', 'heavy'],
thrashing: ['none', 'mild', 'severe'],
promptQuality: [
'well_specified',
'moderate_friction',
'underspecified',
'verbose_but_unclear',
],
testTrajectory: ['improving', 'stable', 'regressing', 'insufficient_data'],
};
it('returns non-empty string for all valid metric/assessment combos', () => {
for (const [metricKey, assessments] of Object.entries(ALL_METRIC_ASSESSMENTS)) {
for (const assessment of assessments) {
const result = assessmentExplanation(metricKey as MetricKey, assessment);
expect(result, `${metricKey}/${assessment}`).not.toBe('');
}
}
});
it('returns empty string for unknown combinations', () => {
expect(assessmentExplanation('costPerCommit', 'unknown_value')).toBe('');
expect(assessmentExplanation('toolHealth' as MetricKey, 'nonexistent')).toBe('');
});
it('includes threshold values in explanations', () => {
expect(assessmentExplanation('costPerCommit', 'efficient')).toContain(
String(THRESHOLDS.costPerCommit.efficient)
);
expect(assessmentExplanation('toolHealth', 'healthy')).toContain(
String(THRESHOLDS.toolSuccess.healthy)
);
});
});
describe('computeTakeaways', () => {
const healthyReport = {
costAnalysis: {
costPerCommitAssessment: 'efficient',
costPerLineAssessment: 'efficient',
totalSessionCostUsd: 0.5,
},
cacheEconomics: { cacheEfficiencyAssessment: 'good', cacheEfficiencyPct: 97 },
toolUsage: { overallToolHealth: 'healthy' },
thrashingSignals: {
thrashingAssessment: 'none',
bashNearDuplicates: [],
editReworkFiles: [],
},
idleAnalysis: { idleAssessment: 'efficient', idlePct: 10 },
promptQuality: { assessment: 'well_specified', frictionRate: 0.05 },
overview: { contextAssessment: 'healthy', compactionCount: 0 },
fileReadRedundancy: { redundancyAssessment: 'normal', readsPerUniqueFile: 1.5 },
testProgression: { trajectory: 'improving' },
};
it('returns healthy message when all metrics are good', () => {
const result = computeTakeaways(healthyReport);
expect(result).toHaveLength(1);
expect(result[0].severity).toBe('good');
expect(result[0].title).toContain('healthy');
});
it('detects cost red flags', () => {
const report = {
...healthyReport,
costAnalysis: {
...healthyReport.costAnalysis,
costPerCommitAssessment: 'red_flag',
totalSessionCostUsd: 15,
},
};
const result = computeTakeaways(report);
expect(result.some((t) => t.severity === 'danger' && t.title.includes('cost'))).toBe(true);
});
it('detects thrashing', () => {
const report = {
...healthyReport,
thrashingSignals: {
thrashingAssessment: 'severe',
bashNearDuplicates: [{}],
editReworkFiles: [],
},
};
const result = computeTakeaways(report);
expect(result.some((t) => t.title.includes('thrashing'))).toBe(true);
});
it('limits to 4 takeaways', () => {
const report = {
...healthyReport,
costAnalysis: {
...healthyReport.costAnalysis,
costPerCommitAssessment: 'red_flag',
totalSessionCostUsd: 15,
},
cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 },
toolUsage: { overallToolHealth: 'unreliable' },
thrashingSignals: {
thrashingAssessment: 'severe',
bashNearDuplicates: [{}],
editReworkFiles: [],
},
promptQuality: { assessment: 'underspecified', frictionRate: 0.5 },
overview: { contextAssessment: 'critical', compactionCount: 3 },
fileReadRedundancy: { redundancyAssessment: 'wasteful', readsPerUniqueFile: 4 },
testProgression: { trajectory: 'regressing' },
};
const result = computeTakeaways(report);
expect(result.length).toBeLessThanOrEqual(4);
});
it('sorts danger before warning', () => {
const report = {
...healthyReport,
cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 },
toolUsage: { overallToolHealth: 'unreliable' },
};
const result = computeTakeaways(report);
expect(result.length).toBeGreaterThanOrEqual(2);
expect(result[0].severity).toBe('danger');
});
});
});