From cefa8bfe63ab7d21963ef983b6b2792dfe4e574c Mon Sep 17 00:00:00 2001 From: Vijay Janapa Reddi Date: Tue, 7 Apr 2026 19:22:31 -0400 Subject: [PATCH] =?UTF-8?q?feat(staffml):=20client=20UX=20overhaul=20?= =?UTF-8?q?=E2=80=94=20drawer,=20palette,=20shortcuts,=20AI=20interviewer?= =?UTF-8?q?=20panel,=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six passes of UX work consolidated into one client commit. Each section below is reviewable as an independent unit. PASS A — bug fixes from initial UX review * Gate napkin grader so it can never fire on non-numeric questions (defensive isNumericQuestion heuristic in lib/corpus.ts; the actual fix is a corpus answer_type field, marked TODO) * Vault topic detail rebuilt as a right-anchored slide-over drawer with role=dialog, aria-modal, Escape, focus management, backdrop click-out * Mock Interview now preserves the user's typed answer after reveal so they can compare against the model answer * Explicit ::selection CSS rule for visible mouse text selection on the dark terminal palette * New URL classifier (lib/refs.ts) labels deep-dive references honestly per source (book / arxiv / pytorch / etc.) instead of hard-coding "Read on MLSysBook.ai" everywhere * "May be unavailable" indicator for book-source links until the mlsysbook.ai chapter routes are redeployed PASS B/C — UX features and infra * FirstRunExplainer panel for Practice and Mock Interview modes, empty-state pattern, persisted in localStorage * Cmd+K command palette with three sections (Pages / Topics / Questions), keyboard nav, focus trap, debounced corpus search * Global ? KeyboardShortcutsOverlay listing every shortcut in the app * Nav reorganization: Plans promoted to primary, Tools renamed to Lab, About / Contribute / Dashboard moved to a new "More" user menu, ⌘K hint button added in the top-right * Mock Interview Strict / Standard / Open realism toggle that gates the Hardware Reference, Napkin Calc, and Ask Interviewer panels (persisted in localStorage) * Per-question clarifications log surfaced in the gauntlet results phase as "you asked N clarifications on this problem" PASS D — Ask Interviewer client * Three behavior modes: journal (no endpoint), hosted (LLM via worker), copy-as-prompt fallback (always available) * Reads NEXT_PUBLIC_INTERVIEWER_ENDPOINT at module load * Inline attribution returned by the worker (vendor + model + privacy note) so attribution updates automatically when the deployed provider changes * Four user-facing security/privacy notices wired into the panel PASS E — Gemini-reviewed robustness * AskInterviewer: AbortController on questionContext change to prevent stale fetches injecting into next question, setBusy(false) on reset to prevent input lockout, stable message ids for aria-live correctness, insecure-context clipboard guard, conditional auto-scroll * CommandPalette: searchQuestions debounced (120 ms) to keep main thread responsive on a 9k-question corpus, focus trap (Tab/Shift+Tab cycling), Escape stopPropagation to avoid bubbling to the global ? listener * KeyboardShortcutsOverlay: focus trap, SELECT added to typing guard * Nav: navigator.userAgent (not deprecated navigator.platform) for Mac detection PASS F — Polish * MetaTooltip component (CSS-only, role=tooltip, focus-within, native title fallback) wrapping LevelBadge so every L1-L6+ badge in the app reveals Bloom name + role + verb + sample question on hover/focus * meta-descriptions.ts: single source of truth for level / track / competency tooltip text * Practice: track and competency filter buttons get title= tooltips with the same descriptions * Inline "(how does this affect drilling?)" disclosure next to the Rate yourself buttons explaining the SR scheduling intervals * Progress page empty state now shows three on-ramps (Drill 5 easy / Daily challenge / Mock interview) instead of forcing brand-new users into the highest-friction action Other: * tsconfig.json excludes worker/ so the client doesn't try to typecheck the worker's Cloudflare-typed files * .gitignore adds the link checker JSON report and the wrangler cache Typecheck: clean across all of src/ (only the pre-existing stale .next/ framework reference remains, unchanged from before). --- interviews/staffml/.gitignore | 7 + interviews/staffml/src/app/gauntlet/page.tsx | 123 +++++- interviews/staffml/src/app/globals.css | 12 + interviews/staffml/src/app/layout.tsx | 4 + interviews/staffml/src/app/page.tsx | 37 +- interviews/staffml/src/app/practice/page.tsx | 179 +++++--- interviews/staffml/src/app/progress/page.tsx | 35 +- .../staffml/src/components/AskInterviewer.tsx | 413 ++++++++++++++++++ .../staffml/src/components/CommandPalette.tsx | 356 +++++++++++++++ .../src/components/FirstRunExplainer.tsx | 138 ++++++ .../staffml/src/components/HardwareRef.tsx | 4 +- .../components/KeyboardShortcutsOverlay.tsx | 214 +++++++++ .../staffml/src/components/LevelBadge.tsx | 23 +- .../staffml/src/components/MetaTooltip.tsx | 73 ++++ .../staffml/src/components/NapkinCalc.tsx | 4 +- interviews/staffml/src/components/Nav.tsx | 187 ++++++-- .../src/components/vault/TopicDetail.tsx | 93 ++-- interviews/staffml/src/lib/corpus.ts | 26 ++ .../staffml/src/lib/meta-descriptions.ts | 126 ++++++ interviews/staffml/src/lib/refs.ts | 177 ++++++++ interviews/staffml/tsconfig.json | 2 +- 21 files changed, 2077 insertions(+), 156 deletions(-) create mode 100644 interviews/staffml/src/components/AskInterviewer.tsx create mode 100644 interviews/staffml/src/components/CommandPalette.tsx create mode 100644 interviews/staffml/src/components/FirstRunExplainer.tsx create mode 100644 interviews/staffml/src/components/KeyboardShortcutsOverlay.tsx create mode 100644 interviews/staffml/src/components/MetaTooltip.tsx create mode 100644 interviews/staffml/src/lib/meta-descriptions.ts create mode 100644 interviews/staffml/src/lib/refs.ts diff --git a/interviews/staffml/.gitignore b/interviews/staffml/.gitignore index a307bc885f..ffdb92c843 100644 --- a/interviews/staffml/.gitignore +++ b/interviews/staffml/.gitignore @@ -4,3 +4,10 @@ out/ # Dependencies node_modules/ + +# Link-checker report (generated by scripts/check-deep-dive-links.py) +scripts/_deep_dive_link_report.json + +# Cloudflare Wrangler cache (worker dev/deploy) +worker/.wrangler/ +worker/wrangler.toml.bak diff --git a/interviews/staffml/src/app/gauntlet/page.tsx b/interviews/staffml/src/app/gauntlet/page.tsx index 821d8976fd..773bf51a33 100644 --- a/interviews/staffml/src/app/gauntlet/page.tsx +++ b/interviews/staffml/src/app/gauntlet/page.tsx @@ -18,6 +18,10 @@ import { track } from "@/lib/analytics"; import { saveAttempt, saveGauntletResult, AttemptRecord, recordActivity, updateSRCard } from "@/lib/progress"; import NapkinMathDisplay from "@/components/NapkinMathDisplay"; import QuestionFeedback from "@/components/QuestionFeedback"; +import HardwareRef from "@/components/HardwareRef"; +import NapkinCalc from "@/components/NapkinCalc"; +import AskInterviewer from "@/components/AskInterviewer"; +import FirstRunExplainer from "@/components/FirstRunExplainer"; type Phase = "setup" | "active" | "review" | "results"; @@ -38,6 +42,26 @@ export default function GauntletPage() { const [selectedDuration, setSelectedDuration] = useState(1); // index into DURATIONS const [availableCount, setAvailableCount] = useState(0); + // Realism: how interview-like is the session? + // strict = no Hardware Reference, no Napkin Calc, no Ask Interviewer + // standard = tools available, collapsed by default (current default) + // open = tools available, expanded by default + // Persisted in localStorage so the user's choice survives reloads. + type Realism = "strict" | "standard" | "open"; + const [realism, setRealism] = useState("standard"); + useEffect(() => { + try { + const saved = localStorage.getItem("staffml_gauntlet_realism"); + if (saved === "strict" || saved === "standard" || saved === "open") { + setRealism(saved); + } + } catch { /* localStorage may be unavailable */ } + }, []); + const updateRealism = (r: Realism) => { + setRealism(r); + try { localStorage.setItem("staffml_gauntlet_realism", r); } catch { /* ignore */ } + }; + // Active state const [questions, setQuestions] = useState([]); const [currentIdx, setCurrentIdx] = useState(0); @@ -50,6 +74,17 @@ export default function GauntletPage() { const [scores, setScores] = useState([]); const [copied, setCopied] = useState(false); + // Per-question clarifications log — surfaced in the results phase as + // "you asked N clarifications on this problem." Powers the metacognitive + // post-mortem ritual for both Journal and Hosted AskInterviewer modes. + const [clarifications, setClarifications] = useState>({}); + const recordClarification = (qId: string, q: string) => { + setClarifications((prev) => ({ + ...prev, + [qId]: [...(prev[qId] || []), q], + })); + }; + const tracks = getTracks().filter(t => t !== "global"); const levels = getLevels(); @@ -167,6 +202,7 @@ export default function GauntletPage() { setShowAnswer(false); setUserAnswer(""); setScores([]); + setClarifications({}); setTimeRemaining(dur.minutes * 60); setPhase("active"); track({ type: 'gauntlet_started', track: selectedTrack, level: selectedLevel, questionCount: selected.length }); @@ -242,7 +278,11 @@ export default function GauntletPage() { // ─── SETUP PHASE ───────────────────────────────────── if (phase === "setup") { return ( -
+
+
+ +
+
+ {/* Realism — controls which helper tools appear during the interview */} +
+ +
+ {([ + { id: "strict", label: "Strict", desc: "No tools. Closest to a real whiteboard." }, + { id: "standard", label: "Standard", desc: "Tools available, collapsed by default." }, + { id: "open", label: "Open", desc: "Tools open. Use any time." }, + ] as { id: Realism; label: string; desc: string }[]).map(opt => ( + + ))} +
+
+ {/* Available count + start */}
@@ -356,6 +423,7 @@ export default function GauntletPage() {
)}
+
); } @@ -423,6 +491,22 @@ export default function GauntletPage() {
+ {/* Tools — gated by the user's realism choice on the setup screen. + Strict = no tools at all (closest to a real whiteboard) + Standard = tools mounted, collapsed by default + Open = tools mounted, expanded by default */} + {realism !== "strict" && ( + <> + + + recordClarification(q.id, question)} + /> + + )} +
{!showAnswer ? ( <> @@ -449,6 +533,20 @@ export default function GauntletPage() { animate={{ opacity: 1, y: 0 }} className="space-y-5" > + {/* User's answer — preserved for self-comparison against the model. + Collapsed by default so the model answer stays the visual focus. */} + {userAnswer.trim() && ( +
+ + + Your answer + +
+ {userAnswer} +
+
+ )} + {/* Model answer */} {q.details.common_mistake && (
@@ -568,6 +666,7 @@ export default function GauntletPage() { {questions.map((q, i) => { const s = scores[i] ?? 0; const labels = ['Skipped', 'Wrong', 'Partial', 'Nailed']; + const askedClarifications = clarifications[q.id] || []; return (
@@ -578,6 +677,11 @@ export default function GauntletPage() { {i + 1} {q.title} + {askedClarifications.length > 0 && ( + + ?{askedClarifications.length} + + )} {labels[s]}
@@ -585,6 +689,23 @@ export default function GauntletPage() { {q.details.common_mistake && (

Common mistake: {q.details.common_mistake}

)} + {askedClarifications.length > 0 && ( +
+

+ Your clarifications ({askedClarifications.length}) +

+
    + {askedClarifications.map((c, j) => ( +
  • + {j + 1}.{c} +
  • + ))} +
+

+ Senior interviewees typically ask 3–6 clarifications before solving. Compare against the model answer's assumptions. +

+
+ )}
- {/* Detail panel */} - - {selectedTopic && selectedStyle && ( +
+ + {/* Desktop detail drawer (right-anchored slide-over) */} + + {selectedTopic && selectedStyle && ( +
+ {/* Backdrop — click anywhere outside to close */} setSelectedTopic(null)} + aria-hidden="true" + /> + setSelectedTopic(null)} /> - )} - -
+ + )} +
{/* Mobile detail sheet + backdrop */} diff --git a/interviews/staffml/src/app/practice/page.tsx b/interviews/staffml/src/app/practice/page.tsx index 29aecbdb11..49aba8f8b2 100644 --- a/interviews/staffml/src/app/practice/page.tsx +++ b/interviews/staffml/src/app/practice/page.tsx @@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation"; import { motion, AnimatePresence } from "framer-motion"; import { Target, CheckCircle2, XCircle, Terminal, SkipForward, - BookOpen, Calculator + BookOpen, Calculator, FileText, ExternalLink as ExternalLinkIcon } from "lucide-react"; import clsx from "clsx"; import HardwareRef from "@/components/HardwareRef"; @@ -18,10 +18,12 @@ import { getTracks, getLevels, getCompetencyAreas, getZones, getQuestionsByFilter, getQuestions, getQuestionsByTopic, Question, checkNapkinMath, extractFinalNumber, cleanScenario, - NapkinResult + NapkinResult, isNumericQuestion } from "@/lib/corpus"; import { saveAttempt, getAttempts, updateSRCard, getDueQuestionIds, getDueCount, recordActivity } from "@/lib/progress"; import { extractRubric, rubricToScore, RubricItem } from "@/lib/rubric"; +import { classifyRef } from "@/lib/refs"; +import { trackTooltip, competencyTooltip } from "@/lib/meta-descriptions"; import { getQuestionById } from "@/lib/corpus"; import { getTopicById, getZoneDefinition } from "@/lib/taxonomy"; import { getLevelDef } from "@/lib/levels"; @@ -35,6 +37,7 @@ import Link from "next/link"; import { buildReportUrl } from "@/lib/issue-url"; import QuestionFeedback from "@/components/QuestionFeedback"; import { track } from "@/lib/analytics"; +import FirstRunExplainer from "@/components/FirstRunExplainer"; export default function PracticePageWrapper() { return ( @@ -250,12 +253,15 @@ function PracticePage() { } incrementReveals(); - // Try napkin math check if the question has napkin_math and user typed something + // Try napkin math check ONLY when (a) the question is actually numeric + // (so we never grade a recall question like "what does NPU stand for?") + // and (b) the user typed something containing at least one digit. + // Falls back to self-rate when either condition fails. let napkinGrade: string | undefined; - if (current?.details.napkin_math && userAnswer.trim()) { + if (current && isNumericQuestion(current) && userAnswer.trim() && /\d/.test(userAnswer)) { const userNum = extractFinalNumber(userAnswer); - const modelNum = extractFinalNumber(current.details.napkin_math); - if (userNum !== null && modelNum !== null && modelNum > 0) { + const modelNum = extractFinalNumber(current.details.napkin_math || ''); + if (userNum !== null && modelNum !== null && modelNum > 0 && !isNaN(userNum) && isFinite(userNum)) { const result = checkNapkinMath(userNum, modelNum, current.track); setNapkinResult({ ...result, userNum, modelNum }); napkinGrade = result.grade; @@ -364,7 +370,9 @@ function PracticePage() { } return ( -
+
+ +
{/* Sidebar filters */}
@@ -671,17 +687,25 @@ function PracticePage() {

- {current.details.deep_dive_title && ( - - - {current.details.deep_dive_title} - - )} + {current.details.deep_dive_title && current.details.deep_dive_url && (() => { + const refInfo = classifyRef(current.details.deep_dive_url); + const href = refInfo.isBook + ? current.details.deep_dive_url!.replace('https://mlsysbook.ai', ECOSYSTEM_BASE) + : current.details.deep_dive_url!; + const Icon = refInfo.isBook ? BookOpen : refInfo.source === "arxiv" || refInfo.source === "paper" ? FileText : ExternalLinkIcon; + return ( + + + {current.details.deep_dive_title} + + ); + })()} @@ -706,7 +730,7 @@ function PracticePage() {
- {current.details.napkin_math ? "napkin_math.py" : "answer.md"} + {isNumericQuestion(current) ? "napkin_math.py" : "answer.md"}
)} - {/* Deep-dive link to MLSysBook.ai */} - {current.details.deep_dive_title && ( - - - Learn more on MLSysBook.ai — {current.details.deep_dive_title} - - )} + {/* Deep-dive reference — labelled honestly per destination + (book / paper / vendor docs / blog / external) so users + know what they're clicking into. See lib/refs.ts. */} + {current.details.deep_dive_title && current.details.deep_dive_url && (() => { + const refInfo = classifyRef(current.details.deep_dive_url); + const href = refInfo.isBook + ? current.details.deep_dive_url!.replace('https://mlsysbook.ai', ECOSYSTEM_BASE) + : current.details.deep_dive_url!; + const Icon = refInfo.isBook ? BookOpen : refInfo.source === "arxiv" || refInfo.source === "paper" ? FileText : ExternalLinkIcon; + return ( + + + {refInfo.label} — {current.details.deep_dive_title} + {refInfo.mayBeUnavailable && ( + + )} + + ); + })()} {/* Rubric checkboxes */} {rubricItems.length > 0 && ( @@ -882,6 +919,25 @@ function PracticePage() { {rubricItems.length > 0 ? 'Confirm or override' : 'Rate yourself'} Press 1-4 +
+ + (how does this affect drilling?) + +
+

+ Your honest rating drives spaced repetition. The system schedules the next time you'll see this question based on how you did: +

+
    +
  • Wrong → comes back tomorrow
  • +
  • Partial → comes back in 3 days
  • +
  • Nailed → comes back in 1–2 weeks (intervals lengthen each time you nail it)
  • +
  • Skip → no schedule change, doesn't count toward your streak
  • +
+

+ The "due" counter in the nav shows how many cards are waiting for you. Be honest — over-rating "Nailed" means you'll forget; under-rating wastes your time on stuff you already know. +

+
+
{[ @@ -949,6 +1005,7 @@ function PracticePage() { {showStarGate && ( { setShowStarGate(false); track({ type: 'star_gate_verified' }); }} /> )} +
); } diff --git a/interviews/staffml/src/app/progress/page.tsx b/interviews/staffml/src/app/progress/page.tsx index 35685c598d..5268d45c08 100644 --- a/interviews/staffml/src/app/progress/page.tsx +++ b/interviews/staffml/src/app/progress/page.tsx @@ -225,15 +225,34 @@ export default function ProgressPage() {

No data yet

-

- Complete a Gauntlet or drill some questions to see your readiness heat map populate. +

+ Your progress page lights up after you've drilled questions or completed a mock interview. Pick a starting point — there's no wrong choice. +

+
+ + Drill 5 easy + + + Daily challenge + + + Mock interview + +
+

+ Tip: every question you answer feeds the heat map below, so you can see at a glance which competency areas need more work.

- - Start a Gauntlet - ) : ( <> diff --git a/interviews/staffml/src/components/AskInterviewer.tsx b/interviews/staffml/src/components/AskInterviewer.tsx new file mode 100644 index 0000000000..4f39e3c403 --- /dev/null +++ b/interviews/staffml/src/components/AskInterviewer.tsx @@ -0,0 +1,413 @@ +"use client"; + +/** + * Ask Interviewer panel — clarification ritual for Mock Interview mode. + * + * Three behaviors that auto-detect at runtime: + * + * 1. JOURNAL — no NEXT_PUBLIC_INTERVIEWER_ENDPOINT configured. + * User types clarifications, they're logged for post-mortem, + * no AI call happens. Works for everyone, no infra needed. + * + * 2. HOSTED — NEXT_PUBLIC_INTERVIEWER_ENDPOINT is set. Each clarification + * is POSTed to the Worker, which forwards to whichever LLM + * provider is configured (default: Cloudflare Workers AI + * Llama 3.1 8B). Inline attribution shows the actual model + * that answered. + * + * 3. FALLBACK — Hosted mode was configured but failed (rate-limited, 503, + * network error). Shows the friendly error inline AND + * surfaces the Copy-as-prompt button prominently. The user's + * question is still logged for post-mortem. + * + * Copy-as-prompt is ALWAYS available as a button regardless of mode. It's + * the universal safety net — works with any LLM the user has access to, + * including local Ollama, ChatGPT, Claude, etc. + * + * The Socratic system prompt is enforced server-side in the Worker (so it + * can't be bypassed) but is also embedded in the Copy-as-prompt output so + * users pasting into other LLMs get the same constraint. + */ + +import { useState, useRef, useEffect } from "react"; +import { + ChevronDown, ChevronRight, MessageCircle, Send, Info, + ClipboardCopy, Check, AlertTriangle, +} from "lucide-react"; +import clsx from "clsx"; + +const INTERVIEWER_ENDPOINT = + process.env.NEXT_PUBLIC_INTERVIEWER_ENDPOINT?.replace(/\/+$/, "") || ""; + +// The same Socratic constraint enforced in the Worker. Embedded here so the +// Copy-as-prompt output carries it into whatever LLM the user pastes into. +const SOCRATIC_PROMPT_FOR_COPY = `You are a senior ML systems interviewer running a clarification round. Your only job is to answer the candidate's clarifying questions about constraints, scale, latency budgets, SLOs, traffic patterns, hardware availability, team size, and timeline. + +You must NOT solve the problem. You must NOT propose architectures, algorithms, frameworks, or implementations. If the candidate asks "how should I do X" or "what's the right approach," redirect with: "That's the part I want to see you reason through. What constraint do you need from me first?" + +Keep answers under 60 words. Be specific and concrete — give numbers when reasonable. Use a senior interviewer's tone: direct, no fluff, no apologies.`; + +interface Message { + /** Stable id for React keys + aria-live announcements. Generated client-side + * per message; never rely on array index because we reset and append. */ + id: string; + role: "user" | "interviewer"; + text: string; + // For interviewer messages: provenance metadata returned by the Worker + vendorLabel?: string; + modelLabel?: string; + privacyNote?: string; +} + +let _msgIdCounter = 0; +function nextMessageId(): string { + _msgIdCounter += 1; + return `m${_msgIdCounter}`; +} + +interface AskInterviewerProps { + /** The current question's scenario text. Sent as context to the LLM. */ + questionContext: string; + /** Open by default (e.g. when realism = "open"). */ + defaultOpen?: boolean; + /** Notified every time the user submits a clarification. Used by the + * gauntlet results phase to surface "you asked N clarifications." */ + onAsk?: (question: string) => void; +} + +const HOSTED_AVAILABLE = INTERVIEWER_ENDPOINT.length > 0; + +export default function AskInterviewer({ questionContext, defaultOpen = false, onAsk }: AskInterviewerProps) { + const [open, setOpen] = useState(defaultOpen); + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const transcriptRef = useRef(null); + // AbortController for the in-flight fetch. Cleared on questionContext change + // so a stale response can never inject into the next question's transcript. + const inFlightRef = useRef(null); + + // Reset transcript when the question changes. CRITICAL: also clear `busy` + // and abort any in-flight fetch — otherwise navigating mid-fetch leaves + // the input permanently disabled until the user reopens the panel. + useEffect(() => { + setMessages([]); + setDraft(""); + setError(null); + setBusy(false); + if (inFlightRef.current) { + inFlightRef.current.abort(); + inFlightRef.current = null; + } + }, [questionContext]); + + // Cleanup any in-flight fetch on unmount as well + useEffect(() => { + return () => { + inFlightRef.current?.abort(); + inFlightRef.current = null; + }; + }, []); + + // Auto-scroll the transcript on new messages — but only if the user is + // already near the bottom. Don't yank them away from older messages they're + // actively reading. + useEffect(() => { + const el = transcriptRef.current; + if (!el) return; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (distanceFromBottom < 64) { + el.scrollTop = el.scrollHeight; + } + }, [messages]); + + const submit = async () => { + const question = draft.trim(); + if (!question || busy) return; + + // Always log the user's clarification for post-mortem aggregation + setMessages((prev) => [...prev, { id: nextMessageId(), role: "user", text: question }]); + onAsk?.(question); + setDraft(""); + setError(null); + + if (!HOSTED_AVAILABLE) { + // Journal mode — no AI call. The clarification is the educational act. + // Show a one-time inline note so users understand why there's no answer. + setMessages((prev) => [ + ...prev, + { + id: nextMessageId(), + role: "interviewer", + text: + "(journal mode) Your clarification is logged for the post-mortem. " + + "Use the Copy-as-prompt button below to ask in your own LLM.", + }, + ]); + return; + } + + // Tear down any older in-flight request before starting a new one. + inFlightRef.current?.abort(); + const controller = new AbortController(); + inFlightRef.current = controller; + setBusy(true); + try { + const res = await fetch(`${INTERVIEWER_ENDPOINT}/ask`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ + question, + context: questionContext, + history: messages.map((m) => ({ + role: m.role, + content: m.text, + })), + }), + }); + + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { message?: string; error?: string }; + const msg = data.message || data.error || `interviewer service returned ${res.status}`; + setMessages((prev) => [ + ...prev, + { id: nextMessageId(), role: "interviewer", text: `⚠ ${msg}` }, + ]); + setError(msg); + return; + } + + const data = (await res.json()) as { + answer: string; + provider: string; + vendorLabel: string; + modelLabel: string; + privacyNote: string; + }; + setMessages((prev) => [ + ...prev, + { + id: nextMessageId(), + role: "interviewer", + text: data.answer, + vendorLabel: data.vendorLabel, + modelLabel: data.modelLabel, + privacyNote: data.privacyNote, + }, + ]); + } catch (e) { + // AbortError from a stale request is expected — swallow silently. + if (e instanceof DOMException && e.name === "AbortError") return; + const msg = e instanceof Error ? e.message : String(e); + setMessages((prev) => [ + ...prev, + { id: nextMessageId(), role: "interviewer", text: `⚠ Failed to reach interviewer service: ${msg}` }, + ]); + setError(msg); + } finally { + // Only clear busy/inFlightRef if THIS controller is still the active one. + // If a newer submit started (or questionContext changed), don't unset. + if (inFlightRef.current === controller) { + setBusy(false); + inFlightRef.current = null; + } + } + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + submit(); + } + }; + + // ─── Copy-as-prompt: build a self-contained prompt for any LLM ───── + const copyAsPrompt = async () => { + const userTurns = messages.filter((m) => m.role === "user"); + const numbered = userTurns.length > 0 + ? userTurns.map((m, i) => `${i + 1}. ${m.text}`).join("\n") + : draft.trim() + ? `1. ${draft.trim()}` + : "(no clarifying questions yet — write yours below this prompt)"; + + const text = `${SOCRATIC_PROMPT_FOR_COPY} + +--- + +Scenario: +${questionContext} + +My clarifying questions so far: +${numbered} + +Please answer each as the interviewer.`; + + // navigator.clipboard is undefined on insecure-context (http on non-localhost) + // and may throw on permission denial. Handle both cleanly. + if (typeof navigator === "undefined" || !navigator.clipboard) { + setError("Clipboard API not available — try using a secure (https) context."); + return; + } + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2500); + } catch (e) { + const msg = e instanceof Error ? e.message : "permission denied"; + setError(`Could not write to clipboard: ${msg}`); + } + }; + + const userClarificationCount = messages.filter((m) => m.role === "user").length; + + return ( +
+ + + {open && ( +
+ {/* Mode banner (journal vs hosted) — shown once at the top */} + {messages.length === 0 && ( + HOSTED_AVAILABLE ? ( +
+ +

+ Practice the clarification ritual real interviews reward. Your questions go to a + small AI interviewer with a Socratic constraint — it can answer constraints, never + solve the problem. AI may be wrong — verify against the model answer. +

+
+ ) : ( +
+ +

+ Journal mode. Your clarifying questions are logged for the post-mortem but no + AI is wired. Use the Copy as prompt button + below to ask in your own LLM. +

+
+ ) + )} + + {/* Transcript */} + {messages.length > 0 && ( +
+ {messages.map((m) => ( +
+ + {m.role === "user" ? "you" : "interviewer"} + + + {m.text} + + {m.role === "interviewer" && m.modelLabel && ( +
+ {m.modelLabel} via {m.vendorLabel} · AI may be wrong · check the model answer +
+ )} +
+ ))} +
+ )} + + {/* Composer */} + +