From f66687b96dd5e89959c5454cf9a983c70909c43d Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Fri, 31 Jul 2026 23:07:39 -0700 Subject: [PATCH] fix(ui): lay the savings header out with the card's own slots The header was hand-rolled rows, so the title, the subtitle, the legend and the tab control all competed for one line. The subtitle is longer on Cumulative than on Per day, so it wrapped on one tab and not the other and the chart moved with it; pinning the controls against shrinking then pushed them past the card edge once the viewport narrowed. CardHeader already solves this. It is a grid that switches to `grid-cols-[1fr_auto]` when a card-action slot is present, sizing the controls to their content and giving the rest to the title, with the description on its own row. Using CardTitle, CardDescription and CardAction removes the bespoke layout rather than tuning it, and the controls wrap inside their own column instead of overflowing. The guard test now anchors on those slots. Its previous selector matched a summary card's hint rather than this subtitle, so it passed with the subtitle moved back into the controls. --- .../_components/UsageTab.test.tsx | 49 +++++++++---------- .../_components/UsageTab.tsx | 35 +++++++------ 2 files changed, 40 insertions(+), 44 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx index a754f97d9f3..26b24950006 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx @@ -249,41 +249,38 @@ describe("UsageTab", () => { expect(readSeries(bars)[0]).toMatchObject({ "Auto-router": -0.05 }); }); - it("keeps the header the same shape on both tabs so the chart cannot shift", async () => { + it("lays the savings header out with the card's own slots so nothing shifts between tabs", async () => { // The subtitle differs in length between the tabs ("Running total saved" vs "Saved - // per day"). Sharing a row with the title and controls made the header grow a line - // when it wrapped, moving the legend, the toggle and the chart below it. + // per day"). Hand-rolled rows made it compete with the legend and the toggle for + // width, so the header grew a line on one tab and the chart moved with it. CardHeader + // sizes the action column to its content and gives the rest to the title column. const { getByRole, getByTestId, container } = renderWith(twoDays()); - const layout = () => { + const header = () => { const legend = getByTestId("chart-legend"); - const controls = legend.parentElement as HTMLElement; - // the toggle travels with the legend, so neither can move independently - expect(controls.contains(getByRole("tablist"))).toBe(true); - const titleRow = controls.parentElement as HTMLElement; - // by text, not by class: several cards render a muted

, and grabbing the first - // one silently asserts against a summary-card hint instead of this subtitle - const subtitle = Array.from(container.querySelectorAll("p")).find((el) => - /Running total saved|Saved per day/.test(el.textContent ?? ""), - ); - expect(subtitle, "savings subtitle should be rendered").toBeTruthy(); - return { controls, titleRow, subtitle: subtitle as HTMLElement }; + const action = legend.closest('[data-slot="card-action"]') as HTMLElement; + const cardHeader = action.parentElement as HTMLElement; + const description = cardHeader.querySelector('[data-slot="card-description"]') as HTMLElement; + return { action, cardHeader, description }; }; - const before = layout(); - // the subtitle is a sibling BELOW the title row, never inside it, so its length - // cannot change that row's height - expect(before.titleRow.contains(before.subtitle)).toBe(false); - expect(before.titleRow.className).not.toContain("flex-wrap"); - expect(before.controls.className).toContain("shrink-0"); + const before = header(); + expect(before.action).toBeTruthy(); + expect(before.description).toBeTruthy(); + // the toggle rides in the same action slot as the legend, so neither moves alone + expect(before.action.contains(getByRole("tablist"))).toBe(true); + // the subtitle lives outside that slot, so its length cannot reposition the controls + expect(before.action.contains(before.description)).toBe(false); + expect(before.description.textContent).toContain("Running total saved"); await userEvent.click(getByRole("tab", { name: "Per day" })); - const after = layout(); - expect(after.controls).toBe(before.controls); - expect(after.titleRow).toBe(before.titleRow); - expect(after.titleRow.contains(after.subtitle)).toBe(false); - expect(container.textContent).toContain("Saved per day"); + const after = header(); + expect(after.action).toBe(before.action); + expect(after.cardHeader).toBe(before.cardHeader); + expect(after.action.contains(after.description)).toBe(false); + expect(after.description.textContent).toContain("Saved per day"); + expect(container.textContent).toContain("Savings"); }); it("subtracts a losing auto-router route from the total and keeps it out of the donut", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx index e9618d8f7a6..1997182f506 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx @@ -5,7 +5,7 @@ import { Info } from "lucide-react"; import { AreaChart, BarChart, CustomLegend, DonutChart, SEQUENTIAL_COLOR_RAMP } from "@/components/shared/charts"; import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getToolSpend, ToolSpendResponse } from "@/components/networking"; @@ -210,23 +210,22 @@ const UsageTab: React.FC = ({ accessToken, activity }) => {

- {/* Title and controls share a fixed row; the subtitle gets its own line below. - Competing for one row made the header taller whenever the subtitle wrapped, - which differs between the two tabs, so the chart shifted down on one of them */} - -
- Savings -
- - setAccumulation(value as SavingsAccumulation)}> - - Cumulative - {intervalLabel} - - -
-
-

{savingsSubtitle}

+ {/* CardHeader's own slots rather than hand-rolled rows: the action column is + sized to its content and the title column takes the rest, so the subtitle + never competes with the controls for width and neither moves when it grows. + The controls wrap within their column instead of pushing past the card */} + + Savings + {savingsSubtitle} + + + setAccumulation(value as SavingsAccumulation)}> + + Cumulative + {intervalLabel} + + + {accumulation === "cumulative" ? (