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';
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
const sectionId = (title: string) =>
|
||||||
|
`report-section-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
||||||
|
|
||||||
interface ReportSectionProps {
|
interface ReportSectionProps {
|
||||||
title: string;
|
title: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
|
@ -16,9 +19,25 @@ export const ReportSection = ({
|
||||||
defaultCollapsed = false,
|
defaultCollapsed = false,
|
||||||
}: ReportSectionProps) => {
|
}: ReportSectionProps) => {
|
||||||
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
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 (
|
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
|
<button
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
className="flex w-full items-center gap-2 p-4 text-left"
|
className="flex w-full items-center gap-2 p-4 text-left"
|
||||||
|
|
@ -35,3 +54,5 @@ export const ReportSection = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { sectionId };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { computeTakeaways } from '@renderer/utils/reportAssessments';
|
||||||
import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
|
import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
|
||||||
|
|
||||||
import { CostSection } from './sections/CostSection';
|
import { CostSection } from './sections/CostSection';
|
||||||
|
|
@ -8,6 +9,7 @@ import { ErrorSection } from './sections/ErrorSection';
|
||||||
import { FrictionSection } from './sections/FrictionSection';
|
import { FrictionSection } from './sections/FrictionSection';
|
||||||
import { GitSection } from './sections/GitSection';
|
import { GitSection } from './sections/GitSection';
|
||||||
import { InsightsSection } from './sections/InsightsSection';
|
import { InsightsSection } from './sections/InsightsSection';
|
||||||
|
import { KeyTakeawaysSection } from './sections/KeyTakeawaysSection';
|
||||||
import { OverviewSection } from './sections/OverviewSection';
|
import { OverviewSection } from './sections/OverviewSection';
|
||||||
import { QualitySection } from './sections/QualitySection';
|
import { QualitySection } from './sections/QualitySection';
|
||||||
import { SubagentSection } from './sections/SubagentSection';
|
import { SubagentSection } from './sections/SubagentSection';
|
||||||
|
|
@ -34,6 +36,8 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
|
||||||
[sessionDetail]
|
[sessionDetail]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const takeaways = useMemo(() => (report ? computeTakeaways(report) : []), [report]);
|
||||||
|
|
||||||
if (!report) {
|
if (!report) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-text-muted">
|
<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)' }}>
|
<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>
|
<h1 className="mb-6 text-lg font-semibold text-text">Session Analysis Report</h1>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{takeaways.length > 0 && <KeyTakeawaysSection takeaways={takeaways} />}
|
||||||
<OverviewSection data={report.overview} />
|
<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} />
|
<TokenSection data={report.tokenUsage} cacheEconomics={report.cacheEconomics} />
|
||||||
<ToolSection data={report.toolUsage} />
|
<ToolSection data={report.toolUsage} />
|
||||||
{report.subagentMetrics.count > 0 && <SubagentSection data={report.subagentMetrics} />}
|
{report.subagentMetrics.count > 0 && (
|
||||||
{report.errors.errors.length > 0 && <ErrorSection data={report.errors} />}
|
<SubagentSection data={report.subagentMetrics} defaultCollapsed />
|
||||||
<GitSection data={report.gitActivity} />
|
)}
|
||||||
<FrictionSection data={report.frictionSignals} thrashing={report.thrashingSignals} />
|
{report.errors.errors.length > 0 && <ErrorSection data={report.errors} defaultCollapsed />}
|
||||||
|
<GitSection data={report.gitActivity} defaultCollapsed />
|
||||||
|
<FrictionSection
|
||||||
|
data={report.frictionSignals}
|
||||||
|
thrashing={report.thrashingSignals}
|
||||||
|
defaultCollapsed
|
||||||
|
/>
|
||||||
<TimelineSection
|
<TimelineSection
|
||||||
idle={report.idleAnalysis}
|
idle={report.idleAnalysis}
|
||||||
modelSwitches={report.modelSwitches}
|
modelSwitches={report.modelSwitches}
|
||||||
keyEvents={report.keyEvents}
|
keyEvents={report.keyEvents}
|
||||||
|
defaultCollapsed
|
||||||
/>
|
/>
|
||||||
<QualitySection
|
<QualitySection
|
||||||
prompt={report.promptQuality}
|
prompt={report.promptQuality}
|
||||||
startup={report.startupOverhead}
|
startup={report.startupOverhead}
|
||||||
testProgression={report.testProgression}
|
testProgression={report.testProgression}
|
||||||
fileReadRedundancy={report.fileReadRedundancy}
|
fileReadRedundancy={report.fileReadRedundancy}
|
||||||
|
defaultCollapsed
|
||||||
/>
|
/>
|
||||||
<InsightsSection
|
<InsightsSection
|
||||||
skills={report.skillsInvoked}
|
skills={report.skillsInvoked}
|
||||||
|
|
@ -73,6 +91,7 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
|
||||||
outOfScope={report.outOfScopeFindings}
|
outOfScope={report.outOfScopeFindings}
|
||||||
agentTree={report.agentTree}
|
agentTree={report.agentTree}
|
||||||
subagentsList={report.subagentsList}
|
subagentsList={report.subagentsList}
|
||||||
|
defaultCollapsed
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 { DollarSign } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AssessmentBadge } from '../AssessmentBadge';
|
||||||
import { ReportSection } from '../ReportSection';
|
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 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 {
|
interface CostSectionProps {
|
||||||
data: ReportCostAnalysis;
|
data: ReportCostAnalysis;
|
||||||
|
tokensByModel: Record<string, ModelTokenStats>;
|
||||||
|
commitCount: number;
|
||||||
|
linesChanged: number;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CostSection = ({ data }: CostSectionProps) => {
|
interface BreakdownLine {
|
||||||
const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]);
|
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 (
|
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>
|
<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 className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<div>
|
{!showStackedBar && (
|
||||||
<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">Parent Cost</div>
|
||||||
<div>
|
<div className="text-sm font-medium text-text">{fmt(data.parentCostUsd)}</div>
|
||||||
<div className="text-xs text-text-muted">Subagent Cost</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-text">{fmt(data.subagentCostUsd)}</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>
|
||||||
<div className="text-xs text-text-muted">Per Commit</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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-text">
|
<span className="text-sm font-medium text-text">
|
||||||
{data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'}
|
{data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
{data.costPerCommitAssessment && (
|
{data.costPerCommitAssessment && (
|
||||||
<span
|
<AssessmentBadge
|
||||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
assessment={data.costPerCommitAssessment}
|
||||||
style={{
|
metricKey="costPerCommit"
|
||||||
backgroundColor: `${assessmentColor(data.costPerCommitAssessment)}20`,
|
/>
|
||||||
color: assessmentColor(data.costPerCommitAssessment),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{assessmentLabel(data.costPerCommitAssessment)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-text-muted">Per Line Changed</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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-text">
|
<span className="text-sm font-medium text-text">
|
||||||
{data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'}
|
{data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
{data.costPerLineAssessment && (
|
{data.costPerLineAssessment && (
|
||||||
<span
|
<AssessmentBadge assessment={data.costPerLineAssessment} metricKey="costPerLine" />
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 && (
|
{modelEntries.length > 0 && (
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border text-left text-text-muted">
|
<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">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>
|
<th className="pb-2 pr-4 text-right">Cost</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{modelEntries.map(([model, cost]) => (
|
{modelEntries.map(([model, cost]) => {
|
||||||
<tr key={model} className="border-border/50 border-b">
|
const stats = tokensByModel[model];
|
||||||
<td className="py-1.5 pr-4 text-text">{model}</td>
|
const isExpanded = expandedModel === model;
|
||||||
<td className="py-1.5 pr-4 text-right text-text">{fmt(cost)}</td>
|
const pricing = getPricing(model);
|
||||||
</tr>
|
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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,10 @@ const ErrorItem = ({ error }: ErrorItemProps) => {
|
||||||
{error.isPermissionDenial && (
|
{error.isPermissionDenial && (
|
||||||
<span
|
<span
|
||||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium"
|
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
|
Permission Denied
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -36,8 +39,28 @@ const ErrorItem = ({ error }: ErrorItemProps) => {
|
||||||
<span className="ml-auto text-text-muted">msg #{error.messageIndex}</span>
|
<span className="ml-auto text-text-muted">msg #{error.messageIndex}</span>
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="mt-2 whitespace-pre-wrap break-words rounded bg-surface-raised p-2 text-xs text-text-secondary">
|
<div className="mt-2 flex flex-col gap-1.5">
|
||||||
{error.error}
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -46,15 +69,19 @@ const ErrorItem = ({ error }: ErrorItemProps) => {
|
||||||
|
|
||||||
interface ErrorSectionProps {
|
interface ErrorSectionProps {
|
||||||
data: ReportErrors;
|
data: ReportErrors;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ErrorSection = ({ data }: ErrorSectionProps) => {
|
export const ErrorSection = ({ data, defaultCollapsed }: ErrorSectionProps) => {
|
||||||
return (
|
return (
|
||||||
<ReportSection title="Errors" icon={AlertTriangle}>
|
<ReportSection title="Errors" icon={AlertTriangle} defaultCollapsed={defaultCollapsed}>
|
||||||
<div className="mb-3 flex items-center gap-3">
|
<div className="mb-3 flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
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' : ''}
|
{data.errors.length} error{data.errors.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
|
import { severityColor } from '@renderer/utils/reportAssessments';
|
||||||
import { MessageSquareWarning } from 'lucide-react';
|
import { MessageSquareWarning } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AssessmentBadge } from '../AssessmentBadge';
|
||||||
import { ReportSection } from '../ReportSection';
|
import { ReportSection } from '../ReportSection';
|
||||||
|
|
||||||
import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport';
|
import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport';
|
||||||
|
|
@ -8,20 +9,25 @@ import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/ty
|
||||||
interface FrictionSectionProps {
|
interface FrictionSectionProps {
|
||||||
data: ReportFrictionSignals;
|
data: ReportFrictionSignals;
|
||||||
thrashing: ReportThrashingSignals;
|
thrashing: ReportThrashingSignals;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => {
|
export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionSectionProps) => {
|
||||||
const frictionColor =
|
const frictionSeverity =
|
||||||
data.frictionRate <= 0.1 ? '#4ade80' : data.frictionRate <= 0.25 ? '#fbbf24' : '#f87171';
|
data.frictionRate <= 0.1 ? 'good' : data.frictionRate <= 0.25 ? 'warning' : 'danger';
|
||||||
const thrashColor = assessmentColor(thrashing.thrashingAssessment);
|
const frictionColor = severityColor(frictionSeverity);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportSection title="Friction Signals" icon={MessageSquareWarning}>
|
<ReportSection
|
||||||
|
title="Friction Signals"
|
||||||
|
icon={MessageSquareWarning}
|
||||||
|
defaultCollapsed={defaultCollapsed}
|
||||||
|
>
|
||||||
<div className="mb-4 flex items-center gap-3">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
className="rounded px-2 py-0.5 text-xs font-medium"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${frictionColor}20`,
|
backgroundColor: `color-mix(in srgb, ${frictionColor} 12%, transparent)`,
|
||||||
color: frictionColor,
|
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">
|
<div key={idx} className="flex items-start gap-2 rounded px-2 py-1 text-xs">
|
||||||
<span
|
<span
|
||||||
className="shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]"
|
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}
|
{corr.keyword}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -55,12 +64,7 @@ export const FrictionSection = ({ data, thrashing }: FrictionSectionProps) => {
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-text-muted">Thrashing Signals</span>
|
<span className="text-xs font-medium text-text-muted">Thrashing Signals</span>
|
||||||
<span
|
<AssessmentBadge assessment={thrashing.thrashingAssessment} metricKey="thrashing" />
|
||||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
|
||||||
style={{ backgroundColor: `${thrashColor}20`, color: thrashColor }}
|
|
||||||
>
|
|
||||||
{assessmentLabel(thrashing.thrashingAssessment)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{thrashing.bashNearDuplicates.length > 0 && (
|
{thrashing.bashNearDuplicates.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ import type { ReportGitActivity } from '@renderer/types/sessionReport';
|
||||||
|
|
||||||
interface GitSectionProps {
|
interface GitSectionProps {
|
||||||
data: ReportGitActivity;
|
data: ReportGitActivity;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GitSection = ({ data }: GitSectionProps) => {
|
export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => {
|
||||||
return (
|
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 className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-text-muted">Commits</div>
|
<div className="text-xs text-text-muted">Commits</div>
|
||||||
|
|
@ -22,13 +23,13 @@ export const GitSection = ({ data }: GitSectionProps) => {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-text-muted">Lines Added</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()}
|
+{data.linesAdded.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-text-muted">Lines Removed</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()}
|
-{data.linesRemoved.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ interface InsightsSectionProps {
|
||||||
outOfScope: OutOfScopeFindings[];
|
outOfScope: OutOfScopeFindings[];
|
||||||
agentTree: ReportAgentTree;
|
agentTree: ReportAgentTree;
|
||||||
subagentsList: SubagentBasicEntry[];
|
subagentsList: SubagentBasicEntry[];
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InsightsSection = ({
|
export const InsightsSection = ({
|
||||||
|
|
@ -29,9 +30,10 @@ export const InsightsSection = ({
|
||||||
outOfScope,
|
outOfScope,
|
||||||
agentTree,
|
agentTree,
|
||||||
subagentsList,
|
subagentsList,
|
||||||
|
defaultCollapsed,
|
||||||
}: InsightsSectionProps) => {
|
}: InsightsSectionProps) => {
|
||||||
return (
|
return (
|
||||||
<ReportSection title="Session Insights" icon={Lightbulb}>
|
<ReportSection title="Session Insights" icon={Lightbulb} defaultCollapsed={defaultCollapsed}>
|
||||||
{/* Skills invoked */}
|
{/* Skills invoked */}
|
||||||
{skills.length > 0 && (
|
{skills.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|
@ -187,7 +189,10 @@ export const InsightsSection = ({
|
||||||
<div key={idx} className="rounded-md bg-surface-raised px-3 py-2">
|
<div key={idx} className="rounded-md bg-surface-raised px-3 py-2">
|
||||||
<span
|
<span
|
||||||
className="mr-2 rounded px-1.5 py-0.5 text-xs"
|
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}
|
{f.keyword}
|
||||||
</span>
|
</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 { BarChart3 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AssessmentBadge } from '../AssessmentBadge';
|
||||||
import { ReportSection } from '../ReportSection';
|
import { ReportSection } from '../ReportSection';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -15,6 +16,7 @@ interface QualitySectionProps {
|
||||||
startup: ReportStartupOverhead;
|
startup: ReportStartupOverhead;
|
||||||
testProgression: ReportTestProgression;
|
testProgression: ReportTestProgression;
|
||||||
fileReadRedundancy: ReportFileReadRedundancy;
|
fileReadRedundancy: ReportFileReadRedundancy;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QualitySection = ({
|
export const QualitySection = ({
|
||||||
|
|
@ -22,24 +24,15 @@ export const QualitySection = ({
|
||||||
startup,
|
startup,
|
||||||
testProgression,
|
testProgression,
|
||||||
fileReadRedundancy,
|
fileReadRedundancy,
|
||||||
|
defaultCollapsed,
|
||||||
}: QualitySectionProps) => {
|
}: QualitySectionProps) => {
|
||||||
const promptColor = assessmentColor(prompt.assessment);
|
|
||||||
const trajectoryColor = assessmentColor(testProgression.trajectory);
|
|
||||||
const overheadColor = assessmentColor(startup.overheadAssessment);
|
|
||||||
const redundancyColor = assessmentColor(fileReadRedundancy.redundancyAssessment);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportSection title="Quality Signals" icon={BarChart3}>
|
<ReportSection title="Quality Signals" icon={BarChart3} defaultCollapsed={defaultCollapsed}>
|
||||||
{/* Prompt quality */}
|
{/* Prompt quality */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="mb-2 text-xs font-medium text-text-muted">Prompt Quality</div>
|
<div className="mb-2 text-xs font-medium text-text-muted">Prompt Quality</div>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span
|
<AssessmentBadge assessment={prompt.assessment} metricKey="promptQuality" />
|
||||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
|
||||||
style={{ backgroundColor: `${promptColor}20`, color: promptColor }}
|
|
||||||
>
|
|
||||||
{assessmentLabel(prompt.assessment)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-secondary">{prompt.note}</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">
|
<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-4">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-text-muted">Startup Overhead</span>
|
<span className="text-xs font-medium text-text-muted">Startup Overhead</span>
|
||||||
<span
|
<AssessmentBadge assessment={startup.overheadAssessment} metricKey="startup" />
|
||||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
|
||||||
style={{ backgroundColor: `${overheadColor}20`, color: overheadColor }}
|
|
||||||
>
|
|
||||||
{assessmentLabel(startup.overheadAssessment)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -99,12 +87,10 @@ export const QualitySection = ({
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-text-muted">File Read Redundancy</span>
|
<span className="text-xs font-medium text-text-muted">File Read Redundancy</span>
|
||||||
<span
|
<AssessmentBadge
|
||||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
assessment={fileReadRedundancy.redundancyAssessment}
|
||||||
style={{ backgroundColor: `${redundancyColor}20`, color: redundancyColor }}
|
metricKey="fileReads"
|
||||||
>
|
/>
|
||||||
{assessmentLabel(fileReadRedundancy.redundancyAssessment)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -128,12 +114,7 @@ export const QualitySection = ({
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 text-xs font-medium text-text-muted">Test Progression</div>
|
<div className="mb-2 text-xs font-medium text-text-muted">Test Progression</div>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span
|
<AssessmentBadge assessment={testProgression.trajectory} metricKey="testTrajectory" />
|
||||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
|
||||||
style={{ backgroundColor: `${trajectoryColor}20`, color: trajectoryColor }}
|
|
||||||
>
|
|
||||||
{assessmentLabel(testProgression.trajectory)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-text-muted">
|
<span className="text-xs text-text-muted">
|
||||||
{testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''}
|
{testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,12 @@ const fmtDuration = (ms: number) => {
|
||||||
|
|
||||||
interface SubagentSectionProps {
|
interface SubagentSectionProps {
|
||||||
data: ReportSubagentMetrics;
|
data: ReportSubagentMetrics;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubagentSection = ({ data }: SubagentSectionProps) => {
|
export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps) => {
|
||||||
return (
|
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 className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-text-muted">Count</div>
|
<div className="text-xs text-text-muted">Count</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
|
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
|
||||||
import { Clock } from 'lucide-react';
|
import { Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AssessmentBadge } from '../AssessmentBadge';
|
||||||
import { ReportSection } from '../ReportSection';
|
import { ReportSection } from '../ReportSection';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -13,23 +14,24 @@ interface TimelineSectionProps {
|
||||||
idle: ReportIdleAnalysis;
|
idle: ReportIdleAnalysis;
|
||||||
modelSwitches: ReportModelSwitches;
|
modelSwitches: ReportModelSwitches;
|
||||||
keyEvents: KeyEvent[];
|
keyEvents: KeyEvent[];
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TimelineSection = ({ idle, modelSwitches, keyEvents }: TimelineSectionProps) => {
|
export const TimelineSection = ({
|
||||||
|
idle,
|
||||||
|
modelSwitches,
|
||||||
|
keyEvents,
|
||||||
|
defaultCollapsed,
|
||||||
|
}: TimelineSectionProps) => {
|
||||||
const idleColor = assessmentColor(idle.idleAssessment);
|
const idleColor = assessmentColor(idle.idleAssessment);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportSection title="Timeline & Activity" icon={Clock}>
|
<ReportSection title="Timeline & Activity" icon={Clock} defaultCollapsed={defaultCollapsed}>
|
||||||
{/* Idle stats */}
|
{/* Idle stats */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-text-muted">Idle Analysis</span>
|
<span className="text-xs font-medium text-text-muted">Idle Analysis</span>
|
||||||
<span
|
<AssessmentBadge assessment={idle.idleAssessment} metricKey="idle" />
|
||||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
|
||||||
style={{ backgroundColor: `${idleColor}20`, color: idleColor }}
|
|
||||||
>
|
|
||||||
{assessmentLabel(idle.idleAssessment)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
|
|
||||||
import { Coins } from 'lucide-react';
|
import { Coins } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AssessmentBadge } from '../AssessmentBadge';
|
||||||
import { ReportSection } from '../ReportSection';
|
import { ReportSection } from '../ReportSection';
|
||||||
|
|
||||||
import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport';
|
import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport';
|
||||||
|
|
@ -11,13 +11,14 @@ const fmtCost = (v: number) => `$${v.toFixed(4)}`;
|
||||||
interface TokenSectionProps {
|
interface TokenSectionProps {
|
||||||
data: ReportTokenUsage;
|
data: ReportTokenUsage;
|
||||||
cacheEconomics: ReportCacheEconomics;
|
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);
|
const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportSection title="Token Usage" icon={Coins}>
|
<ReportSection title="Token Usage" icon={Coins} defaultCollapsed={defaultCollapsed}>
|
||||||
{/* By-model table */}
|
{/* By-model table */}
|
||||||
<div className="mb-4 overflow-x-auto">
|
<div className="mb-4 overflow-x-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
|
|
@ -71,15 +72,10 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => {
|
||||||
{cacheEconomics.cacheEfficiencyPct}%
|
{cacheEconomics.cacheEfficiencyPct}%
|
||||||
</span>
|
</span>
|
||||||
{cacheEconomics.cacheEfficiencyAssessment && (
|
{cacheEconomics.cacheEfficiencyAssessment && (
|
||||||
<span
|
<AssessmentBadge
|
||||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
assessment={cacheEconomics.cacheEfficiencyAssessment}
|
||||||
style={{
|
metricKey="cacheEfficiency"
|
||||||
backgroundColor: `${assessmentColor(cacheEconomics.cacheEfficiencyAssessment)}20`,
|
/>
|
||||||
color: assessmentColor(cacheEconomics.cacheEfficiencyAssessment),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{assessmentLabel(cacheEconomics.cacheEfficiencyAssessment)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -90,15 +86,10 @@ export const TokenSection = ({ data, cacheEconomics }: TokenSectionProps) => {
|
||||||
{cacheEconomics.cacheReadToWriteRatio}x
|
{cacheEconomics.cacheReadToWriteRatio}x
|
||||||
</span>
|
</span>
|
||||||
{cacheEconomics.cacheRatioAssessment && (
|
{cacheEconomics.cacheRatioAssessment && (
|
||||||
<span
|
<AssessmentBadge
|
||||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
assessment={cacheEconomics.cacheRatioAssessment}
|
||||||
style={{
|
metricKey="cacheRatio"
|
||||||
backgroundColor: `${assessmentColor(cacheEconomics.cacheRatioAssessment)}20`,
|
/>
|
||||||
color: assessmentColor(cacheEconomics.cacheRatioAssessment),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{assessmentLabel(cacheEconomics.cacheRatioAssessment)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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-xs text-text-muted">Cold Start</div>
|
||||||
<div
|
<div
|
||||||
className="text-sm font-medium"
|
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'}
|
{cacheEconomics.coldStartDetected ? 'Yes' : 'No'}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,28 @@
|
||||||
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
|
import { assessmentColor } from '@renderer/utils/reportAssessments';
|
||||||
import { Wrench } from 'lucide-react';
|
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';
|
import type { ReportToolUsage } from '@renderer/types/sessionReport';
|
||||||
|
|
||||||
interface ToolSectionProps {
|
interface ToolSectionProps {
|
||||||
data: ReportToolUsage;
|
data: ReportToolUsage;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolSection = ({ data }: ToolSectionProps) => {
|
export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => {
|
||||||
const toolEntries = Object.entries(data.successRates).sort(
|
const toolEntries = Object.entries(data.successRates).sort(
|
||||||
(a, b) => b[1].totalCalls - a[1].totalCalls
|
(a, b) => b[1].totalCalls - a[1].totalCalls
|
||||||
);
|
);
|
||||||
|
|
||||||
const healthColor = assessmentColor(data.overallToolHealth);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportSection title="Tool Usage" icon={Wrench}>
|
<ReportSection title="Tool Usage" icon={Wrench} defaultCollapsed={defaultCollapsed}>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span className="text-xs text-text-muted">
|
<span className="text-xs text-text-muted">
|
||||||
{data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools
|
{data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools
|
||||||
</span>
|
</span>
|
||||||
<span
|
<AssessmentBadge assessment={data.overallToolHealth} metricKey="toolHealth" />
|
||||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${healthColor}20`,
|
|
||||||
color: healthColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{assessmentLabel(data.overallToolHealth)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
|
|
@ -53,18 +45,26 @@ export const ToolSection = ({ data }: ToolSectionProps) => {
|
||||||
{stats.totalCalls.toLocaleString()}
|
{stats.totalCalls.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pr-4 text-right text-text">
|
<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>
|
||||||
<td className="py-1.5 pr-4 text-right" style={{ color }}>
|
<td className="py-1.5 pr-4 text-right" style={{ color }}>
|
||||||
{stats.successRatePct}%
|
{stats.successRatePct}%
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 text-right">
|
<td className="py-1.5 text-right">
|
||||||
<span
|
<AssessmentBadge assessment={stats.assessment} metricKey="toolHealth" />
|
||||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium"
|
|
||||||
style={{ backgroundColor: `${color}20`, color }}
|
|
||||||
>
|
|
||||||
{assessmentLabel(stats.assessment)}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,12 @@
|
||||||
--card-text-lighter: #e4e4e7;
|
--card-text-lighter: #e4e4e7;
|
||||||
--card-separator: #3f3f46;
|
--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 */
|
/* Sticky Context button - transparent glass */
|
||||||
--context-btn-bg: rgba(255, 255, 255, 0.08);
|
--context-btn-bg: rgba(255, 255, 255, 0.08);
|
||||||
--context-btn-bg-hover: rgba(255, 255, 255, 0.14);
|
--context-btn-bg-hover: rgba(255, 255, 255, 0.14);
|
||||||
|
|
@ -206,6 +212,12 @@
|
||||||
--color-text-secondary: #4d4b46; /* Warm secondary text */
|
--color-text-secondary: #4d4b46; /* Warm secondary text */
|
||||||
--color-text-muted: #6d6b65; /* Warm muted 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 colors for light mode */
|
||||||
--scrollbar-thumb: rgba(0, 0, 0, 0.15);
|
--scrollbar-thumb: rgba(0, 0, 0, 0.15);
|
||||||
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.28);
|
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.28);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,14 @@ export type Severity = 'good' | 'warning' | 'danger' | 'neutral';
|
||||||
// Colors
|
// 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',
|
good: '#4ade80',
|
||||||
warning: '#fbbf24',
|
warning: '#fbbf24',
|
||||||
danger: '#f87171',
|
danger: '#f87171',
|
||||||
|
|
@ -23,7 +30,11 @@ const SEVERITY_COLORS: Record<Severity, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function severityColor(severity: 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;
|
} 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
|
// Assessment Computers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -268,3 +365,191 @@ export function detectSwitchPattern(
|
||||||
|
|
||||||
return 'manual_switch';
|
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,
|
cache_creation: 3.75,
|
||||||
};
|
};
|
||||||
|
|
||||||
function getPricing(modelName: string): ModelPricing {
|
export function getPricing(modelName: string): ModelPricing {
|
||||||
const name = modelName.toLowerCase();
|
const name = modelName.toLowerCase();
|
||||||
for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
|
for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
|
||||||
if (name.includes(key)) return pricing;
|
if (name.includes(key)) return pricing;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
assessmentColor,
|
assessmentColor,
|
||||||
|
assessmentExplanation,
|
||||||
assessmentLabel,
|
assessmentLabel,
|
||||||
assessmentSeverity,
|
assessmentSeverity,
|
||||||
computeCacheEfficiencyAssessment,
|
computeCacheEfficiencyAssessment,
|
||||||
|
|
@ -12,6 +13,7 @@ import {
|
||||||
computeOverheadAssessment,
|
computeOverheadAssessment,
|
||||||
computeRedundancyAssessment,
|
computeRedundancyAssessment,
|
||||||
computeSubagentCostShareAssessment,
|
computeSubagentCostShareAssessment,
|
||||||
|
computeTakeaways,
|
||||||
computeThrashingAssessment,
|
computeThrashingAssessment,
|
||||||
computeToolHealthAssessment,
|
computeToolHealthAssessment,
|
||||||
detectModelMismatch,
|
detectModelMismatch,
|
||||||
|
|
@ -20,6 +22,8 @@ import {
|
||||||
THRESHOLDS,
|
THRESHOLDS,
|
||||||
} from '@renderer/utils/reportAssessments';
|
} from '@renderer/utils/reportAssessments';
|
||||||
|
|
||||||
|
import type { MetricKey } from '@renderer/utils/reportAssessments';
|
||||||
|
|
||||||
describe('reportAssessments', () => {
|
describe('reportAssessments', () => {
|
||||||
describe('severityColor', () => {
|
describe('severityColor', () => {
|
||||||
it('maps severity to hex color', () => {
|
it('maps severity to hex color', () => {
|
||||||
|
|
@ -256,4 +260,139 @@ describe('reportAssessments', () => {
|
||||||
).toBe('manual_switch');
|
).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