const workspace = document.querySelector("#workspace"); const status = document.querySelector("#status"); const cohorts = document.querySelector("#cohorts"); const diff = document.querySelector("#diff"); const allChanges = document.querySelector("#all-changes"); const notes = document.querySelector("#notes"); const questionInput = document.querySelector("#question"); const commentDialog = document.querySelector("#comment-dialog"); const commentForm = document.querySelector("#comment-form"); const askForm = document.querySelector("#ask-form"); const narrowLayout = window.matchMedia("(max-width: 900px)"); const keyboardHelp = document.querySelector("#keyboard-help"); const keyboardGuide = document.querySelector("#keyboard-guide"); const keyboardSearch = document.querySelector("#keyboard-search"); const NOTE_DEBOUNCE_MS = 300; const EXPLICIT_JUMP_MS = 500; const COMMANDS = [ { group: "Navigation", keys: "j / k", label: "Next / previous line; scroll side pane", }, { group: "Navigation", keys: "h / l", label: "Scroll focused pane horizontally", }, { group: "Navigation", keys: "Ctrl+d / Ctrl+u", label: "Scroll half a page" }, { group: "Navigation", keys: "gg / G", label: "First / last line or pane edge", prefix: "g", continuation: "g", short: "first line or pane top", }, { group: "Navigation", keys: "]c / [c", label: "Next / previous hunk", prefix: "]", continuation: "c", short: "next hunk", }, { group: "Navigation", keys: "]l / [l", label: "Next / previous layer", prefix: "]", continuation: "l", short: "next layer", }, { group: "Navigation", keys: "]u / [u", label: "Next / previous unreviewed hunk", prefix: "]", continuation: "u", short: "next unreviewed hunk", }, { group: "Navigation", keys: "/ · n / N", label: "Search · next / previous match", }, { group: "Workspace", keys: "gd / ga / gf", label: "Details / Ask / Feedback", prefix: "g", continuation: "d / a / f", short: "open Details / Ask / Feedback", }, { group: "Review", keys: "Space r", label: "Toggle focused hunk reviewed", prefix: " ", continuation: "r", short: "toggle hunk reviewed", }, { group: "Review", keys: "Space R", label: "Toggle selected layer reviewed", prefix: " ", continuation: "R", short: "toggle whole layer", }, { group: "Writing", keys: "Space c", label: "Comment on focused line", prefix: " ", continuation: "c", short: "comment on focused line", }, { group: "Writing", keys: "Space a", label: "Compose a question", prefix: " ", continuation: "a", short: "compose question", }, { group: "Writing", keys: "Space n", label: "Edit review notes", prefix: " ", continuation: "n", short: "edit notes", }, { group: "Workspace", keys: "Space h / Space l", label: "Previous / next visible pane", prefix: " ", continuation: "h / l", short: "focus previous / next pane", }, { group: "Workspace", keys: "Space b / Space d", label: "Toggle layer sidebar / drawer", prefix: " ", continuation: "b / d", short: "toggle sidebar / drawer", }, { group: "Workspace", keys: "Space s", label: "Confirm feedback submission", prefix: " ", continuation: "s", short: "send feedback…", }, { group: "Workspace", keys: "Space f", label: "Confirm snapshot refresh", prefix: " ", continuation: "f", short: "refresh snapshot…", }, { group: "Workspace", keys: "Space q", label: "Confirm review closure", prefix: " ", continuation: "q", short: "close review…", }, { group: "Help", keys: "? / Escape", label: "Open help / dismiss transient UI", }, ]; const READINESS_RETRIES = 12; let review; let threads = []; let askDrafts = {}; let revision = 0; let snapshotId = ""; let selectedLayerId = null; let currentHunkId = null; let commentTarget = null; let observer; let busy = false; let askInFlight = false; let pendingAsk = null; let staleClient = false; let noteTimer; let notesPending = false; let saveChain = Promise.resolve(); let draftGeneration = 0; let layersOpen = !narrowLayout.matches; let drawerOpen = !narrowLayout.matches; let keyboardPane = "diff"; let currentLineKey = null; let pendingPrefix = null; let searchQuery = ""; let suppressObserverUntil = 0; let explicitJumpLocked = false; let renderedLines = []; let currentLineRow = null; let currentLineIndex = -1; let renderedLineByKey = new Map(); let renderedLineIndexes = new WeakMap(); let renderedHunks = new Map(); let renderedHunkLines = new Map(); let forgeCommentStates = new Map(); let forgeInlineByAnchor = new Map(); const askErrors = new Map(); const byId = (id) => document.querySelector(`#${id}`); function node(tag, className, text) { const value = document.createElement(tag); if (className) value.className = className; if (text !== undefined) value.textContent = text; return value; } function forgeStorageScope() { const forge = review?.forge; return forge ? `${forge.provider}:${forge.url}:` : ""; } function storageKey() { return `strata:draft:${location.origin}:${forgeStorageScope()}${review?.snapshot.id ?? snapshotId}`; } function askStorageKey() { return `strata:questions:${location.origin}:${forgeStorageScope()}${review?.snapshot.id ?? snapshotId}`; } function persistDraft(draft) { try { sessionStorage.setItem(storageKey(), JSON.stringify(draft)); } catch {} } function persistAskDrafts() { try { sessionStorage.setItem(askStorageKey(), JSON.stringify(askDrafts)); } catch {} } function clearStoredDraft() { try { sessionStorage.removeItem(storageKey()); } catch {} } function clearStoredAskDrafts() { try { sessionStorage.removeItem(askStorageKey()); } catch {} } function storedDraft() { try { const value = sessionStorage.getItem(storageKey()); return value ? JSON.parse(value) : undefined; } catch { return undefined; } } function restoreAskDrafts() { try { const value = sessionStorage.getItem(askStorageKey()); const parsed = value ? JSON.parse(value) : undefined; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return; askDrafts = Object.fromEntries( Object.entries(parsed).filter( ([key, draft]) => typeof key === "string" && typeof draft === "string" && draft.length <= 4000, ), ); } catch {} } function applyVersion(payload) { if (Number.isSafeInteger(payload.revision) && payload.revision >= 0) { revision = payload.revision; } if (typeof payload.snapshotId === "string" && payload.snapshotId) { snapshotId = payload.snapshotId; } } function applyThreads(payload) { if (Array.isArray(payload.threads)) threads = payload.threads; } async function request(path, body) { const isPost = body !== undefined; const headers = isPost ? { "Content-Type": "application/json" } : {}; if (isPost && path !== "/api/close") { headers["If-Match"] = String(revision); headers["X-Strata-Snapshot"] = snapshotId; } const response = await fetch(path, { method: isPost ? "POST" : "GET", headers, body: isPost ? JSON.stringify(body) : undefined, }); let payload; try { payload = await response.json(); } catch { throw new Error(`Request failed (${response.status})`); } applyVersion(payload); if (!response.ok) { const error = new Error( payload.error || `Request failed (${response.status})`, ); error.code = payload.code; throw error; } applyThreads(payload); return payload; } function setStatus(message, error = false) { status.textContent = message; status.classList.toggle("error", error); } function reportError(error) { if (error?.code === "stale_client") { staleClient = true; persistDraft(currentDraft()); setStatus( "Review changed in another tab. Reload to keep these notes.", true, ); return; } setStatus(error instanceof Error ? error.message : "Request failed", true); } function isNavigationControl(control) { return ( control.id === "close-button" || control.id === "keyboard-help-button" || control.id === "keyboard-help-close" || control.id === "layers-toggle" || control.id === "drawer-toggle" || control.id === "all-changes" || control.hasAttribute("data-layer-id") || control.hasAttribute("data-drawer-tab") ); } function setBusy(nextBusy, message) { busy = nextBusy; if (nextBusy) cancelPendingPrefix(); workspace.setAttribute("aria-busy", String(nextBusy)); for (const control of document.querySelectorAll( "button, textarea, select, input", )) { if (!isNavigationControl(control)) control.disabled = nextBusy; } updateDetails(); updateAskControls(); updateLayerAction(); updateKeyboardStatus(); if (message) setStatus(message); } function sourceLabel() { const source = review.snapshot.source; const shortId = review.snapshot.id.slice(0, 12); if (review.forge) return `${review.forge.providerLabel} #${review.forge.number} · ${shortId}`; if (source.kind === "base") return `base ${source.ref} · ${shortId}`; return `${source.kind} · ${shortId}`; } function safeHttpsUrl(value) { if (typeof value !== "string") return null; try { const url = new URL(value); if (url.protocol !== "https:" || url.username || url.password) return null; return url.href; } catch { return null; } } function setSafeLink(link, value) { const href = safeHttpsUrl(value); link.hidden = href === null; if (href) link.href = href; else link.removeAttribute("href"); return href !== null; } function forgeAnchorKey(hunkId, side, line) { return `${hunkId}\u0000${side}\u0000${line}`; } function forgeCheckoutMatches() { return ( review?.forge && review.snapshot.source.kind === "base" && review.snapshot.source.ref === review.forge.baseSha && review.snapshot.head === review.forge.headSha ); } function indexForgeComments() { forgeCommentStates = new Map(); forgeInlineByAnchor = new Map(); const forge = review.forge; if (!forge) return; const anchors = new Map(); for (const hunk of review.snapshot.hunks) { for (const line of hunk.lines) { if (line.oldLine !== undefined) { anchors.set( `${hunk.oldPath ?? hunk.path}\u0000old\u0000${line.oldLine}`, forgeAnchorKey(hunk.id, "old", line.oldLine), ); } if (line.newLine !== undefined) { anchors.set( `${hunk.path}\u0000new\u0000${line.newLine}`, forgeAnchorKey(hunk.id, "new", line.newLine), ); } } } for (const comment of forge.comments) { const anchor = comment.kind === "inline" && comment.path && comment.side && comment.line !== undefined ? anchors.get( `${comment.path}\u0000${comment.side}\u0000${comment.line}`, ) : undefined; const outdated = comment.kind === "inline" && (comment.outdated || !forgeCheckoutMatches()); const state = { anchor, outdated, current: Boolean(anchor) && !outdated }; forgeCommentStates.set(comment, state); if (state.current) { const comments = forgeInlineByAnchor.get(anchor) ?? []; comments.push(comment); forgeInlineByAnchor.set(anchor, comments); } } } function appendForgeSource(container, forge) { container.replaceChildren(); if (forge.provider.toLocaleLowerCase() === "github") { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 16 16"); svg.setAttribute("aria-hidden", "true"); svg.classList.add("forge-source-icon"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute( "d", "M8 0C3.58 0 0 3.64 0 8.13c0 3.59 2.29 6.64 5.47 7.71.4.08.55-.18.55-.39 0-.19-.01-.83-.01-1.5-2.01.38-2.53-.5-2.69-.96-.09-.23-.48-.96-.82-1.15-.28-.15-.68-.53-.01-.54.63-.01 1.08.59 1.23.83.72 1.23 1.87.88 2.33.67.07-.53.28-.88.51-1.08-1.78-.21-3.64-.91-3.64-4.02 0-.89.31-1.62.82-2.19-.08-.2-.36-1.04.08-2.16 0 0 .67-.22 2.2.84A7.5 7.5 0 0 1 8 3.92a7.5 7.5 0 0 1 2 .27c1.53-1.06 2.2-.84 2.2-.84.44 1.12.16 1.96.08 2.16.51.57.82 1.3.82 2.19 0 3.12-1.87 3.81-3.65 4.02.29.25.54.74.54 1.51 0 1.09-.01 1.97-.01 2.24 0 .21.15.47.55.39A8.14 8.14 0 0 0 16 8.13C16 3.64 12.42 0 8 0Z", ); svg.append(path); container.append(svg); } container.append(node("span", "", forge.providerLabel)); } function layers() { return review.plan.cohorts.flatMap((cohort) => cohort.layers); } function layerById(id) { return layers().find((layer) => layer.id === id); } function layerForHunk(hunkId) { return layers().find((layer) => layer.hunks.some((hunk) => hunk.id === hunkId), ); } function hunkById(id) { return review.snapshot.hunks.find((hunk) => hunk.id === id); } function visibleHunks() { if (!selectedLayerId) return review.snapshot.hunks; return (layerById(selectedLayerId)?.hunks ?? []) .map((reference) => hunkById(reference.id)) .filter(Boolean); } function scopeKey(layerId = selectedLayerId) { return JSON.stringify(layerId); } function scopeTitle(layerId = selectedLayerId) { return layerId ? (layerById(layerId)?.title ?? layerId) : "All changes"; } function threadForScope(layerId = selectedLayerId) { return threads.find((thread) => thread.layerId === layerId); } function currentDraft() { return { reviewed: [...review.draft.reviewed], findings: review.draft.findings.map((finding) => ({ ...finding })), notes: notes.value, }; } function paneElement(name = keyboardPane) { return document.querySelector(`[data-keyboard-pane="${name}"]`); } function visiblePaneNames() { return ["layers", "diff", "drawer"].filter((name) => { const pane = paneElement(name); return pane && !pane.hidden; }); } function setKeyboardPane(name, focus = false) { if (!visiblePaneNames().includes(name)) name = "diff"; keyboardPane = name; for (const pane of document.querySelectorAll("[data-keyboard-pane]")) { pane.classList.toggle( "keyboard-focused", pane.dataset.keyboardPane === name, ); } if (focus) paneElement()?.focus({ preventScroll: true }); updateKeyboardStatus(); } function anchorableLines() { return renderedLines; } function lineKey(row) { return row ? `${row.dataset.hunkId}:${row.dataset.side}:${row.dataset.line}` : null; } function currentLine() { if (currentLineRow?.isConnected) return currentLineRow; currentLineRow = renderedLineByKey.get(currentLineKey) ?? null; currentLineIndex = currentLineRow ? (renderedLineIndexes.get(currentLineRow) ?? -1) : -1; return currentLineRow; } function lineDescription(row = currentLine()) { if (!row) return "no lines"; const hunk = hunkById(row.dataset.hunkId); return `${hunk?.path ?? row.dataset.hunkId} · line ${row.dataset.line}`; } function updateKeyboardStatus() { const editing = isEditable(document.activeElement); byId("keyboard-mode").textContent = busy ? "BUSY" : editing ? "TEXT" : "NORMAL"; byId("keyboard-cursor").textContent = editing ? `Editing · ${document.activeElement?.getAttribute("aria-label") || document.activeElement?.id || "text"}` : `${keyboardPane[0].toUpperCase()}${keyboardPane.slice(1)} · ${lineDescription()}`; byId("keyboard-context").hidden = editing; byId("keyboard-return-hint").hidden = !editing; } function beginExplicitJump() { suppressObserverUntil = performance.now() + EXPLICIT_JUMP_MS; explicitJumpLocked = true; } function clearCurrentLine() { currentLineRow?.classList.remove("keyboard-current"); currentLineKey = null; currentLineRow = null; currentLineIndex = -1; } function setCurrentLine(row, scroll = false) { if (!row?.matches(".diff-line[data-line]")) return; if (scroll) beginExplicitJump(); if (currentLineRow !== row) { currentLineRow?.classList.remove("keyboard-current"); row.classList.add("keyboard-current"); } currentLineRow = row; currentLineKey = lineKey(row); currentLineIndex = renderedLineIndexes.get(row) ?? -1; selectCurrentHunk(row.dataset.hunkId, false, false); setKeyboardPane("diff"); if (scroll) { row.scrollIntoView({ block: "center", inline: "nearest", behavior: "instant", }); row.focus({ preventScroll: true }); } updateKeyboardStatus(); } function ensureCurrentLine(preferredHunkId) { const preferredLines = renderedHunkLines.get(preferredHunkId) ?? []; const retained = renderedLineByKey.get(currentLineKey); if (retained?.dataset.hunkId === preferredHunkId) { setCurrentLine(retained, false); return; } if (preferredHunkId && renderedHunks.has(preferredHunkId)) { if (preferredLines.length > 0) setCurrentLine(preferredLines[0], false); else { clearCurrentLine(); updateKeyboardStatus(); } return; } if (renderedLines.length > 0) setCurrentLine(renderedLines[0], false); else { clearCurrentLine(); updateKeyboardStatus(); } } function isEditable(target) { return ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || target?.isContentEditable ); } function renderKeyboardHelp() { const content = byId("keyboard-help-content"); content.replaceChildren(); for (const groupName of [ ...new Set(COMMANDS.map((command) => command.group)), ]) { const group = node("section", "keyboard-help-group"); group.append(node("h3", "", groupName)); for (const command of COMMANDS.filter( (candidate) => candidate.group === groupName, )) { const row = node("div", "keyboard-help-row"); row.append(node("span", "", command.label)); const keys = node("span", "keyboard-help-keys"); const keyLabels = command.keys.split(/\s+\/\s+|\s*·\s*/); for (const key of keyLabels) keys.append(node("kbd", "", key)); row.append(keys); group.append(row); } content.append(group); } } function showPendingPrefix(prefix) { pendingPrefix = prefix; keyboardGuide.replaceChildren(); const heading = node( "strong", "keyboard-guide-heading", prefix === " " ? "Space then…" : `${prefix} then…`, ); keyboardGuide.append(heading); const commandPrefix = prefix === "[" ? "]" : prefix; const commands = COMMANDS.filter( (command) => command.prefix === commandPrefix, ); for (const groupName of [ ...new Set(commands.map((command) => command.group)), ]) { const group = node("div", "keyboard-guide-group"); group.append(node("span", "keyboard-guide-group-name", groupName)); for (const command of commands.filter( (candidate) => candidate.group === groupName, )) { const item = node("span", "keyboard-guide-command"); const description = prefix === "[" ? command.short.replace(/^next\b/, "previous") : command.short; item.append(node("kbd", "", command.continuation), ` ${description}`); group.append(item); } keyboardGuide.append(group); } keyboardGuide.append(node("span", "muted", "Esc cancels")); keyboardGuide.hidden = false; updateKeyboardStatus(); } function cancelPendingPrefix() { pendingPrefix = null; keyboardGuide.hidden = true; keyboardGuide.replaceChildren(); } function showHelp() { cancelPendingPrefix(); if (!keyboardHelp.open) keyboardHelp.showModal(); } function openSearch() { cancelPendingPrefix(); keyboardSearch.hidden = false; keyboardSearch.value = searchQuery; keyboardSearch.focus(); keyboardSearch.select(); updateKeyboardStatus(); } function closeSearch(focusDiff = true) { searchQuery = keyboardSearch.value; keyboardSearch.hidden = true; if (focusDiff) currentLine()?.focus({ preventScroll: true }); setKeyboardPane("diff"); } function searchLines(direction = 1) { searchQuery = keyboardSearch.value || searchQuery; if (!searchQuery) return; const query = searchQuery.toLocaleLowerCase(); const matches = anchorableLines().filter((row) => row .querySelector(".line-code") ?.textContent.toLocaleLowerCase() .includes(query), ); if (matches.length === 0) { setStatus(`No matches for “${searchQuery}” in visible diff lines`, true); return; } const current = matches.indexOf(currentLine()); const next = current < 0 ? direction > 0 ? 0 : matches.length - 1 : (current + direction + matches.length) % matches.length; setCurrentLine(matches[next], true); setStatus(`${next + 1} / ${matches.length} matches for “${searchQuery}”`); } function queueDraftSave(draft) { const copy = structuredClone(draft); const generation = ++draftGeneration; review.draft = copy; persistDraft(copy); if (staleClient) { setStatus( "Review changed in another tab. Reload to keep these notes.", true, ); return Promise.resolve(); } const pending = saveChain.then(async () => { try { const payload = await request("/api/draft", copy); if (generation === draftGeneration) { review.draft = payload.draft; persistDraft(payload.draft); updateProgress(); setStatus("Draft saved"); } return payload.draft; } catch (error) { if (error && typeof error === "object") { error.draftGeneration = generation; } throw error; } }); saveChain = pending.catch(reportError); return pending; } function scheduleNotesSave() { notesPending = true; if (noteTimer) clearTimeout(noteTimer); noteTimer = setTimeout(() => { noteTimer = undefined; notesPending = false; queueDraftSave(currentDraft()).catch(() => {}); }, NOTE_DEBOUNCE_MS); } async function flushDraft() { if (noteTimer) clearTimeout(noteTimer); noteTimer = undefined; if (notesPending) { notesPending = false; await queueDraftSave(currentDraft()); } await saveChain; if (staleClient) { throw new Error( "Review changed in another tab. Reload to keep these notes.", ); } } function applyPanelState() { const layerNav = byId("layer-nav"); const detailRegion = byId("detail-region"); layerNav.hidden = !layersOpen; detailRegion.hidden = !drawerOpen; workspace.classList.toggle("layers-collapsed", !layersOpen); workspace.classList.toggle("drawer-collapsed", !drawerOpen); byId("layers-toggle").setAttribute("aria-expanded", String(layersOpen)); byId("layers-toggle").setAttribute( "aria-label", layersOpen ? "Hide review layers" : "Show review layers", ); byId("drawer-toggle").setAttribute("aria-expanded", String(drawerOpen)); byId("drawer-toggle").setAttribute( "aria-label", drawerOpen ? "Hide review drawer" : "Show review drawer", ); if (!visiblePaneNames().includes(keyboardPane)) setKeyboardPane("diff"); else setKeyboardPane(keyboardPane); if (review) { observer?.disconnect(); observeHunks(); } } function selectDrawerTab(name, focus = false) { drawerOpen = true; for (const tab of document.querySelectorAll("[data-drawer-tab]")) { const selected = tab.dataset.drawerTab === name; tab.setAttribute("aria-selected", String(selected)); tab.tabIndex = selected ? 0 : -1; if (selected && focus) tab.focus(); } for (const panel of document.querySelectorAll(".drawer-panel")) { panel.hidden = panel.id !== `${name}-panel`; } applyPanelState(); if (name === "ask") renderAsk(); } function renderNavigation() { cohorts.replaceChildren(); allChanges.classList.toggle("selected", selectedLayerId === null); allChanges.setAttribute( "aria-current", selectedLayerId === null ? "page" : "false", ); allChanges.setAttribute("aria-label", "All changes"); byId("all-count").textContent = String(review.snapshot.hunks.length); for (const cohort of review.plan.cohorts) { const group = node("section", "cohort"); const heading = node("h2", "cohort-heading", cohort.title); group.append(heading); for (const layer of cohort.layers) { const button = node("button", "layer-button"); button.type = "button"; button.dataset.layerId = layer.id; button.classList.toggle("selected", selectedLayerId === layer.id); button.setAttribute( "aria-current", selectedLayerId === layer.id ? "page" : "false", ); button.setAttribute("aria-label", layer.title); button.append(node("span", "", layer.title)); button.append(node("span", "count", String(layer.hunks.length))); button.addEventListener("click", () => selectLayer(layer.id)); group.append(button); } cohorts.append(group); } const omitted = byId("omitted-list"); omitted.replaceChildren(); if (review.snapshot.skipped.length === 0) { omitted.append(node("li", "muted", "None")); } else { for (const item of review.snapshot.skipped) { omitted.append(node("li", "", `${item.path}: ${item.reason}`)); } } } function updateLayerAction() { if (!review) return; const button = byId("mark-layer-reviewed"); const layer = selectedLayerId ? layerById(selectedLayerId) : undefined; const ids = layer?.hunks.map((hunk) => hunk.id) ?? []; const allReviewed = ids.length > 0 && ids.every((id) => review.draft.reviewed.includes(id)); button.disabled = busy || staleClient || !layer || ids.length === 0; button.setAttribute("aria-pressed", String(allReviewed)); button.setAttribute( "aria-label", allReviewed ? "Unmark layer reviewed" : "Mark layer reviewed", ); button.querySelector(".action-label").textContent = allReviewed ? "Unmark layer reviewed" : "Mark layer reviewed"; } function updateHunkReviewButton(hunkId) { const button = renderedHunks.get(hunkId)?.querySelector(".mark-reviewed"); if (!button) return; const reviewed = review.draft.reviewed.includes(hunkId); const label = reviewed ? "Reviewed ✓" : "Mark reviewed"; button.classList.toggle("done", reviewed); button.setAttribute("aria-pressed", String(reviewed)); button.setAttribute("aria-label", label); button.firstChild.textContent = label; } function updateReviewControls(hunkIds) { for (const hunkId of hunkIds) updateHunkReviewButton(hunkId); updateLayerAction(); updateProgress(); } function toggleHunkReviewed(hunkId) { if (busy || staleClient || !hunkById(hunkId)) return; const draft = currentDraft(); const reviewed = new Set(draft.reviewed); const wasReviewed = reviewed.has(hunkId); if (wasReviewed) reviewed.delete(hunkId); else reviewed.add(hunkId); draft.reviewed = [...reviewed]; const save = queueDraftSave(draft); const generation = draftGeneration; save.catch((error) => { if (error?.draftGeneration !== generation || generation !== draftGeneration) return; const restored = currentDraft(); const current = new Set(restored.reviewed); if (wasReviewed) current.add(hunkId); else current.delete(hunkId); restored.reviewed = [...current]; review.draft = restored; persistDraft(restored); updateReviewControls([hunkId]); }); updateReviewControls([hunkId]); } function toggleLayerReviewed() { if (busy || staleClient || !selectedLayerId) return; const layer = layerById(selectedLayerId); if (!layer) return; const ids = layer.hunks.map((hunk) => hunk.id); const draft = currentDraft(); const reviewed = new Set(draft.reviewed); const previous = new Map(ids.map((id) => [id, reviewed.has(id)])); const remove = ids.every((id) => reviewed.has(id)); for (const id of ids) { if (remove) reviewed.delete(id); else reviewed.add(id); } draft.reviewed = [...reviewed]; const save = queueDraftSave(draft); const generation = draftGeneration; save.catch((error) => { if (error?.draftGeneration !== generation || generation !== draftGeneration) return; const restored = currentDraft(); const current = new Set(restored.reviewed); for (const [id, wasReviewed] of previous) { if (wasReviewed) current.add(id); else current.delete(id); } restored.reviewed = [...current]; review.draft = restored; persistDraft(restored); updateReviewControls(ids); }); updateReviewControls(ids); } function renderForgeComment(comment, compact = false) { const item = node( "article", compact ? "forge-comment compact" : "forge-comment", ); item.dataset.forgeCommentId = comment.id; const heading = node("div", "forge-comment-heading"); const kind = node("span", "forge-comment-kind", comment.kind); const author = node("strong", "", comment.author); heading.append(kind, author); if (comment.kind === "inline") { const state = forgeCommentStates.get(comment); const labels = []; if (state?.outdated) labels.push("Outdated"); if (!state?.anchor) labels.push("Unmapped"); if (state?.current) labels.push("Current anchor"); heading.append( node( "span", `forge-comment-state${state?.current ? " current" : ""}`, labels.join(" · "), ), ); } item.append(heading); if ( comment.kind === "inline" && comment.path && comment.side && comment.line !== undefined ) { item.append( node( "p", "forge-comment-anchor", `${comment.path} · ${comment.side}:${comment.line}`, ), ); } item.append(node("p", "forge-comment-body", comment.body)); const footer = node("div", "forge-comment-footer"); if (comment.replyTo) { footer.append(node("span", "muted", `Reply to ${comment.replyTo}`)); } const link = node( "a", "forge-comment-link", `Open on ${review.forge.providerLabel}`, ); link.rel = "noreferrer noopener"; link.target = "_blank"; if (setSafeLink(link, comment.url)) footer.append(link); if (footer.hasChildNodes()) item.append(footer); return item; } function renderForge() { const region = byId("forge-region"); const forge = review.forge; const source = byId("forge-source"); const sourceLink = byId("forge-link"); const refs = byId("forge-refs"); const checkout = byId("forge-checkout"); const description = byId("forge-description"); const discussions = byId("forge-discussions"); const comments = byId("forge-comments"); region.hidden = !forge; indexForgeComments(); source.replaceChildren(); sourceLink.textContent = ""; setSafeLink(sourceLink, null); byId("forge-heading").textContent = ""; byId("forge-identity").textContent = ""; refs.replaceChildren(); checkout.classList.remove("mismatch"); checkout.textContent = ""; byId("forge-body").textContent = ""; description.open = false; byId("forge-discussions-summary").textContent = ""; discussions.open = false; comments.replaceChildren(); if (!forge) return; appendForgeSource(source, forge); sourceLink.textContent = `Open #${forge.number} on ${forge.providerLabel}`; setSafeLink(sourceLink, forge.url); byId("forge-heading").textContent = forge.title; byId("forge-identity").textContent = `${forge.repository} #${forge.number} · opened by ${forge.author}`; for (const [label, ref, sha] of [ ["Base tip", forge.baseRef, forge.baseSha], ["Merge base", `${forge.baseRef} ↔ ${forge.headRef}`, review.snapshot.base], ["Head", forge.headRef, forge.headSha], ]) { const row = node("div", "forge-ref"); row.append(node("strong", "", label), node("span", "", ref)); row.append(node("code", "", sha)); refs.append(row); } const matches = forgeCheckoutMatches(); checkout.classList.toggle("mismatch", !matches); checkout.textContent = matches ? `Checked out: ${forge.checkoutBranch}. Snapshot is bound to the exact PR base tip and head commit.` : `Checked out: ${forge.checkoutBranch}. Snapshot is not bound to the exact PR base tip and head commit.`; byId("forge-body").textContent = forge.body || "No description."; description.open = forge.body.length <= 240; byId("forge-discussions-summary").textContent = `Discussions (${forge.comments.length})`; if (forge.comments.length === 0) { comments.append(node("p", "muted", "No imported discussions.")); } else { for (const comment of forge.comments) { comments.append(renderForgeComment(comment)); } } } function renderDiff() { observer?.disconnect(); diff.replaceChildren(); currentLineRow = null; currentLineIndex = -1; renderedLines = []; renderedLineByKey = new Map(); renderedLineIndexes = new WeakMap(); renderedHunks = new Map(); renderedHunkLines = new Map(); const hunks = visibleHunks(); const layer = selectedLayerId ? layerById(selectedLayerId) : undefined; byId("diff-heading").textContent = layer?.title || "All changes"; byId("view-summary").textContent = layer?.summary || review.plan.summary; byId("visible-count").textContent = `${hunks.length} / ${review.snapshot.hunks.length} hunks`; updateLayerAction(); if (hunks.length === 0) { diff.append(node("p", "empty-state", "No captured hunks in this view.")); currentHunkId = null; currentLineKey = null; updateDetails(); updateKeyboardStatus(); return; } for (const hunk of hunks) diff.append(renderHunk(hunk)); if (!hunks.some((hunk) => hunk.id === currentHunkId)) { currentHunkId = hunks[0].id; } selectCurrentHunk(currentHunkId, false, false, true); ensureCurrentLine(currentHunkId); observeHunks(); } function renderHunk(hunk) { const article = node("article", "hunk"); article.id = `hunk-${hunk.id}`; article.dataset.hunkId = hunk.id; article.tabIndex = -1; renderedHunks.set(hunk.id, article); renderedHunkLines.set(hunk.id, []); article.addEventListener("click", () => selectCurrentHunk(hunk.id, false)); const heading = node("header", "file-heading"); const path = hunk.oldPath ? `${hunk.oldPath} → ${hunk.path}` : hunk.path; heading.append(node("span", "file-name", path)); const reviewed = review.draft.reviewed.includes(hunk.id); const mark = node( "button", "mark-reviewed", reviewed ? "Reviewed ✓" : "Mark reviewed", ); mark.type = "button"; mark.dataset.hunkId = hunk.id; mark.classList.toggle("done", reviewed); mark.setAttribute("aria-pressed", String(reviewed)); mark.setAttribute("aria-label", reviewed ? "Reviewed ✓" : "Mark reviewed"); const hint = node("span", "action-hint"); hint.setAttribute("aria-hidden", "true"); hint.append(node("kbd", "", "Space"), node("kbd", "", "r")); mark.append(hint); mark.addEventListener("click", (event) => { event.stopPropagation(); toggleHunkReviewed(hunk.id); }); heading.append(mark); const plannedHunk = layerForHunk(hunk.id)?.hunks.find( (item) => item.id === hunk.id, ); article.append(heading); if (plannedHunk) { article.append(node("p", "hunk-summary", plannedHunk.summary)); } article.append(node("pre", "hunk-header", hunk.header)); const scroll = node("div", "diff-scroll"); const table = node("table", "diff-table"); const body = document.createElement("tbody"); for (const [index, line] of hunk.lines.entries()) { body.append(renderLine(hunk, line, index)); const imported = [ ...(line.oldLine === undefined ? [] : (forgeInlineByAnchor.get( forgeAnchorKey(hunk.id, "old", line.oldLine), ) ?? [])), ...(line.newLine === undefined ? [] : (forgeInlineByAnchor.get( forgeAnchorKey(hunk.id, "new", line.newLine), ) ?? [])), ]; if (imported.length > 0) body.append(renderImportedComments(imported)); } table.append(body); scroll.append(table); article.append(scroll); return article; } function renderImportedComments(comments) { const row = node("tr", "imported-comments-row"); const cell = node("td", "imported-comments-cell"); cell.colSpan = 3; const group = node("div", "imported-comments"); for (const comment of comments) group.append(renderForgeComment(comment, true)); cell.append(group); row.append(cell); return row; } function renderLine(hunk, line, index) { const row = node("tr", `diff-row diff-line ${line.kind}`); row.tabIndex = -1; row.dataset.kind = line.kind; row.dataset.hunkId = hunk.id; row.dataset.lineIndex = String(index); const side = line.kind === "delete" ? "old" : line.newLine !== undefined ? "new" : line.oldLine !== undefined ? "old" : undefined; const number = side === "old" ? line.oldLine : line.newLine; if (side && number !== undefined) { row.dataset.side = side; row.dataset.line = String(number); const key = lineKey(row); renderedLineIndexes.set(row, renderedLines.length); renderedLines.push(row); renderedLineByKey.set(key, row); renderedHunkLines.get(hunk.id).push(row); row.addEventListener("click", (event) => { if (event.target instanceof HTMLButtonElement) return; setCurrentLine(row, false); }); } row.append( lineNumberCell(hunk, line, "old"), lineNumberCell(hunk, line, "new"), ); const code = node("td", "line-code"); const prefix = line.kind === "add" ? "+" : line.kind === "delete" ? "−" : " "; code.append( node("span", "line-prefix", prefix), document.createTextNode(line.text), ); row.append(code); return row; } function lineNumberCell(hunk, line, side) { const cell = node("td", "line-number"); const number = side === "old" ? line.oldLine : line.newLine; if (number === undefined) return cell; const button = node("button", "", String(number)); button.type = "button"; button.id = `line-${hunk.id}-${side}-${number}`; button.setAttribute( "aria-label", `Comment on ${side} line ${number} in ${hunk.path}`, ); button.addEventListener("click", (event) => { event.stopPropagation(); const row = button.closest(".diff-line"); if (row?.dataset.line) setCurrentLine(row, false); openComment(hunk.id, side, number); }); cell.append(button); return cell; } function observeHunks() { if (!review || renderedHunks.size === 0) return; const intersections = new Map(); const root = narrowLayout.matches ? null : paneElement("diff"); observer = new IntersectionObserver( (entries) => { for (const entry of entries) { const id = entry.target.dataset.hunkId; if (entry.isIntersecting) intersections.set(id, entry); else intersections.delete(id); } if (performance.now() < suppressObserverUntil || explicitJumpLocked) return; const rootBounds = root?.getBoundingClientRect(); const viewportTop = (rootBounds?.top ?? 0) + 75; const viewportBottom = rootBounds?.bottom ?? window.innerHeight; const atBottom = root && root.scrollTop + root.clientHeight >= root.scrollHeight - 50; const lastVisibleId = [...renderedHunks.keys()] .reverse() .find((id) => intersections.has(id)); if (atBottom && lastVisibleId) { selectCurrentHunk(lastVisibleId, false, true); return; } let bestId; let bestArea = 0; for (const id of intersections.keys()) { const rect = renderedHunks.get(id)?.getBoundingClientRect(); if (!rect) continue; const visibleHeight = Math.max( 0, Math.min(rect.bottom, viewportBottom) - Math.max(rect.top, viewportTop), ); const visibleWidth = Math.max( 0, Math.min(rect.right, rootBounds?.right ?? window.innerWidth) - Math.max(rect.left, rootBounds?.left ?? 0), ); const area = visibleHeight * visibleWidth; if (area > bestArea) { bestId = id; bestArea = area; } } if (bestId) selectCurrentHunk(bestId, false, true); }, { root, rootMargin: "-75px 0px 0px 0px", threshold: [0, 0.1, 0.3, 0.6, 1], }, ); for (const hunk of renderedHunks.values()) observer.observe(hunk); } function selectCurrentHunk(id, scroll, syncLine = true, force = false) { if (!id || !hunkById(id)) return; if (scroll) beginExplicitJump(); const previousId = currentHunkId; const changed = previousId !== id; if (changed) renderedHunks.get(previousId)?.classList.remove("current"); currentHunkId = id; renderedHunks.get(id)?.classList.add("current"); if (syncLine && currentLine()?.dataset.hunkId !== id) { const first = renderedHunkLines.get(id)?.[0]; if (first) setCurrentLine(first, false); else clearCurrentLine(); } if (changed || force) updateDetails(); else updateKeyboardStatus(); if (scroll) { const target = renderedHunkLines.get(id)?.[0] ?? renderedHunks.get(id); target?.scrollIntoView({ block: "start", inline: "nearest", behavior: "instant", }); target?.focus({ preventScroll: true }); } } function updateDetails() { if (!review) return; const hunk = hunkById(currentHunkId); const layer = hunk ? layerForHunk(hunk.id) : undefined; const plannedHunk = layer?.hunks.find((item) => item.id === hunk?.id); byId("current-path").textContent = hunk ? hunk.path : "No hunk selected"; byId("explanation").textContent = plannedHunk?.summary || "No explanation available."; const flow = byId("flow"); flow.replaceChildren(); for (const [index, step] of (layer?.flow ?? []).entries()) { if (index > 0) flow.append(node("div", "flow-arrow", "↓")); flow.append(node("div", "flow-step", step)); } byId("flow-region").hidden = (layer?.flow?.length ?? 0) === 0; renderAsk(); } function renderAskMessage(exchange) { const message = node("article", "chat-exchange"); const context = exchange.hunkId ? `Focused hunk: ${hunkById(exchange.hunkId)?.path ?? exchange.hunkId}` : "No focused hunk"; message.append( node("p", "chat-context", context), node("p", "chat-question", exchange.question), node("p", "chat-answer", exchange.answer), ); return message; } function renderAsk() { if (!review) return; byId("ask-scope").textContent = scopeTitle(); const hunk = hunkById(currentHunkId); byId("ask-focus").textContent = hunk ? `Focus: ${hunk.path} · ${hunk.id}` : "No focused hunk"; const chat = byId("chat"); const scrollTop = chat.scrollTop; const followLatest = chat.dataset.scope !== scopeKey() || chat.scrollHeight - chat.clientHeight - scrollTop < 24; chat.dataset.scope = scopeKey(); chat.replaceChildren(); const thread = threadForScope(); for (const exchange of thread?.messages ?? []) { chat.append(renderAskMessage(exchange)); } if (pendingAsk?.scopeKey === scopeKey()) { const waiting = node("article", "chat-exchange pending"); waiting.append( node( "p", "chat-context", pendingAsk.hunkId ? `Focused hunk: ${hunkById(pendingAsk.hunkId)?.path ?? pendingAsk.hunkId}` : "No focused hunk", ), node("p", "chat-question", pendingAsk.question), node("p", "chat-answer muted", "Waiting for answer…"), ); chat.append(waiting); } const askError = askErrors.get(scopeKey()); if (askError) { chat.append(node("p", "chat-error", askError)); } if (!chat.hasChildNodes()) { chat.append(node("p", "empty-state", "No questions in this thread.")); } chat.scrollTop = followLatest ? chat.scrollHeight : scrollTop; updateAskControls(); } function updateAskControls() { const unavailable = busy || askInFlight || !review; byId("ask-button").disabled = unavailable; questionInput.disabled = unavailable; } function restoreQuestionForScope() { questionInput.value = askDrafts[scopeKey()] ?? ""; } function jumpToFinding(finding) { const layer = layerForHunk(finding.hunkId); if (layer) selectLayer(layer.id); else selectLayer(null); selectCurrentHunk(finding.hunkId, true); byId(`line-${finding.hunkId}-${finding.side}-${finding.line}`)?.focus({ preventScroll: false, }); } function renderFindings() { const container = byId("findings"); container.replaceChildren(); if (review.draft.findings.length === 0) { container.append(node("p", "muted", "No line comments.")); return; } for (const finding of review.draft.findings) { const hunk = hunkById(finding.hunkId); const item = node("article", "finding"); item.dataset.findingId = finding.id; const head = node("div", "finding-head"); head.append(node("span", `severity ${finding.severity}`, finding.severity)); const anchor = node( "button", "finding-anchor quiet", `${hunk?.path || finding.hunkId} · ${finding.side}:${finding.line}`, ); anchor.type = "button"; anchor.addEventListener("click", () => jumpToFinding(finding)); head.append(anchor); const remove = node("button", "quiet", "Remove"); remove.type = "button"; remove.setAttribute( "aria-label", `Remove comment on ${finding.side} line ${finding.line}`, ); remove.addEventListener("click", async () => { const draft = currentDraft(); draft.findings = draft.findings.filter((item) => item.id !== finding.id); try { await queueDraftSave(draft); renderFindings(); } catch {} }); head.append(remove); item.append(head, node("p", "finding-text", finding.text)); container.append(item); } } function updateProgress() { const reviewed = review.draft.reviewed.length; const total = review.snapshot.hunks.length; byId("progress-label").textContent = `${reviewed} / ${total} reviewed`; } function renderAll() { byId("source-label").textContent = sourceLabel(); notes.value = review.draft.notes; renderForge(); renderNavigation(); renderDiff(); renderFindings(); updateProgress(); restoreQuestionForScope(); renderAsk(); applyPanelState(); updateLayerAction(); updateKeyboardStatus(); } function selectLayer(layerId, focusDiff = false) { selectedLayerId = layerId; currentHunkId = layerId ? (layerById(layerId)?.hunks[0]?.id ?? null) : (review.snapshot.hunks[0]?.id ?? null); currentLineKey = null; renderNavigation(); renderDiff(); restoreQuestionForScope(); renderAsk(); if (currentHunkId) { selectCurrentHunk(currentHunkId, true, true); ensureCurrentLine(currentHunkId); } if (focusDiff) setKeyboardPane("diff", true); } function openComment(hunkId, side, line) { if (busy || staleClient || !hunkById(hunkId) || !Number.isSafeInteger(line)) { return; } selectDrawerTab("feedback"); commentTarget = { hunkId, side, line }; byId("comment-anchor").textContent = `${hunkById(hunkId)?.path} · ${side}:${line}`; byId("comment-text").value = ""; byId("comment-severity").value = "question"; commentDialog.showModal(); byId("comment-text").focus(); } function restoreLocalDraft() { const draft = storedDraft(); if (!draft || typeof draft !== "object") return; if (!Array.isArray(draft.reviewed) || !Array.isArray(draft.findings)) return; if (typeof draft.notes !== "string") return; const needsSave = JSON.stringify(draft) !== JSON.stringify(review.draft); review.draft = draft; if (needsSave) { notesPending = true; scheduleNotesSave(); } } async function pollReadiness(attempt = 0) { try { const payload = await request("/api/state"); if (!payload.busy) { if (payload.snapshotId === snapshotId) review = payload.review; renderAll(); setBusy(false, "Review ready"); return; } } catch (error) { setBusy(true); setStatus( `${error instanceof Error ? error.message : "Readiness check failed"}. Reload this page to retry; Close remains available.`, true, ); return; } if (attempt >= READINESS_RETRIES) { setBusy( true, "Review is still busy. Reload this page to retry; Close remains available.", ); return; } setTimeout(() => pollReadiness(attempt + 1), NOTE_DEBOUNCE_MS); } narrowLayout.addEventListener("change", (event) => { if (event.matches) { layersOpen = false; drawerOpen = false; } applyPanelState(); }); byId("layers-toggle").addEventListener("click", () => { layersOpen = !layersOpen; applyPanelState(); }); byId("drawer-toggle").addEventListener("click", () => { drawerOpen = !drawerOpen; applyPanelState(); }); for (const tab of document.querySelectorAll("[data-drawer-tab]")) { tab.addEventListener("click", () => selectDrawerTab(tab.dataset.drawerTab)); tab.addEventListener("keydown", (event) => { const tabs = [...document.querySelectorAll("[data-drawer-tab]")]; const current = tabs.indexOf(event.currentTarget); let next; if (event.key === "ArrowRight") next = (current + 1) % tabs.length; else if (event.key === "ArrowLeft") { next = (current - 1 + tabs.length) % tabs.length; } else if (event.key === "Home") next = 0; else if (event.key === "End") next = tabs.length - 1; else return; event.preventDefault(); selectDrawerTab(tabs[next].dataset.drawerTab, true); }); } allChanges.addEventListener("click", () => selectLayer(null)); byId("mark-layer-reviewed").addEventListener("click", toggleLayerReviewed); byId("keyboard-help-button").addEventListener("click", showHelp); byId("keyboard-help-close").addEventListener("click", () => keyboardHelp.close(), ); keyboardHelp.addEventListener("close", () => { if (keyboardHelp.open || isEditable(document.activeElement)) return; setKeyboardPane("diff"); currentLine()?.focus({ preventScroll: true }); }); keyboardHelp.addEventListener("cancel", () => cancelPendingPrefix()); commentDialog.addEventListener("cancel", () => { commentTarget = null; }); keyboardSearch.addEventListener("input", () => { searchQuery = keyboardSearch.value; }); keyboardSearch.addEventListener("keydown", (event) => { if (event.isComposing) return; if (event.key === "Enter") { event.preventDefault(); searchLines(event.shiftKey ? -1 : 1); closeSearch(); } }); for (const pane of document.querySelectorAll("[data-keyboard-pane]")) { pane.addEventListener("pointerdown", () => setKeyboardPane(pane.dataset.keyboardPane), ); pane.addEventListener("focusin", () => setKeyboardPane(pane.dataset.keyboardPane), ); } document.addEventListener("focusin", updateKeyboardStatus); document.addEventListener("focusout", () => queueMicrotask(updateKeyboardStatus), ); document.addEventListener("pointerdown", cancelPendingPrefix); window.addEventListener("blur", cancelPendingPrefix); function releaseExplicitJump() { if (performance.now() >= suppressObserverUntil) explicitJumpLocked = false; } for (const eventName of ["wheel", "touchmove", "pointerdown"]) { paneElement("diff").addEventListener(eventName, releaseExplicitJump, { passive: true, }); } renderKeyboardHelp(); notes.addEventListener("input", () => { review.draft.notes = notes.value; persistDraft(currentDraft()); if (staleClient) { setStatus( "Review changed in another tab. Reload to keep these notes.", true, ); return; } scheduleNotesSave(); }); questionInput.addEventListener("input", () => { askDrafts[scopeKey()] = questionInput.value; persistAskDrafts(); }); questionInput.addEventListener("keydown", (event) => { if (event.isComposing || event.key === "Process") return; if (event.key === "Enter" && event.shiftKey) { event.preventDefault(); askForm.requestSubmit(); } }); commentForm.addEventListener("submit", async (event) => { event.preventDefault(); if (!commentTarget) return; const text = byId("comment-text").value; if (!text) return; const draft = currentDraft(); draft.findings.push({ id: crypto.randomUUID(), ...commentTarget, severity: byId("comment-severity").value, text, }); try { await queueDraftSave(draft); commentDialog.close(); commentTarget = null; renderFindings(); setKeyboardPane("drawer", true); } catch {} }); byId("comment-cancel").addEventListener("click", () => { commentTarget = null; commentDialog.close(); setKeyboardPane("drawer", true); }); askForm.addEventListener("submit", async (event) => { event.preventDefault(); const question = questionInput.value; if (!question.trim() || busy || askInFlight) return; const sent = { layerId: selectedLayerId, hunkId: currentHunkId, question, scopeKey: scopeKey(), }; askInFlight = true; pendingAsk = sent; askErrors.delete(sent.scopeKey); setBusy(true, "Saving draft…"); renderAsk(); try { await flushDraft(); setStatus("Asking model…"); const payload = await request("/api/ask", { layerId: sent.layerId, hunkId: sent.hunkId, question: sent.question, }); delete askDrafts[sent.scopeKey]; persistAskDrafts(); if (scopeKey() === sent.scopeKey) questionInput.value = ""; setStatus("Answer received"); applyThreads(payload); } catch (error) { askErrors.set( sent.scopeKey, error instanceof Error ? error.message : "Question failed", ); reportError(error); } finally { pendingAsk = null; askInFlight = false; setBusy(false); renderAsk(); if ( scopeKey() === sent.scopeKey && drawerOpen && !byId("ask-panel").hidden && document.activeElement === document.body ) questionInput.focus({ preventScroll: true }); } }); async function refreshReview() { if (busy || staleClient) { if (staleClient) reportError({ code: "stale_client" }); return; } if (!window.confirm("Discard the current draft and refresh the review?")) return; setBusy(true, "Saving draft…"); try { await flushDraft(); setStatus("Refreshing snapshot…"); const previousDraftKey = storageKey(); const previousAskKey = askStorageKey(); const payload = await request("/api/refresh", { confirm: true }); review = payload.review; threads = Array.isArray(payload.threads) ? payload.threads : []; askDrafts = {}; askErrors.clear(); selectedLayerId = null; currentHunkId = null; currentLineKey = null; searchQuery = ""; try { sessionStorage.removeItem(previousDraftKey); sessionStorage.removeItem(previousAskKey); } catch {} clearStoredDraft(); clearStoredAskDrafts(); renderAll(); setKeyboardPane("diff", true); setStatus( payload.wasCurrent ? "Review refreshed" : "Stale snapshot replaced", ); } catch (error) { reportError(error); } finally { setBusy(false); } } async function submitFeedback() { if (busy || staleClient) { if (staleClient) reportError({ code: "stale_client" }); return; } if (!window.confirm("Send feedback to Pi with the current draft?")) return; setBusy(true, "Saving draft…"); try { await flushDraft(); setStatus("Checking snapshot and sending feedback…"); const payload = await request("/api/submit", { draft: currentDraft() }); review.draft = payload.draft; clearStoredDraft(); notes.value = review.draft.notes; renderFindings(); renderDiff(); updateProgress(); setStatus("Feedback sent to Pi"); } catch (error) { reportError(error); } finally { setBusy(false); } } async function closeReview() { if (!window.confirm("Close this review workspace?")) return; if (busy) { try { await request("/api/close", {}); setStatus("Review closed. You may close this tab."); } catch (error) { reportError(error); } return; } setBusy(true, "Saving draft…"); try { await flushDraft(); await request("/api/close", {}); setStatus("Review closed. You may close this tab."); } catch (error) { reportError(error); setBusy(false); } } byId("refresh-button").addEventListener("click", refreshReview); byId("submit-button").addEventListener("click", submitFeedback); byId("close-button").addEventListener("click", closeReview); function moveLine(direction) { if (renderedLines.length === 0) return; if (currentLineIndex < 0) { const hunks = visibleHunks(); const hunkIndex = hunks.findIndex((hunk) => hunk.id === currentHunkId); const candidates = direction > 0 ? hunks.slice(hunkIndex + 1) : hunks.slice(0, hunkIndex).reverse(); for (const hunk of candidates) { const lines = renderedHunkLines.get(hunk.id) ?? []; const target = direction > 0 ? lines[0] : lines.at(-1); if (target) { setCurrentLine(target, true); return; } } return; } const next = Math.max( 0, Math.min(renderedLines.length - 1, currentLineIndex + direction), ); setCurrentLine(renderedLines[next], true); } function focusedScrollSurface(axis = "top") { if (keyboardPane === "drawer") { return document.querySelector(".drawer-panel:not([hidden])"); } if (keyboardPane === "diff" && axis === "left") { return ( currentLine()?.closest(".hunk")?.querySelector(".diff-scroll") || paneElement() ); } return paneElement(); } function scrollFocusedPane(axis, amount) { focusedScrollSurface(axis)?.scrollBy({ [axis]: amount, behavior: "instant", }); } function jumpBoundary(last) { if (keyboardPane !== "diff") { const surface = focusedScrollSurface(); surface?.scrollTo({ top: last ? surface.scrollHeight : 0, behavior: "instant", }); return; } const lines = anchorableLines(); if (lines.length) setCurrentLine(lines[last ? lines.length - 1 : 0], true); } function jumpHunk(direction, unreviewed = false) { const hunks = visibleHunks(); if (hunks.length === 0) return; const current = hunks.findIndex((hunk) => hunk.id === currentHunkId); let next = current; for ( let index = current + direction; index >= 0 && index < hunks.length; index += direction ) { if (!unreviewed || !review.draft.reviewed.includes(hunks[index].id)) { next = index; break; } } if (next === current || next < 0) return; const id = hunks[next].id; const row = renderedHunkLines.get(id)?.[0]; if (row) setCurrentLine(row, true); else { clearCurrentLine(); selectCurrentHunk(id, true, false); updateKeyboardStatus(); } } function jumpLayer(direction) { const available = layers(); if (available.length === 0) return; const current = available.findIndex((layer) => layer.id === selectedLayerId); const next = current < 0 ? direction > 0 ? 0 : available.length - 1 : Math.max(0, Math.min(available.length - 1, current + direction)); if (next !== current) selectLayer(available[next].id, true); } function focusAdjacentPane(direction) { const available = visiblePaneNames(); const current = Math.max(0, available.indexOf(keyboardPane)); const next = Math.max(0, Math.min(available.length - 1, current + direction)); setKeyboardPane(available[next], true); } function composeQuestion() { if (busy) return; selectDrawerTab("ask"); setKeyboardPane("drawer"); questionInput.focus(); } function editNotes() { if (busy) return; selectDrawerTab("feedback"); setKeyboardPane("drawer"); notes.focus(); } function runSequence(prefix, key) { cancelPendingPrefix(); if (prefix === "g") { if (key === "g") jumpBoundary(false); else if (["d", "a", "f"].includes(key)) { const tab = key === "d" ? "details" : key === "a" ? "ask" : "feedback"; selectDrawerTab(tab, true); setKeyboardPane("drawer"); } return; } if (prefix === "[" || prefix === "]") { const direction = prefix === "]" ? 1 : -1; if (key === "c") jumpHunk(direction); else if (key === "l") jumpLayer(direction); else if (key === "u") jumpHunk(direction, true); return; } if (prefix !== " ") return; if (busy || staleClient) return; if (key === "r" && currentHunkId) toggleHunkReviewed(currentHunkId); else if (key === "R") toggleLayerReviewed(); else if (key === "c") { const row = currentLine(); if (row) openComment( row.dataset.hunkId, row.dataset.side, Number(row.dataset.line), ); } else if (key === "a") composeQuestion(); else if (key === "n") editNotes(); else if (key === "h") focusAdjacentPane(-1); else if (key === "l") focusAdjacentPane(1); else if (key === "b") { layersOpen = !layersOpen; applyPanelState(); } else if (key === "d") { drawerOpen = !drawerOpen; applyPanelState(); } else if (key === "s") submitFeedback(); else if (key === "f") refreshReview(); else if (key === "q") closeReview(); } function dismissTransient(event) { if (keyboardHelp.open) { event.preventDefault(); keyboardHelp.close(); return true; } if (commentDialog.open) { event.preventDefault(); commentTarget = null; commentDialog.close(); return true; } if (pendingPrefix) { event.preventDefault(); cancelPendingPrefix(); return true; } if (!keyboardSearch.hidden) { event.preventDefault(); closeSearch(); return true; } if (isEditable(event.target)) { event.preventDefault(); event.target.blur(); setKeyboardPane("diff"); currentLine()?.focus({ preventScroll: true }); return true; } return false; } function isHalfPageKey(event) { return ( event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && ["d", "u"].includes(event.key.toLowerCase()) ); } function hasUnrelatedModifiers(event) { if (event.metaKey) return true; const printableModifiedKey = ["?", "/", "[", "]"].includes(event.key); const altGraph = event.ctrlKey && event.altKey && event.getModifierState("AltGraph"); if (event.ctrlKey || event.altKey) { return !(altGraph && printableModifiedKey); } return ( event.shiftKey && !["?", "/", "[", "]", "G", "N", "R"].includes(event.key) ); } document.addEventListener("keydown", (event) => { if (event.isComposing || event.key === "Process") return; if (event.key === "Escape" && dismissTransient(event)) return; if (keyboardHelp.open || commentDialog.open || isEditable(event.target)) return; if ( event.target instanceof HTMLButtonElement && (event.key === " " || event.key === "Enter") ) { cancelPendingPrefix(); return; } if (isHalfPageKey(event)) { cancelPendingPrefix(); if (busy) return; event.preventDefault(); scrollFocusedPane( "top", ((event.key.toLowerCase() === "d" ? 1 : -1) * (paneElement()?.clientHeight ?? 400)) / 2, ); return; } if (hasUnrelatedModifiers(event)) { cancelPendingPrefix(); return; } if (pendingPrefix && event.key === "Tab") { cancelPendingPrefix(); return; } if (busy) return; if (pendingPrefix) { event.preventDefault(); runSequence(pendingPrefix, event.key); return; } if ([" ", "g", "[", "]"].includes(event.key)) { event.preventDefault(); showPendingPrefix(event.key); return; } if (event.key === "?") { event.preventDefault(); showHelp(); } else if (event.key === "/") { event.preventDefault(); openSearch(); } else if (event.key === "n" || event.key === "N") { event.preventDefault(); searchLines(event.key === "n" ? 1 : -1); } else if (event.key === "j" || event.key === "k") { event.preventDefault(); if (keyboardPane === "diff") moveLine(event.key === "j" ? 1 : -1); else scrollFocusedPane("top", event.key === "j" ? 48 : -48); } else if (event.key === "h" || event.key === "l") { event.preventDefault(); scrollFocusedPane("left", event.key === "l" ? 96 : -96); } else if (event.key === "G") { event.preventDefault(); jumpBoundary(true); } }); request("/api/state") .then((payload) => { review = payload.review; applyThreads(payload); restoreLocalDraft(); restoreAskDrafts(); renderAll(); if (payload.busy) { setBusy(true, "Review operation in progress…"); setTimeout(() => pollReadiness(), NOTE_DEBOUNCE_MS); } else { setBusy(false, "Review ready"); } }) .catch((error) => { workspace.setAttribute("aria-busy", "false"); diff.replaceChildren(node("p", "empty-state", error.message)); reportError(error); });