diff --git a/docs/avatar-contract.md b/docs/avatar-contract.md index 3ec4bca77..02e3c6317 100644 --- a/docs/avatar-contract.md +++ b/docs/avatar-contract.md @@ -332,6 +332,16 @@ Keeping the last turn lets the candidate reread a hint or follow-up question while thinking, without switching away from the problem. The Transcript tab still keeps every turn. +An "Unclear" button beside the "Live CC:" label marks the Jim turn on screen, +and pressing it again unmarks it. It shows only while a Jim turn is on screen, +and once `MAX_UNCLEAR_LINES` (10) turns are marked it is disabled on any turn +not already marked. Jim is not told about a mark. When the report is saved, +the marked turns are resolved to their transcript text and stored as +`unclearLines` in the history entry, outside the report and its request. The +report and its Markdown export list them, and the export tags them in the +transcript. A report reopened from Past attempts or a replay keeps the list, +but its export has no transcript to tag. + Jim captions reveal gradually, even after final text arrives, because generation can finish while audio is still queued. An edit during the reveal is remembered for that Jim segment: its final text finishes revealing, then diff --git a/tests/browser/captions.test.js b/tests/browser/captions.test.js index 052e67557..46a8281f5 100644 --- a/tests/browser/captions.test.js +++ b/tests/browser/captions.test.js @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { functionBody, read } from "./source.js"; +import { MAX_UNCLEAR_LINES } from "../../web/lib.js"; let captionCopies = 0; @@ -671,3 +672,195 @@ test("a pending code edit preserves the candidate idle deadline", async (t) => { t.mock.timers.tick(60000); assert.equal(nodes.captionsBar.hidden, true); }); + +function fakeUnclearButton() { + return { + hidden: true, + disabled: false, + title: "", + attributes: {}, + click: null, + setAttribute(name, value) { + this.attributes[name] = value; + }, + addEventListener(type, handler) { + if (type === "click") this.click = handler; + }, + }; +} + +async function setupUnclear(t) { + const captions = await import( + `../../web/captions.js?copy=${++captionCopies}` + ); + const button = fakeUnclearButton(); + const nodes = { + captionsText: { textContent: "" }, + captionsBar: { hidden: true }, + captionUnclear: button, + }; + captions.initCaptions({ nodes }); + t.mock.timers.enable({ apis: ["setTimeout"] }); + return { captions, button, nodes }; +} + +test("the Unclear button marks and unmarks the current Jim turn", async (t) => { + const { captions, button } = await setupUnclear(t); + + captions.updateCaptions("interviewer", "Use a hash map.", "jim-1", { + final: true, + }); + assert.equal(button.hidden, false); + + button.click(); + assert.equal(button.attributes["aria-pressed"], "true"); + assert.deepEqual(captions.unclearLineIds(), ["jim-1"]); + + button.click(); + assert.equal(button.attributes["aria-pressed"], "false"); + assert.deepEqual(captions.unclearLineIds(), []); +}); + +test("candidate speech hides the Unclear button and a new Jim turn starts unmarked", async (t) => { + const { captions, button } = await setupUnclear(t); + + captions.updateCaptions("interviewer", "Use a hash map.", "jim-1", { + final: true, + }); + button.click(); + captions.updateCaptions("you", "Okay.", "you-1"); + assert.equal(button.hidden, true); + + captions.updateCaptions("interviewer", "Try sorting.", "jim-2"); + assert.equal(button.hidden, false); + assert.equal(button.attributes["aria-pressed"], "false"); + assert.deepEqual(captions.unclearLineIds(), ["jim-1"]); +}); + +test("the Unclear button stops marking at the cap", async (t) => { + const { captions, button } = await setupUnclear(t); + + for (let turn = 1; turn <= MAX_UNCLEAR_LINES; turn++) { + captions.updateCaptions("interviewer", `Line ${turn}.`, `jim-${turn}`, { + final: true, + }); + button.click(); + } + assert.equal(captions.unclearLineIds().length, MAX_UNCLEAR_LINES); + + captions.updateCaptions("interviewer", "One more line.", "jim-extra", { + final: true, + }); + assert.equal(button.attributes["aria-disabled"], "true"); + button.click(); + assert.equal(captions.unclearLineIds().length, MAX_UNCLEAR_LINES); + assert.ok(!captions.unclearLineIds().includes("jim-extra")); +}); + +function focusOn(button, nodes, editor = {}) { + const document = { activeElement: button }; + button.ownerDocument = document; + const target = (name, options = {}) => ({ + isConnected: true, + disabled: false, + ...options, + focus() { + document.activeElement = nodes[name]; + }, + }); + nodes.editor = target("editor", editor); + nodes.board = target("board"); + return document; +} + +test("candidate speech moves focus off the Unclear button it hides", async (t) => { + const { captions, button, nodes } = await setupUnclear(t); + const document = focusOn(button, nodes); + + captions.updateCaptions("interviewer", "Use a hash map.", "jim-1", { + final: true, + }); + button.click(); + assert.equal(document.activeElement, button); + + captions.updateCaptions("you", "Okay.", "you-1"); + assert.equal(button.hidden, true); + assert.equal(document.activeElement, nodes.editor); +}); + +test("the cap leaves focus on the Unclear button it marks unavailable", async (t) => { + const { captions, button, nodes } = await setupUnclear(t); + const document = focusOn(button, nodes); + + for (let turn = 1; turn <= MAX_UNCLEAR_LINES; turn++) { + captions.updateCaptions("interviewer", `Line ${turn}.`, `jim-${turn}`, { + final: true, + }); + button.click(); + } + assert.equal(document.activeElement, button); + + captions.updateCaptions("interviewer", "One more line.", "jim-extra", { + final: true, + }); + assert.equal(button.attributes["aria-disabled"], "true"); + assert.equal(document.activeElement, button); +}); + +test("the idle timeout moves focus off the Unclear button it hides", async (t) => { + const { captions, button, nodes } = await setupUnclear(t); + const document = focusOn(button, nodes); + + // A Jim turn that never turns final keeps the idle timeout armed. + captions.updateCaptions("interviewer", "Go on.", "jim-1"); + t.mock.timers.tick(12000); + assert.equal(nodes.captionsBar.hidden, true); + assert.equal(document.activeElement, nodes.editor); +}); + +test("a code edit moves focus off the Unclear button it hides", async (t) => { + const { captions, button, nodes } = await setupUnclear(t); + const document = focusOn(button, nodes); + + captions.updateCaptions("interviewer", "Use a hash map.", "jim-1", { + final: true, + }); + t.mock.timers.tick(1000); + captions.dismissCaptions(); + assert.equal(nodes.captionsBar.hidden, true); + assert.equal(document.activeElement, nodes.editor); +}); + +for (const [name, editor] of [ + ["disabled", { disabled: true }], + ["removed", { isConnected: false }], +]) { + test(`focus skips a ${name} editor for the board`, async (t) => { + const { captions, button, nodes } = await setupUnclear(t); + const document = focusOn(button, nodes, editor); + + captions.updateCaptions("interviewer", "Go on.", "jim-1", { final: true }); + captions.updateCaptions("you", "Okay.", "you-1"); + assert.equal(document.activeElement, nodes.board); + }); +} + +test("a mouse click on Unclear hands focus back to the editor", async (t) => { + const { captions, button, nodes } = await setupUnclear(t); + const document = focusOn(button, nodes); + + captions.updateCaptions("interviewer", "Go on.", "jim-1", { final: true }); + button.click({ detail: 1 }); + assert.equal(button.attributes["aria-pressed"], "true"); + assert.equal(document.activeElement, nodes.editor); +}); + +test("Space or Enter on Unclear keeps focus on the button", async (t) => { + const { captions, button, nodes } = await setupUnclear(t); + const document = focusOn(button, nodes); + + captions.updateCaptions("interviewer", "Go on.", "jim-1", { final: true }); + button.click({ detail: 0 }); + assert.equal(button.attributes["aria-pressed"], "true"); + assert.equal(document.activeElement, button); +}); diff --git a/tests/browser/interview-lifecycle.test.js b/tests/browser/interview-lifecycle.test.js index d4f89afed..9b20b2f4f 100644 --- a/tests/browser/interview-lifecycle.test.js +++ b/tests/browser/interview-lifecycle.test.js @@ -5,6 +5,7 @@ import { shouldWarnBeforeUnload, } from "../../web/before-unload.js"; import { reportIsPersisted } from "../../web/history.js"; +import { withUnclearLines } from "../../web/lib.js"; import { functionBody, read } from "./source.js"; function beforeUnloadEvent(target) { @@ -269,6 +270,8 @@ test("guest reports are not persisted across sessions", async () => { "saveReportHistory", "reportIsPersisted", "updateBeforeUnloadGuard", + "unclearLines", + "withUnclearLines", `let saveGeneration = 0;\n${body}\n}\nreturn saveHistory;`, )( state, @@ -282,6 +285,8 @@ test("guest reports are not persisted across sessions", async () => { saveReportHistory, reportIsPersisted, updateBeforeUnloadGuard, + () => [], + withUnclearLines, ); const result = await saveHistory({ summary: "guest report" }); @@ -326,6 +331,8 @@ test("report is saved locally when session state is unavailable", async () => { "saveReportHistory", "reportIsPersisted", "updateBeforeUnloadGuard", + "unclearLines", + "withUnclearLines", `let saveGeneration = 0;\n${body}\n}\nreturn saveHistory;`, )( state, @@ -338,6 +345,8 @@ test("report is saved locally when session state is unavailable", async () => { saveReportHistory, () => true, updateBeforeUnloadGuard, + () => [], + withUnclearLines, ); const result = await saveHistory({ summary: "temporary report" }); @@ -379,6 +388,8 @@ test("only the newest overlapping report save can release the warning", async () "saveReportHistory", "reportIsPersisted", "updateBeforeUnloadGuard", + "unclearLines", + "withUnclearLines", `let saveGeneration = 0;\n${body}\n}\nreturn saveHistory;`, )( state, @@ -392,6 +403,8 @@ test("only the newest overlapping report save can release the warning", async () saveReportHistory, reportIsPersisted, updateBeforeUnloadGuard, + () => [], + withUnclearLines, ); const provisional = saveHistory({ summary: "provisional" }); diff --git a/tests/browser/lib.test.js b/tests/browser/lib.test.js index 7dd074952..59368243b 100644 --- a/tests/browser/lib.test.js +++ b/tests/browser/lib.test.js @@ -14,6 +14,9 @@ import { import { ACTIVE_CONTRACT, + MAX_REPORT_BODY_BYTES, + unclearLinesOf, + withUnclearLines, boardOpBatches, surfaceLabel, boardStreamOptions, @@ -3713,3 +3716,50 @@ test("interviewer provenance survives report migration and storage without inven 128, ); }); + +test("unclear lines are clipped to 600 UTF-8 bytes with an ellipsis", () => { + const bytes = (text) => new TextEncoder().encode(text).length; + const exact = "x".repeat(600); + const [ascii, cjk, whole, short] = unclearLinesOf([ + "x".repeat(5000), + "\u6e2c".repeat(1000), + exact, + "Go on.", + ]); + for (const clipped of [ascii, cjk]) { + assert.ok(bytes(clipped) <= 600); + assert.ok(bytes(clipped) > 590); + assert.ok(clipped.endsWith("\u2026")); + } + assert.equal(whole, exact); + assert.equal(short, "Go on."); +}); + +test("marks are dropped before they push the report body past the limit", () => { + const lines = Array.from({ length: 10 }, () => "y".repeat(600)); + const small = { id: "a", report: { summary: "ok" } }; + assert.deepEqual(withUnclearLines(small, lines).unclearLines, lines); + assert.deepEqual(withUnclearLines(small, []), small); + + const size = (value) => + new TextEncoder().encode(JSON.stringify(value)).length; + const room = + MAX_REPORT_BODY_BYTES - size({ id: "a", report: { summary: "" } }); + const large = { id: "a", report: { summary: "z".repeat(room - 2000) } }; + const trimmed = withUnclearLines(large, lines); + assert.ok(trimmed.unclearLines.length > 0); + assert.ok(trimmed.unclearLines.length < lines.length); + assert.ok(size(trimmed) <= MAX_REPORT_BODY_BYTES); + + const full = { id: "a", report: { summary: "z".repeat(room) } }; + assert.equal("unclearLines" in withUnclearLines(full, lines), false); +}); + +test("the report body limit matches the server's", () => { + assert.match( + readFileSync(new URL("../../src/web/mod.rs", import.meta.url), "utf8"), + new RegExp( + `MAX_REPORT_BYTES: usize = ${MAX_REPORT_BODY_BYTES / 1024} \\* 1024;`, + ), + ); +}); diff --git a/tests/browser/progress.test.js b/tests/browser/progress.test.js index d5f2cf5ba..ad76f716a 100644 --- a/tests/browser/progress.test.js +++ b/tests/browser/progress.test.js @@ -795,3 +795,37 @@ test("retry mode metadata falls back to the original report and defaults safely" assert.equal(invalid.interviewLoop, "coding_behavioral"); assert.equal(invalid.interviewMode, "coding"); }); +test("an entry at the unclear-line cap keeps every marked line", () => { + const lines = Array.from({ length: 10 }, (_, index) => `Line ${index + 1}.`); + const attempt = normalizeProgressEntry( + entry("cap", "2026-01-02T00:00:00Z", 70, { unclearLines: lines }), + ); + assert.deepEqual(attempt.unclearLines, lines); +}); + +test("unclearLines is ignored unless it is an array of strings", () => { + for (const unclearLines of [ + undefined, + null, + "Line 1.", + 42, + { 0: "Line 1." }, + ["Line 1.", 2], + ["Line 1.", null], + ]) { + const attempt = normalizeProgressEntry( + entry("bad", "2026-01-02T00:00:00Z", 70, { unclearLines }), + ); + assert.deepEqual(attempt.unclearLines, [], String(unclearLines)); + } +}); +test("unclearLines past the cap or too long is bounded on read", () => { + const lines = Array.from({ length: 12 }, (_, index) => `Line ${index + 1}.`); + lines[0] = "x".repeat(5000); + const attempt = normalizeProgressEntry( + entry("over", "2026-01-02T00:00:00Z", 70, { unclearLines: lines }), + ); + assert.equal(attempt.unclearLines.length, 10); + assert.ok(attempt.unclearLines[0].length < 5000); + assert.deepEqual(attempt.unclearLines.slice(1), lines.slice(1, 10)); +}); diff --git a/tests/browser/render.test.js b/tests/browser/render.test.js index b6d71104a..ba555d93a 100644 --- a/tests/browser/render.test.js +++ b/tests/browser/render.test.js @@ -2413,3 +2413,87 @@ test("markdown export includes test run history", () => { assert.match(markdown, /Expected: 6/); assert.match(markdown, /Actual: 8/); }); +test("markdown lists unclear lines and tags them in the transcript", () => { + const markdown = reportMarkdown({ + report: sanitizeReport({ decision: "HIRE" }), + problemTitle: "Two Sum", + language: "python", + code: "", + transcript: [ + { + id: "jim-1", + speaker: "interviewer", + text: "Use a hash map.", + final: true, + }, + { + id: "you-1", + speaker: "you", + text: "Sorry, could you repeat that?", + final: true, + }, + { + id: "jim-2", + speaker: "interviewer", + text: "Try sorting.", + final: true, + }, + ], + at: "2026-01-02", + unclearLines: ["Use a hash map."], + unclearIds: ["jim-1"], + }); + + assert.match( + markdown, + /## Lines you marked as unclear\n\n1\. Jim: "Use a hash map\."/, + ); + assert.match(markdown, /\*\*Jim \(marked unclear\):\*\* Use a hash map\./); + assert.match(markdown, /\*\*Jim:\*\* Try sorting\./); +}); + +test("a report without unclear lines has no unclear section", () => { + const session = { + report: sanitizeReport({ decision: "HIRE" }), + problemTitle: "Two Sum", + language: "python", + code: "", + transcript: [], + at: "2026-01-02", + }; + assert.doesNotMatch(reportMarkdown(session), /marked as unclear/); + assert.doesNotMatch(reportMarkup(session), /marked as unclear/); +}); +test("markdown tags only the marked turn when Jim repeats himself", () => { + const markdown = reportMarkdown({ + report: sanitizeReport({ decision: "HIRE" }), + problemTitle: "Two Sum", + language: "python", + code: "", + transcript: [ + { + id: "jim-1", + speaker: "interviewer", + text: "Try sorting.", + final: true, + }, + { id: "you-1", speaker: "you", text: "Sorry?", final: true }, + { + id: "jim-2", + speaker: "interviewer", + text: "Try sorting.", + final: true, + }, + ], + at: "2026-01-02", + unclearLines: ["Try sorting."], + unclearIds: ["jim-2"], + }); + + assert.match(markdown, /1\. Jim: "Try sorting\."/); + assert.equal(markdown.match(/Jim \(marked unclear\)/g)?.length, 1); + assert.match( + markdown, + /\*\*Jim:\*\* Try sorting\.\n\n\*\*You:\*\* Sorry\?\n\n\*\*Jim \(marked unclear\):\*\* Try sorting\./, + ); +}); diff --git a/tests/browser/replay-render.test.js b/tests/browser/replay-render.test.js index 808ba9681..406f10acb 100644 --- a/tests/browser/replay-render.test.js +++ b/tests/browser/replay-render.test.js @@ -519,6 +519,7 @@ test("the report card this page renders names no finding either", () => { }, }, { code: "" }, + { unclearLines: ["Sxnote"] }, // A whiteboard interview: the board timeline in place of the code block, // and the coding steps under the names the candidate was shown. Replay // keeps no board images, so the timeline says where they are. @@ -554,6 +555,7 @@ test("the report card this page renders names no finding either", () => { problemTitle: "Sxtitle", language: "Sxlang", code: "code" in extra ? extra.code : "Sxcode", + unclearLines: extra.unclearLines, }); rendered.push(markup); for (const word of markup.replace(/<[^>]*>/g, " ").split(/\s+/)) { @@ -714,6 +716,7 @@ test("the report card this page renders names no finding either", () => { "Interviewer", "Judged", "Legacy/unversioned", + "Lines", "NO", "No", "Not", @@ -748,6 +751,7 @@ test("the report card this page renders names no finding either", () => { "an", "and", "are", + "as", "attempt", "back", "back,", @@ -810,6 +814,7 @@ test("the report card this page renders names no finding either", () => { "listed", "lobby", "malformed", + "marked", "mid-level", "min", "more", @@ -862,6 +867,7 @@ test("the report card this page renders names no finding either", () => { "this", "through", "to", + "unclear", "unknown.", "unsupported", "used", @@ -876,6 +882,7 @@ test("the report card this page renders names no finding either", () => { "will", "with", "work", + "you", "your", ]), "a word on the report card is a word somebody chose", diff --git a/web/app.js b/web/app.js index ce01d5d5a..4d2846db1 100644 --- a/web/app.js +++ b/web/app.js @@ -1712,6 +1712,7 @@ function renderAttemptHistory(attempts) { problemTitle: attempt.problemTitle, language: languageLabel(attempt.language ?? "not recorded"), code: "(final code was not saved)", + unclearLines: attempt.unclearLines, }); report.querySelector(".report-actions")?.remove(); report.hidden = true; @@ -1790,6 +1791,7 @@ function downloadSavedReport(attempt) { code: null, transcript: null, at: new Date(attempt.at).toLocaleString(), + unclearLines: attempt.unclearLines, }); downloadMarkdown( markdown, diff --git a/web/captions.js b/web/captions.js index 39d1fd4ca..41b5430a9 100644 --- a/web/captions.js +++ b/web/captions.js @@ -4,12 +4,13 @@ /// Interviewer captions reveal gradually, including final copies. Candidate /// captions retain their bounded window and idle timeout. -import { captionWindow } from "./lib.js"; +import { captionWindow, MAX_UNCLEAR_LINES } from "./lib.js"; let nodes = null; export function initCaptions(deps) { ({ nodes } = deps); + nodes.captionUnclear?.addEventListener("click", toggleUnclear); } const CAPTION_TICK_MS = 300; @@ -32,6 +33,8 @@ let interviewerCaption = { // Each publish opens a separate stream, so replaced turns can arrive late. const retiredInterviewerIds = new Set(); const retiredCandidateIds = new Set(); +// Marks are the candidate's study notes: kept here, never sent to Jim. +const unclearIds = new Set(); let candidateCaption = { id: null, text: "", replaced: false }; export function updateCaptions( @@ -97,6 +100,7 @@ export function updateCaptions( interviewerCaption.timer = null; } const clipped = captionWindow(text, CAPTION_MAX_CHARS); + showUnclearButton(false); nodes.captionsText.textContent = `[${speaker === "you" ? "You" : "Jim"}]: ${clipped}`; showCaptions(); } @@ -112,6 +116,7 @@ export function paceInterviewerCaption() { ? caption.text : captionWindow(caption.text.slice(0, caption.shown), CAPTION_MAX_CHARS); nodes.captionsText.textContent = `[Jim]: ${text}`; + showUnclearButton(true); if (retained && caption.pendingDismissal) { dismissCaptions(); return; @@ -135,6 +140,7 @@ export function showCaptions({ persistent = false } = {}) { if (persistent) return; captionIdleTimer = setTimeout(() => { + releaseUnclearFocus(); nodes.captionsBar.hidden = true; }, CAPTION_IDLE_HIDE_MS); } @@ -159,6 +165,7 @@ export function dismissCaptions() { retireInterviewerCaption(); clearTimeout(captionIdleTimer); captionIdleTimer = null; + releaseUnclearFocus(); if (nodes.captionsBar) nodes.captionsBar.hidden = true; } @@ -177,3 +184,60 @@ function retireCandidateCaption() { retiredCandidateIds.add(candidateCaption.id); } } + +/// The marked interviewer turn ids, for the report to resolve into text. +export function unclearLineIds() { + return [...unclearIds]; +} + +function refreshUnclearButton() { + const button = nodes.captionUnclear; + if (!button) return; + const marked = unclearIds.has(interviewerCaption.id); + const full = !marked && unclearIds.size >= MAX_UNCLEAR_LINES; + button.setAttribute("aria-pressed", String(marked)); + // aria-disabled rather than disabled: disabling the focused button would + // drop keyboard focus to , and toggleUnclear already refuses the cap. + button.setAttribute("aria-disabled", String(full)); + button.title = full ? `Up to ${MAX_UNCLEAR_LINES} lines` : ""; +} + +function toggleUnclear(event) { + const id = interviewerCaption.id; + if (id == null) return; + if (unclearIds.has(id)) { + unclearIds.delete(id); + } else if (unclearIds.size < MAX_UNCLEAR_LINES) { + unclearIds.add(id); + } + refreshUnclearButton(); + // A click leaves focus on the button, where the candidate's next keystrokes + // would land and the first space would undo the mark. Space or Enter + // (detail 0) means they came here by keyboard, so focus stays. + if (event?.detail > 0) focusWorkspace(); +} + +function showUnclearButton(visible) { + if (!nodes.captionUnclear) return; + if (!visible) releaseUnclearFocus(); + nodes.captionUnclear.hidden = !visible; + if (visible) refreshUnclearButton(); +} +/// Hiding the focused button, or the bar that holds it, would drop keyboard +/// focus to the page body, so it moves to the workspace first. +function releaseUnclearFocus() { + const button = nodes.captionUnclear; + if (button?.ownerDocument?.activeElement !== button) return; + focusWorkspace(); +} + +/// The surface in use: the editor unless a whiteboard removed it or a pause or +/// the behavioral round disabled it, then the board, then Jim's stage. +function focusWorkspace() { + const document = nodes.captionUnclear?.ownerDocument; + for (const target of [nodes.editor, nodes.board, nodes.jimStage]) { + if (!target?.isConnected || target.disabled) continue; + target.focus(); + if (document?.activeElement === target) return; + } +} diff --git a/web/interview.html b/web/interview.html index 43183a8cf..125d685b0 100644 --- a/web/interview.html +++ b/web/interview.html @@ -932,14 +932,25 @@

Alt+Enter (⌥Enter on a Mac).

-
- Live CC: - Listening... +
+
+ Live CC: + +
+ Listening...
diff --git a/web/interview.js b/web/interview.js index 38519f215..271b433c1 100644 --- a/web/interview.js +++ b/web/interview.js @@ -96,7 +96,9 @@ import { isYieldShortcut, turnCountdown, turnWindowMs, + unclearLinesOf, uncapturedBoardPhases, + withUnclearLines, } from "./lib.js"; import { attachAvatarAnalyser, @@ -143,6 +145,7 @@ import { dismissCaptions, initCaptions, showCaptions, + unclearLineIds, updateCaptions, } from "./captions.js"; import { @@ -541,6 +544,7 @@ const nodes = { audioPlaybackStatus: document.querySelector("#audio-playback-status"), captionsBar: document.querySelector("#captions-bar"), captionsText: document.querySelector("#captions-text"), + captionUnclear: document.querySelector("#caption-unclear"), timer: document.querySelector("#timer"), frameworkProgress: document.querySelector("#framework-progress"), frameworkHint: document.querySelector("#framework-hint"), @@ -3869,6 +3873,7 @@ function renderReport() { exampleDrawing: exampleBoard?.image(), boardPhases: [...board.snapshots], saveResult: null, + unclearLines: unclearLines(), }); mountBehavioralReview(nodes.report, state.transcript.values()); } @@ -3885,6 +3890,18 @@ function renderReportSaveStatus(result) { // whether the current report is safe to leave behind. let saveGeneration = 0; +/// Resolved to text at save time: saved history keeps no transcript, so a +/// turn id would point at nothing once the report is reopened. +function unclearLines() { + const marked = new Set(unclearLineIds()); + return unclearLinesOf( + state.transcript + .values() + .filter((row) => marked.has(row.id) && row.text) + .map((row) => row.text), + ); +} + async function saveHistory(report = state.report) { // The interview id travels with the report so the replay page can put the // two beside each other. Reports are keyed by their own id and recordings by @@ -3899,7 +3916,7 @@ async function saveHistory(report = state.report) { // No language at a whiteboard: nothing is compiled, the tabs are not on // screen, and `state.language` is the default nobody chose. Recorded as one, // it would filter the candidate's own history by a choice they never made. - const entry = { + const base = { id: state.reportId, date: new Date().toISOString(), interviewId: state.interviewId, @@ -3912,6 +3929,7 @@ async function saveHistory(report = state.report) { interviewMode: mode, report, }; + const entry = withUnclearLines(base, unclearLines()); // A later save may replace an earlier report under this same id. Keep the // warning active until this save itself confirms that Past attempts can // reopen the current copy. @@ -3943,6 +3961,8 @@ function buildMarkdown(at) { transcript: state.transcript.values(), at: at.toLocaleString(), testHistory: state.testHistory, + unclearLines: unclearLines(), + unclearIds: unclearLineIds(), }); } diff --git a/web/lib.js b/web/lib.js index 49c1b5c81..cc26563ee 100644 --- a/web/lib.js +++ b/web/lib.js @@ -1121,6 +1121,53 @@ function reportInterviewerStyle(value) { return text && text !== "Random" ? text : undefined; } +export const MAX_UNCLEAR_LINES = 10; +// UTF-8 bytes, the unit the history budget is measured in. Above the longest +// interviewer turn measured (548 bytes), so a typical turn is kept whole; a +// longer one is cut with an ellipsis that says so, in any script. +const MAX_UNCLEAR_LINE_BYTES = 600; +const UNCLEAR_LINE_ELLIPSIS = "\u2026"; +// MAX_REPORT_BYTES in src/web/mod.rs: the server refuses a larger POST body. +export const MAX_REPORT_BODY_BYTES = 64 * 1024; + +/// Saved history is input an older build, devtools or the server can return, +/// so marks are kept only as the array of strings the interview page saves, +/// bounded like the report text beside them. +export function unclearLinesOf(value) { + return Array.isArray(value) && value.every((line) => typeof line === "string") + ? value.slice(0, MAX_UNCLEAR_LINES).map(clipUnclearLine) + : []; +} + +function clipUnclearLine(line) { + const text = boundedText(line, Infinity); + if (textEncoder.encode(text).length <= MAX_UNCLEAR_LINE_BYTES) return text; + const budget = + MAX_UNCLEAR_LINE_BYTES - textEncoder.encode(UNCLEAR_LINE_ELLIPSIS).length; + let kept = ""; + let size = 0; + for (const char of text) { + size += textEncoder.encode(char).length; + if (size > budget) break; + kept += char; + } + return `${kept}${UNCLEAR_LINE_ELLIPSIS}`; +} + +/// The history entry with as many marks as fit the report body limit. +/// +/// The marks share the POST body with the report, so marks that would push it +/// past the limit are dropped from the end, and the report is saved without +/// them rather than refused with them. +export function withUnclearLines(entry, lines) { + for (let kept = lines.length; kept > 0; kept--) { + const marked = { ...entry, unclearLines: lines.slice(0, kept) }; + const size = textEncoder.encode(JSON.stringify(marked)).length; + if (size <= MAX_REPORT_BODY_BYTES) return marked; + } + return entry; +} + export function sanitizeReport(raw) { const { interviewContract, unsupported: unsupportedContract } = reportContract(raw); diff --git a/web/progress.js b/web/progress.js index 6b53d55a4..4314e5fc6 100644 --- a/web/progress.js +++ b/web/progress.js @@ -4,6 +4,7 @@ import { interviewMode, frameworkPhases, sanitizeReport, + unclearLinesOf, } from "./lib.js"; import { LEVELS } from "./problem-picker.js"; @@ -101,6 +102,7 @@ export function normalizeProgressEntry(raw) { ? entry.report.decision : null, report, + unclearLines: unclearLinesOf(entry.unclearLines), }; } diff --git a/web/render.js b/web/render.js index f4ee3632c..100445004 100644 --- a/web/render.js +++ b/web/render.js @@ -291,6 +291,7 @@ export function reportMarkup({ boardPhases, exampleDrawing, saveResult, + unclearLines = [], }) { const atBoard = heldAtBoard(report, board); // At a board the coding score is for the drawing, the trace and the cases @@ -332,6 +333,11 @@ export function reportMarkup({ `, ) .join("")}`; + const unclear = unclearLines.length + ? `

Lines you marked as unclear

    ${unclearLines + .map((line) => `
  1. ${escapeHtml(line)}
  2. `) + .join("")}
` + : ""; const debrief = report.debrief ? `
What the interviewer held back

Spaced review will bring this problem back, so your next attempt tests recall.

@@ -393,6 +399,7 @@ export function reportMarkup({ ${atBoard ? boardTimelineMarkup(report, board, boardPhases) : ""} ${feedback} ${practiceNext} + ${unclear} ${debrief} ${rounds}${executionNote} ${phaseScores} @@ -497,6 +504,8 @@ export function reportMarkdown({ transcript, at, testHistory = [], + unclearLines = [], + unclearIds = [], }) { const atBoard = heldAtBoard(report, board); const workTitle = atBoard ? "Board work" : "Coding"; @@ -572,6 +581,16 @@ export function reportMarkdown({ ]), "", ]; + const unclear = unclearLines.length + ? [ + "## Lines you marked as unclear", + "", + ...unclearLines.map( + (line, index) => `${index + 1}. Jim: "${mdText(line)}"`, + ), + "", + ] + : []; const debrief = report.debrief ? [ "## What the interviewer held back", @@ -648,15 +667,22 @@ export function reportMarkdown({ const chainNote = mdText(chainSentence(report)); // Missing storage is not evidence of silence: only a saved, empty transcript // can say that no speech was captured. Omitted fields have the same meaning. + const marked = new Set(unclearIds); const conversation = transcript == null ? "(transcript was not saved)" : transcript .filter((segment) => segment.final || segment.text.trim()) - .map( - (segment) => - `**${segment.speaker === "interviewer" ? "Jim" : "You"}:** ${mdText(segment.text.trim())}`, - ) + .map((segment) => { + const text = segment.text.trim(); + const who = + segment.speaker !== "interviewer" + ? "You" + : marked.has(segment.id) + ? "Jim (marked unclear)" + : "Jim"; + return `**${who}:** ${mdText(text)}`; + }) .join("\n\n"); // Candidate code is the one field here that must not be escaped, so when it // contains a run of backticks the fence is what has to give. Without this, a @@ -793,6 +819,7 @@ export function reportMarkdown({ "", ...phaseScores, ...practiceNext, + ...unclear, ...debrief, ...frameworkTimeline, // A session with no evaluation can still be one that ended because the @@ -921,7 +948,7 @@ export function createTranscriptView(document, panel) { upsert(id, speaker, text, final, at = Date.now()) { let row = rowOf.get(id); if (!row) { - row = { speaker, text: "", final, at, body: null }; + row = { id, speaker, text: "", final, at, body: null }; rows.push(row); rowOf.set(id, row); } diff --git a/web/replay.js b/web/replay.js index d9cfae14f..2ac023e17 100644 --- a/web/replay.js +++ b/web/replay.js @@ -17,6 +17,7 @@ import { replayTimeline, responseWindowLabel, sanitizeReport, + unclearLinesOf, whiteboardPhaseLabel, } from "/lib.js"; import { @@ -511,6 +512,7 @@ async function loadReport(interviewId, recordingId) { nodes.report.innerHTML = reportMarkup({ report, problemTitle: saved.problemTitle || "", + unclearLines: unclearLinesOf(saved.unclearLines), ...final, }); } diff --git a/web/styles.css b/web/styles.css index 12b191c7d..27d955e8e 100644 --- a/web/styles.css +++ b/web/styles.css @@ -105,6 +105,25 @@ margin-right: 0.375rem; } +.captions-head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.caption-unclear { + font-size: 0.75rem; + padding: 0.125rem 0.5rem; +} +.caption-unclear[aria-pressed="true"] { + background: var(--accent); + color: var(--page); +} +.caption-unclear[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; +} + html, body { min-height: 100%;