diff --git a/packages/ui/src/features/autoresearch/AutoresearchPanel.stories.tsx b/packages/ui/src/features/autoresearch/AutoresearchPanel.stories.tsx new file mode 100644 index 0000000000..cc20962a30 --- /dev/null +++ b/packages/ui/src/features/autoresearch/AutoresearchPanel.stories.tsx @@ -0,0 +1,164 @@ +import type { + AutoresearchIteration, + AutoresearchRun, +} from "@posthog/core/autoresearch/schemas"; +import { Flex } from "@radix-ui/themes"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RunStats } from "./AutoresearchPanel"; +import { IterationsTable } from "./IterationsTable"; +import { MetricChart } from "./MetricChart"; + +/** + * The run dashboard's presentational stack — stat cards, metric chart, and + * iterations table — as `AutoresearchPanel` composes them, minus the header + * controls and dialogs (which need a live service). + */ +function RunDashboard({ run }: { run: AutoresearchRun }) { + return ( + + + + + + ); +} + +const BASE_AT = Date.parse("2026-07-07T09:00:00Z"); + +const iterationsFrom = ( + values: number[], + direction: AutoresearchRun["config"]["direction"], + summaries: (string | null)[] = [], +): AutoresearchIteration[] => { + let best: number | null = null; + return values.map((value, i) => { + best = + best === null || (direction === "minimize" ? value < best : value > best) + ? value + : best; + return { + index: i + 1, + value, + bestValue: best, + delta: i === 0 ? null : value - values[i - 1], + summary: summaries[i] ?? null, + at: BASE_AT + i * 8 * 60_000, + }; + }); +}; + +const run = (overrides: Partial = {}): AutoresearchRun => ({ + id: "run-1", + config: { + taskId: "task-1", + direction: "minimize", + targetValue: 380, + maxIterations: 12, + implementModel: null, + measureModel: null, + implementEffort: null, + measureEffort: null, + instructions: "Shrink the renderer bundle without breaking tests.", + }, + status: "running", + metricName: "bundle size", + metricUnit: "kB", + phase: null, + originalModel: null, + originalEffort: null, + iterations: [], + startedAt: BASE_AT, + endedAt: null, + endReason: null, + interruptedReason: null, + lastError: null, + ...overrides, +}); + +const meta: Meta = { + title: "Autoresearch/RunDashboard", + component: RunDashboard, + // Match the panel's column width so cards, chart, and table size realistically. + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** A minimize run mid-flight: noisy progress, best-so-far frontier, target line. */ +export const MinimizeInProgress: Story = { + args: { + run: run({ + iterations: iterationsFrom( + [512, 498, 505, 461, 442, 449, 407, 398], + "minimize", + [ + "Baseline", + "Tree-shake icon imports", + "Revert: broke lazy routes", + "Split vendor chunk", + "Drop moment locales", + "Inline critical CSS (regression)", + "Lazy-load diff worker", + "Dedupe zod versions", + ], + ), + }), + }, +}; + +/** A completed maximize run with no target — the loop spent its budget. */ +export const MaximizeCompleted: Story = { + args: { + run: run({ + status: "completed", + endedAt: BASE_AT + 10 * 8 * 60_000, + endReason: "max-iterations", + metricName: "cache hit rate", + metricUnit: "%", + config: { + ...run().config, + direction: "maximize", + targetValue: null, + maxIterations: 10, + }, + iterations: iterationsFrom( + [62, 71, 68, 74, 79, 77, 83, 82, 86, 85], + "maximize", + [ + "Baseline", + "Warm cache on boot", + null, + "Bigger LRU", + null, + null, + "Precompute keys", + null, + "Batch invalidations", + null, + ], + ), + }), + }, +}; + +/** Before the first metric report arrives: empty cards, chart, and table. */ +export const NoIterationsYet: Story = { + args: { run: run() }, +}; diff --git a/packages/ui/src/features/autoresearch/AutoresearchPanel.tsx b/packages/ui/src/features/autoresearch/AutoresearchPanel.tsx index 8cd14534bd..756b8508ec 100644 --- a/packages/ui/src/features/autoresearch/AutoresearchPanel.tsx +++ b/packages/ui/src/features/autoresearch/AutoresearchPanel.tsx @@ -14,8 +14,9 @@ import { EmptyMedia, EmptyTitle, } from "@posthog/quill"; +import { MetricCard, useChartTheme } from "@posthog/quill-charts"; import { Badge, Button, Callout, Flex, Select, Text } from "@radix-ui/themes"; -import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { getConfigOptionByCategory, useSessionStore, @@ -24,7 +25,7 @@ import { usePendingPermissionsForTask } from "../sessions/useSession"; import { AutoresearchConfigDialog } from "./AutoresearchConfigDialog"; import { IterationsTable } from "./IterationsTable"; import { MetricChart } from "./MetricChart"; -import { withMetricUnit } from "./metricFormat"; +import { metricNumberFormat, withMetricUnit } from "./metricFormat"; import { type AutoresearchModelOption, stageValueLabel, @@ -62,10 +63,6 @@ const INTERRUPTION_LABEL: Record = { "app-restart": "App restarted mid-run", }; -const numberFormat = new Intl.NumberFormat("en-US", { - maximumFractionDigits: 4, -}); - interface AutoresearchPanelProps { taskId: string; } @@ -378,65 +375,69 @@ function PendingPermissionNotice({ ); } -function RunStats({ run }: { run: AutoresearchRun }) { +export function RunStats({ run }: { run: AutoresearchRun }) { const summary = useMemo(() => summarizeRun(run), [run]); + const theme = useChartTheme(); const unit = run.metricUnit; + const iterations = run.iterations; + const labels = useMemo( + () => iterations.map((iteration) => `iter ${iteration.index}`), + [iterations], + ); + const formatMetricValue = (value: number) => + Number.isNaN(value) + ? "—" + : withMetricUnit(metricNumberFormat.format(value), unit); return (
- - {withMetricUnit(numberFormat.format(summary.best.value), unit)} - - {" "} - (iter {summary.best.index}) - - - ) : ( - "—" - ) + 0 + ? iterations.map((iteration) => iteration.bestValue) + : undefined } + labels={labels} + theme={theme} + formatValue={formatMetricValue} + change={null} + subtitle={summary.best ? `iter ${summary.best.index}` : undefined} + dataAttr="autoresearch-stat-best" /> - 0 + ? iterations.map((iteration) => iteration.value) + : undefined } + labels={labels} + theme={theme} + formatValue={formatMetricValue} + change={null} + dataAttr="autoresearch-stat-last" /> - `${value} / ${run.config.maxIterations}`} + change={null} + dataAttr="autoresearch-stat-iterations" /> -
); } -function StatCard({ label, value }: { label: string; value: ReactNode }) { - return ( -
- - {label} - - - {value} - -
- ); -} - function stageText( model: string | null, effort: string | null, diff --git a/packages/ui/src/features/autoresearch/IterationsTable.tsx b/packages/ui/src/features/autoresearch/IterationsTable.tsx index 0b5427af17..e964a4dd75 100644 --- a/packages/ui/src/features/autoresearch/IterationsTable.tsx +++ b/packages/ui/src/features/autoresearch/IterationsTable.tsx @@ -2,10 +2,16 @@ import type { AutoresearchDirection, AutoresearchIteration, } from "@posthog/core/autoresearch/schemas"; -import { computeBest, isImprovement } from "@posthog/core/autoresearch/stats"; +import { computeBest } from "@posthog/core/autoresearch/stats"; import { Badge, Table, Text } from "@radix-ui/themes"; import { useMemo } from "react"; -import { withMetricUnit } from "./metricFormat"; +import { + type DeltaTone, + deltaTone, + formatMetricDelta, + metricNumberFormat, + withMetricUnit, +} from "./metricFormat"; interface IterationsTableProps { iterations: AutoresearchIteration[]; @@ -13,22 +19,16 @@ interface IterationsTableProps { unit: string | null; } -const numberFormat = new Intl.NumberFormat("en-US", { - maximumFractionDigits: 4, -}); - const timeFormat = new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit", }); -function deltaColor( - delta: number | null, - direction: AutoresearchDirection, -): "green" | "red" | "gray" { - if (delta === null || delta === 0) return "gray"; - return isImprovement(delta, 0, direction) ? "green" : "red"; -} +const TONE_COLOR: Record = { + improved: "green", + worsened: "red", + neutral: "gray", +}; export function IterationsTable({ iterations, @@ -66,7 +66,10 @@ export function IterationsTable({ {iteration.index} - {withMetricUnit(numberFormat.format(iteration.value), unit)} + {withMetricUnit( + metricNumberFormat.format(iteration.value), + unit, + )} {best?.index === iteration.index && ( best @@ -77,15 +80,10 @@ export function IterationsTable({ - {iteration.delta === null - ? "—" - : withMetricUnit( - `${iteration.delta > 0 ? "+" : ""}${numberFormat.format(iteration.delta)}`, - unit, - )} + {formatMetricDelta(iteration.delta, unit)} diff --git a/packages/ui/src/features/autoresearch/MetricChart.tsx b/packages/ui/src/features/autoresearch/MetricChart.tsx index 879fb878f5..d20bc3a7b6 100644 --- a/packages/ui/src/features/autoresearch/MetricChart.tsx +++ b/packages/ui/src/features/autoresearch/MetricChart.tsx @@ -2,13 +2,15 @@ import type { AutoresearchDirection, AutoresearchIteration, } from "@posthog/core/autoresearch/schemas"; +import { + LineChart, + ReferenceLine, + type Series, + useChartTheme, +} from "@posthog/quill-charts"; import { Text } from "@radix-ui/themes"; import { useMemo } from "react"; -import { withMetricUnit } from "./metricFormat"; - -const WIDTH = 640; -const HEIGHT = 220; -const PADDING = { top: 12, right: 16, bottom: 24, left: 52 }; +import { formatChartValue, withMetricUnit } from "./metricFormat"; interface MetricChartProps { iterations: AutoresearchIteration[]; @@ -18,19 +20,6 @@ interface MetricChartProps { unit: string | null; } -const wholeNumberFormat = new Intl.NumberFormat("en-US", { - maximumFractionDigits: 0, -}); -const fractionalNumberFormat = new Intl.NumberFormat("en-US", { - maximumFractionDigits: 2, -}); - -function formatValue(value: number): string { - return ( - Math.abs(value) >= 1000 ? wholeNumberFormat : fractionalNumberFormat - ).format(value); -} - /** * Metric value per iteration (solid, with dots) plus the best-so-far * frontier (dashed) and the optional target line. @@ -42,53 +31,37 @@ export function MetricChart({ metricName, unit, }: MetricChartProps) { - const chart = useMemo(() => { - if (iterations.length === 0) return null; + const theme = useChartTheme(); + // Canvas colors must be concrete — `var(--…)` strings don't paint. The + // theme's palette is already resolved from CSS variables. + const valueColor = theme.colors[0] ?? "#1d4aff"; + const bestColor = theme.axisColor ?? "#8b8d98"; - const values = iterations.map((iteration) => iteration.value); - const bests = iterations.map((iteration) => iteration.bestValue); - const all = [...values, ...bests]; - if (targetValue !== null) all.push(targetValue); - - let min = Math.min(...all); - let max = Math.max(...all); - if (min === max) { - min -= 1; - max += 1; - } - const span = max - min; - min -= span * 0.05; - max += span * 0.05; - - const innerWidth = WIDTH - PADDING.left - PADDING.right; - const innerHeight = HEIGHT - PADDING.top - PADDING.bottom; - const x = (index: number) => - PADDING.left + - (iterations.length === 1 - ? innerWidth / 2 - : (index / (iterations.length - 1)) * innerWidth); - const y = (value: number) => - PADDING.top + ((max - value) / (max - min)) * innerHeight; - - const valuePoints = iterations.map( - (iteration, i) => `${x(i)},${y(iteration.value)}`, - ); - const bestPoints = iterations.map( - (iteration, i) => `${x(i)},${y(iteration.bestValue)}`, - ); - - return { - x, - y, - min, - max, - valuePath: valuePoints.join(" "), - bestPath: bestPoints.join(" "), - targetY: targetValue === null ? null : y(targetValue), - }; - }, [iterations, targetValue]); + const series: Series[] = useMemo( + () => [ + { + key: "value", + label: "value", + data: iterations.map((iteration) => iteration.value), + color: valueColor, + points: { radius: 3 }, + }, + { + key: "best", + label: "best so far", + data: iterations.map((iteration) => iteration.bestValue), + color: bestColor, + stroke: { pattern: [4, 4] }, + }, + ], + [iterations, valueColor, bestColor], + ); + const labels = useMemo( + () => iterations.map((iteration) => String(iteration.index)), + [iterations], + ); - if (!chart) { + if (iterations.length === 0) { return (
@@ -99,119 +72,53 @@ export function MetricChart({ } return ( -
- - {/* y-axis extremes */} - + {/* flex-col + fixed height: the quill chart sizes its canvas by filling + a flex-column parent; a plain block collapses it to 0. */} +
+ + withMetricUnit(formatChartValue(value), unit), + // Keep an off-scale target visible instead of clipping it. + valueDomain: + targetValue === null ? undefined : { include: [targetValue] }, + }} + theme={theme} + dataAttr="autoresearch-metric-chart" > - {withMetricUnit(formatValue(chart.max), unit)} - - - {withMetricUnit(formatValue(chart.min), unit)} - - - - - {chart.targetY !== null && ( - - - - target {withMetricUnit(formatValue(targetValue ?? 0), unit)} - - - )} - - - - {iterations.map((iteration, i) => ( - - - {`Iteration ${iteration.index}: ${withMetricUnit(formatValue(iteration.value), unit)}${iteration.summary ? ` — ${iteration.summary}` : ""}`} - - - ))} - - {/* x-axis extremes */} - - 1 - - {iterations.length > 1 && ( - - {iterations[iterations.length - 1].index} - - )} - + )} + +
- value + {" "} + value - {" "} + {" "} best so far
diff --git a/packages/ui/src/features/autoresearch/metricFormat.test.ts b/packages/ui/src/features/autoresearch/metricFormat.test.ts new file mode 100644 index 0000000000..d8b919efb7 --- /dev/null +++ b/packages/ui/src/features/autoresearch/metricFormat.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { deltaTone, formatChartValue, formatMetricDelta } from "./metricFormat"; + +describe("deltaTone", () => { + it.each([ + [null, "minimize", "neutral"], + [0, "minimize", "neutral"], + [-10, "minimize", "improved"], + [10, "minimize", "worsened"], + [10, "maximize", "improved"], + [-10, "maximize", "worsened"], + ] as const)("delta %s under %s is %s", (delta, direction, expected) => { + expect(deltaTone(delta, direction)).toBe(expected); + }); +}); + +describe("formatMetricDelta", () => { + it.each([ + [null, "kB", "—"], + [10.5, "kB", "+10.5 kB"], + [-3, null, "-3"], + [2, "%", "+2%"], + ])("formats %s with unit %s as %s", (delta, unit, expected) => { + expect(formatMetricDelta(delta, unit)).toBe(expected); + }); +}); + +describe("formatChartValue", () => { + it.each([ + [1234.56, "1,235"], + [999.456, "999.46"], + [-1500.4, "-1,500"], + ])("formats %s as %s", (value, expected) => { + expect(formatChartValue(value)).toBe(expected); + }); +}); diff --git a/packages/ui/src/features/autoresearch/metricFormat.ts b/packages/ui/src/features/autoresearch/metricFormat.ts index 2dc18cf5ff..c60c18db6a 100644 --- a/packages/ui/src/features/autoresearch/metricFormat.ts +++ b/packages/ui/src/features/autoresearch/metricFormat.ts @@ -1,3 +1,6 @@ +import type { AutoresearchDirection } from "@posthog/core/autoresearch/schemas"; +import { isImprovement } from "@posthog/core/autoresearch/stats"; + /** * Attach the run's metric unit to an already-formatted value. Percent hugs * the number ("42%"); every other unit gets a space ("412 kB"). A null unit @@ -7,3 +10,49 @@ export function withMetricUnit(formatted: string, unit: string | null): string { if (!unit) return formatted; return unit.startsWith("%") ? `${formatted}${unit}` : `${formatted} ${unit}`; } + +/** Full-precision metric value, as shown in stats and iteration tables. */ +export const metricNumberFormat = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 4, +}); + +const wholeNumberFormat = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 0, +}); +const fractionalNumberFormat = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 2, +}); + +/** Compact metric value for chart axis labels: no fraction past 1000. */ +export function formatChartValue(value: number): string { + return ( + Math.abs(value) >= 1000 ? wholeNumberFormat : fractionalNumberFormat + ).format(value); +} + +/** + * Whether an iteration's delta moved the metric the right way. Renderers map + * a tone to their own color space (Radix tokens on screen, fixed hex in the + * exported report). + */ +export type DeltaTone = "improved" | "worsened" | "neutral"; + +export function deltaTone( + delta: number | null, + direction: AutoresearchDirection, +): DeltaTone { + if (delta === null || delta === 0) return "neutral"; + return isImprovement(delta, 0, direction) ? "improved" : "worsened"; +} + +/** Signed delta with unit ("+1.5 kB"); "—" for the baseline iteration. */ +export function formatMetricDelta( + delta: number | null, + unit: string | null, +): string { + if (delta === null) return "—"; + return withMetricUnit( + `${delta > 0 ? "+" : ""}${metricNumberFormat.format(delta)}`, + unit, + ); +}