diff --git a/SPEC.md b/SPEC.md index 1352c198..02067d21 100644 --- a/SPEC.md +++ b/SPEC.md @@ -103,7 +103,7 @@ Full name, profile/CV, and context are stored on the user's backend account and ### Credits and Payments -Purchase and usage tracking via the payment API. Route: `/payment`. Plans and the credit balance are always served by the backend (`/api/payment/plans`, `/api/payment/credits`, plus the balance carried on every 5-second `/api/health-check/ping-client`); the client holds no local pricing, so a failed plan fetch surfaces as an error rather than falling back to stale figures. +Purchase and usage tracking via the payment API. Route: `/payment`. Plans, the credit balance and the per-minute burn rate are always served by the backend (`/api/payment/plans`, `/api/payment/credits`, plus the balance and `credits_per_minute` carried on every 5-second `/api/health-check/ping-client`); a failed plan fetch surfaces as an error rather than falling back to stale figures. `CREDITS_PER_MINUTE` in `renderer/lib/consts.ts` is a fallback mirror only, used while `AppState.creditsPerMinute` is `undefined` - before the first ping answers, or against a backend old enough not to send it - since the real rate is an env-overridable deployment setting rather than a constant. ### Auto-Updates diff --git a/src/main/ipc/app-state.ts b/src/main/ipc/app-state.ts index a515db43..ed4a36b9 100644 --- a/src/main/ipc/app-state.ts +++ b/src/main/ipc/app-state.ts @@ -5,6 +5,24 @@ import { ipcMain } from 'electron'; import { appStateService } from '../services/app-state.service.js'; +import { type AppState } from '../types/app-state.js'; + +/** + * Fields the main process derives from the backend's authenticated ping, never from anything the + * renderer sends. Without this, `app:update-state` passed a renderer-supplied `updates` object + * straight through, and a balance or role written this way would live until the next ping (up to + * `FAILURE_INTERVAL`/`SUCCESS_INTERVAL` later) silently overwrote it - an unguarded path onto a + * financial field that happened to have no caller today. + */ +const SERVER_OWNED_KEYS = ['credits', 'creditsPerMinute', 'userRole', 'mockPricing'] as const; + +function stripServerOwnedFields(updates: Partial): Partial { + const sanitized = { ...updates }; + for (const key of SERVER_OWNED_KEYS) { + delete sanitized[key]; + } + return sanitized; +} export function registerAppStateHandlers(): void { // Get current app state @@ -13,8 +31,8 @@ export function registerAppStateHandlers(): void { }); // Update app state - ipcMain.handle('app:update-state', async (_event, updates) => { - appStateService.updateState(updates); + ipcMain.handle('app:update-state', async (_event, updates: Partial) => { + appStateService.updateState(stripServerOwnedFields(updates)); return appStateService.getRendererState(); }); } diff --git a/src/main/services/app-state.service.ts b/src/main/services/app-state.service.ts index 5379175c..a0adcb4b 100644 --- a/src/main/services/app-state.service.ts +++ b/src/main/services/app-state.service.ts @@ -27,6 +27,7 @@ const DEFAULT_STATE: AppState = { credits: undefined, userRole: undefined, providedLLMModel: undefined, + creditsPerMinute: undefined, interviewConfig: { fullName: '', profileData: '', context: '' }, interviewConfigLoaded: false, onboardingCompleted: false, diff --git a/src/main/services/health-check.service.ts b/src/main/services/health-check.service.ts index 2f6a24ed..5d7e7c24 100644 --- a/src/main/services/health-check.service.ts +++ b/src/main/services/health-check.service.ts @@ -27,6 +27,18 @@ function nextFailureInterval(current: number): number { return Math.min(current * FAILURE_BACKOFF_FACTOR, MAX_FAILURE_INTERVAL); } +/** + * The per-minute rate is the divisor behind every "minutes remaining" figure the UI shows, and it + * is now a number the backend supplies rather than a compiled-in constant. A zero or a negative - + * a misconfigured `CREDITS_PER_MINUTE` env override, which is the whole reason this is served + * rather than compiled in - would divide a balance into `Infinity` or a negative duration and + * render it. Anything that is not a usable rate is reported as absent, which is a case the + * renderer already handles: it falls back to its own mirror. + */ +function usableRate(value: number | undefined): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; +} + export class HealthCheckService { private running = false; private client = new HealthCheckApi(); @@ -54,6 +66,10 @@ export class HealthCheckService { // undefined rather than defaulted: the mock setup dialog reads the absence as "this // deployment still meters a mock by the minute", and a zero would read as free. mockPricing: res.data?.mock_pricing, + // Same reasoning: undefined here means "not answered yet", not free and not the + // compiled-in default - the renderer falls back to its own mirror for that case. An + // unusable rate is folded into that same absence; see `usableRate`. + creditsPerMinute: usableRate(res.data?.credits_per_minute), }); } catch (error) { console.error('[HealthCheckService] Initial client ping error:', error); @@ -183,6 +199,7 @@ export class HealthCheckService { providedLLMModel: res.data?.provided_llm_model, userRole: res.data?.user_role, mockPricing: res.data?.mock_pricing, + creditsPerMinute: usableRate(res.data?.credits_per_minute), }); } failureInterval = FAILURE_INTERVAL; diff --git a/src/main/types/app-state.ts b/src/main/types/app-state.ts index 6f426ad8..6c1bf6d2 100644 --- a/src/main/types/app-state.ts +++ b/src/main/types/app-state.ts @@ -186,6 +186,18 @@ export interface AppState { * any of this existed. See `MockBilling` on the backend for the whole compatibility story. */ mockPricing?: MockPricing; + /** + * The price of a live interview, in credits per minute, or `undefined` before the backend has + * said. + * + * `undefined` does not mean free and does not mean the shipped default - it means this ping + * has not answered yet, or came from a backend old enough not to send it, and the renderer + * falls back to its own compiled-in mirror for exactly that case. The real value is a + * deployment setting (`CREDITS_PER_MINUTE` in the backend's config, env-overridable) rather + * than a constant, so a "minutes remaining" figure computed from a compiled-in number can + * silently disagree with what the backend actually charges. + */ + creditsPerMinute?: number; } /** The app state as sent to the renderer, with the interview config reduced to a summary. */ diff --git a/src/main/types/health-check.ts b/src/main/types/health-check.ts index def529fd..9bf66ba9 100644 --- a/src/main/types/health-check.ts +++ b/src/main/types/health-check.ts @@ -31,4 +31,11 @@ export interface ClientPingResponse { user_role: UserRole; /** Absent on a backend that predates per-turn pricing - see `AppState.mockPricing`. */ mock_pricing?: MockPricing; + /** + * The price of a live interview, in credits per minute. Optional here even though the + * backend always sends it now: this client ships independently of the hand-deployed backend, + * so an older deployment still answers without it - see `AppState.creditsPerMinute` for the + * fallback that case reads as. + */ + credits_per_minute?: number; } diff --git a/src/renderer/components/custom/credits-display.tsx b/src/renderer/components/custom/credits-display.tsx index b6900147..7d704d03 100644 --- a/src/renderer/components/custom/credits-display.tsx +++ b/src/renderer/components/custom/credits-display.tsx @@ -8,6 +8,8 @@ interface CreditsDisplayProps { credits: number; llmModel?: string; userRole?: UserRole; + /** From the backend ping; falls back to the compiled-in mirror while that has not arrived. */ + creditsPerMinute?: number; className?: string; style?: React.CSSProperties; } @@ -16,11 +18,14 @@ export default function CreditsDisplay({ credits, llmModel, userRole, + creditsPerMinute, className, style, }: CreditsDisplayProps) { - const planLabel = userRole === UserRole.TrialUser ? 'Trial Plan' : 'Pro Plan'; - const availableMinutes = Math.floor(credits / CREDITS_PER_MINUTE); + // Not a real plan name - "Pro" is an actual purchasable SKU (see CreditPlan), so labeling + // every non-trial user that way shows a starter or enterprise buyer a plan they never bought. + const planLabel = userRole === UserRole.TrialUser ? 'Trial Plan' : 'Paid Plan'; + const availableMinutes = Math.floor(credits / (creditsPerMinute ?? CREDITS_PER_MINUTE)); const formatDuration = (mins: number) => { const hours = Math.floor(mins / 60); diff --git a/src/renderer/components/custom/panels/mock-transcript-panel.tsx b/src/renderer/components/custom/panels/mock-transcript-panel.tsx index 347f6217..cebffb4a 100644 --- a/src/renderer/components/custom/panels/mock-transcript-panel.tsx +++ b/src/renderer/components/custom/panels/mock-transcript-panel.tsx @@ -1,5 +1,5 @@ import { ArrowDown } from 'lucide-react'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { StreamingQuestion } from '@/components/custom/panels/streaming-question'; import { Badge } from '@/components/ui/badge'; @@ -99,8 +99,30 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) { const { appState } = useAppState(); const { config, updateConfig } = useConfigStore(); const username = appState?.interviewConfig?.fullName || 'You'; - const endRef = useRef(null); + const scrollerRef = useRef(null); const [autoScroll, setAutoScroll] = useState(() => config?.autoScrollTranscript ?? true); + // A callback ref rather than a `useRef`, because the observer below has to be re-attached when + // this element is swapped out - the empty state renders in its place until the first question. + const [contentEl, setContentEl] = useState(null); + + /** + * Scrolls this panel's own scroller, and nothing else. + * + * It used to be `endRef.scrollIntoView({ behavior: 'smooth' })` on a sentinel at the end of the + * list, which reaches upward by definition: it brings the element into view inside *every* + * scrollable ancestor, and Chromium counts a box with `overflow: hidden` as one. The session + * screen's panel-and-status column is exactly that, and while a question was being spoken with + * the transcript already longer than the panel it had something to scroll - so each smooth + * scroll here dragged the column too, taking the status line and the control bar under it up + * with it before they settled back. `session.tsx` closes the other half of that by clamping + * the wrappers so the column has nothing to scroll; scrolling this element by name means the + * panel could not move anything above it even if it did. + */ + const scrollToEnd = useCallback((behavior: ScrollBehavior = 'smooth') => { + const el = scrollerRef.current; + if (!el) return; + el.scrollTo({ top: el.scrollHeight, behavior }); + }, []); useEffect(() => { if (typeof config?.autoScrollTranscript === 'boolean') { @@ -138,10 +160,40 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) { const progressValue = totalQuestions > 0 ? (questionNumber / totalQuestions) * 100 : 0; const isRunning = isMockInterviewSessionActive(session); + /** + * Follows the transcript whenever the content itself grows, not only when `turns` changes. + * + * `turns` is a list of finished strings, so watching it alone misses the reveal entirely: the + * question arrives whole and `StreamingQuestion` writes it out in the DOM, so for the whole + * time the voice is speaking - which is most of what there is to follow - the effect below + * never fires and the panel sat still, catching up only on the next state change. That is the + * "auto-scroll only works once the voice ends" of it. The same gap covers anything else that + * changes height on its own: a reflow when the dock is resized, or a font finishing loading. + * + * A ResizeObserver on the list is the general form - it fires on the content box actually + * getting taller, whatever made it taller - and only at the moments that matter, since + * revealing a word mid-line does not change the height at all. + * + * Instant rather than smooth, and this is the path essentially every auto-scroll now takes + * (a new turn grows the list too, so the observer fires there as well and its scroll lands on + * top of the effect below). Growth arrives a line at a time, and re-targeting a running smooth + * animation on every wrap leaves the panel permanently trailing the text it is meant to be + * showing. Smooth is kept for the button, where the reader asked for one long jump. + */ + useEffect(() => { + if (!autoScroll || !contentEl || typeof ResizeObserver === 'undefined') return; + + const observer = new ResizeObserver(() => scrollToEnd('auto')); + observer.observe(contentEl); + return () => observer.disconnect(); + }, [autoScroll, contentEl, scrollToEnd]); + + // Covers what the observer cannot see: a turn swapped for one of the same height, and the first + // render after the list appears, before the observer above is attached to it. useEffect(() => { if (!autoScroll) return; - endRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [turns, autoScroll]); + scrollToEnd('auto'); + }, [turns, autoScroll, scrollToEnd]); return ( @@ -184,13 +236,18 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) { {totalQuestions > 0 && } -
+ {/* `overflow-x-hidden` is not decoration: `overflow-y-auto` on its own leaves the other + axis computing to `auto` rather than staying visible, so the vertical scrollbar + appearing on a long transcript narrowed the content by ten pixels and could bring a + horizontal one in behind it - which shortens the content box again. Turn text wraps + (`wrap-break-word`), so there is nothing here that horizontal scrolling would reach. */} +
{turns.length === 0 ? (

Preparing your first question…

) : ( -
+
{turns.map((turn) => (
)} -
{!autoScroll && (
@@ -140,7 +143,7 @@ export default function BuyCreditsTab({ credits, onPaymentCreated }: BuyCreditsT {plans.map((plan) => { const isPro = plan.plan === CreditPlan.Pro; const isSelected = selectedPlan?.plan === plan.plan; - const minutes = Math.floor(plan.credits / CREDITS_PER_MINUTE); + const minutes = Math.floor(plan.credits / effectiveCreditsPerMinute); const planName = planNames[plan.plan] || plan.plan; const planDescription = planDescriptions[plan.plan] || plan.description || ''; diff --git a/src/renderer/components/custom/status-panel.tsx b/src/renderer/components/custom/status-panel.tsx index 2c08a1a1..63423072 100644 --- a/src/renderer/components/custom/status-panel.tsx +++ b/src/renderer/components/custom/status-panel.tsx @@ -36,6 +36,7 @@ interface StatusPanelProps { credits: number; llmModel: string; userRole?: UserRole; + creditsPerMinute?: number; } // Filled background rather than a text-color tint: a tint against this panel's muted-foreground @@ -51,6 +52,7 @@ export default function StatusPanel({ llmModel, credits, userRole, + creditsPerMinute, }: StatusPanelProps) { // calculate and formatting handled by CreditsDisplay component const { hintOnly } = useSuggestionMode(); @@ -61,7 +63,13 @@ export default function StatusPanel({ return (
- +
diff --git a/src/renderer/components/custom/titlebar.tsx b/src/renderer/components/custom/titlebar.tsx index e12d6bac..11c2ba2e 100644 --- a/src/renderer/components/custom/titlebar.tsx +++ b/src/renderer/components/custom/titlebar.tsx @@ -80,6 +80,7 @@ export default function Titlebar() { credits={appState.credits ?? 0} llmModel={appState.providedLLMModel ?? ''} userRole={appState.userRole} + creditsPerMinute={appState.creditsPerMinute} style={DRAG} /> )} diff --git a/src/renderer/hooks/use-app-state.tsx b/src/renderer/hooks/use-app-state.tsx index 9a30c3c0..9b919800 100644 --- a/src/renderer/hooks/use-app-state.tsx +++ b/src/renderer/hooks/use-app-state.tsx @@ -41,6 +41,14 @@ class AppStateManager { credits: raw.credits, userRole: raw.userRole, providedLLMModel: raw.providedLLMModel, + // Was missing from this object entirely - every write to `AppState.mockPricing` from main + // was silently dropped here, so the mock-interview affordability gate this field exists to + // drive never actually engaged: `pricing` in the setup form and `appState?.mockPricing` on + // the home card were always undefined, which both read the same way "this backend predates + // per-turn pricing" does - quote nothing, gate nothing - even against a backend that does + // send it. + mockPricing: raw.mockPricing, + creditsPerMinute: raw.creditsPerMinute, interviewConfig: raw.interviewConfig ?? { fullName: '', hasProfileData: false }, interviewConfigLoaded: raw.interviewConfigLoaded ?? false, // Defaults false, which is the safe direction: a main process that does not send it offers diff --git a/src/renderer/lib/consts.ts b/src/renderer/lib/consts.ts index 539629e5..4204c5de 100644 --- a/src/renderer/lib/consts.ts +++ b/src/renderer/lib/consts.ts @@ -2,6 +2,13 @@ export const APP_NAME = 'Power Interview AI'; export const isMac = navigator.platform.toUpperCase().includes('MAC'); +/** + * Fallback only, for while `AppState.creditsPerMinute` is `undefined` - before the first ping + * answers, or against a backend old enough not to send it. The real price is a deployment + * setting on the backend (`CREDITS_PER_MINUTE` in `app/cfg/payment.py`, env-overridable), not a + * constant, so this mirror can silently disagree with what is actually charged and must never be + * read as authoritative once the real value is known. + */ export const CREDITS_PER_MINUTE = 10; /** diff --git a/src/renderer/pages/main/index.tsx b/src/renderer/pages/main/index.tsx index cf3eed13..81aa038a 100644 --- a/src/renderer/pages/main/index.tsx +++ b/src/renderer/pages/main/index.tsx @@ -375,6 +375,7 @@ export default function MainPage() { credits={appState?.credits ?? 0} llmModel={appState?.providedLLMModel ?? ''} userRole={appState?.userRole} + creditsPerMinute={appState?.creditsPerMinute} /> )} diff --git a/src/renderer/pages/mock-interview/session.tsx b/src/renderer/pages/mock-interview/session.tsx index e0b5c38a..ddffbbda 100644 --- a/src/renderer/pages/mock-interview/session.tsx +++ b/src/renderer/pages/mock-interview/session.tsx @@ -137,13 +137,32 @@ export function SessionScreen({ session, onDone, onEnd, onAnswerReady }: Session Mock interview session -
+ {/* `overflow-hidden`, not `overflow-y-hidden`. Setting one axis to hidden and leaving the + other `visible` is not a thing CSS can express: the visible axis computes to `auto` + instead, so this column carried a live `overflow-x: auto` and would grow a horizontal + scrollbar - ten pixels tall, immediately above the control bar - the moment anything + inside it overran the width. Both axes clip here; neither of them is meant to scroll. */} +
+ {/* `min-h-0` on both panel wrappers for the same reason the two columns above carry it, + and it is the last link in that chain rather than a repetition of it: these two set + no `overflow` of their own, so their minimum height is their content's - and their + content is a panel whose `h-full` reads as `auto` while that minimum is being + computed, which makes it the whole transcript. Once the transcript is longer than the + row, the wrapper's floor is taller than the row, and the panel overflows the column + above by however much the transcript overruns. + + Nothing of that is visible - the column clips it - but clipping is what makes the + column scrollable, and `scrollIntoView` inside the panel then scrolled *it* as well + as the transcript, dragging the status line and the control bar up and letting them + settle back. The panel now scrolls only itself (see mock-transcript-panel.tsx); this + is the other half, so the column has no scroll range to be dragged by in the first + place. */}
-
+
{showHintsPanel && ( -
+
- + diff --git a/src/renderer/types/app-state.ts b/src/renderer/types/app-state.ts index 8c6f69d9..f341ae6a 100644 --- a/src/renderer/types/app-state.ts +++ b/src/renderer/types/app-state.ts @@ -94,6 +94,16 @@ export interface AppState { * answers, and only one of them is a price. */ mockPricing?: MockPricing; + /** + * The price of a live interview, in credits per minute, or `undefined` before the backend has + * said (or before main answers it at all). + * + * `CREDITS_PER_MINUTE` in `lib/consts.ts` is a fallback mirror of this, used only while this + * is `undefined` - the real value is a deployment setting, not a constant, so a "minutes + * remaining" figure computed from the mirror can silently disagree with what the backend + * actually charges. + */ + creditsPerMinute?: number; } /** diff --git a/test/app-state.test.mjs b/test/app-state.test.mjs index 63452227..f80130ac 100644 --- a/test/app-state.test.mjs +++ b/test/app-state.test.mjs @@ -155,5 +155,33 @@ export async function run() { appStateService.updateState({ mockInterview: null }); + // creditsPerMinute is a deployment setting relayed from the ping, not a constant - it must + // start unset (the fallback lives in the renderer's compiled-in mirror, not here), reach the + // renderer once the backend answers, and behave like every other ping-relayed field under the + // no-op broadcast dedup above. + check( + 'creditsPerMinute starts undefined - an old backend that never sends it must not look priced', + appStateService.getState().creditsPerMinute === undefined + ); + + appStateService.updateState({ creditsPerMinute: 12 }); + check( + 'creditsPerMinute is applied', + appStateService.getState().creditsPerMinute === 12 + ); + check( + 'creditsPerMinute reaches the renderer view', + appStateService.getRendererState().creditsPerMinute === 12 + ); + + appStateService.flushRenderer(); + const beforeRepeatedPing = sent.length; + appStateService.updateState({ creditsPerMinute: 12 }); + appStateService.flushRenderer(); + check( + 'an unchanged creditsPerMinute does not rebroadcast', + sent.length === beforeRepeatedPing + ); + return failures; } diff --git a/test/mock-session-scroll.test.mjs b/test/mock-session-scroll.test.mjs index 1a848684..c5cb078f 100644 --- a/test/mock-session-scroll.test.mjs +++ b/test/mock-session-scroll.test.mjs @@ -22,9 +22,45 @@ export async function run() { const outerDiv = session.match(/
/)?.[0] ?? ''; check('the route root clamps its height rather than growing to fit its content', outerDiv.includes('min-h-0')); - const secondDiv = session.match(/
/)?.[0] ?? ''; + const secondDiv = session.match(/
/)?.[0] ?? ''; check('the panel-and-status column does too', secondDiv.includes('min-h-0')); + // And the two wrappers inside it, which are the last link in that chain: they set no + // `overflow` either, so their floor is their content's min-content height - and the panel they + // hold is `h-full`, which reads as `auto` while that floor is being computed, making the floor + // the whole transcript. Past the point where the transcript is longer than the row, each + // wrapper was taller than the row that holds it and the panel overflowed the column above. + const wrappers = session.match(/
/g) ?? []; + check( + 'both panel wrappers clamp their height too', + wrappers.length === 2 && wrappers.every((w) => w.includes('min-h-0')) + ); + + // Hidden is not the same as not scrollable: Chromium scrolls an `overflow: hidden` box + // programmatically, and `scrollIntoView` brings its target into view inside *every* scrollable + // ancestor rather than only the nearest one. With the column above having anything to scroll, + // the transcript's own auto-scroll dragged it - and the status line and control bar under it - + // on every question while the interviewer was speaking. Scrolling the panel's own scroller + // reaches nothing outside the panel. + const panel = codeOnly( + readSource( + new URL('../src/renderer/components/custom/panels/mock-transcript-panel.tsx', import.meta.url) + ) + ); + check( + 'the mock transcript scrolls its own container rather than reaching up through ancestors', + !panel.includes('scrollIntoView') && /scrollerRef\.current/.test(panel) + ); + + // One axis hidden and the other left `visible` is not expressible: the visible axis computes to + // `auto`. Both of these are meant to clip rather than scroll sideways, and a horizontal + // scrollbar in either is ten pixels of height taken out of the bottom of the screen. + check('the panel-and-status column clips both axes', !/overflow-y-hidden/.test(session)); + check( + 'the transcript scroller pins its horizontal axis', + panel.includes('overflow-y-auto overflow-x-hidden') + ); + // The Idle fallback on mock-interview/index.tsx redirects now that setup lives on the home // screen - where the old full-page setup screen used to render harmlessly for one frame, a // `` there actually fires. React mounts a child's own effect before the parent's