mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
* feat(desktop): revamp Billing page — plan card, in-app plans view, tier art
Reshape the desktop Billing settings per wayfinder ticket 09. New page order:
Plan → Payment → One-time top-up → Automatic refill → Usage, with the at-a-glance
summary strip unchanged at the top.
- CurrentPlanCard replaces the old Subscription row: tier name + price + renewal
and at most one button — "View plans" (free/no-sub + can_change_plan),
"Change plan" (subscriber + can_change_plan), or none for teams / non-changers.
Teams keep the portal "Adjust plan ↗" link so they are not stranded. The button
navigates in-app to the plans sub-view.
- bview=plans sub-view mirrors the settings pview/kview pattern (useRouteEnumParam,
default overview). BillingPlansView renders a grid of PlanCard from live tiers[]
(is_enabled, sorted by tier_order, free tier included).
- PlanCard: tier art + name + $/mo + monthly credits as dollars ("$110 credits/mo").
Current tier is highlighted + inert; higher/no-current tiers get "Choose ↗"
(opens portal with plan=<tierId>); lower tiers are a DISABLED "Downgrade" with a
caption — downgrades move in-app in ticket 11 (gateway pending-change flow), so
this PR intentionally links them out/disabled rather than wiring the money path.
- buildManageSubscriptionUrl gains an optional third arg (tierId) → appends
plan=<tierId>. Signature kept identical to draft PR #68666 for a trivial rebase;
NAS #748 validates the param server-side.
- Tier art: four NAS hero webps rendered as ~40px thumbnails over a Nous-blue well
with per-tier blend modes (the only place Nous blue appears). Keyed by lowercase
tier NAME (free/starter→connect, plus→memory, super→automation, ultra→sandbox);
unknown name → text-only card. Imported via vite static imports for packaged
file:// + webSecurity.
- Top-up vs auto-refill disambiguated by section label + first sentence: "One-time
top-up" / "Buy credits now" vs "Automatic refill" / "Refill when low" (configured
copy reads "Charges $X automatically when your balance falls below $Y.").
- Variant-A auto-refill editing: Manage swaps the row's left side (caption → the two
$ fields with a pre-allocated error line) and the action column (Manage → Save/
Cancel) in place, with the row height reserved for the tallest state so the Usage
section never shifts. Fixes the spurious on-open validation error (errors now show
only after an edit or a save attempt). Save/disable API calls + confirm-disable
flow unchanged.
- Remove subscriptionTierChips and the subscription-row chips; reshape (not delete)
deriveBillingView to expose plan + tiers. Buy-credits row keeps the chips seam.
- Dev fixtures: add free-personal and subscriber-personal (personal orgs, full
4-tier Free/Plus/Super/Ultra catalog) so the plans view is exercisable.
Tests: update/extend index.test.tsx + use-billing-state.test.ts, add tier-art.test.ts;
delete the old chips tests. Desktop billing suite 70/70 green, typecheck clean.
* fix(desktop): mark the free/lowest tier current (not an upgrade) when there is no subscription
Visual verification caught a spec-fidelity bug: in the plans grid, an account with
no active subscription rendered the Free tier ($0/mo, tier_order 0) as a "Choose ↗"
upgrade — clicking would deep-link the portal to "subscribe to Free".
Ruling: current-card = tier.is_current OR (subscription.current == null AND the tier
is the lowest-order / $0 tier). derivePlanTiers now falls back to the lowest-order
tier as the stand-in current plan when there is no subscription, so the free card
renders exactly like is_current (inert, "Current plan") and — being the lowest order
— no tier can be a downgrade; every paid tier is a "Choose ↗" upgrade.
CurrentPlanCard is unaffected (still "Free" + "View plans"); subscriber-personal is
unchanged (Free stays a disabled Downgrade below the current Plus tier).
Tests: free-personal grid now asserts Free = current/inert, no downgrade state, three
Choose buttons; text-only unknown-tier test gains a free tier so the unknown paid tier
is unambiguously an upgrade. Billing suite 70/70 green, typecheck + lint clean.
* chore(desktop): shrink bundled tier art to 128px thumbnails
The plan-card wells render the art at ~40px; shipping the full landing
images added 2.7 MB to the repo for no visible difference. 128px covers
2x displays; total is now 26 KB.
* fix(desktop): address 6 adversarial-review findings on the Billing revamp
1. Grandfathered current tier (BLOCKER). NAS marks a grandfathered current tier
is_enabled:false; the enabled-only filter dropped it, leaving currentOrder
undefined so every lower tier rendered as an actionable "Choose ↗". derivePlanTiers
now resolves current identity/ordering against the UNFILTERED tiers and keeps the
grandfathered current tier in the grid as the inert "Current plan" card; downgrades
classify against its tier_order. (Non-current disabled tiers are still dropped.)
2. Dead plan-card button. derivePlanCard offered "View plans"/"Change plan" purely on
can_change_plan, but the grid could be empty / current-only and showPlans refused,
so the button no-oped. It now offers the in-app action ONLY when the grid has ≥1
actionable (non-current) tier; otherwise it falls back to the portal link.
3. Deep-link bypass. showPlans now gates on the same capability that renders the button
(view.plan?.action), so a team / non-changer deep-linking bview=plans always falls
back to overview instead of a grid of live Choose buttons.
4. Lost portal escape hatch. Whenever the card has no in-app action (teams, non-changers,
refused subscription, empty catalog) it now ALWAYS carries the "Adjust plan ↗" portal
link built from subscription?.portal_url ?? billing.portal_url — the refusal caption
no longer promises a portal the UI didn't render.
5. Choose URLs dropping org_id/plan. (a) derivePlanTiers now threads billing.portal_url
as the fallback base for the Choose URL. (b) buildManageSubscriptionUrl treats the
hard-coded FALLBACK_PORTAL_BILLING_URL as a last-resort ORIGIN (applying org_id/plan)
instead of a bare return, so a null portal_url never strips the routing params.
6. Zero-shift on narrow panes. Replaced the magic min-h-28 (under-reserved once the two
inputs stack below @2xl) with exact reservation: the edit form is always rendered and
both states share one grid cell ([grid-template-areas:'stack']), invisible+aria-hidden
when not editing — the row equals the tallest state at every width, no breakpoint math.
The refusal stays inside the reserved layer.
Tests: +12 (grandfathered current, no-dead-button + empty-catalog portal link, team &
personal deep-link fallback to overview, billing.portal_url-backed Choose URL, fallback
org_id/plan, reserved-form-mounted); updated the two portal-link expectations for §4.
Billing suite 78/78 green; typecheck (app/electron/e2e) + lint clean.
* refactor(desktop): reuse the shared openExternalLink helper in the plans view
* fix(desktop): honor the auto_reload wire contract — null card + disable amounts
A full-stack contract sweep (desktop ↔ shared types ↔ gateway ↔ NAS) surfaced two
real desktop bugs in the auto-refill row:
A. auto_reload.card can be null. The gateway's _parse_auto_reload_card returns None
for a missing/unknown-kind card and _serialize_billing_state emits `card: null`,
but the shared BillingAutoReload.card union had no null arm and use-billing-state
dereferenced `autoReload.card.kind` bare — a crash on the enabled path. Add `| null`
to the shared union (contract honesty) and guard the read (`card?.kind`); null now
falls through to the default enabled path, same as a canonical card.
B. Disable was rejected by the gateway. billing.auto_reload unconditionally requires
threshold + top_up_amount, so `updateAutoReload({ enabled: false })` came back
invalid_request. (The TUI always sends both; desktop fixture mode stubbed it.)
disable() now sends the current threshold_usd/reload_to_usd from the autoReload
prop alongside enabled: false, matching the TUI.
Tests: enabled auto_reload with card:null renders the normal enabled row (derivation
+ render, no crash); disable call carries both current amounts. Billing suite 80/80
green; typecheck (app/electron/e2e) + lint clean.
* fix(tui): guard the nullable auto_reload card in the auto-reload screen
The shared BillingAutoReload.card union gained its honest null arm (the
gateway emits card: null for a missing/unknown card); the TUI's only bare
dereference follows the same default path as a canonical card.
* fix(desktop): align billing inputs to the sm control height
The three billing inputs used an ad-hoc h-8 (32px) next to size=sm
buttons (24px). They now use the control system's size=sm with a
py-[3px] compensation for the input's real 1px border — buttons draw
theirs as an inset shadow, so sm alone still sits 2px taller. All five
controls in the buy row now measure 24px.
* fix(desktop): plan-card actionability + billing view-model hardening
Code-quality review of the Billing revamp (PR #68722).
BLOCKING — a top-tier subscriber (only downgrades/current below them) opened a
plans grid with zero enabled actions AND no portal link. The plan card gated its
in-app button on `tiers.some(state !== 'current')`, which counts the (disabled)
downgrade tiles. It now gates on an actual UPGRADE being present
(`capable && tiers.some(state === 'upgrade')`); with no upgrade the card falls back
to its "Adjust plan ↗" portal link, and the bview=plans deep link (gated on the same
plan.action) falls back to overview.
Reviewer structural items:
- One "plans capability" verdict (personal + can_change_plan + subscription ok) is
derived once in deriveBillingView and threaded to BOTH derivePlanCard and
derivePlanTiers; the grid only mints upgrade actions when capable, so the invariant
lives in one place.
- BillingPlanTierView is now a discriminated union (`current` | `downgrade` w/
disabledCaption | `upgrade` w/ required action), and BillingPlanCardView is an
action-XOR-link union — deleting the `tier.action?.url ?? ''` and `plan.link?.url`
defensive branches in the consumers.
- `findCurrentTier(subscription)` replaces the repeated is_current||id predicate at
its three sites (plan card price, grid ordering, summary plan line).
- BillingView exposes named `paymentRow` / `topupRow` / `refillRow` instead of an
`accountRows[]` + three `.find(id)` lookups.
- The auto-refill row that edits in place carries an explicit `manageInApp: true`;
AutoReloadRow keys off it instead of sniffing the action label/url.
- tier-art header comment no longer cites an internal repo path; dead `?.` removed
from RowValue (via a destructured const) and the plan-card link handler.
Behavior is identical except the blocking fix. Billing suite 82/82 green; typecheck
(app/electron/e2e) + lint clean.
* refactor(desktop): adopt inline-review nits on the billing plan card
Resolves the inline suggestion threads:
- plan-card gate reads a named `hasActionableTier` = "a tile carries an action"
(union-safe `'action' in tier`, equivalent to the old upgrade-only check).
- re-narrow link/action inside the click callbacks (`plan.link && …`,
`tier.action && …`) rather than relying on outer narrowing.
Behavior unchanged; billing suite 82/82 green, typecheck + lint clean.
|
||
|---|---|---|
| .. | ||
| src | ||
| eslint.config.mjs | ||
| package.json | ||
| tsconfig.json | ||