feat(web): edit usage prices across selected environments#9861

merged4
t3code/stream-usage-environment-data
t3code/usage-environment-prices
openedSep 5, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago

Custom prices had to be edited one model and environment at a time, even when Usage combined several servers. Move Model prices into the Usage environment dropdown and edit prices in a table scoped to the selected environments.

Add or reset rows locally, then explicitly save all changed cells in one settings patch per environment. Mixed cells retain each environment’s own untouched rates. Each destination reports its result, and retry writes only to failed destinations.

Validation: focused form, table, and fan-out tests; web typecheck and scoped lint; real browser saves against two isolated environments, including mixed rates, reset/undo, no save on blur, and a 320px viewport. Regression coverage includes unavailable destinations and model IDs matching object prototype properties, and empty new rows alongside valid edits.

Both captures use the actual base/head at 1440Γ—1000 with the same synthetic prices on two environments. The old editor initially shows one environment; the table starts with all environments selected.

Recording: environment filtering and staged table edits

Implemented with GPT-6 in Codex.

CcursorBotreviewed Β· 1 inline threadSep 5, 2026
Synced from GitHub

Stale Bugbot comment from a previous run.

apps/web/src/components/usage/UsagePriceOverrides.tsxline 150Outdated Β· 0d1c2efOpen thread
……@@ -1,318 +1,558 @@
147+ selected
148+ .filter((target) => target.prices !== null)
149+ .flatMap((target) => [...plans.get(target.environmentId)!.errors]),
150+ );
CcursorBoton line 150Sep 5, 2026

Unwritable envs block valid price saves

Medium Severity

The save-blocking errors map keeps validation failures from every selected environment with loaded prices, including ones already marked unavailable (offline, read-only, old server). A single-field edit of an existing override then fails validation on those destinations because they still need a complete price to create one. Save stays disabled, and Apply to is frozen once drafts exist, so writable environments cannot receive the change without discarding and deselecting first.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 0d1c2ef4d8475dbadac7a383bbdef20020d425cf. Configure here.

Fixed in f3f881b61. Only writable destinations contribute save-blocking validation; unavailable destinations still receive their own Not saved result. Regression tests cover offline, read-only, and older servers without an existing override while another environment can accept a single-column edit. All 33 focused tests and web typecheck pass.

Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable β€” This PR adds a substantial cross-environment pricing workflow with new state, validation, partial-save/retry behavior, and persistent writes that affect usage-cost calculations. Its production and metering impact exceeds the scope of an automatically approvable UI change.

Notes:

  • Diff unchanged. Approvability was decided on eligibility alone.

You can add or adjust custom eligibility rules. Learn more.

Synced from GitHub

Thread transfer impact

βœ… Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wireβ€”13.8 KiBβ€”15.1 KiBβœ…
CodexThread snapshot wireβ€”7.0 KiBβ€”7.3 KiBβœ…
CodexLive turn WebSocket wireβ€”6.7 KiBβ€”7.8 KiBβœ…
CodexLive turn WebSocket decodedβ€”58.5 KiBβ€”66.4 KiBβœ…
CodexLive turn messagesβ€”10β€”21βœ…
ClaudeTotal thread wireβ€”13.8 KiBβ€”15.1 KiBβœ…
ClaudeThread snapshot wireβ€”7.0 KiBβ€”7.3 KiBβœ…
ClaudeLive turn WebSocket wireβ€”6.7 KiBβ€”7.8 KiBβœ…
ClaudeLive turn WebSocket decodedβ€”59.3 KiBβ€”66.4 KiBβœ…
ClaudeLive turn messagesβ€”10β€”21βœ…

Baseline: unavailable Β· PR result: 6afb81c Β· Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 113.8 KiB
  • Claude decoded thread snapshot: 114.5 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

apps/web/src/components/usage/usagePriceTable.tslines 26–26Outdated Β· f3f881bOpen thread
……@@ -0,0 +1,84 @@
23+) {
24+ const optional = USAGE_PRICE_FIELDS.find((entry) => entry.key === field)!.optional;
25+ const values = targets.map((target) =>
26+ target.prices?.[model] ? usagePriceForm(model, target.prices[model])[field] : null,
MmacroscopeappBoton lines 26–26Sep 5, 2026

🟠 High usage/usagePriceTable.ts:26

usagePriceCell crashes while rendering a model named toString (or another inherited Object.prototype key): target.prices?.[model] returns the inherited function, and usagePriceForm then dereferences missing price fields. Require an own property before reading the price record.

diff
1- target.prices?.[model] ? usagePriceForm(model, target.prices[model])[field] : null,
2+ target.prices && Object.prototype.hasOwnProperty.call(target.prices, model)
3+ ? usagePriceForm(model, target.prices[model])[field]
4+ : null,
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/usage/usagePriceTable.ts around line 26:
2
3`usagePriceCell` crashes while rendering a model named `toString` (or another inherited `Object.prototype` key): `target.prices?.[model]` returns the inherited function, and `usagePriceForm` then dereferences missing price fields. Require an own property before reading the price record.

Fixed in e540d908c. All table price lookups now require an own record property, including display, edits, and reset. Regression tests cover constructor, toString, and proto as exact model IDs, both before and after a custom price exists. The focused tests, web typecheck, and scoped lint pass.

CcursorBotreviewed Β· 1 inline threadSep 5, 2026
Synced from GitHub

Stale Bugbot comment from a previous run.

apps/web/src/components/usage/UsagePriceOverrides.tsxline 153Outdated Β· 32c1d4cOpen thread
……@@ -1,318 +1,552 @@
150+ (customModels.includes(draft.model.trim()) ||
151+ drafts.some((other) => other.id !== draft.id && other.model.trim() === draft.model.trim()))
152+ )
153+ errors.set(draft.id, "This model already has a row. Edit its prices there.");
CcursorBoton line 153Sep 5, 2026

Empty rows silently block saving

Medium Severity

Adding a model with the plus button inserts an empty draft that is treated as invalid, so Save changes stays disabled. The Enter a model ID message is hidden until the row has content, and Apply to also locks because hasChanges is already true. Other staged edits cannot be saved until that blank row is filled or removed.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 32c1d4cb358ac90234d659844bc8ae7eed039f22. Configure here.

Fixed in ef877f722. Completely empty new rows no longer count as staged changes or save-blocking errors. Partially filled rows still validate. A regression test covers valid edits alongside an empty row and a nameless row with an explicit zero. Browser verification confirms Apply to stays available for an empty row and existing edits remain saveable.

apps/web/src/components/usage/UsagePriceOverrides.tsxline 263Outdated Β· 32c1d4cOpen thread
……@@ -1,318 +1,552 @@
260+ <MenuTrigger
261+ aria-labelledby="usage-prices-apply-label usage-prices-selection"
262+ disabled={pending || hasChanges}
263+ className={cn(buttonVariants({ variant: "outline", size: "sm" }), "min-w-0")}
MmacroscopeappBoton line 263Sep 5, 2026

usage/UsagePriceOverrides.tsx:263

Shared primitives: rather than reapplying buttonVariants as a class string, MenuTrigger should compose Button from ui/ via render, which is the convention at the other menu call sites (PreviewMoreMenu.tsx, CompactComposerControlsMenu.tsx).

diff
1 <MenuTrigger
2+ render={<Button variant="outline" size="sm" className="min-w-0" />}
3 aria-labelledby="usage-prices-apply-label usage-prices-selection"
4 disabled={pending || hasChanges}
5- className={cn(buttonVariants({ variant: "outline", size: "sm" }), "min-w-0")}
6 >

(buttonVariants can then be dropped from the ../ui/button import.)

No longer relevant as of ef877f722c22f41122711c3a2bf1d2675c12160e

Fixed in ef877f722. The menu trigger now composes Button through render. Verified the environment dropdown still opens and selects normally; web typecheck and scoped lint pass.

apps/web/src/components/usage/UsagePriceOverrides.tsxline 430Outdated Β· 32c1d4cOpen thread
……@@ -1,318 +1,552 @@
427+ }
428+ autoComplete="off"
429+ disabled={locked}
430+ className="bg-transparent shadow-none tabular-nums"
MmacroscopeappBoton line 430Sep 5, 2026

usage/UsagePriceOverrides.tsx:430

Shared primitives: bg-transparent shadow-none overrides the Input primitive's base chrome from the call site. Input already supports opting out (unstyled), and InputGroup variant="ghost" is the existing borderless treatment β€” prefer one of those, or add a ghost variant to Input, instead of an ad hoc class override on every cell.

No longer relevant as of ef877f722c22f41122711c3a2bf1d2675c12160e

Fixed in ef877f722. Removed the background and shadow overrides so table cells use the standard compact Input styling and keep its focus and disabled states.

CcursorBotreviewed Β· 1 inline threadSep 5, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON, but a cloud agent failed to start.

Reviewed by Cursor Bugbot for commit ef877f722c22f41122711c3a2bf1d2675c12160e. Configure here.

apps/web/src/components/usage/UsagePriceOverrides.tsxline 238Outdated Β· ef877f7Open thread
……@@ -20,299 +20,535 @@ import { Button } from "../ui/button";
235+ },
236+ });
237+ setPending(false);
238+ if (!failed) setDrafts([]);
CcursorBoton line 238Sep 5, 2026

Retry lock blocks completing prices

Medium Severity

A single-column edit can save on a writable destination while an unavailable one is skipped. After that destination becomes writable without a full override, retry treats the missing required rate as a failed save and locked keeps the table read-only, so the incomplete fields cannot be filled. Discard is the only way out, and it drops the staged change instead of finishing the documented retry.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit ef877f722c22f41122711c3a2bf1d2675c12160e. Configure here.

Retry deliberately replays the original batch only on failed destinations. The table stays locked after a partial save so changing its values cannot silently alter that batch or write again to successful destinations. An environment without an existing override needs both required rates; a single-column edit cannot create that override. That case reports the missing rate and requires discarding the pending batch, selecting that environment, and entering a complete price. Successful writes remain persisted. I am keeping this explicit recovery path within the approved flow rather than making Retry edit an already partially applied batch.

Added 4 commits

  • fix(web): let available environments save valid price edits22146f7
  • feat(web): edit usage prices across selected environments586402f
  • fix(web): ignore untouched model price rows when saving6afb81c
  • fix(web): handle model IDs matching object propertiesa6e2b00