Skip to content

Commit 9f4084c

Browse files
authored
feat(autoresearch): render run dashboard with quill-charts (#3213)
@fercgomes can we switch to the quill chart library? ## Summary Renders the autoresearch run dashboard with **`@posthog/quill-charts`** (already a `packages/ui` dependency — no new packages): - **`MetricChart`** is now a quill `LineChart`: solid value series with point dots, dashed "best so far" frontier, and a goal `ReferenceLine` for the target (kept on-plot via `valueDomain: { include }`). Gains crosshair + tooltips and follows the app theme via `useChartTheme()`. - **`RunStats`** cards are quill `MetricCard`s: Best and Last carry sparklines (best-so-far frontier / raw values, with hover-to-inspect headlines); Iterations formats `N / max`; Target shows the target or "—". - Canvas series colors use the **resolved theme palette** — canvas `strokeStyle` ignores `var(--…)` strings, so explicit CSS-variable colors silently never painted (caught via the new storybook story). The DOM-rendered `ReferenceLine` keeps `var(--green-9)`. - New **storybook story** `Autoresearch/RunDashboard` (in-progress minimize run, completed maximize run, empty state) for visual review — `pnpm --filter code storybook`. - Shared `metricFormat.ts` helpers (`deltaTone`, `formatMetricDelta`, `metricNumberFormat`, `formatChartValue`) remain, used by the chart, cards, and iterations table. ## We changed our minds 🙂 This PR started as an HTML/PNG report export (see early commits and review threads). Review surfaced duplication between the export's hand-rolled chart and the live one; unifying on quill-charts turned out to be incompatible with a static export (canvas rendering + DOM-overlay reference lines + runtime CSS-variable theming don't survive static rasterization). Choosing between the two, the quill migration won — the export feature is **removed** (`reportExport`, `chartLayout`, the Export button, and their tests). The QA Swarm review threads about export code refer to deleted code. ## Testing - 13 vitest cases on the shared formatting helpers; typecheck, Biome, and `build-storybook` clean. - Stories screenshot-verified headlessly (chart lines, target line, card sparklines all paint). 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Screenshots From the `Autoresearch/RunDashboard` storybook stories (dark theme, 2×): **Minimize run in progress** — value line + best-so-far frontier + target line; Best/Last cards with sparklines: ![Minimize run in progress](https://raw.githubusercontent.com/PostHog/code/2e90015af50ea8876bb1e6ee5ee920b806b233bd/screenshots/autoresearch-minimize-in-progress.png) **Maximize run completed** — `%` unit, no target, best tag at iteration 9: ![Maximize run completed](https://raw.githubusercontent.com/PostHog/code/2e90015af50ea8876bb1e6ee5ee920b806b233bd/screenshots/autoresearch-maximize-completed.png) <sub>Images live on the non-merging [`posthog-code/pr-3213-assets`](https://github.com/PostHog/code/tree/posthog-code/pr-3213-assets/screenshots) branch, so no binaries land in `main` with this PR.</sub>
1 parent 8dcfcb1 commit 9f4084c

6 files changed

Lines changed: 396 additions & 241 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import type {
2+
AutoresearchIteration,
3+
AutoresearchRun,
4+
} from "@posthog/core/autoresearch/schemas";
5+
import { Flex } from "@radix-ui/themes";
6+
import type { Meta, StoryObj } from "@storybook/react-vite";
7+
import { RunStats } from "./AutoresearchPanel";
8+
import { IterationsTable } from "./IterationsTable";
9+
import { MetricChart } from "./MetricChart";
10+
11+
/**
12+
* The run dashboard's presentational stack — stat cards, metric chart, and
13+
* iterations table — as `AutoresearchPanel` composes them, minus the header
14+
* controls and dialogs (which need a live service).
15+
*/
16+
function RunDashboard({ run }: { run: AutoresearchRun }) {
17+
return (
18+
<Flex direction="column" gap="4">
19+
<RunStats run={run} />
20+
<MetricChart
21+
iterations={run.iterations}
22+
direction={run.config.direction}
23+
targetValue={run.config.targetValue}
24+
metricName={run.metricName ?? "the metric"}
25+
unit={run.metricUnit}
26+
/>
27+
<IterationsTable
28+
iterations={run.iterations}
29+
direction={run.config.direction}
30+
unit={run.metricUnit}
31+
/>
32+
</Flex>
33+
);
34+
}
35+
36+
const BASE_AT = Date.parse("2026-07-07T09:00:00Z");
37+
38+
const iterationsFrom = (
39+
values: number[],
40+
direction: AutoresearchRun["config"]["direction"],
41+
summaries: (string | null)[] = [],
42+
): AutoresearchIteration[] => {
43+
let best: number | null = null;
44+
return values.map((value, i) => {
45+
best =
46+
best === null || (direction === "minimize" ? value < best : value > best)
47+
? value
48+
: best;
49+
return {
50+
index: i + 1,
51+
value,
52+
bestValue: best,
53+
delta: i === 0 ? null : value - values[i - 1],
54+
summary: summaries[i] ?? null,
55+
at: BASE_AT + i * 8 * 60_000,
56+
};
57+
});
58+
};
59+
60+
const run = (overrides: Partial<AutoresearchRun> = {}): AutoresearchRun => ({
61+
id: "run-1",
62+
config: {
63+
taskId: "task-1",
64+
direction: "minimize",
65+
targetValue: 380,
66+
maxIterations: 12,
67+
implementModel: null,
68+
measureModel: null,
69+
implementEffort: null,
70+
measureEffort: null,
71+
instructions: "Shrink the renderer bundle without breaking tests.",
72+
},
73+
status: "running",
74+
metricName: "bundle size",
75+
metricUnit: "kB",
76+
phase: null,
77+
originalModel: null,
78+
originalEffort: null,
79+
iterations: [],
80+
startedAt: BASE_AT,
81+
endedAt: null,
82+
endReason: null,
83+
interruptedReason: null,
84+
lastError: null,
85+
...overrides,
86+
});
87+
88+
const meta: Meta<typeof RunDashboard> = {
89+
title: "Autoresearch/RunDashboard",
90+
component: RunDashboard,
91+
// Match the panel's column width so cards, chart, and table size realistically.
92+
decorators: [
93+
(Story) => (
94+
<div style={{ maxWidth: 760, margin: "2rem auto", padding: "0 1rem" }}>
95+
<Story />
96+
</div>
97+
),
98+
],
99+
};
100+
101+
export default meta;
102+
type Story = StoryObj<typeof RunDashboard>;
103+
104+
/** A minimize run mid-flight: noisy progress, best-so-far frontier, target line. */
105+
export const MinimizeInProgress: Story = {
106+
args: {
107+
run: run({
108+
iterations: iterationsFrom(
109+
[512, 498, 505, 461, 442, 449, 407, 398],
110+
"minimize",
111+
[
112+
"Baseline",
113+
"Tree-shake icon imports",
114+
"Revert: broke lazy routes",
115+
"Split vendor chunk",
116+
"Drop moment locales",
117+
"Inline critical CSS (regression)",
118+
"Lazy-load diff worker",
119+
"Dedupe zod versions",
120+
],
121+
),
122+
}),
123+
},
124+
};
125+
126+
/** A completed maximize run with no target — the loop spent its budget. */
127+
export const MaximizeCompleted: Story = {
128+
args: {
129+
run: run({
130+
status: "completed",
131+
endedAt: BASE_AT + 10 * 8 * 60_000,
132+
endReason: "max-iterations",
133+
metricName: "cache hit rate",
134+
metricUnit: "%",
135+
config: {
136+
...run().config,
137+
direction: "maximize",
138+
targetValue: null,
139+
maxIterations: 10,
140+
},
141+
iterations: iterationsFrom(
142+
[62, 71, 68, 74, 79, 77, 83, 82, 86, 85],
143+
"maximize",
144+
[
145+
"Baseline",
146+
"Warm cache on boot",
147+
null,
148+
"Bigger LRU",
149+
null,
150+
null,
151+
"Precompute keys",
152+
null,
153+
"Batch invalidations",
154+
null,
155+
],
156+
),
157+
}),
158+
},
159+
};
160+
161+
/** Before the first metric report arrives: empty cards, chart, and table. */
162+
export const NoIterationsYet: Story = {
163+
args: { run: run() },
164+
};

packages/ui/src/features/autoresearch/AutoresearchPanel.tsx

Lines changed: 51 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import {
1414
EmptyMedia,
1515
EmptyTitle,
1616
} from "@posthog/quill";
17+
import { MetricCard, useChartTheme } from "@posthog/quill-charts";
1718
import { Badge, Button, Callout, Flex, Select, Text } from "@radix-ui/themes";
18-
import { type ReactNode, useEffect, useMemo, useState } from "react";
19+
import { useEffect, useMemo, useState } from "react";
1920
import {
2021
getConfigOptionByCategory,
2122
useSessionStore,
@@ -24,7 +25,7 @@ import { usePendingPermissionsForTask } from "../sessions/useSession";
2425
import { AutoresearchConfigDialog } from "./AutoresearchConfigDialog";
2526
import { IterationsTable } from "./IterationsTable";
2627
import { MetricChart } from "./MetricChart";
27-
import { withMetricUnit } from "./metricFormat";
28+
import { metricNumberFormat, withMetricUnit } from "./metricFormat";
2829
import {
2930
type AutoresearchModelOption,
3031
stageValueLabel,
@@ -62,10 +63,6 @@ const INTERRUPTION_LABEL: Record<string, string> = {
6263
"app-restart": "App restarted mid-run",
6364
};
6465

65-
const numberFormat = new Intl.NumberFormat("en-US", {
66-
maximumFractionDigits: 4,
67-
});
68-
6966
interface AutoresearchPanelProps {
7067
taskId: string;
7168
}
@@ -378,65 +375,69 @@ function PendingPermissionNotice({
378375
);
379376
}
380377

381-
function RunStats({ run }: { run: AutoresearchRun }) {
378+
export function RunStats({ run }: { run: AutoresearchRun }) {
382379
const summary = useMemo(() => summarizeRun(run), [run]);
380+
const theme = useChartTheme();
383381
const unit = run.metricUnit;
382+
const iterations = run.iterations;
383+
const labels = useMemo(
384+
() => iterations.map((iteration) => `iter ${iteration.index}`),
385+
[iterations],
386+
);
387+
const formatMetricValue = (value: number) =>
388+
Number.isNaN(value)
389+
? "—"
390+
: withMetricUnit(metricNumberFormat.format(value), unit);
384391

385392
return (
386393
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
387-
<StatCard
388-
label="Best"
389-
value={
390-
summary.best ? (
391-
<>
392-
{withMetricUnit(numberFormat.format(summary.best.value), unit)}
393-
<Text size="1" color="gray">
394-
{" "}
395-
(iter {summary.best.index})
396-
</Text>
397-
</>
398-
) : (
399-
"—"
400-
)
394+
<MetricCard
395+
title="Best"
396+
value={summary.best?.value ?? Number.NaN}
397+
data={
398+
iterations.length > 0
399+
? iterations.map((iteration) => iteration.bestValue)
400+
: undefined
401401
}
402+
labels={labels}
403+
theme={theme}
404+
formatValue={formatMetricValue}
405+
change={null}
406+
subtitle={summary.best ? `iter ${summary.best.index}` : undefined}
407+
dataAttr="autoresearch-stat-best"
402408
/>
403-
<StatCard
404-
label="Last"
405-
value={
406-
summary.last
407-
? withMetricUnit(numberFormat.format(summary.last.value), unit)
408-
: "—"
409+
<MetricCard
410+
title="Last"
411+
value={summary.last?.value ?? Number.NaN}
412+
data={
413+
iterations.length > 0
414+
? iterations.map((iteration) => iteration.value)
415+
: undefined
409416
}
417+
labels={labels}
418+
theme={theme}
419+
formatValue={formatMetricValue}
420+
change={null}
421+
dataAttr="autoresearch-stat-last"
410422
/>
411-
<StatCard
412-
label="Iterations"
413-
value={`${summary.iterationCount} / ${run.config.maxIterations}`}
423+
<MetricCard
424+
title="Iterations"
425+
value={summary.iterationCount}
426+
formatValue={(value) => `${value} / ${run.config.maxIterations}`}
427+
change={null}
428+
dataAttr="autoresearch-stat-iterations"
414429
/>
415-
<StatCard
416-
label="Target"
417-
value={
418-
run.config.targetValue === null
419-
? "—"
420-
: withMetricUnit(numberFormat.format(run.config.targetValue), unit)
421-
}
430+
<MetricCard
431+
title="Target"
432+
value={run.config.targetValue ?? Number.NaN}
433+
formatValue={formatMetricValue}
434+
change={null}
435+
dataAttr="autoresearch-stat-target"
422436
/>
423437
</div>
424438
);
425439
}
426440

427-
function StatCard({ label, value }: { label: string; value: ReactNode }) {
428-
return (
429-
<div className="rounded-md border border-(--gray-5) bg-(--gray-2) px-3 py-2">
430-
<Text as="div" size="1" color="gray">
431-
{label}
432-
</Text>
433-
<Text as="div" size="2" weight="medium" className="tabular-nums">
434-
{value}
435-
</Text>
436-
</div>
437-
);
438-
}
439-
440441
function stageText(
441442
model: string | null,
442443
effort: string | null,

packages/ui/src/features/autoresearch/IterationsTable.tsx

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,33 @@ import type {
22
AutoresearchDirection,
33
AutoresearchIteration,
44
} from "@posthog/core/autoresearch/schemas";
5-
import { computeBest, isImprovement } from "@posthog/core/autoresearch/stats";
5+
import { computeBest } from "@posthog/core/autoresearch/stats";
66
import { Badge, Table, Text } from "@radix-ui/themes";
77
import { useMemo } from "react";
8-
import { withMetricUnit } from "./metricFormat";
8+
import {
9+
type DeltaTone,
10+
deltaTone,
11+
formatMetricDelta,
12+
metricNumberFormat,
13+
withMetricUnit,
14+
} from "./metricFormat";
915

1016
interface IterationsTableProps {
1117
iterations: AutoresearchIteration[];
1218
direction: AutoresearchDirection;
1319
unit: string | null;
1420
}
1521

16-
const numberFormat = new Intl.NumberFormat("en-US", {
17-
maximumFractionDigits: 4,
18-
});
19-
2022
const timeFormat = new Intl.DateTimeFormat("en-US", {
2123
hour: "2-digit",
2224
minute: "2-digit",
2325
});
2426

25-
function deltaColor(
26-
delta: number | null,
27-
direction: AutoresearchDirection,
28-
): "green" | "red" | "gray" {
29-
if (delta === null || delta === 0) return "gray";
30-
return isImprovement(delta, 0, direction) ? "green" : "red";
31-
}
27+
const TONE_COLOR: Record<DeltaTone, "green" | "red" | "gray"> = {
28+
improved: "green",
29+
worsened: "red",
30+
neutral: "gray",
31+
};
3232

3333
export function IterationsTable({
3434
iterations,
@@ -66,7 +66,10 @@ export function IterationsTable({
6666
<Table.Cell>{iteration.index}</Table.Cell>
6767
<Table.Cell>
6868
<span className="flex items-center gap-1 tabular-nums">
69-
{withMetricUnit(numberFormat.format(iteration.value), unit)}
69+
{withMetricUnit(
70+
metricNumberFormat.format(iteration.value),
71+
unit,
72+
)}
7073
{best?.index === iteration.index && (
7174
<Badge color="amber" size="1">
7275
best
@@ -77,15 +80,10 @@ export function IterationsTable({
7780
<Table.Cell>
7881
<Text
7982
size="1"
80-
color={deltaColor(iteration.delta, direction)}
83+
color={TONE_COLOR[deltaTone(iteration.delta, direction)]}
8184
className="tabular-nums"
8285
>
83-
{iteration.delta === null
84-
? "—"
85-
: withMetricUnit(
86-
`${iteration.delta > 0 ? "+" : ""}${numberFormat.format(iteration.delta)}`,
87-
unit,
88-
)}
86+
{formatMetricDelta(iteration.delta, unit)}
8987
</Text>
9088
</Table.Cell>
9189
<Table.Cell className="max-w-[320px]">

0 commit comments

Comments
 (0)