fix(web): fall back to project totals when dashboard metrics are zero (#2847)

The dashboard reads elapsed time, total cost, and tokens used
exclusively from AutoDashboardData. When auto-mode is not active
(e.g. manual /gsd next), auto is null and all three metrics show 0
— even though the status bar displays real values via /api/visualizer.

Add the same projectTotals polling pattern (30s interval via
/api/visualizer) that status-bar.tsx already uses, and wire it into
the fallback chain: projectTotals ?? auto ?? 0.

Closes #2709
This commit is contained in:
NilsR0711 2026-03-28 01:09:55 +01:00 committed by GitHub
parent 6918fb76c6
commit 7c5dae0298
2 changed files with 109 additions and 3 deletions

View file

@ -1,5 +1,6 @@
"use client"
import { useEffect, useState, useCallback } from "react"
import {
Activity,
Clock,
@ -16,6 +17,7 @@ import {
useGSDWorkspaceState,
useGSDWorkspaceActions,
buildPromptCommand,
buildProjectUrl,
formatDuration,
formatCost,
formatTokens,
@ -37,6 +39,8 @@ import {
} from "@/components/gsd/loading-skeletons"
import { ScopeBadge } from "@/components/gsd/scope-badge"
import { ProjectWelcome } from "@/components/gsd/project-welcome"
import { authFetch } from "@/lib/auth"
import { type ProjectTotals } from "@/lib/visualizer-types"
/** Interpolate progress bar color from red (0%) through yellow (50%) to green (100%) using oklch. */
function getProgressColor(percent: number): string {
@ -114,10 +118,40 @@ export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {
const auto = getLiveAutoDashboard(state)
const bridge = boot?.bridge ?? null
const freshness = state.live.freshness
const projectCwd = boot?.project.cwd
const elapsed = auto?.elapsed ?? 0
const totalCost = auto?.totalCost ?? 0
const totalTokens = auto?.totalTokens ?? 0
// ── Project-level totals from visualizer API ──
// Provides fallback metrics when auto-mode is not active (#2709).
// Same polling pattern as status-bar.tsx.
const [projectTotals, setProjectTotals] = useState<ProjectTotals | null>(null)
const fetchProjectTotals = useCallback(async () => {
try {
const resp = await authFetch(buildProjectUrl("/api/visualizer", projectCwd))
if (!resp.ok) return
const json = await resp.json()
if (json.totals) setProjectTotals(json.totals)
} catch {
// Silently ignore — dashboard metrics are non-critical
}
}, [projectCwd])
useEffect(() => {
const timeout = window.setTimeout(() => {
void fetchProjectTotals()
}, 0)
const interval = window.setInterval(() => {
void fetchProjectTotals()
}, 30_000)
return () => {
window.clearTimeout(timeout)
window.clearInterval(interval)
}
}, [fetchProjectTotals])
const elapsed = projectTotals?.duration ?? auto?.elapsed ?? 0
const totalCost = projectTotals?.cost ?? auto?.totalCost ?? 0
const totalTokens = projectTotals?.tokens.total ?? auto?.totalTokens ?? 0
const rtkSavings = auto?.rtkSavings ?? null
const rtkEnabled = auto?.rtkEnabled === true

View file

@ -0,0 +1,72 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
/**
* Regression tests for dashboard metric fallback chain.
*
* The dashboard reads metrics from two sources:
* 1. projectTotals (polled from /api/visualizer always available)
* 2. auto (live auto-mode data null when auto is not active)
*
* Fallback chain: projectTotals?.X ?? auto?.X ?? 0
*
* See: https://github.com/gsd-build/gsd-2/issues/2709
*/
interface ProjectTotals {
duration: number;
cost: number;
tokens: { total: number };
}
interface AutoDashboard {
elapsed: number;
totalCost: number;
totalTokens: number;
}
/** Mirrors the fallback logic in dashboard.tsx */
function deriveMetrics(
projectTotals: ProjectTotals | null,
auto: AutoDashboard | null,
) {
return {
elapsed: projectTotals?.duration ?? auto?.elapsed ?? 0,
totalCost: projectTotals?.cost ?? auto?.totalCost ?? 0,
totalTokens: projectTotals?.tokens.total ?? auto?.totalTokens ?? 0,
};
}
describe("dashboard metric fallback (#2709 regression)", () => {
test("returns zero when both sources are null", () => {
const result = deriveMetrics(null, null);
assert.equal(result.elapsed, 0);
assert.equal(result.totalCost, 0);
assert.equal(result.totalTokens, 0);
});
test("uses auto data when projectTotals is null", () => {
const auto: AutoDashboard = { elapsed: 5000, totalCost: 1.5, totalTokens: 10000 };
const result = deriveMetrics(null, auto);
assert.equal(result.elapsed, 5000);
assert.equal(result.totalCost, 1.5);
assert.equal(result.totalTokens, 10000);
});
test("uses projectTotals when auto is null (manual mode)", () => {
const totals: ProjectTotals = { duration: 60000, cost: 3.2, tokens: { total: 50000 } };
const result = deriveMetrics(totals, null);
assert.equal(result.elapsed, 60000);
assert.equal(result.totalCost, 3.2);
assert.equal(result.totalTokens, 50000);
});
test("projectTotals takes precedence over auto when both present", () => {
const totals: ProjectTotals = { duration: 120000, cost: 5.0, tokens: { total: 80000 } };
const auto: AutoDashboard = { elapsed: 10000, totalCost: 0.5, totalTokens: 5000 };
const result = deriveMetrics(totals, auto);
assert.equal(result.elapsed, 120000, "projectTotals duration should take precedence");
assert.equal(result.totalCost, 5.0, "projectTotals cost should take precedence");
assert.equal(result.totalTokens, 80000, "projectTotals tokens should take precedence");
});
});