From dad7b6ef63db118294194fde371ebf3d1270ebd0 Mon Sep 17 00:00:00 2001 From: April-ying~ Date: Thu, 22 Oct 2026 17:39:47 +0800 Subject: [PATCH] Let the candidate mark unclear interviewer lines Non-native candidates often miss a word or two of what Jim says and answer anyway, and nothing let them note the line to review later. An Unclear button beside the live caption marks the Jim turn on screen. Marks never reach Jim or the report request: they are resolved to transcript text when the report is saved and stored as unclearLines in the history entry, since saved history keeps no transcript, while the live export tags turns by id so a repeated line is not tagged twice. The cap of 10 keeps a marked interview within one average report of the 164 KB local history budget, and marks are dropped before they would push the 64 KB report body past the server limit, so they never cost the report. Past attempts and replay accept the field only as an array of strings, so older or hand-edited entries render as before. --- docs/avatar-contract.md | 10 ++ tests/browser/captions.test.js | 193 ++++++++++++++++++++++ tests/browser/interview-lifecycle.test.js | 13 ++ tests/browser/lib.test.js | 50 ++++++ tests/browser/progress.test.js | 34 ++++ tests/browser/render.test.js | 84 ++++++++++ tests/browser/replay-render.test.js | 7 + web/app.js | 2 + web/captions.js | 66 +++++++- web/interview.html | 27 ++- web/interview.js | 22 ++- web/lib.js | 47 ++++++ web/progress.js | 2 + web/render.js | 37 ++++- web/replay.js | 2 + web/styles.css | 19 +++ 16 files changed, 600 insertions(+), 15 deletions(-) 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%;