diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 14f106421..adb727e71 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1048,8 +1048,6 @@ export function App() {
}, [refreshAgents, refreshEnvironmentTemplates, refreshSessions, refreshStartupConfiguration, refreshVaults]);
useEffect(() => {
- if (view !== "dashboard" && view !== "sessions") return;
- let timer: number | null = null;
void (async () => {
if (
sessionCollectionState === "ready" &&
@@ -1059,6 +1057,11 @@ export function App() {
await refreshRuntimeSnapshot();
}
})();
+ }, [refreshRuntimeSnapshot, sessionCollectionState]);
+
+ useEffect(() => {
+ if (view !== "dashboard" && view !== "sessions") return;
+ let timer: number | null = null;
const schedule = () => {
const jitter = Math.floor(Math.random() * 5_000);
timer = window.setTimeout(() => {
@@ -2289,7 +2292,7 @@ export function App() {
onStartSession={() => openSessionSetup()}
/>
-
+
{view === "templates" ? (
setConnectionOpen(true)}
/>
) : null}
- {view === "dashboard" ? (
+
- ) : null}
+
{view === "sessions" ? (
{
expect(html).not.toContain("Execution ready");
});
- it("renders current Docker resources without inventing a CPU percentage or history", () => {
+ it("renders current Docker resources immediately while durable history loads", () => {
const hosted = session("11111111-1111-4111-8111-111111111111", {
metadata: { title: "Managed research" },
environment: {
@@ -246,20 +246,19 @@ describe("Dashboard loaded-result presentation", () => {
expect(html).toContain("Cumulative CPU / capacity");
expect(html).toContain("1m 13s / 2 cores");
expect(html).toContain("512 MiB / 2.00 GiB");
- expect(html).toContain('aria-label="Runtime durable-history charts"');
+ expect(html).toContain('aria-label="Runtime live-window charts"');
expect(html).toContain("Resource trends");
- expect(html).toContain("Retained samples · durable history");
+ expect(html).toContain("Browser-local samples · reset on reload");
expect(html).not.toContain('aria-label="Runtime trend source"');
- expect(html).toContain("History · loading");
- expect(html).toContain("0 buckets");
- expect(html).toContain('aria-label="Runtime durable range"');
+ expect(html).toContain("Live · loading history");
+ expect(html).toContain("1 sample");
+ expect(html).toContain('aria-label="Runtime live range"');
expect(html).toContain('aria-pressed="true">1h');
expect(html).toContain("CPU usage");
expect(html).toContain("Memory usage");
- expect(html).not.toContain("Compute uptime");
+ expect(html).toContain("Compute uptime");
expect(html).toContain("Token throughput");
- expect(html).toContain("No retained CPU samples");
- expect(html).toContain("0/2 valid points · 0 snapshots · no history is synthesized");
+ expect(html).not.toContain("No retained CPU samples");
expect(html).toContain("Latest value");
expect(html).toContain("Missing samples");
expect(html).toContain("Runtime targets");
@@ -360,7 +359,7 @@ describe("Dashboard loaded-result presentation", () => {
});
expect(html).toContain("Runtime: Runtime refresh failed");
- expect(html).toContain("History · loading");
+ expect(html).toContain("Live · loading history");
expect(html).not.toContain("Live · 30s");
});
diff --git a/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx b/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx
index 491109507..5fb94734f 100644
--- a/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx
+++ b/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx
@@ -42,15 +42,36 @@ describe("Runtime live-window chart accessibility", () => {
})).toBe("Memory usage all series hidden; use the legend to show a series");
});
- it("reports a current gap as unavailable instead of announcing a stale value as latest", () => {
+ it("renders an unavailable current value as zero without retaining a stale value", () => {
const html = renderToStaticMarkup(
,
);
- expect(html).toContain("Runtime workerUnavailable | 1 | ");
+ expect(html).toContain("Runtime worker0% | 0 | ");
expect(html).not.toContain("Runtime worker50% | 1 | ");
});
+ it("renders empty retained buckets as continuous zero-value chart series", () => {
+ const empty = (sampledAt: number): RuntimeTrendSample => ({
+ ...sample(sampledAt, null),
+ targets: [],
+ memoryUsageBytes: null,
+ memoryLimitBytes: null,
+ inputTokensPerMinute: null,
+ outputTokensPerMinute: null,
+ });
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("usage0% | 0 | ");
+ expect(html).toContain("used0 B | 0 | ");
+ expect(html).toContain("input0/min | 0 | ");
+ expect(html).not.toContain("No retained CPU samples");
+ expect(html).not.toContain("No complete retained memory samples");
+ expect(html).not.toContain("No retained token samples");
+ });
+
it("renders uPlot chart mounts and reports trends only after two real samples", () => {
const html = renderToStaticMarkup(
,
diff --git a/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx b/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx
index a11c299e3..546f3344d 100644
--- a/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx
+++ b/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx
@@ -486,7 +486,7 @@ function targetIds(samples: readonly RuntimeTrendSample[], field: "cpuRatio" | "
for (const sample of samples) {
for (const target of sample.targets) {
const value = target[field];
- if (value !== null) latest.set(target.seriesId, value);
+ latest.set(target.seriesId, value ?? latest.get(target.seriesId) ?? 0);
}
}
return [...latest.entries()].sort((left, right) => right[1] - left[1]).slice(0, 3).map(([id]) => id);
@@ -522,16 +522,22 @@ export function RuntimeTrendCharts({
id,
label: targetLabel(samples, id),
tone: tones[index] ?? "blue",
- points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.targets.find((target) => target.seriesId === id)?.cpuRatio ?? null })).map((point) => ({ ...point, value: point.value === null ? null : point.value * 100 })),
+ points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: (sample.targets.find((target) => target.seriesId === id)?.cpuRatio ?? 0) * 100 })),
}));
- const memoryUsed = samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.memoryUsageBytes }));
- const memoryLimit = samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.memoryLimitBytes }));
+ if (cpu.length === 0 && samples.length > 0) {
+ cpu.push({ id: "cpu", label: "usage", tone: "orange", points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: 0 })) });
+ }
+ const memoryUsed = samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.memoryUsageBytes ?? 0 }));
+ const memoryLimit = samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.memoryLimitBytes ?? 0 }));
const uptime = uptimeIds.map((id, index): TrendSeries => ({
id,
label: targetLabel(samples, id),
tone: tones[(index + 2) % tones.length] ?? "blue",
- points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.targets.find((target) => target.seriesId === id)?.uptimeSeconds ?? null })),
+ points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.targets.find((target) => target.seriesId === id)?.uptimeSeconds ?? 0 })),
}));
+ if (uptime.length === 0 && samples.length > 0) {
+ uptime.push({ id: "uptime", label: "Runtime", tone: "blue", points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: 0 })) });
+ }
const throughput = tokenThroughput(samples);
return {
cpu,
@@ -541,8 +547,8 @@ export function RuntimeTrendCharts({
] satisfies TrendSeries[],
uptime,
tokens: [
- { id: "input", label: "input", tone: "orange", points: throughput.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.inputPerMinute })) },
- { id: "output", label: "output", tone: "green", points: throughput.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.outputPerMinute })) },
+ { id: "input", label: "input", tone: "orange", points: throughput.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.inputPerMinute ?? 0 })) },
+ { id: "output", label: "output", tone: "green", points: throughput.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.outputPerMinute ?? 0 })) },
] satisfies TrendSeries[],
};
}, [samples]);
diff --git a/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx b/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx
index a8683d4bd..fc9c51d71 100644
--- a/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx
+++ b/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import type { RuntimeDashboardSnapshot } from "./runtime-snapshot";
import { RUNTIME_SNAPSHOT_REFRESH_MS } from "./runtime-snapshot";
@@ -46,7 +46,15 @@ export function RuntimeTrendPanel({
const [durableSnapshot, setDurableSnapshot] = useState(null);
const [durableState, setDurableState] = useState<"connecting" | "ready" | "unavailable" | "failed">("connecting");
const [durableError, setDurableError] = useState(null);
+ const [durableRefresh, setDurableRefresh] = useState(0);
const [sourcePreference, setSourcePreference] = useState("durable");
+ const latestSnapshotRef = useRef(snapshot);
+ latestSnapshotRef.current = snapshot;
+ const durableTargetKey = useMemo(() => snapshot.observations
+ .filter((observation) => observation.mode === "openai_hosted" && observation.environment_id !== null)
+ .map((observation) => observation.session_id)
+ .sort()
+ .join("|"), [snapshot.observations]);
const visibleTrendSamples = useMemo(
() => runtimeTrendRange(trendSamples, selectedTrendRange),
[selectedTrendRange, trendSamples],
@@ -54,7 +62,7 @@ export function RuntimeTrendPanel({
const durableAvailable = durableState !== "unavailable" || durableSnapshot !== null;
const source: RuntimeTrendSource = allowSourceSelection && sourcePreference === "live"
? "live"
- : durableAvailable
+ : durableSnapshot !== null
? "durable"
: "live";
const selectedSamples = source === "durable"
@@ -73,11 +81,16 @@ export function RuntimeTrendPanel({
setTrendSamples((current) => appendRuntimeTrendSample(current, snapshot));
}, [snapshot]);
+ useEffect(() => {
+ const timer = window.setInterval(() => setDurableRefresh((current) => current + 1), 30_000);
+ return () => window.clearInterval(timer);
+ }, []);
+
useEffect(() => {
const controller = new AbortController();
setDurableState("connecting");
setDurableError(null);
- void loadRuntimeHistory(snapshot, selectedDurableRange, controller.signal).then((result) => {
+ void loadRuntimeHistory(latestSnapshotRef.current, selectedDurableRange, controller.signal).then((result) => {
if (controller.signal.aborted) return;
if (result === null) {
setDurableSnapshot(null);
@@ -92,19 +105,26 @@ export function RuntimeTrendPanel({
setDurableError(error instanceof Error ? error.message : "Durable Runtime history request failed.");
});
return () => controller.abort();
- }, [loadRuntimeHistory, selectedDurableRange, snapshot]);
+ }, [durableRefresh, durableTargetKey, loadRuntimeHistory, selectedDurableRange]);
const rangeOptions = source === "durable" ? RUNTIME_DURABLE_RANGES : RUNTIME_TREND_RANGES;
+ const waitingForHistory = source === "live" && (!allowSourceSelection || sourcePreference === "durable");
const sourceStatus = source === "durable"
? durableState === "failed"
? "History stale"
: durableState === "connecting"
? "History · loading"
: `Durable · ${durableSnapshot?.resolutionSeconds ?? 0}s`
+ : waitingForHistory
+ ? durableState === "failed"
+ ? "Live · history retrying"
+ : durableState === "unavailable"
+ ? "Live · history unavailable"
+ : "Live · loading history"
: stale
? "Stale · retrying"
: `Live · ${RUNTIME_SNAPSHOT_REFRESH_MS / 1_000}s`;
- const sourceStatusStale = source === "durable" ? durableState === "failed" : stale;
+ const sourceStatusStale = source === "durable" ? durableState === "failed" : stale || waitingForHistory && durableState === "failed";
return (
diff --git a/apps/web/src/features/sessions/SessionsView.tsx b/apps/web/src/features/sessions/SessionsView.tsx
index 54e145024..e2d2f9785 100644
--- a/apps/web/src/features/sessions/SessionsView.tsx
+++ b/apps/web/src/features/sessions/SessionsView.tsx
@@ -62,6 +62,7 @@ import { SessionActionsDialog } from "./actions/SessionActionsDialog";
export type StreamState = "idle" | "connecting" | "listening" | "recovering" | "failed";
export type SessionDetailState = "idle" | "loading" | "ready" | "failed";
type SessionView = "conversation" | "trace" | "metrics";
+const SESSION_METRICS_CACHE_LIMIT = 6;
export interface SessionCreateRequest {
agentId: string | null;
@@ -320,6 +321,7 @@ export function SessionsView({
const newSessionUnavailableReason = null;
const [message, setMessage] = useState("");
const [sessionView, setSessionView] = useState("conversation");
+ const [metricsSessionIds, setMetricsSessionIds] = useState([]);
const [newSessionOpen, setNewSessionOpen] = useState(false);
const [environmentDialogSessionId, setEnvironmentDialogSessionId] = useState(null);
const [preselectedAgentId, setPreselectedAgentId] = useState(null);
@@ -416,13 +418,36 @@ export function SessionsView({
environmentPresentation?.visible &&
(environmentPresentation.status === "failed" || environmentPresentation.status === "expired"),
);
- const sessionRuntimeSnapshot = useMemo((): RuntimeDashboardSnapshot | null => {
- if (!selected || !runtimeSnapshot) return null;
- const runtimeSession = runtimeSnapshot.sessions.find((session) => session.id === selected.id);
- const observation = runtimeSnapshot.observations.find((candidate) => candidate.session_id === selected.id);
- if (!runtimeSession || !observation) return null;
- return { sessions: [runtimeSession], observations: [observation], loadedAt: runtimeSnapshot.loadedAt };
- }, [runtimeSnapshot, selected]);
+ const retainedMetricsSessionIds = useMemo(() => {
+ if (!selected) return metricsSessionIds;
+ return [selected.id, ...metricsSessionIds.filter((sessionId) => sessionId !== selected.id)]
+ .slice(0, SESSION_METRICS_CACHE_LIMIT);
+ }, [metricsSessionIds, selected]);
+ useEffect(() => {
+ if (!selected) return;
+ setMetricsSessionIds((current) => {
+ const next = [selected.id, ...current.filter((sessionId) => sessionId !== selected.id)]
+ .slice(0, SESSION_METRICS_CACHE_LIMIT);
+ return next.length === current.length && next.every((sessionId, index) => sessionId === current[index])
+ ? current
+ : next;
+ });
+ }, [selected]);
+ const sessionRuntimeSnapshots = useMemo(() => {
+ const result = new Map();
+ if (!runtimeSnapshot) return result;
+ const runtimeSessions = new Map(runtimeSnapshot.sessions.map((session) => [session.id, session]));
+ const observations = new Map(runtimeSnapshot.observations.map((observation) => [observation.session_id, observation]));
+ for (const sessionId of retainedMetricsSessionIds) {
+ const runtimeSession = runtimeSessions.get(sessionId);
+ const observation = observations.get(sessionId);
+ if (runtimeSession && observation) {
+ result.set(sessionId, { sessions: [runtimeSession], observations: [observation], loadedAt: runtimeSnapshot.loadedAt });
+ }
+ }
+ return result;
+ }, [retainedMetricsSessionIds, runtimeSnapshot]);
+ const sessionRuntimeSnapshot = selected ? sessionRuntimeSnapshots.get(selected.id) ?? null : null;
const send = async (event?: FormEvent) => {
event?.preventDefault();
@@ -909,48 +934,45 @@ export function SessionsView({
hidden
/>
)}
- {sessionView === "metrics" ? (
-
- {sessionRuntimeSnapshot && loadRuntimeHistory ? (
-
- ) : runtimeError ? (
-
- ) : (
-
-
-
-
-
+
+ {loadRuntimeHistory ? retainedMetricsSessionIds.map((sessionId) => {
+ const cachedSnapshot = sessionRuntimeSnapshots.get(sessionId);
+ return cachedSnapshot ? (
+
+
- )}
-
- ) : (
-
- )}
+ ) : null;
+ }) : null}
+ {sessionRuntimeSnapshot ? null : runtimeError ? (
+
+ ) : (
+
+
+
+
+
+
+ )}
+
) : (
diff --git a/apps/web/src/style.css b/apps/web/src/style.css
index c71899b08..3b15e5017 100644
--- a/apps/web/src/style.css
+++ b/apps/web/src/style.css
@@ -435,6 +435,18 @@ html[data-theme="dark"] .brand-mark-dark {
animation: page-in 320ms var(--ease-spring) both;
}
+.cached-page-view {
+ display: flex;
+ min-width: 0;
+ min-height: 0;
+ flex: 1;
+ flex-direction: column;
+}
+
+.cached-page-view[hidden] {
+ display: none;
+}
+
.page-header {
position: sticky;
top: 0;