fix(credits): quote minutes from the backend's rate, not a compiled-in copy - #126
Merged
Conversation
CREDITS_PER_MINUTE is an env-overridable backend setting, not a constant, and SPEC.md already claimed the client held no local pricing - true of plans and mock pricing, false of this one. The backend's ping now carries credits_per_minute; AppState.creditsPerMinute follows it through, with the old constant kept only as a fallback for before the first ping answers or against a backend old enough not to send it. Also fixes a real bug found while wiring this through: use-app-state's normalize() never copied mockPricing from main's state at all, so AppState.mockPricing was always undefined in the renderer regardless of what the backend sent - the mock-interview affordability gate this field exists to drive never actually engaged. Smaller things alongside it: - CreditsDisplay labeled every non-trial user 'Pro Plan', a real purchasable SKU a starter/enterprise buyer never bought - relabeled to 'Paid Plan' - the renderer->main app:update-state IPC handler applied a renderer's updates unfiltered, so credits/creditsPerMinute/userRole/mockPricing were writable from the renderer; now stripped at the boundary Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-minute rate became a number the backend supplies rather than a compiled-in constant, and it is the divisor behind every "minutes remaining" figure the UI shows. A zero or negative `CREDITS_PER_MINUTE` - possible precisely because it is an env-overridable deployment setting, which is why it is served at all - would render a balance as `Infinity` minutes or a negative duration. Sanitised once at the ping boundary rather than at each consumer, so anything that is not a usable rate arrives as `undefined`: a case the renderer already handles by falling back to its own mirror. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
While the interviewer was speaking and the transcript was already longer than its panel, the bottom of the session screen grew and settled back on every question. Two things together: The panel wrappers inside the panel-and-status column set no `overflow` and no `min-h-0`, so their minimum height was their content's, and the panel they hold is `h-full`, which reads as `auto` while that minimum is computed. Past the point where the transcript outgrew the row, each wrapper's floor was taller than the row and the panel overflowed the column. The column clips, so none of it showed, but clipping is what gave the column a scroll range. `scrollIntoView` then reached it. It brings its target into view inside every scrollable ancestor, not just the nearest, and Chromium scrolls an `overflow: hidden` box programmatically, so the transcript's own auto-scroll dragged the column along with it, status line and control bar included. The panel now scrolls its own scroller by name and cannot move anything above it. The column also asked for `overflow-y-hidden`, which leaves the other axis computing to `auto` rather than staying visible: a row that overran the width would put a ten-pixel horizontal scrollbar directly above the control bar. Both axes clip now, and the transcript scroller pins its horizontal axis for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auto-scroll was keyed on the turn list, which is a list of finished strings. The question arrives whole and StreamingQuestion writes it out in the DOM, so nothing the effect watched changed for the entire reveal: the panel sat still while the interviewer spoke and only caught up on the next state change, which read as auto-scroll working only once the voice ended. Observe the list's box with a ResizeObserver instead, so the panel follows the content whenever it actually gets taller - the reveal, an ASR partial wrapping to a new line, a reflow on a dock resize. Those arrive a line at a time, so auto-follow scrolls instantly; the scroll-to-bottom button keeps the smooth scroll, where the reader asked for one long jump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Consumes the
credits_per_minutehalf of PowerInterviewAI/backend#63. Paired with alpha5611331/pia-admin#7 (the admin dashboard has no repo in this org).What changed
The per-minute rate is no longer a compiled-in constant.
CREDITS_PER_MINUTEis an env-overridable backend deployment setting, but the client computed every "minutes remaining" figure from a hardcoded copy of the shipped default - so the number quoted to the user could silently disagree with what they were actually charged. SPEC.md already claimed the client held no local pricing: true of plans and mock pricing, false of this one. The backend's ping now carriescredits_per_minute,AppState.creditsPerMinutefollows it through toCreditsDisplay,BuyCreditsTaband the status panel, and the old constant is kept only as a fallback for before the first ping answers or against a backend old enough not to send it. (undefinedthere means "not answered yet" - never free, and never the shipped default.)A real bug found while wiring that through:
use-app-state'snormalize()never copiedmockPricingfrom main's state at all, soAppState.mockPricingwas alwaysundefinedin the renderer regardless of what the backend sent. The mock-interview affordability gate this field exists to drive therefore never engaged - the setup form'spricingand the home card'sappState?.mockPricingboth read as "this backend predates per-turn pricing", which quotes nothing and gates nothing, even against a backend that does send it.An unusable rate is rejected at the boundary. A zero or negative
CREDITS_PER_MINUTE- possible precisely because it is an env override, which is the whole reason it is served rather than compiled in - would divide a balance intoInfinityminutes or a negative duration and render it. Sanitised once in the ping handler rather than at each consumer, so anything that is not a usable rate arrives asundefined: a case the renderer already handles by falling back to its own mirror.Two smaller things alongside:
CreditsDisplaylabeled every non-trial user "Pro Plan" - a real purchasable SKU that a starter or enterprise buyer never bought. Relabeled to "Paid Plan".app:update-stateIPC handler applied a renderer'supdatesobject unfiltered, socredits,creditsPerMinute,userRoleandmockPricingwere all writable from the renderer. A value written that way would stand until the next ping overwrote it, up to a failure-backoff interval later. Now stripped at the boundary; these four are derived from the authenticated ping only. No renderer code writes them today, so this closes an unguarded path onto a financial field rather than changing any behaviour.Review notes
The restored
mockPricingwiring switches on a gate that was previously always inert, which is a real behaviour change for users. Its formula (per_question * count + per_report) matches backend'ssession_price()exactly, so it cannot refuse a session the backend would then have allowed - it mirrors the backend's own upfront refusal ingenerate_questionrather than adding a stricter one.Verification
🤖 Generated with Claude Code