fix(web): measure active stage duration from startedAt

The left sidebar tracked when it first *observed* a running stage
(Date.now() on mount) instead of the stage's actual startedAt, so the
duration reset to 0s on every page load.

Compute elapsed time directly from stage.startedAt via a shared
elapsedSecsSince helper, dropping the runningStartRef tracking. The
stage meta bar already did this correctly but with a duplicated parser;
fold it onto the same helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-21 18:10:31 -04:00
parent 12e665c341
commit 06ee2fea39
No known key found for this signature in database
4 changed files with 74 additions and 44 deletions

View file

@ -3,23 +3,9 @@ import { ClockIcon } from "@heroicons/react/20/solid";
import type { Stage } from "../stage-sidebar";
import { Tooltip } from "../ui";
import { formatAbsoluteTs } from "../../lib/format";
import { formatAbsoluteTs, formatDurationSecs } from "../../lib/format";
import { ACTIVE_STAGE_STATES, stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar";
import { useTickingNow } from "../../lib/time";
function liveDuration(startedAt: string | null, fallback: string): string {
if (!startedAt) return fallback;
const startMs = Date.parse(startedAt);
if (Number.isNaN(startMs)) return fallback;
const secs = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
if (secs < 60) return `${secs}s`;
const minutes = Math.floor(secs / 60);
const remainSecs = secs % 60;
if (minutes < 60) return remainSecs > 0 ? `${minutes}m ${remainSecs}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainMin = minutes % 60;
return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`;
}
import { elapsedSecsSince, useTickingNow } from "../../lib/time";
/**
* Compact horizontal status strip used at the top of every specialized stage
@ -37,8 +23,9 @@ export function StageMetaBar({
}) {
const isActive = ACTIVE_STAGE_STATES.has(stage.status);
// Re-render every second while running so the elapsed clock keeps up.
useTickingNow(isActive);
const duration = isActive ? liveDuration(stage.startedAt, stage.duration) : stage.duration;
const now = useTickingNow(isActive);
const liveSecs = isActive ? elapsedSecsSince(stage.startedAt, now) : null;
const duration = liveSecs !== null ? formatDurationSecs(liveSecs) : stage.duration;
const durationNode = (
<span className="inline-flex items-center gap-1 font-mono tabular-nums text-fg-muted">

View file

@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { StageSidebar, type Stage } from "./stage-sidebar";
function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
id: "implement@1",
name: "implement",
handler: "agent",
nodeId: "implement",
visit: 1,
status: "running",
duration: "--",
startedAt: null,
...overrides,
};
}
function renderSidebar(stages: Stage[]): string {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/runs/run-1"]}>
<StageSidebar stages={stages} runId="run-1" />
</MemoryRouter>,
);
});
return JSON.stringify(renderer.toJSON());
}
describe("StageSidebar duration", () => {
test("active stage duration is measured from startedAt, not page load", () => {
const stage = makeStage({
status: "running",
startedAt: new Date(Date.now() - 90_000).toISOString(),
});
expect(renderSidebar([stage])).toContain("1m 30s");
});
test("active stage with no startedAt falls back to provided duration", () => {
const stage = makeStage({ status: "running", startedAt: null, duration: "--" });
expect(renderSidebar([stage])).toContain("--");
});
test("finished stage shows its final duration", () => {
const stage = makeStage({ status: "succeeded", duration: "2m 10s" });
expect(renderSidebar([stage])).toContain("2m 10s");
});
});

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, type ComponentType } from "react";
import { type ComponentType } from "react";
import { Link } from "react-router";
import type { StageHandler, StageState } from "@qltysh/fabro-api-client";
import {
@ -18,7 +18,7 @@ import {
} from "@heroicons/react/24/outline";
import { formatDurationSecs } from "../lib/format";
import { ACTIVE_STAGE_STATES, formatStageLabel } from "../lib/stage-sidebar";
import { useTickingNow } from "../lib/time";
import { elapsedSecsSince, useTickingNow } from "../lib/time";
export interface Stage {
id: string;
@ -50,35 +50,14 @@ interface StageSidebarProps {
}
export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) {
// Track when we first observed each running stage (for ticking timer)
const runningStartRef = useRef<Map<string, number>>(new Map());
// Track start times for running stages
useEffect(() => {
const running = new Set<string>(
stages.filter((s) => ACTIVE_STAGE_STATES.has(s.status)).map((s) => s.id),
);
for (const stageId of running) {
if (!runningStartRef.current.has(stageId)) {
runningStartRef.current.set(stageId, Date.now());
}
}
for (const stageId of runningStartRef.current.keys()) {
if (!running.has(stageId)) {
runningStartRef.current.delete(stageId);
}
}
}, [stages]);
// Tick every second while any stage is running
// Tick every second while any stage is running so the elapsed clock keeps up.
const hasActive = stages.some((s) => ACTIVE_STAGE_STATES.has(s.status));
const now = useTickingNow(hasActive);
function stageDuration(stage: Stage): string {
if (ACTIVE_STAGE_STATES.has(stage.status)) {
const start = runningStartRef.current.get(stage.id);
if (start) return formatDurationSecs(Math.floor((now - start) / 1000));
return "0s";
const secs = elapsedSecsSince(stage.startedAt, now);
if (secs !== null) return formatDurationSecs(secs);
}
return stage.duration;
}

View file

@ -16,6 +16,18 @@ export function useTickingNow(active: boolean, intervalMs = 1000): number {
return now;
}
/**
* Whole seconds elapsed since an ISO 8601 timestamp, or `null` when the
* timestamp is missing or unparseable. Never negative. Pass the value from
* `useTickingNow` as `now` so the count advances on each tick.
*/
export function elapsedSecsSince(startedAt: string | null, now: number = Date.now()): number | null {
if (!startedAt) return null;
const startMs = Date.parse(startedAt);
if (Number.isNaN(startMs)) return null;
return Math.max(0, Math.floor((now - startMs) / 1000));
}
function relativeTime(seconds: number, past: boolean): string {
if (seconds < 60) return past ? "just now" : "in <1m";
const minutes = Math.floor(seconds / 60);