From bb0fcff83f50b1b623a693c245ff4e330d607c4a Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 8 Sep 2026 18:11:26 -0400 Subject: [PATCH 1/4] fix(credits): stop quoting minutes from a hardcoded per-minute rate 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 --- SPEC.md | 2 +- src/main/ipc/app-state.ts | 22 +++++++++++++-- src/main/services/app-state.service.ts | 1 + src/main/services/health-check.service.ts | 4 +++ src/main/types/app-state.ts | 12 ++++++++ src/main/types/health-check.ts | 7 +++++ .../components/custom/credits-display.tsx | 9 ++++-- .../custom/payment/buy-credits-tab.tsx | 11 +++++--- .../components/custom/status-panel.tsx | 10 ++++++- src/renderer/components/custom/titlebar.tsx | 1 + src/renderer/hooks/use-app-state.tsx | 8 ++++++ src/renderer/lib/consts.ts | 7 +++++ src/renderer/pages/main/index.tsx | 1 + src/renderer/pages/payment/index.tsx | 6 +++- src/renderer/types/app-state.ts | 10 +++++++ test/app-state.test.mjs | 28 +++++++++++++++++++ 16 files changed, 128 insertions(+), 11 deletions(-) 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..9a0bff2a 100644 --- a/src/main/services/health-check.service.ts +++ b/src/main/services/health-check.service.ts @@ -54,6 +54,9 @@ 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. + creditsPerMinute: res.data?.credits_per_minute, }); } catch (error) { console.error('[HealthCheckService] Initial client ping error:', error); @@ -183,6 +186,7 @@ export class HealthCheckService { providedLLMModel: res.data?.provided_llm_model, userRole: res.data?.user_role, mockPricing: res.data?.mock_pricing, + creditsPerMinute: 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/payment/buy-credits-tab.tsx b/src/renderer/components/custom/payment/buy-credits-tab.tsx index fd694e6a..58ff22bc 100644 --- a/src/renderer/components/custom/payment/buy-credits-tab.tsx +++ b/src/renderer/components/custom/payment/buy-credits-tab.tsx @@ -32,10 +32,13 @@ const planDescriptions: Record = { 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(null); const [selectedCurrency, setSelectedCurrency] = useState(''); @@ -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; @@ -120,7 +123,7 @@ export default function BuyCreditsTab({ credits, onPaymentCreated }: BuyCreditsT {availableRemMinutes} minute{availableRemMinutes !== 1 ? 's' : ''} )}{' '} - (10 credits per minute) + ({effectiveCreditsPerMinute} credits per minute) @@ -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/payment/index.tsx b/src/renderer/pages/payment/index.tsx index ec6028b2..adad9655 100644 --- a/src/renderer/pages/payment/index.tsx +++ b/src/renderer/pages/payment/index.tsx @@ -70,7 +70,11 @@ export default function PaymentPage() { {/* Content */}
- + 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; } From 86d60c6f89bfed1a7f5aac7dc952b4258c178e2c Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 8 Sep 2026 18:59:36 -0400 Subject: [PATCH 2/4] fix(credits): ignore an unusable per-minute rate from the ping 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 --- src/main/services/health-check.service.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/services/health-check.service.ts b/src/main/services/health-check.service.ts index 9a0bff2a..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(); @@ -55,8 +67,9 @@ export class HealthCheckService { // 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. - creditsPerMinute: res.data?.credits_per_minute, + // 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); @@ -186,7 +199,7 @@ export class HealthCheckService { providedLLMModel: res.data?.provided_llm_model, userRole: res.data?.user_role, mockPricing: res.data?.mock_pricing, - creditsPerMinute: res.data?.credits_per_minute, + creditsPerMinute: usableRate(res.data?.credits_per_minute), }); } failureInterval = FAILURE_INTERVAL; From 44f7e4d49e856d1b8ba81e0e6a11cb9b7856b428 Mon Sep 17 00:00:00 2001 From: alpha Date: Wed, 9 Sep 2026 09:11:54 -0400 Subject: [PATCH 3/4] Stop the mock transcript's auto-scroll from moving the control bar 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 --- .../custom/panels/mock-transcript-panel.tsx | 37 ++++++++++++++---- src/renderer/pages/mock-interview/session.tsx | 25 ++++++++++-- test/mock-session-scroll.test.mjs | 38 ++++++++++++++++++- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/custom/panels/mock-transcript-panel.tsx b/src/renderer/components/custom/panels/mock-transcript-panel.tsx index 347f6217..831d0723 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,9 +99,28 @@ 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); + /** + * 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(() => { + const el = scrollerRef.current; + if (!el) return; + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + }, []); + useEffect(() => { if (typeof config?.autoScrollTranscript === 'boolean') { setAutoScroll(config.autoScrollTranscript); @@ -140,8 +159,8 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) { useEffect(() => { if (!autoScroll) return; - endRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [turns, autoScroll]); + scrollToEnd(); + }, [turns, autoScroll, scrollToEnd]); return ( @@ -184,7 +203,12 @@ 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…

@@ -233,14 +257,13 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) { ))}
)} -
{!autoScroll && (