Skip to content

feat(desktop): add pricing override management UI - #4164

Draft
liuxiaocs7 wants to merge 6 commits into
apache:mainfrom
liuxiaocs7:feat/usage-pricing-override-ui
Draft

feat(desktop): add pricing override management UI#4164
liuxiaocs7 wants to merge 6 commits into
apache:mainfrom
liuxiaocs7:feat/usage-pricing-override-ui

Conversation

@liuxiaocs7

@liuxiaocs7liuxiaocs7 commented Aug 29, 2026

Copy link
Copy Markdown
Member

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

⚠️ Product-direction note (please read)

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

  • IPC: usage:pricing:load + usage:pricing:mutate. The renderer round-trips the exact snapshot it loaded as the CAS base (carries the viewed revision — no reload-latest). mutate runs through the reconciled-control path (like goal:arm): on an unknown outcome it waits for a replacement Host and reconciles against it via a non-replaying reconcilePricingMutation. The three legacy usage:pricing:{list,put,reset} handlers are removed (a test fences them — Acceptance Add RuntimeEvent read model projection #12).
  • Boundary: DesktopPricing* types in src/shared with a decoder; settings.pricing.{load,mutate} scoped to the Settings-selected Host.
  • Renderer: controller / pure view-model / presentational panel (mirrors the Memory page), bound to the selected Host via context. Full built-in ∪ overrides table with a Source column; resetEffect-aware reset-vs-delete copy; "Not set" vs explicit $0 cache rates; range/summary hidden on the tab; field-level validation; skeleton loading (zero layout shift); write-blocked controls carry their reason via tooltip.
  • Removes the dead UsageStats.pricing read 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:

  • Reconnection (P1): reconciled-control path waits for the replacement Host and reconciles without replaying.
  • Host-generation race (P1): mutations capture a generation epoch and drop their result if the Host re-generationed in flight.
  • Conflict shows fresh authority (P1): review_required adopts 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).
  • Reset conflict keeps intent (P1): the confirm dialog stays open for an explicit second confirm against the latest revision.
  • Uncertain/refresh-failed (P2): dims the possibly-stale list and blocks writes until reload.
  • Delete copy (P2): no longer conflates "unpriced" with $0.
  • Loading (P2) skeleton geometry; disabled reason (P2) via tooltip; stories (P2): dropped the FIDELITY-violating narrow duplicate and the unreachable built-in-only/custom-only states — interaction states (editor/validation/conflict/uncertain) are asserted in the renderer tests per 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/core build; 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 latest main.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

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

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

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 Code
@github-actionsgithub-actionsBot added the effort/M Under 500 readable lines label Aug 29, 2026
The 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 Code
Follow-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 Code
@liuxiaocs7
liuxiaocs7 marked this pull request as draft August 29, 2026 21:25
@liuxiaocs7

Copy link
Copy Markdown
MemberAuthor

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.

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

Labels

effort/MUnder 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Usage → Pricing tab is read-only: no UI to add/edit/reset custom pricing overrides

1 participant

@liuxiaocs7