mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
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:
parent
12e665c341
commit
06ee2fea39
4 changed files with 74 additions and 44 deletions
|
|
@ -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">
|
||||
|
|
|
|||
52
apps/fabro-web/app/components/stage-sidebar.test.tsx
Normal file
52
apps/fabro-web/app/components/stage-sidebar.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue