Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 20 additions & 2 deletions src/main/ipc/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>): Partial<AppState> {
const sanitized = { ...updates };
for (const key of SERVER_OWNED_KEYS) {
delete sanitized[key];
}
return sanitized;
}

export function registerAppStateHandlers(): void {
// Get current app state
Expand All @@ -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<AppState>) => {
appStateService.updateState(stripServerOwnedFields(updates));
return appStateService.getRendererState();
});
}
1 change: 1 addition & 0 deletions src/main/services/app-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const DEFAULT_STATE: AppState = {
credits: undefined,
userRole: undefined,
providedLLMModel: undefined,
creditsPerMinute: undefined,
interviewConfig: { fullName: '', profileData: '', context: '' },
interviewConfigLoaded: false,
onboardingCompleted: false,
Expand Down
17 changes: 17 additions & 0 deletions src/main/services/health-check.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions src/main/types/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
7 changes: 7 additions & 0 deletions src/main/types/health-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
9 changes: 7 additions & 2 deletions src/renderer/components/custom/credits-display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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);
Expand Down
72 changes: 64 additions & 8 deletions src/renderer/components/custom/panels/mock-transcript-panel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -99,8 +99,30 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) {
const { appState } = useAppState();
const { config, updateConfig } = useConfigStore();
const username = appState?.interviewConfig?.fullName || 'You';
const endRef = useRef<HTMLDivElement>(null);
const scrollerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState<boolean>(() => 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<HTMLDivElement | null>(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') {
Expand Down Expand Up @@ -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 (
<Card className="relative flex flex-col w-full h-full bg-card p-0 rounded-md gap-1">
Expand Down Expand Up @@ -184,13 +236,18 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) {

{totalQuestions > 0 && <Progress value={progressValue} className="h-1 rounded-none shrink-0" />}

<div className="flex-1 overflow-y-auto px-2 py-1">
{/* `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. */}
<div ref={scrollerRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-1">
{turns.length === 0 ? (
<div className="flex items-center justify-center h-full text-center p-4">
<p className="text-sm text-muted-foreground">Preparing your first question…</p>
</div>
) : (
<div className="divide-y divide-border/50">
<div ref={setContentEl} className="divide-y divide-border/50">
{turns.map((turn) => (
<div key={turn.key} className="flex gap-2 py-2 max-w-3xl mx-auto">
<span
Expand Down Expand Up @@ -233,14 +290,13 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) {
))}
</div>
)}
<div ref={endRef} />
</div>

{!autoScroll && (
<Button
size="icon-sm"
className="absolute bottom-3 right-3 rounded-full shadow-md bg-blue-600 text-white hover:bg-blue-600/90"
onClick={() => endRef.current?.scrollIntoView({ behavior: 'smooth' })}
onClick={() => scrollToEnd()}
aria-label="Scroll to bottom"
>
<ArrowDown className="size-4" />
Expand Down
11 changes: 7 additions & 4 deletions src/renderer/components/custom/payment/buy-credits-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ const planDescriptions: Record<CreditPlan, string> = {

interface BuyCreditsTabProps {
credits: number;
/** From the backend ping; falls back to the compiled-in mirror while that has not arrived. */
creditsPerMinute?: number;
onPaymentCreated: (paymentId: string) => void;
}

export default function BuyCreditsTab({ credits, onPaymentCreated }: BuyCreditsTabProps) {
export default function BuyCreditsTab({ credits, creditsPerMinute, onPaymentCreated }: BuyCreditsTabProps) {
const effectiveCreditsPerMinute = creditsPerMinute ?? CREDITS_PER_MINUTE;
const { plans, currencies, loading, error, createPayment } = usePayment();
const [selectedPlan, setSelectedPlan] = useState<CreditPlanInfo | null>(null);
const [selectedCurrency, setSelectedCurrency] = useState<string>('');
Expand Down Expand Up @@ -71,7 +74,7 @@ export default function BuyCreditsTab({ credits, onPaymentCreated }: BuyCreditsT
if (!open) setCurrencySearch('');
}, []);

const availableMinutes = Math.floor(credits / CREDITS_PER_MINUTE);
const availableMinutes = Math.floor(credits / effectiveCreditsPerMinute);
const availableHours = Math.floor(availableMinutes / 60);
const availableRemMinutes = availableMinutes % 60;

Expand Down Expand Up @@ -120,7 +123,7 @@ export default function BuyCreditsTab({ credits, onPaymentCreated }: BuyCreditsT
{availableRemMinutes} minute{availableRemMinutes !== 1 ? 's' : ''}
</>
)}{' '}
(10 credits per minute)
({effectiveCreditsPerMinute} credits per minute)
</div>
</div>

Expand All @@ -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 || '';

Expand Down
10 changes: 9 additions & 1 deletion src/renderer/components/custom/status-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -51,6 +52,7 @@ export default function StatusPanel({
llmModel,
credits,
userRole,
creditsPerMinute,
}: StatusPanelProps) {
// calculate and formatting handled by CreditsDisplay component
const { hintOnly } = useSuggestionMode();
Expand All @@ -61,7 +63,13 @@ export default function StatusPanel({
return (
<div id="status-panel" className="flex items-center justify-between text-muted-foreground p-1">
<RunningIndicator runningState={runningState} />
<CreditsDisplay credits={credits} llmModel={llmModel} userRole={userRole} className="ml-2" />
<CreditsDisplay
credits={credits}
llmModel={llmModel}
userRole={userRole}
creditsPerMinute={creditsPerMinute}
className="ml-2"
/>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('ml-2', badgeClass(hintOnly))}>
Expand Down
1 change: 1 addition & 0 deletions src/renderer/components/custom/titlebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export default function Titlebar() {
credits={appState.credits ?? 0}
llmModel={appState.providedLLMModel ?? ''}
userRole={appState.userRole}
creditsPerMinute={appState.creditsPerMinute}
style={DRAG}
/>
)}
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/hooks/use-app-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/lib/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
1 change: 1 addition & 0 deletions src/renderer/pages/main/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ export default function MainPage() {
credits={appState?.credits ?? 0}
llmModel={appState?.providedLLMModel ?? ''}
userRole={appState?.userRole}
creditsPerMinute={appState?.creditsPerMinute}
/>
)}

Expand Down
Loading