From 8b728fa47a4e5c2f4b0bc3ecbd7c1cbbe2f57593 Mon Sep 17 00:00:00 2001 From: Lucas Tonon Date: Sun, 19 Jul 2026 08:08:40 -0300 Subject: [PATCH 1/2] feat(frontend): comparison leaderboard consuming the /compare endpoint The backend /compare endpoint already aggregates metrics, feature overlap, and cost totals across up to 8 sessions, but nothing in the frontend consumed it. This adds the leaderboard UI: - api.ts: typed api.compare(sessionIds) client fn; types.ts: Compare* types mirroring routers/compare.py (incl. the feature_overlap list-when-empty quirk) - /compare page: sortable leaderboard table (per-metric latest values with best-value highlight, total cost, created-at), overlaid per-metric charts reusing the MetricsTab chart stack (palette, tooltip, formatters now exported), session legend toggles, and a feature-overlap panel (common + per-session extras) - experiments page: checkbox column + "Compare selected (n/8)" header action that navigates to /compare with the selected session ids (project-scoped when the selection stays within one project) Closes #105 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4 --- frontend/src/app/compare/page.tsx | 662 +++++++++++++++++++++++++ frontend/src/app/experiments/page.tsx | 68 +++ frontend/src/components/MetricsTab.tsx | 13 +- frontend/src/lib/api.ts | 8 + frontend/src/lib/types.ts | 51 ++ 5 files changed, 796 insertions(+), 6 deletions(-) create mode 100644 frontend/src/app/compare/page.tsx diff --git a/frontend/src/app/compare/page.tsx b/frontend/src/app/compare/page.tsx new file mode 100644 index 0000000..7527e92 --- /dev/null +++ b/frontend/src/app/compare/page.tsx @@ -0,0 +1,662 @@ +'use client'; + +/** + * Comparison leaderboard — /compare?sessions=a,b,c[&project=] + * + * Consumes the backend /compare payload (routers/compare.py): one + * round-trip returns session/experiment headers, per-metric series for + * every session, feature overlap, and cost totals for up to 8 sessions. + * + * Renders a sortable leaderboard table (one row per session; one column + * per metric showing the latest value, plus total cost and created-at) + * and overlaid per-metric charts reusing the MetricsTab chart stack + * (palette, tooltip, formatters). Lives inside the app shell like the + * experiments index. + */ + +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import { + ArrowDown, + ArrowLeft, + ArrowUp, + ArrowUpDown, + Loader2, + RefreshCw, + Trophy, +} from 'lucide-react'; + +import { api } from '@/lib/api'; +import { useApp } from '@/lib/AppContext'; +import Sidebar from '@/components/Sidebar'; +import { + PALETTE, + ChartTooltip, + compactFormat, + smartFormat, + isLowerBetter, + prettyMetricName, +} from '@/components/MetricsTab'; +import type { CompareFeatureOverlap, CompareResponse } from '@/lib/types'; + +const STATE_TONE: Record = { + created: 'bg-amber-500/10 text-amber-300 border-amber-500/30', + prepping: 'bg-amber-500/10 text-amber-300 border-amber-500/30', + training: 'bg-amber-500/20 text-amber-200 border-amber-500/40', + trained: 'bg-emerald-500/10 text-emerald-300 border-emerald-500/30', + abandoned: 'bg-rose-500/10 text-rose-300 border-rose-500/30', + failed: 'bg-rose-500/10 text-rose-300 border-rose-500/30', +}; + +function formatCost(c: number): string { + if (c === 0) return '$0'; + if (c < 0.01) return '<$0.01'; + if (c < 1) return `$${c.toFixed(3)}`; + return `$${c.toFixed(2)}`; +} + +interface LeaderRow { + id: string; + missing: boolean; + label: string; // experiment name (disambiguated with a short id if duplicated) + state?: string; + model?: string | null; + created_at?: string; + cost: number; + // metric name → latest (highest-step) value for this session + latest: Record; +} + +type SortDir = 'asc' | 'desc'; + +function CompareContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { projects } = useApp(); + + const sessionIds = useMemo( + () => + (searchParams?.get('sessions') || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + [searchParams], + ); + const projectId = searchParams?.get('project') || null; + const project = projectId ? projects.find((p) => p.id === projectId) || null : null; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [sortKey, setSortKey] = useState(null); // 'name' | 'cost' | 'created' | 'metric:' + const [sortDir, setSortDir] = useState('asc'); + const [hiddenSessions, setHiddenSessions] = useState>(new Set()); + + const refresh = useCallback(async () => { + if (sessionIds.length === 0) return; + setLoading(true); + setError(null); + try { + setData(await api.compare(sessionIds)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [sessionIds]); + + useEffect(() => { + refresh(); + }, [refresh]); + + // ── Derived: metric names, per-session rows, colors, labels ── + const metricNames = useMemo(() => Object.keys(data?.metrics || {}).sort(), [data]); + + const rows = useMemo(() => { + if (!data) return []; + // Disambiguate duplicate experiment names with a short session id. + const nameCounts = new Map(); + for (const s of data.sessions) { + const n = s.experiment_name || ''; + nameCounts.set(n, (nameCounts.get(n) || 0) + 1); + } + return data.sessions.map((s) => { + const base = s.experiment_name || `session ${s.id.slice(0, 8)}`; + const label = + s.experiment_name && (nameCounts.get(s.experiment_name) || 0) > 1 + ? `${base} (${s.id.slice(0, 6)})` + : base; + const latest: Record = {}; + for (const name of metricNames) { + const series = (data.metrics[name] || []).find((x) => x.session_id === s.id); + const pts = series?.points || []; + if (pts.length > 0) latest[name] = pts[pts.length - 1].value; + } + return { + id: s.id, + missing: s.missing, + label, + state: s.state, + model: s.model, + created_at: s.created_at, + cost: data.totals[s.id]?.cost_usd ?? 0, + latest, + }; + }); + }, [data, metricNames]); + + const colorOf = useCallback( + (sessionId: string) => { + const i = rows.findIndex((r) => r.id === sessionId); + return PALETTE[(i >= 0 ? i : 0) % PALETTE.length]; + }, + [rows], + ); + + // ── Sorting — default: rank by the first metric, best value first ── + const effectiveSortKey = + sortKey ?? (metricNames.length > 0 ? `metric:${metricNames[0]}` : 'created'); + const effectiveSortDir: SortDir = + sortKey !== null + ? sortDir + : metricNames.length > 0 + ? isLowerBetter(metricNames[0]) + ? 'asc' + : 'desc' + : 'asc'; + + const toggleSort = (key: string, defaultDir: SortDir = 'asc') => { + if (effectiveSortKey === key) { + setSortKey(key); + setSortDir(effectiveSortDir === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortDir(defaultDir); + } + }; + + const sortedRows = useMemo(() => { + const key = effectiveSortKey; + const dir = effectiveSortDir === 'asc' ? 1 : -1; + const val = (r: LeaderRow): string | number | null => { + if (key === 'name') return r.label.toLowerCase(); + if (key === 'cost') return r.cost; + if (key === 'created') return r.created_at || null; + if (key.startsWith('metric:')) { + const v = r.latest[key.slice('metric:'.length)]; + return v === undefined ? null : v; + } + return null; + }; + return [...rows].sort((a, b) => { + const va = val(a); + const vb = val(b); + // Rows without a value (missing session / metric never logged) sink + // to the bottom regardless of direction. + if (va === null && vb === null) return 0; + if (va === null) return 1; + if (vb === null) return -1; + if (va < vb) return -1 * dir; + if (va > vb) return 1 * dir; + return 0; + }); + }, [rows, effectiveSortKey, effectiveSortDir]); + + // Best value per metric column (for the trophy highlight). + const bestPerMetric = useMemo(() => { + const best = new Map(); + for (const name of metricNames) { + const lower = isLowerBetter(name); + let b: number | null = null; + for (const r of rows) { + const v = r.latest[name]; + if (v === undefined) continue; + if (b === null || (lower ? v < b : v > b)) b = v; + } + if (b !== null) best.set(name, b); + } + return best; + }, [rows, metricNames]); + + // ── Chart data: one merged step-indexed table per metric ── + const charts = useMemo(() => { + if (!data) return []; + return metricNames.map((name) => { + const stepMap = new Map>(); + for (const series of data.metrics[name] || []) { + if (hiddenSessions.has(series.session_id)) continue; + for (const p of series.points) { + if (!stepMap.has(p.step)) stepMap.set(p.step, { step: p.step }); + stepMap.get(p.step)![series.session_id] = p.value; + } + } + const sessionIdsWithData = (data.metrics[name] || []).map((s) => s.session_id); + return { + name, + data: Array.from(stepMap.values()).sort((a, b) => a.step - b.step), + sessionIds: sessionIdsWithData, + }; + }); + }, [data, metricNames, hiddenSessions]); + + const toggleSession = (id: string) => { + setHiddenSessions((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const labelOf = useCallback( + (sessionId: string) => rows.find((r) => r.id === sessionId)?.label || sessionId.slice(0, 8), + [rows], + ); + + const overlap: CompareFeatureOverlap | null = + data && !Array.isArray(data.feature_overlap) ? data.feature_overlap : null; + + const SortIndicator = ({ colKey }: { colKey: string }) => + effectiveSortKey === colKey ? ( + effectiveSortDir === 'asc' ? ( + + ) : ( + + ) + ) : ( + + ); + + return ( +
+ +
+
+ + +

Comparison leaderboard

+ {project ? · {project.name} : null} + + · {sessionIds.length} session{sessionIds.length === 1 ? '' : 's'} + +
+ +
+ +
+ {error ? ( +
+ {error} +
+ ) : null} + + {sessionIds.length < 2 ? ( +
+ +

+ Select at least two sessions to compare — pick experiments on the{' '} + + experiments page + {' '} + and hit “Compare selected”. +

+
+ ) : loading && !data ? ( +
+ + Loading comparison… +
+ ) : data ? ( + <> + {/* ── Leaderboard table ── */} +
+
+ + + + + + {metricNames.map((name) => ( + + ))} + + + + + + {sortedRows.map((r) => ( + + + + {metricNames.map((name) => { + const v = r.latest[name]; + const isBest = v !== undefined && bestPerMetric.get(name) === v; + return ( + + ); + })} + + + + ))} + +
+ + State + + + + + +
+
+ + {r.missing ? ( + + session not found ({r.id.slice(0, 8)}) + + ) : ( +
+
+ {r.label} +
+ {r.model ? ( +
+ {r.model} +
+ ) : null} +
+ )} +
+
+ {r.state ? ( + + {r.state} + + ) : null} + + {v === undefined ? ( + + ) : ( + + {smartFormat(v)} + + )} + + {formatCost(r.cost)} + + {r.created_at ? new Date(r.created_at).toLocaleString() : ''} +
+
+
+ + {/* ── Session legend — toggles a session's series across all charts ── */} + {rows.length > 0 && metricNames.length > 0 ? ( +
+ {rows + .filter((r) => !r.missing) + .map((r) => { + const hidden = hiddenSessions.has(r.id); + const c = colorOf(r.id); + return ( + + ); + })} +
+ ) : null} + + {/* ── Overlaid metric charts ── */} + {metricNames.length > 0 ? ( +
+ {charts.map((chart) => ( +
+
+ + {prettyMetricName(chart.name)} + + + {chart.data.length} pts + +
+
+
+ + + + + + } + cursor={{ stroke: 'rgba(255,255,255,0.08)', strokeWidth: 1 }} + /> + {chart.sessionIds + .filter((sid) => !hiddenSessions.has(sid)) + .map((sid) => ( + + ))} + + +
+
+
+ ))} +
+ ) : ( +
+ None of the selected sessions logged metrics yet. +
+ )} + + {/* ── Feature overlap ── */} + {overlap ? ( +
+
+

Feature overlap

+
+
+
+
+ Common to all ({overlap.common.length}) +
+ {overlap.common.length > 0 ? ( +
+ {overlap.common.map((f) => ( + + {f} + + ))} +
+ ) : ( +
+ No features shared by all sessions. +
+ )} +
+ {Object.entries(overlap.per_session).map(([sid, feats]) => { + const unique = feats.filter((f) => !overlap.common.includes(f)); + return ( +
+
+ + {labelOf(sid)} —{' '} + {unique.length ? `+${unique.length} extra` : 'no extras'} ( + {feats.length} total) +
+ {unique.length > 0 ? ( +
+ {unique.map((f) => ( + + {f} + + ))} +
+ ) : null} +
+ ); + })} +
+
+ ) : null} + + ) : null} +
+
+
+ ); +} + +// useSearchParams requires a Suspense boundary for static prerendering. +export default function ComparePage() { + return ( + + + Loading… + + } + > + + + ); +} diff --git a/frontend/src/app/experiments/page.tsx b/frontend/src/app/experiments/page.tsx index 3230328..e0b9f86 100644 --- a/frontend/src/app/experiments/page.tsx +++ b/frontend/src/app/experiments/page.tsx @@ -23,6 +23,7 @@ import { Loader2, RefreshCw, Search, + Trophy, } from 'lucide-react'; import { api } from '@/lib/api'; @@ -30,6 +31,9 @@ import { useApp } from '@/lib/AppContext'; import Sidebar from '@/components/Sidebar'; import type { Experiment, ExperimentFullDetail, Project } from '@/lib/types'; +// The /compare backend endpoint caps a comparison at 8 sessions. +const COMPARE_LIMIT = 8; + const STATE_TONE: Record = { created: 'bg-amber-500/10 text-amber-300 border-amber-500/30', prepping: 'bg-amber-500/10 text-amber-300 border-amber-500/30', @@ -69,6 +73,9 @@ export default function ExperimentsListPage() { const [error, setError] = useState(null); const [hydrating, setHydrating] = useState(false); const [query, setQuery] = useState(''); + // Session ids picked for comparison (checkbox column). Only rows with a + // session can be compared — /compare aggregates per-session. + const [selectedSessions, setSelectedSessions] = useState>(new Set()); const fetchExperiments = useCallback(async () => { setLoading(true); @@ -176,6 +183,29 @@ export default function ExperimentsListPage() { router.push(`/experiments/${r.id}`); }; + const toggleSelected = (sessionId: string) => { + setSelectedSessions((prev) => { + const next = new Set(prev); + if (next.has(sessionId)) next.delete(sessionId); + else if (next.size < COMPARE_LIMIT) next.add(sessionId); + return next; + }); + }; + + const openCompare = () => { + const ids = Array.from(selectedSessions); + if (ids.length < 2) return; + // Scope the leaderboard to a project when the selection is homogeneous. + const projectIds = new Set( + rows + .filter((r) => r.session_id && selectedSessions.has(r.session_id)) + .map((r) => r.project_id), + ); + const qs = new URLSearchParams({ sessions: ids.join(',') }); + if (projectIds.size === 1) qs.set('project', Array.from(projectIds)[0]); + router.push(`/compare?${qs.toString()}`); + }; + return (
@@ -185,6 +215,21 @@ export default function ExperimentsListPage() {

Experiments

{hydrating ? : null}
+ {selectedSessions.size > 0 ? ( + + ) : null}
+ Name State @@ -270,6 +316,28 @@ export default function ExperimentsListPage() { onClick={() => openLineage(r)} className="border-b border-surface-border last:border-b-0 hover:bg-white/[0.04] cursor-pointer text-gray-300" > + e.stopPropagation()} + > + = COMPARE_LIMIT) + } + onChange={() => r.session_id && toggleSelected(r.session_id)} + className="accent-amber-400 cursor-pointer disabled:cursor-not-allowed" + title={ + !r.session_id + ? 'No session yet — nothing to compare' + : 'Select for comparison' + } + aria-label={`Select ${r.name} for comparison`} + /> +
{r.name}
{r.hypothesis ? ( diff --git a/frontend/src/components/MetricsTab.tsx b/frontend/src/components/MetricsTab.tsx index c6d0b75..ad49110 100644 --- a/frontend/src/components/MetricsTab.tsx +++ b/frontend/src/components/MetricsTab.tsx @@ -116,9 +116,10 @@ interface RichPanel { // --------------------------------------------------------------------------- // Color palette — 16 distinct, ordered for max contrast between neighbors +// (exported for reuse by the /compare leaderboard charts) // --------------------------------------------------------------------------- -const PALETTE = [ +export const PALETTE = [ '#3B82F6', // blue '#F97316', // orange '#10B981', // emerald @@ -150,15 +151,15 @@ function inferGroup(name: string): string { return 'Other'; } -function isLowerBetter(name: string): boolean { +export function isLowerBetter(name: string): boolean { return LOSS_PATTERN.test(name); } -function prettyMetricName(name: string): string { +export function prettyMetricName(name: string): string { return name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } -function smartFormat(v: number): string { +export function smartFormat(v: number): string { if (v === 0) return '0'; const abs = Math.abs(v); if (abs >= 10000) return v.toLocaleString('en-US', { maximumFractionDigits: 0 }); @@ -168,7 +169,7 @@ function smartFormat(v: number): string { return v.toExponential(2); } -function compactFormat(v: number): string { +export function compactFormat(v: number): string { const abs = Math.abs(v); if (abs >= 1000000) return (v / 1000000).toFixed(1) + 'M'; if (abs >= 1000) return (v / 1000).toFixed(1) + 'K'; @@ -182,7 +183,7 @@ function compactFormat(v: number): string { // Custom Tooltip // --------------------------------------------------------------------------- -function ChartTooltip({ active, payload, label }: any) { +export function ChartTooltip({ active, payload, label }: any) { if (!active || !payload?.length) return null; return (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a8f9ce9..a1feaf7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -31,6 +31,7 @@ import type { DatasetVersionDetail, SessionRow, ExperimentFullDetail, + CompareResponse, } from './types'; const API_BASE = '/api'; @@ -308,6 +309,13 @@ export const api = { listModels: () => fetchJSON('/models'), listProviders: () => fetchJSON('/providers'), + // Session comparison — metrics + feature overlap + cost totals across + // up to 8 sessions in one round-trip (backend routers/compare.py). + compare: (sessionIds: string[]) => { + const qs = new URLSearchParams({ sessions: sessionIds.join(',') }); + return fetchJSON(`/compare?${qs.toString()}`); + }, + // Usage / cost usageSummary: () => fetchJSON(`/usage/summary`), projectUsage: (projectId: string) => fetchJSON(`/projects/${projectId}/usage`), diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a1cd5ac..a93c97f 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -621,6 +621,57 @@ export type TaskUpdatePayload = Partial; // Task dict — UI just upserts by id. export type TaskEventData = Task; +// --------------------------------------------------------------------------- +// /compare — session comparison payload (routers/compare.py) +// --------------------------------------------------------------------------- + +// Session + experiment header row. When a requested id doesn't exist the +// backend still returns a stub with `missing: true` so the UI can keep the +// user-supplied ordering. +export interface CompareSessionInfo { + id: string; + missing: boolean; + experiment_id?: string; + experiment_name?: string; + state?: string; + model?: string | null; + created_at?: string; +} + +export interface CompareMetricSample { + step: number; + value: number; + stage?: string | null; +} + +// One series per session for a given metric name. +export interface CompareMetricSeries { + session_id: string; + points: CompareMetricSample[]; +} + +export interface CompareFeatureOverlap { + common: string[]; + per_session: Record; +} + +export interface CompareSessionTotals { + cost_usd: number; + input_tokens: number; + output_tokens: number; + sandbox_seconds: number; +} + +export interface CompareResponse { + sessions: CompareSessionInfo[]; + // metric name → per-session series (points ordered by step) + metrics: Record; + // Backend quirk: initialized as an empty list and only replaced with the + // overlap object when at least one session has a prep summary. + feature_overlap: CompareFeatureOverlap | []; + totals: Record; +} + // Structured search result emitted by web-search and papers-search(search) // alongside the markdown text output. Used by the chat to render a rich // ChatGPT-style source-card panel. From a86a93f96394ec80076c5cfd0b07074fd99049ca Mon Sep 17 00:00:00 2001 From: Lucas Tonon Date: Mon, 20 Jul 2026 10:42:15 -0300 Subject: [PATCH 2/2] fix(review): address Greptile findings on #161 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: feature_overlap empty-list arm typed as never[] instead of the empty-tuple type [] — clearer intent, same Array.isArray narrowing - add docs/manual-tests/compare-leaderboard.md (no frontend unit-test framework exists; manual test tutorial covers selection flow, leaderboard, charts, feature overlap, edge cases) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4 --- docs/manual-tests/compare-leaderboard.md | 78 ++++++++++++++++++++++++ frontend/src/lib/types.ts | 2 +- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 docs/manual-tests/compare-leaderboard.md diff --git a/docs/manual-tests/compare-leaderboard.md b/docs/manual-tests/compare-leaderboard.md new file mode 100644 index 0000000..85d6dcc --- /dev/null +++ b/docs/manual-tests/compare-leaderboard.md @@ -0,0 +1,78 @@ +# Manual test tutorial — Comparison leaderboard (`/compare`) + +Covers the comparison leaderboard shipped in PR #161 (issue #105): the +experiments-page selection flow and the `/compare` page (leaderboard table, +overlaid charts, session legend, feature overlap, cost totals). + +The frontend has no unit-test framework (scripts are `lint` / `build` / +`format` only), so this page is verified manually. Static gates that must +pass first: `cd frontend && npx tsc --noEmit && npm run lint && npm run build`. + +## Prerequisites + +- Backend + frontend running (e.g. `docker compose up` or backend uvicorn + + `cd frontend && npm run dev`). +- At least 3 experiments with sessions, at least 2 of which logged training + metrics; ideally at least one with a prep summary (so `feature_overlap` + is populated) and one still `created`/`failed` (no metrics). + +## 1. Entry point — experiments page selection + +1. Open `/experiments`. +2. Each row with a session shows a checkbox in the first column; rows + without a session show none (only sessions can be compared). +3. Check one row → an amber "Compare selected (1/8)" button appears in the + header, disabled with tooltip "Select at least 2 experiments to compare". +4. Check a second row → button enables. Click it. +5. Expect navigation to `/compare?sessions=,` — plus + `&project=` iff all selected sessions belong to the same project + (verify the project name then shows in the compare header). +6. Selection cap: try to check a 9th session → it must not be added + (counter stays at 8/8). + +## 2. Leaderboard table + +1. Header shows the trophy icon, "Comparison leaderboard", session count, + and a Refresh button (spinner while loading). +2. One row per selected session; duplicate experiment names are + disambiguated with a short session-id suffix like `name (a1b2c3)`. +3. One column per metric showing the latest (highest-step) value; the best + value per metric column carries the trophy highlight — for loss-like + metrics ("lower is better") the *smallest* value must win. +4. Default sort: ranked by the first (alphabetical) metric, best first. +5. Click a metric header → sorts by it; click again → direction flips + (arrow icons update). Sort by Name, Cost, Created too. +6. Sessions without a value for the sorted column sink to the bottom in + both directions. +7. Cost column formatting: `$0`, `<$0.01`, 3 decimals under $1, else 2. + +## 3. Charts + legend + +1. One chart per metric, all sessions overlaid with distinct palette + colors; tooltip shows per-session values at a step. +2. Click a session in the legend → its line disappears from every chart + and the legend entry dims/dashes; click again to restore. +3. If none of the sessions logged metrics: charts are replaced by "None of + the selected sessions logged metrics yet." and no legend renders. + +## 4. Feature overlap + +1. With at least one session having a prep summary: a "Feature overlap" + section renders — "Common to all (n)" green pills, then per-session + rows listing only the extra (non-common) features with a color dot + matching the chart palette. +2. With no prep summaries anywhere: the backend returns `feature_overlap: + []` (empty list — this is the `never[]` union arm in + `CompareResponse`); the section must be entirely absent, with no + runtime error in the console. + +## 5. Edge cases + +1. `/compare` with no `sessions` param (or a single id): empty state with + a link back to the experiments page — no fetch fired. +2. `/compare?sessions=,`: the unknown session appears as + a `missing` row and is excluded from legend/charts; page still renders. +3. Kill the backend and hit Refresh: a rose error banner shows the + message; restoring the backend + Refresh recovers. +4. Direct-load the URL (hard refresh): the Suspense fallback spinner shows + briefly, then content — no hydration warnings in the console. diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a93c97f..aebc6d0 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -668,7 +668,7 @@ export interface CompareResponse { metrics: Record; // Backend quirk: initialized as an empty list and only replaced with the // overlap object when at least one session has a prep summary. - feature_overlap: CompareFeatureOverlap | []; + feature_overlap: CompareFeatureOverlap | never[]; totals: Record; }