- {/*
- `preserveAspectRatio="none"`: without it the default `xMidYMid meet`
- letterboxes the 1:1 viewBox into a centred SQUARE — measured 224px of
- drawn width inside a 323px card (144px at this `h-36`), so a third of
- the chart area was empty and the timeline was compressed to a third of
- its width. Every polyline already carries
- `vectorEffect="non-scaling-stroke"`, which only matters under
- non-uniform scaling: stretching was always the intent.
- */}
-
-
- CPU: green solid line, normalized to the CPU peak for this chunk.
- Memory needed: purple solid line (working set, when reported).
- Total RAM: faint purple dashed line, including reclaimable file cache.
-
- Blue bands: concurrent tool windows. Dashed markers: gaps, counter resets, or OOM samples.
-
-
- Chunk I/O deltas: {formatBytes(chunkIoRead)} read · {formatBytes(chunkIoWrite)} write.
-
-
- Correlation is based on concurrent tool windows and background resource usage. It is not
- per-process causal attribution. Disk space is not sampled on the hot loop.
-
+ The workspace samples CPU, memory and disk every few seconds and uploads them every{' '}
+ {formatElapsed(uploadIntervalMs)}, so the first data appears about {formatElapsed(uploadIntervalMs)} after the
+ session starts.
+
+ 0}
+ onAll={showAll}
+ onSpan={(spanMs) => showSpan(spanMs, centre)}
+ onLatest={() => showLatest(LATEST_SPAN_MS)}
+ onAxisMode={onAxisMode}
+ />
+
+
+ About this data
+
+
+ Sampled every {formatElapsed(index.sampleIntervalMs)} from the workspace container's cgroup. It covers
+ everything in the container, not one process.
+
+
CPU is in cores: 1.0 means one core fully busy.
+
+ Memory “used” is the working set the kernel cannot reclaim; “incl. cache” adds page
+ cache it can drop under pressure. Size workspaces by “used”.
+
+
+ Tool calls line up with usage by time only. A spike during a tool call is a correlation, not proof that the
+ tool caused it.
+
+
Zoomed out, each point is an average with its peak shaded; zoom in for individual samples.
+
+
+ );
+}
diff --git a/apps/web/src/components/chat/resource-timeline/chart-theme.ts b/apps/web/src/components/chat/resource-timeline/chart-theme.ts
new file mode 100644
index 0000000000..20dbf34656
--- /dev/null
+++ b/apps/web/src/components/chat/resource-timeline/chart-theme.ts
@@ -0,0 +1,100 @@
+import { useMemo } from 'react';
+
+import { type ResolvedTheme, useTheme } from '../../../contexts/ThemeContext';
+import type { ToolKind } from './types';
+
+/**
+ * Canvas cannot read CSS custom properties, so the timeline resolves the design
+ * tokens to concrete colours once per theme.
+ */
+export interface ChartTheme {
+ text: string;
+ mutedText: string;
+ grid: string;
+ cpu: string;
+ memory: string;
+ memoryCache: string;
+ ioRead: string;
+ ioWrite: string;
+ danger: string;
+ reservation: string;
+ cursor: string;
+ sleep: string;
+ tools: Record;
+}
+
+/** Token name, then fallbacks for dark and light in case a token is missing. */
+const TOKENS = {
+ text: ['--sam-color-fg-primary', '#e6f2ee', '#0f2a20'],
+ mutedText: ['--sam-color-fg-muted', '#9fb7ae', '#4b665c'],
+ grid: ['--sam-color-border-default', '#29423b', '#cfe0d8'],
+ cpu: ['--sam-color-success', '#22c55e', '#15803d'],
+ memory: ['--sam-color-purple', '#c084fc', '#7c3aed'],
+ ioRead: ['--sam-color-info', '#60a5fa', '#2563eb'],
+ ioWrite: ['--sam-color-warning', '#f59e0b', '#b45309'],
+ danger: ['--sam-color-danger', '#ef4444', '#dc2626'],
+ cyan: ['--sam-admin-chart-series-9', '#06b6d4', '#0e7490'],
+} as const;
+
+type Token = (typeof TOKENS)[keyof typeof TOKENS];
+
+function readToken([name, darkFallback, lightFallback]: Token, theme: ResolvedTheme): string {
+ const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+ return value || (theme === 'dark' ? darkFallback : lightFallback);
+}
+
+/** `#rrggbb` or `rgb(...)` with the given alpha; other colour syntaxes pass through. */
+export function withAlpha(color: string, alpha: number): string {
+ const hex = /^#([0-9a-f]{6})$/i.exec(color);
+ if (hex?.[1]) {
+ const value = parseInt(hex[1], 16);
+ return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`;
+ }
+ const rgb = /^rgba?\(([^)]+)\)$/i.exec(color);
+ if (rgb?.[1]) {
+ const [r, g, b] = rgb[1].split(',').map((part) => part.trim());
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+ }
+ return color;
+}
+
+function resolveChartTheme(theme: ResolvedTheme): ChartTheme {
+ const muted = readToken(TOKENS.mutedText, theme);
+ const text = readToken(TOKENS.text, theme);
+ const memory = readToken(TOKENS.memory, theme);
+ const ioRead = readToken(TOKENS.ioRead, theme);
+ const ioWrite = readToken(TOKENS.ioWrite, theme);
+ const cpu = readToken(TOKENS.cpu, theme);
+ return {
+ text,
+ mutedText: muted,
+ grid: withAlpha(readToken(TOKENS.grid, theme), 0.75),
+ cpu,
+ memory,
+ memoryCache: withAlpha(memory, 0.3),
+ ioRead,
+ ioWrite,
+ danger: readToken(TOKENS.danger, theme),
+ reservation: withAlpha(muted, 0.9),
+ cursor: withAlpha(text, 0.5),
+ sleep: withAlpha(muted, theme === 'dark' ? 0.1 : 0.14),
+ tools: {
+ execute: ioWrite,
+ edit: cpu,
+ read: ioRead,
+ search: memory,
+ fetch: readToken(TOKENS.cyan, theme),
+ think: withAlpha(muted, 0.7),
+ other: withAlpha(muted, 0.9),
+ },
+ };
+}
+
+/**
+ * Theme colours for canvas drawing. `ThemeProvider` applies the theme attribute in
+ * the same effect that publishes `resolvedTheme`, so the tokens are current here.
+ */
+export function useChartTheme(): ChartTheme {
+ const { resolvedTheme } = useTheme();
+ return useMemo(() => resolveChartTheme(resolvedTheme), [resolvedTheme]);
+}
diff --git a/apps/web/src/components/chat/resource-timeline/format.ts b/apps/web/src/components/chat/resource-timeline/format.ts
new file mode 100644
index 0000000000..8c232f12d1
--- /dev/null
+++ b/apps/web/src/components/chat/resource-timeline/format.ts
@@ -0,0 +1,76 @@
+/**
+ * Display formatting for resource values. Every number the timeline shows passes
+ * through here so units are consistent between the readout, axes and stats.
+ */
+
+import { formatFileSize } from '../../../lib/file-utils';
+
+const EM_DASH = '—';
+
+export function formatCores(cores: number | null | undefined): string {
+ if (cores == null || !Number.isFinite(cores)) return EM_DASH;
+ if (cores === 0) return '0 cores';
+ if (cores < 0.01) return '<0.01 cores';
+ const digits = cores < 10 ? 2 : 1;
+ return `${cores.toFixed(digits)} ${cores === 1 ? 'core' : 'cores'}`;
+}
+
+/** Compact core count for axis labels ("1.5"), where the unit is in the panel title. */
+export function formatCoresAxis(cores: number): string {
+ if (cores === 0) return '0';
+ return cores < 1 ? cores.toFixed(2).replace(/0+$/, '') : `${Number(cores.toFixed(1))}`;
+}
+
+export function formatBytes(bytes: number | null | undefined): string {
+ if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return EM_DASH;
+ return formatFileSize(bytes);
+}
+
+export function formatRate(bytesPerSecond: number | null | undefined): string {
+ if (bytesPerSecond == null || !Number.isFinite(bytesPerSecond)) return EM_DASH;
+ return `${formatFileSize(Math.round(bytesPerSecond))}/s`;
+}
+
+/** Elapsed time with seconds precision below two minutes ("35s", "4m 10s", "2h 5m", "3d 4h"). */
+export function formatElapsed(ms: number): string {
+ const seconds = Math.max(0, Math.round(ms / 1000));
+ if (seconds < 120) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 10) return seconds % 60 ? `${minutes}m ${seconds % 60}s` : `${minutes}m`;
+ if (minutes < 120) return `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 48) return minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`;
+ const days = Math.floor(hours / 24);
+ return hours % 24 ? `${days}d ${hours % 24}h` : `${days}d`;
+}
+
+/** Wall-clock label whose precision follows the zoom level. */
+export function formatClock(ts: number, precisionMs: number): string {
+ return new Date(ts).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ ...(precisionMs < 60_000 ? { second: '2-digit' } : {}),
+ });
+}
+
+/** "Sep 28, 20:13" — used where the day matters (range labels, long sessions). */
+export function formatDayTime(ts: number): string {
+ return new Date(ts).toLocaleString([], {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+export function isSameDay(a: number, b: number): boolean {
+ return new Date(a).toDateString() === new Date(b).toDateString();
+}
+
+/** A time range label that only repeats the date when the range crosses midnight. */
+export function formatRange(start: number, end: number, precisionMs: number): string {
+ if (isSameDay(start, end)) {
+ return `${formatClock(start, precisionMs)} – ${formatClock(end, precisionMs)}`;
+ }
+ return `${formatDayTime(start)} – ${formatDayTime(end)}`;
+}
diff --git a/apps/web/src/components/chat/resource-timeline/panels.ts b/apps/web/src/components/chat/resource-timeline/panels.ts
new file mode 100644
index 0000000000..fc107ac5ed
--- /dev/null
+++ b/apps/web/src/components/chat/resource-timeline/panels.ts
@@ -0,0 +1,413 @@
+/**
+ * uPlot configuration for the stacked timeline panels.
+ *
+ * Every panel shares one x scale (axis milliseconds, see `time-axis.ts`) and one
+ * cursor sync group, so a crosshair or zoom in one is a crosshair or zoom in all.
+ * Draw hooks read `PanelState` through a ref: data and view changes redraw the
+ * existing canvas instead of rebuilding it.
+ */
+
+import type { RefObject } from 'react';
+import uPlot from 'uplot';
+
+import { type ChartTheme, withAlpha } from './chart-theme';
+import { formatBytes, formatClock, formatCoresAxis, formatElapsed, formatRate } from './format';
+import type { TimelineSeries } from './series';
+import { axisTicks, sleeps, tickStepMs, type TimeAxis, toAxis, toReal } from './time-axis';
+import type { ResourceReservation, ResourceToolSpan } from './types';
+
+export type PanelKind = 'cpu' | 'memory' | 'disk' | 'tools';
+
+export interface PanelState {
+ axis: TimeAxis;
+ viewMin: number;
+ viewMax: number;
+ series: TimelineSeries;
+ reservation: ResourceReservation | null;
+ toolSpans: readonly ResourceToolSpan[];
+ /** Whether the agent reports working-set memory (otherwise memory includes cache). */
+ hasWorkingSet: boolean;
+}
+
+export interface PanelCallbacks {
+ /** Axis position under the cursor, or null when the cursor leaves. */
+ onCursor: (x: number | null) => void;
+ /** A mouse drag selected `[min, max]` (axis units). */
+ onSelectRange: (min: number, max: number) => void;
+ onResetZoom: () => void;
+}
+
+/** Left gutter (y-axis labels) and right padding shared by every panel and the navigator. */
+export const PLOT_LEFT_GUTTER_PX = 44;
+export const PLOT_RIGHT_PADDING_PX = 6;
+const TICK_MIN_SPACING_PX = 64;
+const TOOL_LANE_ROWS = 3;
+
+function px(value: number): number {
+ return Math.round(value * uPlot.pxRatio);
+}
+
+/** Canvas x (device pixels) for an axis position. */
+function canvasX(u: uPlot, x: number): number {
+ return u.valToPos(x, 'x', true);
+}
+
+const hatchCache = new WeakMap();
+
+function hatchPattern(u: uPlot, color: string): CanvasPattern | null {
+ if (hatchCache.has(u)) return hatchCache.get(u) ?? null;
+ const size = px(6);
+ const tile = document.createElement('canvas');
+ tile.width = size;
+ tile.height = size;
+ const ctx = tile.getContext('2d');
+ if (ctx) {
+ ctx.strokeStyle = color;
+ ctx.lineWidth = px(1);
+ ctx.beginPath();
+ ctx.moveTo(0, size);
+ ctx.lineTo(size, 0);
+ ctx.stroke();
+ }
+ const pattern = u.ctx.createPattern(tile, 'repeat');
+ hatchCache.set(u, pattern);
+ return pattern;
+}
+
+/** Hatches every sleep inside the view; the tool lane also labels how long it lasted. */
+function drawSleeps(u: uPlot, state: PanelState, theme: ChartTheme, label: boolean) {
+ const { ctx, bbox } = u;
+ for (const sleep of sleeps(state.axis)) {
+ if (sleep.axisEnd <= state.viewMin || sleep.axisStart >= state.viewMax) continue;
+ const x0 = Math.max(bbox.left, canvasX(u, sleep.axisStart));
+ const x1 = Math.min(bbox.left + bbox.width, canvasX(u, sleep.axisEnd));
+ if (x1 - x0 < 1) continue;
+ ctx.save();
+ ctx.fillStyle = theme.sleep;
+ ctx.fillRect(x0, bbox.top, x1 - x0, bbox.height);
+ ctx.fillStyle = hatchPattern(u, theme.sleep) ?? theme.sleep;
+ ctx.fillRect(x0, bbox.top, x1 - x0, bbox.height);
+ if (label && x1 - x0 > px(40)) {
+ ctx.fillStyle = theme.mutedText;
+ ctx.font = `${px(10)}px system-ui, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(`asleep ${formatElapsed(sleep.realEnd - sleep.realStart)}`, (x0 + x1) / 2, bbox.top + bbox.height / 2);
+ }
+ ctx.restore();
+ }
+}
+
+function drawReservation(u: uPlot, value: number | null, theme: ChartTheme, label: string) {
+ if (value == null) return;
+ const y = u.valToPos(value, 'y', true);
+ const { ctx, bbox } = u;
+ if (y < bbox.top || y > bbox.top + bbox.height) return;
+ ctx.save();
+ ctx.strokeStyle = theme.reservation;
+ ctx.lineWidth = px(1);
+ ctx.setLineDash([px(4), px(3)]);
+ ctx.beginPath();
+ ctx.moveTo(bbox.left, y);
+ ctx.lineTo(bbox.left + bbox.width, y);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.fillStyle = theme.mutedText;
+ ctx.font = `${px(10)}px system-ui, sans-serif`;
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'bottom';
+ ctx.fillText(label, bbox.left + bbox.width - px(4), y - px(2));
+ ctx.restore();
+}
+
+function drawOomMarkers(u: uPlot, state: PanelState, theme: ChartTheme) {
+ const { ctx, bbox } = u;
+ state.series.oomKills.forEach((kills, index) => {
+ if (kills <= 0) return;
+ const x = canvasX(u, state.series.x[index] ?? 0);
+ ctx.save();
+ ctx.strokeStyle = theme.danger;
+ ctx.lineWidth = px(1.5);
+ ctx.setLineDash([px(3), px(2)]);
+ ctx.beginPath();
+ ctx.moveTo(x, bbox.top);
+ ctx.lineTo(x, bbox.top + bbox.height);
+ ctx.stroke();
+ ctx.fillStyle = theme.danger;
+ ctx.font = `bold ${px(10)}px system-ui, sans-serif`;
+ ctx.textAlign = x > bbox.left + bbox.width - px(40) ? 'right' : 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText(' OOM kill ', x, bbox.top + px(1));
+ ctx.restore();
+ });
+}
+
+/** Greedy interval packing so concurrent tool calls stack instead of hiding each other. */
+function laneRows(spans: readonly ResourceToolSpan[]): number[] {
+ const rowEnds: number[] = [];
+ return spans.map((span) => {
+ const row = rowEnds.findIndex((end) => end <= span.startedAt);
+ const target = row === -1 ? Math.min(rowEnds.length, TOOL_LANE_ROWS - 1) : row;
+ rowEnds[target] = Math.max(rowEnds[target] ?? 0, span.endedAt);
+ return target;
+ });
+}
+
+function drawToolLane(u: uPlot, state: PanelState, theme: ChartTheme) {
+ const { ctx, bbox } = u;
+ const rowHeight = bbox.height / TOOL_LANE_ROWS;
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
+ ctx.clip();
+
+ // Where only the overview is loaded, show how busy each bucket was.
+ const { series } = state;
+ const densest = Math.max(1, ...series.toolStarts);
+ series.toolStarts.forEach((count, index) => {
+ if (count <= 0 || series.exact[index]) return;
+ const x = canvasX(u, (series.x[index] ?? 0) - series.bucketMs / 2);
+ const width = Math.max(px(1), canvasX(u, (series.x[index] ?? 0) + series.bucketMs / 2) - x);
+ ctx.globalAlpha = 0.2 + 0.8 * Math.sqrt(count / densest);
+ ctx.fillStyle = theme.tools.other;
+ ctx.fillRect(x, bbox.top + rowHeight * 0.5, width, bbox.height - rowHeight);
+ });
+ ctx.globalAlpha = 1;
+
+ const sorted = [...state.toolSpans].sort((a, b) => a.startedAt - b.startedAt);
+ const rows = laneRows(sorted);
+ sorted.forEach((span, index) => {
+ const x0 = canvasX(u, toAxis(state.axis, span.startedAt));
+ const x1 = canvasX(u, toAxis(state.axis, span.endedAt));
+ if (x1 < bbox.left || x0 > bbox.left + bbox.width) return;
+ const row = rows[index] ?? 0;
+ ctx.fillStyle = theme.tools[span.kind];
+ ctx.globalAlpha = span.approximateEnd ? 0.5 : 0.9;
+ ctx.fillRect(x0, bbox.top + row * rowHeight + px(1), Math.max(px(2), x1 - x0), rowHeight - px(2));
+ });
+ ctx.restore();
+ drawSleeps(u, state, theme, true);
+}
+
+function xAxis(stateRef: RefObject, theme: ChartTheme, showLabels: boolean): uPlot.Axis {
+ let stepMs = 60_000;
+ return {
+ scale: 'x',
+ show: true,
+ size: showLabels ? 22 : 0,
+ stroke: theme.mutedText,
+ font: '10px system-ui, sans-serif',
+ ticks: { show: false },
+ grid: { stroke: theme.grid, width: 1 },
+ splits: (u, _axisIdx, min, max) => {
+ const state = stateRef.current;
+ const widthPx = u.bbox.width / uPlot.pxRatio;
+ stepMs = tickStepMs((max - min) / Math.max(1, widthPx), TICK_MIN_SPACING_PX);
+ return axisTicks(state.axis, min, max, stepMs).map((tick) => tick.x);
+ },
+ values: (_u, splits) =>
+ splits.map((x) => (showLabels ? formatClock(toReal(stateRef.current.axis, x), stepMs) : '')),
+ };
+}
+
+function yAxis(theme: ChartTheme, format: (value: number) => string): uPlot.Axis {
+ return {
+ scale: 'y',
+ size: PLOT_LEFT_GUTTER_PX,
+ stroke: theme.mutedText,
+ font: '10px system-ui, sans-serif',
+ space: 24,
+ ticks: { show: false },
+ grid: { stroke: theme.grid, width: 1 },
+ values: (_u, splits) => splits.map(format),
+ };
+}
+
+function peak(values: ReadonlyArray): number {
+ return values.reduce((max, value) => (value != null && value > max ? value : max), 0);
+}
+
+function commonOptions(
+ kind: PanelKind,
+ height: number,
+ syncKey: string,
+ stateRef: RefObject,
+ callbacks: RefObject
+): Pick & { hooks: uPlot.Hooks.Arrays } {
+ return {
+ height,
+ legend: { show: false },
+ padding: [kind === 'tools' ? 2 : 8, PLOT_RIGHT_PADDING_PX, 0, 0],
+ select: { show: true, left: 0, top: 0, width: 0, height: 0 },
+ cursor: {
+ y: false,
+ points: { show: false },
+ sync: { key: syncKey, setSeries: false },
+ drag: { x: true, y: false, setScale: false },
+ bind: {
+ dblclick: () => () => {
+ callbacks.current.onResetZoom();
+ return null;
+ },
+ },
+ },
+ scales: {
+ x: { time: false, range: () => [stateRef.current.viewMin, stateRef.current.viewMax] },
+ },
+ hooks: {
+ setSelect: [
+ (u) => {
+ if (u.select.width > 4) {
+ const min = u.posToVal(u.select.left, 'x');
+ const max = u.posToVal(u.select.left + u.select.width, 'x');
+ callbacks.current.onSelectRange(min, max);
+ }
+ u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false);
+ },
+ ],
+ },
+ };
+}
+
+export function panelOptions(
+ kind: PanelKind,
+ theme: ChartTheme,
+ height: number,
+ syncKey: string,
+ stateRef: RefObject,
+ callbacks: RefObject
+): Omit {
+ const base = commonOptions(kind, height, syncKey, stateRef, callbacks);
+ const drawClear: uPlot.Hooks.Arrays['drawClear'] =
+ kind === 'tools' ? [] : [(u) => drawSleeps(u, stateRef.current, theme, false)];
+ const hooks: uPlot.Hooks.Arrays = { ...base.hooks, drawClear };
+
+ switch (kind) {
+ case 'cpu':
+ return {
+ ...base,
+ hooks: {
+ ...hooks,
+ setCursor: [
+ (u) => {
+ const left = u.cursor.left ?? -1;
+ callbacks.current.onCursor(left >= 0 ? u.posToVal(left, 'x') : null);
+ },
+ ],
+ draw: [
+ (u) => {
+ const cores = stateRef.current.reservation?.cpuCores ?? null;
+ drawReservation(u, cores, theme, cores == null ? '' : `reserved ${formatCoresAxis(cores)}`);
+ },
+ ],
+ },
+ scales: {
+ ...base.scales,
+ y: {
+ range: () => {
+ const state = stateRef.current;
+ const top = Math.max(0.5, peak(state.series.cpuMax), state.reservation?.cpuCores ?? 0);
+ return [0, top * 1.12];
+ },
+ },
+ },
+ axes: [xAxis(stateRef, theme, false), yAxis(theme, formatCoresAxis)],
+ series: [
+ {},
+ { stroke: 'transparent', points: { show: false } },
+ { stroke: theme.cpu, width: 1.5, fill: withAlpha(theme.cpu, 0.15), points: { show: false } },
+ ],
+ bands: [{ series: [1, 2], fill: withAlpha(theme.cpu, 0.25) }],
+ };
+ case 'memory':
+ return {
+ ...base,
+ hooks: {
+ ...hooks,
+ draw: [
+ (u) => {
+ const state = stateRef.current;
+ const bytes = state.reservation?.memoryBytes ?? null;
+ drawReservation(u, bytes, theme, bytes == null ? '' : `reserved ${formatBytes(bytes)}`);
+ drawOomMarkers(u, state, theme);
+ },
+ ],
+ },
+ scales: {
+ ...base.scales,
+ y: {
+ range: () => {
+ const state = stateRef.current;
+ const top = Math.max(
+ 256 * 1024 ** 2,
+ peak(state.series.memoryMax),
+ state.reservation?.memoryBytes ?? 0
+ );
+ return [0, top * 1.1];
+ },
+ },
+ },
+ axes: [xAxis(stateRef, theme, false), yAxis(theme, (value) => formatBytes(value).replace(' ', ''))],
+ series: [
+ {},
+ { stroke: theme.memory, width: 1, dash: [3, 3], points: { show: false } },
+ { stroke: 'transparent', points: { show: false } },
+ { stroke: theme.memory, width: 1.5, fill: withAlpha(theme.memory, 0.18), points: { show: false } },
+ ],
+ bands: [{ series: [2, 3], fill: withAlpha(theme.memory, 0.3) }],
+ };
+ case 'disk':
+ return {
+ ...base,
+ hooks,
+ scales: {
+ ...base.scales,
+ y: {
+ range: () => {
+ const { series } = stateRef.current;
+ const write = Math.max(1024 ** 2, peak(series.ioWriteRate));
+ const read = Math.max(1024 ** 2, peak(series.ioReadRate));
+ return [-read * 1.1, write * 1.1];
+ },
+ },
+ },
+ axes: [
+ xAxis(stateRef, theme, false),
+ yAxis(theme, (value) => (value === 0 ? '0' : formatRate(Math.abs(value)).replace(' ', ''))),
+ ],
+ series: [
+ {},
+ { stroke: theme.ioWrite, width: 1.25, fill: withAlpha(theme.ioWrite, 0.2), points: { show: false } },
+ { stroke: theme.ioRead, width: 1.25, fill: withAlpha(theme.ioRead, 0.2), points: { show: false } },
+ ],
+ };
+ case 'tools':
+ return {
+ ...base,
+ hooks: { ...hooks, draw: [(u) => drawToolLane(u, stateRef.current, theme)] },
+ scales: { ...base.scales, y: { range: () => [0, 1] } },
+ axes: [xAxis(stateRef, theme, true), { scale: 'y', size: PLOT_LEFT_GUTTER_PX, show: true, values: () => [], grid: { show: false }, ticks: { show: false } }],
+ series: [{}],
+ };
+ }
+}
+
+/** Column-major data for a panel, in the series order `panelOptions` declares. */
+export function panelData(kind: PanelKind, state: PanelState): uPlot.AlignedData {
+ const { series, hasWorkingSet } = state;
+ switch (kind) {
+ case 'cpu':
+ return [series.x, series.cpuMax, series.cpuMean];
+ case 'memory': {
+ // Working set is what the session needed; without it, memory.current stands in.
+ const primaryMean = hasWorkingSet ? series.workingSetMean : series.memoryMean;
+ const primaryMax = hasWorkingSet ? series.workingSetMax : series.memoryMax;
+ const withCache = hasWorkingSet ? series.memoryMean : series.x.map(() => null);
+ return [series.x, withCache, primaryMax, primaryMean];
+ }
+ case 'disk':
+ return [series.x, series.ioWriteRate, series.ioReadRate.map((value) => (value == null ? null : -value))];
+ case 'tools':
+ return [series.x];
+ }
+}
diff --git a/apps/web/src/components/chat/resource-timeline/readout.ts b/apps/web/src/components/chat/resource-timeline/readout.ts
new file mode 100644
index 0000000000..f18603588e
--- /dev/null
+++ b/apps/web/src/components/chat/resource-timeline/readout.ts
@@ -0,0 +1,151 @@
+/**
+ * What the readout says for a cursor position or, without a cursor, for the
+ * visible range. Every value is labelled with what it is: a single 5-second
+ * measurement, or an average/peak over a wider window.
+ */
+
+import {
+ formatBytes,
+ formatClock,
+ formatCores,
+ formatElapsed,
+ formatRange,
+ formatRate,
+} from './format';
+import { summarizeUsage, type TimelineSeries } from './series';
+import { sleepAt, type TimeAxis, toReal } from './time-axis';
+import type { ResourceAggregate, ResourceRun, ResourceToolSpan } from './types';
+
+export interface Readout {
+ /** Whether this describes one instant under the cursor or the whole visible range. */
+ mode: 'cursor' | 'range';
+ time: string;
+ context: string;
+ cpu: string;
+ memory: string;
+ disk: string;
+ tools: string;
+ /** Out-of-memory kills in scope, called out separately because they are the one causal event. */
+ oomKills: number;
+}
+
+const DASH = '—';
+
+/** Index of the bucket whose centre is nearest to `x`. */
+export function nearestBucket(series: TimelineSeries, x: number): number {
+ let lo = 0;
+ let hi = series.x.length - 1;
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1;
+ if ((series.x[mid] ?? 0) < x) lo = mid + 1;
+ else hi = mid;
+ }
+ const previous = lo - 1;
+ if (previous >= 0 && Math.abs((series.x[previous] ?? 0) - x) <= Math.abs((series.x[lo] ?? 0) - x)) {
+ return previous;
+ }
+ return lo;
+}
+
+function runLabel(runs: readonly ResourceRun[], t: number): string {
+ const index = runs.findIndex((run) => t >= run.startedAt && t <= run.endedAt);
+ const run = runs[index];
+ if (!run) return 'No workspace running';
+ const node = run.nodeId ? ` · ${run.nodeId}` : '';
+ const problem = run.unsupportedReason ? ' · not observed' : '';
+ return runs.length > 1 ? `Run ${index + 1} of ${runs.length}${node}${problem}` : `Workspace${node}${problem}`;
+}
+
+function memoryText(used: number | null, total: number | null): string {
+ if (used == null) return total == null ? DASH : `${formatBytes(total)} (incl. cache)`;
+ const cache = total == null ? null : Math.max(0, total - used);
+ return cache == null ? `${formatBytes(used)} used` : `${formatBytes(used)} used + ${formatBytes(cache)} cache`;
+}
+
+function activeToolsText(spans: readonly ResourceToolSpan[], from: number, to: number, instant: number): string | null {
+ const running = spans.filter((span) => span.startedAt <= instant && span.endedAt >= instant);
+ const inWindow = running.length ? running : spans.filter((span) => span.startedAt < to && span.endedAt > from);
+ if (inWindow.length === 0) return null;
+ const longest = [...inWindow].sort((a, b) => b.endedAt - b.startedAt - (a.endedAt - a.startedAt))[0];
+ if (!longest) return null;
+ const label = longest.name ?? 'tool call';
+ const more = inWindow.length > 1 ? ` +${inWindow.length - 1} more` : '';
+ const verb = running.length ? 'running' : 'ran';
+ return `${label} ${verb} ${formatElapsed(longest.endedAt - longest.startedAt)}${more}`;
+}
+
+export function readoutAtCursor(
+ x: number,
+ series: TimelineSeries,
+ axis: TimeAxis,
+ runs: readonly ResourceRun[],
+ toolSpans: readonly ResourceToolSpan[],
+ sampleIntervalMs: number
+): Readout {
+ const sleep = sleepAt(axis, x);
+ const t = toReal(axis, x);
+ if (sleep) {
+ return {
+ mode: 'cursor',
+ time: formatClock(t, 60_000),
+ context: `Session asleep for ${formatElapsed(sleep.realEnd - sleep.realStart)} — no workspace was running`,
+ cpu: DASH,
+ memory: DASH,
+ disk: DASH,
+ tools: DASH,
+ oomKills: 0,
+ };
+ }
+ const i = nearestBucket(series, x);
+ const measured = series.exact[i] === true && series.bucketMs <= sampleIntervalMs * 1.5;
+ const from = toReal(axis, (series.x[i] ?? x) - series.bucketMs / 2);
+ const to = toReal(axis, (series.x[i] ?? x) + series.bucketMs / 2);
+ const cpuMean = series.cpuMean[i] ?? null;
+ const cpuMax = series.cpuMax[i] ?? null;
+ const used = series.workingSetMean[i] ?? null;
+ const total = series.memoryMean[i] ?? null;
+ const write = series.ioWriteRate[i] ?? null;
+ const read = series.ioReadRate[i] ?? null;
+ const overview = Math.round(series.toolStarts[i] ?? 0);
+
+ return {
+ mode: 'cursor',
+ time: measured ? formatClock(t, 1_000) : `${formatClock(from, series.bucketMs)} · ${formatElapsed(to - from)} avg`,
+ context: runLabel(runs, t),
+ cpu: measured || cpuMax == null ? formatCores(cpuMean) : `${formatCores(cpuMean)} · peak ${formatCores(cpuMax)}`,
+ memory: memoryText(used, total),
+ disk: write == null && read == null ? DASH : `↑ ${formatRate(write)} write · ↓ ${formatRate(read)} read`,
+ tools:
+ activeToolsText(toolSpans, from, to, t) ??
+ (overview > 0 ? `~${overview} call${overview === 1 ? '' : 's'} in this window` : 'No tool calls'),
+ oomKills: series.oomKills[i] ?? 0,
+ };
+}
+
+export function readoutForRange(
+ viewMin: number,
+ viewMax: number,
+ axis: TimeAxis,
+ aggregates: readonly ResourceAggregate[]
+): Readout {
+ const from = toReal(axis, viewMin);
+ const to = toReal(axis, viewMax);
+ const usage = summarizeUsage(aggregates, from, to);
+ const whole = viewMin <= axis.min && viewMax >= axis.max;
+ const scope = whole ? 'Whole session' : formatRange(from, to, viewMax - viewMin < 30 * 60_000 ? 1_000 : 60_000);
+ return {
+ mode: 'range',
+ time: scope,
+ context: `${formatElapsed(usage.coveredMs)} of ${formatElapsed(axis.activeMs)} active time in view`,
+ cpu: usage.cpuMaxCores == null ? DASH : `avg ${formatCores(usage.cpuMeanCores)} · peak ${formatCores(usage.cpuMaxCores)}`,
+ memory:
+ usage.workingSetMaxBytes != null
+ ? `peak ${formatBytes(usage.workingSetMaxBytes)} used · ${formatBytes(usage.memoryMaxBytes)} incl. cache`
+ : usage.memoryMaxBytes == null
+ ? DASH
+ : `peak ${formatBytes(usage.memoryMaxBytes)} (incl. cache)`,
+ disk: `${formatBytes(usage.ioWriteBytes)} written · ${formatBytes(usage.ioReadBytes)} read`,
+ tools: `${usage.toolCallStarts} tool call${usage.toolCallStarts === 1 ? '' : 's'}`,
+ oomKills: usage.oomKills,
+ };
+}
diff --git a/apps/web/src/components/chat/resource-timeline/resource-source.ts b/apps/web/src/components/chat/resource-timeline/resource-source.ts
new file mode 100644
index 0000000000..1e0442a081
--- /dev/null
+++ b/apps/web/src/components/chat/resource-timeline/resource-source.ts
@@ -0,0 +1,228 @@
+/**
+ * Where timeline data comes from.
+ *
+ * The timeline asks for two things: an index that describes the whole session
+ * cheaply, and individual chunks at full resolution. Chunks are immutable once
+ * uploaded, so a chunk is fetched at most once per page lifetime and every later
+ * zoom or pan over it is served from the query cache.
+ */
+
+import {
+ getSessionResourceHistory,
+ type WorkspaceResourceChunk,
+ type WorkspaceResourceHistoryResponse,
+ type WorkspaceResourceSample,
+ type WorkspaceResourceToolSpan,
+} from '../../../lib/api';
+import type {
+ HistoryCompleteness,
+ ResourceAggregate,
+ ResourceChunkDetail,
+ ResourceChunkRef,
+ ResourceRun,
+ ResourceTimelineIndex,
+ ResourceToolSpan,
+ ToolKind,
+} from './types';
+
+export interface ResourceHistorySource {
+ /** Separates cache entries between sources; must change whenever the source's data does. */
+ readonly cacheKey: readonly unknown[];
+ loadIndex(): Promise;
+ loadChunk(chunkId: string): Promise;
+}
+
+const DEFAULT_SAMPLE_INTERVAL_MS = 5_000;
+const DEFAULT_UPLOAD_INTERVAL_MS = 15 * 60_000;
+
+const TOOL_KINDS: ReadonlySet = new Set([
+ 'execute',
+ 'edit',
+ 'read',
+ 'search',
+ 'fetch',
+ 'think',
+]);
+
+function toolKind(raw: string | undefined): ToolKind {
+ return raw && TOOL_KINDS.has(raw) ? (raw as ToolKind) : 'other';
+}
+
+function numberField(record: unknown, key: string): number | null {
+ if (typeof record !== 'object' || record === null) return null;
+ const value = (record as Record)[key];
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
+}
+
+function stringField(record: unknown, key: string): string | null {
+ if (typeof record !== 'object' || record === null) return null;
+ const value = (record as Record)[key];
+ return typeof value === 'string' && value.trim() ? value : null;
+}
+
+/** A raw cgroup sample as an aggregate over its own sampling interval. */
+export function sampleToAggregate(sample: WorkspaceResourceSample): ResourceAggregate {
+ const intervalMs = sample.intervalMillis ?? 0;
+ const measured = intervalMs > 0 && !sample.unsupported;
+ const cores = measured && sample.cpuMillis != null ? sample.cpuMillis / intervalMs : null;
+ const memory = sample.unsupported ? null : (sample.memoryBytes ?? null);
+ const workingSet = numberField(sample, 'workingSetBytes');
+ return {
+ start: sample.t - (intervalMs || DEFAULT_SAMPLE_INTERVAL_MS),
+ end: sample.t,
+ cpuMeanCores: cores,
+ cpuMaxCores: cores,
+ memoryMeanBytes: memory,
+ memoryMaxBytes: memory,
+ workingSetMeanBytes: workingSet,
+ workingSetMaxBytes: workingSet,
+ ioReadBytes: measured ? (sample.ioReadBytes ?? 0) : null,
+ ioWriteBytes: measured ? (sample.ioWriteBytes ?? 0) : null,
+ oomKills: (sample.oom ?? 0) + (sample.oomKill ?? 0),
+ toolCallStarts: 0,
+ exact: true,
+ };
+}
+
+/**
+ * Counts each tool call against the sample whose interval saw it start, so raw
+ * samples and overview aggregates report tool activity the same way. Calls that
+ * started before the chunk's first sample are counted on that first sample.
+ */
+export function withToolStarts(
+ samples: readonly ResourceAggregate[],
+ spans: readonly ResourceToolSpan[]
+): ResourceAggregate[] {
+ const counted = samples.map((sample) => ({ ...sample, toolCallStarts: 0 }));
+ for (const span of spans) {
+ const target =
+ counted.find((sample) => span.startedAt >= sample.start && span.startedAt < sample.end) ??
+ (span.startedAt < (counted[0]?.start ?? -Infinity) ? counted[0] : undefined);
+ if (target) target.toolCallStarts += 1;
+ }
+ return counted;
+}
+
+function toolSpanFromApi(span: WorkspaceResourceToolSpan): ResourceToolSpan {
+ return {
+ id: span.id,
+ kind: toolKind(stringField(span, 'toolKind') ?? span.kind),
+ name: stringField(span, 'toolName'),
+ startedAt: span.startedAt,
+ endedAt: span.endedAt ?? span.startedAt,
+ approximateEnd: Boolean(span.approximate),
+ };
+}
+
+/** The per-chunk summary the current API stores, as one aggregate spanning the chunk. */
+function chunkOverview(chunk: WorkspaceResourceChunk): ResourceAggregate {
+ const summary = chunk.summary;
+ const intervalMs = numberField(summary, 'sampleIntervalMillis') ?? DEFAULT_SAMPLE_INTERVAL_MS;
+ const cpuMean = numberField(summary, 'cpuMeanMillis');
+ const cpuPeak = numberField(summary, 'cpuPeakMillis');
+ return {
+ start: chunk.startedAt,
+ end: chunk.endedAt,
+ cpuMeanCores: cpuMean == null ? null : cpuMean / intervalMs,
+ cpuMaxCores: cpuPeak == null ? null : cpuPeak / intervalMs,
+ memoryMeanBytes: numberField(summary, 'memoryMeanBytes'),
+ memoryMaxBytes: numberField(summary, 'memoryPeakBytes'),
+ workingSetMeanBytes: numberField(summary, 'workingSetMeanBytes'),
+ workingSetMaxBytes: numberField(summary, 'workingSetPeakBytes'),
+ ioReadBytes: numberField(summary, 'ioReadBytes'),
+ ioWriteBytes: numberField(summary, 'ioWriteBytes'),
+ oomKills: numberField(summary, 'oomCount') ?? 0,
+ toolCallStarts: chunk.toolSpanCount,
+ exact: false,
+ };
+}
+
+function runsFromChunks(chunks: readonly WorkspaceResourceChunk[]): ResourceRun[] {
+ const runs = new Map();
+ for (const chunk of chunks) {
+ const unsupported = stringField(chunk.completeness, 'unsupported');
+ const existing = runs.get(chunk.workspaceId);
+ if (existing) {
+ existing.startedAt = Math.min(existing.startedAt, chunk.startedAt);
+ existing.endedAt = Math.max(existing.endedAt, chunk.endedAt);
+ existing.unsupportedReason ??= unsupported;
+ } else {
+ runs.set(chunk.workspaceId, {
+ id: chunk.workspaceId,
+ nodeId: chunk.nodeId,
+ startedAt: chunk.startedAt,
+ endedAt: chunk.endedAt,
+ unsupportedReason: unsupported,
+ });
+ }
+ }
+ return [...runs.values()].sort((a, b) => a.startedAt - b.startedAt);
+}
+
+/**
+ * The current list endpoint returns only the newest chunks, without a total. The
+ * one certain signal is the latest workspace's own first chunk missing from the
+ * page: then older history exists and was not returned.
+ */
+function completenessOf(response: WorkspaceResourceHistoryResponse): HistoryCompleteness {
+ const firstChunkId = response.summary?.firstChunkId;
+ if (firstChunkId && !response.chunks.some((chunk) => chunk.id === firstChunkId)) {
+ return { kind: 'truncated', omitted: null };
+ }
+ return { kind: 'complete' };
+}
+
+export function indexFromApi(response: WorkspaceResourceHistoryResponse): ResourceTimelineIndex {
+ const chunks: ResourceChunkRef[] = [...response.chunks]
+ .sort((a, b) => a.startedAt - b.startedAt)
+ .map((chunk) => ({
+ id: chunk.id,
+ runId: chunk.workspaceId,
+ startedAt: chunk.startedAt,
+ endedAt: chunk.endedAt,
+ sampleCount: chunk.sampleCount,
+ overview: [chunkOverview(chunk)],
+ memoryHighWaterBytes: numberField(chunk.summary, 'memoryKernelPeakBytes'),
+ }));
+ return {
+ runs: runsFromChunks(response.chunks),
+ chunks,
+ sampleIntervalMs:
+ numberField(response.summary?.summary, 'sampleIntervalMillis') ?? DEFAULT_SAMPLE_INTERVAL_MS,
+ uploadIntervalMs: DEFAULT_UPLOAD_INTERVAL_MS,
+ completeness: completenessOf(response),
+ reservation: null,
+ };
+}
+
+export function chunkDetailFromApi(
+ chunkId: string,
+ detail: NonNullable
+): ResourceChunkDetail {
+ const toolSpans = detail.toolSpans.map(toolSpanFromApi);
+ return {
+ chunkId,
+ samples: withToolStarts(detail.samples.map(sampleToAggregate), toolSpans),
+ toolSpans,
+ samplerGaps: detail.gaps
+ .map((gap) => ({ start: numberField(gap, 'startedAt'), end: numberField(gap, 'endedAt') }))
+ .filter((gap): gap is { start: number; end: number } => gap.start != null && gap.end != null),
+ };
+}
+
+/** Reads a session's history through today's `/resource-history` endpoint. */
+export function apiResourceHistorySource(projectId: string, sessionId: string): ResourceHistorySource {
+ return {
+ cacheKey: ['session-resource-history', projectId, sessionId],
+ async loadIndex() {
+ return indexFromApi(await getSessionResourceHistory(projectId, sessionId));
+ },
+ async loadChunk(chunkId) {
+ const response = await getSessionResourceHistory(projectId, sessionId, { chunkId });
+ if (!response.detail || response.detail.chunkId !== chunkId) {
+ throw new Error(`Resource history chunk ${chunkId} was not returned`);
+ }
+ return chunkDetailFromApi(chunkId, response.detail);
+ },
+ };
+}
diff --git a/apps/web/src/components/chat/resource-timeline/series.ts b/apps/web/src/components/chat/resource-timeline/series.ts
new file mode 100644
index 0000000000..cf6cd67a73
--- /dev/null
+++ b/apps/web/src/components/chat/resource-timeline/series.ts
@@ -0,0 +1,275 @@
+/**
+ * Level-of-detail series building.
+ *
+ * The chart never receives more points than it has pixels. Whatever data is
+ * available for the visible window — raw 5-second samples for downloaded chunks,
+ * coarse overview aggregates for the rest — is folded into one bucket per pixel
+ * column, keeping both the time-weighted mean and the maximum so a short spike
+ * survives any zoom level.
+ */
+
+import { type TimeAxis, toAxis, toReal } from './time-axis';
+import type { ResourceAggregate } from './types';
+
+export interface TimelineSeries {
+ /** Bucket centres, in axis units. */
+ x: number[];
+ /** Bucket centres, wall clock. */
+ t: number[];
+ /** Axis milliseconds per bucket. */
+ bucketMs: number;
+ cpuMean: Array;
+ cpuMax: Array;
+ memoryMean: Array;
+ memoryMax: Array;
+ workingSetMean: Array;
+ workingSetMax: Array;
+ /** Bytes per second, averaged over the part of the bucket that has data. */
+ ioReadRate: Array;
+ ioWriteRate: Array;
+ /** Tool calls started in the bucket, as far as the overview knows (raw samples carry none). */
+ toolStarts: number[];
+ /** OOM kills recorded in the bucket. */
+ oomKills: number[];
+ /** True when every contributing aggregate was a raw sample. */
+ exact: boolean[];
+}
+
+interface Accumulator {
+ covered: number;
+ exact: boolean;
+ cpuWeighted: number;
+ cpuCovered: number;
+ cpuMax: number | null;
+ memoryWeighted: number;
+ memoryCovered: number;
+ memoryMax: number | null;
+ workingSetWeighted: number;
+ workingSetCovered: number;
+ workingSetMax: number | null;
+ ioRead: number;
+ ioWrite: number;
+ ioCovered: number;
+ toolStarts: number;
+ oomKills: number;
+}
+
+function emptyAccumulator(): Accumulator {
+ return {
+ covered: 0,
+ exact: true,
+ cpuWeighted: 0,
+ cpuCovered: 0,
+ cpuMax: null,
+ memoryWeighted: 0,
+ memoryCovered: 0,
+ memoryMax: null,
+ workingSetWeighted: 0,
+ workingSetCovered: 0,
+ workingSetMax: null,
+ ioRead: 0,
+ ioWrite: 0,
+ ioCovered: 0,
+ toolStarts: 0,
+ oomKills: 0,
+ };
+}
+
+function maxOf(current: number | null, next: number | null): number | null {
+ if (next == null) return current;
+ return current == null ? next : Math.max(current, next);
+}
+
+/** Folds the part of `aggregate` overlapping the bucket (`overlap` ms of `duration`) into `acc`. */
+function accumulate(acc: Accumulator, aggregate: ResourceAggregate, overlap: number, duration: number) {
+ acc.covered += overlap;
+ acc.exact &&= aggregate.exact;
+ if (aggregate.cpuMeanCores != null) {
+ acc.cpuWeighted += aggregate.cpuMeanCores * overlap;
+ acc.cpuCovered += overlap;
+ }
+ acc.cpuMax = maxOf(acc.cpuMax, aggregate.cpuMaxCores);
+ if (aggregate.memoryMeanBytes != null) {
+ acc.memoryWeighted += aggregate.memoryMeanBytes * overlap;
+ acc.memoryCovered += overlap;
+ }
+ acc.memoryMax = maxOf(acc.memoryMax, aggregate.memoryMaxBytes);
+ if (aggregate.workingSetMeanBytes != null) {
+ acc.workingSetWeighted += aggregate.workingSetMeanBytes * overlap;
+ acc.workingSetCovered += overlap;
+ }
+ acc.workingSetMax = maxOf(acc.workingSetMax, aggregate.workingSetMaxBytes);
+ const share = duration > 0 ? overlap / duration : 1;
+ if (aggregate.ioReadBytes != null && aggregate.ioWriteBytes != null) {
+ acc.ioRead += aggregate.ioReadBytes * share;
+ acc.ioWrite += aggregate.ioWriteBytes * share;
+ acc.ioCovered += overlap;
+ }
+ acc.toolStarts += aggregate.toolCallStarts * share;
+}
+
+/** An OOM kill is an event, not a rate: it belongs wholly to the window holding the aggregate's end. */
+function endsWithin(end: number, from: number, to: number): boolean {
+ return end > from && end <= to;
+}
+
+function weightedMean(weighted: number, covered: number): number | null {
+ return covered > 0 ? weighted / covered : null;
+}
+
+/**
+ * Builds at most `maxBuckets` points for `[viewMin, viewMax]` (axis units).
+ *
+ * Buckets never get finer than `minBucketMs` — below the sampling interval a
+ * bucket would only repeat its neighbour. `aggregates` must be sorted by start
+ * and must not overlap (chunks never do; callers pass either a chunk's raw
+ * samples or its overview, never both).
+ */
+export function buildSeries(
+ aggregates: readonly ResourceAggregate[],
+ axis: TimeAxis,
+ viewMin: number,
+ viewMax: number,
+ maxBuckets: number,
+ minBucketMs: number
+): TimelineSeries {
+ const span = Math.max(1, viewMax - viewMin);
+ const bucketMs = Math.max(minBucketMs, span / Math.max(1, maxBuckets));
+ const count = Math.max(1, Math.ceil(span / bucketMs));
+ const series: TimelineSeries = {
+ x: [],
+ t: [],
+ bucketMs,
+ cpuMean: [],
+ cpuMax: [],
+ memoryMean: [],
+ memoryMax: [],
+ workingSetMean: [],
+ workingSetMax: [],
+ ioReadRate: [],
+ ioWriteRate: [],
+ toolStarts: [],
+ oomKills: [],
+ exact: [],
+ };
+
+ const placed = aggregates.map((aggregate) => ({
+ aggregate,
+ from: toAxis(axis, aggregate.start),
+ to: toAxis(axis, aggregate.end),
+ }));
+ let first = 0;
+
+ for (let index = 0; index < count; index += 1) {
+ const bucketStart = viewMin + index * bucketMs;
+ const bucketEnd = bucketStart + bucketMs;
+ const acc = emptyAccumulator();
+
+ while (first < placed.length && (placed[first]?.to ?? Infinity) <= bucketStart) first += 1;
+ for (let cursor = first; cursor < placed.length; cursor += 1) {
+ const item = placed[cursor];
+ if (!item || item.from >= bucketEnd) break;
+ const overlap = Math.min(item.to, bucketEnd) - Math.max(item.from, bucketStart);
+ if (overlap > 0) accumulate(acc, item.aggregate, overlap, item.to - item.from);
+ if (endsWithin(item.to, bucketStart, bucketEnd)) acc.oomKills += item.aggregate.oomKills;
+ }
+
+ const centre = bucketStart + bucketMs / 2;
+ series.x.push(centre);
+ series.t.push(toReal(axis, centre));
+ const hasData = acc.covered > 0;
+ series.exact.push(hasData && acc.exact);
+ series.cpuMean.push(hasData ? weightedMean(acc.cpuWeighted, acc.cpuCovered) : null);
+ series.cpuMax.push(hasData ? acc.cpuMax : null);
+ series.memoryMean.push(hasData ? weightedMean(acc.memoryWeighted, acc.memoryCovered) : null);
+ series.memoryMax.push(hasData ? acc.memoryMax : null);
+ series.workingSetMean.push(
+ hasData ? weightedMean(acc.workingSetWeighted, acc.workingSetCovered) : null
+ );
+ series.workingSetMax.push(hasData ? acc.workingSetMax : null);
+ const seconds = acc.ioCovered / 1000;
+ series.ioReadRate.push(seconds > 0 ? acc.ioRead / seconds : null);
+ series.ioWriteRate.push(seconds > 0 ? acc.ioWrite / seconds : null);
+ series.toolStarts.push(acc.toolStarts);
+ series.oomKills.push(acc.oomKills);
+ }
+
+ return series;
+}
+
+export interface UsageSummary {
+ /** Real milliseconds with data inside the range. */
+ coveredMs: number;
+ cpuMeanCores: number | null;
+ cpuMaxCores: number | null;
+ memoryMaxBytes: number | null;
+ workingSetMaxBytes: number | null;
+ ioReadBytes: number;
+ ioWriteBytes: number;
+ oomKills: number;
+ toolCallStarts: number;
+ /** Every contributing aggregate was a raw sample. */
+ exact: boolean;
+}
+
+/** Totals and peaks over the part of each aggregate inside `[from, to]` (wall clock). */
+export function summarizeUsage(
+ aggregates: readonly ResourceAggregate[],
+ from: number,
+ to: number
+): UsageSummary {
+ const acc = emptyAccumulator();
+ for (const aggregate of aggregates) {
+ const overlap = Math.min(aggregate.end, to) - Math.max(aggregate.start, from);
+ if (overlap > 0) accumulate(acc, aggregate, overlap, aggregate.end - aggregate.start);
+ if (endsWithin(aggregate.end, from, to)) acc.oomKills += aggregate.oomKills;
+ }
+ return {
+ coveredMs: acc.covered,
+ cpuMeanCores: weightedMean(acc.cpuWeighted, acc.cpuCovered),
+ cpuMaxCores: acc.cpuMax,
+ memoryMaxBytes: acc.memoryMax,
+ workingSetMaxBytes: acc.workingSetMax,
+ ioReadBytes: acc.ioRead,
+ ioWriteBytes: acc.ioWrite,
+ oomKills: acc.oomKills,
+ toolCallStarts: Math.round(acc.toolStarts),
+ exact: acc.covered > 0 && acc.exact,
+ };
+}
+
+export type PeakMetric = 'cpu' | 'memory';
+
+export interface UsagePeak {
+ metric: PeakMetric;
+ /** Wall-clock centre of the aggregate that holds the peak. */
+ at: number;
+ value: number;
+}
+
+/**
+ * The `count` highest peaks for `metric`, at least `separationMs` apart so one
+ * long spike does not fill the list.
+ */
+export function findPeaks(
+ aggregates: readonly ResourceAggregate[],
+ metric: PeakMetric,
+ count: number,
+ separationMs: number
+): UsagePeak[] {
+ const candidates = aggregates
+ .map((aggregate) => ({
+ at: (aggregate.start + aggregate.end) / 2,
+ value: metric === 'cpu' ? aggregate.cpuMaxCores : aggregate.memoryMaxBytes,
+ }))
+ .filter((candidate): candidate is { at: number; value: number } => candidate.value != null)
+ .sort((a, b) => b.value - a.value || a.at - b.at);
+ const peaks: UsagePeak[] = [];
+ for (const candidate of candidates) {
+ if (peaks.length >= count) break;
+ if (peaks.every((peak) => Math.abs(peak.at - candidate.at) >= separationMs)) {
+ peaks.push({ metric, ...candidate });
+ }
+ }
+ return peaks;
+}
diff --git a/apps/web/src/components/chat/resource-timeline/time-axis.ts b/apps/web/src/components/chat/resource-timeline/time-axis.ts
new file mode 100644
index 0000000000..de2cf94652
--- /dev/null
+++ b/apps/web/src/components/chat/resource-timeline/time-axis.ts
@@ -0,0 +1,174 @@
+/**
+ * The timeline's x axis.
+ *
+ * A session is a sequence of runs separated by sleeps. Drawn on a plain clock axis,
+ * a session that worked for an hour, slept overnight and worked another hour is two
+ * slivers either side of a void. In `active` mode each sleep longer than the break
+ * width is compressed to that width and drawn as an explicit, labelled break, so
+ * the time that has data gets the pixels. `clock` mode keeps real proportions.
+ *
+ * Positions on the axis are "axis milliseconds": inside a run one axis millisecond
+ * is one real millisecond, so durations read off the axis stay true.
+ */
+
+import type { ResourceRun } from './types';
+
+export type TimeAxisMode = 'active' | 'clock';
+
+export interface AxisSpan {
+ kind: 'active' | 'sleep';
+ realStart: number;
+ realEnd: number;
+ axisStart: number;
+ axisEnd: number;
+}
+
+export interface TimeAxis {
+ mode: TimeAxisMode;
+ /** Contiguous and ascending; together they cover `[min, max]`. */
+ spans: AxisSpan[];
+ min: number;
+ max: number;
+ /** Real milliseconds covered by runs (the session's active time). */
+ activeMs: number;
+}
+
+/** A compressed sleep takes this fraction of the session's active time… */
+const SLEEP_BREAK_FRACTION = 0.025;
+/** …bounded so it stays visible on short sessions and modest on long ones. */
+const SLEEP_BREAK_MIN_MS = 15_000;
+const SLEEP_BREAK_MAX_MS = 10 * 60_000;
+
+/** Merges overlapping or touching runs into disjoint active intervals. */
+function activeIntervals(runs: readonly ResourceRun[]): Array<[number, number]> {
+ const sorted = [...runs].sort((a, b) => a.startedAt - b.startedAt);
+ const merged: Array<[number, number]> = [];
+ for (const run of sorted) {
+ const last = merged.at(-1);
+ if (last && run.startedAt <= last[1]) {
+ last[1] = Math.max(last[1], run.endedAt);
+ } else {
+ merged.push([run.startedAt, Math.max(run.startedAt, run.endedAt)]);
+ }
+ }
+ return merged;
+}
+
+export function sleepBreakWidthMs(activeMs: number): number {
+ return Math.min(SLEEP_BREAK_MAX_MS, Math.max(SLEEP_BREAK_MIN_MS, activeMs * SLEEP_BREAK_FRACTION));
+}
+
+export function buildTimeAxis(runs: readonly ResourceRun[], mode: TimeAxisMode): TimeAxis {
+ const intervals = activeIntervals(runs);
+ const activeMs = intervals.reduce((sum, [start, end]) => sum + (end - start), 0);
+ const breakWidth = sleepBreakWidthMs(activeMs);
+ const spans: AxisSpan[] = [];
+ let cursor = intervals[0]?.[0] ?? 0;
+
+ for (const [start, end] of intervals) {
+ const previous = spans.at(-1);
+ if (previous) {
+ const sleepMs = start - previous.realEnd;
+ const width = mode === 'active' ? Math.min(sleepMs, breakWidth) : sleepMs;
+ spans.push({
+ kind: 'sleep',
+ realStart: previous.realEnd,
+ realEnd: start,
+ axisStart: cursor,
+ axisEnd: cursor + width,
+ });
+ cursor += width;
+ }
+ spans.push({ kind: 'active', realStart: start, realEnd: end, axisStart: cursor, axisEnd: cursor + (end - start) });
+ cursor += end - start;
+ }
+
+ return { mode, spans, min: spans[0]?.axisStart ?? 0, max: cursor, activeMs };
+}
+
+/** Index of the span containing `value`, searched by `startKey`; clamps to the ends. */
+function spanIndex(axis: TimeAxis, value: number, startKey: 'realStart' | 'axisStart'): number {
+ let lo = 0;
+ let hi = axis.spans.length - 1;
+ while (lo < hi) {
+ const mid = Math.ceil((lo + hi) / 2);
+ const span = axis.spans[mid];
+ if (span && span[startKey] <= value) lo = mid;
+ else hi = mid - 1;
+ }
+ return lo;
+}
+
+function scaleOf(span: AxisSpan): number {
+ const real = span.realEnd - span.realStart;
+ return real > 0 ? (span.axisEnd - span.axisStart) / real : 1;
+}
+
+/** Real (wall-clock) time → axis position. Outside the session the axis runs at clock speed. */
+export function toAxis(axis: TimeAxis, t: number): number {
+ const span = axis.spans[spanIndex(axis, t, 'realStart')];
+ if (!span) return t;
+ if (t <= span.realStart) return span.axisStart - (span.realStart - t);
+ if (t >= span.realEnd) return span.axisEnd + (t - span.realEnd);
+ return span.axisStart + (t - span.realStart) * scaleOf(span);
+}
+
+/** Axis position → real (wall-clock) time. */
+export function toReal(axis: TimeAxis, x: number): number {
+ const span = axis.spans[spanIndex(axis, x, 'axisStart')];
+ if (!span) return x;
+ if (x <= span.axisStart) return span.realStart - (span.axisStart - x);
+ if (x >= span.axisEnd) return span.realEnd + (x - span.axisEnd);
+ const scale = scaleOf(span);
+ return span.realStart + (scale > 0 ? (x - span.axisStart) / scale : 0);
+}
+
+/** The sleep containing the axis position, if the position falls inside one. */
+export function sleepAt(axis: TimeAxis, x: number): AxisSpan | null {
+ const span = axis.spans[spanIndex(axis, x, 'axisStart')];
+ return span?.kind === 'sleep' && x >= span.axisStart && x < span.axisEnd ? span : null;
+}
+
+/** Axis spans (compressed breaks included) that are sleeps. */
+export function sleeps(axis: TimeAxis): AxisSpan[] {
+ return axis.spans.filter((span) => span.kind === 'sleep');
+}
+
+const TICK_STEPS_MS = [
+ 1_000, 5_000, 15_000, 30_000, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 3_600_000,
+ 3 * 3_600_000, 6 * 3_600_000, 12 * 3_600_000, 86_400_000,
+];
+
+/** The finest tick step that keeps labels at least `minSpacingPx` apart. */
+export function tickStepMs(msPerPx: number, minSpacingPx: number): number {
+ const wanted = msPerPx * minSpacingPx;
+ return TICK_STEPS_MS.find((step) => step >= wanted) ?? TICK_STEPS_MS.at(-1) ?? wanted;
+}
+
+/**
+ * Tick positions (axis units) for the visible window, aligned to round wall-clock
+ * times in the viewer's zone. Compressed sleeps get no ticks: their scale differs
+ * from the runs around them, so a tick inside one would lie about the spacing.
+ */
+export function axisTicks(
+ axis: TimeAxis,
+ viewMin: number,
+ viewMax: number,
+ stepMs: number
+): Array<{ x: number; t: number }> {
+ const zoneOffsetMs = new Date(toReal(axis, viewMin)).getTimezoneOffset() * 60_000;
+ const ticks: Array<{ x: number; t: number }> = [];
+ for (const span of axis.spans) {
+ if (span.axisEnd < viewMin || span.axisStart > viewMax) continue;
+ if (span.kind === 'sleep' && scaleOf(span) !== 1) continue;
+ const from = Math.max(span.realStart, toReal(axis, viewMin));
+ const to = Math.min(span.realEnd, toReal(axis, viewMax));
+ const first = Math.ceil((from - zoneOffsetMs) / stepMs) * stepMs + zoneOffsetMs;
+ for (let t = first; t <= to; t += stepMs) {
+ const x = toAxis(axis, t);
+ const last = ticks.at(-1);
+ if (!last || x > last.x) ticks.push({ x, t });
+ }
+ }
+ return ticks;
+}
diff --git a/apps/web/src/components/chat/resource-timeline/timeline-view.ts b/apps/web/src/components/chat/resource-timeline/timeline-view.ts
new file mode 100644
index 0000000000..97e1e39714
--- /dev/null
+++ b/apps/web/src/components/chat/resource-timeline/timeline-view.ts
@@ -0,0 +1,96 @@
+import { useCallback, useMemo, useState } from 'react';
+
+import { type TimeAxis, toAxis, toReal } from './time-axis';
+
+/**
+ * Which part of the session is on screen.
+ *
+ * `all` and `latest` are kept as intents rather than frozen ranges, so while the
+ * session keeps uploading data "All" still shows everything and "Latest" keeps
+ * following the newest samples. A chosen range is stored in wall-clock time, so
+ * switching between active-time and clock-time axes keeps the same moment on screen.
+ */
+export type ViewIntent =
+ | { kind: 'all' }
+ | { kind: 'latest'; spanMs: number }
+ | { kind: 'range'; from: number; to: number };
+
+/** Visible window in axis units. */
+export interface ViewRange {
+ min: number;
+ max: number;
+}
+
+/** Narrowest window: a dozen 5-second samples. */
+export const MIN_VIEW_SPAN_MS = 60_000;
+
+function fullSpan(axis: TimeAxis): number {
+ return Math.max(MIN_VIEW_SPAN_MS, axis.max - axis.min);
+}
+
+/** Keeps a window inside the session without changing its width (unless it is too wide). */
+export function clampRange(min: number, max: number, axis: TimeAxis): ViewRange {
+ const full = fullSpan(axis);
+ const span = Math.min(full, Math.max(MIN_VIEW_SPAN_MS, max - min));
+ const start = Math.min(Math.max(min, axis.min), axis.min + full - span);
+ return { min: start, max: start + span };
+}
+
+export function resolveView(intent: ViewIntent, axis: TimeAxis): ViewRange {
+ const full = fullSpan(axis);
+ switch (intent.kind) {
+ case 'all':
+ return { min: axis.min, max: axis.min + full };
+ case 'latest': {
+ const span = Math.min(full, Math.max(MIN_VIEW_SPAN_MS, intent.spanMs));
+ return { min: axis.min + full - span, max: axis.min + full };
+ }
+ case 'range':
+ return clampRange(toAxis(axis, intent.from), toAxis(axis, intent.to), axis);
+ }
+}
+
+/** The intent a freshly chosen range should be stored as. */
+function intentFor(range: ViewRange, axis: TimeAxis): ViewIntent {
+ const full = fullSpan(axis);
+ const span = range.max - range.min;
+ const epsilon = full * 1e-6;
+ if (span >= full - epsilon) return { kind: 'all' };
+ if (range.max >= axis.min + full - epsilon) return { kind: 'latest', spanMs: span };
+ return { kind: 'range', from: toReal(axis, range.min), to: toReal(axis, range.max) };
+}
+
+export interface TimelineViewControls {
+ view: ViewRange;
+ intent: ViewIntent;
+ setRange: (min: number, max: number) => void;
+ /** Zooms by `factor` (<1 zooms in) keeping the axis position `anchor` fixed on screen. */
+ zoom: (factor: number, anchor: number) => void;
+ /** Shows `spanMs` of the session centred on `centre` (axis units). */
+ showSpan: (spanMs: number, centre: number) => void;
+ showLatest: (spanMs: number) => void;
+ showAll: () => void;
+}
+
+export function useTimelineView(axis: TimeAxis): TimelineViewControls {
+ const [intent, setIntent] = useState({ kind: 'all' });
+ const view = useMemo(() => resolveView(intent, axis), [intent, axis]);
+
+ const setRange = useCallback(
+ (min: number, max: number) => setIntent(intentFor(clampRange(min, max, axis), axis)),
+ [axis]
+ );
+ const zoom = useCallback(
+ (factor: number, anchor: number) =>
+ setRange(anchor - (anchor - view.min) * factor, anchor + (view.max - anchor) * factor),
+ [setRange, view]
+ );
+ const showSpan = useCallback(
+ (spanMs: number, centre: number) => setRange(centre - spanMs / 2, centre + spanMs / 2),
+ [setRange]
+ );
+ const showLatest = useCallback((spanMs: number) => setIntent({ kind: 'latest', spanMs }), []);
+ const showAll = useCallback(() => setIntent({ kind: 'all' }), []);
+
+ return { view, intent, setRange, zoom, showSpan, showLatest, showAll };
+}
diff --git a/apps/web/src/components/chat/resource-timeline/types.ts b/apps/web/src/components/chat/resource-timeline/types.ts
new file mode 100644
index 0000000000..54eaa4ff65
--- /dev/null
+++ b/apps/web/src/components/chat/resource-timeline/types.ts
@@ -0,0 +1,110 @@
+/**
+ * Domain model for the session resource timeline.
+ *
+ * Storage splits a session's history into immutable 15-minute chunks. That is a
+ * storage detail: nothing here exposes a chunk to the user. The UI sees one
+ * continuous timeline made of *runs* (one per workspace lifetime — every wake
+ * provisions a fresh workspace) separated by the gaps where the session slept.
+ */
+
+/** ACP tool-call kinds, plus `other` for anything an agent reports that we do not know. */
+export type ToolKind = 'execute' | 'edit' | 'read' | 'search' | 'fetch' | 'think' | 'other';
+
+/**
+ * Resource usage over a closed time window `[start, end)`.
+ *
+ * One shape serves every level of detail: a raw 5-second sample is an aggregate
+ * over its own sampling interval, and an overview bucket is an aggregate over a
+ * minute or a whole chunk. `exact` records which one it is so the UI can say
+ * whether a number is a measurement or an average.
+ */
+export interface ResourceAggregate {
+ start: number;
+ end: number;
+ /** CPU in cores (1 = one core fully busy). */
+ cpuMeanCores: number | null;
+ cpuMaxCores: number | null;
+ /** cgroup `memory.current`: everything the workspace holds, including reclaimable cache. */
+ memoryMeanBytes: number | null;
+ memoryMaxBytes: number | null;
+ /** Non-reclaimable working set; null when the VM agent does not report it. */
+ workingSetMeanBytes: number | null;
+ workingSetMaxBytes: number | null;
+ /** Bytes moved during the window (not rates). */
+ ioReadBytes: number | null;
+ ioWriteBytes: number | null;
+ oomKills: number;
+ /** Tool calls that started inside the window. */
+ toolCallStarts: number;
+ exact: boolean;
+}
+
+/** One workspace lifetime inside the session. */
+export interface ResourceRun {
+ /** The workspace id — stable, and what storage keys the run by. */
+ id: string;
+ nodeId: string | null;
+ startedAt: number;
+ endedAt: number;
+ /** Why the collector could not observe this run (for example the container never started). */
+ unsupportedReason: string | null;
+}
+
+/** A stored chunk, described well enough to draw an overview without downloading it. */
+export interface ResourceChunkRef {
+ id: string;
+ runId: string;
+ startedAt: number;
+ endedAt: number;
+ sampleCount: number;
+ /**
+ * Coarse usage for this chunk. When the backend stores per-minute rollups this
+ * holds one aggregate per minute; otherwise a single aggregate spans the chunk.
+ */
+ overview: ResourceAggregate[];
+ /** Kernel-tracked memory high-water mark (`memory.peak`) — catches spikes between samples. */
+ memoryHighWaterBytes: number | null;
+}
+
+export type HistoryCompleteness =
+ | { kind: 'complete' }
+ /** The backend returned only the newest chunks; `omitted` is null when it did not say how many. */
+ | { kind: 'truncated'; omitted: number | null };
+
+export interface ResourceReservation {
+ cpuCores: number | null;
+ memoryBytes: number | null;
+}
+
+/** Everything needed to draw the whole session before any chunk is downloaded. */
+export interface ResourceTimelineIndex {
+ runs: ResourceRun[];
+ /** Ascending by `startedAt`. */
+ chunks: ResourceChunkRef[];
+ sampleIntervalMs: number;
+ /** How often the VM uploads a chunk — data is never newer than one upload interval. */
+ uploadIntervalMs: number;
+ completeness: HistoryCompleteness;
+ /** What the workspace reserved, when known; usage is judged against this. */
+ reservation: ResourceReservation | null;
+}
+
+export interface ResourceToolSpan {
+ id: string;
+ kind: ToolKind;
+ /** e.g. `Bash`. Null for spans recorded by VM agents that predate tool labels. */
+ name: string | null;
+ startedAt: number;
+ endedAt: number;
+ /** The call never reported completion; the end is when the collector gave up on it. */
+ approximateEnd: boolean;
+}
+
+/** One downloaded chunk at full 5-second resolution. */
+export interface ResourceChunkDetail {
+ chunkId: string;
+ samples: ResourceAggregate[];
+ toolSpans: ResourceToolSpan[];
+ /** Instants where the sampler fell behind and no data exists. */
+ samplerGaps: Array<{ start: number; end: number }>;
+}
diff --git a/apps/web/src/components/chat/resource-timeline/useElementWidth.ts b/apps/web/src/components/chat/resource-timeline/useElementWidth.ts
new file mode 100644
index 0000000000..6c7a119b85
--- /dev/null
+++ b/apps/web/src/components/chat/resource-timeline/useElementWidth.ts
@@ -0,0 +1,15 @@
+import { type RefObject, useLayoutEffect, useState } from 'react';
+
+/** Content-box width of `ref`'s element in CSS pixels, kept current as it resizes. */
+export function useElementWidth(ref: RefObject): number {
+ const [width, setWidth] = useState(0);
+ useLayoutEffect(() => {
+ const element = ref.current;
+ if (!element) return;
+ setWidth(element.clientWidth);
+ const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry?.contentRect.width ?? 0)));
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [ref]);
+ return width;
+}
diff --git a/apps/web/src/components/chat/resource-timeline/useResourceTimelineData.ts b/apps/web/src/components/chat/resource-timeline/useResourceTimelineData.ts
new file mode 100644
index 0000000000..b9fc94e0ac
--- /dev/null
+++ b/apps/web/src/components/chat/resource-timeline/useResourceTimelineData.ts
@@ -0,0 +1,147 @@
+import { useQueries, useQuery } from '@tanstack/react-query';
+import { useMemo } from 'react';
+
+import type { ResourceHistorySource } from './resource-source';
+import { buildTimeAxis, type TimeAxis, type TimeAxisMode, toReal } from './time-axis';
+import type {
+ ResourceAggregate,
+ ResourceChunkDetail,
+ ResourceChunkRef,
+ ResourceTimelineIndex,
+ ResourceToolSpan,
+} from './types';
+
+/** The index grows while the session runs (one chunk per upload), so it goes stale. */
+const INDEX_STALE_TIME_MS = 30_000;
+/** Chunks are immutable once uploaded: never refetch, keep long enough to pan back. */
+const CHUNK_GC_TIME_MS = 30 * 60_000;
+/**
+ * Upper bound on chunks downloaded for one view (15-minute chunks: 8 hours). Past
+ * it the overview already has more than one aggregate per pixel, so detail would
+ * not change what is drawn.
+ */
+const MAX_DETAIL_CHUNKS = 32;
+
+export interface TimelineWindow {
+ /** Visible range, axis units. */
+ min: number;
+ max: number;
+ /** Plot width in CSS pixels — the number of buckets the chart can show. */
+ widthPx: number;
+}
+
+export interface ResourceTimelineData {
+ index: ResourceTimelineIndex;
+ axis: TimeAxis;
+ /** Best available data for the window: raw samples where downloaded, overview elsewhere. */
+ aggregates: ResourceAggregate[];
+ /** Whole-session overview, for the navigator, peaks and session totals. */
+ overview: ResourceAggregate[];
+ /** Tool spans from downloaded chunks overlapping the window. */
+ toolSpans: ResourceToolSpan[];
+ /** Chunks the window needs that are still downloading. */
+ pendingChunks: number;
+ failedChunks: number;
+}
+
+/**
+ * Chunks overlapping `[from, to]` plus one neighbour either side. The neighbours
+ * make a small pan land on cached data, and they carry tool calls that overlap the
+ * window but ended after it (the collector files a span under the chunk where it ends).
+ */
+function chunksAround(chunks: readonly ResourceChunkRef[], from: number, to: number): ResourceChunkRef[] {
+ const first = chunks.findIndex((chunk) => chunk.endedAt > from);
+ if (first === -1) return chunks.slice(-1);
+ let last = first;
+ while (last + 1 < chunks.length && (chunks[last + 1]?.startedAt ?? Infinity) < to) last += 1;
+ return chunks.slice(Math.max(0, first - 1), last + 2);
+}
+
+interface ChunkQueryState {
+ details: Array;
+ pending: number;
+ failed: number;
+}
+
+/** Module-level so `useQueries` does not re-run it on every render; its output is structurally shared. */
+function combineChunkQueries(
+ results: Array<{ data?: ResourceChunkDetail; isPending: boolean; isError: boolean; fetchStatus: string }>
+): ChunkQueryState {
+ return {
+ details: results.map((result) => result.data),
+ pending: results.filter((result) => result.isPending && result.fetchStatus === 'fetching').length,
+ failed: results.filter((result) => result.isError).length,
+ };
+}
+
+/** Duration of the chunk's coarsest overview aggregate — what the overview can resolve. */
+function overviewResolutionMs(chunk: ResourceChunkRef): number {
+ return chunk.overview.reduce((widest, aggregate) => Math.max(widest, aggregate.end - aggregate.start), 0);
+}
+
+export function useResourceTimelineIndex(source: ResourceHistorySource) {
+ return useQuery({
+ queryKey: [...source.cacheKey, 'index'],
+ queryFn: () => source.loadIndex(),
+ staleTime: INDEX_STALE_TIME_MS,
+ });
+}
+
+export function useTimelineAxis(index: ResourceTimelineIndex | undefined, mode: TimeAxisMode) {
+ return useMemo(() => buildTimeAxis(index?.runs ?? [], mode), [index?.runs, mode]);
+}
+
+/**
+ * Resolves the data for one visible window. Downloads a chunk only when the
+ * window is zoomed in past what that chunk's overview can show.
+ */
+export function useResourceTimelineData(
+ source: ResourceHistorySource,
+ index: ResourceTimelineIndex,
+ axis: TimeAxis,
+ window: TimelineWindow
+): ResourceTimelineData {
+ const from = toReal(axis, window.min);
+ const to = toReal(axis, window.max);
+ const msPerPx = (window.max - window.min) / Math.max(1, window.widthPx);
+
+ const visibleChunks = useMemo(() => chunksAround(index.chunks, from, to), [index.chunks, from, to]);
+ const wantDetail =
+ visibleChunks.length <= MAX_DETAIL_CHUNKS &&
+ visibleChunks.some((chunk) => overviewResolutionMs(chunk) > msPerPx);
+
+ // Chunks already in the cache are used at any zoom level; only downloads are gated.
+ const { details, pending, failed } = useQueries({
+ queries: visibleChunks.map((chunk) => ({
+ queryKey: [...source.cacheKey, 'chunk', chunk.id],
+ queryFn: () => source.loadChunk(chunk.id),
+ enabled: wantDetail,
+ staleTime: Infinity,
+ gcTime: CHUNK_GC_TIME_MS,
+ })),
+ combine: combineChunkQueries,
+ });
+
+ const overview = useMemo(() => index.chunks.flatMap((chunk) => chunk.overview), [index.chunks]);
+
+ const { aggregates, toolSpans } = useMemo(() => {
+ const merged: ResourceAggregate[] = [];
+ const spans: ResourceToolSpan[] = [];
+ visibleChunks.forEach((chunk, position) => {
+ const detail = details[position];
+ merged.push(...(detail ? detail.samples : chunk.overview));
+ if (detail) spans.push(...detail.toolSpans);
+ });
+ return { aggregates: merged, toolSpans: spans };
+ }, [visibleChunks, details]);
+
+ return {
+ index,
+ axis,
+ aggregates,
+ overview,
+ toolSpans,
+ pendingChunks: pending,
+ failedChunks: failed,
+ };
+}
diff --git a/apps/web/src/components/chat/resource-timeline/useTimelineGestures.ts b/apps/web/src/components/chat/resource-timeline/useTimelineGestures.ts
new file mode 100644
index 0000000000..5c7cd64c0e
--- /dev/null
+++ b/apps/web/src/components/chat/resource-timeline/useTimelineGestures.ts
@@ -0,0 +1,154 @@
+import { type RefObject, useEffect } from 'react';
+
+import type { ViewRange } from './timeline-view';
+
+/**
+ * Touch and wheel interaction for the stacked panels. Mouse hover and
+ * drag-to-zoom stay with uPlot; this layer adds what uPlot does not do.
+ *
+ * - One finger, moving mostly sideways: scrub (the readout follows the finger).
+ * Mostly vertical: nothing — the element is `touch-action: pan-y`, so the page
+ * scrolls as usual.
+ * - A tap: moves the readout to that instant.
+ * - Two fingers: pinch to zoom and drag to pan, anchored under the fingers.
+ * - Trackpad pinch or ctrl+wheel zooms at the pointer; a sideways swipe pans.
+ * A plain vertical wheel scrolls the drawer.
+ */
+export interface GestureHandlers {
+ /** Client-space rectangle of the plotting area, which all panels share. */
+ plotRect: () => DOMRect | null;
+ view: () => ViewRange;
+ onScrub: (x: number) => void;
+ onTap: (x: number) => void;
+ onRange: (min: number, max: number) => void;
+}
+
+/** Movement (CSS px) before a touch commits to a direction. */
+const DIRECTION_LOCK_PX = 8;
+const WHEEL_ZOOM_SENSITIVITY = 0.01;
+
+type Gesture =
+ | { kind: 'idle' }
+ | { kind: 'pending'; x: number; y: number }
+ | { kind: 'scrub' }
+ | { kind: 'scroll' }
+ | { kind: 'pinch'; distance: number; midX: number; view: ViewRange };
+
+function toAxis(clientX: number, rect: DOMRect, view: ViewRange): number {
+ return view.min + ((clientX - rect.left) / Math.max(1, rect.width)) * (view.max - view.min);
+}
+
+export function useTimelineGestures(
+ elementRef: RefObject,
+ handlersRef: RefObject
+) {
+ useEffect(() => {
+ const element = elementRef.current;
+ if (!element) return;
+ const pointers = new Map();
+ let gesture: Gesture = { kind: 'idle' };
+
+ const axisAt = (clientX: number): number | null => {
+ const rect = handlersRef.current.plotRect();
+ return rect ? toAxis(clientX, rect, handlersRef.current.view()) : null;
+ };
+
+ const startPinch = () => {
+ const [a, b] = [...pointers.values()];
+ if (!a || !b) return;
+ gesture = {
+ kind: 'pinch',
+ distance: Math.max(1, Math.abs(a.x - b.x)),
+ midX: (a.x + b.x) / 2,
+ view: handlersRef.current.view(),
+ };
+ };
+
+ const updatePinch = (pinch: Extract) => {
+ const [a, b] = [...pointers.values()];
+ const rect = handlersRef.current.plotRect();
+ if (!a || !b || !rect) return;
+ const span = pinch.view.max - pinch.view.min;
+ const nextSpan = span * (pinch.distance / Math.max(1, Math.abs(a.x - b.x)));
+ // The instant that was under the fingers' midpoint stays under it.
+ const anchor = toAxis(pinch.midX, rect, pinch.view);
+ const midFraction = ((a.x + b.x) / 2 - rect.left) / Math.max(1, rect.width);
+ const min = anchor - midFraction * nextSpan;
+ handlersRef.current.onRange(min, min + nextSpan);
+ };
+
+ const onPointerDown = (event: PointerEvent) => {
+ if (event.pointerType !== 'touch') return;
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
+ if (pointers.size === 1) gesture = { kind: 'pending', x: event.clientX, y: event.clientY };
+ if (pointers.size === 2) {
+ for (const id of pointers.keys()) element.setPointerCapture(id);
+ startPinch();
+ }
+ };
+
+ const onPointerMove = (event: PointerEvent) => {
+ const pointer = pointers.get(event.pointerId);
+ if (!pointer) return;
+ pointer.x = event.clientX;
+ pointer.y = event.clientY;
+ if (gesture.kind === 'pending') {
+ const dx = Math.abs(event.clientX - gesture.x);
+ const dy = Math.abs(event.clientY - gesture.y);
+ if (dx > DIRECTION_LOCK_PX && dx > dy) {
+ gesture = { kind: 'scrub' };
+ element.setPointerCapture(event.pointerId);
+ } else if (dy > DIRECTION_LOCK_PX) {
+ gesture = { kind: 'scroll' };
+ }
+ }
+ if (gesture.kind === 'scrub') {
+ const x = axisAt(event.clientX);
+ if (x != null) handlersRef.current.onScrub(x);
+ } else if (gesture.kind === 'pinch') {
+ updatePinch(gesture);
+ }
+ };
+
+ const onPointerEnd = (event: PointerEvent) => {
+ if (!pointers.has(event.pointerId)) return;
+ if (event.type === 'pointerup' && gesture.kind === 'pending') {
+ const x = axisAt(event.clientX);
+ if (x != null) handlersRef.current.onTap(x);
+ }
+ pointers.delete(event.pointerId);
+ // Lifting one finger of a pinch ends the gesture; it never turns into a scrub.
+ gesture = pointers.size === 0 ? { kind: 'idle' } : { kind: 'scroll' };
+ };
+
+ const onWheel = (event: WheelEvent) => {
+ const rect = handlersRef.current.plotRect();
+ if (!rect) return;
+ const view = handlersRef.current.view();
+ const span = view.max - view.min;
+ if (event.ctrlKey) {
+ event.preventDefault();
+ const anchor = toAxis(event.clientX, rect, view);
+ const factor = Math.exp(event.deltaY * WHEEL_ZOOM_SENSITIVITY);
+ handlersRef.current.onRange(anchor - (anchor - view.min) * factor, anchor + (view.max - anchor) * factor);
+ } else if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
+ event.preventDefault();
+ const shift = (event.deltaX / Math.max(1, rect.width)) * span;
+ handlersRef.current.onRange(view.min + shift, view.max + shift);
+ }
+ };
+
+ element.addEventListener('pointerdown', onPointerDown);
+ element.addEventListener('pointermove', onPointerMove);
+ element.addEventListener('pointerup', onPointerEnd);
+ element.addEventListener('pointercancel', onPointerEnd);
+ element.addEventListener('wheel', onWheel, { passive: false });
+ return () => {
+ element.removeEventListener('pointerdown', onPointerDown);
+ element.removeEventListener('pointermove', onPointerMove);
+ element.removeEventListener('pointerup', onPointerEnd);
+ element.removeEventListener('pointercancel', onPointerEnd);
+ element.removeEventListener('wheel', onWheel);
+ };
+ }, [elementRef, handlersRef]);
+}
diff --git a/apps/web/src/components/chat/resource-timeline/useUplot.ts b/apps/web/src/components/chat/resource-timeline/useUplot.ts
new file mode 100644
index 0000000000..1a6ffb8bee
--- /dev/null
+++ b/apps/web/src/components/chat/resource-timeline/useUplot.ts
@@ -0,0 +1,51 @@
+import 'uplot/dist/uPlot.min.css';
+
+import { type RefObject, useLayoutEffect, useRef } from 'react';
+import uPlot from 'uplot';
+
+/**
+ * Owns one uPlot instance inside `containerRef`.
+ *
+ * The plot is rebuilt only when `options` changes identity (callers memoize it);
+ * new `data` is pushed into the live plot, and the plot follows the container's
+ * width. Hooks inside `options` should read mutable render state through refs so
+ * that ordinary updates never rebuild the canvas.
+ */
+export function useUplot(
+ containerRef: RefObject,
+ options: Omit,
+ data: uPlot.AlignedData
+): RefObject {
+ const plotRef = useRef(null);
+ const dataRef = useRef(data);
+
+ useLayoutEffect(() => {
+ dataRef.current = data;
+ plotRef.current?.setData(data, true);
+ }, [data]);
+
+ useLayoutEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const plot = new uPlot(
+ { ...options, width: Math.max(1, container.clientWidth) },
+ dataRef.current,
+ container
+ );
+ plotRef.current = plot;
+
+ const observer = new ResizeObserver(([entry]) => {
+ const width = Math.round(entry?.contentRect.width ?? 0);
+ if (width > 0 && width !== plot.width) plot.setSize({ width, height: options.height });
+ });
+ observer.observe(container);
+
+ return () => {
+ observer.disconnect();
+ plot.destroy();
+ plotRef.current = null;
+ };
+ }, [containerRef, options]);
+
+ return plotRef;
+}
diff --git a/apps/web/src/components/project-message-view/ProjectMessageViewDrawers.tsx b/apps/web/src/components/project-message-view/ProjectMessageViewDrawers.tsx
index 68a7001976..12843c0388 100644
--- a/apps/web/src/components/project-message-view/ProjectMessageViewDrawers.tsx
+++ b/apps/web/src/components/project-message-view/ProjectMessageViewDrawers.tsx
@@ -1,5 +1,8 @@
+import { useMemo } from 'react';
+
import type { MessageCommentAction } from '../../lib/api/comments';
import { ChatTimelineDrawer } from '../chat/ChatTimelineDrawer';
+import { apiResourceHistorySource } from '../chat/resource-timeline/resource-source';
import { SessionCommentsDrawer } from '../chat/SessionCommentsDrawer';
import { SessionResourceHistoryDrawer } from '../chat/SessionResourceHistoryDrawer';
import type { CommentInboxItem } from './comments/comment-inbox';
@@ -50,6 +53,11 @@ export function ProjectMessageViewDrawers({
onReopen: (threadId: string) => Promise;
onSendToAgent: (threadId: string) => Promise;
}>) {
+ const resourceSource = useMemo(
+ () => apiResourceHistorySource(projectId, sessionId),
+ [projectId, sessionId]
+ );
+
return (
<>
{showTimeline && (
@@ -64,11 +72,7 @@ export function ProjectMessageViewDrawers({
)}
{showResources && (
-
+
)}
{showComments && (
diff --git a/apps/web/src/pages/resource-timeline-prototype/index.tsx b/apps/web/src/pages/resource-timeline-prototype/index.tsx
new file mode 100644
index 0000000000..5b680a677b
--- /dev/null
+++ b/apps/web/src/pages/resource-timeline-prototype/index.tsx
@@ -0,0 +1,132 @@
+/**
+ * PROTOTYPE ONLY — dev-only route `/prototype/resource-timeline`.
+ *
+ * Renders the real Resources drawer against synthetic sessions. Everything under
+ * `components/chat/resource-timeline/` is the production candidate; only this
+ * page and its mock data are throwaway. Scenario, backend contract and network
+ * latency are URL parameters so a link reproduces exactly what was seen.
+ */
+
+import { Button } from '@simple-agent-manager/ui';
+import { Activity } from 'lucide-react';
+import { useMemo } from 'react';
+import { useSearchParams } from 'react-router';
+
+import { SessionResourceHistoryDrawer } from '../../components/chat/SessionResourceHistoryDrawer';
+import { scenarioById, SCENARIOS } from './mock-sessions';
+import { type BackendMode, createMockSource, type LatencyProfile } from './mock-source';
+
+const BACKENDS: Array<{ id: BackendMode; label: string; detail: string }> = [
+ {
+ id: 'proposed',
+ label: 'Proposed backend',
+ detail: 'Every chunk listed, with per-minute rollups stored at upload time.',
+ },
+ {
+ id: 'current',
+ label: 'Current backend',
+ detail: 'One summary per chunk, and only the newest 24 chunks.',
+ },
+];
+
+const LATENCIES: Array<{ id: LatencyProfile; label: string }> = [
+ { id: 'fast', label: 'Fast network' },
+ { id: 'realistic', label: 'Realistic' },
+ { id: 'slow', label: 'Slow 3G' },
+];
+
+function pick(value: string | null, allowed: readonly T[], fallback: T): T {
+ return allowed.includes(value as T) ? (value as T) : fallback;
+}
+
+const selectClass =
+ 'w-full rounded-md border border-border-default bg-surface px-2 py-2 text-sm text-fg-primary';
+
+export function ResourceTimelinePrototype() {
+ const [params, setParams] = useSearchParams();
+ const scenario = scenarioById(params.get('scenario'));
+ const backend = pick(params.get('backend'), ['proposed', 'current'] as const, 'proposed');
+ const latency = pick(params.get('latency'), ['fast', 'realistic', 'slow'] as const, 'realistic');
+ const open = params.get('open') !== '0';
+ const source = useMemo(() => createMockSource(scenario.id, backend, latency), [scenario.id, backend, latency]);
+
+ const update = (key: string, value: string) =>
+ setParams(
+ (current) => {
+ const next = new URLSearchParams(current);
+ next.set(key, value);
+ return next;
+ },
+ { replace: true }
+ );
+
+ return (
+
+
+
+
Prototype
+
Session resources timeline
+
+ The real Resources drawer, fed by synthetic sessions shaped like production ones. Scrub with a finger,
+ pinch to zoom, drag the strip under the charts, or tap a busiest moment.
+
The workspace samples CPU, memory and disk every few seconds and uploads them every{' '}
- {formatElapsed(uploadIntervalMs)}, so the first data appears about {formatElapsed(uploadIntervalMs)} after the
+ {formatMinutes(uploadIntervalMs)}, so the first data appears about {formatMinutes(uploadIntervalMs)} after the
session starts.
@@ -79,7 +79,7 @@ function SessionSummary({ index, activeMs }: Readonly<{ index: ResourceTimelineI
{nodes > 1 && ` on ${nodes} nodes`}
- Data until {formatDayTime(last)} · uploaded every {formatElapsed(index.uploadIntervalMs)}
+ Data until {formatDayTime(last)} · uploaded every {formatMinutes(index.uploadIntervalMs)}
{highWater > 0 && ` · kernel memory peak ${formatBytes(highWater)}`}
diff --git a/apps/web/src/components/chat/resource-timeline/format.ts b/apps/web/src/components/chat/resource-timeline/format.ts
index 8c232f12d1..f64910eaa3 100644
--- a/apps/web/src/components/chat/resource-timeline/format.ts
+++ b/apps/web/src/components/chat/resource-timeline/format.ts
@@ -31,9 +31,10 @@ export function formatRate(bytesPerSecond: number | null | undefined): string {
return `${formatFileSize(Math.round(bytesPerSecond))}/s`;
}
-/** Elapsed time with seconds precision below two minutes ("35s", "4m 10s", "2h 5m", "3d 4h"). */
+/** Elapsed time, as precise as its size warrants ("12ms", "35s", "4m 10s", "2h 5m", "3d 4h"). */
export function formatElapsed(ms: number): string {
- const seconds = Math.max(0, Math.round(ms / 1000));
+ if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`;
+ const seconds = Math.round(ms / 1000);
if (seconds < 120) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 10) return seconds % 60 ? `${minutes}m ${seconds % 60}s` : `${minutes}m`;
@@ -74,3 +75,21 @@ export function formatRange(start: number, end: number, precisionMs: number): st
}
return `${formatDayTime(start)} – ${formatDayTime(end)}`;
}
+
+/** "mcp__sam-mcp__update_task_status" reads as "update_task_status (sam-mcp)"; built-in tools keep their name. */
+export function formatToolName(name: string | null): string {
+ if (!name) return 'Tool call';
+ const [prefix, server, ...rest] = name.split('__');
+ return prefix === 'mcp' && server && rest.length > 0 ? `${rest.join('__')} (${server})` : name;
+}
+
+/** Tight label for small spaces: whole hours from an hour up ("9h"), else minutes ("37m"). */
+export function formatCompactDuration(ms: number): string {
+ return ms >= 3_600_000 ? `${Math.round(ms / 3_600_000)}h` : `${Math.max(1, Math.round(ms / 60_000))}m`;
+}
+
+/** Whole minutes for running prose ("every 15 minutes"). */
+export function formatMinutes(ms: number): string {
+ const minutes = Math.max(1, Math.round(ms / 60_000));
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
+}
diff --git a/apps/web/src/components/chat/resource-timeline/panels.ts b/apps/web/src/components/chat/resource-timeline/panels.ts
index fc107ac5ed..a2e30c1625 100644
--- a/apps/web/src/components/chat/resource-timeline/panels.ts
+++ b/apps/web/src/components/chat/resource-timeline/panels.ts
@@ -30,8 +30,12 @@ export interface PanelState {
}
export interface PanelCallbacks {
- /** Axis position under the cursor, or null when the cursor leaves. */
- onCursor: (x: number | null) => void;
+ /**
+ * uPlot moved its cursor: the axis position under it, or null when it left the
+ * plot. uPlot also fires this on redraws with the old pixel position, so the
+ * receiver must only trust it while a mouse is actually hovering.
+ */
+ onHover: (x: number | null) => void;
/** A mouse drag selected `[min, max]` (axis units). */
onSelectRange: (min: number, max: number) => void;
onResetZoom: () => void;
@@ -40,7 +44,10 @@ export interface PanelCallbacks {
/** Left gutter (y-axis labels) and right padding shared by every panel and the navigator. */
export const PLOT_LEFT_GUTTER_PX = 44;
export const PLOT_RIGHT_PADDING_PX = 6;
-const TICK_MIN_SPACING_PX = 64;
+/** Clock labels ("09:55 PM") need about this much room each… */
+const TICK_MIN_SPACING_PX = 58;
+/** …and half of it to the right of their tick, which the right edge cannot give. */
+const TICK_RIGHT_EDGE_CLEARANCE_PX = 22;
const TOOL_LANE_ROWS = 3;
function px(value: number): number {
@@ -205,8 +212,14 @@ function xAxis(stateRef: RefObject, theme: ChartTheme, showLabels: b
stepMs = tickStepMs((max - min) / Math.max(1, widthPx), TICK_MIN_SPACING_PX);
return axisTicks(state.axis, min, max, stepMs).map((tick) => tick.x);
},
- values: (_u, splits) =>
- splits.map((x) => (showLabels ? formatClock(toReal(stateRef.current.axis, x), stepMs) : '')),
+ values: (u, splits) => {
+ const widthPx = u.bbox.width / uPlot.pxRatio;
+ return splits.map((x) =>
+ showLabels && u.valToPos(x, 'x') <= widthPx - TICK_RIGHT_EDGE_CLEARANCE_PX
+ ? formatClock(toReal(stateRef.current.axis, x), stepMs)
+ : ''
+ );
+ },
};
}
@@ -291,7 +304,7 @@ export function panelOptions(
setCursor: [
(u) => {
const left = u.cursor.left ?? -1;
- callbacks.current.onCursor(left >= 0 ? u.posToVal(left, 'x') : null);
+ callbacks.current.onHover(left >= 0 ? u.posToVal(left, 'x') : null);
},
],
draw: [
diff --git a/apps/web/src/components/chat/resource-timeline/readout.ts b/apps/web/src/components/chat/resource-timeline/readout.ts
index f18603588e..da81d3ad17 100644
--- a/apps/web/src/components/chat/resource-timeline/readout.ts
+++ b/apps/web/src/components/chat/resource-timeline/readout.ts
@@ -11,9 +11,10 @@ import {
formatElapsed,
formatRange,
formatRate,
+ formatToolName,
} from './format';
import { summarizeUsage, type TimelineSeries } from './series';
-import { sleepAt, type TimeAxis, toReal } from './time-axis';
+import { activeMsInView, sleepAt, type TimeAxis, toReal } from './time-axis';
import type { ResourceAggregate, ResourceRun, ResourceToolSpan } from './types';
export interface Readout {
@@ -56,10 +57,11 @@ function runLabel(runs: readonly ResourceRun[], t: number): string {
return runs.length > 1 ? `Run ${index + 1} of ${runs.length}${node}${problem}` : `Workspace${node}${problem}`;
}
+/** Kept short enough to sit beside the panel's title and legend on a phone. */
function memoryText(used: number | null, total: number | null): string {
- if (used == null) return total == null ? DASH : `${formatBytes(total)} (incl. cache)`;
+ if (used == null) return total == null ? DASH : `${formatBytes(total)} incl. cache`;
const cache = total == null ? null : Math.max(0, total - used);
- return cache == null ? `${formatBytes(used)} used` : `${formatBytes(used)} used + ${formatBytes(cache)} cache`;
+ return cache == null ? `${formatBytes(used)} used` : `${formatBytes(used)} + ${formatBytes(cache)} cache`;
}
function activeToolsText(spans: readonly ResourceToolSpan[], from: number, to: number, instant: number): string | null {
@@ -68,7 +70,7 @@ function activeToolsText(spans: readonly ResourceToolSpan[], from: number, to: n
if (inWindow.length === 0) return null;
const longest = [...inWindow].sort((a, b) => b.endedAt - b.startedAt - (a.endedAt - a.startedAt))[0];
if (!longest) return null;
- const label = longest.name ?? 'tool call';
+ const label = formatToolName(longest.name);
const more = inWindow.length > 1 ? ` +${inWindow.length - 1} more` : '';
const verb = running.length ? 'running' : 'ran';
return `${label} ${verb} ${formatElapsed(longest.endedAt - longest.startedAt)}${more}`;
@@ -110,7 +112,8 @@ export function readoutAtCursor(
return {
mode: 'cursor',
- time: measured ? formatClock(t, 1_000) : `${formatClock(from, series.bucketMs)} · ${formatElapsed(to - from)} avg`,
+ // The instant under the cursor stays put while zooming; the averaging window says how wide the reading is.
+ time: measured ? formatClock(t, 1_000) : `${formatClock(t, series.bucketMs)} · ${formatElapsed(to - from)} avg`,
context: runLabel(runs, t),
cpu: measured || cpuMax == null ? formatCores(cpuMean) : `${formatCores(cpuMean)} · peak ${formatCores(cpuMax)}`,
memory: memoryText(used, total),
@@ -133,17 +136,20 @@ export function readoutForRange(
const usage = summarizeUsage(aggregates, from, to);
const whole = viewMin <= axis.min && viewMax >= axis.max;
const scope = whole ? 'Whole session' : formatRange(from, to, viewMax - viewMin < 30 * 60_000 ? 1_000 : 60_000);
+ const activeTotal = formatElapsed(axis.activeMs);
return {
mode: 'range',
time: scope,
- context: `${formatElapsed(usage.coveredMs)} of ${formatElapsed(axis.activeMs)} active time in view`,
+ context: whole
+ ? `All ${activeTotal} of active time`
+ : `${formatElapsed(activeMsInView(axis, viewMin, viewMax))} of ${activeTotal} active time in view`,
cpu: usage.cpuMaxCores == null ? DASH : `avg ${formatCores(usage.cpuMeanCores)} · peak ${formatCores(usage.cpuMaxCores)}`,
memory:
usage.workingSetMaxBytes != null
- ? `peak ${formatBytes(usage.workingSetMaxBytes)} used · ${formatBytes(usage.memoryMaxBytes)} incl. cache`
+ ? `peak ${formatBytes(usage.workingSetMaxBytes)} (${formatBytes(usage.memoryMaxBytes)} w/ cache)`
: usage.memoryMaxBytes == null
? DASH
- : `peak ${formatBytes(usage.memoryMaxBytes)} (incl. cache)`,
+ : `peak ${formatBytes(usage.memoryMaxBytes)} incl. cache`,
disk: `${formatBytes(usage.ioWriteBytes)} written · ${formatBytes(usage.ioReadBytes)} read`,
tools: `${usage.toolCallStarts} tool call${usage.toolCallStarts === 1 ? '' : 's'}`,
oomKills: usage.oomKills,
diff --git a/apps/web/src/components/chat/resource-timeline/time-axis.ts b/apps/web/src/components/chat/resource-timeline/time-axis.ts
index de2cf94652..e48c13815c 100644
--- a/apps/web/src/components/chat/resource-timeline/time-axis.ts
+++ b/apps/web/src/components/chat/resource-timeline/time-axis.ts
@@ -3,9 +3,10 @@
*
* A session is a sequence of runs separated by sleeps. Drawn on a plain clock axis,
* a session that worked for an hour, slept overnight and worked another hour is two
- * slivers either side of a void. In `active` mode each sleep longer than the break
- * width is compressed to that width and drawn as an explicit, labelled break, so
- * the time that has data gets the pixels. `clock` mode keeps real proportions.
+ * slivers either side of a void. In `active` mode each sleep is compressed to a
+ * width that grows with the logarithm of its length and drawn as an explicit,
+ * labelled break, so the time that has data gets the pixels while a night still
+ * reads longer than a short pause. `clock` mode keeps real proportions.
*
* Positions on the axis are "axis milliseconds": inside a run one axis millisecond
* is one real millisecond, so durations read off the axis stay true.
@@ -33,11 +34,15 @@ export interface TimeAxis {
activeMs: number;
}
-/** A compressed sleep takes this fraction of the session's active time… */
-const SLEEP_BREAK_FRACTION = 0.025;
-/** …bounded so it stays visible on short sessions and modest on long ones. */
-const SLEEP_BREAK_MIN_MS = 15_000;
-const SLEEP_BREAK_MAX_MS = 10 * 60_000;
+/**
+ * A compressed sleep's width is one "break unit" per doubling of its length, so a
+ * night reads visibly longer than a coffee break without either swamping the runs.
+ * The unit is a small fraction of the session's active time…
+ */
+const SLEEP_BREAK_UNIT_FRACTION = 0.0025;
+/** …bounded so breaks stay visible on short sessions and modest on long ones. */
+const SLEEP_BREAK_UNIT_MIN_MS = 5_000;
+const SLEEP_BREAK_UNIT_MAX_MS = 5 * 60_000;
/** Merges overlapping or touching runs into disjoint active intervals. */
function activeIntervals(runs: readonly ResourceRun[]): Array<[number, number]> {
@@ -54,14 +59,19 @@ function activeIntervals(runs: readonly ResourceRun[]): Array<[number, number]>
return merged;
}
-export function sleepBreakWidthMs(activeMs: number): number {
- return Math.min(SLEEP_BREAK_MAX_MS, Math.max(SLEEP_BREAK_MIN_MS, activeMs * SLEEP_BREAK_FRACTION));
+/** Axis width of a compressed sleep; a sleep never gets wider than it really was. */
+export function sleepBreakWidthMs(activeMs: number, sleepMs: number): number {
+ const unit = Math.min(
+ SLEEP_BREAK_UNIT_MAX_MS,
+ Math.max(SLEEP_BREAK_UNIT_MIN_MS, activeMs * SLEEP_BREAK_UNIT_FRACTION)
+ );
+ const doublings = Math.log2(Math.max(1, sleepMs / 60_000));
+ return Math.min(sleepMs, unit * (1 + doublings));
}
export function buildTimeAxis(runs: readonly ResourceRun[], mode: TimeAxisMode): TimeAxis {
const intervals = activeIntervals(runs);
const activeMs = intervals.reduce((sum, [start, end]) => sum + (end - start), 0);
- const breakWidth = sleepBreakWidthMs(activeMs);
const spans: AxisSpan[] = [];
let cursor = intervals[0]?.[0] ?? 0;
@@ -69,7 +79,7 @@ export function buildTimeAxis(runs: readonly ResourceRun[], mode: TimeAxisMode):
const previous = spans.at(-1);
if (previous) {
const sleepMs = start - previous.realEnd;
- const width = mode === 'active' ? Math.min(sleepMs, breakWidth) : sleepMs;
+ const width = mode === 'active' ? sleepBreakWidthMs(activeMs, sleepMs) : sleepMs;
spans.push({
kind: 'sleep',
realStart: previous.realEnd,
@@ -129,14 +139,24 @@ export function sleepAt(axis: TimeAxis, x: number): AxisSpan | null {
return span?.kind === 'sleep' && x >= span.axisStart && x < span.axisEnd ? span : null;
}
+/** Real milliseconds of run time inside `[viewMin, viewMax]` (axis units map 1:1 inside runs). */
+export function activeMsInView(axis: TimeAxis, viewMin: number, viewMax: number): number {
+ return axis.spans
+ .filter((span) => span.kind === 'active')
+ .reduce((sum, span) => sum + Math.max(0, Math.min(span.axisEnd, viewMax) - Math.max(span.axisStart, viewMin)), 0);
+}
+
/** Axis spans (compressed breaks included) that are sleeps. */
export function sleeps(axis: TimeAxis): AxisSpan[] {
return axis.spans.filter((span) => span.kind === 'sleep');
}
+const MINUTE_MS = 60_000;
+const HOUR_MS = 60 * MINUTE_MS;
const TICK_STEPS_MS = [
- 1_000, 5_000, 15_000, 30_000, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 3_600_000,
- 3 * 3_600_000, 6 * 3_600_000, 12 * 3_600_000, 86_400_000,
+ 1_000, 5_000, 15_000, 30_000, MINUTE_MS, 2 * MINUTE_MS, 5 * MINUTE_MS, 10 * MINUTE_MS,
+ 15 * MINUTE_MS, 30 * MINUTE_MS, HOUR_MS, 2 * HOUR_MS, 3 * HOUR_MS, 6 * HOUR_MS, 12 * HOUR_MS,
+ 24 * HOUR_MS,
];
/** The finest tick step that keeps labels at least `minSpacingPx` apart. */
@@ -148,7 +168,9 @@ export function tickStepMs(msPerPx: number, minSpacingPx: number): number {
/**
* Tick positions (axis units) for the visible window, aligned to round wall-clock
* times in the viewer's zone. Compressed sleeps get no ticks: their scale differs
- * from the runs around them, so a tick inside one would lie about the spacing.
+ * from the runs around them, so a tick inside one would lie about the spacing. For
+ * the same reason, ticks either side of a break can land close together; the later
+ * one is dropped when it would sit closer than most of a step to its neighbour.
*/
export function axisTicks(
axis: TimeAxis,
@@ -167,7 +189,7 @@ export function axisTicks(
for (let t = first; t <= to; t += stepMs) {
const x = toAxis(axis, t);
const last = ticks.at(-1);
- if (!last || x > last.x) ticks.push({ x, t });
+ if (!last || x - last.x >= stepMs * 0.75) ticks.push({ x, t });
}
}
return ticks;
diff --git a/apps/web/src/components/chat/resource-timeline/useResourceTimelineData.ts b/apps/web/src/components/chat/resource-timeline/useResourceTimelineData.ts
index b9fc94e0ac..d97cc7dccc 100644
--- a/apps/web/src/components/chat/resource-timeline/useResourceTimelineData.ts
+++ b/apps/web/src/components/chat/resource-timeline/useResourceTimelineData.ts
@@ -22,7 +22,7 @@ const CHUNK_GC_TIME_MS = 30 * 60_000;
*/
const MAX_DETAIL_CHUNKS = 32;
-export interface TimelineWindow {
+export interface TimelineViewport {
/** Visible range, axis units. */
min: number;
max: number;
@@ -35,8 +35,10 @@ export interface ResourceTimelineData {
axis: TimeAxis;
/** Best available data for the window: raw samples where downloaded, overview elsewhere. */
aggregates: ResourceAggregate[];
- /** Whole-session overview, for the navigator, peaks and session totals. */
+ /** Whole-session overview (stable while zooming), for peaks and session totals. */
overview: ResourceAggregate[];
+ /** The whole session at the best detail loaded so far: raw samples where fetched, overview elsewhere. */
+ sessionAggregates: ResourceAggregate[];
/** Tool spans from downloaded chunks overlapping the window. */
toolSpans: ResourceToolSpan[];
/** Chunks the window needs that are still downloading. */
@@ -99,11 +101,11 @@ export function useResourceTimelineData(
source: ResourceHistorySource,
index: ResourceTimelineIndex,
axis: TimeAxis,
- window: TimelineWindow
+ viewport: TimelineViewport
): ResourceTimelineData {
- const from = toReal(axis, window.min);
- const to = toReal(axis, window.max);
- const msPerPx = (window.max - window.min) / Math.max(1, window.widthPx);
+ const from = toReal(axis, viewport.min);
+ const to = toReal(axis, viewport.max);
+ const msPerPx = (viewport.max - viewport.min) / Math.max(1, viewport.widthPx);
const visibleChunks = useMemo(() => chunksAround(index.chunks, from, to), [index.chunks, from, to]);
const wantDetail =
@@ -135,11 +137,17 @@ export function useResourceTimelineData(
return { aggregates: merged, toolSpans: spans };
}, [visibleChunks, details]);
+ const sessionAggregates = useMemo(() => {
+ const loaded = new Map(visibleChunks.map((chunk, position) => [chunk.id, details[position]]));
+ return index.chunks.flatMap((chunk) => loaded.get(chunk.id)?.samples ?? chunk.overview);
+ }, [index.chunks, visibleChunks, details]);
+
return {
index,
axis,
aggregates,
overview,
+ sessionAggregates,
toolSpans,
pendingChunks: pending,
failedChunks: failed,
diff --git a/apps/web/src/pages/resource-timeline-prototype/mock-sessions.ts b/apps/web/src/pages/resource-timeline-prototype/mock-sessions.ts
index c6ddfbf048..ef2134ba35 100644
--- a/apps/web/src/pages/resource-timeline-prototype/mock-sessions.ts
+++ b/apps/web/src/pages/resource-timeline-prototype/mock-sessions.ts
@@ -32,12 +32,16 @@ interface RunPlan {
oomAtMin?: number;
}
+/** A coordinator mostly waits and dispatches; a builder compiles and tests all day. */
+type Workload = 'coordinator' | 'builder';
+
export interface ScenarioPlan {
id: string;
label: string;
description: string;
startedAt: number;
seed: number;
+ workload: Workload;
runs: RunPlan[];
reservation: { cpuCores: number; memoryBytes: number } | null;
/** VM agents that predate working-set memory and tool labels report neither. */
@@ -75,19 +79,30 @@ interface Phase {
memoryLift: number;
}
-const PHASE_WEIGHTS: Array<[PhaseKind, number]> = [
- ['idle', 3],
- ['agent', 5],
- ['build', 2],
- ['install', 1],
- ['test', 1.5],
- ['fetch', 1],
-];
+const PHASE_WEIGHTS: Record> = {
+ coordinator: [
+ ['idle', 5],
+ ['agent', 6],
+ ['build', 0.7],
+ ['install', 0.3],
+ ['test', 0.5],
+ ['fetch', 1.5],
+ ],
+ builder: [
+ ['idle', 3],
+ ['agent', 5],
+ ['build', 2],
+ ['install', 1],
+ ['test', 1.5],
+ ['fetch', 1],
+ ],
+};
-function pickPhase(random: () => number): PhaseKind {
- const total = PHASE_WEIGHTS.reduce((sum, [, weight]) => sum + weight, 0);
+function pickPhase(random: () => number, workload: Workload): PhaseKind {
+ const weights = PHASE_WEIGHTS[workload];
+ const total = weights.reduce((sum, [, weight]) => sum + weight, 0);
let roll = random() * total;
- for (const [kind, weight] of PHASE_WEIGHTS) {
+ for (const [kind, weight] of weights) {
roll -= weight;
if (roll <= 0) return kind;
}
@@ -111,12 +126,12 @@ function phaseShape(kind: PhaseKind, random: () => number): Omit number): Phase[] {
+function planPhases(start: number, end: number, random: () => number, workload: Workload): Phase[] {
const phases: Phase[] = [];
let cursor = start;
let previous: PhaseKind = 'idle';
while (cursor < end) {
- let kind = pickPhase(random);
+ let kind = pickPhase(random, workload);
if (kind === previous && kind !== 'agent') kind = 'agent';
const shape = phaseShape(kind, random);
const phaseEnd = Math.min(end, cursor + shape.minutes * MINUTE);
@@ -197,7 +212,7 @@ export function generateRun(plan: RunPlan, scenario: ScenarioPlan, index: number
};
if (plan.unsupported) return { run, samples: [], toolSpans: [], samplerGaps: [], memoryHighWater: [] };
- const phases = planPhases(startedAt, endedAt, random);
+ const phases = planPhases(startedAt, endedAt, random, scenario.workload);
let spanCounter = 0;
const nextId = () => `span-${scenario.id}-${index}-${(spanCounter += 1)}`;
const toolSpans = phases.flatMap((phase) =>
@@ -309,6 +324,7 @@ id: 'overnight',
description: 'Modelled on session 449d73f8: many wake cycles on three nodes, a 9-hour overnight sleep, an OOM kill and a wake whose container never started.',
startedAt: EVENING,
seed: 7,
+ workload: 'coordinator',
reservation: { cpuCores: 2, memoryBytes: 4 * GB },
agentReportsWorkingSet: true,
agentReportsToolNames: true,
@@ -334,6 +350,7 @@ export const SCENARIOS: ScenarioPlan[] = [
description: 'Modelled on session 98ac3e40: a single four-hour run with frequent builds and hundreds of tool calls.',
startedAt: Date.parse('2026-09-28T09:05:00'),
seed: 21,
+ workload: 'builder',
reservation: { cpuCores: 4, memoryBytes: 8 * GB },
agentReportsWorkingSet: true,
agentReportsToolNames: true,
@@ -345,6 +362,7 @@ export const SCENARIOS: ScenarioPlan[] = [
description: 'One run shorter than a single upload interval: one partial chunk.',
startedAt: Date.parse('2026-09-29T07:12:00'),
seed: 3,
+ workload: 'builder',
reservation: { cpuCores: 2, memoryBytes: 4 * GB },
agentReportsWorkingSet: true,
agentReportsToolNames: true,
@@ -356,6 +374,7 @@ export const SCENARIOS: ScenarioPlan[] = [
description: 'The overnight session as reported by agents that predate tool labels and working-set memory.',
startedAt: EVENING,
seed: 7,
+ workload: 'coordinator',
reservation: null,
agentReportsWorkingSet: false,
agentReportsToolNames: false,
@@ -371,6 +390,7 @@ export const SCENARIOS: ScenarioPlan[] = [
description: 'Samples upload every 15 minutes; a new session has none yet.',
startedAt: Date.parse('2026-09-29T07:40:00'),
seed: 1,
+ workload: 'coordinator',
reservation: null,
agentReportsWorkingSet: true,
agentReportsToolNames: true,
diff --git a/apps/web/src/pages/resource-timeline-prototype/mock-source.ts b/apps/web/src/pages/resource-timeline-prototype/mock-source.ts
index 89fcc671ba..02e8363b1d 100644
--- a/apps/web/src/pages/resource-timeline-prototype/mock-source.ts
+++ b/apps/web/src/pages/resource-timeline-prototype/mock-source.ts
@@ -88,6 +88,24 @@ function rollup(
return buckets;
}
+/** Each run trimmed to the chunks that were actually returned for it. */
+function runsCoveredBy(
+ runs: ResourceTimelineIndex['runs'],
+ chunks: readonly ResourceChunkRef[]
+): ResourceTimelineIndex['runs'] {
+ return runs.flatMap((run) => {
+ const own = chunks.filter((chunk) => chunk.runId === run.id);
+ if (own.length === 0) return [];
+ return [
+ {
+ ...run,
+ startedAt: Math.min(...own.map((chunk) => chunk.startedAt)),
+ endedAt: Math.max(...own.map((chunk) => chunk.endedAt)),
+ },
+ ];
+ });
+}
+
interface BuiltScenario {
plan: ScenarioPlan;
refs: ResourceChunkRef[];
@@ -140,9 +158,9 @@ export function createMockSource(
const scenario = buildScenario(plan, backend);
const truncated = backend === 'current' && scenario.refs.length > CURRENT_API_CHUNK_LIMIT;
const chunks = truncated ? scenario.refs.slice(-CURRENT_API_CHUNK_LIMIT) : scenario.refs;
- const listedRuns = new Set(chunks.map((chunk) => chunk.runId));
return {
- runs: scenario.runs.filter((run) => backend === 'proposed' || listedRuns.has(run.id)),
+ // Like the real adapter, the current API only knows the runs its returned chunks cover.
+ runs: backend === 'proposed' ? scenario.runs : runsCoveredBy(scenario.runs, chunks),
chunks,
sampleIntervalMs: SAMPLE_INTERVAL_MS,
uploadIntervalMs: CHUNK_INTERVAL_MS,
From 5432deea008148b48ed398ccf5857bb63c337148 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?=
Date: Tue, 29 Sep 2026 07:34:36 +0000
Subject: [PATCH 03/22] test(web): cover the resource timeline data layer
Unit tests for the pure modules behind the prototype timeline: time-axis
compression, round-trips and tick placement; level-of-detail bucketing
(spike preservation, time-weighted means, gaps, disk rates, OOM counted
once); summaries and peaks; the adapter over today's API (cores from
CPU-ms, truncation detection, tool labels); view intents across axis
modes; and readout wording at each zoom level.
Co-Authored-By: Claude Opus 5.5
---
.../resource-timeline/readout.test.ts | 123 ++++++++++++++
.../resource-timeline/resource-source.test.ts | 154 ++++++++++++++++++
.../resource-timeline/series.test.ts | 146 +++++++++++++++++
.../resource-timeline/time-axis.test.ts | 118 ++++++++++++++
.../resource-timeline/timeline-view.test.ts | 61 +++++++
5 files changed, 602 insertions(+)
create mode 100644 apps/web/tests/unit/components/resource-timeline/readout.test.ts
create mode 100644 apps/web/tests/unit/components/resource-timeline/resource-source.test.ts
create mode 100644 apps/web/tests/unit/components/resource-timeline/series.test.ts
create mode 100644 apps/web/tests/unit/components/resource-timeline/time-axis.test.ts
create mode 100644 apps/web/tests/unit/components/resource-timeline/timeline-view.test.ts
diff --git a/apps/web/tests/unit/components/resource-timeline/readout.test.ts b/apps/web/tests/unit/components/resource-timeline/readout.test.ts
new file mode 100644
index 0000000000..53d340ac8d
--- /dev/null
+++ b/apps/web/tests/unit/components/resource-timeline/readout.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from 'vitest';
+
+import { formatToolName } from '../../../../src/components/chat/resource-timeline/format';
+import {
+ nearestBucket,
+ readoutAtCursor,
+ readoutForRange,
+} from '../../../../src/components/chat/resource-timeline/readout';
+import { buildSeries } from '../../../../src/components/chat/resource-timeline/series';
+import { buildTimeAxis, toAxis } from '../../../../src/components/chat/resource-timeline/time-axis';
+import type {
+ ResourceAggregate,
+ ResourceRun,
+ ResourceToolSpan,
+} from '../../../../src/components/chat/resource-timeline/types';
+
+const SECOND = 1_000;
+const MINUTE = 60 * SECOND;
+const HOUR = 60 * MINUTE;
+const GB = 1024 ** 3;
+const T0 = new Date(2026, 8, 28, 19, 0, 0).getTime();
+
+function run(id: string, startedAt: number, endedAt: number): ResourceRun {
+ return { id, nodeId: 'node-a', startedAt, endedAt, unsupportedReason: null };
+}
+
+function sample(end: number, cores: number): ResourceAggregate {
+ return {
+ start: end - 5 * SECOND,
+ end,
+ cpuMeanCores: cores,
+ cpuMaxCores: cores,
+ memoryMeanBytes: 3 * GB,
+ memoryMaxBytes: 3 * GB,
+ workingSetMeanBytes: 2 * GB,
+ workingSetMaxBytes: 2 * GB,
+ ioReadBytes: 0,
+ ioWriteBytes: 5 * 1024 ** 2,
+ oomKills: 0,
+ toolCallStarts: 0,
+ exact: true,
+ };
+}
+
+const RUNS = [run('ws-1', T0, T0 + HOUR), run('ws-2', T0 + 10 * HOUR, T0 + 11 * HOUR)];
+const SAMPLES = Array.from({ length: 720 }, (_, i) => sample(T0 + (i + 1) * 5 * SECOND, i === 100 ? 3 : 0.5));
+const BASH: ResourceToolSpan = {
+ id: 'bash',
+ kind: 'execute',
+ name: 'Bash',
+ startedAt: T0 + 8 * MINUTE,
+ endedAt: T0 + 8 * MINUTE + 35 * SECOND,
+ approximateEnd: false,
+};
+
+describe('nearestBucket', () => {
+ it('picks the bucket whose centre is closest', () => {
+ const series = { x: [10, 20, 30] } as Parameters[0];
+ expect(nearestBucket(series, 24)).toBe(1);
+ expect(nearestBucket(series, 26)).toBe(2);
+ expect(nearestBucket(series, -5)).toBe(0);
+ expect(nearestBucket(series, 99)).toBe(2);
+ });
+});
+
+describe('readoutAtCursor', () => {
+ const axis = buildTimeAxis(RUNS, 'active');
+
+ it('reads one 5-second measurement to the second when zoomed all the way in', () => {
+ const view = { min: toAxis(axis, T0 + 8 * MINUTE), max: toAxis(axis, T0 + 9 * MINUTE) };
+ const series = buildSeries(SAMPLES, axis, view.min, view.max, 400, 5 * SECOND);
+ const at = toAxis(axis, T0 + 8 * MINUTE + 12 * SECOND);
+
+ const readout = readoutAtCursor(at, series, axis, RUNS, [BASH], 5 * SECOND);
+
+ expect(readout.time).not.toContain('avg');
+ expect(readout.time).toMatch(/:\d{2}:\d{2}/);
+ expect(readout.context).toBe('Run 1 of 2 · node-a');
+ expect(readout.cpu).toBe('0.50 cores');
+ expect(readout.memory).toBe('2.0 GB + 1.0 GB cache');
+ expect(readout.tools).toBe('Bash running 35s');
+ });
+
+ it('labels a zoomed-out reading as an average and shows its peak', () => {
+ const series = buildSeries(SAMPLES, axis, axis.min, axis.max, 30, 5 * SECOND);
+ const at = toAxis(axis, T0 + 100 * 5 * SECOND);
+
+ const readout = readoutAtCursor(at, series, axis, RUNS, [], 5 * SECOND);
+
+ expect(readout.time).toContain('avg');
+ expect(readout.cpu).toContain('peak 3.00 cores');
+ });
+
+ it('says the session was asleep, and for how long, between runs', () => {
+ const series = buildSeries(SAMPLES, axis, axis.min, axis.max, 30, 5 * SECOND);
+ const sleep = axis.spans[1];
+ const middle = ((sleep?.axisStart ?? 0) + (sleep?.axisEnd ?? 0)) / 2;
+
+ const readout = readoutAtCursor(middle, series, axis, RUNS, [], 5 * SECOND);
+
+ expect(readout.context).toBe('Session asleep for 9h — no workspace was running');
+ expect(readout.cpu).toBe('—');
+ });
+});
+
+describe('readoutForRange', () => {
+ it('describes the whole session without implying data is missing', () => {
+ const axis = buildTimeAxis(RUNS, 'active');
+ const readout = readoutForRange(axis.min, axis.max, axis, SAMPLES);
+
+ expect(readout.time).toBe('Whole session');
+ expect(readout.context).toBe('All 2h of active time');
+ expect(readout.memory).toBe('peak 2.0 GB (3.0 GB w/ cache)');
+ });
+});
+
+describe('formatToolName', () => {
+ it('names MCP tools by tool, then server', () => {
+ expect(formatToolName('mcp__sam-mcp__update_task_status')).toBe('update_task_status (sam-mcp)');
+ expect(formatToolName('Bash')).toBe('Bash');
+ expect(formatToolName(null)).toBe('Tool call');
+ });
+});
diff --git a/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts b/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts
new file mode 100644
index 0000000000..70e49aafea
--- /dev/null
+++ b/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ chunkDetailFromApi,
+ indexFromApi,
+ sampleToAggregate,
+ withToolStarts,
+} from '../../../../src/components/chat/resource-timeline/resource-source';
+import type { ResourceToolSpan } from '../../../../src/components/chat/resource-timeline/types';
+import type {
+ WorkspaceResourceChunk,
+ WorkspaceResourceHistoryResponse,
+ WorkspaceResourceSummary,
+} from '../../../../src/lib/api';
+
+const T0 = Date.UTC(2026, 8, 28, 19, 0, 0);
+const MINUTE = 60_000;
+
+function chunk(id: string, workspaceId: string, startedAt: number, overrides: Partial = {}): WorkspaceResourceChunk {
+ return {
+ id,
+ workspaceId,
+ sessionId: 'session-1',
+ taskId: null,
+ nodeId: `node-of-${workspaceId}`,
+ chunkSequence: 1,
+ sourceVersion: 1,
+ storageFormat: 'resource-history-gzip-json-v1',
+ compressedBytes: 1000,
+ uncompressedBytes: 9000,
+ sha256: 'abc',
+ startedAt,
+ endedAt: startedAt + 15 * MINUTE,
+ sampleCount: 180,
+ gapCount: 0,
+ toolSpanCount: 12,
+ completeness: { status: 'complete', unsupported: '' },
+ summary: {
+ sampleIntervalMillis: 5000,
+ cpuMeanMillis: 2500,
+ cpuPeakMillis: 15000,
+ memoryMeanBytes: 1e9,
+ memoryPeakBytes: 2e9,
+ memoryKernelPeakBytes: 2.2e9,
+ ioReadBytes: 10,
+ ioWriteBytes: 20,
+ oomCount: 0,
+ },
+ expiresAt: startedAt + 90 * 24 * 60 * MINUTE,
+ ...overrides,
+ };
+}
+
+function response(chunks: WorkspaceResourceChunk[], firstChunkId: string | null): WorkspaceResourceHistoryResponse {
+ const summary = { firstChunkId, summary: { sampleIntervalMillis: 5000 } } as unknown as WorkspaceResourceSummary;
+ return { summary, chunks };
+}
+
+describe('sampleToAggregate', () => {
+ it('turns CPU milliseconds per interval into cores and keeps disk bytes', () => {
+ const aggregate = sampleToAggregate({ t: T0, intervalMillis: 5000, cpuMillis: 2500, memoryBytes: 1e9, ioReadBytes: 7, ioWriteBytes: 9 });
+
+ expect(aggregate).toMatchObject({ start: T0 - 5000, end: T0, cpuMeanCores: 0.5, cpuMaxCores: 0.5, ioReadBytes: 7, ioWriteBytes: 9, exact: true });
+ });
+
+ it('has no CPU or disk reading for the first sample of a run, which has no interval yet', () => {
+ const aggregate = sampleToAggregate({ t: T0, memoryBytes: 1e9 });
+
+ expect(aggregate.cpuMeanCores).toBeNull();
+ expect(aggregate.ioReadBytes).toBeNull();
+ expect(aggregate.memoryMeanBytes).toBe(1e9);
+ });
+
+ it('drops the memory reading of a sample the collector could not observe', () => {
+ const aggregate = sampleToAggregate({ t: T0, intervalMillis: 5000, memoryBytes: 0, unsupported: 'no running devcontainer found' });
+ expect(aggregate.memoryMeanBytes).toBeNull();
+ expect(aggregate.cpuMeanCores).toBeNull();
+ });
+});
+
+describe('withToolStarts', () => {
+ const samples = [0, 1, 2].map((i) => sampleToAggregate({ t: T0 + (i + 1) * 5000, intervalMillis: 5000, cpuMillis: 0 }));
+ const span = (startedAt: number): ResourceToolSpan => ({ id: `s${startedAt}`, kind: 'read', name: 'Read', startedAt, endedAt: startedAt + 10, approximateEnd: false });
+
+ it('counts each call on the sample whose interval saw it start', () => {
+ const counted = withToolStarts(samples, [span(T0 + 1000), span(T0 + 6000), span(T0 + 7000)]);
+ expect(counted.map((sample) => sample.toolCallStarts)).toEqual([1, 2, 0]);
+ });
+
+ it('counts a call that started before the chunk on its first sample', () => {
+ const counted = withToolStarts(samples, [span(T0 - 60_000)]);
+ expect(counted.map((sample) => sample.toolCallStarts)).toEqual([1, 0, 0]);
+ });
+});
+
+describe('indexFromApi', () => {
+ it('orders chunks oldest first and groups them into one run per workspace', () => {
+ const index = indexFromApi(
+ response([chunk('c3', 'ws-2', T0 + 60 * MINUTE), chunk('c2', 'ws-1', T0 + 15 * MINUTE), chunk('c1', 'ws-1', T0)], 'c3')
+ );
+
+ expect(index.chunks.map((ref) => ref.id)).toEqual(['c1', 'c2', 'c3']);
+ expect(index.runs).toEqual([
+ { id: 'ws-1', nodeId: 'node-of-ws-1', startedAt: T0, endedAt: T0 + 30 * MINUTE, unsupportedReason: null },
+ { id: 'ws-2', nodeId: 'node-of-ws-2', startedAt: T0 + 60 * MINUTE, endedAt: T0 + 75 * MINUTE, unsupportedReason: null },
+ ]);
+ });
+
+ it('reads the per-chunk summary as one coarse aggregate in cores', () => {
+ const index = indexFromApi(response([chunk('c1', 'ws-1', T0)], 'c1'));
+ const [overview] = index.chunks[0]?.overview ?? [];
+
+ expect(overview).toMatchObject({ cpuMeanCores: 0.5, cpuMaxCores: 3, memoryMaxBytes: 2e9, toolCallStarts: 12, exact: false });
+ expect(index.chunks[0]?.memoryHighWaterBytes).toBe(2.2e9);
+ });
+
+ it('reports truncated history when the newest workspace lost its own first chunk from the page', () => {
+ const truncated = indexFromApi(response([chunk('c9', 'ws-1', T0)], 'c1'));
+ const complete = indexFromApi(response([chunk('c1', 'ws-1', T0)], 'c1'));
+
+ expect(truncated.completeness).toEqual({ kind: 'truncated', omitted: null });
+ expect(complete.completeness).toEqual({ kind: 'complete' });
+ });
+
+ it('carries the reason a run could not be observed', () => {
+ const index = indexFromApi(
+ response([chunk('c1', 'ws-1', T0, { completeness: { status: 'partial', unsupported: 'no running devcontainer found' } })], 'c1')
+ );
+ expect(index.runs[0]?.unsupportedReason).toBe('no running devcontainer found');
+ });
+});
+
+describe('chunkDetailFromApi', () => {
+ it('maps tool labels when present and falls back for spans from older agents', () => {
+ // Newer VM agents add `toolName`; the web type predates it, which is why the adapter reads it defensively.
+ const labelled = { id: 'b', kind: 'execute', startedAt: T0 + 3000, endedAt: T0 + 4000, toolName: 'Bash' };
+ const detail = chunkDetailFromApi('c1', {
+ chunkId: 'c1',
+ samples: [{ t: T0 + 5000, intervalMillis: 5000, cpuMillis: 5000 }],
+ toolSpans: [{ id: 'a', kind: 'acp_tool_call', startedAt: T0 + 1000, endedAt: T0 + 2000 }, labelled],
+ gaps: [{ startedAt: T0, endedAt: T0 + 40_000, reason: 'sampler_delay' }],
+ originalSampleCount: 1,
+ downsampled: false,
+ downsampleLimit: 720,
+ });
+
+ expect(detail.toolSpans.map((span) => [span.kind, span.name])).toEqual([
+ ['other', null],
+ ['execute', 'Bash'],
+ ]);
+ expect(detail.samples[0]?.toolCallStarts).toBe(2);
+ expect(detail.samplerGaps).toEqual([{ start: T0, end: T0 + 40_000 }]);
+ });
+});
diff --git a/apps/web/tests/unit/components/resource-timeline/series.test.ts b/apps/web/tests/unit/components/resource-timeline/series.test.ts
new file mode 100644
index 0000000000..9242157872
--- /dev/null
+++ b/apps/web/tests/unit/components/resource-timeline/series.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildSeries, findPeaks, summarizeUsage } from '../../../../src/components/chat/resource-timeline/series';
+import { buildTimeAxis } from '../../../../src/components/chat/resource-timeline/time-axis';
+import type { ResourceAggregate, ResourceRun } from '../../../../src/components/chat/resource-timeline/types';
+
+const SECOND = 1_000;
+const MINUTE = 60 * SECOND;
+const HOUR = 60 * MINUTE;
+const MB = 1024 ** 2;
+const T0 = Date.UTC(2026, 8, 28, 19, 0, 0);
+
+function aggregate(start: number, end: number, overrides: Partial = {}): ResourceAggregate {
+ return {
+ start,
+ end,
+ cpuMeanCores: 0.1,
+ cpuMaxCores: 0.1,
+ memoryMeanBytes: 1000 * MB,
+ memoryMaxBytes: 1000 * MB,
+ workingSetMeanBytes: null,
+ workingSetMaxBytes: null,
+ ioReadBytes: 0,
+ ioWriteBytes: 0,
+ oomKills: 0,
+ toolCallStarts: 0,
+ exact: false,
+ ...overrides,
+ };
+}
+
+/** Raw 5-second samples of steady load, as a downloaded chunk would give them. */
+function samples(from: number, count: number, cores = 0.1): ResourceAggregate[] {
+ return Array.from({ length: count }, (_, i) =>
+ aggregate(from + i * 5 * SECOND, from + (i + 1) * 5 * SECOND, { cpuMeanCores: cores, cpuMaxCores: cores, exact: true })
+ );
+}
+
+function run(startedAt: number, endedAt: number): ResourceRun {
+ return { id: `run-${startedAt}`, nodeId: null, startedAt, endedAt, unsupportedReason: null };
+}
+
+describe('buildSeries', () => {
+ const hour = buildTimeAxis([run(T0, T0 + HOUR)], 'active');
+
+ it('keeps a single-sample spike visible when many samples share a bucket', () => {
+ const data = samples(T0, 720);
+ data[300] = { ...(data[300] as ResourceAggregate), cpuMeanCores: 3.9, cpuMaxCores: 3.9 };
+
+ const series = buildSeries(data, hour, hour.min, hour.max, 60, 5 * SECOND);
+
+ expect(series.x).toHaveLength(60);
+ const peaks = series.cpuMax.filter((value) => value === 3.9);
+ expect(peaks).toHaveLength(1);
+ const spikeBucket = series.cpuMax.indexOf(3.9);
+ expect(series.cpuMean[spikeBucket]).toBeCloseTo((11 * 0.1 + 3.9) / 12, 6);
+ expect(series.cpuMean[0]).toBeCloseTo(0.1, 6);
+ expect(series.exact.every(Boolean)).toBe(true);
+ });
+
+ it('time-weights coarse aggregates that only partly overlap a bucket', () => {
+ const data = [
+ aggregate(T0, T0 + MINUTE, { cpuMeanCores: 1, cpuMaxCores: 1 }),
+ aggregate(T0 + MINUTE, T0 + 2 * MINUTE, { cpuMeanCores: 3, cpuMaxCores: 5 }),
+ ];
+ const axis = buildTimeAxis([run(T0, T0 + 2 * MINUTE)], 'active');
+
+ // One bucket straddling the boundary: half of each minute.
+ const series = buildSeries(data, axis, axis.min + 30 * SECOND, axis.min + 90 * SECOND, 1, 5 * SECOND);
+
+ expect(series.cpuMean[0]).toBeCloseTo(2, 6);
+ expect(series.cpuMax[0]).toBe(5);
+ expect(series.exact[0]).toBe(false);
+ });
+
+ it('leaves buckets with no data empty so the line breaks across a sleep', () => {
+ const axis = buildTimeAxis([run(T0, T0 + HOUR), run(T0 + 10 * HOUR, T0 + 11 * HOUR)], 'clock');
+ const data = [...samples(T0, 720), ...samples(T0 + 10 * HOUR, 720)];
+
+ const series = buildSeries(data, axis, axis.min, axis.max, 110, 5 * SECOND);
+
+ const asleep = series.t.map((t, i) => ({ t, value: series.cpuMean[i] })).filter(({ t }) => t > T0 + 2 * HOUR && t < T0 + 9 * HOUR);
+ expect(asleep.length).toBeGreaterThan(0);
+ expect(asleep.every(({ value }) => value === null)).toBe(true);
+ expect(series.cpuMean[0]).not.toBeNull();
+ });
+
+ it('reports disk traffic as a rate over the time that has data', () => {
+ const data = [aggregate(T0, T0 + 5 * SECOND, { ioWriteBytes: 5 * MB, ioReadBytes: 10 * MB, exact: true })];
+ const axis = buildTimeAxis([run(T0, T0 + 5 * SECOND)], 'active');
+
+ const series = buildSeries(data, axis, axis.min, axis.max, 1, 5 * SECOND);
+
+ expect(series.ioWriteRate[0]).toBeCloseTo(MB, 3);
+ expect(series.ioReadRate[0]).toBeCloseTo(2 * MB, 3);
+ });
+
+ it('counts an OOM kill once even when its aggregate spans many buckets', () => {
+ const data = [aggregate(T0, T0 + 15 * MINUTE, { oomKills: 1 })];
+ const axis = buildTimeAxis([run(T0, T0 + 15 * MINUTE)], 'active');
+
+ const series = buildSeries(data, axis, axis.min, axis.max, 15, 5 * SECOND);
+
+ expect(series.oomKills.reduce((sum, kills) => sum + kills, 0)).toBe(1);
+ expect(series.cpuMean.every((value) => value === 0.1)).toBe(true);
+ });
+
+ it('never makes buckets finer than the sampling interval', () => {
+ const series = buildSeries(samples(T0, 12), hour, hour.min, hour.min + MINUTE, 400, 5 * SECOND);
+ expect(series.bucketMs).toBe(5 * SECOND);
+ expect(series.x).toHaveLength(12);
+ });
+});
+
+describe('summarizeUsage', () => {
+ it('totals bytes, takes peaks and counts events inside the window only', () => {
+ const data = [
+ aggregate(T0, T0 + MINUTE, { cpuMaxCores: 2, ioWriteBytes: 60 * MB, oomKills: 1, toolCallStarts: 4 }),
+ aggregate(T0 + MINUTE, T0 + 2 * MINUTE, { cpuMaxCores: 4, ioWriteBytes: 60 * MB, oomKills: 1 }),
+ ];
+
+ const firstMinute = summarizeUsage(data, T0, T0 + MINUTE);
+ expect(firstMinute.cpuMaxCores).toBe(2);
+ expect(firstMinute.ioWriteBytes).toBeCloseTo(60 * MB, 0);
+ expect(firstMinute.oomKills).toBe(1);
+ expect(firstMinute.toolCallStarts).toBe(4);
+
+ const halfOfSecond = summarizeUsage(data, T0 + MINUTE, T0 + 90 * SECOND);
+ expect(halfOfSecond.ioWriteBytes).toBeCloseTo(30 * MB, 0);
+ expect(halfOfSecond.oomKills).toBe(0);
+ });
+});
+
+describe('findPeaks', () => {
+ it('returns the highest peaks, at least the separation apart, highest first', () => {
+ const data = [
+ aggregate(T0, T0 + MINUTE, { cpuMaxCores: 3 }),
+ aggregate(T0 + MINUTE, T0 + 2 * MINUTE, { cpuMaxCores: 3.5 }),
+ aggregate(T0 + HOUR, T0 + HOUR + MINUTE, { cpuMaxCores: 2 }),
+ ];
+
+ const peaks = findPeaks(data, 'cpu', 3, 20 * MINUTE);
+
+ expect(peaks.map((peak) => peak.value)).toEqual([3.5, 2]);
+ });
+});
diff --git a/apps/web/tests/unit/components/resource-timeline/time-axis.test.ts b/apps/web/tests/unit/components/resource-timeline/time-axis.test.ts
new file mode 100644
index 0000000000..15509bd6ba
--- /dev/null
+++ b/apps/web/tests/unit/components/resource-timeline/time-axis.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ activeMsInView,
+ axisTicks,
+ buildTimeAxis,
+ sleepAt,
+ sleepBreakWidthMs,
+ toAxis,
+ toReal,
+} from '../../../../src/components/chat/resource-timeline/time-axis';
+import type { ResourceRun } from '../../../../src/components/chat/resource-timeline/types';
+
+const MINUTE = 60_000;
+const HOUR = 60 * MINUTE;
+const T0 = new Date(2026, 8, 28, 19, 0, 0).getTime();
+
+function run(id: string, startedAt: number, endedAt: number): ResourceRun {
+ return { id, nodeId: null, startedAt, endedAt, unsupportedReason: null };
+}
+
+/** An hour of work, a nine-hour night, another hour of work. */
+const NIGHT = [run('a', T0, T0 + HOUR), run('b', T0 + 10 * HOUR, T0 + 11 * HOUR)];
+
+describe('buildTimeAxis', () => {
+ it('maps run time one-to-one and compresses the sleep between runs in active mode', () => {
+ const axis = buildTimeAxis(NIGHT, 'active');
+
+ expect(axis.spans.map((span) => span.kind)).toEqual(['active', 'sleep', 'active']);
+ expect(axis.activeMs).toBe(2 * HOUR);
+ const sleepWidth = (axis.spans[1]?.axisEnd ?? 0) - (axis.spans[1]?.axisStart ?? 0);
+ expect(sleepWidth).toBeGreaterThan(0);
+ expect(sleepWidth).toBeLessThan(9 * HOUR / 10);
+ expect(axis.max - axis.min).toBe(2 * HOUR + sleepWidth);
+ expect(toAxis(axis, T0 + 30 * MINUTE)).toBe(axis.min + 30 * MINUTE);
+ });
+
+ it('keeps real proportions in clock mode', () => {
+ const axis = buildTimeAxis(NIGHT, 'clock');
+
+ expect(axis.max - axis.min).toBe(11 * HOUR);
+ expect(toAxis(axis, T0 + 5 * HOUR)).toBe(axis.min + 5 * HOUR);
+ });
+
+ it.each([
+ ['inside the first run', T0 + 15 * MINUTE],
+ ['inside the compressed sleep', T0 + 5 * HOUR],
+ ['inside the second run', T0 + 10.5 * HOUR],
+ ])('round-trips wall-clock time %s to within a millisecond', (_label, t) => {
+ const axis = buildTimeAxis(NIGHT, 'active');
+ expect(Math.abs(toReal(axis, toAxis(axis, t)) - t)).toBeLessThan(1);
+ });
+
+ it('never draws a short pause wider than it really was', () => {
+ // Ten active hours make the break unit (90 s) wider than this 30 s handoff between runs.
+ const axis = buildTimeAxis([run('a', T0, T0 + 5 * HOUR), run('b', T0 + 5 * HOUR + 30_000, T0 + 10 * HOUR)], 'active');
+ const sleep = axis.spans[1];
+ expect(sleepBreakWidthMs(axis.activeMs, 2 * MINUTE)).toBeGreaterThan(30_000);
+ expect(sleep?.kind).toBe('sleep');
+ expect((sleep?.axisEnd ?? 0) - (sleep?.axisStart ?? 0)).toBe(30_000);
+ });
+
+ it('draws a night wider than a coffee break, both compressed', () => {
+ const activeMs = 10 * HOUR;
+ const night = sleepBreakWidthMs(activeMs, 9 * HOUR);
+ const coffee = sleepBreakWidthMs(activeMs, 20 * MINUTE);
+ expect(night).toBeGreaterThan(coffee);
+ expect(night).toBeLessThan(9 * HOUR);
+ expect(coffee).toBeLessThan(20 * MINUTE);
+ });
+
+ it('merges overlapping runs into one active span', () => {
+ const axis = buildTimeAxis([run('a', T0, T0 + HOUR), run('b', T0 + 30 * MINUTE, T0 + 2 * HOUR)], 'active');
+ expect(axis.spans).toHaveLength(1);
+ expect(axis.activeMs).toBe(2 * HOUR);
+ });
+});
+
+describe('sleepAt and activeMsInView', () => {
+ const axis = buildTimeAxis(NIGHT, 'active');
+
+ it('finds the sleep under an axis position and nothing inside a run', () => {
+ const sleep = axis.spans[1];
+ const middle = ((sleep?.axisStart ?? 0) + (sleep?.axisEnd ?? 0)) / 2;
+ expect(sleepAt(axis, middle)?.realEnd).toBe(T0 + 10 * HOUR);
+ expect(sleepAt(axis, axis.min + 10 * MINUTE)).toBeNull();
+ });
+
+ it('counts only run time inside the view', () => {
+ expect(activeMsInView(axis, axis.min, axis.max)).toBe(2 * HOUR);
+ expect(activeMsInView(axis, axis.min, axis.min + 30 * MINUTE)).toBe(30 * MINUTE);
+ });
+});
+
+describe('axisTicks', () => {
+ it('aligns ticks to round local times and never puts one inside a compressed sleep', () => {
+ const axis = buildTimeAxis(NIGHT, 'active');
+ const ticks = axisTicks(axis, axis.min, axis.max, 15 * MINUTE);
+
+ expect(ticks.length).toBeGreaterThanOrEqual(8);
+ for (const tick of ticks) {
+ const date = new Date(tick.t);
+ expect(date.getMinutes() % 15).toBe(0);
+ expect(date.getSeconds()).toBe(0);
+ expect(tick.t <= T0 + HOUR || tick.t >= T0 + 10 * HOUR).toBe(true);
+ }
+ });
+
+ it('drops a tick that would crowd its neighbour across a break', () => {
+ // Ticks at the end of one run and the start of the next sit a compressed break apart.
+ const axis = buildTimeAxis([run('a', T0, T0 + HOUR), run('b', T0 + 3 * HOUR, T0 + 4 * HOUR)], 'active');
+ const ticks = axisTicks(axis, axis.min, axis.max, HOUR);
+ const gaps = ticks.slice(1).map((tick, i) => tick.x - (ticks[i]?.x ?? 0));
+
+ expect(ticks.map((tick) => tick.t)).not.toContain(T0 + 3 * HOUR);
+ expect(Math.min(...gaps)).toBeGreaterThanOrEqual(0.75 * HOUR);
+ });
+});
diff --git a/apps/web/tests/unit/components/resource-timeline/timeline-view.test.ts b/apps/web/tests/unit/components/resource-timeline/timeline-view.test.ts
new file mode 100644
index 0000000000..d4517e0061
--- /dev/null
+++ b/apps/web/tests/unit/components/resource-timeline/timeline-view.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildTimeAxis, toReal } from '../../../../src/components/chat/resource-timeline/time-axis';
+import { clampRange, MIN_VIEW_SPAN_MS, resolveView } from '../../../../src/components/chat/resource-timeline/timeline-view';
+import type { ResourceRun } from '../../../../src/components/chat/resource-timeline/types';
+
+const MINUTE = 60_000;
+const HOUR = 60 * MINUTE;
+const T0 = Date.UTC(2026, 8, 28, 19, 0, 0);
+
+function run(startedAt: number, endedAt: number): ResourceRun {
+ return { id: `run-${startedAt}`, nodeId: null, startedAt, endedAt, unsupportedReason: null };
+}
+
+const RUNS = [run(T0, T0 + HOUR), run(T0 + 10 * HOUR, T0 + 11 * HOUR)];
+
+describe('resolveView', () => {
+ const axis = buildTimeAxis(RUNS, 'active');
+
+ it('shows the whole axis for "all"', () => {
+ expect(resolveView({ kind: 'all' }, axis)).toEqual({ min: axis.min, max: axis.max });
+ });
+
+ it('pins "latest" to the end of the session, so it follows new data', () => {
+ const view = resolveView({ kind: 'latest', spanMs: 15 * MINUTE }, axis);
+ expect(view).toEqual({ min: axis.max - 15 * MINUTE, max: axis.max });
+
+ const grown = buildTimeAxis([...RUNS.slice(0, 1), run(T0 + 10 * HOUR, T0 + 12 * HOUR)], 'active');
+ expect(resolveView({ kind: 'latest', spanMs: 15 * MINUTE }, grown).max).toBe(grown.max);
+ });
+
+ it('keeps a chosen range on the same wall-clock times when the axis mode changes', () => {
+ const intent = { kind: 'range' as const, from: T0 + 10 * HOUR + 5 * MINUTE, to: T0 + 10 * HOUR + 20 * MINUTE };
+ for (const mode of ['active', 'clock'] as const) {
+ const modeAxis = buildTimeAxis(RUNS, mode);
+ const view = resolveView(intent, modeAxis);
+ expect(toReal(modeAxis, view.min)).toBeCloseTo(intent.from, 3);
+ expect(toReal(modeAxis, view.max)).toBeCloseTo(intent.to, 3);
+ }
+ });
+});
+
+describe('clampRange', () => {
+ const axis = buildTimeAxis(RUNS, 'active');
+
+ it('slides a window that runs past the end back inside without changing its width', () => {
+ expect(clampRange(axis.max - 10 * MINUTE, axis.max + 20 * MINUTE, axis)).toEqual({
+ min: axis.max - 30 * MINUTE,
+ max: axis.max,
+ });
+ });
+
+ it('never zooms in past the minimum span', () => {
+ const view = clampRange(axis.min + MINUTE, axis.min + MINUTE + 1_000, axis);
+ expect(view.max - view.min).toBe(MIN_VIEW_SPAN_MS);
+ });
+
+ it('never zooms out past the whole session', () => {
+ expect(clampRange(axis.min - HOUR, axis.max + HOUR, axis)).toEqual({ min: axis.min, max: axis.max });
+ });
+});
From 267f4622ef0d0af0f6f72952b82157f7d360b9fa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?=
Date: Tue, 29 Sep 2026 07:42:51 +0000
Subject: [PATCH 04/22] test(web): drive the new resource drawer from the real
chat rail
- the session tool-rail audit now opens Resources from the real rail
button against today's /resource-history contract and asserts the
whole-session readout, the OOM call-out, no chunk controls, and a
keyboard-driven cursor reading
- mock samples carry the collector's 5 s interval, and every chunk
the view needs returns detail
- adapter: when the server thinned a chunk, each kept sample stands
for the time since the previous one (never across a sampler gap) and
is no longer reported as an exact 5 s measurement
Co-Authored-By: Claude Opus 5.5
---
.../chat/resource-timeline/resource-source.ts | 19 +-
.../session-tool-rail-audit.spec.ts | 221 +++++-------------
.../resource-timeline/resource-source.test.ts | 31 +++
3 files changed, 101 insertions(+), 170 deletions(-)
diff --git a/apps/web/src/components/chat/resource-timeline/resource-source.ts b/apps/web/src/components/chat/resource-timeline/resource-source.ts
index 1e0442a081..71e852f5b9 100644
--- a/apps/web/src/components/chat/resource-timeline/resource-source.ts
+++ b/apps/web/src/components/chat/resource-timeline/resource-source.ts
@@ -195,14 +195,31 @@ export function indexFromApi(response: WorkspaceResourceHistoryResponse): Resour
};
}
+/**
+ * When the server thinned a chunk (`downsampled`), the kept samples are far apart
+ * but each still spans only its own 5-second interval. Stretch each back to the
+ * previous kept sample so the line does not break between them — except across a
+ * sampler gap, which must stay visible.
+ */
+function coverThinnedSamples(samples: ResourceAggregate[], raw: readonly WorkspaceResourceSample[]): ResourceAggregate[] {
+ return samples.map((sample, i) => {
+ const previous = samples[i - 1];
+ if (!previous || raw[i]?.gap || previous.end >= sample.start) return sample;
+ // One kept sample now stands for a wider window: a reading, not a full-resolution measurement of it.
+ return { ...sample, start: previous.end, exact: false };
+ });
+}
+
export function chunkDetailFromApi(
chunkId: string,
detail: NonNullable
): ResourceChunkDetail {
const toolSpans = detail.toolSpans.map(toolSpanFromApi);
+ const raw = [...detail.samples].sort((a, b) => a.t - b.t);
+ const samples = raw.map(sampleToAggregate);
return {
chunkId,
- samples: withToolStarts(detail.samples.map(sampleToAggregate), toolSpans),
+ samples: withToolStarts(detail.downsampled ? coverThinnedSamples(samples, raw) : samples, toolSpans),
toolSpans,
samplerGaps: detail.gaps
.map((gap) => ({ start: numberField(gap, 'startedAt'), end: numberField(gap, 'endedAt') }))
diff --git a/apps/web/tests/playwright/session-tool-rail-audit.spec.ts b/apps/web/tests/playwright/session-tool-rail-audit.spec.ts
index e2b5f58128..b5ae38e31d 100644
--- a/apps/web/tests/playwright/session-tool-rail-audit.spec.ts
+++ b/apps/web/tests/playwright/session-tool-rail-audit.spec.ts
@@ -123,8 +123,6 @@ const RESOURCE_HISTORY_SUMMARY = {
memoryMeanBytes: 524_288_000,
memoryPeakBytes: 1_342_177_280,
memoryKernelPeakBytes: 1_610_612_736,
- memoryWorkingSetMeanBytes: 346_030_080,
- memoryWorkingSetPeakBytes: 671_088_640,
ioReadBytes: 18_874_368,
ioWriteBytes: 94_371_840,
oomCount: 1,
@@ -192,11 +190,10 @@ const RESOURCE_HISTORY_DETAIL = {
downsampleLimit: 720,
samples: Array.from({ length: 36 }, (_, i) => ({
t: NOW - 1_800_000 + i * 25_000,
+ intervalMillis: 5_000,
cpuMillis: resourceHistoryCpuMillis(i),
memoryBytes: i === 24 ? 1_342_177_280 : 410_000_000 + i * 12_000_000,
memoryPeakBytes: i >= 24 ? 1_342_177_280 : 610_000_000 + i * 8_000_000,
- memoryWorkingSetBytes:
- i === 12 ? undefined : i === 24 ? 671_088_640 : 275_000_000 + i * 5_000_000,
ioReadBytes: i % 8 === 0 ? 1_048_576 : 16_384,
ioWriteBytes: i % 9 === 0 ? 4_194_304 : 65_536,
oom: i === 24 ? 1 : 0,
@@ -205,46 +202,54 @@ const RESOURCE_HISTORY_DETAIL = {
toolSpans: [
{
id: 'tool-compile',
- kind: 'execute',
- toolName: 'Bash',
+ kind: 'acp_tool_call',
startedAt: NOW - 1_650_000,
endedAt: NOW - 1_520_000,
concurrency: 1,
},
{
id: 'tool-tests',
- kind: 'search',
+ kind: 'acp_tool_call',
startedAt: NOW - 1_470_000,
endedAt: NOW - 1_240_000,
concurrency: 2,
},
{
id: 'tool-review',
+ kind: 'acp_tool_call',
startedAt: NOW - 1_210_000,
endedAt: NOW - 980_000,
concurrency: 1,
approximate: true,
},
- {
- id: 'tool-long-name',
- kind: 'other',
- toolName: `mcp__sam-mcp__${'dispatch_task_with_a_deliberately_long_name_'.repeat(6)}`,
- startedAt: NOW - 930_000,
- endedAt: NOW - 900_000,
- concurrency: 1,
- },
- {
- id: 'tool-special-name',
- kind: 'fetch',
- toolName: 'fetch & "quotes" 🔥',
- startedAt: NOW - 880_000,
- endedAt: NOW - 850_000,
- concurrency: 1,
- },
],
gaps: [{ startedAt: NOW - 1_300_000, endedAt: NOW - 1_250_000, reason: 'sampler_delay' }],
};
+/** The older, quieter chunk: the timeline loads every chunk its view needs, not only the newest. */
+const RESOURCE_HISTORY_QUIET_DETAIL = {
+ chunkId: 'wrchunk-rail-1',
+ originalSampleCount: 180,
+ downsampled: false,
+ downsampleLimit: 720,
+ samples: Array.from({ length: 180 }, (_, i) => ({
+ t: NOW - 2_700_000 + (i + 1) * 5_000,
+ intervalMillis: 5_000,
+ cpuMillis: 20 + (i % 3) * 5,
+ memoryBytes: 400_000_000,
+ memoryPeakBytes: 420_000_000,
+ ioReadBytes: 4_096,
+ ioWriteBytes: 8_192,
+ })),
+ toolSpans: [],
+ gaps: [],
+};
+
+const RESOURCE_HISTORY_DETAILS: Record = {
+ 'wrchunk-rail-2': RESOURCE_HISTORY_DETAIL,
+ 'wrchunk-rail-1': RESOURCE_HISTORY_QUIET_DETAIL,
+};
+
/** Enough messages to overflow any test viewport, so the scroll-to-bottom button appears. */
function makeManyMessages(count = 60) {
return {
@@ -311,8 +316,6 @@ interface MockOptions extends SessionOptions {
empty?: boolean;
/** Seeds a long conversation so the scroll-to-bottom button can actually appear. */
manyMessages?: boolean;
- legacyResourceHistory?: boolean;
- resourceHistoryScenario?: 'normal' | 'empty' | 'error' | 'many';
}
async function setupMocks(page: Page, options: MockOptions = {}) {
@@ -322,8 +325,6 @@ async function setupMocks(page: Page, options: MockOptions = {}) {
messagesLong = false,
empty = false,
manyMessages = false,
- legacyResourceHistory = false,
- resourceHistoryScenario = 'normal',
} = options;
await page.addInitScript(
@@ -376,47 +377,12 @@ async function setupMocks(page: Page, options: MockOptions = {}) {
}
if (pathname === `/api/projects/${PROJECT_ID}/sessions/${SESSION_ID}/resource-history`) {
- if (resourceHistoryScenario === 'error') {
- await route.fulfill({ status: 500, json: { error: 'resource_history_unavailable' } });
- return;
- }
- if (resourceHistoryScenario === 'empty') {
- await route.fulfill({ json: { summary: null, chunks: [] } });
- return;
- }
- const includeDetail = new URL(url).searchParams.get('chunkId') === 'wrchunk-rail-2';
- const chunks =
- resourceHistoryScenario === 'many'
- ? Array.from({ length: 30 }, (_, index) => ({
- ...RESOURCE_HISTORY_CHUNKS[0],
- id: `wrchunk-many-${index}`,
- chunkSequence: 30 - index,
- startedAt: NOW - (index + 2) * 900_000,
- endedAt: NOW - (index + 1) * 900_000,
- }))
- : RESOURCE_HISTORY_CHUNKS;
+ const detail = RESOURCE_HISTORY_DETAILS[new URL(url).searchParams.get('chunkId') ?? ''];
await route.fulfill({
json: {
- summary: legacyResourceHistory
- ? {
- ...RESOURCE_HISTORY_SUMMARY,
- memoryWorkingSetMeanBytes: null,
- memoryWorkingSetPeakBytes: null,
- }
- : RESOURCE_HISTORY_SUMMARY,
- chunks,
- ...(includeDetail
- ? {
- detail: legacyResourceHistory
- ? {
- ...RESOURCE_HISTORY_DETAIL,
- samples: RESOURCE_HISTORY_DETAIL.samples.map(
- ({ memoryWorkingSetBytes: _memoryWorkingSetBytes, ...sample }) => sample
- ),
- }
- : RESOURCE_HISTORY_DETAIL,
- }
- : {}),
+ summary: RESOURCE_HISTORY_SUMMARY,
+ chunks: RESOURCE_HISTORY_CHUNKS,
+ ...(detail ? { detail } : {}),
},
});
return;
@@ -489,7 +455,9 @@ async function setupMocks(page: Page, options: MockOptions = {}) {
await route.fulfill({ json: { email: false, push: false } });
return;
}
- if (pathname === `/api/projects/${PROJECT_ID}/credential-attribution-health`) {
+ if (
+ pathname === `/api/projects/${PROJECT_ID}/credential-attribution-health`
+ ) {
await route.fulfill({ json: { healthy: true } });
return;
}
@@ -1094,7 +1062,7 @@ test.describe('Session Details — desktop', () => {
});
test.describe('Session resource history drawer', () => {
- test('opens contextual summary and auto-loads detail from the real rail action', async ({
+ test('opens the whole session from the real rail action and reads values under the cursor', async ({
page,
}) => {
await openChat(page, { state: 'active', messagesLong: true });
@@ -1104,114 +1072,29 @@ test.describe('Session resource history drawer', () => {
await expect(dialog).toBeVisible();
await expect(page.getByRole('heading', { name: 'Resources' })).toBeVisible();
- // Stat cards — use exact match to avoid ambiguity with chart legend text
- await expect(page.getByText('CPU peak', { exact: true })).toBeVisible();
- await expect(page.getByText('Memory needed (peak)', { exact: true })).toBeVisible();
- await expect(page.getByText('640 MB', { exact: true })).toBeVisible();
- await expect(page.getByText('Total RAM (incl. cache)', { exact: true })).toBeVisible();
- await expect(page.getByText('1 OOM event observed in retained samples.')).toBeVisible();
-
- // Chart auto-loads via useEffect selecting newest chunk — wait for it
- await expect(page.getByRole('img', { name: 'CPU and memory resource timeline' })).toBeVisible({
- timeout: 10_000,
- });
- await expect(
- page.getByText('CPU: green solid line, normalized to the CPU peak for this chunk.')
- ).toBeVisible();
- await expect(
- page.getByText('Memory needed: purple solid line (working set, when reported).')
- ).toBeVisible();
- await expect(
- page.getByText('Total RAM: faint purple dashed line, including reclaimable file cache.')
- ).toBeVisible();
- await expect(
- page
- .getByRole('img', { name: 'CPU and memory resource timeline' })
- .locator('polyline[data-series="working-set"]')
- ).toHaveCount(2);
- await expect(page.getByText(/Blue bands: concurrent tool windows/)).toBeVisible();
- await expect(page.getByText('Tool windows', { exact: true })).toBeVisible();
- await expect(page.getByText(/Bash ·/)).toBeVisible();
- await expect(page.getByText(/search ·/)).toBeVisible();
- await expect(page.getByText(/tool ·/)).toBeVisible();
- await expect(
- page.getByText(/fetch & "quotes" 🔥 ·/)
- ).toBeVisible();
- await expect(dialog.locator('img[src="x"]')).toHaveCount(0);
- await expect(dialog.locator('[title^="mcp__sam-mcp__"]')).toHaveCount(1);
- const toolWindows = page.getByText('Tool windows', { exact: true }).locator('..');
- expect(
- await toolWindows.evaluate((element) => element.scrollWidth <= element.clientWidth)
- ).toBe(true);
-
- // Correlation disclaimer is contextual — only visible after chart loads
- await expect(page.getByText('Correlation is based on concurrent tool windows')).toBeVisible();
+ // The whole session is on screen, and the OOM kill recorded in the detail samples is called out.
+ await expect(dialog.getByText('Whole session', { exact: true })).toBeVisible({ timeout: 10_000 });
+ await expect(dialog.getByText('1 out-of-memory kill')).toBeVisible({ timeout: 10_000 });
+ const timeline = dialog.getByRole('slider', { name: /Session timeline/ });
+ await expect(timeline).toBeVisible();
+ await expect(dialog.getByText('Busiest moments', { exact: true })).toBeVisible();
+ // Chunks are a storage detail: nothing in the drawer offers them.
+ await expect(dialog.getByRole('button', { name: /chunk/i })).toHaveCount(0);
await capture(page, `resource-history-summary-${page.viewportSize()?.width ?? 'viewport'}`);
- // Scroll to bottom for detail screenshot
- await dialog.locator('.overflow-y-auto').evaluate((el) => {
- el.scrollTop = el.scrollHeight;
- });
- await capture(page, `resource-history-detail-${page.viewportSize()?.width ?? 'viewport'}`);
+ // Keyboard reaches the same readout a finger or pointer does.
+ await timeline.focus();
+ await page.keyboard.press('ArrowRight');
+ await expect(dialog.getByRole('button', { name: 'Clear the selected instant' })).toBeVisible();
+ await expect(timeline).toHaveAttribute('aria-valuetext', /CPU \d+(\.\d+)? cores?/);
+ await expect(timeline).toHaveAttribute('aria-valuetext', /Memory \d/);
- // Chunks disclosure — collapsed by default, toggle to expand
- const chunksToggle = page.getByRole('button', { name: /chunk/ });
- await expect(chunksToggle).toBeVisible();
- await expect(chunksToggle).toHaveAttribute('aria-expanded', 'false');
- await chunksToggle.click();
- await expect(chunksToggle).toHaveAttribute('aria-expanded', 'true');
- // Verify chunk buttons are visible after expanding
- await expect(page.getByRole('button', { name: /samples/ }).first()).toBeVisible();
+ await capture(page, `resource-history-cursor-${page.viewportSize()?.width ?? 'viewport'}`);
await dialog.locator('.overflow-y-auto').evaluate((el) => {
el.scrollTop = el.scrollHeight;
});
- await capture(page, `resource-history-chunks-${page.viewportSize()?.width ?? 'viewport'}`);
- });
-
- test('shows unknown working set for history from an older VM agent', async ({ page }) => {
- await openChat(page, { state: 'active', legacyResourceHistory: true });
- await page.getByTestId('session-tool-resources').click();
-
- for (const label of ['Memory needed (peak)', 'Memory needed (mean)']) {
- const neededCard = page.getByText(label, { exact: true }).locator('..');
- await expect(neededCard).toContainText('—');
- await expect(neededCard).not.toContainText('0 B');
- }
- await expect(
- page
- .getByRole('img', { name: 'CPU and memory resource timeline' })
- .locator('polyline[data-series="working-set"]')
- ).toHaveCount(0);
- await capture(page, `resource-history-legacy-${page.viewportSize()?.width ?? 'viewport'}`);
- });
-
- test('shows the empty resource-history state', async ({ page }) => {
- await openChat(page, { state: 'active', resourceHistoryScenario: 'empty' });
- await page.getByTestId('session-tool-resources').click();
-
- await expect(
- page.getByText('No retained resource history is available for this session yet.')
- ).toBeVisible();
- await capture(page, `resource-history-empty-${page.viewportSize()?.width ?? 'viewport'}`);
- });
-
- test('shows the resource-history error state', async ({ page }) => {
- await openChat(page, { state: 'active', resourceHistoryScenario: 'error' });
- await page.getByTestId('session-tool-resources').click();
-
- await expect(page.getByText('Resource history could not be loaded.')).toBeVisible();
- await capture(page, `resource-history-error-${page.viewportSize()?.width ?? 'viewport'}`);
- });
-
- test('keeps a long chunk list usable', async ({ page }) => {
- await openChat(page, { state: 'active', resourceHistoryScenario: 'many' });
- await page.getByTestId('session-tool-resources').click();
-
- const chunksToggle = page.getByRole('button', { name: '30 chunks' });
- await chunksToggle.click();
- await expect(page.getByRole('button', { name: /samples/ })).toHaveCount(30);
- await capture(page, `resource-history-many-${page.viewportSize()?.width ?? 'viewport'}`);
+ await capture(page, `resource-history-detail-${page.viewportSize()?.width ?? 'viewport'}`);
});
});
diff --git a/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts b/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts
index 70e49aafea..c797e8ef09 100644
--- a/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts
+++ b/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts
@@ -152,3 +152,34 @@ describe('chunkDetailFromApi', () => {
expect(detail.samplerGaps).toEqual([{ start: T0, end: T0 + 40_000 }]);
});
});
+
+describe('chunkDetailFromApi with server-thinned samples', () => {
+ const detail = (downsampled: boolean) =>
+ chunkDetailFromApi('c1', {
+ chunkId: 'c1',
+ samples: [
+ { t: T0 + 5_000, intervalMillis: 5000, cpuMillis: 5000 },
+ { t: T0 + 30_000, intervalMillis: 5000, cpuMillis: 5000 },
+ { t: T0 + 90_000, intervalMillis: 5000, cpuMillis: 5000, gap: true },
+ ],
+ toolSpans: [],
+ gaps: [],
+ originalSampleCount: 18,
+ downsampled,
+ downsampleLimit: 3,
+ });
+
+ it('stretches each kept sample back to the previous one, but never across a sampler gap', () => {
+ const samples = detail(true).samples;
+ expect(samples.map((sample) => [sample.start - T0, sample.end - T0])).toEqual([
+ [0, 5_000],
+ [5_000, 30_000],
+ [85_000, 90_000],
+ ]);
+ expect(samples.map((sample) => sample.exact)).toEqual([true, false, true]);
+ });
+
+ it('leaves full-resolution samples on their own intervals', () => {
+ expect(detail(false).samples.map((sample) => sample.start - T0)).toEqual([0, 25_000, 85_000]);
+ });
+});
From 6e50164d988419d8fb7698cc16c62a2fbd76a73a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?=
Date: Tue, 29 Sep 2026 08:00:27 +0000
Subject: [PATCH 05/22] task: start whole-session resource timeline
Co-Authored-By: Claude Opus 5.5
---
...6-09-29-whole-session-resource-timeline.md | 78 +++++++++++++++++++
1 file changed, 78 insertions(+)
create mode 100644 tasks/active/2026-09-29-whole-session-resource-timeline.md
diff --git a/tasks/active/2026-09-29-whole-session-resource-timeline.md b/tasks/active/2026-09-29-whole-session-resource-timeline.md
new file mode 100644
index 0000000000..6f77bf3993
--- /dev/null
+++ b/tasks/active/2026-09-29-whole-session-resource-timeline.md
@@ -0,0 +1,78 @@
+# Whole-session resource timeline (ship the prototype)
+
+## Problem
+
+The session Resources drawer shows one 15-minute storage chunk at a time and gives no information on hover or tap. Raphaël said it left him "a bit misled": he did not realise he was looking at a thin slice of time. He approved the whole-session timeline prototype for production ("let's get this ui into prod", 2026-09-29).
+
+Idea: `01M3P13H0W6EG1FS47PCG0N2EG`. Prototype branch: `sam/looks-resources-details-timeline-51112h` (commits af95d2969..75420e910).
+
+## Research findings (verified on main af1544127/c17508d51 and prod, 2026-09-29)
+
+1. **Only the newest chunk is drawn.** `SessionResourceHistoryDrawer` auto-loads the newest chunk. Chunks are 15 minutes (`collector.go` `DefaultChunkInterval`) of 5 s samples.
+2. **Older history is unreachable.** `getWorkspaceResourceHistory` (`services/workspace-resource-history.ts`) lists only the newest 24 chunks (`DEFAULT_LIST_LIMIT`) with no disclosure. Prod session 449d73f8 ran about 27 h; its first ~20 h cannot be reached.
+3. **Stat cards describe the last wake only.** The summary query returns the newest `(workspace, session)` row, and each wake is a new workspace. 449d73f8 spans 8 workspaces on 3 nodes and reports 258 samples.
+4. **Lines have no absolute scale.** The sparkline normalises each line to its chunk peak, and CPU is shown as "ms/sample".
+5. **The upload path already decodes each chunk** (`validateWorkspaceResourceChunkBytes` → `readBoundedGzipJson`). A per-minute rollup can be computed there at negligible CPU and stored per chunk, so the whole session draws from D1 alone.
+6. **Workspace reservations exist** in `workspaces.resolved_reservation_json`. The parser is `parseStoredResolvedReservationJson` (`services/resource-requirements-input.ts`); it throws on malformed rows, so wrap it per row.
+7. **Instant sessions collect nothing yet** (idea 01M3NV89R0CKFNMX6AQSK9FY5Q). `nodes.runtime = 'cf-container'` identifies them, and the empty state must say so rather than promise data in 15 minutes.
+8. **Sibling tasks change the data** (coordinated by durable messages 01M3P2NE…, 01M3P2NJ…):
+ - Tool labels (task 01M3NV760JFXQD4Y53VCW5TTC5): spans carry `kind` (ACP kind) and `toolName`.
+ - Working set (task 01M3NV7MBCSASWHAT0W0JPJ3NT, migration 0176): sample `memoryWorkingSetBytes`; summary `memoryWorkingSetMeanBytes` / `memoryWorkingSetPeakBytes`.
+ - Attribution (PR #2181, migration 0175).
+ - All three touch the old drawer, `workspace-resource-history.ts` and the tool-rail audit spec. Keep shared-file edits small and put new logic in new modules.
+9. **Library: uPlot 1.6.32** (MIT, ~23 KB gz). ECharts 6 is the runner-up. Highcharts and AG Charts Enterprise are rejected on licence (SAM is AGPL). Recharts is SVG without zoom. No library caches fetched data, so level-of-detail loading is app code in every option.
+10. **Bundle.** The drawer is statically imported into the project-chat route, so the timeline (and uPlot) must load through a dynamic import. Measured: uPlot ends up only in the lazy `ResourceTimeline` chunk (36 KB gz).
+11. **Rules that apply.**
+ - Prototype route and mock data must be removed before merge (rule 37).
+ - Request budget: index ≤ 8 round-trips (rule 60).
+ - Scope predicates are tested on a real SQL engine with an attack case plus an owner control (rules 11, 28).
+ - Per-row fault isolation (rule 50).
+ - Additive migration only (rule 31).
+ - Capped selection must disclose what it dropped (rule 65).
+ - Every limit is configurable (constitution XI).
+
+## Implementation checklist
+
+### Backend
+- [ ] Migration `0177_workspace_resource_chunk_rollups.sql`: `ALTER TABLE workspace_resource_chunks ADD COLUMN rollup_json TEXT` (+ schema.ts)
+- [ ] `services/workspace-resource-rollup.ts`: pure per-minute rollup from a decoded chunk payload. It holds CPU mean/max in cores, memory mean/max, working set mean/max, disk bytes, OOM kills, tool-call starts and sample count. Bucket count is bounded and configurable, and the result is compact and columnar.
+- [ ] Upload hook: compute and store `rollup_json` in `storeWorkspaceResourceChunk`. A rollup failure must never fail the upload.
+- [ ] `services/workspace-resource-timeline.ts`: the session index lists every chunk for `(project, session)`, newest first, capped by `WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS` with `omittedChunkCount` disclosure. It groups chunks into runs with each workspace's reservation, and tolerates a malformed row. With no chunks, it reports whether the session's runtime collects at all (cf-container → unsupported).
+- [ ] Chunk read by id scoped to project + session, reusing `readChunkPayload`.
+- [ ] Routes `GET /api/projects/:id/sessions/:sessionId/resource-timeline` and `.../resource-timeline/chunks/:chunkId`, gated by project access.
+- [ ] Env var documented: `.env.example`, env.ts, env-reference skill, configuration.md.
+
+### Web
+- [ ] API client + types for the two endpoints; the API source adapter reads the index (rollups → overview, summary fallback for older chunks) and the chunk endpoint
+- [ ] Per-run reservation lines
+- [ ] Empty state distinguishes "not uploaded yet" from "not recorded for Instant sessions"
+- [ ] ToolKind covers the full ACP set (read, edit, delete, move, search, execute, think, fetch, switch_mode, other)
+- [ ] Working-set fields read by the sibling's names (`memoryWorkingSetBytes`, `memoryWorkingSetMeanBytes`, `memoryWorkingSetPeakBytes`)
+- [ ] Remove the prototype route, page, mock data and the DEV_ONLY entry
+- [ ] Remove dead code: the old-API index adapter, and `getSessionResourceHistory` if unused
+- [ ] Playwright visual audit with stress data (27 h multi-wake, empty, Instant, older agent) at 375 and 1280, plus the rail audit
+
+### Docs
+- [ ] `apps/www/.../guides/session-resources.md` rewritten for the timeline
+- [ ] `reference/api.md` lists the new endpoints
+
+### Tests
+- [ ] Rollup unit tests (buckets, gaps, working set present and absent, tool starts, bounded bucket count)
+- [ ] Upload stores `rollup_json` (real SQL engine + fake R2)
+- [ ] Index on a real SQL engine: all chunks across workspaces; a foreign project/session is excluded (attack) while the owner is served (control); cap + disclosure; malformed row tolerated; reservation join; Instant detection
+- [ ] Chunk read: a foreign session's chunk id → 404 (attack) + owner control
+- [ ] Routes through the Hono app with project-access gating
+- [ ] Web data-layer tests updated for the new adapter
+
+## Acceptance criteria
+- [ ] Opening Resources on a multi-wake session shows the whole session, with no chunk UI and no silent cap (disclosed only past the configured cap)
+- [ ] Hover, tap or keyboard shows exact values and says whether each is a 5 s sample or an average
+- [ ] Zoom (pinch, drag, chips, busiest moments) loads 5 s detail for the visible window only; chunks are fetched once
+- [ ] Reservation lines and OOM kills are visible; Instant sessions explain that no data is recorded
+- [ ] Newly uploaded chunks carry a per-minute rollup; older chunks still render from their summary
+- [ ] uPlot is not in the initial or chat-route bundle
+- [ ] Staging: a real VM session shows its resource timeline end to end, including a chunk uploaded after deploy with a rollup
+
+## References
+- `.claude/rules/37` (prototypes), `60` (request budgets), `11` and `28` (scope tests), `50` (row isolation), `31` (migrations), `65` (capped selection), `62` (real trigger), `17` and `56` (visual audit)
+- Idea 01KZP2972NHBXVRM4695E3DCB3 (telemetry), 01M3P13H0W6EG1FS47PCG0N2EG (this work)
From 0e2c69df49f02f45230f3ed0e12a2a46248ed58f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?=
Date: Tue, 29 Sep 2026 09:10:01 +0000
Subject: [PATCH 06/22] feat(api): add resource chunk rollup column and
timeline env knobs
Co-Authored-By: Claude Opus 5.5
---
apps/api/.env.example | 3 +++
.../migrations/0177_workspace_resource_chunk_rollups.sql | 8 ++++++++
apps/api/src/db/schema.ts | 2 ++
apps/api/src/env.ts | 3 +++
4 files changed, 16 insertions(+)
create mode 100644 apps/api/src/db/migrations/0177_workspace_resource_chunk_rollups.sql
diff --git a/apps/api/.env.example b/apps/api/.env.example
index adfed65e90..c52125b4c9 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -1026,6 +1026,9 @@ INFOMANIAK_IP_POLL_INTERVAL_MS=3000
# WORKSPACE_RESOURCE_LIST_LIMIT=24
# WORKSPACE_RESOURCE_CLEANUP_BATCH_SIZE=50
# WORKSPACE_RESOURCE_OBJECT_CLEANUP_LIMIT=5000
+# WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS=1000
+# WORKSPACE_RESOURCE_ROLLUP_BUCKET_MS=60000
+# WORKSPACE_RESOURCE_ROLLUP_MAX_BUCKETS=60
# PROJECT_DATA_ARCHIVE_GLOBAL_SWEEP_ENABLED=false # Separate fail-closed switch for the unscoped scheduled archive-sharding sweep
# PROJECT_DATA_ARCHIVE_GLOBAL_SWEEP_INTERVAL_MS=86400000 # Persisted cadence gate for unscoped scheduled archive-sharding sweeps (code fallback daily; wrangler.toml ships 1080000: one claim per 20 min on the 5-min cron)
# PROJECT_DATA_ARCHIVE_SHARD_COUNT=128 # Deterministic ProjectData archive shard fanout
diff --git a/apps/api/src/db/migrations/0177_workspace_resource_chunk_rollups.sql b/apps/api/src/db/migrations/0177_workspace_resource_chunk_rollups.sql
new file mode 100644
index 0000000000..17c57b6078
--- /dev/null
+++ b/apps/api/src/db/migrations/0177_workspace_resource_chunk_rollups.sql
@@ -0,0 +1,8 @@
+-- Per-minute resource rollup for each stored chunk, computed on upload from the
+-- decoded chunk payload. It lets the session resource timeline draw a whole
+-- multi-day session from D1 alone, without downloading every R2 chunk.
+--
+-- Existing rows stay NULL. They remain fully readable: the timeline falls back
+-- to the chunk's own summary_json (one aggregate spanning the chunk), and the
+-- full-resolution detail is still read from R2 on zoom.
+ALTER TABLE workspace_resource_chunks ADD COLUMN rollup_json TEXT;
diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts
index d470b26bae..604bf9076c 100644
--- a/apps/api/src/db/schema.ts
+++ b/apps/api/src/db/schema.ts
@@ -1647,6 +1647,8 @@ export const workspaceResourceChunks = sqliteTable(
toolSpanCount: integer('tool_span_count').notNull().default(0),
completenessJson: text('completeness_json').notNull(),
summaryJson: text('summary_json').notNull(),
+ /** Per-minute rollup computed on upload; NULL for chunks uploaded before migration 0177. */
+ rollupJson: text('rollup_json'),
createdAt: integer('created_at').notNull(),
expiresAt: integer('expires_at').notNull(),
uploadedByNodeId: text('uploaded_by_node_id'),
diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts
index 5121d6e5f9..1176d69a94 100644
--- a/apps/api/src/env.ts
+++ b/apps/api/src/env.ts
@@ -643,6 +643,9 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv {
WORKSPACE_RESOURCE_LIST_LIMIT?: string; // Max chunk indexes returned by detail list (default: 24)
WORKSPACE_RESOURCE_CLEANUP_BATCH_SIZE?: string; // Max expired chunks/summaries cleaned per sweep (default: 50)
WORKSPACE_RESOURCE_OBJECT_CLEANUP_LIMIT?: string; // Max R2 objects deleted for one project/workspace resource-history prefix cleanup (default: 5000)
+ WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS?: string; // Max chunks listed by the whole-session resource timeline index; older ones are disclosed as omitted (default: 1000)
+ WORKSPACE_RESOURCE_ROLLUP_BUCKET_MS?: string; // Width of each per-chunk rollup bucket computed on upload (default: 60000)
+ WORKSPACE_RESOURCE_ROLLUP_MAX_BUCKETS?: string; // Max rollup buckets stored per chunk; the bucket width widens to fit (default: 60)
PROJECT_DATA_STORAGE_TELEMETRY_LIST_LIMIT_DEFAULT?: string;
PROJECT_DATA_STORAGE_TELEMETRY_LIST_LIMIT_MAX?: string;
PROJECT_DATA_TOOL_PAYLOAD_CLEANUP_ENABLED?: string;
From 5dae57db794b4afd5ca6b3351ae4001702fba0cf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?=
Date: Tue, 29 Sep 2026 09:14:39 +0000
Subject: [PATCH 07/22] feat(api): whole-session resource timeline index,
per-minute chunk rollups
Co-Authored-By: Claude Opus 5.5
---
.../projects/workspace-resource-history.ts | 23 ++
.../services/workspace-resource-history.ts | 33 ++-
.../src/services/workspace-resource-rollup.ts | 244 ++++++++++++++++
.../services/workspace-resource-timeline.ts | 273 ++++++++++++++++++
4 files changed, 571 insertions(+), 2 deletions(-)
create mode 100644 apps/api/src/services/workspace-resource-rollup.ts
create mode 100644 apps/api/src/services/workspace-resource-timeline.ts
diff --git a/apps/api/src/routes/projects/workspace-resource-history.ts b/apps/api/src/routes/projects/workspace-resource-history.ts
index 38e4dff61e..d300aeb240 100644
--- a/apps/api/src/routes/projects/workspace-resource-history.ts
+++ b/apps/api/src/routes/projects/workspace-resource-history.ts
@@ -7,6 +7,10 @@ import { getUserId } from '../../middleware/auth';
import { errors } from '../../middleware/error';
import { requireProjectAccess } from '../../middleware/project-auth';
import { getWorkspaceResourceHistory } from '../../services/workspace-resource-history';
+import {
+ getSessionResourceTimeline,
+ getSessionResourceTimelineChunk,
+} from '../../services/workspace-resource-timeline';
const projectResourceHistoryRoutes = new Hono<{ Bindings: Env }>();
@@ -38,6 +42,25 @@ projectResourceHistoryRoutes.get('/:id/sessions/:sessionId/resource-history', as
);
});
+projectResourceHistoryRoutes.get('/:id/sessions/:sessionId/resource-timeline', async (c) => {
+ const projectId = c.req.param('id');
+ const sessionId = c.req.param('sessionId');
+ await requireAccess(c.env, projectId, getUserId(c));
+ return c.json(await getSessionResourceTimeline(c.env, { projectId, sessionId }));
+});
+
+projectResourceHistoryRoutes.get(
+ '/:id/sessions/:sessionId/resource-timeline/chunks/:chunkId',
+ async (c) => {
+ const projectId = c.req.param('id');
+ const sessionId = c.req.param('sessionId');
+ const chunkId = optionalDetailChunkId(c.req.param('chunkId'));
+ if (!chunkId) throw errors.badRequest('chunkId is required');
+ await requireAccess(c.env, projectId, getUserId(c));
+ return c.json(await getSessionResourceTimelineChunk(c.env, { projectId, sessionId, chunkId }));
+ }
+);
+
projectResourceHistoryRoutes.get('/:id/tasks/:taskId/resource-history', async (c) => {
const projectId = c.req.param('id');
const taskId = c.req.param('taskId');
diff --git a/apps/api/src/services/workspace-resource-history.ts b/apps/api/src/services/workspace-resource-history.ts
index e48a6cb8d2..0696310018 100644
--- a/apps/api/src/services/workspace-resource-history.ts
+++ b/apps/api/src/services/workspace-resource-history.ts
@@ -8,6 +8,7 @@ import { log } from '../lib/logger';
import { parsePositiveInt } from '../lib/route-helpers';
import { parseJsonRecord } from '../lib/runtime-validation';
import { AppError, errors } from '../middleware/error';
+import { computeWorkspaceResourceRollup, getRollupConfig } from './workspace-resource-rollup';
export const WORKSPACE_RESOURCE_STORAGE_FORMAT = 'resource-history-gzip-json-v1';
const R2_PREFIX = 'resource-history/v1';
@@ -629,6 +630,7 @@ async function validateWorkspaceResourceChunkBytes(
actualSha: string;
compressedBytes: number;
uncompressedBytes: number;
+ payload: WorkspaceResourceChunkPayload;
}> {
const bytes = decodeBase64(body.compressedBase64);
if (bytes.byteLength !== body.compressedBytes) {
@@ -671,9 +673,35 @@ async function validateWorkspaceResourceChunkBytes(
actualSha: await sha256Hex(sanitized.bytes),
compressedBytes: sanitized.bytes.byteLength,
uncompressedBytes: sanitized.uncompressedBytes,
+ payload: decodedChunk.value,
};
}
+/** The rollup is an optimisation for the timeline; failing to build one must not reject the chunk. */
+function buildRollupJson(
+ env: Env,
+ payload: WorkspaceResourceChunkPayload,
+ body: WorkspaceResourceUploadBody,
+ context: { projectId: string; workspaceId: string }
+): string | null {
+ try {
+ return JSON.stringify(
+ computeWorkspaceResourceRollup(
+ payload,
+ { startedAt: body.startedAt, endedAt: body.endedAt },
+ getRollupConfig(env)
+ )
+ );
+ } catch (error) {
+ log.warn('workspace_resource_history.rollup_failed', {
+ ...context,
+ chunkSequence: body.chunkSequence,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ }
+}
+
export async function storeWorkspaceResourceChunk(
env: Env,
projectId: string,
@@ -694,7 +722,7 @@ export async function storeWorkspaceResourceChunk(
);
const nodeId = validateWorkspaceUploadIdentity(workspace, body, uploadedByNodeId);
assertWorkspaceResourceUploadMetadata(body);
- const { bytes, actualSha, compressedBytes, uncompressedBytes } =
+ const { bytes, actualSha, compressedBytes, uncompressedBytes, payload } =
await validateWorkspaceResourceChunkBytes(env, body);
const db = drizzle(env.DATABASE, { schema });
@@ -911,6 +939,7 @@ export async function storeWorkspaceResourceChunk(
toolSpanCount: body.toolSpanCount ?? 0,
completenessJson,
summaryJson,
+ rollupJson: buildRollupJson(env, payload, body, { projectId, workspaceId }),
createdAt: now,
expiresAt,
uploadedByNodeId,
@@ -1031,7 +1060,7 @@ export function downsamplePreservingSpikes(
return { samples: selected, downsampled: true };
}
-async function readChunkPayload(env: Env, chunk: schema.WorkspaceResourceChunkRow) {
+export async function readChunkPayload(env: Env, chunk: schema.WorkspaceResourceChunkRow) {
const object = await env.PROJECT_DATA_ARCHIVE_R2.get(chunk.r2Key);
if (!object) throw errors.notFound('Resource history chunk');
const compressedBytes = new Uint8Array(await object.arrayBuffer());
diff --git a/apps/api/src/services/workspace-resource-rollup.ts b/apps/api/src/services/workspace-resource-rollup.ts
new file mode 100644
index 0000000000..a04ae357a1
--- /dev/null
+++ b/apps/api/src/services/workspace-resource-rollup.ts
@@ -0,0 +1,244 @@
+/**
+ * Per-minute rollup of one resource-history chunk.
+ *
+ * Computed once on upload from the already-decoded chunk payload and stored in
+ * `workspace_resource_chunks.rollup_json`, so the session timeline can draw a
+ * whole multi-day session from D1 without downloading every R2 chunk. The
+ * layout is columnar (one array per metric) to keep a 15-minute chunk's rollup
+ * around a kilobyte.
+ */
+import type { Env } from '../env';
+import { parsePositiveInt } from '../lib/route-helpers';
+import type { ResourceSamplePoint, ResourceToolSpan } from './workspace-resource-history';
+
+export const WORKSPACE_RESOURCE_ROLLUP_VERSION = 1;
+const DEFAULT_ROLLUP_BUCKET_MS = 60_000;
+const DEFAULT_ROLLUP_MAX_BUCKETS = 60;
+
+/** Columnar rollup. Every array has one entry per bucket, ascending by `start`. */
+export interface WorkspaceResourceRollup {
+ v: typeof WORKSPACE_RESOURCE_ROLLUP_VERSION;
+ bucketMs: number;
+ start: number[];
+ end: number[];
+ /** Measured samples in the bucket (gaps and unsupported samples excluded). */
+ samples: number[];
+ /** CPU in cores (1 = one core fully busy). */
+ cpuMeanCores: Array;
+ cpuMaxCores: Array;
+ memoryMeanBytes: Array;
+ memoryMaxBytes: Array;
+ workingSetMeanBytes: Array;
+ workingSetMaxBytes: Array;
+ ioReadBytes: Array;
+ ioWriteBytes: Array;
+ oomKills: number[];
+ toolCallStarts: number[];
+}
+
+export interface RollupConfig {
+ bucketMs: number;
+ maxBuckets: number;
+}
+
+export function getRollupConfig(env: Env): RollupConfig {
+ return {
+ bucketMs: parsePositiveInt(env.WORKSPACE_RESOURCE_ROLLUP_BUCKET_MS, DEFAULT_ROLLUP_BUCKET_MS),
+ maxBuckets: parsePositiveInt(
+ env.WORKSPACE_RESOURCE_ROLLUP_MAX_BUCKETS,
+ DEFAULT_ROLLUP_MAX_BUCKETS
+ ),
+ };
+}
+
+interface Accumulator {
+ start: number;
+ samples: number;
+ cpuSum: number;
+ cpuCount: number;
+ cpuMax: number | null;
+ memorySum: number;
+ memoryCount: number;
+ memoryMax: number | null;
+ workingSetSum: number;
+ workingSetCount: number;
+ workingSetMax: number | null;
+ ioRead: number | null;
+ ioWrite: number | null;
+ oomKills: number;
+ toolCallStarts: number;
+}
+
+function finiteNonNegative(value: unknown): number | null {
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
+}
+
+function emptyAccumulator(start: number): Accumulator {
+ return {
+ start,
+ samples: 0,
+ cpuSum: 0,
+ cpuCount: 0,
+ cpuMax: null,
+ memorySum: 0,
+ memoryCount: 0,
+ memoryMax: null,
+ workingSetSum: 0,
+ workingSetCount: 0,
+ workingSetMax: null,
+ ioRead: null,
+ ioWrite: null,
+ oomKills: 0,
+ toolCallStarts: 0,
+ };
+}
+
+function max(current: number | null, value: number): number {
+ return current == null ? value : Math.max(current, value);
+}
+
+/**
+ * Bucket width: the configured width, widened in whole multiples until the
+ * chunk's span fits in `maxBuckets`. A normal 15-minute chunk keeps one-minute
+ * buckets; a malformed or unusually long one cannot produce an unbounded rollup.
+ */
+function bucketWidth(spanMs: number, config: RollupConfig): number {
+ const needed = Math.ceil(Math.max(spanMs, 1) / config.maxBuckets);
+ return Math.max(1, Math.ceil(needed / config.bucketMs)) * config.bucketMs;
+}
+
+export function computeWorkspaceResourceRollup(
+ payload: { samples?: ResourceSamplePoint[]; toolSpans?: ResourceToolSpan[] },
+ window: { startedAt: number; endedAt: number },
+ config: RollupConfig
+): WorkspaceResourceRollup {
+ const width = bucketWidth(window.endedAt - window.startedAt, config);
+ const buckets = new Map();
+ const bucketFor = (t: number): Accumulator => {
+ // Clamp into the chunk window so a stray timestamp cannot add buckets beyond the bound.
+ const clamped = Math.min(Math.max(t, window.startedAt), window.endedAt);
+ const key = Math.floor(clamped / width) * width;
+ let bucket = buckets.get(key);
+ if (!bucket) {
+ bucket = emptyAccumulator(key);
+ buckets.set(key, bucket);
+ }
+ return bucket;
+ };
+
+ for (const sample of payload.samples ?? []) {
+ if (sample.gap || sample.unsupported) continue;
+ const bucket = bucketFor(sample.t);
+ bucket.samples += 1;
+
+ const intervalMs = finiteNonNegative(sample.intervalMillis);
+ const cpuMillis = finiteNonNegative(sample.cpuMillis);
+ if (intervalMs && cpuMillis != null) {
+ const cores = cpuMillis / intervalMs;
+ bucket.cpuSum += cores;
+ bucket.cpuCount += 1;
+ bucket.cpuMax = max(bucket.cpuMax, cores);
+ }
+
+ const memory = finiteNonNegative(sample.memoryBytes);
+ if (memory != null) {
+ bucket.memorySum += memory;
+ bucket.memoryCount += 1;
+ bucket.memoryMax = max(bucket.memoryMax, memory);
+ }
+ // The kernel's memory.peak catches spikes between two samples.
+ const memoryPeak = finiteNonNegative(sample.memoryPeakBytes);
+ if (memoryPeak != null) bucket.memoryMax = max(bucket.memoryMax, memoryPeak);
+
+ const workingSet = finiteNonNegative(sample.memoryWorkingSetBytes);
+ if (workingSet != null) {
+ bucket.workingSetSum += workingSet;
+ bucket.workingSetCount += 1;
+ bucket.workingSetMax = max(bucket.workingSetMax, workingSet);
+ }
+
+ // I/O counters are per-sample deltas, so the bucket total is their sum.
+ const ioRead = finiteNonNegative(sample.ioReadBytes);
+ if (ioRead != null) bucket.ioRead = (bucket.ioRead ?? 0) + ioRead;
+ const ioWrite = finiteNonNegative(sample.ioWriteBytes);
+ if (ioWrite != null) bucket.ioWrite = (bucket.ioWrite ?? 0) + ioWrite;
+
+ // Same accounting as the collector's chunk summary: oom + oom_kill deltas.
+ bucket.oomKills += (finiteNonNegative(sample.oom) ?? 0) + (finiteNonNegative(sample.oomKill) ?? 0);
+ }
+
+ for (const span of payload.toolSpans ?? []) {
+ bucketFor(span.startedAt).toolCallStarts += 1;
+ }
+
+ const ordered = [...buckets.values()].sort((a, b) => a.start - b.start);
+ const mean = (sum: number, count: number) => (count > 0 ? sum / count : null);
+ return {
+ v: WORKSPACE_RESOURCE_ROLLUP_VERSION,
+ bucketMs: width,
+ start: ordered.map((b) => Math.max(b.start, window.startedAt)),
+ end: ordered.map((b) => Math.min(b.start + width, window.endedAt)),
+ samples: ordered.map((b) => b.samples),
+ cpuMeanCores: ordered.map((b) => mean(b.cpuSum, b.cpuCount)),
+ cpuMaxCores: ordered.map((b) => b.cpuMax),
+ memoryMeanBytes: ordered.map((b) => {
+ const value = mean(b.memorySum, b.memoryCount);
+ return value == null ? null : Math.round(value);
+ }),
+ memoryMaxBytes: ordered.map((b) => b.memoryMax),
+ workingSetMeanBytes: ordered.map((b) => {
+ const value = mean(b.workingSetSum, b.workingSetCount);
+ return value == null ? null : Math.round(value);
+ }),
+ workingSetMaxBytes: ordered.map((b) => b.workingSetMax),
+ ioReadBytes: ordered.map((b) => b.ioRead),
+ ioWriteBytes: ordered.map((b) => b.ioWrite),
+ oomKills: ordered.map((b) => b.oomKills),
+ toolCallStarts: ordered.map((b) => b.toolCallStarts),
+ };
+}
+
+const ROLLUP_COLUMNS = [
+ 'start',
+ 'end',
+ 'samples',
+ 'cpuMeanCores',
+ 'cpuMaxCores',
+ 'memoryMeanBytes',
+ 'memoryMaxBytes',
+ 'workingSetMeanBytes',
+ 'workingSetMaxBytes',
+ 'ioReadBytes',
+ 'ioWriteBytes',
+ 'oomKills',
+ 'toolCallStarts',
+] as const;
+
+/**
+ * Parses a stored rollup, or returns null when it is absent or malformed. A
+ * malformed rollup must never break the timeline: the caller falls back to the
+ * chunk summary.
+ */
+export function parseWorkspaceResourceRollup(raw: string | null): WorkspaceResourceRollup | null {
+ if (!raw) return null;
+ let value: unknown;
+ try {
+ value = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (typeof value !== 'object' || value === null) return null;
+ const record = value as Record;
+ if (record.v !== WORKSPACE_RESOURCE_ROLLUP_VERSION) return null;
+ if (typeof record.bucketMs !== 'number' || !(record.bucketMs > 0)) return null;
+ const starts = record.start;
+ if (!Array.isArray(starts)) return null;
+ for (const column of ROLLUP_COLUMNS) {
+ const entries = record[column];
+ if (!Array.isArray(entries) || entries.length !== starts.length) return null;
+ if (!entries.every((entry) => entry === null || (typeof entry === 'number' && Number.isFinite(entry)))) {
+ return null;
+ }
+ }
+ return value as WorkspaceResourceRollup;
+}
diff --git a/apps/api/src/services/workspace-resource-timeline.ts b/apps/api/src/services/workspace-resource-timeline.ts
new file mode 100644
index 0000000000..3fa1fe68df
--- /dev/null
+++ b/apps/api/src/services/workspace-resource-timeline.ts
@@ -0,0 +1,273 @@
+/**
+ * Whole-session resource timeline.
+ *
+ * A session's resource history is stored as immutable ~15-minute chunks, one
+ * series per workspace lifetime (every wake provisions a fresh workspace). The
+ * index returns every chunk of the session with its per-minute rollup, so the
+ * client can draw the whole session from D1 alone and fetch full-resolution
+ * chunks only for the window the user zooms into.
+ */
+import { and, count, desc, eq } from 'drizzle-orm';
+import { drizzle } from 'drizzle-orm/d1';
+
+import * as schema from '../db/schema';
+import type { Env } from '../env';
+import { log } from '../lib/logger';
+import { parsePositiveInt } from '../lib/route-helpers';
+import { errors } from '../middleware/error';
+import { parseStoredResolvedReservationJson } from './resource-requirements-input';
+import { readChunkPayload } from './workspace-resource-history';
+import {
+ parseWorkspaceResourceRollup,
+ type WorkspaceResourceRollup,
+} from './workspace-resource-rollup';
+
+const DEFAULT_TIMELINE_MAX_CHUNKS = 1000;
+/** `nodes.runtime` for Instant sessions, whose container runtime records no resource history yet. */
+const UNSUPPORTED_RUNTIMES: ReadonlySet = new Set(['cf-container']);
+
+export interface ResourceTimelineChunk {
+ id: string;
+ workspaceId: string;
+ startedAt: number;
+ endedAt: number;
+ sampleCount: number;
+ gapCount: number;
+ toolSpanCount: number;
+ summary: unknown;
+ completeness: unknown;
+ /** Per-minute rollup; null for chunks uploaded before rollups existed. */
+ rollup: WorkspaceResourceRollup | null;
+}
+
+export interface ResourceTimelineRun {
+ workspaceId: string;
+ nodeId: string | null;
+ runtime: string | null;
+ startedAt: number;
+ endedAt: number;
+ /** What the workspace reserved; null when unknown or unparseable. */
+ reservation: { cpuMillis: number; memoryMb: number } | null;
+}
+
+export interface ResourceTimelineIndexResponse {
+ sessionId: string;
+ runs: ResourceTimelineRun[];
+ /** Ascending by `startedAt`. */
+ chunks: ResourceTimelineChunk[];
+ totalChunkCount: number;
+ /** Oldest chunks left out because the session exceeds `maxChunks`. */
+ omittedChunkCount: number;
+ maxChunks: number;
+ /**
+ * `unsupported` when nothing is recorded and the session's runtime does not
+ * collect resource history; `pending` when nothing has been uploaded yet.
+ */
+ collection: 'collected' | 'pending' | 'unsupported';
+ runtime: string | null;
+}
+
+export function getTimelineMaxChunks(env: Env): number {
+ return parsePositiveInt(env.WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS, DEFAULT_TIMELINE_MAX_CHUNKS);
+}
+
+function jsonOrNull(value: string): unknown {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return null;
+ }
+}
+
+function timelineChunk(row: schema.WorkspaceResourceChunkRow): ResourceTimelineChunk {
+ return {
+ id: row.id,
+ workspaceId: row.workspaceId,
+ startedAt: row.startedAt,
+ endedAt: row.endedAt,
+ sampleCount: row.sampleCount,
+ gapCount: row.gapCount,
+ toolSpanCount: row.toolSpanCount,
+ summary: jsonOrNull(row.summaryJson),
+ completeness: jsonOrNull(row.completenessJson),
+ rollup: parseWorkspaceResourceRollup(row.rollupJson),
+ };
+}
+
+interface RunWorkspaceRow {
+ id: string;
+ node_id: string | null;
+ resolved_reservation_json: string | null;
+ runtime: string | null;
+}
+
+function reservationOf(row: RunWorkspaceRow): ResourceTimelineRun['reservation'] {
+ try {
+ const reservation = parseStoredResolvedReservationJson(row.resolved_reservation_json);
+ return reservation ? { cpuMillis: reservation.cpuMillis, memoryMb: reservation.memoryMb } : null;
+ } catch (error) {
+ // A malformed reservation hides the reservation line for that run; it must not fail the index.
+ log.warn('workspace_resource_timeline.reservation_skipped', {
+ workspaceId: row.id,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ }
+}
+
+/** Workspaces behind the session's chunks, scoped to the project on both sides of the join. */
+async function loadRunWorkspaces(
+ env: Env,
+ projectId: string,
+ sessionId: string
+): Promise
@@ -118,11 +138,11 @@ function TimelineBody({ source, index }: Readonly<{ source: ResourceHistorySourc
viewMin: view.min,
viewMax: view.max,
series,
- reservation: index.reservation,
+ runs: index.runs,
toolSpans: data.toolSpans,
hasWorkingSet,
}),
- [axis, view.min, view.max, series, index.reservation, data.toolSpans, hasWorkingSet]
+ [axis, view.min, view.max, series, index.runs, data.toolSpans, hasWorkingSet]
);
const cursorX = cursorT == null ? null : toAxis(axis, cursorT);
diff --git a/apps/web/src/components/chat/resource-timeline/chart-theme.ts b/apps/web/src/components/chat/resource-timeline/chart-theme.ts
index 20dbf34656..392db608fe 100644
--- a/apps/web/src/components/chat/resource-timeline/chart-theme.ts
+++ b/apps/web/src/components/chat/resource-timeline/chart-theme.ts
@@ -81,10 +81,13 @@ function resolveChartTheme(theme: ResolvedTheme): ChartTheme {
tools: {
execute: ioWrite,
edit: cpu,
+ delete: readToken(TOKENS.danger, theme),
+ move: withAlpha(cpu, 0.7),
read: ioRead,
search: memory,
fetch: readToken(TOKENS.cyan, theme),
think: withAlpha(muted, 0.7),
+ switch_mode: withAlpha(muted, 0.5),
other: withAlpha(muted, 0.9),
},
};
diff --git a/apps/web/src/components/chat/resource-timeline/panels.ts b/apps/web/src/components/chat/resource-timeline/panels.ts
index a2e30c1625..57d4d5c3cf 100644
--- a/apps/web/src/components/chat/resource-timeline/panels.ts
+++ b/apps/web/src/components/chat/resource-timeline/panels.ts
@@ -14,7 +14,7 @@ import { type ChartTheme, withAlpha } from './chart-theme';
import { formatBytes, formatClock, formatCoresAxis, formatElapsed, formatRate } from './format';
import type { TimelineSeries } from './series';
import { axisTicks, sleeps, tickStepMs, type TimeAxis, toAxis, toReal } from './time-axis';
-import type { ResourceReservation, ResourceToolSpan } from './types';
+import type { ResourceRun, ResourceToolSpan } from './types';
export type PanelKind = 'cpu' | 'memory' | 'disk' | 'tools';
@@ -23,7 +23,8 @@ export interface PanelState {
viewMin: number;
viewMax: number;
series: TimelineSeries;
- reservation: ResourceReservation | null;
+ /** Each run carries its own reservation: a wake can land on a different size. */
+ runs: readonly ResourceRun[];
toolSpans: readonly ResourceToolSpan[];
/** Whether the agent reports working-set memory (otherwise memory includes cache). */
hasWorkingSet: boolean;
@@ -105,26 +106,48 @@ function drawSleeps(u: uPlot, state: PanelState, theme: ChartTheme, label: boole
}
}
-function drawReservation(u: uPlot, value: number | null, theme: ChartTheme, label: string) {
- if (value == null) return;
- const y = u.valToPos(value, 'y', true);
+type ReservationMetric = 'cpuCores' | 'memoryBytes';
+
+function highestReservation(runs: readonly ResourceRun[], metric: ReservationMetric): number {
+ return Math.max(0, ...runs.map((run) => run.reservation?.[metric] ?? 0));
+}
+
+/** A dashed line at each run's reservation, spanning only that run. */
+function drawReservations(
+ u: uPlot,
+ state: PanelState,
+ metric: ReservationMetric,
+ theme: ChartTheme,
+ label: (value: number) => string
+) {
const { ctx, bbox } = u;
- if (y < bbox.top || y > bbox.top + bbox.height) return;
- ctx.save();
- ctx.strokeStyle = theme.reservation;
- ctx.lineWidth = px(1);
- ctx.setLineDash([px(4), px(3)]);
- ctx.beginPath();
- ctx.moveTo(bbox.left, y);
- ctx.lineTo(bbox.left + bbox.width, y);
- ctx.stroke();
- ctx.setLineDash([]);
- ctx.fillStyle = theme.mutedText;
- ctx.font = `${px(10)}px system-ui, sans-serif`;
- ctx.textAlign = 'right';
- ctx.textBaseline = 'bottom';
- ctx.fillText(label, bbox.left + bbox.width - px(4), y - px(2));
- ctx.restore();
+ const right = bbox.left + bbox.width;
+ for (const run of state.runs) {
+ const value = run.reservation?.[metric];
+ if (value == null) continue;
+ const x0 = Math.max(bbox.left, canvasX(u, toAxis(state.axis, run.startedAt)));
+ const x1 = Math.min(right, canvasX(u, toAxis(state.axis, run.endedAt)));
+ const y = u.valToPos(value, 'y', true);
+ if (x1 - x0 < 1 || y < bbox.top || y > bbox.top + bbox.height) continue;
+ ctx.save();
+ ctx.strokeStyle = theme.reservation;
+ ctx.lineWidth = px(1);
+ ctx.setLineDash([px(4), px(3)]);
+ ctx.beginPath();
+ ctx.moveTo(x0, y);
+ ctx.lineTo(x1, y);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ const text = `reserved ${label(value)}`;
+ ctx.font = `${px(10)}px system-ui, sans-serif`;
+ if (ctx.measureText(text).width + px(8) <= x1 - x0) {
+ ctx.fillStyle = theme.mutedText;
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'bottom';
+ ctx.fillText(text, x1 - px(4), y - px(2));
+ }
+ ctx.restore();
+ }
}
function drawOomMarkers(u: uPlot, state: PanelState, theme: ChartTheme) {
@@ -308,10 +331,7 @@ export function panelOptions(
},
],
draw: [
- (u) => {
- const cores = stateRef.current.reservation?.cpuCores ?? null;
- drawReservation(u, cores, theme, cores == null ? '' : `reserved ${formatCoresAxis(cores)}`);
- },
+ (u) => drawReservations(u, stateRef.current, 'cpuCores', theme, formatCoresAxis),
],
},
scales: {
@@ -319,7 +339,7 @@ export function panelOptions(
y: {
range: () => {
const state = stateRef.current;
- const top = Math.max(0.5, peak(state.series.cpuMax), state.reservation?.cpuCores ?? 0);
+ const top = Math.max(0.5, peak(state.series.cpuMax), highestReservation(state.runs, 'cpuCores'));
return [0, top * 1.12];
},
},
@@ -340,8 +360,7 @@ export function panelOptions(
draw: [
(u) => {
const state = stateRef.current;
- const bytes = state.reservation?.memoryBytes ?? null;
- drawReservation(u, bytes, theme, bytes == null ? '' : `reserved ${formatBytes(bytes)}`);
+ drawReservations(u, state, 'memoryBytes', theme, formatBytes);
drawOomMarkers(u, state, theme);
},
],
@@ -354,7 +373,7 @@ export function panelOptions(
const top = Math.max(
256 * 1024 ** 2,
peak(state.series.memoryMax),
- state.reservation?.memoryBytes ?? 0
+ highestReservation(state.runs, 'memoryBytes')
);
return [0, top * 1.1];
},
diff --git a/apps/web/src/components/chat/resource-timeline/resource-source.ts b/apps/web/src/components/chat/resource-timeline/resource-source.ts
index 71e852f5b9..ac811a34c7 100644
--- a/apps/web/src/components/chat/resource-timeline/resource-source.ts
+++ b/apps/web/src/components/chat/resource-timeline/resource-source.ts
@@ -8,21 +8,26 @@
*/
import {
- getSessionResourceHistory,
- type WorkspaceResourceChunk,
- type WorkspaceResourceHistoryResponse,
+ getSessionResourceTimeline,
+ getSessionResourceTimelineChunk,
+ type ResourceTimelineChunkEntry,
+ type ResourceTimelineChunkResponse,
+ type ResourceTimelineIndexResponse,
+ type ResourceTimelineRollup,
+ type ResourceTimelineRunEntry,
type WorkspaceResourceSample,
type WorkspaceResourceToolSpan,
} from '../../../lib/api';
-import type {
- HistoryCompleteness,
- ResourceAggregate,
- ResourceChunkDetail,
- ResourceChunkRef,
- ResourceRun,
- ResourceTimelineIndex,
- ResourceToolSpan,
- ToolKind,
+import {
+ type ResourceAggregate,
+ type ResourceChunkDetail,
+ type ResourceChunkRef,
+ type ResourceReservation,
+ type ResourceRun,
+ type ResourceTimelineIndex,
+ type ResourceToolSpan,
+ TOOL_KINDS,
+ type ToolKind,
} from './types';
export interface ResourceHistorySource {
@@ -35,17 +40,10 @@ export interface ResourceHistorySource {
const DEFAULT_SAMPLE_INTERVAL_MS = 5_000;
const DEFAULT_UPLOAD_INTERVAL_MS = 15 * 60_000;
-const TOOL_KINDS: ReadonlySet = new Set([
- 'execute',
- 'edit',
- 'read',
- 'search',
- 'fetch',
- 'think',
-]);
+const KNOWN_TOOL_KINDS: ReadonlySet = new Set(TOOL_KINDS);
function toolKind(raw: string | undefined): ToolKind {
- return raw && TOOL_KINDS.has(raw) ? (raw as ToolKind) : 'other';
+ return raw && KNOWN_TOOL_KINDS.has(raw) ? (raw as ToolKind) : 'other';
}
function numberField(record: unknown, key: string): number | null {
@@ -66,7 +64,7 @@ export function sampleToAggregate(sample: WorkspaceResourceSample): ResourceAggr
const measured = intervalMs > 0 && !sample.unsupported;
const cores = measured && sample.cpuMillis != null ? sample.cpuMillis / intervalMs : null;
const memory = sample.unsupported ? null : (sample.memoryBytes ?? null);
- const workingSet = numberField(sample, 'workingSetBytes');
+ const workingSet = sample.unsupported ? null : (sample.memoryWorkingSetBytes ?? null);
return {
start: sample.t - (intervalMs || DEFAULT_SAMPLE_INTERVAL_MS),
end: sample.t,
@@ -106,7 +104,7 @@ export function withToolStarts(
function toolSpanFromApi(span: WorkspaceResourceToolSpan): ResourceToolSpan {
return {
id: span.id,
- kind: toolKind(stringField(span, 'toolKind') ?? span.kind),
+ kind: toolKind(span.kind),
name: stringField(span, 'toolName'),
startedAt: span.startedAt,
endedAt: span.endedAt ?? span.startedAt,
@@ -114,8 +112,8 @@ function toolSpanFromApi(span: WorkspaceResourceToolSpan): ResourceToolSpan {
};
}
-/** The per-chunk summary the current API stores, as one aggregate spanning the chunk. */
-function chunkOverview(chunk: WorkspaceResourceChunk): ResourceAggregate {
+/** Older chunks carry no rollup: their stored summary becomes one aggregate spanning the chunk. */
+function summaryOverview(chunk: ResourceTimelineChunkEntry): ResourceAggregate {
const summary = chunk.summary;
const intervalMs = numberField(summary, 'sampleIntervalMillis') ?? DEFAULT_SAMPLE_INTERVAL_MS;
const cpuMean = numberField(summary, 'cpuMeanMillis');
@@ -127,8 +125,8 @@ function chunkOverview(chunk: WorkspaceResourceChunk): ResourceAggregate {
cpuMaxCores: cpuPeak == null ? null : cpuPeak / intervalMs,
memoryMeanBytes: numberField(summary, 'memoryMeanBytes'),
memoryMaxBytes: numberField(summary, 'memoryPeakBytes'),
- workingSetMeanBytes: numberField(summary, 'workingSetMeanBytes'),
- workingSetMaxBytes: numberField(summary, 'workingSetPeakBytes'),
+ workingSetMeanBytes: numberField(summary, 'memoryWorkingSetMeanBytes'),
+ workingSetMaxBytes: numberField(summary, 'memoryWorkingSetPeakBytes'),
ioReadBytes: numberField(summary, 'ioReadBytes'),
ioWriteBytes: numberField(summary, 'ioWriteBytes'),
oomKills: numberField(summary, 'oomCount') ?? 0,
@@ -137,42 +135,50 @@ function chunkOverview(chunk: WorkspaceResourceChunk): ResourceAggregate {
};
}
-function runsFromChunks(chunks: readonly WorkspaceResourceChunk[]): ResourceRun[] {
- const runs = new Map();
- for (const chunk of chunks) {
- const unsupported = stringField(chunk.completeness, 'unsupported');
- const existing = runs.get(chunk.workspaceId);
- if (existing) {
- existing.startedAt = Math.min(existing.startedAt, chunk.startedAt);
- existing.endedAt = Math.max(existing.endedAt, chunk.endedAt);
- existing.unsupportedReason ??= unsupported;
- } else {
- runs.set(chunk.workspaceId, {
- id: chunk.workspaceId,
- nodeId: chunk.nodeId,
- startedAt: chunk.startedAt,
- endedAt: chunk.endedAt,
- unsupportedReason: unsupported,
- });
- }
- }
- return [...runs.values()].sort((a, b) => a.startedAt - b.startedAt);
+export function rollupOverview(rollup: ResourceTimelineRollup): ResourceAggregate[] {
+ return rollup.start.map((start, i) => ({
+ start,
+ end: rollup.end[i] ?? start + rollup.bucketMs,
+ cpuMeanCores: rollup.cpuMeanCores[i] ?? null,
+ cpuMaxCores: rollup.cpuMaxCores[i] ?? null,
+ memoryMeanBytes: rollup.memoryMeanBytes[i] ?? null,
+ memoryMaxBytes: rollup.memoryMaxBytes[i] ?? null,
+ workingSetMeanBytes: rollup.workingSetMeanBytes[i] ?? null,
+ workingSetMaxBytes: rollup.workingSetMaxBytes[i] ?? null,
+ ioReadBytes: rollup.ioReadBytes[i] ?? null,
+ ioWriteBytes: rollup.ioWriteBytes[i] ?? null,
+ oomKills: rollup.oomKills[i] ?? 0,
+ toolCallStarts: rollup.toolCallStarts[i] ?? 0,
+ exact: false,
+ }));
}
-/**
- * The current list endpoint returns only the newest chunks, without a total. The
- * one certain signal is the latest workspace's own first chunk missing from the
- * page: then older history exists and was not returned.
- */
-function completenessOf(response: WorkspaceResourceHistoryResponse): HistoryCompleteness {
- const firstChunkId = response.summary?.firstChunkId;
- if (firstChunkId && !response.chunks.some((chunk) => chunk.id === firstChunkId)) {
- return { kind: 'truncated', omitted: null };
- }
- return { kind: 'complete' };
+function reservationFromApi(run: ResourceTimelineRunEntry): ResourceReservation | null {
+ if (!run.reservation) return null;
+ return {
+ cpuCores: run.reservation.cpuMillis > 0 ? run.reservation.cpuMillis / 1000 : null,
+ memoryBytes: run.reservation.memoryMb > 0 ? run.reservation.memoryMb * 1024 * 1024 : null,
+ };
+}
+
+function runsFromApi(response: ResourceTimelineIndexResponse): ResourceRun[] {
+ return response.runs
+ .map((run) => ({
+ id: run.workspaceId,
+ nodeId: run.nodeId,
+ startedAt: run.startedAt,
+ endedAt: run.endedAt,
+ unsupportedReason:
+ response.chunks
+ .filter((chunk) => chunk.workspaceId === run.workspaceId)
+ .map((chunk) => stringField(chunk.completeness, 'unsupported'))
+ .find((reason) => reason != null) ?? null,
+ reservation: reservationFromApi(run),
+ }))
+ .sort((a, b) => a.startedAt - b.startedAt);
}
-export function indexFromApi(response: WorkspaceResourceHistoryResponse): ResourceTimelineIndex {
+export function indexFromApi(response: ResourceTimelineIndexResponse): ResourceTimelineIndex {
const chunks: ResourceChunkRef[] = [...response.chunks]
.sort((a, b) => a.startedAt - b.startedAt)
.map((chunk) => ({
@@ -181,17 +187,20 @@ export function indexFromApi(response: WorkspaceResourceHistoryResponse): Resour
startedAt: chunk.startedAt,
endedAt: chunk.endedAt,
sampleCount: chunk.sampleCount,
- overview: [chunkOverview(chunk)],
+ overview: chunk.rollup && chunk.rollup.start.length > 0 ? rollupOverview(chunk.rollup) : [summaryOverview(chunk)],
memoryHighWaterBytes: numberField(chunk.summary, 'memoryKernelPeakBytes'),
}));
return {
- runs: runsFromChunks(response.chunks),
+ runs: runsFromApi(response),
chunks,
sampleIntervalMs:
- numberField(response.summary?.summary, 'sampleIntervalMillis') ?? DEFAULT_SAMPLE_INTERVAL_MS,
+ numberField(response.chunks[0]?.summary, 'sampleIntervalMillis') ?? DEFAULT_SAMPLE_INTERVAL_MS,
uploadIntervalMs: DEFAULT_UPLOAD_INTERVAL_MS,
- completeness: completenessOf(response),
- reservation: null,
+ completeness:
+ response.omittedChunkCount > 0
+ ? { kind: 'truncated', omitted: response.omittedChunkCount }
+ : { kind: 'complete' },
+ collection: response.collection,
};
}
@@ -210,10 +219,7 @@ function coverThinnedSamples(samples: ResourceAggregate[], raw: readonly Workspa
});
}
-export function chunkDetailFromApi(
- chunkId: string,
- detail: NonNullable
-): ResourceChunkDetail {
+export function chunkDetailFromApi(chunkId: string, detail: ResourceTimelineChunkResponse): ResourceChunkDetail {
const toolSpans = detail.toolSpans.map(toolSpanFromApi);
const raw = [...detail.samples].sort((a, b) => a.t - b.t);
const samples = raw.map(sampleToAggregate);
@@ -227,19 +233,19 @@ export function chunkDetailFromApi(
};
}
-/** Reads a session's history through today's `/resource-history` endpoint. */
+/** Reads a session's whole resource timeline. */
export function apiResourceHistorySource(projectId: string, sessionId: string): ResourceHistorySource {
return {
- cacheKey: ['session-resource-history', projectId, sessionId],
+ cacheKey: ['session-resource-timeline', projectId, sessionId],
async loadIndex() {
- return indexFromApi(await getSessionResourceHistory(projectId, sessionId));
+ return indexFromApi(await getSessionResourceTimeline(projectId, sessionId));
},
async loadChunk(chunkId) {
- const response = await getSessionResourceHistory(projectId, sessionId, { chunkId });
- if (!response.detail || response.detail.chunkId !== chunkId) {
+ const response = await getSessionResourceTimelineChunk(projectId, sessionId, chunkId);
+ if (response.chunkId !== chunkId) {
throw new Error(`Resource history chunk ${chunkId} was not returned`);
}
- return chunkDetailFromApi(chunkId, response.detail);
+ return chunkDetailFromApi(chunkId, response);
},
};
}
diff --git a/apps/web/src/components/chat/resource-timeline/types.ts b/apps/web/src/components/chat/resource-timeline/types.ts
index 54eaa4ff65..65badef84f 100644
--- a/apps/web/src/components/chat/resource-timeline/types.ts
+++ b/apps/web/src/components/chat/resource-timeline/types.ts
@@ -7,8 +7,20 @@
* provisions a fresh workspace) separated by the gaps where the session slept.
*/
-/** ACP tool-call kinds, plus `other` for anything an agent reports that we do not know. */
-export type ToolKind = 'execute' | 'edit' | 'read' | 'search' | 'fetch' | 'think' | 'other';
+/** The ACP tool-call kinds (`other` also covers anything an agent reports that we do not know). */
+export const TOOL_KINDS = [
+ 'read',
+ 'edit',
+ 'delete',
+ 'move',
+ 'search',
+ 'execute',
+ 'think',
+ 'fetch',
+ 'switch_mode',
+ 'other',
+] as const;
+export type ToolKind = (typeof TOOL_KINDS)[number];
/**
* Resource usage over a closed time window `[start, end)`.
@@ -48,6 +60,8 @@ export interface ResourceRun {
endedAt: number;
/** Why the collector could not observe this run (for example the container never started). */
unsupportedReason: string | null;
+ /** What this workspace reserved; each wake can land on a different size. */
+ reservation: ResourceReservation | null;
}
/** A stored chunk, described well enough to draw an overview without downloading it. */
@@ -85,8 +99,11 @@ export interface ResourceTimelineIndex {
/** How often the VM uploads a chunk — data is never newer than one upload interval. */
uploadIntervalMs: number;
completeness: HistoryCompleteness;
- /** What the workspace reserved, when known; usage is judged against this. */
- reservation: ResourceReservation | null;
+ /**
+ * Whether history is recorded at all: `unsupported` for runtimes that collect
+ * none (Instant sessions), `pending` before the first upload.
+ */
+ collection: 'collected' | 'pending' | 'unsupported';
}
export interface ResourceToolSpan {
diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts
index 13f1c684e0..5011f99901 100644
--- a/apps/web/src/lib/api/index.ts
+++ b/apps/web/src/lib/api/index.ts
@@ -465,11 +465,6 @@ export type {
SessionSummaryResponse,
StartInstantChatSessionRequest,
StartInstantChatSessionResponse,
- WorkspaceResourceChunk,
- WorkspaceResourceHistoryResponse,
- WorkspaceResourceSample,
- WorkspaceResourceSummary,
- WorkspaceResourceToolSpan,
} from './sessions';
export {
cancelAgentPrompt,
@@ -478,7 +473,6 @@ export {
getChatSession,
getChatSessionState,
getRecentChats,
- getSessionResourceHistory,
listActivityEvents,
listChatMessages,
listChatSessions,
@@ -565,3 +559,13 @@ export {
updateWorkspace,
updateWorkspacePortsPublic,
} from './workspaces';
+export type {
+ ResourceTimelineChunkEntry,
+ ResourceTimelineChunkResponse,
+ ResourceTimelineIndexResponse,
+ ResourceTimelineRollup,
+ ResourceTimelineRunEntry,
+ WorkspaceResourceSample,
+ WorkspaceResourceToolSpan,
+} from './resource-timeline';
+export { getSessionResourceTimeline, getSessionResourceTimelineChunk } from './resource-timeline';
diff --git a/apps/web/src/lib/api/resource-timeline.ts b/apps/web/src/lib/api/resource-timeline.ts
new file mode 100644
index 0000000000..022d5ddb5b
--- /dev/null
+++ b/apps/web/src/lib/api/resource-timeline.ts
@@ -0,0 +1,114 @@
+import { request } from './client';
+
+/** One raw 5-second cgroup sample, as the VM agent records it. */
+export interface WorkspaceResourceSample {
+ t: number;
+ intervalMillis?: number;
+ cpuMillis?: number;
+ memoryBytes?: number;
+ memoryPeakBytes?: number;
+ /** Non-reclaimable working set; absent on VM agents that predate it. */
+ memoryWorkingSetBytes?: number;
+ ioReadBytes?: number;
+ ioWriteBytes?: number;
+ oom?: number;
+ oomKill?: number;
+ pidsCurrent?: number;
+ counterReset?: boolean;
+ unsupported?: string;
+ gap?: boolean;
+}
+
+export interface WorkspaceResourceToolSpan {
+ id: string;
+ /** ACP tool-call kind (`execute`, `edit`, ...); `acp_tool_call` on VM agents that predate tool labels. */
+ kind: string;
+ toolName?: string;
+ startedAt: number;
+ endedAt?: number;
+ concurrency?: number;
+ approximate?: boolean;
+}
+
+/** Columnar per-minute rollup stored for each chunk; every array has one entry per bucket. */
+export interface ResourceTimelineRollup {
+ v: 1;
+ bucketMs: number;
+ start: number[];
+ end: number[];
+ samples: number[];
+ cpuMeanCores: Array;
+ cpuMaxCores: Array;
+ memoryMeanBytes: Array;
+ memoryMaxBytes: Array;
+ workingSetMeanBytes: Array;
+ workingSetMaxBytes: Array;
+ ioReadBytes: Array;
+ ioWriteBytes: Array;
+ oomKills: number[];
+ toolCallStarts: number[];
+}
+
+export interface ResourceTimelineChunkEntry {
+ id: string;
+ workspaceId: string;
+ startedAt: number;
+ endedAt: number;
+ sampleCount: number;
+ gapCount: number;
+ toolSpanCount: number;
+ summary: unknown;
+ completeness: unknown;
+ /** Null for chunks uploaded before rollups existed; draw them from `summary`. */
+ rollup: ResourceTimelineRollup | null;
+}
+
+export interface ResourceTimelineRunEntry {
+ workspaceId: string;
+ nodeId: string | null;
+ runtime: string | null;
+ startedAt: number;
+ endedAt: number;
+ reservation: { cpuMillis: number; memoryMb: number } | null;
+}
+
+export interface ResourceTimelineIndexResponse {
+ sessionId: string;
+ runs: ResourceTimelineRunEntry[];
+ /** Ascending by `startedAt`. */
+ chunks: ResourceTimelineChunkEntry[];
+ totalChunkCount: number;
+ omittedChunkCount: number;
+ maxChunks: number;
+ collection: 'collected' | 'pending' | 'unsupported';
+ runtime: string | null;
+}
+
+export interface ResourceTimelineChunkResponse {
+ chunkId: string;
+ samples: WorkspaceResourceSample[];
+ toolSpans: WorkspaceResourceToolSpan[];
+ gaps: Array>;
+ originalSampleCount: number;
+ downsampled: boolean;
+ downsampleLimit: number;
+}
+
+export async function getSessionResourceTimeline(
+ projectId: string,
+ sessionId: string
+): Promise {
+ return request(
+ `/api/projects/${projectId}/sessions/${sessionId}/resource-timeline`
+ );
+}
+
+export async function getSessionResourceTimelineChunk(
+ projectId: string,
+ sessionId: string,
+ chunkId: string
+): Promise {
+ return request(
+ `/api/projects/${projectId}/sessions/${sessionId}/resource-timeline/chunks/${encodeURIComponent(chunkId)}`
+ );
+}
diff --git a/apps/web/src/lib/api/sessions.ts b/apps/web/src/lib/api/sessions.ts
index c8b75e27b4..1a1dcd8505 100644
--- a/apps/web/src/lib/api/sessions.ts
+++ b/apps/web/src/lib/api/sessions.ts
@@ -142,102 +142,6 @@ export interface ChatSessionStateResponse {
agentType: string | null;
}
-export interface WorkspaceResourceSummary {
- id: string;
- projectId: string;
- workspaceId: string;
- sessionId: string | null;
- taskId: string | null;
- nodeId: string | null;
- agentProfileId: string | null;
- skillId: string | null;
- agentType: string | null;
- runtime: string;
- sourceVersion: number;
- startedAt: number;
- endedAt: number;
- sampleCount: number;
- gapCount: number;
- toolSpanCount: number;
- cpuMeanMillis: number | null;
- cpuPeakMillis: number | null;
- memoryMeanBytes: number | null;
- memoryPeakBytes: number | null;
- memoryKernelPeakBytes: number | null;
- memoryWorkingSetMeanBytes: number | null;
- memoryWorkingSetPeakBytes: number | null;
- ioReadBytes: number | null;
- ioWriteBytes: number | null;
- oomCount: number;
- completeness: unknown;
- summary: unknown;
- firstChunkId: string | null;
- latestChunkId: string | null;
-}
-
-export interface WorkspaceResourceChunk {
- id: string;
- workspaceId: string;
- sessionId: string | null;
- taskId: string | null;
- nodeId: string | null;
- chunkSequence: number;
- sourceVersion: number;
- storageFormat: string;
- compressedBytes: number;
- uncompressedBytes: number;
- sha256: string;
- startedAt: number;
- endedAt: number;
- sampleCount: number;
- gapCount: number;
- toolSpanCount: number;
- completeness: unknown;
- summary: unknown;
- expiresAt: number;
-}
-
-export interface WorkspaceResourceSample {
- t: number;
- intervalMillis?: number;
- cpuMillis?: number;
- memoryBytes?: number;
- memoryPeakBytes?: number;
- memoryWorkingSetBytes?: number;
- ioReadBytes?: number;
- ioWriteBytes?: number;
- oom?: number;
- oomKill?: number;
- pidsCurrent?: number;
- counterReset?: boolean;
- unsupported?: string;
- gap?: boolean;
-}
-
-export interface WorkspaceResourceToolSpan {
- id: string;
- kind?: string;
- toolName?: string;
- startedAt: number;
- endedAt?: number;
- concurrency?: number;
- approximate?: boolean;
-}
-
-export interface WorkspaceResourceHistoryResponse {
- summary: WorkspaceResourceSummary | null;
- chunks: WorkspaceResourceChunk[];
- detail?: {
- chunkId: string;
- samples: WorkspaceResourceSample[];
- toolSpans: WorkspaceResourceToolSpan[];
- gaps: Array>;
- originalSampleCount: number;
- downsampled: boolean;
- downsampleLimit: number;
- };
-}
-
export interface ChatMessagesListResponse {
messages: ChatMessageResponse[];
hasMore: boolean;
@@ -305,21 +209,6 @@ export async function getRecentChats(
return request(qs ? `/api/chats/recent?${qs}` : '/api/chats/recent');
}
-export async function getSessionResourceHistory(
- projectId: string,
- sessionId: string,
- params: { chunkId?: string | null } = {}
-): Promise {
- const searchParams = new URLSearchParams();
- if (params.chunkId) searchParams.set('chunkId', params.chunkId);
- const qs = searchParams.toString();
- return request(
- qs
- ? `/api/projects/${projectId}/sessions/${sessionId}/resource-history?${qs}`
- : `/api/projects/${projectId}/sessions/${sessionId}/resource-history`
- );
-}
-
/** Fetch all sessions across all projects with pagination (single D1 query). */
export async function getAllChats(
params: { limit?: number; offset?: number; status?: string } = {}
diff --git a/apps/web/src/pages/resource-timeline-prototype/index.tsx b/apps/web/src/pages/resource-timeline-prototype/index.tsx
deleted file mode 100644
index 5b680a677b..0000000000
--- a/apps/web/src/pages/resource-timeline-prototype/index.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-/**
- * PROTOTYPE ONLY — dev-only route `/prototype/resource-timeline`.
- *
- * Renders the real Resources drawer against synthetic sessions. Everything under
- * `components/chat/resource-timeline/` is the production candidate; only this
- * page and its mock data are throwaway. Scenario, backend contract and network
- * latency are URL parameters so a link reproduces exactly what was seen.
- */
-
-import { Button } from '@simple-agent-manager/ui';
-import { Activity } from 'lucide-react';
-import { useMemo } from 'react';
-import { useSearchParams } from 'react-router';
-
-import { SessionResourceHistoryDrawer } from '../../components/chat/SessionResourceHistoryDrawer';
-import { scenarioById, SCENARIOS } from './mock-sessions';
-import { type BackendMode, createMockSource, type LatencyProfile } from './mock-source';
-
-const BACKENDS: Array<{ id: BackendMode; label: string; detail: string }> = [
- {
- id: 'proposed',
- label: 'Proposed backend',
- detail: 'Every chunk listed, with per-minute rollups stored at upload time.',
- },
- {
- id: 'current',
- label: 'Current backend',
- detail: 'One summary per chunk, and only the newest 24 chunks.',
- },
-];
-
-const LATENCIES: Array<{ id: LatencyProfile; label: string }> = [
- { id: 'fast', label: 'Fast network' },
- { id: 'realistic', label: 'Realistic' },
- { id: 'slow', label: 'Slow 3G' },
-];
-
-function pick(value: string | null, allowed: readonly T[], fallback: T): T {
- return allowed.includes(value as T) ? (value as T) : fallback;
-}
-
-const selectClass =
- 'w-full rounded-md border border-border-default bg-surface px-2 py-2 text-sm text-fg-primary';
-
-export function ResourceTimelinePrototype() {
- const [params, setParams] = useSearchParams();
- const scenario = scenarioById(params.get('scenario'));
- const backend = pick(params.get('backend'), ['proposed', 'current'] as const, 'proposed');
- const latency = pick(params.get('latency'), ['fast', 'realistic', 'slow'] as const, 'realistic');
- const open = params.get('open') !== '0';
- const source = useMemo(() => createMockSource(scenario.id, backend, latency), [scenario.id, backend, latency]);
-
- const update = (key: string, value: string) =>
- setParams(
- (current) => {
- const next = new URLSearchParams(current);
- next.set(key, value);
- return next;
- },
- { replace: true }
- );
-
- return (
-
-
-
-
Prototype
-
Session resources timeline
-
- The real Resources drawer, fed by synthetic sessions shaped like production ones. Scrub with a finger,
- pinch to zoom, drag the strip under the charts, or tap a busiest moment.
-
- The workspace samples CPU, memory and disk every few seconds and uploads them every{' '}
- {formatMinutes(uploadIntervalMs)}, so the first data appears about {formatMinutes(uploadIntervalMs)} after the
- session starts.
-
- Instant sessions run in a lightweight container that does not record CPU, memory or disk usage yet. Sessions on
- a VM workspace record the full timeline.
-
- Sampled every {formatElapsed(index.sampleIntervalMs)} from the workspace container's cgroup. It covers
- everything in the container, not one process.
+ Sampled every {formatElapsed(index.sampleIntervalMs)} from the workspace
+ container's cgroup. It covers everything in the container, not one process.
CPU is in cores: 1.0 means one core fully busy.
- Memory “used” is the working set the kernel cannot reclaim; “incl. cache” adds page
- cache it can drop under pressure. Size workspaces by “used”.
+ Memory “used” is the working set the kernel cannot reclaim; “incl.
+ cache” adds page cache it can drop under pressure. Size workspaces by
+ “used”.
+
+
+ Tool calls line up with usage by time only. A spike during a tool call is a correlation,
+ not proof that the tool caused it.
- Tool calls line up with usage by time only. A spike during a tool call is a correlation, not proof that the
- tool caused it.
+ Zoomed out, each point is an average with its peak shaded; zoom in for individual
+ samples.
-
Zoomed out, each point is an average with its peak shaded; zoom in for individual samples.