From 009f51586b46a9c9f176ea66295fc91c32d51dea Mon Sep 17 00:00:00 2001 From: tpikachu Date: Wed, 1 Jul 2026 12:25:04 -0500 Subject: [PATCH 1/2] feat: per-question regenerate, coding re-solve, story-teller + persona (v1.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cue Card answer-UX pass driven by live testing: - First-person persona: the system prompt now leads with "You ARE the candidate — a second version of them — answering ON THEIR BEHALF, in first person." Story-teller reinforces "you are ME telling MY OWN story." - Story-teller: a 4th AnswerFormat (story_teller, cap 420) — a short, vivid first-person narrative. Type + prompt + zod + a 4th Cue Card toggle. - Per-question regenerate: the single toolbar ↻ is gone; each answer card has its own. The Cue Card now routes answer events by questionId (AnswerCard gains questionId + isCoding; patchById/appendById replace patchLast; a streamingId ref flushes tokens to the right card), so ANY card — even an older collapsed one (which auto-expands) — can be regenerated. Backend regenerateActive() → regenerate(questionId?); IPC + preload take questionId?. - Coding re-solve: the ↻ shows on every card; regenerateCard falls back to capture:resolve-last (codingMode.resolveLast re-runs the last solve, current language) when a card isn't a persisted question. Fixes a bug where a live 'coding'-classified question wrongly hid its ↻. Fixes 2 adversarial-review findings: an aborted card no longer keeps a blinking cursor (abort branch broadcasts answerDone), and the header/"Data sent" panels follow the streaming card. Verified: typecheck · 118 unit · build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/05-IPC-MAP.md | 3 +- docs/06-OPENAI-SERVICE.md | 7 +- docs/sessions/2026-07-01.md | 33 +++++++- src/main/ipc/capture.ipc.ts | 7 ++ src/main/ipc/session.ipc.ts | 6 +- src/main/services/capture/codingMode.ts | 28 +++++++ src/main/services/openai/answer.test.ts | 6 ++ src/main/services/openai/answer.ts | 13 ++- src/main/services/session/sessionManager.ts | 46 ++++++++--- src/preload/index.ts | 4 +- src/renderer/overlay/Overlay.tsx | 89 +++++++++++++++------ src/renderer/overlay/answerCards.test.ts | 43 ++++++++-- src/renderer/overlay/answerCards.ts | 41 +++++++++- src/shared/ipc.ts | 1 + src/shared/types.ts | 8 +- 15 files changed, 277 insertions(+), 58 deletions(-) diff --git a/docs/05-IPC-MAP.md b/docs/05-IPC-MAP.md index b6f181a..6f5114f 100644 --- a/docs/05-IPC-MAP.md +++ b/docs/05-IPC-MAP.md @@ -122,7 +122,7 @@ résumé, persisted, and indexed as `story` chunks so they ground live answers. | `session:set-interview-type` | `{ sessionId, interviewType }` | `{ ok }` (set the session-level type — chosen by the user in the save prompt at stop) | | `session:set-answer-prefs` | `{ interviewType?, format?, pronunciation? }` | `{ interviewType, format, pronunciation }` (live Cue Card controls; acts on the active session. Switching `interviewType` is dynamic — it persists onto the session row + reframes later answers) | | `session:set-answering` | `{ enabled }` | `{ enabled, answered }` (coding "listen-only" toggle: when disabled, the interviewer is still transcribed but not auto-answered; enabling it also answers the question they just asked) | -| `session:regenerate` | — | `{ regenerated }` (re-answer the last question for the active session) | +| `session:regenerate` | `{ questionId? }` | `{ regenerated }` (re-answer a SPECIFIC question by id — the Cue Card's per-card ↻ — or, with no id, the last question after a format/pronunciation toggle) | | `session:clear-answer` | — | `{ cleared }` (abort the in-flight answer for the active session) | ### mock (AI-driven mock interviewer) @@ -161,6 +161,7 @@ persisted; no DB session, no Cue Card). | `capture:add-region` | `{ image }` | `{ added: true }` (add a captured region to the multi-image buffer; broadcasts `capture:buffer`) | | `capture:solve-buffer` | — | `{ started: true }` (solve ALL buffered screenshots in one vision call, then clear) | | `capture:clear-buffer` | — | `{ cleared: true }` | +| `capture:resolve-last` | — | `{ started: true }` (re-solve the most recent coding problem — the per-card ↻ on a coding-solve card; picks up the current language) | ### overlay / privacy | Channel | Request | Response | diff --git a/docs/06-OPENAI-SERVICE.md b/docs/06-OPENAI-SERVICE.md index a8df879..24894f9 100644 --- a/docs/06-OPENAI-SERVICE.md +++ b/docs/06-OPENAI-SERVICE.md @@ -116,9 +116,12 @@ Builds a **grounding** prompt: (`overlay/pronunciation.ts` `splitPronunciation`, tolerant of model-output variance) and renders a structured "🗣 How to say it" panel below the answer. Adds +160 `max_output_tokens` headroom so the guide never eats the answer. +- **Persona:** the system prompt frames the model as the candidate themselves — "You ARE the + candidate … answering ON THEIR BEHALF, in first person" — never third-person. - `format` — the single answer control (v1.2): `key_points` (terse bullets) | `explanation` - (a natural, flowing first-person explanation) | `detailed` (thorough, with one example). - It also sets a hard `max_output_tokens` ceiling (220 / 340 / 800) so "key points" can never + (a natural, flowing first-person explanation) | `detailed` (thorough, with one example) | + `story_teller` (a short, vivid first-person story — "you are ME telling MY OWN story"). + It also sets a hard `max_output_tokens` ceiling (220 / 340 / 800 / 420) so "key points" can never drift long regardless of the prompt. (The old format/tone × length split — `star`/`technical`/ `conversational` — was removed.) Streams tokens (`{type:'delta', token}`), then a `usage` event, then a structured diff --git a/docs/sessions/2026-07-01.md b/docs/sessions/2026-07-01.md index 6deeafe..04e21ce 100644 --- a/docs/sessions/2026-07-01.md +++ b/docs/sessions/2026-07-01.md @@ -87,6 +87,35 @@ each finding. Verified: `typecheck` · 113 unit (+9 across v1.2 #3) · `build` green. +## Cue Card answer UX (follow-ups, same branch) + +Driven by live testing feedback: + +- **First-person persona.** The system prompt now leads with identity: *"You ARE the candidate — a + second version of them — answering ON THEIR BEHALF, in first person."* Applies to every format. +- **Story-teller format.** A 4th `AnswerFormat` (`story_teller`, cap 420 tok): a short, vivid + first-person narrative ("you are ME telling MY OWN story"). Type + prompt + `session.ipc` zod + + a 4th Cue Card toggle. +- **Per-question regenerate.** The single toolbar ↻ was removed; **each answer card has its own ↻**. + To let *any* card (not just the last) be regenerated, the Cue Card now routes all answer events + by `questionId`: `AnswerCard` gained `questionId` (from `questionDetected.id`) + `isCoding`; new + `patchById`/`appendById` reducers replace `patchLast` in the delta/meta/done/reset/context handlers; + a `streamingId` ref flushes buffered tokens to the right card. Backend `regenerateActive()` → + `regenerate(questionId?)` (specific question by id, or last); `session:regenerate` + preload take + `questionId?`. Regenerating a collapsed history card auto-expands it. +- **Coding re-solve.** The ↻ shows on **every** card. `regenerateCard` tries `session.regenerate(qid)`; + a coding-solve card (not a persisted question) returns `{regenerated:false}` → falls back to + `capture:resolve-last`, which re-runs the last solve (`codingMode.resolveLast`, picking up the + current language). Also fixed a bug where a *live* question the classifier labeled "coding" had its + ↻ wrongly hidden. + +**Adversarial review** of the per-question-regenerate refactor found + fixed **2 real bugs**: an +aborted-but-still-visible card kept a blinking streaming cursor forever (the abort branch now +broadcasts `answerDone`), and the header/"Data sent" panels only reflected the last card (now derive +from the streaming card). The coding-re-solve follow-up was reviewed **inline** (workflow agents were +rate-limited). Verified: typecheck · 118 unit · build green. + ## v1.2 status -All three increments done on `feat/prompt-overhaul` (Answer Format + naturalness · Coding solver · -Pronunciation). Ready for one PR. Version/changelog bump deferred until the user asks (would be v1.2.0). +On `feat/prompt-overhaul`: Answer Format + naturalness · Coding solver · Pronunciation · story-teller +format · first-person persona · per-question regenerate · coding re-solve. Ready for one PR. +Version/changelog bump deferred until asked (would be v1.2.0). diff --git a/src/main/ipc/capture.ipc.ts b/src/main/ipc/capture.ipc.ts index f3f3074..20c6cd6 100644 --- a/src/main/ipc/capture.ipc.ts +++ b/src/main/ipc/capture.ipc.ts @@ -6,6 +6,7 @@ import { addCapture, clearCaptures, quickSolveFromClipboard, + resolveLast, runCodingSolve, runCodingSolveFromImage, solveCaptures, @@ -67,4 +68,10 @@ export function registerCaptureIpc(): void { clearCaptures(); return { cleared: true as const }; }); + + // Re-solve the most recent coding problem (the Cue Card's per-card ↻ on a coding card). + handle(IPC.capture.resolveLast, NoInput, () => { + void resolveLast(); + return { started: true as const }; + }); } diff --git a/src/main/ipc/session.ipc.ts b/src/main/ipc/session.ipc.ts index 335920a..7f9cd68 100644 --- a/src/main/ipc/session.ipc.ts +++ b/src/main/ipc/session.ipc.ts @@ -15,7 +15,7 @@ const interviewType = z.enum([ 'sales', 'general', ]); -const answerFormat = z.enum(['key_points', 'explanation', 'detailed']); +const answerFormat = z.enum(['key_points', 'explanation', 'detailed', 'story_teller']); export function registerSessionIpc(): void { handle( @@ -113,7 +113,9 @@ export function registerSessionIpc(): void { ({ enabled }) => sessionManager.setAnsweringActive(enabled), ); - handle(IPC.session.regenerate, z.void(), () => sessionManager.regenerateActive()); + handle(IPC.session.regenerate, z.object({ questionId: z.string().optional() }), ({ questionId }) => + sessionManager.regenerate(questionId), + ); handle(IPC.session.clearAnswer, z.void(), () => sessionManager.clearAnswerActive()); diff --git a/src/main/services/capture/codingMode.ts b/src/main/services/capture/codingMode.ts index 63c8e1f..bb5f230 100644 --- a/src/main/services/capture/codingMode.ts +++ b/src/main/services/capture/codingMode.ts @@ -18,6 +18,9 @@ const codingLanguage = (): string => // request. const MAX_CAPTURES = 8; let captureBuffer: string[] = []; +// The most recent solve's input, so the Cue Card's per-card ↻ can re-solve the SAME +// problem (e.g. after switching language) without re-copying or re-capturing it. +let lastSolve: { text: string } | { images: string[] } | null = null; function broadcastBuffer(): void { broadcast(EVENTS.captureBuffer, { images: captureBuffer }, ['overlay']); @@ -40,6 +43,7 @@ export function clearCaptures(): void { export function solveCaptures(): Promise { if (captureBuffer.length === 0) return Promise.resolve(); const images = captureBuffer; + lastSolve = { images }; captureBuffer = []; broadcastBuffer(); const label = @@ -70,17 +74,41 @@ async function streamToOverlay(gen: AsyncGenerator, label: string): /** Stream a coding solution from plain text (clipboard). */ export function runCodingSolve(text: string): Promise { + lastSolve = { text }; return streamToOverlay(solveFromOcr(text, codingLanguage()), 'Coding problem (from clipboard)'); } /** Stream a coding solution from a single screenshot/region image (OpenAI vision). */ export function runCodingSolveFromImage(dataUrl: string): Promise { + lastSolve = { images: [dataUrl] }; return streamToOverlay( solveFromImages([dataUrl], codingLanguage()), 'Coding problem (from screenshot)', ); } +/** Re-run the most recent coding solve (same problem) — picks up the current + * language/model/effort, so the user can iterate via the Cue Card's per-card ↻. */ +export function resolveLast(): Promise { + if (!lastSolve) { + const questionId = crypto.randomUUID(); + showOverlay(); + broadcast(EVENTS.questionDetected, { id: questionId, text: 'Re-solve', type: 'coding' }, [ + 'overlay', + ]); + broadcast(EVENTS.sessionError, { message: 'Nothing to re-solve yet — solve a problem first.' }, [ + 'overlay', + ]); + broadcast(EVENTS.answerDone, { questionId }, ['overlay']); + return Promise.resolve(); + } + const gen = + 'text' in lastSolve + ? solveFromOcr(lastSolve.text, codingLanguage()) + : solveFromImages(lastSolve.images, codingLanguage()); + return streamToOverlay(gen, 'Coding problem (re-solve)'); +} + /** * Quick coding help from the clipboard: the user copies the problem text and * presses the hotkey; we answer from that text. Reliable, no OCR. diff --git a/src/main/services/openai/answer.test.ts b/src/main/services/openai/answer.test.ts index 95419cc..3bfc1aa 100644 --- a/src/main/services/openai/answer.test.ts +++ b/src/main/services/openai/answer.test.ts @@ -72,6 +72,12 @@ describe('streamAnswer — request body', () => { expect(userPrompt()).toContain('DETAILED'); }); + it('caps story_teller at 420 output tokens', async () => { + await collect(streamAnswer(baseInput({ format: 'story_teller' }))); + expect(h.lastBody!.max_output_tokens).toBe(420); + expect(userPrompt()).toContain('STORY TELLER'); + }); + it('includes the structured pronunciation-guide instruction only when enabled', async () => { await collect(streamAnswer(baseInput({ pronunciation: true }))); expect(userPrompt()).toMatch(/phonetic respelling/i); diff --git a/src/main/services/openai/answer.ts b/src/main/services/openai/answer.ts index 78dd530..ecc0c2b 100644 --- a/src/main/services/openai/answer.ts +++ b/src/main/services/openai/answer.ts @@ -27,6 +27,11 @@ const FORMAT_INSTRUCTION: Record = { detailed: 'FORMAT = DETAILED. A thorough, well-structured spoken answer (~150–220 words) with specifics ' + 'and one concrete example drawn from the context. Natural spoken language, not an essay.', + story_teller: + 'FORMAT = STORY TELLER. You are ME telling MY OWN story on my behalf. Tell it as a short, vivid ' + + 'first-person STORY (~110–150 words): a quick hook, the challenge/stakes, what I actually did, and ' + + 'how it turned out (with a real result from the context). Flowing narrative, not bullets — ' + + 'memorable and natural, the way I would tell it in the room. One story, tightly told.', }; /** Hard output ceiling per format — the model literally cannot exceed this, so @@ -35,6 +40,7 @@ const FORMAT_MAX_TOKENS: Record = { key_points: 220, explanation: 340, detailed: 800, + story_teller: 420, }; export type AnswerEvent = @@ -50,9 +56,10 @@ export type AnswerEvent = } | { type: 'usage'; prompt: number; completion: number }; -const SYSTEM = `You are a live interview copilot. The candidate reads your output WHILE -speaking in a real interview, so it must be instantly skimmable and spoken in their -first-person voice ("I led…", not "The candidate led…"). +const SYSTEM = `You ARE the candidate — a second version of them — answering the interview ON +THEIR BEHALF, in first person, as if they are speaking. Never say "the candidate" or "they"; +you are them ("I led…", not "The candidate led…"). They read your output WHILE speaking in a +real interview, so it must be instantly skimmable. Rules: - FORMAT is a HARD constraint. Obey the requested format EXACTLY — even if you have more to say. When unsure, be shorter. Never pad. (KEY POINTS especially must stay tiny.) diff --git a/src/main/services/session/sessionManager.ts b/src/main/services/session/sessionManager.ts index 3d29c3d..ccc37e0 100644 --- a/src/main/services/session/sessionManager.ts +++ b/src/main/services/session/sessionManager.ts @@ -469,8 +469,14 @@ export const sessionManager = { } } } catch (e) { - // Aborted by clear/regenerate — drop this partial answer silently. - if (abort.signal.aborted) return { questionId }; + // Aborted by clear/regenerate — drop this partial answer, but still tell the Cue + // Card this question is done so its card stops showing the streaming cursor. (With + // per-card regenerate + history, the aborted card may be a DIFFERENT, still-visible + // one than the card being regenerated.) + if (abort.signal.aborted) { + broadcast(EVENTS.answerDone, { questionId }); + return { questionId }; + } // A real failure (auth, quota, network drop, model-not-found): surface it and // clear the Cue Card's streaming state, instead of leaving the card spinning // forever with no error (the most common live failure — e.g. an expired key). @@ -546,18 +552,36 @@ export const sessionManager = { }; }, - /** Re-answer the last question for the active session (e.g. after toggling - * length/format/pronunciation, or via the Cue Card "Regenerate" button). - * Reuses the SAME question row — no new transcript line or DB question. */ - async regenerateActive(): Promise<{ regenerated: boolean }> { - if (!live?.lastQuestion) return { regenerated: false }; - const q = live.lastQuestion; + /** Re-answer a question for the active session — a SPECIFIC one by id (the Cue + * Card's per-card "Regenerate" button) or, with no id, the last question (after + * toggling format/pronunciation). Reuses the SAME question row — no new transcript + * line or DB question. */ + async regenerate(questionId?: string): Promise<{ regenerated: boolean }> { + if (!live) return { regenerated: false }; + let qid: string; + let text: string; + if (questionId) { + // A specific card: pull its text from its question row (any question in this session). + const row = db() + .select() + .from(schema.detectedQuestions) + .where(eq(schema.detectedQuestions.id, questionId)) + .get(); + if (!row) return { regenerated: false }; // e.g. an ad-hoc coding-solve card (not persisted) + qid = questionId; + text = row.text; + } else if (live.lastQuestion) { + qid = live.lastQuestion.questionId; + text = live.lastQuestion.text; + } else { + return { regenerated: false }; + } // Abort the current answer BEFORE clearing the Cue Card, so a late token from // the aborted stream can't land in the cleared answer. if (live.answerAbort) live.answerAbort.abort(); - // Clear the current answer in the Cue Card (without touching the transcript). - broadcast(EVENTS.answerReset, { questionId: q.questionId }); - await this.generateAnswer(live.sessionId, q.questionId, q.text); + // Clear that question's answer in the Cue Card (without touching the transcript). + broadcast(EVENTS.answerReset, { questionId: qid }); + await this.generateAnswer(live.sessionId, qid, text); return { regenerated: true }; }, diff --git a/src/preload/index.ts b/src/preload/index.ts index c03ba80..d1d60e0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -150,7 +150,8 @@ const api = { invoke<{ ok: true }>(IPC.session.setInterviewType, { sessionId, interviewType }), setAnswering: (enabled: boolean) => invoke<{ enabled: boolean; answered: boolean }>(IPC.session.setAnswering, { enabled }), - regenerate: () => invoke<{ regenerated: boolean }>(IPC.session.regenerate), + regenerate: (questionId?: string) => + invoke<{ regenerated: boolean }>(IPC.session.regenerate, { questionId }), clearAnswer: () => invoke<{ cleared: boolean }>(IPC.session.clearAnswer), stop: (sessionId: string) => invoke(IPC.session.stop, { sessionId }), togglePause: (sessionId: string) => invoke(IPC.session.togglePause, { sessionId }), @@ -224,6 +225,7 @@ const api = { addRegion: (image: string) => invoke<{ added: true }>(IPC.capture.addRegion, { image }), solveBuffer: () => invoke<{ started: true }>(IPC.capture.solveBuffer), clearBuffer: () => invoke<{ cleared: true }>(IPC.capture.clearBuffer), + resolveLast: () => invoke<{ started: true }>(IPC.capture.resolveLast), }, overlay: { show: () => invoke(IPC.overlay.show), diff --git a/src/renderer/overlay/Overlay.tsx b/src/renderer/overlay/Overlay.tsx index 888d909..f407404 100644 --- a/src/renderer/overlay/Overlay.tsx +++ b/src/renderer/overlay/Overlay.tsx @@ -15,8 +15,9 @@ import { Modal } from '../components/ui'; import { type AnswerCard, addCard, + appendById, makeCard, - patchLast, + patchById, removeCard, toggleCollapsed, } from './answerCards'; @@ -130,6 +131,9 @@ export default function Overlay() { // ~60×/sec instead of once per token. const pendingTokens = useRef(''); const flushHandle = useRef(null); + // The questionId whose answer is currently streaming — so buffered tokens flush to + // the RIGHT card (routing by id supports regenerating any card, not just the last). + const streamingId = useRef(''); useEffect(() => { const flush = () => { @@ -137,7 +141,7 @@ export default function Overlay() { if (!pendingTokens.current) return; const chunk = pendingTokens.current; pendingTokens.current = ''; - setCards((cs) => patchLast(cs, { answer: (cs[cs.length - 1]?.answer ?? '') + chunk })); + setCards((cs) => appendById(cs, streamingId.current, chunk)); }; const scheduleFlush = () => { if (flushHandle.current == null) flushHandle.current = requestAnimationFrame(flush); @@ -150,13 +154,22 @@ export default function Overlay() { cleanup.current.push( api.events.onQuestionDetected((p) => { - const text = (p as { text: string }).text; + const q = p as { id: string; text: string; type?: string }; cancelFlush(); + streamingId.current = q.id; // History on: collapse prior cards and add a fresh one. Off: replace. - setCards((cs) => addCard(cs, makeCard(cardId.current++, text), historyEnabledRef.current)); + setCards((cs) => + addCard( + cs, + makeCard(cardId.current++, q.id, q.text, q.type === 'coding'), + historyEnabledRef.current, + ), + ); // Mirror the dashboard: surface the detected question in the transcript too. setTranscript((t) => - [...t, { id: lineId.current++, speaker: 'detected question', text }].slice(-MAX_LINES * 2), + [...t, { id: lineId.current++, speaker: 'detected question', text: q.text }].slice( + -MAX_LINES * 2, + ), ); }), api.events.onTranscriptDelta((p) => { @@ -171,23 +184,40 @@ export default function Overlay() { } }), api.events.onAnswerDelta((p) => { - pendingTokens.current += (p as { token: string }).token; + const d = p as { questionId: string; token: string }; + streamingId.current = d.questionId; + pendingTokens.current += d.token; scheduleFlush(); }), - api.events.onAnswerMeta((p) => setCards((cs) => patchLast(cs, { meta: p as AnswerMetaEvent }))), - api.events.onAnswerDone(() => { + api.events.onAnswerMeta((p) => { + const m = p as AnswerMetaEvent; + setCards((cs) => patchById(cs, m.questionId, { meta: m })); + }), + api.events.onAnswerDone((p) => { flush(); - setCards((cs) => patchLast(cs, { streaming: false })); + setCards((cs) => patchById(cs, (p as { questionId: string }).questionId, { streaming: false })); }), - // Regenerate: clear the current card's answer (transcript untouched) so the - // re-streamed tokens don't append to the old answer. Reuses the same card. - api.events.onAnswerReset(() => { + // Regenerate: clear THAT card's answer (transcript untouched) so the re-streamed + // tokens don't append to the old answer. Reuses the same card (routed by id). + api.events.onAnswerReset((p) => { + const qid = (p as { questionId: string }).questionId; cancelFlush(); - setCards((cs) => patchLast(cs, { answer: '', meta: null, context: null, streaming: true })); + streamingId.current = qid; + // Also expand it — regenerating a collapsed history card should surface it. + setCards((cs) => + patchById(cs, qid, { + answer: '', + meta: null, + context: null, + streaming: true, + collapsed: false, + }), + ); + }), + api.events.onContextSent((p) => { + const c = p as ContextSentEvent; + setCards((cs) => patchById(cs, c.questionId, { context: c })); }), - api.events.onContextSent((p) => - setCards((cs) => patchLast(cs, { context: p as ContextSentEvent })), - ), api.events.onCaptureBuffer((p) => setCaptures(p.images)), api.events.onSessionError((p) => setSessionError((p as { message?: string }).message || 'Session error.'), @@ -266,10 +296,12 @@ export default function Overlay() { }; }, []); - // Derived from the cards list — the newest card is the live/streaming answer. - const current = cards[cards.length - 1]; + // Derived from the cards list. The "focus" card = whichever is currently streaming + // (so regenerating an OLDER card still drives the header + transparency panels), + // else the newest. + const streaming = cards.some((c) => c.streaming); + const current = cards.find((c) => c.streaming) ?? cards[cards.length - 1]; const question = current?.question ?? ''; - const streaming = !!current?.streaming; const meta = current?.meta ?? null; const context = current?.context ?? null; @@ -321,7 +353,13 @@ export default function Overlay() { await api.session.setAnswerPrefs({ pronunciation: next }); if (question) await api.session.regenerate(); }; - const regenerate = () => void api.session.regenerate(); + // Regenerate ONE card's answer (its per-card ↻ button). Live-session questions + // re-run via the answer pipeline; a coding-solve card isn't a persisted question, so + // that returns {regenerated:false} and we re-solve the last coding problem instead. + const regenerateCard = async (card: AnswerCard) => { + const r = await api.session.regenerate(card.questionId); + if (!r.regenerated) await api.capture.resolveLast(); + }; const clearAnswer = () => { if (flushHandle.current != null) { cancelAnimationFrame(flushHandle.current); @@ -667,6 +705,7 @@ export default function Overlay() { ['key_points', 'Key points', 'Short, glanceable key points'], ['explanation', 'Explanation', 'A natural, spoken explanation'], ['detailed', 'Detailed', 'Thorough, with a concrete example'], + ['story_teller', 'Story', 'A short, vivid first-person story'], ] as const ).map(([value, label, title]) => ( + + {c.answer && ( + + )}