FN-6970: fix live token usage refreshes

Keep Command Center token usage surfaces mounted and updated during live analytics polling.

- Preserve existing analytics data while background polls are in flight so token surfaces revalidate without disappearing.
- Add live refresh intervals for Overview and Tokens token-usage data.
- Cover in-place token stat, chart, and model-row updates with Command Center and area tests.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-6970-command-center-token-live.md    |   7 ++
 .../components/command-center/CommandCenter.tsx    |   4 +
 .../__tests__/CommandCenter.test.tsx               |  81 +++++++++++++--
 .../components/command-center/areas/AreaShell.tsx  |   3 +
 .../components/command-center/areas/TokensArea.tsx |   4 +
 .../command-center/areas/__tests__/areas.test.tsx  | 111 +++++++++++++++++++--
 .../command-center/areas/useAnalyticsArea.ts       |  12 ++-
 7 files changed, 208 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-6970

Fusion-Task-Lineage: 670bb5ac-bc7d-47d8-abed-ef6748292cc6
This commit is contained in:
gsxdsm
2026-06-25 10:39:32 -07:00
parent 419d58f65c
commit 4880a0f857
7 changed files with 208 additions and 14 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Command Center token usage updating live without manual refresh.
category: fix
dev: Analytics polling now revalidates in the background when prior data exists so token cards, charts, and model rows stay mounted during live refresh.

View File

@@ -94,6 +94,10 @@ The Open signals card must be real project data, not a swallowed missing-route b
FNXC:CommandCenter 2026-06-18-23:45: FNXC:CommandCenter 2026-06-18-23:45:
FN-6683 adds real Overview pie and line charts by reusing the already-fetched tokens and activity analytics. Keep the existing overview bars, sparkline, live strip, funnel, and loading/error/empty branches intact; no new endpoint is allowed for these additive affordances. FN-6683 adds real Overview pie and line charts by reusing the already-fetched tokens and activity analytics. Keep the existing overview bars, sparkline, live strip, funnel, and loading/error/empty branches intact; no new endpoint is allowed for these additive affordances.
*/ */
/*
FNXC:CommandCenterTokenLive 2026-06-25-09:06:
Overview token usage is a live surface: the stat card, live strip, and token-by-model charts must consume the latest successful `/command-center/tokens` poll without remounting the page or requiring a date-range nudge.
*/
const OVERVIEW_TOKEN_REFRESH_MS = 15_000; const OVERVIEW_TOKEN_REFRESH_MS = 15_000;
interface CommandCenterProps { interface CommandCenterProps {

View File

@@ -534,11 +534,18 @@ describe("CommandCenter shell", () => {
expect(liveMetricValue("command-center-live-tokens")).toBe("1,234,567,890"); expect(liveMetricValue("command-center-live-tokens")).toBe("1,234,567,890");
}); });
it("live-polls token totals for the Overview card and live strip", async () => { it("live-polls token totals for the Overview card, live strip, and model charts", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
let tokenTotal = 1_500; let resolvePoll: ((value: ReturnType<typeof tokenFixture>) => void) | null = null;
apiMock.mockImplementation((path: string) => { apiMock.mockImplementation((path: string) => {
if (path.startsWith("/command-center/tokens")) return Promise.resolve(tokenFixture(tokenTotal)); if (path.startsWith("/command-center/tokens")) {
if (apiMock.mock.calls.filter(([calledPath]) => typeof calledPath === "string" && calledPath.startsWith("/command-center/tokens")).length === 1) {
return Promise.resolve(tokenFixture(1_500));
}
return new Promise((resolve) => {
resolvePoll = resolve;
});
}
if (path.startsWith("/command-center/tools")) return Promise.resolve(toolsFixture()); if (path.startsWith("/command-center/tools")) return Promise.resolve(toolsFixture());
if (path.startsWith("/command-center/activity")) return Promise.resolve(activityFixture()); if (path.startsWith("/command-center/activity")) return Promise.resolve(activityFixture());
if (path.startsWith("/command-center/github")) return Promise.resolve(githubFixture()); if (path.startsWith("/command-center/github")) return Promise.resolve(githubFixture());
@@ -553,16 +560,78 @@ describe("CommandCenter shell", () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(statValue("command-center-stat-tokens")).toBe("1,500"); expect(statValue("command-center-stat-tokens")).toBe("1,500");
expect(screen.getByTestId("command-center-live-tokens").textContent).toContain("1,500");
expect(within(screen.getByTestId("command-center-overview-chart-tokens")).getByText("900")).toBeTruthy();
tokenTotal = 1_700;
await act(async () => { await act(async () => {
vi.advanceTimersByTime(15_000); vi.advanceTimersByTime(15_000);
await Promise.resolve(); await Promise.resolve();
});
expect(resolvePoll).not.toBeNull();
expect(screen.queryByTestId("command-center-overview-loading")).toBeNull();
expect(statValue("command-center-stat-tokens")).toBe("1,500");
await act(async () => {
resolvePoll?.({
...tokenFixture(1_900),
groups: [
{ ...tokenFixture().groups[0], totalTokens: 1_100, inputTokens: 700, outputTokens: 300 },
{ ...tokenFixture().groups[1], totalTokens: 800, inputTokens: 500, outputTokens: 200 },
],
});
await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
}); });
expect(statValue("command-center-stat-tokens")).toBe("1,700"); expect(statValue("command-center-stat-tokens")).toBe("1,900");
expect(screen.getByTestId("command-center-live-tokens").textContent).toContain("1,700"); expect(screen.getByTestId("command-center-live-tokens").textContent).toContain("1,900");
expect(within(screen.getByTestId("command-center-overview-chart-tokens")).getByText("1,100")).toBeTruthy();
});
it("updates mounted Overview token surfaces from empty token data to populated poll data", async () => {
vi.useFakeTimers();
let resolvePoll: ((value: ReturnType<typeof tokenFixture>) => void) | null = null;
apiMock.mockImplementation((path: string) => {
if (path.startsWith("/command-center/tokens")) {
if (apiMock.mock.calls.filter(([calledPath]) => typeof calledPath === "string" && calledPath.startsWith("/command-center/tokens")).length === 1) {
return Promise.resolve(tokenFixture(0));
}
return new Promise((resolve) => {
resolvePoll = resolve;
});
}
if (path.startsWith("/command-center/tools")) return Promise.resolve(toolsFixture(0));
if (path.startsWith("/command-center/activity")) return Promise.resolve(emptyActivityFixture());
if (path.startsWith("/command-center/github")) return Promise.resolve(githubFixture());
if (path.startsWith("/command-center/signals")) return Promise.resolve(signalsFixture(0));
if (path === "/command-center/live") return Promise.resolve(liveFixture([{ column: "in-progress", count: 0 }]));
return Promise.reject(new Error(`Unhandled api path: ${path}`));
});
render(<CommandCenter />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId("command-center-empty")).toBeTruthy();
expect(screen.queryByTestId("command-center-stat-tokens")).toBeNull();
await act(async () => {
vi.advanceTimersByTime(15_000);
await Promise.resolve();
});
expect(resolvePoll).not.toBeNull();
expect(screen.getByTestId("command-center-empty")).toBeTruthy();
await act(async () => {
resolvePoll?.(tokenFixture(1_500));
await Promise.resolve();
await Promise.resolve();
});
expect(statValue("command-center-stat-tokens")).toBe("1,500");
expect(screen.getByTestId("command-center-live-tokens").textContent).toContain("1,500");
expect(screen.getByTestId("command-center-overview-chart-tokens")).toBeTruthy();
}); });
it("sources live tasks in progress from current column counts instead of funnel entered", async () => { it("sources live tasks in progress from current column counts instead of funnel entered", async () => {

View File

@@ -20,6 +20,9 @@ export interface AreaShellProps {
* mirroring `ReliabilityView`'s state handling. Renders children only once * mirroring `ReliabilityView`'s state handling. Renders children only once
* there is data to show; never crashes on an empty area (degrades to the * there is data to show; never crashes on an empty area (degrades to the
* empty state instead). * empty state instead).
*
* FNXC:CommandCenterTokenLive 2026-06-25-09:06:
* `isLoading` is a blocking initial-load signal. Background analytics polls with fallback data must keep children rendered so live token cards/charts do not disappear while revalidating.
*/ */
export function AreaShell({ testId, isLoading, error, isEmpty, emptyMessage, children }: AreaShellProps) { export function AreaShell({ testId, isLoading, error, isEmpty, emptyMessage, children }: AreaShellProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");

View File

@@ -20,6 +20,10 @@ import { formatCost, formatCount } from "./areaShared";
type SortKey = "key" | "totalTokens" | "cost"; type SortKey = "key" | "totalTokens" | "cost";
/*
FNXC:CommandCenterTokenLive 2026-06-25-09:06:
The Tokens detail area is the canonical token-usage live view. Polling must update totals, series bars, trends, and per-model rows in place while preserving controls such as granularity and sort state.
*/
const TOKENS_LIVE_REFRESH_MS = 15_000; const TOKENS_LIVE_REFRESH_MS = 15_000;
const GRANULARITIES: TokenTimeGranularity[] = ["hour", "day", "week"]; const GRANULARITIES: TokenTimeGranularity[] = ["hour", "day", "week"];

View File

@@ -426,6 +426,78 @@ describe("useAnalyticsArea", () => {
}); });
expect(apiMock).not.toHaveBeenCalled(); expect(apiMock).not.toHaveBeenCalled();
}); });
it("treats poll refreshes as background revalidation after data has loaded", async () => {
vi.useFakeTimers();
let resolvePoll: ((value: { ok: boolean; total: number }) => void) | null = null;
apiMock
.mockResolvedValueOnce({ ok: true, total: 1 })
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePoll = resolve;
}),
);
const { result } = renderHook(() =>
useAnalyticsArea<{ ok: boolean; total: number }>("/command-center/tokens", range7d, { pollMs: 1_000 }),
);
await act(async () => {
await Promise.resolve();
});
expect(result.current.data?.total).toBe(1);
expect(result.current.isLoading).toBe(false);
await act(async () => {
vi.advanceTimersByTime(1_000);
await Promise.resolve();
});
expect(apiMock).toHaveBeenCalledTimes(2);
expect(resolvePoll).not.toBeNull();
expect(result.current.data?.total).toBe(1);
expect(result.current.isLoading).toBe(false);
await act(async () => {
resolvePoll?.({ ok: true, total: 2 });
await Promise.resolve();
});
expect(result.current.data?.total).toBe(2);
});
it("cleans up polling when a valid range becomes invalid and restarts after it becomes valid", async () => {
vi.useFakeTimers();
apiMock.mockResolvedValue({ ok: true });
const { rerender } = renderHook(
({ range }) => useAnalyticsArea<{ ok: boolean }>("/command-center/tokens", range, { pollMs: 1_000 }),
{ initialProps: { range: range7d } },
);
await act(async () => {
await Promise.resolve();
});
expect(apiMock).toHaveBeenCalledTimes(1);
rerender({ range: customRange("2026-06-10", "2026-06-01") });
await act(async () => {
vi.advanceTimersByTime(2_000);
await Promise.resolve();
});
expect(apiMock).toHaveBeenCalledTimes(1);
rerender({ range: customRange("2026-06-01", "2026-06-10") });
await act(async () => {
await Promise.resolve();
});
expect(apiMock).toHaveBeenCalledTimes(2);
await act(async () => {
vi.advanceTimersByTime(1_000);
await Promise.resolve();
});
expect(apiMock).toHaveBeenCalledTimes(3);
});
}); });
describe("ActivityArea", () => { describe("ActivityArea", () => {
@@ -660,12 +732,15 @@ describe("TokensArea", () => {
it("polls the live token value while preserving rendered content", async () => { it("polls the live token value while preserving rendered content", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
let resolvePoll: ((value: ReturnType<typeof tokenFixture>) => void) | null = null;
apiMock apiMock
.mockResolvedValueOnce(tokenFixture()) .mockResolvedValueOnce(tokenFixture())
.mockResolvedValueOnce({ .mockImplementationOnce(
...tokenFixture(), () =>
totals: { ...tokenFixture().totals, totalTokens: 1700 }, new Promise((resolve) => {
}); resolvePoll = resolve;
}),
);
render(<TokensArea range={range7d} />); render(<TokensArea range={range7d} />);
await act(async () => { await act(async () => {
@@ -673,14 +748,38 @@ describe("TokensArea", () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,500"); expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,500");
expect(screen.getByLabelText("2026-06-09: 900")).toBeTruthy();
expect(screen.getByTestId("cc-tokens-row-gpt-4o").textContent).toContain("900");
await act(async () => { await act(async () => {
vi.advanceTimersByTime(15_000); vi.advanceTimersByTime(15_000);
await Promise.resolve(); await Promise.resolve();
});
expect(resolvePoll).not.toBeNull();
expect(screen.queryByTestId("cc-area-tokens-loading")).toBeNull();
expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,500");
expect(screen.getByTestId("cc-token-series-chart")).toBeTruthy();
const updated = {
...tokenFixture(1_900),
series: [
tokenFixture().series[0],
{ ...tokenFixture().series[1], totalTokens: 1_300, inputTokens: 900, outputTokens: 400 },
],
groups: [
{ ...tokenFixture().groups[0], totalTokens: 1_100, inputTokens: 700, outputTokens: 300 },
{ ...tokenFixture().groups[1], totalTokens: 800, inputTokens: 500, outputTokens: 200 },
],
};
await act(async () => {
resolvePoll?.(updated);
await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
}); });
expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,700"); expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,900");
expect(screen.getByTestId("cc-token-series-chart")).toBeTruthy(); expect(screen.getByLabelText("2026-06-09: 1,300")).toBeTruthy();
expect(screen.getByTestId("cc-tokens-row-gpt-4o").textContent).toContain("1,100");
expect(screen.getByTestId("cc-tokens-line")).toBeTruthy();
}); });
it("refetches when the date range changes", async () => { it("refetches when the date range changes", async () => {

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../../api/legacy"; import { api } from "../../../api/legacy";
import type { DateRange } from "../DateRangePicker"; import type { DateRange } from "../DateRangePicker";
import { isInvalidRange, rangeQuery } from "./areaShared"; import { isInvalidRange, rangeQuery } from "./areaShared";
@@ -34,6 +34,9 @@ function withRangeQuery(endpoint: string, query: string): string {
* - Polling is opt-in via `options.pollMs`; invalid ranges never schedule an * - Polling is opt-in via `options.pollMs`; invalid ranges never schedule an
* interval, and the interval is cleaned up on unmount/range/endpoint changes. * interval, and the interval is cleaned up on unmount/range/endpoint changes.
* *
* FNXC:CommandCenterTokenLive 2026-06-25-09:06:
* Live token refresh is background revalidation after the first successful payload, not a loading-state replacement. Command Center must keep Overview and Tokens surfaces mounted while a 15s poll is in flight so increased token totals render without manual refresh or range toggles.
*
* NOTE on the SWR-identity trap: this hook intentionally replaces `data` * NOTE on the SWR-identity trap: this hook intentionally replaces `data`
* identity on every successful fetch. Consumers MUST key any selection / sort / * identity on every successful fetch. Consumers MUST key any selection / sort /
* drill-down reset effect on a DERIVED value (e.g. `rows.map(r => r.id).join()`), * drill-down reset effect on a DERIVED value (e.g. `rows.map(r => r.id).join()`),
@@ -45,6 +48,7 @@ export function useAnalyticsArea<T>(
options: AnalyticsAreaOptions = {}, options: AnalyticsAreaOptions = {},
): AnalyticsAreaState<T> { ): AnalyticsAreaState<T> {
const [data, setData] = useState<T | null>(null); const [data, setData] = useState<T | null>(null);
const dataRef = useRef<T | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -57,10 +61,14 @@ export function useAnalyticsArea<T>(
setIsLoading(false); setIsLoading(false);
return; return;
} }
setIsLoading(true); const hasFallbackData = dataRef.current !== null;
if (!hasFallbackData) {
setIsLoading(true);
}
setError(null); setError(null);
try { try {
const result = await api<T>(withRangeQuery(endpoint, query)); const result = await api<T>(withRangeQuery(endpoint, query));
dataRef.current = result;
setData(result); setData(result);
} catch (loadError: unknown) { } catch (loadError: unknown) {
setError(loadError instanceof Error ? loadError.message : "Failed to load analytics"); setError(loadError instanceof Error ? loadError.message : "Failed to load analytics");