From 368f1e0e45b5e90defc0be90b8cd6d09d1f58595 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 10:34:15 -0700 Subject: [PATCH] FN-7117: show all token models in Command Center Show full-fidelity model token breakdowns in Command Center while keeping Overview compact. - Remove the Tokens detail chart cap so bars, pies, and tables include every analytics model bucket. - Keep Overview model summaries explicitly limited to the top consumers with a named cap. - Add dashboard coverage and docs for full Tokens attribution versus Overview summaries. - Add a patch changeset for the published CLI bundle. Files changed: .changeset/fn-7117-tokens-by-model.md | 7 ++ docs/dashboard-guide.md | 5 +- .../components/command-center/CommandCenter.tsx | 8 ++- .../__tests__/CommandCenter.test.tsx | 78 +++++++++++++++++++++- .../components/command-center/areas/TokensArea.tsx | 38 ++++++----- .../areas/__tests__/TokensArea.test.tsx | 68 +++++++++++++++++++ 6 files changed, 182 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-7117 Fusion-Task-Lineage: 7bf4c090-1054-40d0-97bf-e0e495077af5 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7117-tokens-by-model.md | 7 ++ docs/dashboard-guide.md | 5 +- .../command-center/CommandCenter.tsx | 8 +- .../__tests__/CommandCenter.test.tsx | 78 ++++++++++++++++++- .../command-center/areas/TokensArea.tsx | 38 ++++----- .../areas/__tests__/TokensArea.test.tsx | 68 ++++++++++++++++ 6 files changed, 182 insertions(+), 22 deletions(-) create mode 100644 .changeset/fn-7117-tokens-by-model.md diff --git a/.changeset/fn-7117-tokens-by-model.md b/.changeset/fn-7117-tokens-by-model.md new file mode 100644 index 0000000000..75e77f637f --- /dev/null +++ b/.changeset/fn-7117-tokens-by-model.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show every used model in Command Center token-by-model detail charts. +category: fix +dev: Removes the Tokens detail chart cap while keeping Overview explicitly top-N. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 499dd118cd..abb77dcce0 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -844,11 +844,12 @@ Features: + - **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes AI engine stop/start backed by `globalPause`, live scheduler status from executor stats, the shared Global Max Concurrent slider backed by `/api/global-concurrency`, range sliders for `maxConcurrent`, `maxTriageConcurrent`, and `maxWorktrees` that persist through `/api/settings`, and a compact theme dropdown with the same color-chip swatches and Shadcn variant list as Settings → Appearance. The four concurrency sliders ask for confirmation after a changed value settles; confirming persists the new cap, while cancel, backdrop, or Escape dismissal reverts to the last persisted value without saving. The global and current-project max-concurrent sliders show running-agent counts plus a current-use dot on the track once utilization data loads; triage and worktree sliders remain cap-only. These controls reuse existing APIs and App-level theme setters; they do not add a new backend route or second theme owner. -- **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with the existing tokens-by-model bar, tool-category bar, real recharts token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. +- **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with the existing top-model-consumers bar, tool-category bar, top-model token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. -- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. Per-model and per-provider breakdowns use the task's analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. Estimated cost is derived at read time from recorded token counts multiplied by the effective per-model pricing table: Settings → Global Models pricing overrides win first, then the built-in fallback table is used. It is not persisted, so historical rows stay tied to current maintained prices instead of stale stored billing truth. The Tokens area shows a **prices as of** date/source for the effective table, marks pricing older than the staleness threshold as low-confidence, and shows cost unavailable for models with no pricing entry rather than guessing a price. It includes the existing token-usage-over-time chart, an additive recharts multi-series line graph, and a token-share pie backed by the same grouped token analytics; use the granularity control to switch the time-series request between hourly, daily, and weekly buckets. The token total and charts poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users. +- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. Per-model and per-provider breakdowns use the task's analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. Estimated cost is derived at read time from recorded token counts multiplied by the effective per-model pricing table: Settings → Global Models pricing overrides win first, then the built-in fallback table is used. It is not persisted, so historical rows stay tied to current maintained prices instead of stale stored billing truth. The Tokens area shows a **prices as of** date/source for the effective table, marks pricing older than the staleness threshold as low-confidence, and shows cost unavailable for models with no pricing entry rather than guessing a price. It includes the existing token-usage-over-time chart, an additive recharts multi-series line graph, a full token-by-model bar, and a token-share pie backed by every grouped model returned by token analytics; use the granularity control to switch the time-series request between hourly, daily, and weekly buckets. The token total and charts poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users. - **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. The area keeps the existing category bar and adds a recharts category-share pie from `ToolAnalytics.byCategory`. There is intentionally no tools line chart yet because `ToolAnalytics` does not expose a per-day tool trend; the dashboard does not fabricate one or call a new endpoint. - **Activity** tracks sessions, messages, active nodes, active agents, agent heartbeat runs, and stickiness. Agent-run sheets show total, active, completed, and failed runs for the selected range, and the Agent runs/day sparkline trends runs by `agentRuns.startedAt`. The area keeps the existing live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`), and adds a recharts multi-series line graph for messages, active agents, and agent runs plus an agent-run outcome pie from the existing `agentRuns` split. These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users. - **Productivity** separates outcome counters (commits and pull requests), task-duration stats, and volume proxies such as modified files, lines changed, and files by language. The task-duration block counts done tasks completed in the selected range and shows average, median, p90, and total active execution time from `cumulativeActiveMs`; when no qualifying duration data exists, duration values render the unavailable `—` sentinel rather than `0`. The Lines changed card includes **Preview LOC backfill**, an explicit operator control for historical commit-association diff stats. Preview runs the project-scoped backfill in dry-run mode by default and reports scanned rows, distinct commits, updated rows, skipped unavailable commits, and skipped invalid SHAs without writing; **Apply backfill** appears after a preview and requires danger confirmation before persisting additions/deletions to `task_commit_associations`, then renders the same counts as an applied report. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called. diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index 4833420e75..5015619e95 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -49,6 +49,12 @@ interface SubView { label: string; } +/* +FNXC:CommandCenter 2026-06-27-09:45: +Overview remains an intentionally compact top-consumers summary because its chart copy promises a top-N snapshot, while the Tokens detail area shows every used model. Keep the bar and pie on this same capped source so the two Overview cards never disagree about which models are summarized. +*/ +const OVERVIEW_TOKEN_MODEL_LIMIT = 8; + /* FNXC:CommandCenter 2026-06-18-16:57: Team tab shows each agent's tokens/cost/files-changed/tasks-completed with live status and bar charts, reusing existing analytics primitives; GitHub-issue per-agent stats are FN-6653, not here. @@ -182,7 +188,7 @@ function OverviewTab({ () => [...(tokens.data?.groups ?? [])] .sort((a, b) => b.totalTokens - a.totalTokens || (a.key ?? "").localeCompare(b.key ?? "")) - .slice(0, 8) + .slice(0, OVERVIEW_TOKEN_MODEL_LIMIT) .map((g) => { const label = g.key ?? t("commandCenter.tokens.unknownModel", "(unknown)"); return { 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 eed82fbb00..ef61a74d9e 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -48,6 +48,22 @@ function providerIconIn(element: HTMLElement, provider: string): Element | null return element.querySelector(`.provider-icon[data-provider="${provider}"]`); } +function makeTokenGroup(key: string | null, totalTokens: number) { + const inputTokens = Math.round(totalTokens * 0.6); + const outputTokens = Math.round(totalTokens * 0.3); + const cachedTokens = totalTokens - inputTokens - outputTokens; + return { + key, + inputTokens, + outputTokens, + cachedTokens, + cacheWriteTokens: 0, + totalTokens, + nTasks: 1, + cost: { usd: key === null ? null : totalTokens / 1_000, unavailable: key === null, stale: false }, + }; +} + function tokenFixture(totalTokens = 1_500) { return { from: "2026-06-08", @@ -90,6 +106,40 @@ function tokenFixture(totalTokens = 1_500) { }; } +function manyModelTokenFixture() { + const groups = [ + makeTokenGroup("model-01", 2_000), + makeTokenGroup("model-02", 1_900), + makeTokenGroup("model-03", 1_800), + makeTokenGroup("model-04", 1_700), + makeTokenGroup("model-05", 1_600), + makeTokenGroup("model-06", 1_500), + makeTokenGroup("model-07", 1_400), + makeTokenGroup("model-08", 1_300), + makeTokenGroup("model-09", 1_200), + makeTokenGroup(null, 1_100), + makeTokenGroup("(unknown)", 1_000), + ]; + const totals = groups.reduce( + (acc, group) => ({ + inputTokens: acc.inputTokens + group.inputTokens, + outputTokens: acc.outputTokens + group.outputTokens, + cachedTokens: acc.cachedTokens + group.cachedTokens, + cacheWriteTokens: acc.cacheWriteTokens + group.cacheWriteTokens, + totalTokens: acc.totalTokens + group.totalTokens, + nTasks: acc.nTasks + group.nTasks, + }), + { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 }, + ); + + return { + ...tokenFixture(totals.totalTokens), + totals, + cost: { usd: null, unavailable: true, stale: false }, + groups, + }; +} + function toolsFixture(toolCalls = 30) { return { from: "2026-06-08", @@ -535,6 +585,27 @@ describe("CommandCenter shell", () => { expectThroughputLastAfter("command-center-stat-tokens", "command-center-live-strip", "command-center-overview-charts"); }); + it("keeps Overview token charts as an accurate top-model summary sourced from the same cap", async () => { + mockOverviewApi({ tokens: manyModelTokenFixture() }); + render(); + + const overviewTokenChart = await screen.findByTestId("command-center-overview-chart-tokens"); + const overviewPie = screen.getByTestId("cc-overview-pie"); + expect(screen.getByTestId("command-center-stat-models")).toHaveTextContent("11"); + expect(within(overviewTokenChart).getByText("Top model token consumers in this range")).toBeTruthy(); + expect(within(overviewPie).getByText("Top model token share in this range")).toBeTruthy(); + + // FNXC:CommandCenter 2026-06-27-09:55: Overview intentionally remains top-8; this locks the design decision that the bar and pie summarize the same leading models while the unique-model card still exposes the full analytics count. + for (const label of ["model-01", "model-02", "model-03", "model-04", "model-05", "model-06", "model-07", "model-08"]) { + expect(within(overviewTokenChart).getByText(label)).toBeTruthy(); + expect(within(overviewPie).getByText(label)).toBeTruthy(); + } + for (const label of ["model-09", "(unknown)"]) { + expect(within(overviewTokenChart).queryByText(label)).toBeNull(); + expect(within(overviewPie).queryByText(label)).toBeNull(); + } + }); + it("renders Overview providerless and unknown model icons without touching pie labels", async () => { mockOverviewApi({ tokens: { @@ -855,7 +926,12 @@ describe("CommandCenter shell", () => { await screen.findByTestId("command-center-empty"); expect(screen.queryByTestId("command-center-stat-tokens")).toBeNull(); - expect(apiMock.mock.calls.some(([path]) => typeof path === "string" && path === "/command-center/tools")).toBe(true); + // FNXC:CommandCenter 2026-06-27-09:55: The All-time preset now serializes an explicit upper bound, so range-change coverage should assert the tools endpoint refetched for the open-lower-bound range rather than requiring the legacy bare path. + expect( + apiMock.mock.calls.some( + ([path]) => typeof path === "string" && path.startsWith("/command-center/tools?to=") && !path.includes("from="), + ), + ).toBe(true); }); it("exposes the ARIA tabs pattern (tablist + tabs + tabpanel)", () => { diff --git a/packages/dashboard/app/components/command-center/areas/TokensArea.tsx b/packages/dashboard/app/components/command-center/areas/TokensArea.tsx index ef2fbd3dab..0e62760c87 100644 --- a/packages/dashboard/app/components/command-center/areas/TokensArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/TokensArea.tsx @@ -32,6 +32,9 @@ const GRANULARITIES: TokenTimeGranularity[] = ["hour", "day", "week"]; /* FNXC:CommandCenterCharts 2026-06-18-23:20: The Tokens surface must add real pie and line charts from already-fetched token analytics only. Keep the existing bars, tables, granularity controls, loading/error/empty branches, and testids intact while the FN-6682 recharts wrappers provide token-themed, non-finite-safe visuals. + +FNXC:CommandCenter 2026-06-27-09:45: +The Tokens detail area is the full-fidelity by-model source of truth: every group returned by model token analytics must appear in the bar chart, pie chart, and per-model table. Do not cap these detail charts; Overview owns any intentional top-N summarization. */ function costSortValue(cost: CostResult): number { @@ -121,30 +124,29 @@ export function TokensArea({ range }: { range: DateRange }) { const sortedGroups = useMemo(() => sortGroups(groups, sortKey, sortDir), [groups, sortKey, sortDir]); + const fullModelGroupsByTokens = useMemo( + () => [...groups].sort((a, b) => b.totalTokens - a.totalTokens || (a.key ?? "").localeCompare(b.key ?? "")), + [groups], + ); + const barData = useMemo( () => - [...groups] - .sort((a, b) => b.totalTokens - a.totalTokens) - .slice(0, 12) - .map((g) => ({ - label: modelGroupDisplayLabel(g, t("commandCenter.tokens.unknownModel", "(unknown)")), - value: g.totalTokens, - valueLabel: formatCount(g.totalTokens), - iconProvider: modelGroupIconProvider(g), - })), - [groups, t], + fullModelGroupsByTokens.map((g) => ({ + label: modelGroupDisplayLabel(g, t("commandCenter.tokens.unknownModel", "(unknown)")), + value: g.totalTokens, + valueLabel: formatCount(g.totalTokens), + iconProvider: modelGroupIconProvider(g), + })), + [fullModelGroupsByTokens, t], ); const pieData = useMemo( () => - [...groups] - .sort((a, b) => b.totalTokens - a.totalTokens) - .slice(0, 12) - .map((g) => ({ - label: modelGroupDisplayLabel(g, t("commandCenter.tokens.unknownModel", "(unknown)")), - value: g.totalTokens, - })), - [groups, t], + fullModelGroupsByTokens.map((g) => ({ + label: modelGroupDisplayLabel(g, t("commandCenter.tokens.unknownModel", "(unknown)")), + value: g.totalTokens, + })), + [fullModelGroupsByTokens, t], ); const lineSeries = useMemo( diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx index fe30a9b4a2..21486b467c 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx @@ -17,6 +17,22 @@ vi.mock("../../../ProviderIcon", () => ({ const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" }; +function makeTokenGroup(key: string | null, totalTokens: number) { + const inputTokens = Math.round(totalTokens * 0.6); + const outputTokens = Math.round(totalTokens * 0.3); + const cachedTokens = totalTokens - inputTokens - outputTokens; + return { + key, + inputTokens, + outputTokens, + cachedTokens, + cacheWriteTokens: 0, + totalTokens, + nTasks: 1, + cost: { usd: key === null ? null : totalTokens / 1_000, unavailable: key === null, stale: false }, + }; +} + function tokenFixture() { return { from: "2026-06-08", @@ -67,6 +83,34 @@ function tokenFixture() { }; } +function manyModelTokenFixture() { + const groups = [ + makeTokenGroup("claude-sonnet-4-5", 2_000), + makeTokenGroup("gpt-4o-mini", 1_900), + ...Array.from({ length: 13 }, (_, index) => makeTokenGroup(`model-${String(index + 1).padStart(2, "0")}`, 1_800 - index * 100)), + makeTokenGroup(null, 150), + makeTokenGroup("(unknown)", 125), + ]; + const totals = groups.reduce( + (acc, group) => ({ + inputTokens: acc.inputTokens + group.inputTokens, + outputTokens: acc.outputTokens + group.outputTokens, + cachedTokens: acc.cachedTokens + group.cachedTokens, + cacheWriteTokens: acc.cacheWriteTokens + group.cacheWriteTokens, + totalTokens: acc.totalTokens + group.totalTokens, + nTasks: acc.nTasks + group.nTasks, + }), + { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 }, + ); + + return { + ...tokenFixture(), + totals, + cost: { usd: null, unavailable: true, stale: false }, + groups, + }; +} + beforeEach(() => { apiMock.mockReset(); apiMock.mockResolvedValue(tokenFixture()); @@ -107,4 +151,28 @@ describe("TokensArea provider model icons", () => { expect(screen.getByTestId("cc-tokens-pie")).toHaveTextContent("gpt-4o-mini"); expect(screen.getByTestId("cc-tokens-pie")).toHaveTextContent("(unknown)"); }); + + it("renders every analytics model group in detail bar, pie, and table even beyond the old cap", async () => { + apiMock.mockResolvedValue(manyModelTokenFixture()); + render(); + + const byModelChart = await screen.findByRole("list", { name: "Tokens by model" }); + const pie = screen.getByTestId("cc-tokens-pie"); + const table = screen.getByTestId("cc-tokens-table"); + const expectedLabels = [ + "claude-sonnet-4-5", + "gpt-4o-mini", + ...Array.from({ length: 13 }, (_, index) => `model-${String(index + 1).padStart(2, "0")}`), + "(unknown)", + ]; + + // FNXC:CommandCenter 2026-06-27-09:55: Symptom verification for FN-7117 uses more than the former 12-row detail cap plus null/literal unknown labels, so any reintroduced bar/pie truncation drops model-13 or one of the unknown buckets here. + for (const label of expectedLabels) { + expect(within(byModelChart).getAllByText(label).length).toBeGreaterThan(0); + expect(within(pie).getAllByText(label).length).toBeGreaterThan(0); + expect(within(table).getAllByText(label).length).toBeGreaterThan(0); + } + expect(screen.getByTestId("cc-tokens-row-unknown")).toHaveTextContent("(unknown)"); + expect(screen.getByTestId("cc-tokens-row-(unknown)")).toHaveTextContent("(unknown)"); + }); });