feat(web): redesign usage insights#7147

merged8
main
ui-refresh/usage
openedAug 16, 2026byMmaria
View on GitHubTwo-way sync enabledPR last imported 3d ago
27 unresolved threads · Jump to file
Selected thread: apps/web/src/components/usage/UsagePage.tsx:162 — <!-- MURMUR_IGNORE --> `usage/UsagePage.tsx:162` These two new popups (`:162` metric, `:178` period) are the only `align="end"` `SelectPopup`s in `apps/web` that do not pass `alignItemWithTrigger={false}` — `SettingsPanels.tsx:409`, `ProjectSettingsPanel.tsx:883`, `IntegrationsSettings.tsx:303`, `ThemeSearchSection.tsx:259`, `ConnectionsSettings.tsx:2864` and the rest all disable it. `SelectPopup` defaults it to `true` (`select.tsx:116`), so these open in Base UI's item-aligned mode: the popup is anchored so the selected item covers the trigger and the positioner reports `side=none`, which also switches items to the `in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)]` width path (`select.tsx:190`) off a `w-auto min-w-0` trigger. In a topbar pinned to the top edge there is no room above, so the menu lands over the header with the scroll arrows engaged instead of dropping below the trigger like every other header/compact select. Suggest matching the convention on both popups: ```suggestion <SelectPopup align="end" alignItemWithTrigger={false}> ``` No longer relevant as of 77e09a2e9de223184c85d85616887e86d326a6c5
0 / 11 viewed11 files changedLine totals unavailable

apps/web/src/components/usage/UsagePage.tsx · Earlier revision 4b69cf3c · right 573–575

Original diff
@@ -571,70 +554,54 @@ function UsageDeviceStrip({
   );
 }
 
-/** Deterministic bar heights (each unique: they double as keys). */
-const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67];
-
 /**
- * Static stand-in with the loaded page's shape: headline, provider split,
- * chart and metrics strip. No shimmer; blocks fill in exactly once when the
- * last device answers.
+ * Static stand-in with the loaded page's shape. No shimmer; blocks fill in
+ * exactly once when the last device answers.
  */
-function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) {
+function UsageSkeleton() {
   return (
     <>
-      <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
+      <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
         <div className="flex flex-col gap-5">
           <div className="flex flex-col gap-1">
-            <span className="text-xs tracking-wide text-muted-foreground uppercase">
-              Raw token cost
-            </span>
-            <div className="my-1.5 h-8 w-36 rounded-sm bg-muted" />
-            <div className="h-3 w-28 rounded-sm bg-muted" />
+            <div className="h-8 w-36 rounded-sm bg-muted" />
+            <div className="h-3 w-32 rounded-sm bg-muted" />
           </div>
-
           {PROVIDER_ORDER.map((provider) => (
-            <div key={provider} className="flex flex-col gap-1.5">
-              <div className="flex items-center justify-between">
-                <span className="flex items-center gap-2 text-sm text-foreground">
+            <div key={provider} className="flex flex-col gap-1">
+              <div className="flex items-center justify-between gap-4">
+                <span className="flex items-center gap-2">
                   <ProviderMark provider={provider} className="size-4" />
-                  {PROVIDER_PRESENTATION[provider].label}
+                  <div className="h-3.5 w-20 rounded-sm bg-muted" />

apps/web/src/components/usage/UsagePage.tsx · right 573–575

M
macroscopeappBotAug 17, 2026

usage/UsagePage.tsx:575

The loaded provider row now starts with an 8px color dot plus an 8px gap (UsagePage.tsx:239-245), but the skeleton row still begins at the brand mark, so every provider row jumps 16px to the right when the last device answers — the shift this skeleton exists to prevent (and the same mismatch that was just fixed for the chart gutter below).

Suggest mirroring the dot here; the skeleton already renders the real ProviderMark, so the real swatch keeps the two states identical:

suggestion
1 <span className="flex items-center gap-2">
2 <span
3 aria-hidden
4 className="size-2 shrink-0 rounded-full"
5 style={{ backgroundColor: PROVIDER_PRESENTATION[provider].color }}
6 />
7 <ProviderMark provider={provider} className="size-4" />
8 <div className="h-3.5 w-20 rounded-sm bg-muted" />

No longer relevant as of 095cd9edb12a39a89dc174831cc8b84592d59df2

apps/web/src/components/usage/UsageProviderChart.tsx · Earlier revision e093685b · right 411–411

Original diff
@@ -373,10 +407,11 @@ export function UsageProviderChart({
 
           {hoveredPeriod === undefined ? null : (
             <div
-              className="pointer-events-none absolute top-0 z-10 min-w-36 border border-border bg-background/95 px-2 py-1.5 text-xs"
+              ref={tooltipRef}
+              className="pointer-events-none absolute z-10 min-w-36 max-w-full rounded-xl border border-border/50 bg-background/65 px-2.5 py-2 text-xs shadow-lg backdrop-blur-xl backdrop-saturate-150"

apps/web/src/components/usage/UsageProviderChart.tsx · right 411–411

M
macroscopeappBotAug 16, 2026

usage/UsageProviderChart.tsx:411

This hand-rolls a glass surface instead of using the shared glass composition. Every other translucent floating surface in apps/web goes through the surface-glass / dropdown-glass utilities (index.css), which are driven by --glass-opacity, --glass-blur and --glass-saturation and are re-tuned under @variant dark. bg-background/65 backdrop-blur-xl backdrop-saturate-150 pins those values at the call site, so the tooltip is more transparent than the token contract (80%) over the coloured chart fills and does not pick up the dark-theme glass adjustments. Consider keeping only the border/radius/shadow locally and letting the utility own the glass:

suggestion
1 className="surface-glass pointer-events-none absolute z-10 min-w-36 max-w-full rounded-xl border border-border/50 px-2.5 py-2 text-xs shadow-lg"

No longer relevant as of acf68768ff7a71042c0b70d826ce9edc8d8e04e4

apps/web/src/components/usage/UsageProviderChart.tsx · Earlier revision af1fc0e6 · right 251

Original diff
@@ -260,30 +247,75 @@ export function UsageProviderChart({
       };
     });
 
-    // Paint the heavier series first so the lighter one is never buried under
-    // it. The fills are faint enough that the order barely shows, but the
-    // strokes are drawn in a second pass regardless, so neither can be hidden.
-    const ordered = [...built].sort((a, b) => b.total - a.total);
-
-    return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns };
+    return {
+      paths: built.toSorted((a, b) => b.total - a.total),
M
macroscopeappBotAug 17, 2026

usage/UsageProviderChart.tsx:251

The layered zero-baseline drawing still depends on three non-obvious invariants whose explanations this PR removes: the scale tops out at the largest single provider-period rather than the summed peak (line 224-228), PLOT_TOP reserves headroom so the constant-width stroke is not shaved off at a peak (line 231), and this sort plus the separate fill and stroke passes (lines 375-392) keep the heavier series from burying the lighter one. With the rationale gone, the sort and the two-pass paint read as arbitrary and are easy to "simplify" away later — the same class of regression the niceScale and buildDayColumns tests already guard with explicit comments.

Suggest keeping a one-line reason next to each, e.g. here:

suggestion
1 // Heavier series first so the lighter one is never buried; strokes are
2 // drawn in a second pass regardless, so neither can be hidden.
3 paths: built.toSorted((a, b) => b.total - a.total),

No longer relevant as of 4b69cf3c6748436adfce3203477b6f409aa38897

apps/web/src/components/WorkspacePageContainer.tsx · Earlier revision 858c85cb · right 56–61

Original diff
@@ -52,3 +52,11 @@ export function WorkspacePageHeader({
     />
   );
 }
+
+/** Keeps an icon glyph on the content edge while its larger hit target extends outward. */
+export function WorkspacePageHeaderEdgeControl({
+  className,
+  ...props
+}: ComponentPropsWithoutRef<"div">) {
+  return <div className={cn("-me-[7px] flex shrink-0", className)} {...props} />;

apps/web/src/components/WorkspacePageContainer.tsx · right 56–61

M
macroscopeappBotAug 17, 2026

components/WorkspacePageContainer.tsx:61

This adds a shared export whose only consumer is UsagePage, and it makes the same header action align differently per page: the Pull Requests header renders the identical ghost refresh button flush inside the header padding with a size-4 glyph (_chat.pull-requests.tsx:1905-1912), and the Settings header places its trailing action flush too (settings.tsx:78-80). The offset is also tied to one call site's geometry rather than a durable contract — -me-[7px] optically centers a 28px button around a 14px glyph, which is size="icon-sm" only at sm and up (size-8 sm:size-7), so below sm the button is 32px and the glyph lands 2px inside the edge this helper exists to touch.

Suggest either dropping the wrapper and rendering the button flush like the other workspace headers, or moving the optical inset into WorkspacePageHeader itself so every workspace header's trailing icon action inherits it instead of one page opting in.

No longer relevant as of 4421251ac3996468d6a3cf3a015c9ee06df69557

apps/web/src/components/usage/UsagePage.tsx · Earlier revision acf68768 · right 61–63

Original diff
@@ -34,6 +44,40 @@ const WINDOW_OPTIONS = [
   { days: 90, label: "90 days" },
 ] as const;
 
+function UsageHeaderMenu<Value extends string>({
+  label,
+  value,
+  options,
+  onChange,
+}: {
+  label: string;
+  value: Value;
+  options: ReadonlyArray<{ value: Value; label: string }>;
+  onChange: (value: Value) => void;
+}) {
+  const current = options.find((option) => option.value === value) ?? options[0]!;
+  return (
+    <Menu>
+      <MenuTrigger
+        aria-label={label}
+        className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"

apps/web/src/components/usage/UsagePage.tsx · right 61–63

M
macroscopeappBotAug 16, 2026

usage/UsagePage.tsx:63

UsageHeaderMenu re-creates CompactFilterMenu (apps/web/src/routes/_chat.pull-requests.tsx:1619) — same component shape, same MenuTrigger + MenuRadioGroup wiring, and a byte-identical trigger class string (git grep "inline-flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5" now matches exactly these two sites).

That trigger geometry and muted-ghost treatment is durable, shared header-bar styling, so it belongs in one named owner rather than a copied call-site string: the height, radius, padding, hover colors and chevron tone can only drift apart from here, and neither copy picks up Button's cursor/focus/hit-target contract. Consider extracting the compact menu (or at least the trigger) into a shared component — e.g. MenuTrigger render={<Button size="xs" variant="ghost-muted" />} inside one HeaderFilterMenu — and leaving only popup align/min-w-* and the option list at each call site.

No longer relevant as of 5e96339b38370155e7f0227d7d5a8fed3f0aaad5

apps/web/src/components/usage/UsagePage.tsx · Earlier revision 3bd11ec3 · right 354–354

Original diff
@@ -358,7 +351,7 @@ export function UsagePage() {
                           merged.models.map((model) => (
                             <tr
                               key={`${model.provider}:${model.model}`}
-                              className="border-b border-border/50"
+                              className="hover:bg-muted/20"

apps/web/src/components/usage/UsagePage.tsx · right 354–354

M
macroscopeappBotAug 19, 2026

usage/UsagePage.tsx:354

The per-row border-b border-border/50 is dropped here and replaced with hover:bg-muted/20, which is effectively invisible in both themes: light --muted is zinc-50 on a zinc-25 background and dark --muted is --alpha(var(--color-white) / 4%) (index.css:1036, :1098), so /20 resolves to well under 1% delta against the page. The breakdown tables can run 90 rows, so they end up with neither delineation nor hover feedback.

Every other data table in the app pairs that same tint with separators — <tbody className="divide-y divide-border/50"> (ResourceTelemetryDiagnostics.tsx:622, DiagnosticsSettings.tsx:468) — and the shared TableRow contract is border-b transition-colors hover:bg-muted/50 (ui/table.tsx:46). Suggest matching that contract here (and on the time-breakdown row at line 401):

suggestion
1 className="border-b border-border/50 transition-colors hover:bg-muted/50"

No longer relevant as of 5147b582cf9c1bed036d8b567c986cf5e50389ec

apps/web/src/components/ui/toggle.tsx · Earlier revision 5e9c8545 · right 32–33

Original diff
@@ -27,6 +29,8 @@ const toggleVariants = cva(
           "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100",
         outline:
           "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none",
+        segmented:
+          "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-accent/45 hover:text-foreground data-pressed:bg-accent data-pressed:text-foreground data-pressed:shadow-xs/5",

apps/web/src/components/ui/toggle.tsx · right 32–33

M
macroscopeappBotAug 17, 2026

ui/toggle.tsx:33

The pressed pill and the group track resolve to the same token in the default dark theme: index.css sets --muted and --accent both to --alpha(var(--color-white) / 4%) under @variant dark (:root), so the track bg-muted/45 (added in toggle-group.tsx) computes to ~1.8% white while data-pressed:bg-accent computes to 4% white — roughly #0e0e0e vs #141414 over neutral-950, and data-pressed:shadow-xs/5 adds nothing on a dark surface. hover:bg-accent/45 computes to exactly the track color, so a hovered unselected segment ends up looking like the selected one (both get text-foreground). All three new controls (UsagePage.tsx metric, period, breakdown) then communicate selection by text color alone in dark mode.

Suggest lifting the pressed/hover states with the token the primitive already uses for pressed state (bg-input/64 in the base class and dark:data-pressed:bg-input in outline), which stays above muted in both themes; data-pressed:bg-background plus a dark-only lift would work too if a white pill is wanted in light mode.

suggestion
1 segmented:
2 "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-input/32 hover:text-foreground data-pressed:bg-input/64 data-pressed:text-foreground data-pressed:shadow-xs/5",

No longer relevant as of 8156179d91f5f5bd9bb77ef8766f729f6d7dfe00

apps/web/src/components/usage/UsagePage.tsx · Earlier revision 8d10d468 · right 162–162

Original diff
@@ -102,78 +94,113 @@ export function UsagePage() {
       setWindowSelection({ days: windowDays, window: nextWindow });
     }
   };
+  const windowLabel =
+    isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
+      ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
+      : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;
+  const topbarContent = (
+    <div className="flex w-full min-w-0 items-center gap-3">
+      <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
+        <WorkspaceBreadcrumbItem current>
+          <h1>Usage</h1>
+        </WorkspaceBreadcrumbItem>
+        <WorkspaceBreadcrumbSeparator className="hidden md:flex" />
+        <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink md:flex">
+          <span className="truncate">{windowLabel}</span>
+        </WorkspaceBreadcrumbItem>
+      </WorkspaceBreadcrumb>
+      <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 lg:flex">
+        <ToggleGroup
+          aria-label="Usage metric"
+          variant="segmented"
+          value={[metric]}
+          onValueChange={(next) => {
+            const value = next[0];
+            if (value === "cost" || value === "tokens") setMetric(value);
+          }}
+        >
+          {(["cost", "tokens"] as const).map((option) => (
+            <Toggle key={option} value={option}>
+              {option === "cost" ? "Cost" : "Tokens"}
+            </Toggle>
+          ))}
+        </ToggleGroup>
+        <ToggleGroup
+          aria-label="Usage period"
+          variant="segmented"
+          value={[String(windowDays)]}
+          onValueChange={(next) => {
+            const value = next[0];
+            if (value) selectWindow(Number(value));
+          }}
+        >
+          {WINDOW_OPTIONS.map((option) => (
+            <Toggle key={option.days} value={String(option.days)}>
+              {option.label}
+            </Toggle>
+          ))}
+        </ToggleGroup>
+        <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost">
+          <RefreshCwIcon className="size-3.5" />
+        </Button>
+      </div>
+      <div className="ms-auto flex min-w-0 items-center justify-end gap-1 lg:hidden">
+        <Select
+          value={metric}
+          onValueChange={(value) => {
+            if (value === "cost" || value === "tokens") setMetric(value);
+          }}
+        >
+          <SelectTrigger
+            aria-label="Usage metric"
+            size="compact"
+            variant="ghost"
+            className="w-auto min-w-0"
+          >
+            <SelectValue>{metric === "cost" ? "Cost" : "Tokens"}</SelectValue>
+          </SelectTrigger>
+          <SelectPopup align="end">

apps/web/src/components/usage/UsagePage.tsx · right 162–162

M
macroscopeappBotAug 19, 2026

usage/UsagePage.tsx:162

These two new popups (:162 metric, :178 period) are the only align="end" SelectPopups in apps/web that do not pass alignItemWithTrigger={false} — SettingsPanels.tsx:409, ProjectSettingsPanel.tsx:883, IntegrationsSettings.tsx:303, ThemeSearchSection.tsx:259, ConnectionsSettings.tsx:2864 and the rest all disable it. SelectPopup defaults it to true (select.tsx:116), so these open in Base UI's item-aligned mode: the popup is anchored so the selected item covers the trigger and the positioner reports side=none, which also switches items to the in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] width path (select.tsx:190) off a w-auto min-w-0 trigger. In a topbar pinned to the top edge there is no room above, so the menu lands over the header with the scroll arrows engaged instead of dropping below the trigger like every other header/compact select.

Suggest matching the convention on both popups:

suggestion
1 <SelectPopup align="end" alignItemWithTrigger={false}>

No longer relevant as of 77e09a2e9de223184c85d85616887e86d326a6c5

apps/web/src/components/ui/toggle-group.tsx · Earlier revision be5b41cb · right 33

Original diff
@@ -30,7 +30,7 @@ function ToggleGroup({
         orientation === "horizontal"
           ? "*:pointer-coarse:after:min-w-auto"
           : "*:pointer-coarse:after:min-h-auto",
-        variant === "default"
+        variant === "default" || variant === "segmented"
M
macroscopeappBotAug 16, 2026

ui/toggle-group.tsx:33

The new segmented variant only owns the item styling here — the track (rounded-lg bg-muted/45 p-0.5) is repeated verbatim at all three call sites this PR adds (UsagePage.tsx:116, :133, :306). That splits one treatment across two owners: variant="segmented" alone renders a transparent, unpadded group, so the next surface that reaches for the variant gets pressed pills with no track unless it copies the same string, and re-tuning the track means editing every consumer.

Suggest folding the track into the segmented branch and keeping only contextual layout at the call sites:

diff
1- variant === "default" || variant === "segmented"
2- ? "gap-0.5"
3+ variant === "segmented"
4+ ? "gap-0.5 rounded-lg bg-muted/45 p-0.5"
5+ : variant === "default"
6+ ? "gap-0.5"

(the existing orientation branches then nest one level deeper), and drop className="rounded-lg bg-muted/45 p-0.5" from the three ToggleGroup usages.

No longer relevant as of 9ac145fda6f2ac9c93d06e06357b4627b1c38515

apps/web/src/components/HeaderFilterMenu.tsx · Earlier revision 8f2efdae · right 21–21

Original diff
@@ -0,0 +1,48 @@
+import { ChevronDownIcon } from "lucide-react";
+
+import { Button } from "./ui/button";
+import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
+
+export interface HeaderFilterMenuOption<Value extends string> {
+  value: Value;
+  label: string;
+}
+
+export function HeaderFilterMenu<Value extends string>({
+  label,
+  value,
+  options,
+  onChange,
+  align = "start",
+  popupClassName,
+}: {
+  label: string;
+  value: Value;
+  options: ReadonlyArray<HeaderFilterMenuOption<Value>>;

apps/web/src/components/HeaderFilterMenu.tsx · right 21–21

M
macroscopeappBotAug 16, 2026

🟡 Medium components/HeaderFilterMenu.tsx:21

Passing options={[]} makes HeaderFilterMenu throw while rendering because current becomes undefined and current.label is read. The public ReadonlyArray prop type permits this value even though the component requires at least one option; require a non-empty tuple (or handle the empty state).

✅ Resolved in e8d00a2e96aafa9eaf8d8922a94c62e2d2ccede4

apps/web/src/components/ui/toggle-group.tsx · Earlier revision 858c85cb · right 33–34

Original diff
@@ -30,11 +30,13 @@ function ToggleGroup({
         orientation === "horizontal"
           ? "*:pointer-coarse:after:min-w-auto"
           : "*:pointer-coarse:after:min-h-auto",
-        variant === "default"
-          ? "gap-0.5"
-          : orientation === "horizontal"
-            ? "*:not-first:not-data-[slot=separator]:before:-start-[0.5px] *:not-last:not-data-[slot=separator]:before:-end-[0.5px] *:not-first:rounded-s-none *:not-last:rounded-e-none *:not-first:border-s-0 *:not-last:border-e-0 *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none"
-            : "*:not-first:not-data-[slot=separator]:before:-top-[0.5px] *:not-last:not-data-[slot=separator]:before:-bottom-[0.5px] flex-col *:not-first:rounded-t-none *:not-last:rounded-b-none *:not-first:border-t-0 *:not-last:border-b-0 *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:data-[slot=toggle]:not-last:before:hidden dark:*:last:before:hidden dark:*:first:before:block",
+        variant === "segmented"
+          ? "gap-0.5 rounded-lg bg-input/40 p-0.5"

apps/web/src/components/ui/toggle-group.tsx · right 33–34

M
macroscopeappBotAug 17, 2026

ui/toggle-group.tsx:34

The segmented treatment is still split across two knobs: the track lands on variant="segmented" here, but the pill geometry it is padded for lives in size="segmented" (toggle.tsx:21-22). Nothing couples them, so every call site has to pass both (UsagePage.tsx:118, :134, :322) and variant="segmented" on its own renders default 36px rounded-lg items inside a p-0.5 track.

Suggest letting the variant own its geometry by defaulting the size from it, so consumers pass one prop:

diff
1 variant = "default",
2- size = "default",
3+ size = variant === "segmented" ? "segmented" : "default",

No longer relevant as of af1fc0e676da1acbee1833e22bbfc2bf30b751a6

apps/web/src/components/ui/segmented-tabs.tsx · Earlier revision 2ec34dc8 · right 26–26

Original diff
@@ -0,0 +1,40 @@
+import type { ComponentProps, HTMLAttributes } from "react";
+
+import { cn } from "~/lib/utils";
+import { Toggle } from "~/components/ui/toggle";
+
+function SegmentedTabList({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
+  return (
+    <div
+      className={cn(
+        "inline-flex w-fit items-center gap-0.5 rounded-lg bg-muted/45 p-0.5",
+        className,
+      )}
+      role="group"
+      {...props}
+    />
+  );
+}
+
+function SegmentedTab({
+  selected,
+  density = "default",
+  className,
+  ...props
+}: {
+  selected: boolean;
+  density?: "default" | "compact";

apps/web/src/components/ui/segmented-tabs.tsx · right 26–26

M
macroscopeappBotAug 16, 2026

density="compact" (and the segmented-compact size it selects in toggle.tsx) has no call site — all three usages here use the default density. Adding a size to the shared toggleVariants contract that nothing renders means its geometry is never validated against a real surface. Suggest dropping density and the segmented-compact size until a second surface needs the smaller pill, and adding them back with that consumer.

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

Fixed by removing the unused compact density and segmented-tabs wrapper.

apps/web/src/components/ui/toggle-group.tsx · Earlier revision be5b41cb · right 33

Original diff
@@ -30,7 +30,7 @@ function ToggleGroup({
         orientation === "horizontal"
           ? "*:pointer-coarse:after:min-w-auto"
           : "*:pointer-coarse:after:min-h-auto",
-        variant === "default"
+        variant === "default" || variant === "segmented"
M
macroscopeappBotAug 16, 2026

ui/toggle-group.tsx:33

The new segmented variant only owns the item styling here — the track (rounded-lg bg-muted/45 p-0.5) is repeated verbatim at all three call sites this PR adds (UsagePage.tsx:116, :133, :306). That splits one treatment across two owners: variant="segmented" alone renders a transparent, unpadded group, so the next surface that reaches for the variant gets pressed pills with no track unless it copies the same string, and re-tuning the track means editing every consumer.

Suggest folding the track into the segmented branch and keeping only contextual layout at the call sites:

diff
1- variant === "default" || variant === "segmented"
2- ? "gap-0.5"
3+ variant === "segmented"
4+ ? "gap-0.5 rounded-lg bg-muted/45 p-0.5"
5+ : variant === "default"
6+ ? "gap-0.5"

(the existing orientation branches then nest one level deeper), and drop className="rounded-lg bg-muted/45 p-0.5" from the three ToggleGroup usages.

No longer relevant as of 9ac145fda6f2ac9c93d06e06357b4627b1c38515

apps/web/src/components/usage/UsagePage.tsx · Earlier revision 144e585f · right 568–585

Original diff
@@ -571,70 +555,59 @@ function UsageDeviceStrip({
   );
 }
 
-/** Deterministic bar heights (each unique: they double as keys). */
-const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67];
-
 /**
- * Static stand-in with the loaded page's shape: headline, provider split,
- * chart and metrics strip. No shimmer; blocks fill in exactly once when the
- * last device answers.
+ * Static stand-in with the loaded page's shape. No shimmer; blocks fill in
+ * exactly once when the last device answers.
  */
-function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) {
+function UsageSkeleton() {
   return (
     <>
-      <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
+      <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
         <div className="flex flex-col gap-5">
           <div className="flex flex-col gap-1">
-            <span className="text-xs tracking-wide text-muted-foreground uppercase">
-              Raw token cost
-            </span>
-            <div className="my-1.5 h-8 w-36 rounded-sm bg-muted" />
-            <div className="h-3 w-28 rounded-sm bg-muted" />
+            <div className="h-8 w-36 rounded-sm bg-muted" />
+            <div className="h-3 w-32 rounded-sm bg-muted" />
           </div>
-
           {PROVIDER_ORDER.map((provider) => (
-            <div key={provider} className="flex flex-col gap-1.5">
-              <div className="flex items-center justify-between">
-                <span className="flex items-center gap-2 text-sm text-foreground">
+            <div key={provider} className="flex flex-col gap-1">
+              <div className="flex items-center justify-between gap-4">
+                <span className="flex items-center gap-2">
+                  <span
+                    aria-hidden
+                    className="size-2 shrink-0 rounded-full"
+                    style={{ backgroundColor: PROVIDER_PRESENTATION[provider].color }}
+                  />
                   <ProviderMark provider={provider} className="size-4" />
-                  {PROVIDER_PRESENTATION[provider].label}
+                  <div className="h-3.5 w-20 rounded-sm bg-muted" />
                 </span>
                 <div className="h-3.5 w-14 rounded-sm bg-muted" />
               </div>
-              <div className="h-1 w-full rounded-full bg-muted" />
               <div className="h-3 w-36 rounded-sm bg-muted" />

apps/web/src/components/usage/UsagePage.tsx · right 568–585

M
macroscopeappBotAug 19, 2026

usage/UsagePage.tsx:585

These placeholders are shorter than the content they stand in for, so the summary column still reflows when the data lands — the shift this skeleton exists to prevent, and below lg (where the columns stack) it moves the Totals and Breakdown sections too.

Loaded geometry: the headline is text-4xl (40px line box) over a text-xs caption (16px) (UsagePage.tsx:216-225), and each provider row is a text-sm line (~20px) over a text-xs detail (16px) (UsagePage.tsx:238-267). Here they are 32/12 and 16/12 — roughly 30px short across the column.

Suggest sizing the blocks to the text they replace:

suggestion
1 <div className="h-10 w-36 rounded-sm bg-muted" />
2 <div className="h-4 w-32 rounded-sm bg-muted" />
3 </div>
4 {PROVIDER_ORDER.map((provider) => (
5 <div key={provider} className="flex flex-col gap-1">
6 <div className="flex min-h-5 items-center justify-between gap-4">
7 <span className="flex items-center gap-2">
8 <span
9 aria-hidden
10 className="size-2 shrink-0 rounded-full"
11 style={{ backgroundColor: PROVIDER_PRESENTATION[provider].color }}
12 />
13 <ProviderMark provider={provider} className="size-4" />
14 <div className="h-3.5 w-20 rounded-sm bg-muted" />
15 </span>
16 <div className="h-3.5 w-14 rounded-sm bg-muted" />
17 </div>
18 <div className="h-4 w-36 rounded-sm bg-muted" />

No longer relevant as of 91c8fa29e612b494a22717243483848f7a0f59ee

apps/web/src/components/HeaderFilterMenu.tsx · Earlier revision 2ec34dc8 · right 32–34

Original diff
@@ -0,0 +1,59 @@
+import { ChevronDownIcon } from "lucide-react";
+import type { ReactNode } from "react";
+
+import { Button } from "./ui/button";
+import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
+
+export interface HeaderFilterMenuOption<Value extends string> {
+  value: Value;
+  label: string;
+  icon?: ReactNode;
+  disabledReason?: string;
+}
+
+export function HeaderFilterMenu<Value extends string>({
+  label,
+  value,
+  options,
+  onChange,
+  align = "start",
+  popupClassName,
+}: {
+  label: string;
+  value: Value;
+  options: ReadonlyArray<HeaderFilterMenuOption<Value>>;
+  onChange: (value: Value) => void;
+  align?: "start" | "center" | "end";
+  popupClassName?: string;
+}) {
+  const current = options.find((option) => option.value === value) ?? options[0]!;
+  return (
+    <Menu>
+      <MenuTrigger
+        render={
+          <Button size="compact" variant="ghost-muted" aria-label={label} className="text-sm" />

apps/web/src/components/HeaderFilterMenu.tsx · right 32–34

M
macroscopeappBotAug 16, 2026

HeaderFilterMenu is the same control as CompactFilterMenu (apps/web/src/routes/_chat.pull-requests.tsx:1619): same Menu + MenuTrigger + MenuRadioGroup shape, same chevron glyph, and the optional icon / disabledReason props map one-to-one onto that component's option.Icon / option.unavailable while having no consumer on this page.

Leaving both means the same header filter control now exists twice with divergent trigger behavior: this one inherits the Button compact / ghost-muted contract (focus-visible ring, coarse-pointer hit target, muted icon color), while the pull-requests copy keeps a hand-rolled trigger class string with no focus ring. Consider migrating those three call sites to this component (Icon -> icon, unavailable -> disabledReason) and deleting the local copy, so the two prop generalizations have the consumer they were shaped for. If that migration is out of scope for this PR, dropping icon and disabledReason until something uses them keeps the new contract to what it actually serves.

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

Fixed by removing the duplicate HeaderFilterMenu and using the shared control.

apps/web/src/components/usage/UsagePage.tsx · Earlier revision e093685b · right 147–159

Original diff
@@ -100,78 +94,66 @@ export function UsagePage() {
       setWindowSelection({ days: windowDays, window: nextWindow });
     }
   };
+  const windowLabel =
+    isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
+      ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
+      : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;
+  const topbarContent = (
+    <div className="flex w-full min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
+      <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
+        <WorkspaceBreadcrumbItem current>
+          <h1>Usage</h1>
+        </WorkspaceBreadcrumbItem>
+        <WorkspaceBreadcrumbSeparator />
+        <WorkspaceBreadcrumbItem className="min-w-0 shrink">
+          <span className="truncate">{windowLabel}</span>
+        </WorkspaceBreadcrumbItem>
+      </WorkspaceBreadcrumb>
+      <div className="ms-auto flex min-w-0 basis-full flex-wrap items-center justify-end gap-2 sm:basis-auto">
+        <SegmentedTabList aria-label="Usage metric">
+          {(["cost", "tokens"] as const).map((option) => (
+            <SegmentedTab
+              key={option}
+              selected={option === metric}
+              onClick={() => setMetric(option)}
+            >
+              {option === "cost" ? "Cost" : "Tokens"}
+            </SegmentedTab>
+          ))}
+        </SegmentedTabList>
+        <SegmentedTabList aria-label="Usage period">
+          {WINDOW_OPTIONS.map((option) => (
+            <SegmentedTab
+              key={option.days}
+              selected={option.days === windowDays}
+              onClick={() => selectWindow(option.days)}
+            >
+              {option.label}
+            </SegmentedTab>
+          ))}
+        </SegmentedTabList>
+        <WorkspacePageHeaderEdgeControl>
+          <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost">
+            <RefreshCwIcon className="size-3.5" />
+          </Button>
+        </WorkspacePageHeaderEdgeControl>
+      </div>
+    </div>
+  );
 
   return (
     <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
       <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
-        {!isElectron && (
-          <header
-            className={cn(
-              "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
-              COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
-            )}
-          >
-            <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb">
-              <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem>
-            </WorkspaceBreadcrumb>
-          </header>
-        )}
-
-        {isElectron && (
-          <div
-            className={cn(
-              "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
-              COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
-            )}
-          >
-            <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb">
-              <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem>
-            </WorkspaceBreadcrumb>
-          </div>
-        )}
+        <WorkspacePageHeader electron={isElectron} className="h-auto py-2">

apps/web/src/components/usage/UsagePage.tsx · right 147–159

M
macroscopeappBotAug 16, 2026

usage/UsagePage.tsx:147

h-auto py-2 overrides the core geometry WorkspacePageHeader owns. That height is not just visual: --workspace-topbar-height resolves to env(titlebar-area-height) under .wco, and the fixed sidebar-toggle / native-control strips are sized and positioned from the same variable (AppSidebarLayout.tsx, ChatView.tsx). Because the header content is flex-wrap with basis-full below sm, the header (and the Electron drag region) grows past the titlebar area on narrow widths, and the vertically centred breadcrumb no longer lines up with those overlay controls — a divergence from every other workspace surface (settings, project settings, pull requests), and the reason the onboarding header is regression-tested against py- in routes/-chatIndexTitlebar.test.ts.

Suggest dropping the override and keeping the shared topbar height, condensing the controls when space is tight instead of growing the bar (e.g. SegmentedTab density="compact", or a compact menu like the Pull Requests header uses).

No longer relevant as of acf68768ff7a71042c0b70d826ce9edc8d8e04e4

apps/web/src/components/usage/UsagePage.tsx · Earlier revision 2ec34dc8 · right 221–247

Original diff
@@ -181,88 +191,64 @@ export function UsagePage() {
                   staleEnvironments={merged.staleEnvironments}
                 />
 
-                {/* Cost first: the financial answer, then the provider split. */}
-                <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
-                  {/* The summary follows the chart toggle, so the headline and the
-                  series are always reading the same units. */}
-                  <div className="flex flex-col gap-5">
+                <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
+                  <div className="flex min-w-0 flex-col gap-5">
                     <div className="flex flex-col gap-1">
-                      <span className="text-xs tracking-wide text-muted-foreground uppercase">
-                        {metric === "cost" ? "Raw token cost" : "Processed tokens"}
-                      </span>
                       <span className="text-4xl font-semibold text-foreground tabular-nums">
                         {metric === "cost"
-                          ? `${formatUsd(merged.costUsd)}*`
+                          ? formatUsd(merged.costUsd)
                           : formatTokens(merged.totalTokens)}
                       </span>
                       <span className="text-xs text-muted-foreground">
                         {metric === "cost"
-                          ? "* if billed at full API rate"
-                          : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}
+                          ? `${formatCount(merged.sessions)} sessions · API estimate`
+                          : `${formatCount(merged.sessions)} sessions`}
                       </span>
                     </div>
 
-                    {orderedProviders.map((provider) => {
-                      const share = metric === "cost" ? provider.costShare : provider.tokenShare;
+                    {PROVIDER_ORDER.map((provider) => {
+                      const totals = merged.providers.find((entry) => entry.provider === provider);
+                      const share =
+                        metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0);
+                      const providerSessions = totals?.sessions ?? 0;
+                      const sessionLabel = `${formatCount(providerSessions)} ${
+                        providerSessions === 1 ? "session" : "sessions"
+                      }`;
                       return (
-                        <div key={provider.provider} className="flex flex-col gap-1.5">
-                          <div className="flex items-baseline justify-between">
-                            <span className="flex items-center gap-2 text-sm text-foreground">
-                              <ProviderMark provider={provider.provider} className="size-4" />
-                              {PROVIDER_PRESENTATION[provider.provider].label}
+                        <div key={provider} className="flex flex-col gap-1">
+                          <div className="flex items-baseline justify-between gap-4">
+                            <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">
+                              <ProviderMark provider={provider} className="size-4" />

apps/web/src/components/usage/UsagePage.tsx · right 221–247

M
macroscopeappBotAug 16, 2026

usage/UsagePage.tsx:221

The chart still encodes each provider purely by color (PROVIDER_PRESENTATION[provider].color drives the fills and strokes), but this PR deletes both UsageChartLegend and the colored share bars that used to live in these summary rows, so color is no longer rendered anywhere outside the chart itself. The only remaining color-to-provider mapping is the hover tooltip, which is mouse-only (UsageProviderChart binds onMouseMove/onMouseLeave and nothing pointer/touch/keyboard), so on touch and keyboard the two series become unidentifiable.

Smallest fix is to keep a static swatch next to the provider label here (or restore a legend next to the chart heading) so the mapping survives without hover:

diff
1 <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">
2+ <span
3+ aria-hidden
4+ className="size-2 shrink-0 rounded-full"
5+ style={{ backgroundColor: PROVIDER_PRESENTATION[provider].color }}
6+ />
7 <ProviderMark provider={provider} className="size-4" />

No longer relevant as of 4e4d9b33a0846e071081993ad5fc0b0654b97853

apps/web/src/components/ui/toggle-group.tsx · Earlier revision 91c8fa29 · right 20

Original diff
@@ -17,7 +17,7 @@ const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariant
 function ToggleGroup({
   className,
   variant = "default",
-  size = "default",
+  size = variant === "segmented" ? "segmented" : "default",
M
macroscopeappBotAug 19, 2026

This makes variant silently own the item geometry, and all three new call sites depend on it (UsagePage.tsx:113, :128, :311 pass variant="segmented" and no size). Nothing pins the contract: reordering the ternary or adding a variant would render default 36px rounded-lg items inside the 2px p-0.5 track with no test failing. Since this is defaulting logic on a shared primitive, a focused test would be worth adding — button.test.tsx already covers geometry contracts this way:

tsx
1// apps/web/src/components/ui/toggle-group.test.tsx
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";
4
5import { Toggle, ToggleGroup } from "./toggle-group";
6
7describe("toggle group segmented defaults", () => {
8 it("derives the segmented item size from the variant", () => {
9 const html = renderToStaticMarkup(
10 <ToggleGroup variant="segmented" value={["a"]}>
11 <Toggle value="a">A</Toggle>
12 </ToggleGroup>,
13 );
14
15 expect(html).toContain('data-size="segmented"');
16 expect(html).toContain("h-6");
17 });
18});

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

Fixed on the latest head. toggle-group.test.tsx now pins the segmented group and item size contract, including data-size="segmented" and h-6.

apps/web/src/components/ui/segmented-tabs.tsx · Earlier revision 8f2efdae · right 6–17

Original diff
@@ -0,0 +1,38 @@
+import type { ComponentProps, HTMLAttributes } from "react";
+
+import { cn } from "~/lib/utils";
+import { Toggle } from "~/components/ui/toggle";
+
+function SegmentedTabList({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
+  return (
+    <div
+      className={cn(
+        "inline-flex w-fit items-center gap-0.5 rounded-lg bg-muted/45 p-0.5",
+        className,
+      )}
+      role="group"
+      {...props}
+    />
+  );
+}

apps/web/src/components/ui/segmented-tabs.tsx · right 6–17

M
macroscopeappBotAug 16, 2026

SegmentedTabList reconstructs ToggleGroup (apps/web/src/components/ui/toggle-group.tsx), the primitive the other single-select segmented controls already compose (DiffPanel.tsx:752, PullRequestCodeTab.tsx:1098). A raw role="group" div with per-item pressed/onClick drops what the Base UI group owns: value/onValueChange single-selection semantics and the composite keyboard model (one tab stop, arrow keys to move between segments — here each segment is its own tab stop and arrows do nothing), plus the group's coarse-pointer hit-target normalisation (*:pointer-coarse:after:min-w-auto), so on touch each 24px-tall pill keeps a 44px ::after overlay that spills onto its neighbour and the content below.

Since the segmented size/variant you added live on the shared toggleVariants, ToggleGroupItem can already wear them; suggest keeping the track styling (rounded-lg bg-muted/45 p-0.5) as a ToggleGroup className and letting variant === "segmented" take the gap-0.5 branch rather than the joined-border branch, instead of shipping a second group primitive next to the existing one.

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

Fixed with the same migration to the shared ToggleGroup primitive.

apps/web/src/components/usage/UsagePage.tsx · Earlier revision f399a1b8 · right 180

Original diff
@@ -100,78 +95,103 @@ export function UsagePage() {
       setWindowSelection({ days: windowDays, window: nextWindow });
     }
   };
+  const windowLabel =
+    isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
+      ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
+      : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;
+  const topbarContent = (
+    <div className="flex w-full min-w-0 items-center gap-3">
+      <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
+        <WorkspaceBreadcrumbItem current>
+          <h1>Usage</h1>
+        </WorkspaceBreadcrumbItem>
+        <WorkspaceBreadcrumbSeparator className="hidden md:flex" />
+        <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink md:flex">
+          <span className="truncate">{windowLabel}</span>
+        </WorkspaceBreadcrumbItem>
+      </WorkspaceBreadcrumb>
+      <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 lg:flex">
+        <ToggleGroup
+          aria-label="Usage metric"
+          size="segmented"
+          variant="segmented"
+          value={[metric]}
+          onValueChange={(next) => {
+            const value = next[0];
+            if (value === "cost" || value === "tokens") setMetric(value);
+          }}
+        >
+          {(["cost", "tokens"] as const).map((option) => (
+            <Toggle key={option} value={option}>
+              {option === "cost" ? "Cost" : "Tokens"}
+            </Toggle>
+          ))}
+        </ToggleGroup>
+        <ToggleGroup
+          aria-label="Usage period"
+          size="segmented"
+          variant="segmented"
+          value={[String(windowDays)]}
+          onValueChange={(next) => {
+            const value = next[0];
+            if (value) selectWindow(Number(value));
+          }}
+        >
+          {WINDOW_OPTIONS.map((option) => (
+            <Toggle key={option.days} value={String(option.days)}>
+              {option.label}
+            </Toggle>
+          ))}
+        </ToggleGroup>
+        <WorkspacePageHeaderEdgeControl>
+          <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost">
+            <RefreshCwIcon className="size-3.5" />
+          </Button>
+        </WorkspacePageHeaderEdgeControl>
+      </div>
+      <div className="ms-auto flex min-w-0 items-center justify-end gap-1 lg:hidden">
+        <HeaderFilterMenu
+          label="Usage metric"
+          value={metric}
+          options={[
+            { value: "cost", label: "Cost" },
+            { value: "tokens", label: "Tokens" },
+          ]}
+          onChange={setMetric}
+          align="end"
+          popupClassName="min-w-32"
+        />
+        <HeaderFilterMenu
+          label="Usage period"
+          value={String(windowDays)}
+          options={WINDOW_OPTIONS.map((option) => ({
+            value: String(option.days),
+            label: option.label,
+          }))}
+          onChange={(value) => selectWindow(Number(value))}
+          align="end"
+          popupClassName="min-w-32"
+        />
+        <WorkspacePageHeaderEdgeControl>
+          <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost">
+            <RefreshCwIcon className="size-3.5" />
+          </Button>
+        </WorkspacePageHeaderEdgeControl>
+      </div>
C
cursorBotAug 16, 2026

Electron header controls still drag

Medium Severity

Metric, period, and refresh controls were moved into WorkspacePageHeader with electron={isElectron}, which applies drag-region. Shared CSS only marks button/a/inputs as no-drag, so padding and gaps around the segmented groups still start a window drag. Elsewhere (DiffPanel) interactive header clusters wrap the whole control row in [-webkit-app-region:no-drag].

Additional Locations (1)

Reviewed by Cursor Bugbot for commit f399a1b81e49fc256f12d16b309d6958be95921a. Configure here.

apps/web/src/components/usage/UsagePage.tsx · Earlier revision d586e6df · right 106–110

Original diff
@@ -102,78 +93,121 @@ export function UsagePage() {
       setWindowSelection({ days: windowDays, window: nextWindow });
     }
   };
+  const windowLabel =
+    isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
+      ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
+      : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;
+  const topbarContent = (
+    <div className="flex w-full min-w-0 items-center gap-3">
+      <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
+        <WorkspaceBreadcrumbItem current>
+          <h1>Usage</h1>
+        </WorkspaceBreadcrumbItem>
+        <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink gap-3 md:flex">
+          <span aria-hidden="true" className="text-icon-muted">
+            /
+          </span>
+          <span className="truncate">{windowLabel}</span>

apps/web/src/components/usage/UsagePage.tsx · right 106–110

M
macroscopeappBotAug 17, 2026

usage/UsagePage.tsx:110

The / between crumbs is hand-rolled here, but the breadcrumb module already exports WorkspaceBreadcrumbSeparator, which every other workspace header uses (_chat.pull-requests.tsx:1861, SettingsBreadcrumb.tsx:26, ProjectSettingsPanel.tsx:215) and which renders the glyph as its own aria-hidden <li> with the text-icon-muted treatment. Nesting a <span aria-hidden> inside the crumb <li> re-declares that treatment at the call site and gives this header a different <ol> structure from the rest of the app.

The reason for the local copy looks like the responsive hide, since the shared separator takes no className. Suggest adding an optional className to WorkspaceBreadcrumbSeparator and rendering <WorkspaceBreadcrumbSeparator className="hidden md:flex" /> before the window crumb, so the separator stays in one owner.

No longer relevant as of ad49e563b67e030ef8846428afe158f1247b4583

apps/web/src/components/HeaderFilterMenu.tsx · Earlier revision 2ec34dc8 · right 41–41

Original diff
@@ -0,0 +1,59 @@
+import { ChevronDownIcon } from "lucide-react";
+import type { ReactNode } from "react";
+
+import { Button } from "./ui/button";
+import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
+
+export interface HeaderFilterMenuOption<Value extends string> {
+  value: Value;
+  label: string;
+  icon?: ReactNode;
+  disabledReason?: string;
+}
+
+export function HeaderFilterMenu<Value extends string>({
+  label,
+  value,
+  options,
+  onChange,
+  align = "start",
+  popupClassName,
+}: {
+  label: string;
+  value: Value;
+  options: ReadonlyArray<HeaderFilterMenuOption<Value>>;
+  onChange: (value: Value) => void;
+  align?: "start" | "center" | "end";
+  popupClassName?: string;
+}) {
+  const current = options.find((option) => option.value === value) ?? options[0]!;
+  return (
+    <Menu>
+      <MenuTrigger
+        render={
+          <Button size="compact" variant="ghost-muted" aria-label={label} className="text-sm" />
+        }
+      >
+        {current.label}
+        <ChevronDownIcon aria-hidden className="size-3 text-muted-foreground/70" />
+      </MenuTrigger>
+      <MenuPopup align={align} side="bottom" className={popupClassName ?? "min-w-40"}>
+        <MenuRadioGroup value={value} onValueChange={(next) => onChange(next as Value)}>

apps/web/src/components/HeaderFilterMenu.tsx · right 41–41

M
macroscopeappBotAug 16, 2026

🟡 Medium components/HeaderFilterMenu.tsx:41

When value is absent from options, the trigger displays options[0] but MenuRadioGroup receives the unmatched value, so no radio item is selected and the filter UI shows conflicting state. Pass the normalized current.value to MenuRadioGroup so the displayed label and selection stay aligned.

✅ Resolved in 4e4d9b33a0846e071081993ad5fc0b0654b97853

apps/web/src/components/usage/UsagePage.tsx · Earlier revision 8d10d468 · right 162–162

Original diff
@@ -102,78 +94,113 @@ export function UsagePage() {
       setWindowSelection({ days: windowDays, window: nextWindow });
     }
   };
+  const windowLabel =
+    isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
+      ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
+      : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;
+  const topbarContent = (
+    <div className="flex w-full min-w-0 items-center gap-3">
+      <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
+        <WorkspaceBreadcrumbItem current>
+          <h1>Usage</h1>
+        </WorkspaceBreadcrumbItem>
+        <WorkspaceBreadcrumbSeparator className="hidden md:flex" />
+        <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink md:flex">
+          <span className="truncate">{windowLabel}</span>
+        </WorkspaceBreadcrumbItem>
+      </WorkspaceBreadcrumb>
+      <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 lg:flex">
+        <ToggleGroup
+          aria-label="Usage metric"
+          variant="segmented"
+          value={[metric]}
+          onValueChange={(next) => {
+            const value = next[0];
+            if (value === "cost" || value === "tokens") setMetric(value);
+          }}
+        >
+          {(["cost", "tokens"] as const).map((option) => (
+            <Toggle key={option} value={option}>
+              {option === "cost" ? "Cost" : "Tokens"}
+            </Toggle>
+          ))}
+        </ToggleGroup>
+        <ToggleGroup
+          aria-label="Usage period"
+          variant="segmented"
+          value={[String(windowDays)]}
+          onValueChange={(next) => {
+            const value = next[0];
+            if (value) selectWindow(Number(value));
+          }}
+        >
+          {WINDOW_OPTIONS.map((option) => (
+            <Toggle key={option.days} value={String(option.days)}>
+              {option.label}
+            </Toggle>
+          ))}
+        </ToggleGroup>
+        <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost">
+          <RefreshCwIcon className="size-3.5" />
+        </Button>
+      </div>
+      <div className="ms-auto flex min-w-0 items-center justify-end gap-1 lg:hidden">
+        <Select
+          value={metric}
+          onValueChange={(value) => {
+            if (value === "cost" || value === "tokens") setMetric(value);
+          }}
+        >
+          <SelectTrigger
+            aria-label="Usage metric"
+            size="compact"
+            variant="ghost"
+            className="w-auto min-w-0"
+          >
+            <SelectValue>{metric === "cost" ? "Cost" : "Tokens"}</SelectValue>
+          </SelectTrigger>
+          <SelectPopup align="end">

apps/web/src/components/usage/UsagePage.tsx · right 162–162

M
macroscopeappBotAug 19, 2026

usage/UsagePage.tsx:162

These two new popups (:162 metric, :178 period) are the only align="end" SelectPopups in apps/web that do not pass alignItemWithTrigger={false} — SettingsPanels.tsx:409, ProjectSettingsPanel.tsx:883, IntegrationsSettings.tsx:303, ThemeSearchSection.tsx:259, ConnectionsSettings.tsx:2864 and the rest all disable it. SelectPopup defaults it to true (select.tsx:116), so these open in Base UI's item-aligned mode: the popup is anchored so the selected item covers the trigger and the positioner reports side=none, which also switches items to the in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] width path (select.tsx:190) off a w-auto min-w-0 trigger. In a topbar pinned to the top edge there is no room above, so the menu lands over the header with the scroll arrows engaged instead of dropping below the trigger like every other header/compact select.

Suggest matching the convention on both popups:

suggestion
1 <SelectPopup align="end" alignItemWithTrigger={false}>

No longer relevant as of 77e09a2e9de223184c85d85616887e86d326a6c5

packages/shared/src/usageMerge.ts · Earlier revision e093685b · right 279

Original diff
@@ -255,12 +263,20 @@ export function mergeUsage(
   const contributingEnvironments: EnvironmentId[] = [];
 
   for (const environment of current) {
-    const { buckets, sessions: environmentSessions } = ownedContribution(
-      environment,
-      ownerByFingerprint,
-    );
+    const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint);
     if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);
-    sessions += environmentSessions;
+
+    for (const [providerKind, providerSessions] of sessionsByProvider) {
+      sessions += providerSessions;
+      const provider = providerAccumulator.get(providerKind) ?? {
+        costUsd: 0,
+        totalTokens: 0,
+        records: 0,
+        sessions: 0,
+      };
+      provider.sessions += providerSessions;
+      providerAccumulator.set(providerKind, provider);
+    }
C
cursorBotAug 16, 2026

Zero-activity providers enter merge

Low Severity

The new session loop writes every owned source into providerAccumulator, including scanned directories with distinctSessions of 0 and no buckets. merged.providers can therefore include all-zero rows. Callers that hide the provider list when it is empty, such as mobile, now show unused providers as $0 / 0 sessions.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit e093685b241e8da2a56784b57fe113095f559688. Configure here.

apps/web/src/components/usage/UsagePage.tsx · Earlier revision d586e6df · right 587–588

Original diff
@@ -571,70 +556,51 @@ function UsageDeviceStrip({
   );
 }
 
-/** Deterministic bar heights (each unique: they double as keys). */
-const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67];
-
 /**
- * Static stand-in with the loaded page's shape: headline, provider split,
- * chart and metrics strip. No shimmer; blocks fill in exactly once when the
- * last device answers.
+ * Static stand-in with the loaded page's shape. No shimmer; blocks fill in
+ * exactly once when the last device answers.
  */
-function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) {
+function UsageSkeleton() {
   return (
     <>
-      <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
+      <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
         <div className="flex flex-col gap-5">
           <div className="flex flex-col gap-1">
-            <span className="text-xs tracking-wide text-muted-foreground uppercase">
-              Raw token cost
-            </span>
-            <div className="my-1.5 h-8 w-36 rounded-sm bg-muted" />
-            <div className="h-3 w-28 rounded-sm bg-muted" />
+            <div className="h-8 w-36 rounded-sm bg-muted" />
+            <div className="h-3 w-32 rounded-sm bg-muted" />
           </div>
-
           {PROVIDER_ORDER.map((provider) => (
-            <div key={provider} className="flex flex-col gap-1.5">
-              <div className="flex items-center justify-between">
-                <span className="flex items-center gap-2 text-sm text-foreground">
+            <div key={provider} className="flex flex-col gap-1">
+              <div className="flex items-center justify-between gap-4">
+                <span className="flex items-center gap-2">
                   <ProviderMark provider={provider} className="size-4" />
-                  {PROVIDER_PRESENTATION[provider].label}
+                  <div className="h-3.5 w-20 rounded-sm bg-muted" />
                 </span>
                 <div className="h-3.5 w-14 rounded-sm bg-muted" />
               </div>
-              <div className="h-1 w-full rounded-full bg-muted" />
               <div className="h-3 w-36 rounded-sm bg-muted" />
             </div>
           ))}
         </div>
 
         <div className="flex flex-col gap-3">
-          <h2 className="py-1 text-sm font-medium text-foreground">
-            {resolution === "hour" ? "Hourly" : "Daily"} cost
-          </h2>
-          {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a
-              relayout when the real chart swaps in. */}
-          <div className="flex h-56 items-end gap-1 pl-16">
-            {SKELETON_BAR_HEIGHTS.map((height) => (
-              <div
-                key={height}
-                className="flex-1 rounded-sm bg-muted"
-                style={{ height: `${height}%` }}
-              />
-            ))}
-          </div>
+          <div className="h-4 w-24 rounded-sm bg-muted" />
+          <div className="h-56 rounded-sm bg-muted/35" />

apps/web/src/components/usage/UsagePage.tsx · right 587–588

M
macroscopeappBotAug 17, 2026

usage/UsagePage.tsx:588

This placeholder no longer matches the geometry of the chart it stands in for: UsageProviderChart renders a w-14 axis gutter plus gap-2 beside the h-56 plot and an x-axis label row under it (UsageProviderChart.tsx:327-457), and the loaded heading is a text-sm h2 (20px), so the skeleton is ~20px shorter and its block starts 64px further left than the real chart. The section — and everything below it once the columns stack under lg — shifts when the last device answers, which is what the mirrored skeleton geometry existed to prevent.

Suggest reserving the gutter and the label row:

suggestion
1 <div className="h-5 w-24 rounded-sm bg-muted" />
2 <div className="flex flex-col gap-1">
3 <div className="ml-16 h-56 rounded-sm bg-muted/35" />
4 <div className="ml-16 h-3 rounded-sm bg-muted/25" />
5 </div>

No longer relevant as of ad49e563b67e030ef8846428afe158f1247b4583

apps/web/src/components/ui/segmented-tabs.tsx · Earlier revision 8f2efdae · right 6–17

Original diff
@@ -0,0 +1,38 @@
+import type { ComponentProps, HTMLAttributes } from "react";
+
+import { cn } from "~/lib/utils";
+import { Toggle } from "~/components/ui/toggle";
+
+function SegmentedTabList({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
+  return (
+    <div
+      className={cn(
+        "inline-flex w-fit items-center gap-0.5 rounded-lg bg-muted/45 p-0.5",
+        className,
+      )}
+      role="group"
+      {...props}
+    />
+  );
+}

apps/web/src/components/ui/segmented-tabs.tsx · right 6–17

M
macroscopeappBotAug 16, 2026

SegmentedTabList reconstructs ToggleGroup (apps/web/src/components/ui/toggle-group.tsx), the primitive the other single-select segmented controls already compose (DiffPanel.tsx:752, PullRequestCodeTab.tsx:1098). A raw role="group" div with per-item pressed/onClick drops what the Base UI group owns: value/onValueChange single-selection semantics and the composite keyboard model (one tab stop, arrow keys to move between segments — here each segment is its own tab stop and arrows do nothing), plus the group's coarse-pointer hit-target normalisation (*:pointer-coarse:after:min-w-auto), so on touch each 24px-tall pill keeps a 44px ::after overlay that spills onto its neighbour and the content below.

Since the segmented size/variant you added live on the shared toggleVariants, ToggleGroupItem can already wear them; suggest keeping the track styling (rounded-lg bg-muted/45 p-0.5) as a ToggleGroup className and letting variant === "segmented" take the gap-0.5 branch rather than the joined-border branch, instead of shipping a second group primitive next to the existing one.

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

Fixed. Usage now composes the existing Base UI ToggleGroup instead of a second group implementation.

apps/web/src/components/ui/toggle.tsx · Earlier revision 146c56f1 · right 32–33

Original diff
@@ -27,6 +29,8 @@ const toggleVariants = cva(
           "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100",
         outline:
           "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none",
+        segmented:
+          "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-background/55 hover:text-foreground data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-xs/10",

apps/web/src/components/ui/toggle.tsx · right 32–33

M
macroscopeappBotAug 19, 2026

ui/toggle.tsx:33

In the default dark theme this makes a hovered unselected segment read the same as the selected one. The track added in toggle-group.tsx:34 is bg-input/40, and dark --input is --alpha(var(--color-white)/8%) (index.css:1106), so the track is 3.2% white over --background #0a0a0a (#121212). hover:bg-background/55 composites 55% of #0a0a0a back over that (#0e0e0e) and data-pressed:bg-background is #0a0a0a — a ~1.4% delta — while hover:text-foreground already matches the pressed text and data-pressed:shadow-xs/10 is a black shadow on a near-black surface, so it adds no lift. Net effect: with the pointer in the group, selection appears to follow the cursor. Light mode is fine (white pill over a ~#eeeeef track).

Suggest keeping the light treatment and letting the pill lift rather than sink in dark, the way the other toggle variants already do (data-pressed:bg-input/64 on the base, dark:data-pressed:bg-input on outline); exact ratios are worth tuning against the real header:

suggestion
1 segmented:
2 "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-background/55 hover:text-foreground data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-xs/10 dark:hover:bg-input/32 dark:data-pressed:bg-input/72",

No longer relevant as of 8156179d91f5f5bd9bb77ef8766f729f6d7dfe00