From 4880a0f8573c803e369edc2049b62919c0a76766 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 10:39:32 -0700 Subject: [PATCH] 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 --- .../fn-6970-command-center-token-live.md | 7 ++ .../command-center/CommandCenter.tsx | 4 + .../__tests__/CommandCenter.test.tsx | 81 ++++++++++++- .../command-center/areas/AreaShell.tsx | 3 + .../command-center/areas/TokensArea.tsx | 4 + .../areas/__tests__/areas.test.tsx | 111 +++++++++++++++++- .../command-center/areas/useAnalyticsArea.ts | 12 +- 7 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 .changeset/fn-6970-command-center-token-live.md diff --git a/.changeset/fn-6970-command-center-token-live.md b/.changeset/fn-6970-command-center-token-live.md new file mode 100644 index 0000000000..e3f109ceb7 --- /dev/null +++ b/.changeset/fn-6970-command-center-token-live.md @@ -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. diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index 6f3dfe5dac..17b8a1423f 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -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: 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; interface CommandCenterProps { diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx index cb3bbc25df..520862e3f0 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -534,11 +534,18 @@ describe("CommandCenter shell", () => { 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(); - let tokenTotal = 1_500; + let resolvePoll: ((value: ReturnType) => void) | null = null; 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/activity")) return Promise.resolve(activityFixture()); if (path.startsWith("/command-center/github")) return Promise.resolve(githubFixture()); @@ -553,16 +560,78 @@ describe("CommandCenter shell", () => { await Promise.resolve(); }); 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 () => { vi.advanceTimersByTime(15_000); 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(); }); - expect(statValue("command-center-stat-tokens")).toBe("1,700"); - expect(screen.getByTestId("command-center-live-tokens").textContent).toContain("1,700"); + expect(statValue("command-center-stat-tokens")).toBe("1,900"); + 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) => 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(); + 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 () => { diff --git a/packages/dashboard/app/components/command-center/areas/AreaShell.tsx b/packages/dashboard/app/components/command-center/areas/AreaShell.tsx index 5471547c90..9c77281c55 100644 --- a/packages/dashboard/app/components/command-center/areas/AreaShell.tsx +++ b/packages/dashboard/app/components/command-center/areas/AreaShell.tsx @@ -20,6 +20,9 @@ export interface AreaShellProps { * mirroring `ReliabilityView`'s state handling. Renders children only once * there is data to show; never crashes on an empty area (degrades to the * 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) { const { t } = useTranslation("app"); diff --git a/packages/dashboard/app/components/command-center/areas/TokensArea.tsx b/packages/dashboard/app/components/command-center/areas/TokensArea.tsx index 57cb291dd1..cfe723d521 100644 --- a/packages/dashboard/app/components/command-center/areas/TokensArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/TokensArea.tsx @@ -20,6 +20,10 @@ import { formatCost, formatCount } from "./areaShared"; 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 GRANULARITIES: TokenTimeGranularity[] = ["hour", "day", "week"]; diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index 7c9c899f39..ab05135af8 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -426,6 +426,78 @@ describe("useAnalyticsArea", () => { }); 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", () => { @@ -660,12 +732,15 @@ describe("TokensArea", () => { it("polls the live token value while preserving rendered content", async () => { vi.useFakeTimers(); + let resolvePoll: ((value: ReturnType) => void) | null = null; apiMock .mockResolvedValueOnce(tokenFixture()) - .mockResolvedValueOnce({ - ...tokenFixture(), - totals: { ...tokenFixture().totals, totalTokens: 1700 }, - }); + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePoll = resolve; + }), + ); render(); await act(async () => { @@ -673,14 +748,38 @@ describe("TokensArea", () => { await Promise.resolve(); }); 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 () => { vi.advanceTimersByTime(15_000); 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(); }); - expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,700"); - expect(screen.getByTestId("cc-token-series-chart")).toBeTruthy(); + expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,900"); + 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 () => { diff --git a/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts b/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts index 98431b2821..8c0c8f7b1f 100644 --- a/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts +++ b/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../../../api/legacy"; import type { DateRange } from "../DateRangePicker"; 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 * 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` * 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()`), @@ -45,6 +48,7 @@ export function useAnalyticsArea( options: AnalyticsAreaOptions = {}, ): AnalyticsAreaState { const [data, setData] = useState(null); + const dataRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -57,10 +61,14 @@ export function useAnalyticsArea( setIsLoading(false); return; } - setIsLoading(true); + const hasFallbackData = dataRef.current !== null; + if (!hasFallbackData) { + setIsLoading(true); + } setError(null); try { const result = await api(withRangeQuery(endpoint, query)); + dataRef.current = result; setData(result); } catch (loadError: unknown) { setError(loadError instanceof Error ? loadError.message : "Failed to load analytics");