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:
parent
1ad2eca8f0
commit
4fda90bc3e
18 changed files with 921 additions and 176 deletions
78
src/renderer/components/report/AssessmentBadge.tsx
Normal file
78
src/renderer/components/report/AssessmentBadge.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue