From 06ee2fea39a9e367134990d7ca88d2b6cf9f73ed Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 21 May 2026 18:10:31 -0400 Subject: [PATCH] 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) --- .../components/stage-renderers/meta-bar.tsx | 23 ++------ .../app/components/stage-sidebar.test.tsx | 52 +++++++++++++++++++ .../app/components/stage-sidebar.tsx | 31 ++--------- apps/fabro-web/app/lib/time.ts | 12 +++++ 4 files changed, 74 insertions(+), 44 deletions(-) create mode 100644 apps/fabro-web/app/components/stage-sidebar.test.tsx diff --git a/apps/fabro-web/app/components/stage-renderers/meta-bar.tsx b/apps/fabro-web/app/components/stage-renderers/meta-bar.tsx index ace525c94..3b5e2dd9f 100644 --- a/apps/fabro-web/app/components/stage-renderers/meta-bar.tsx +++ b/apps/fabro-web/app/components/stage-renderers/meta-bar.tsx @@ -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 = ( diff --git a/apps/fabro-web/app/components/stage-sidebar.test.tsx b/apps/fabro-web/app/components/stage-sidebar.test.tsx new file mode 100644 index 000000000..1f25de404 --- /dev/null +++ b/apps/fabro-web/app/components/stage-sidebar.test.tsx @@ -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 { + 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( + + + , + ); + }); + 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"); + }); +}); diff --git a/apps/fabro-web/app/components/stage-sidebar.tsx b/apps/fabro-web/app/components/stage-sidebar.tsx index 343e1da02..a71ef183e 100644 --- a/apps/fabro-web/app/components/stage-sidebar.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.tsx @@ -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>(new Map()); - - // Track start times for running stages - useEffect(() => { - const running = new Set( - 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; } diff --git a/apps/fabro-web/app/lib/time.ts b/apps/fabro-web/app/lib/time.ts index fb6f9b87f..cdeda43b6 100644 --- a/apps/fabro-web/app/lib/time.ts +++ b/apps/fabro-web/app/lib/time.ts @@ -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);