Improve retros and usage UI: search, filters, layout tweaks

- Add search bar and smoothness dropdown filter to retros list
- Replace Cost column with Frictions count on retros list
- Make retro table rows fully clickable
- Move Retro tab before Usage tab in run detail
- Move Stage Breakdown below Open Items on retro page
- Add per-model breakdown table to usage page with 3 models

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-01 15:02:12 -05:00
parent 4dd2980a56
commit 162318a230
4 changed files with 452 additions and 37 deletions

View file

@ -0,0 +1,134 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { MagnifyingGlassIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
import { allRetros, smoothnessConfig, formatDuration } from "../data/retros";
import type { Retro, SmoothnessRating } from "../data/retros";
import type { Route } from "./+types/retros";
export function meta({}: Route.MetaArgs) {
return [{ title: "Retros \u2014 Arc" }];
}
const smoothnessOptions: Array<{ value: SmoothnessRating; label: string }> = [
{ value: "effortless", label: "Effortless" },
{ value: "smooth", label: "Smooth" },
{ value: "bumpy", label: "Bumpy" },
{ value: "struggled", label: "Struggled" },
{ value: "failed", label: "Failed" },
];
function SmoothnesssBadge({ smoothness }: { smoothness: Retro["smoothness"] }) {
if (!smoothness) {
return <span className="text-xs text-navy-600">--</span>;
}
const config = smoothnessConfig[smoothness];
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${config.bg} ${config.text}`}>
<span className={`size-1.5 rounded-full ${config.dot}`} />
{config.label}
</span>
);
}
function formatTimestamp(ts: string): string {
const date = new Date(ts);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + "\u2026";
}
export default function Retros() {
const retros = allRetros();
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [smoothnessFilter, setSmoothnessFilter] = useState<SmoothnessRating | "all">("all");
if (retros.length === 0) {
return <p className="py-8 text-center text-sm text-navy-600">No retrospectives yet.</p>;
}
const lowerQuery = query.toLowerCase();
const filtered = retros.filter(
(r) =>
(smoothnessFilter === "all" || r.smoothness === smoothnessFilter) &&
(r.goal.toLowerCase().includes(lowerQuery) ||
r.pipeline_name.toLowerCase().includes(lowerQuery)),
);
return (
<div className="space-y-4">
<div className="flex gap-3">
<div className="relative flex-1">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-navy-600" />
<input
type="text"
placeholder="Search retros…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border border-white/[0.06] bg-navy-800/80 py-2 pl-9 pr-3 text-sm text-ice-100 placeholder-navy-600 outline-none transition-colors focus:border-teal-500/40 focus:ring-0"
/>
</div>
<div className="relative">
<select
value={smoothnessFilter}
onChange={(e) => setSmoothnessFilter(e.target.value as SmoothnessRating | "all")}
className="appearance-none rounded-md border border-white/[0.06] bg-navy-800/80 py-2 pl-3 pr-8 text-sm text-ice-100 outline-none transition-colors focus:border-teal-500/40 focus:ring-0"
>
<option value="all">All smoothness</option>
{smoothnessOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<ChevronDownIcon className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-navy-600" />
</div>
</div>
<div className="rounded-md border border-white/[0.06] overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] bg-navy-800/60 text-left text-xs text-navy-600">
<th className="px-4 py-2.5 font-medium">Pipeline</th>
<th className="px-4 py-2.5 font-medium">Goal</th>
<th className="px-4 py-2.5 font-medium">Smoothness</th>
<th className="px-4 py-2.5 font-medium text-right">Duration</th>
<th className="px-4 py-2.5 font-medium text-right">Frictions</th>
<th className="px-4 py-2.5 font-medium text-right">When</th>
</tr>
</thead>
<tbody>
{filtered.map((retro) => (
<tr key={retro.run_id} className="border-b border-white/[0.06] last:border-b-0 transition-colors hover:bg-white/[0.02] cursor-pointer" onClick={() => navigate(`/runs/${retro.run_id}/retro`)}>
<td className="px-4 py-3 font-mono text-xs font-medium text-teal-500">
{retro.pipeline_name}
</td>
<td className="px-4 py-3 text-ice-100">
{truncate(retro.goal, 60)}
</td>
<td className="px-4 py-3">
<SmoothnesssBadge smoothness={retro.smoothness} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-ice-300">
{formatDuration(retro.stats.total_duration_ms)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-ice-300">
{retro.friction_points?.length ?? 0}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-navy-600">
{formatTimestamp(retro.timestamp)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -8,6 +8,7 @@ const tabs = [
{ name: "Overview", path: "", count: null },
{ name: "Stages", path: "/stages/detect-drift", count: 4 },
{ name: "Files Changed", path: "/files", count: 3 },
{ name: "Retro", path: "/retro", count: null },
{ name: "Usage", path: "/usage", count: null },
];

View file

@ -0,0 +1,225 @@
import { Link } from "react-router";
import {
findRetro,
smoothnessConfig,
learningCategoryConfig,
frictionKindConfig,
openItemKindConfig,
formatDuration,
} from "../data/retros";
import type { Route } from "./+types/run-retro";
export function meta({ params }: Route.MetaArgs) {
const retro = findRetro(params.id);
return [{ title: retro ? `Retro: ${retro.goal} \u2014 Arc` : "Retro \u2014 Arc" }];
}
function formatCost(cost: number | undefined): string {
if (cost == null) return "--";
return `$${cost.toFixed(2)}`;
}
export default function RunRetro({ params }: Route.ComponentProps) {
const retro = findRetro(params.id);
if (!retro) {
return <p className="py-8 text-center text-sm text-navy-600">No retrospective found for this run.</p>;
}
const smoothness = retro.smoothness ? smoothnessConfig[retro.smoothness] : null;
return (
<div className="space-y-6">
{/* Smoothness + Summary Header */}
<div className="flex items-start gap-4">
{smoothness && (
<span className={`inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold ${smoothness.bg} ${smoothness.text}`}>
<span className={`size-2.5 rounded-full ${smoothness.dot}`} />
{smoothness.label}
</span>
)}
<div className="min-w-0 flex-1">
<p className="text-sm text-ice-300">{retro.goal}</p>
<p className="mt-1 font-mono text-xs text-navy-600">
{retro.pipeline_name} &middot; {new Date(retro.timestamp).toLocaleString()}
</p>
</div>
</div>
{/* Aggregate Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Duration" value={formatDuration(retro.stats.total_duration_ms)} />
<StatCard label="Cost" value={formatCost(retro.stats.total_cost)} />
<StatCard label="Retries" value={String(retro.stats.total_retries)} warn={retro.stats.total_retries > 0} />
<StatCard label="Files" value={String(retro.stats.files_touched.length)} />
</div>
{/* Intent + Outcome */}
{(retro.intent ?? retro.outcome) && (
<div className="grid gap-3 sm:grid-cols-2">
{retro.intent && (
<div className="rounded-md border border-white/[0.06] bg-navy-800/60 p-4">
<h3 className="text-xs font-medium uppercase tracking-wider text-navy-600">Intent</h3>
<p className="mt-2 text-sm leading-relaxed text-ice-300">{retro.intent}</p>
</div>
)}
{retro.outcome && (
<div className="rounded-md border border-white/[0.06] bg-navy-800/60 p-4">
<h3 className="text-xs font-medium uppercase tracking-wider text-navy-600">Outcome</h3>
<p className="mt-2 text-sm leading-relaxed text-ice-300">{retro.outcome}</p>
</div>
)}
</div>
)}
{/* Learnings */}
{retro.learnings && retro.learnings.length > 0 && (
<div>
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-navy-600">Learnings</h3>
<div className="space-y-2">
{retro.learnings.map((learning, i) => {
const config = learningCategoryConfig[learning.category];
return (
<div key={i} className="flex items-start gap-3 rounded-md border border-white/[0.06] bg-navy-800/60 px-4 py-3">
<span className={`mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.text} bg-white/[0.06]`}>
{config.label}
</span>
<p className="text-sm leading-relaxed text-ice-300">{learning.text}</p>
</div>
);
})}
</div>
</div>
)}
{/* Friction Points */}
{retro.friction_points && retro.friction_points.length > 0 && (
<div>
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-navy-600">Friction Points</h3>
<div className="space-y-2">
{retro.friction_points.map((fp, i) => {
const config = frictionKindConfig[fp.kind];
return (
<div key={i} className="flex items-start gap-3 rounded-md border border-white/[0.06] bg-navy-800/60 px-4 py-3">
<span className={`mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.text} bg-white/[0.06]`}>
{config.label}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-relaxed text-ice-300">{fp.description}</p>
{fp.stage_id && (
<p className="mt-1 font-mono text-xs text-navy-600">
Stage: {retro.stages.find((s) => s.stage_id === fp.stage_id)?.stage_label ?? fp.stage_id}
</p>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Open Items */}
{retro.open_items && retro.open_items.length > 0 && (
<div>
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-navy-600">Open Items</h3>
<div className="space-y-2">
{retro.open_items.map((item, i) => {
const config = openItemKindConfig[item.kind];
return (
<div key={i} className="flex items-start gap-3 rounded-md border border-white/[0.06] bg-navy-800/60 px-4 py-3">
<span className={`mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.text} bg-white/[0.06]`}>
{config.label}
</span>
<p className="text-sm leading-relaxed text-ice-300">{item.description}</p>
</div>
);
})}
</div>
</div>
)}
{/* Stage Breakdown */}
<div>
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-navy-600">Stage Breakdown</h3>
<div className="rounded-md border border-white/[0.06] overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] bg-navy-800/60 text-left text-xs text-navy-600">
<th className="px-4 py-2.5 font-medium">Stage</th>
<th className="px-4 py-2.5 font-medium">Status</th>
<th className="px-4 py-2.5 font-medium text-right">Duration</th>
<th className="px-4 py-2.5 font-medium text-right">Retries</th>
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
<th className="px-4 py-2.5 font-medium text-right">Files</th>
</tr>
</thead>
<tbody>
{retro.stages.map((stage) => (
<tr key={stage.stage_id} className="border-b border-white/[0.06] last:border-b-0">
<td className="px-4 py-3">
<Link
to={`/runs/${retro.run_id}/stages/${stage.stage_id}`}
className="text-ice-100 hover:text-white"
>
{stage.stage_label}
</Link>
{stage.notes && (
<p className="mt-1 text-xs text-navy-600">{stage.notes}</p>
)}
{stage.failure_reason && (
<p className="mt-1 text-xs text-coral/80">{stage.failure_reason}</p>
)}
</td>
<td className="px-4 py-3">
<StageStatusBadge status={stage.status} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-ice-300">
{formatDuration(stage.duration_ms)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums">
<span className={stage.retries > 0 ? "text-amber" : "text-ice-300"}>
{stage.retries}
</span>
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-ice-300">
{formatCost(stage.cost)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-ice-300">
{stage.files_touched.length}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function StatCard({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
return (
<div className="rounded-md border border-white/[0.06] bg-navy-800/60 px-4 py-3">
<p className="text-xs font-medium uppercase tracking-wider text-navy-600">{label}</p>
<p className={`mt-1 font-mono text-lg font-semibold tabular-nums ${warn ? "text-amber" : "text-white"}`}>
{value}
</p>
</div>
);
}
function StageStatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
completed: "text-mint",
running: "text-teal-500",
pending: "text-navy-600",
failed: "text-coral",
};
const colorClass = styles[status] ?? "text-ice-300";
return (
<span className={`text-xs font-medium capitalize ${colorClass}`}>
{status}
</span>
);
}

View file

@ -1,7 +1,7 @@
const stages = [
{ stage: "Detect Drift", model: "Opus 4.6", inputTokens: 12_480, outputTokens: 3_210, runtime: "1m 12s", cost: 0.48 },
{ stage: "Propose Changes", model: "Opus 4.6", inputTokens: 28_640, outputTokens: 8_750, runtime: "2m 34s", cost: 1.12 },
{ stage: "Review Changes", model: "Opus 4.6", inputTokens: 9_120, outputTokens: 2_640, runtime: "0m 45s", cost: 0.31 },
{ stage: "Propose Changes", model: "Gemini 3.1", inputTokens: 28_640, outputTokens: 8_750, runtime: "2m 34s", cost: 0.72 },
{ stage: "Review Changes", model: "Codex 5.3", inputTokens: 9_120, outputTokens: 2_640, runtime: "0m 45s", cost: 0.19 },
{ stage: "Apply Changes", model: "Opus 4.6", inputTokens: 21_300, outputTokens: 6_480, runtime: "1m 58s", cost: 0.87 },
];
@ -10,48 +10,103 @@ const totalCost = stages.reduce((sum, s) => sum + s.cost, 0);
const totalInput = stages.reduce((sum, s) => sum + s.inputTokens, 0);
const totalOutput = stages.reduce((sum, s) => sum + s.outputTokens, 0);
const modelBreakdown = Object.values(
stages.reduce<Record<string, { model: string; inputTokens: number; outputTokens: number; cost: number; stages: number }>>(
(acc, s) => {
const entry = acc[s.model] ?? { model: s.model, inputTokens: 0, outputTokens: 0, cost: 0, stages: 0 };
entry.inputTokens += s.inputTokens;
entry.outputTokens += s.outputTokens;
entry.cost += s.cost;
entry.stages += 1;
acc[s.model] = entry;
return acc;
},
{},
),
).sort((a, b) => b.cost - a.cost);
function formatTokens(n: number) {
return `${(n / 1000).toFixed(1)}k`;
}
export default function RunUsage() {
return (
<div className="rounded-md border border-white/[0.06] overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] bg-navy-800/60 text-left text-xs text-navy-600">
<th className="px-4 py-2.5 font-medium">Stage</th>
<th className="px-4 py-2.5 font-medium">Model</th>
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
<th className="px-4 py-2.5 font-medium text-right">Run time</th>
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
</tr>
</thead>
<tbody>
{stages.map((row) => (
<tr key={row.stage} className="border-b border-white/[0.06] last:border-b-0">
<td className="px-4 py-3 text-ice-100">{row.stage}</td>
<td className="px-4 py-3 font-mono text-xs text-ice-300">{row.model}</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-ice-300">
{formatTokens(row.inputTokens)} <span className="text-navy-600">/</span> {formatTokens(row.outputTokens)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-ice-300">{row.runtime}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-ice-300">${row.cost.toFixed(2)}</td>
<div className="space-y-6">
<div className="rounded-md border border-white/[0.06] overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] bg-navy-800/60 text-left text-xs text-navy-600">
<th className="px-4 py-2.5 font-medium">Stage</th>
<th className="px-4 py-2.5 font-medium">Model</th>
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
<th className="px-4 py-2.5 font-medium text-right">Run time</th>
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-white/[0.08] bg-navy-800/40">
<td className="px-4 py-3 font-medium text-white">Total</td>
<td />
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-white">
{formatTokens(totalInput)} <span className="text-navy-600">/</span> {formatTokens(totalOutput)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-white">{totalRuntime}</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-white">${totalCost.toFixed(2)}</td>
</tr>
</tfoot>
</table>
</thead>
<tbody>
{stages.map((row) => (
<tr key={row.stage} className="border-b border-white/[0.06] last:border-b-0">
<td className="px-4 py-3 text-ice-100">{row.stage}</td>
<td className="px-4 py-3 font-mono text-xs text-ice-300">{row.model}</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-ice-300">
{formatTokens(row.inputTokens)} <span className="text-navy-600">/</span> {formatTokens(row.outputTokens)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-ice-300">{row.runtime}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-ice-300">${row.cost.toFixed(2)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-white/[0.08] bg-navy-800/40">
<td className="px-4 py-3 font-medium text-white">Total</td>
<td />
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-white">
{formatTokens(totalInput)} <span className="text-navy-600">/</span> {formatTokens(totalOutput)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-white">{totalRuntime}</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-white">${totalCost.toFixed(2)}</td>
</tr>
</tfoot>
</table>
</div>
<div>
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-navy-600">By Model</h3>
<div className="rounded-md border border-white/[0.06] overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] bg-navy-800/60 text-left text-xs text-navy-600">
<th className="px-4 py-2.5 font-medium">Model</th>
<th className="px-4 py-2.5 font-medium text-right">Stages</th>
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
</tr>
</thead>
<tbody>
{modelBreakdown.map((row) => (
<tr key={row.model} className="border-b border-white/[0.06] last:border-b-0">
<td className="px-4 py-3 font-mono text-xs text-ice-100">{row.model}</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-ice-300">{row.stages}</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-ice-300">
{formatTokens(row.inputTokens)} <span className="text-navy-600">/</span> {formatTokens(row.outputTokens)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-ice-300">${row.cost.toFixed(2)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-white/[0.08] bg-navy-800/40">
<td className="px-4 py-3 font-medium text-white">Total</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-white">{stages.length}</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-white">
{formatTokens(totalInput)} <span className="text-navy-600">/</span> {formatTokens(totalOutput)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-white">${totalCost.toFixed(2)}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
);
}