- 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>
555 lines
17 KiB
TypeScript
555 lines
17 KiB
TypeScript
/**
|
|
* Centralized assessment severity/color utilities for session reports.
|
|
*
|
|
* Maps raw assessment values to severity levels and colors,
|
|
* replacing duplicated assessmentColor() functions across report sections.
|
|
*/
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
export type Severity = 'good' | 'warning' | 'danger' | 'neutral';
|
|
|
|
// =============================================================================
|
|
// Colors
|
|
// =============================================================================
|
|
|
|
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',
|
|
neutral: '#a1a1aa',
|
|
};
|
|
|
|
export function severityColor(severity: Severity): string {
|
|
if (typeof document === 'undefined') return SEVERITY_FALLBACKS[severity];
|
|
const value = getComputedStyle(document.documentElement)
|
|
.getPropertyValue(SEVERITY_CSS_VAR[severity])
|
|
.trim();
|
|
return value || SEVERITY_FALLBACKS[severity];
|
|
}
|
|
|
|
// =============================================================================
|
|
// Assessment → Severity Mapping
|
|
// =============================================================================
|
|
|
|
const ASSESSMENT_SEVERITY: Record<string, Severity> = {
|
|
// Context
|
|
healthy: 'good',
|
|
moderate: 'warning',
|
|
high: 'danger',
|
|
critical: 'danger',
|
|
|
|
// Cost / subagent share
|
|
efficient: 'good',
|
|
normal: 'good',
|
|
expensive: 'warning',
|
|
red_flag: 'danger',
|
|
very_high: 'danger',
|
|
|
|
// Cache
|
|
good: 'good',
|
|
concerning: 'warning',
|
|
|
|
// Tool health
|
|
degraded: 'warning',
|
|
unreliable: 'danger',
|
|
|
|
// Idle ('moderate' already mapped above under Context)
|
|
high_idle: 'danger',
|
|
|
|
// File read
|
|
wasteful: 'warning',
|
|
|
|
// Startup
|
|
heavy: 'warning',
|
|
|
|
// Thrashing
|
|
none: 'good',
|
|
mild: 'warning',
|
|
severe: 'danger',
|
|
|
|
// Prompt quality
|
|
well_specified: 'good',
|
|
moderate_friction: 'warning',
|
|
underspecified: 'danger',
|
|
verbose_but_unclear: 'danger',
|
|
|
|
// Test trajectory
|
|
improving: 'good',
|
|
stable: 'warning',
|
|
regressing: 'danger',
|
|
insufficient_data: 'neutral',
|
|
|
|
// Model switch
|
|
opus_plan_mode: 'good',
|
|
manual_switch: 'neutral',
|
|
};
|
|
|
|
export function assessmentSeverity(assessment: string | null | undefined): Severity {
|
|
if (!assessment) return 'neutral';
|
|
return ASSESSMENT_SEVERITY[assessment] ?? 'neutral';
|
|
}
|
|
|
|
export function assessmentColor(assessment: string | null | undefined): string {
|
|
return severityColor(assessmentSeverity(assessment));
|
|
}
|
|
|
|
// =============================================================================
|
|
// Label Formatting
|
|
// =============================================================================
|
|
|
|
export function assessmentLabel(value: string): string {
|
|
return value
|
|
.split('_')
|
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
.join(' ');
|
|
}
|
|
|
|
// =============================================================================
|
|
// Threshold Constants
|
|
// =============================================================================
|
|
|
|
export const THRESHOLDS = {
|
|
costPerCommit: {
|
|
efficient: 0.5,
|
|
normal: 2,
|
|
expensive: 5,
|
|
},
|
|
costPerLine: {
|
|
efficient: 0.01,
|
|
normal: 0.05,
|
|
expensive: 0.2,
|
|
},
|
|
subagentCostShare: {
|
|
normal: 30,
|
|
high: 60,
|
|
veryHigh: 80,
|
|
},
|
|
cacheEfficiency: {
|
|
good: 95,
|
|
},
|
|
cacheRwRatio: {
|
|
good: 20,
|
|
},
|
|
toolSuccess: {
|
|
healthy: 95,
|
|
degraded: 80,
|
|
},
|
|
idle: {
|
|
efficient: 20,
|
|
moderate: 50,
|
|
},
|
|
fileReadsPerUnique: {
|
|
normal: 2.0,
|
|
},
|
|
startupOverhead: {
|
|
normal: 5,
|
|
},
|
|
} 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
|
|
// =============================================================================
|
|
|
|
export type CostAssessment = 'efficient' | 'normal' | 'expensive' | 'red_flag';
|
|
export type CacheAssessment = 'good' | 'concerning';
|
|
export type ToolHealthAssessment = 'healthy' | 'degraded' | 'unreliable';
|
|
export type IdleAssessment = 'efficient' | 'moderate' | 'high_idle';
|
|
export type RedundancyAssessment = 'normal' | 'wasteful';
|
|
export type OverheadAssessment = 'normal' | 'heavy';
|
|
export type ThrashingAssessment = 'none' | 'mild' | 'severe';
|
|
export type SubagentCostShareAssessment = 'normal' | 'high' | 'very_high' | 'red_flag';
|
|
export type SwitchPattern = 'opus_plan_mode' | 'manual_switch' | 'none';
|
|
|
|
export function computeCostPerCommitAssessment(costPerCommit: number): CostAssessment {
|
|
if (costPerCommit < THRESHOLDS.costPerCommit.efficient) return 'efficient';
|
|
if (costPerCommit < THRESHOLDS.costPerCommit.normal) return 'normal';
|
|
if (costPerCommit < THRESHOLDS.costPerCommit.expensive) return 'expensive';
|
|
return 'red_flag';
|
|
}
|
|
|
|
export function computeCostPerLineAssessment(costPerLine: number): CostAssessment {
|
|
if (costPerLine < THRESHOLDS.costPerLine.efficient) return 'efficient';
|
|
if (costPerLine < THRESHOLDS.costPerLine.normal) return 'normal';
|
|
if (costPerLine < THRESHOLDS.costPerLine.expensive) return 'expensive';
|
|
return 'red_flag';
|
|
}
|
|
|
|
export function computeSubagentCostShareAssessment(pct: number): SubagentCostShareAssessment {
|
|
if (pct < THRESHOLDS.subagentCostShare.normal) return 'normal';
|
|
if (pct < THRESHOLDS.subagentCostShare.high) return 'high';
|
|
if (pct < THRESHOLDS.subagentCostShare.veryHigh) return 'very_high';
|
|
return 'red_flag';
|
|
}
|
|
|
|
export function computeCacheEfficiencyAssessment(pct: number): CacheAssessment {
|
|
return pct >= THRESHOLDS.cacheEfficiency.good ? 'good' : 'concerning';
|
|
}
|
|
|
|
export function computeCacheRatioAssessment(ratio: number): CacheAssessment {
|
|
return ratio >= THRESHOLDS.cacheRwRatio.good ? 'good' : 'concerning';
|
|
}
|
|
|
|
export function computeToolHealthAssessment(successPct: number): ToolHealthAssessment {
|
|
if (successPct > THRESHOLDS.toolSuccess.healthy) return 'healthy';
|
|
if (successPct >= THRESHOLDS.toolSuccess.degraded) return 'degraded';
|
|
return 'unreliable';
|
|
}
|
|
|
|
export function computeIdleAssessment(idlePct: number): IdleAssessment {
|
|
if (idlePct < THRESHOLDS.idle.efficient) return 'efficient';
|
|
if (idlePct < THRESHOLDS.idle.moderate) return 'moderate';
|
|
return 'high_idle';
|
|
}
|
|
|
|
export function computeRedundancyAssessment(readsPerUnique: number): RedundancyAssessment {
|
|
return readsPerUnique <= THRESHOLDS.fileReadsPerUnique.normal ? 'normal' : 'wasteful';
|
|
}
|
|
|
|
export function computeOverheadAssessment(pctOfTotal: number): OverheadAssessment {
|
|
return pctOfTotal <= THRESHOLDS.startupOverhead.normal ? 'normal' : 'heavy';
|
|
}
|
|
|
|
export function computeThrashingAssessment(signalCount: number): ThrashingAssessment {
|
|
if (signalCount === 0) return 'none';
|
|
if (signalCount <= 2) return 'mild';
|
|
return 'severe';
|
|
}
|
|
|
|
export interface ModelMismatch {
|
|
description: string;
|
|
expectedComplexity: 'mechanical' | 'read_only';
|
|
recommendation: string;
|
|
}
|
|
|
|
const MECHANICAL_PATTERNS = /\b(rename|move|lint|format|delete|remove|copy|replace)\b/i;
|
|
const READ_ONLY_PATTERNS = /\b(explore|search|find|verify|check|scan|discover|list|read)\b/i;
|
|
|
|
export function detectModelMismatch(description: string, model: string): ModelMismatch | null {
|
|
const isOpus = model.toLowerCase().includes('opus');
|
|
if (!isOpus) return null;
|
|
|
|
if (MECHANICAL_PATTERNS.test(description)) {
|
|
return {
|
|
description,
|
|
expectedComplexity: 'mechanical',
|
|
recommendation: 'Consider using Haiku for mechanical tasks to reduce cost.',
|
|
};
|
|
}
|
|
|
|
if (READ_ONLY_PATTERNS.test(description)) {
|
|
return {
|
|
description,
|
|
expectedComplexity: 'read_only',
|
|
recommendation: 'Consider using Haiku or Sonnet for read-only exploration tasks.',
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function detectSwitchPattern(
|
|
switches: { from: string; to: string }[]
|
|
): SwitchPattern | null {
|
|
if (switches.length === 0) return null;
|
|
if (switches.length < 2) return 'manual_switch';
|
|
|
|
// Look for Sonnet→Opus→Sonnet pattern (plan mode)
|
|
for (let i = 0; i < switches.length - 1; i++) {
|
|
const s1 = switches[i];
|
|
const s2 = switches[i + 1];
|
|
if (
|
|
s1.from.toLowerCase().includes('sonnet') &&
|
|
s1.to.toLowerCase().includes('opus') &&
|
|
s2.from.toLowerCase().includes('opus') &&
|
|
s2.to.toLowerCase().includes('sonnet')
|
|
) {
|
|
return 'opus_plan_mode';
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|