Uh oh!
There was an error while loading. Please reload this page.
feat(desktop): add pricing override management UI - #4164
Conversation
The Usage → Pricing tab was display-only. The Runtime Host pricing
authority (pricing.query/mutate, PricingStore) and the desktop IPC
handlers (usage:pricing:{list,put,reset}) were already built, but the
preload never bridged the mutation channels and UsagePricingPanel had
no add/edit/reset affordance, so custom per-model prices could not be
configured from the app.
- Expose settings.pricing.{put,reset} on the preload bridge, scoped to
the selected Runtime Host, unwrapping the Result so the form can
surface failures inline.
- Carry modelKey and optional cache rates on UsageStats.pricing so a row
can be edited or reset by its canonical key; project them in the main
process.
- Rework UsagePricingPanel: an "Add pricing" toolbar action, per-row
edit/reset, a Dialog form (provider, model, input/output rates and an
optional cache-rate section), and a destructive-confirm reset. Rates
are validated inline and again on the Host via normalizePricingConfig.
Fixesapache#4163
Generated-by: Claude CodeThe pricing override panel adds Astryx surfaces (Dialog, AlertDialog, Layout, Collapsible, NumberInput, HStack, VStack); regenerate the tracked inventory via `npm run astryx:surface-inventory:write` so the CI consistency check passes.
Replaces the legacy usage:pricing:{list,put,reset} path (and the
UsageStats.pricing read model) — which the review flagged for bypassing
CAS and writing to the default Host — with the production adapter path
from spec apache#2015.
- IPC: new usage:pricing:load / usage:pricing:mutate over the existing
DesktopRuntimeHostClient adapter. The renderer round-trips the exact
snapshot it loaded back as the CAS base, so a save carries the revision
the user was viewing instead of a freshly reloaded latest; the three
legacy handlers are removed and a test fences them (Acceptance apache#12).
- Shared DesktopPricing* types move to src/shared with a boundary
decoder; preload exposes settings.pricing.{load,mutate} scoped to the
Settings-selected Host (fixes the wrong-Host write).
- Renderer: a controller / pure view-model / presentational panel split
(mirrors the Memory page) bound to the selected Host via context. The
tab lists the full built-in ∪ overrides table with a Source column,
resetEffect-aware reset-vs-delete copy, and "Not set" vs explicit $0
cache rates; it drops the range/summary so pricing no longer reads as
time-scoped; field-level validation and every mutation outcome
(committed, synchronized, revision_conflict, saved-refresh-failed,
uncertain) are handled per spec.
- Removes UsageStats.pricing and its now-dead read path.
- Tests: view-model, adapter IPC (base pass-through + legacy-fence gate),
and a linkedom renderer interaction test; Storybook variants (loading,
read-error, built-in-only, mixed, custom-only, narrow).
Interaction states (validation / conflict / uncertain) are covered by the
renderer tests rather than autoplay-disabled stories, per stories/FIDELITY.md.
Refs apache#2015
Generated-by: Claude CodeFollow-up to the review on the adapter-backed Pricing editor. Concurrency / correctness: - Reconnection: `usage:pricing:mutate` now uses the reconciled-control IPC path (like `goal:arm`). On an unknown outcome it waits for a replacement Host and reconciles against it via a new, non-replaying `client.reconcilePricingMutation` (load fresh authority + compare intent), instead of returning `reconciliation_unavailable` on the dead connection. - Host-generation race: mutations capture a generation epoch and drop their result if the selected Host entered a new generation while in flight, so a stale save can no longer write onto a freshly loaded snapshot. - Conflict now shows fresh authority: `review_required` adopts the latest snapshot into the list and surfaces the latest value beside the draft; the notice distinguishes a real external change from an uncertain outcome (reason is no longer discarded). - Reset/delete conflict keeps its intent: the confirm dialog stays open for an explicit second confirm against the latest revision. - Uncertain / refresh-failed outcomes dim the possibly-stale list and block writes until a fresh snapshot loads. UX / standards: - Loading reserves the table geometry with skeleton rows (zero layout shift). - Write-blocked Add/Save carry their disabled reason via tooltip (stay focusable through aria-disabled). - Delete copy no longer conflates "unpriced" with an explicit $0. Tests / stories: - New coverage: reconcile-on-unknown IPC path, conflict second-confirm, and uncertain write-block. Stories drop the FIDELITY-violating narrow duplicate and the unreachable built-in-only / custom-only states; interaction states stay asserted in the renderer tests. Refs apache#2015 Generated-by: Claude Code
…ading role
Two direction-independent review fixes before parking the PR:
- Reconciliation now threads the original conflict reason (revision_conflict
vs outcome_unknown) from the dispatch outcome through the reconciled-control
context, so a confirmed revision conflict resolved against a replacement Host
is no longer reported as merely "uncertain".
- The panel title uses `<Heading level={3}>` (the repo's settings idiom) rather
than a `Text type="large"` non-heading role.
Refs apache#2015
Generated-by: Claude Codeliuxiaocs7
commented
Aug 29, 2026
Converting to draft and parking this, pending a product-direction decision. This PR implements the Pricing editor as the full built-in ∪ overrides table. After #2329, the maintainer re-evaluated the feature on #2218 and paused #2015, closing the dedicated full-catalog PR and suggesting a narrower overrides-only + catalog-picker surface (the built-in set is now ~1,400 models.dev entries), with the override schema to be settled in #2330. So the surface this PR builds is exactly the one under reconsideration — no amount of incremental polish resolves that, and it's a maintainer call, not a code one. The Runtime Host adapter (#2148) and Host projection are already merged; the remaining review items that are code-fixable have been addressed across the rounds (host-scoped writes, CAS against the loaded revision, reconciled-control reconnection that never replays, generation-race invalidation, conflict/uncertain UX, field-level validation, skeleton loading, disabled-reason tooltips, Heading role, reason propagation, tests). What remains open is genuinely gated on the direction: whether to keep a full effective table (needs pagination/virtualization + realistic fixtures) or pivot to overrides-only per #2330. Happy to reshape this into the overrides-only + picker surface once the #2330 contract is agreed, or to close this in favor of that. Parking rather than closing so the adapter-backed wiring and tests are easy to pick up. |
Summary
Makes the Usage → Pricing tab an editable, Runtime Host–backed price editor, on the production adapter path (
loadPricingSnapshot/applyPricingMutation, full CAS + reconciliation). This is the M4 UI slice of #2015; #4163 is a duplicate of it.Fixes#4163
After #2329, the maintainer re-evaluated this feature on #2218 and paused #2015 pending the #2330 override-schema contract, closing the dedicated full-catalog PR (#2218) and suggesting the surface be narrowed to overrides-only + a catalog-backed picker (the built-in set is now ~1,400 models.dev entries, so a full built-in ∪ overrides table is likely the wrong product). This PR still renders the full effective table. I've kept it current and addressed both review rounds, but I defer to the maintainers on whether to (a) narrow this to overrides-only per #2330, (b) hold it until that contract lands, or (c) close it. Flagging so the direction call is explicit rather than buried in the diff.
What this implements
usage:pricing:load+usage:pricing:mutate. The renderer round-trips the exact snapshot it loaded as the CASbase(carries the viewed revision — no reload-latest).mutateruns through the reconciled-control path (likegoal:arm): on an unknown outcome it waits for a replacement Host and reconciles against it via a non-replayingreconcilePricingMutation. The three legacyusage:pricing:{list,put,reset}handlers are removed (a test fences them — Acceptance Add RuntimeEvent read model projection #12).DesktopPricing*types insrc/sharedwith a decoder;settings.pricing.{load,mutate}scoped to the Settings-selected Host.resetEffect-aware reset-vs-delete copy; "Not set" vs explicit$0cache rates; range/summary hidden on the tab; field-level validation; skeleton loading (zero layout shift); write-blocked controls carry their reason via tooltip.UsageStats.pricingread model.Review follow-up
Round 1 (both P1s): wrong-Host write → host-scoped bridge; CAS bypass → renderer's loaded snapshot is the base.
Round 2:
review_requiredadopts the latest snapshot into the list and shows the latest value beside the draft; the notice distinguishes a real external change from an uncertain outcome (reason no longer discarded).$0.stories/FIDELITY.md.Correction: the earlier "waiting for #2024 / M5" note is stale — #2024 and the #2420 M5 cutover are both merged.
Still open from the review: no in-app screenshot yet (Electron isn't runnable in my sandbox — happy to attach one from a local run); large-catalog scale is the product-direction question above.
Verification
Local, all green:
tsc --noEmit× 4 desktop projects +@maka/corebuild;biome lint ./format .;knip --workspace apps/desktop;astryx:surface-inventory;node --test— 44 pricing/usage/adapter tests including the reconcile-on-unknown IPC path, the conflict second-confirm, the uncertain write-block, and the base/host pass-through. CI (test,package,windows_recovery) green on the merge with latestmain.AI use
Tool(s) and scope: Claude Code (Claude Opus 4.8) — investigated #2015/#2218/#2330, implemented the adapter-backed rewrite and both review rounds, and the tests. Reviewed and submitted by the human contributor of record; commits carry
Generated-by: Claude Code.Checklist
Does this PR entail a change in behavior?