Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/strata/assets/app.js

Raw
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);
	});