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 @@