Skip to content

[5/n] Render per-user/team/own rows in cycle usage section#11123

Open
IsaiahWitzke wants to merge 16 commits into
iw/billing-cycle-usage-sectionfrom
iw/billing-cycle-usage-rows
Open

[5/n] Render per-user/team/own rows in cycle usage section#11123
IsaiahWitzke wants to merge 16 commits into
iw/billing-cycle-usage-sectionfrom
iw/billing-cycle-usage-rows

Conversation

@IsaiahWitzke
Copy link
Copy Markdown
Contributor

@IsaiahWitzke IsaiahWitzke commented May 17, 2026

image image

@cla-bot cla-bot Bot added the cla-signed label May 17, 2026
@IsaiahWitzke IsaiahWitzke changed the title Render per-user/team/own rows in cycle usage section [5/n] Render per-user/team/own rows in cycle usage section May 17, 2026
IsaiahWitzke and others added 7 commits May 17, 2026 00:27
Replaces the placeholder body of the billing cycle usage section with
real per-row rendering driven by the resolved `UsageVisibility` model:

* OwnOnly viewers see one self row with their cost-type breakdown
* TeamAggregate admins see one synthetic 'Team total' row
* PerUserTotals admins see one row per workspace member (zero rows
  for members with no usage), plus a Local/Cloud source filter when
  the workspace has any cloud usage

Each row renders a stacked horizontal bar (filled portion sized to
`total_credits / team_max_credits`, min 5%) with cost-type segments,
and a hover tooltip showing the per-cost-type breakdown plus totals.

Code lives in a new `billing_cycle_usage_rows` module; the section
view only owns the `source_filter` state, the `RowMouseStates`, and a
new `ChangeSourceFilter` action that the toggle dispatches.

Visual model mirrors the admin-panel reference at
`warp-server/client/src/components/admin/billing/memberUsageUtils.ts`
+ `MemberUsageTable.tsx` (sort orders, color palette, zero-row
backfill for the current cycle only, Voice / SuggestedCodeDiffs /
Team subjects filtered before aggregation). Enterprise admins still
short-circuit to the admin-panel CTA before `render_body` runs.

Co-Authored-By: Oz <oz-agent@warp.dev>
…indow edge

Without explicit width bounds, the per-row tooltip's inner rows use
`MainAxisSize::Max` (for their `SpaceBetween` alignment) and the
overlay layout inherits the window width via
`ParentOffsetBounds::WindowByPosition` \u2014 the tooltip greedily expands
to fill the entire panel. Wrap the tooltip in a `ConstrainedBox` with
sensible min/max width bounds (240\u2013360px) so it sizes like a tooltip.

Also bump `TOOLTIP_GAP` from 4px to 6px for a slightly more legible
separation between the row and the tooltip.

Co-Authored-By: Oz <oz-agent@warp.dev>
The tooltip_lines Vec was always initialized as segments.clone() at every
call site and only ever read as a separate source of breakdown lines.
The distinction would only matter if we wanted zero-credit lines to
appear in the tooltip but not in the bar; we don't synthesize those
today, so the two fields stayed in lockstep.

Drop tooltip_lines and use segments directly in render_usage_tooltip_content
and render_member_row's empty-row check.

Co-Authored-By: Oz <oz-agent@warp.dev>
Previously used theme.tooltip_background() which resolves to neutral_6
(60% foreground blend) and in light mode came out as a muddy mid-gray
that didn't sit in the rest of the surface palette.

Switch to theme.background() so the tooltip reads as a clean
card-on-page popover with the outline border providing the only
separation from the rows beneath it. Text colors are computed against
the new background so contrast stays correct in light + dark themes.

Co-Authored-By: Oz <oz-agent@warp.dev>
The bar sits flush against the top edge of the row card, which has a
rounded with_top corner radius. Container.with_corner_radius only
rounds the Container's own painted rect, not its children \u2014 so the
rectangular colored segments inside the bar were visibly overflowing
into the card's rounded top-left/top-right corners. Clipped only does
rectangular clipping so it can't help here.

Apply per-corner radii directly to the bar's outermost children:
- Empty/zero bar: muted track with CornerRadius::with_top
- Leftmost colored segment: top_left
- Rightmost colored segment (only if no muted tail): top_right
- Muted track tail (if present): top_right

This keeps the bar visually flush with the card and removes the
overflow.

Co-Authored-By: Oz <oz-agent@warp.dev>
* Switch row card background from surface_1 to theme.background() so the
  rows visually match the tooltip (which already uses background()).
  Border color goes from surface_3 to theme.outline() for the same
  reason.
* Apply the change to the empty-filter-state card too.
* Container paints its child inside the border, so the stacked bar
  begins 1px inset from the card's outer edge. Using the card's full
  outer radius (8px) on the bar produced a visible 1px step at the
  top-left/top-right corners. Introduce BAR_CORNER_RADIUS = ROW_BORDER_RADIUS - ROW_BORDER_WIDTH
  so the bar's inner curve aligns flush with the card's inner curve.

Co-Authored-By: Oz <oz-agent@warp.dev>
…, dropdown fix

(a) Tooltip drop shadow so it clearly floats above the rows.
(b) Period dropdown: prevent_interaction_with_other_elements() on the
    menu so a click on the trigger while the menu is open is consumed
    by the dismiss handler rather than also firing the trigger's
    on_click, which previously closed-then-reopened the menu.
(c) Tooltip rows: credits and $cost render in fixed-width
    right-aligned columns so the numbers line up vertically across
    rows even though each row is laid out independently. Total row
    drops the swatch and uses semibold for emphasis.
(d) No 'cr' suffix anywhere. Tooltip uses '/' between the credit count
    and the dollar amount, matching the admin-panel reference.
(e) Per-row credit cluster:
    - MemberUsageRow gains a base_limit: Option<i64>, populated from
      WorkspaceMember.usage_info.request_limit when the member is not
      flagged is_unlimited. Service accounts and the synthetic team-
      aggregate row stay None.
    - render_rows looks up the viewer's member entry for OwnOnly so
      the viewer sees their own base limit too.
    - Row body renders [CoinsStacked] X[/limit] (cluster gap)
      [Credits-icon] $cost. No 'cr'. The credit-card slot uses
      Icon::Credits as a stand-in; swap to a real credit-card icon
      when one is added to the bundled set.

Co-Authored-By: Oz <oz-agent@warp.dev>
@IsaiahWitzke IsaiahWitzke force-pushed the iw/billing-cycle-usage-rows branch from 4ff0c42 to 8decc7f Compare May 17, 2026 04:31
IsaiahWitzke and others added 9 commits May 17, 2026 00:34
Drop in a credit-card SVG (~/Downloads/Vector.svg, exported from
Figma) as bundled/svg/credit-card.svg, register Icon::CreditCard in
the warp_core icon enum + path map, and wire up the cycle-usage row
to use it:

- Coin slot (left of credits text) switches from Icon::CoinsStacked to
  Icon::Credits so it matches the 'Buy credits' denomination buttons.
- Cost slot (left of $cost text) switches from the Icon::Credits
  stand-in to the new Icon::CreditCard.

Co-Authored-By: Oz <oz-agent@warp.dev>
Previously the synthetic `Aggregate` cost type shared the pink/purple
swatch of `BaseLimit`, which was confusing in TeamAggregate and
PerUserTotals modes where every bar/tooltip was painted base-pink even
though the segments aren't actually base credits.

Introduces a neutral gray (#8C8C8C) reserved for the aggregated bucket,
adds an "Aggregated" legend entry alongside the existing four chromatic
ones, and renames the label from "Total" to "Aggregated" so it no
longer collides with the tooltip's "Total usage" footer.

Also fixes a pre-existing `let_underscore_future` clippy violation in
the Refresh action handler so the workspace stays warning-clean.

Co-Authored-By: Oz <oz-agent@warp.dev>
…m-aggregate topper

- Swap the row cards, tooltip, and empty-state placeholder borders from
  the theme outline to the same fixed gray (#2B2B2B / CARD_BORDER_COLOR)
  the Balance section's Base/Personal/Team credit cards use, so they
  read as part of the same card family.
- Inline ROW_CLUSTER_GAP, TOOLTIP_CREDITS_COL_WIDTH, and
  TOOLTIP_COST_COL_WIDTH (each was only used once and didn't earn a
  module-level const).
- Tighten the BAR_CORNER_RADIUS, MemberUsageRow.base_limit, MemberUsageRow.segments,
  cost_type_color, entry_is_renderable, aggregate_segments,
  build_own_usage_row, build_team_aggregate_row, build_member_usage_rows,
  render_stacked_bar, render_usage_tooltip_content, render_tooltip_row,
  build_row_card, render_member_row, render_source_filter_toggle, and
  render_rows comments; drop the verbose AGGREGATE_CREDITS_DOT_COLOR doc.
- Render the Team total row at the top of PerUserTotals view: the team
  total fills the bar 100% and each member's bar reads as their share
  of the team's usage.

Co-Authored-By: Oz <oz-agent@warp.dev>
…, lighter borders

- Rename the synthetic Aggregate cost type's label from "Aggregated" to
  "All sources" everywhere (row code + section legend).
- Add a small hover tooltip on the "All sources" legend entry:
  "Includes all credit sources." Rendered as a positioned overlay
  anchored below the dot/label cluster.
- PerUserTotals sizing: team row always fills the bar 100%; member rows
  scale against the top individual member instead of the team total, so
  the heaviest user fills the bar and others read as a fraction of that
  user rather than vanishing to ~10% of the team aggregate.
- Revert row cards / tooltip / empty-state placeholder borders to
  theme.outline().into_solid() (lighter), and also flip the Balance
  section's Base/Personal/Team cards to the same lighter border instead
  of CARD_BORDER_COLOR. CARD_BORDER_COLOR is no longer referenced.
- Tighten the verbose comments in billing_cycle_usage_rows.rs (kept doc
  comments on public items, dropped long inline rationales) and inline
  ROW_CLUSTER_GAP / TOOLTIP_CREDITS_COL_WIDTH / TOOLTIP_COST_COL_WIDTH
  (single-use values).

Co-Authored-By: Oz <oz-agent@warp.dev>
… width

- New copy: "Aggregates usage from base, add-on, pay-as-you-go, and
  ambient credits." — names every source instead of leaving "all credit
  sources" as a vague chip.
- Wrap the tooltip text via Text::new (multi-line capable) and constrain
  width to 220-280px so it wraps to two lines instead of shrink-wrapping
  to a single-line chip that read as a badge.
- Bump padding to 12 horizontal / 8 vertical for a less cramped feel.

Co-Authored-By: Oz <oz-agent@warp.dev>
…to match legend

- Drop the ConstrainedBox min/max width and switch back to Text::new_inline
  so the tooltip renders as a single horizontal chip rather than wrapping
  awkwardly to two lines.
- Rename 'ambient credits' to 'ambient-only credits' to match the legend
  label rendered right next to the tooltip and the row tooltip's cost-type
  label (both already say 'Ambient-only').

The trial widget on the same page uses 'Cloud agent trial' — there's a
broader page-wide inconsistency between 'Cloud agent' and 'Ambient-only'
that's out of scope here; the local choice is to match the adjacent legend.

Co-Authored-By: Oz <oz-agent@warp.dev>
…side label

- The Usage section header now mirrors the admin panel layout: bold
  "Usage" title with secondary muted "Resets MMM dd at h:mm a" subtext
  directly to its right.\n- Removed the duplicate render_resets_label from the right side of the
  header. The right side now only carries the period selector (when the
  tier exposes multiple cycles) and the refresh icon.\n- The subtext is hidden when the viewer has selected a past cycle from
  the period dropdown — in that case the dropdown itself shows the\n  cycle's date range, and "Resets ..." would be misleading.\n- Timezone abbreviation is intentionally omitted, matching the requested\n  pattern (e.g. "Resets May 27 at 11:24 PM").\n\nCo-Authored-By: Oz <oz-agent@warp.dev>
@IsaiahWitzke IsaiahWitzke marked this pull request as ready for review May 17, 2026 06:22
@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 17, 2026

@IsaiahWitzke

I'm starting a first review of this pull request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

This PR adds billing cycle usage row rendering for own, team aggregate, and per-user visibility modes, including stacked bars, tooltips, source filtering, and a new credit-card icon. The PR includes visual evidence in the description.

Concerns

  • Team aggregate visibility can render as empty when the server supplies the already-collapsed Team entries because the aggregate builder reuses a predicate that drops those rows.
  • Own-only filtering treats missing subject UIDs as belonging to the viewer, which weakens the client-side privacy guard.
  • A previously selected Cloud/Local source filter can become stuck when the toggle is hidden for a later data set with no cloud usage.

Security

  • The OwnOnly row should fail closed when the authenticated viewer UID is known but an entry has no subject UID; otherwise malformed or partially redacted rows can be displayed in a viewer-only surface.

Verdict

Found: 0 critical, 3 important, 0 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Comment on lines +255 to +258
.filter(|e| match (viewer_uid, e.subject_uid.as_deref()) {
(Some(uid), Some(entry_uid)) => uid == entry_uid,
(_, None) => true,
(None, _) => true,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] [SECURITY] When viewer_uid is known, entries without a subject_uid are treated as the viewer's own usage, so a malformed or redacted non-viewer row could be exposed in OwnOnly views. Fail closed for missing UIDs in the Some(uid) case.

Suggested change
.filter(|e| match (viewer_uid, e.subject_uid.as_deref()) {
(Some(uid), Some(entry_uid)) => uid == entry_uid,
(_, None) => true,
(None, _) => true,
.filter(|e| match (viewer_uid, e.subject_uid.as_deref()) {
(Some(uid), Some(entry_uid)) => uid == entry_uid,
(Some(_), None) => false,
(None, _) => true,

) -> MemberUsageRow {
let filtered = entries
.iter()
.filter(|e| entry_is_renderable(e))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] build_team_aggregate_row still filters through entry_is_renderable, which drops SubjectType::Team; if the server sends only the collapsed team aggregate row for TeamAggregate visibility, this renders zero usage. Include team aggregate entries here or branch on whether collapsed rows are present.

let member_rows = build_member_usage_rows(entries, &workspace.members, source_filter);

// Only show the toggle if there's cloud usage to filter against.
if has_cloud_usage(entries) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] The toggle is hidden whenever the current data set has no cloud usage, but source_filter can still be Cloud from a previous period; then the UI shows No cloud usage with no way to switch back. Keep the toggle visible when source_filter != SourceFilter::All or reset the filter on period/data changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant