diff --git a/README.md b/README.md index eaeed47..4f2f40b 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,21 @@ or mark files reviewed. **Tour the code** continues into the guided walkthrough. The review sidebar has separate **Tour**, **Questions**, and **Comments** tabs, with drafts and reading positions retained when you switch between them. +Tour stops start with a short summary and optional **More detail**, with **Key**, +**Normal**, or **Supporting context** priority. **Read this step’s code** brings +related before/after ranges together, including code from different files. Open +a full file to explore or comment, then return to the same stop. Grounded notes +include inspectable source quotes; most stops need none. + +**Not covered** lists changed blocks outside the tour, including partial coverage, +generated files, and binary changes. It does not mark files reviewed. Content +fingerprints follow unchanged blocks when line numbers shift and flag changed, +missing, or ambiguous evidence as out of date. Older tours without fingerprints +remain readable with an explicit unverified label. See the +[guided reading guide](docs/guided-reading.md) for limits and saved-session behavior. + +![Related code from two files beside a concise tour explanation](docs/images/tour-reading.png) + Briefing shares the Shiki/Monaco, Split/Unified, and Scroll/Wrap preferences with Changes. These choices follow you across repositories and resumed reviews, with separate layout and wrapping preferences for desktop and mobile. diff --git a/docs/guided-reading.md b/docs/guided-reading.md new file mode 100644 index 0000000..7c57880 --- /dev/null +++ b/docs/guided-reading.md @@ -0,0 +1,63 @@ +# Guided reading + +Each tour stop explains one behavior. The summary stands alone; **More detail** +opens additional mechanics. Key, normal, and supporting-context labels indicate +reading priority, without imposing quotas on the route. + +**Read this step’s code** groups related before/after excerpts in Changes. A stop +can reference up to eight changed ranges across files. The view follows the same +renderer, Split/Unified, and Scroll/Wrap preferences as the rest of the review. +**Open full file** suspends the tour so you can explore or comment; **Back to tour +code** returns to its current stop. Existing question and comment drafts retain +their original context. Source excerpts are read-only; line comments use the full +file view. + +While the tour is paused, a floating **Resume tour** button stays at the bottom +of the review pane as you scroll or switch tabs. It returns to the same stop. + +## Coverage + +**Not covered** lists changed diff blocks not fully addressed by current tour +anchors. Partial blocks remain listed. Generated files, binary changes, empty +files, renames, and metadata-only changes also appear when not represented by +textual tour evidence. A before/after replacement can be covered from either +side; separate edits in the same block need their own coverage. Coverage is +computed against the full comparison even when Changes displays a checkpoint +filter. It describes the tour's scope, not review completion or correctness. + +The list displays at most 100 uncovered entries and reports additional entries. +Grouped code displays up to 160 rows per excerpt and 600 rows / 100,000 source +characters per stop, with visible limits and links to full files. Oversized lines +are omitted rather than silently truncated. + +## Evidence and freshness + +New tours store content fingerprints of the diff blocks behind each anchor. +Line numbers are excluded from the fingerprint. Unchanged blocks can move without +invalidating the stop; changed, missing, or ambiguous blocks mark it **Out of +date**. Stale explanations remain readable with a warning, but the focused view +does not silently substitute different code at the old line numbers. Supporting +notes also become stale when any cited source file changes. + +Fingerprints include the surrounding diff context. A context change or a different +diff boundary can conservatively mark a stop stale even if its central statement +survives. Older sessions have no original fingerprints and display **unverified**; +their original ranges remain accessible without claiming current coverage. A tour +prepared against a checkpoint comparison may be stale against the full comparison +when the before-side content differs. Saving or resuming never invents provenance. + +## Optional notes + +An agent can include at most two notes per stop: decision, upkeep, limitation, +divergence, or test evidence gap. Each needs exact complete-line quotes checked +against captured Git objects. A divergence needs both sources; a test gap needs +the inspected test scope. Quotes establish provenance, not semantic correctness. +The agent must still inspect callers, guards, state writers, consumers, and tests +before making claims or asking review questions. Unsupported notes are repaired +or omitted, rather than displayed as verified observations. + +Markdown and offline HTML exports retain summary, detail, priority, freshness, +related ranges, note evidence, and coverage. HTML also includes bounded grouped +code excerpts and full-file navigation. Existing plain-text attached-agent +responses remain readable: their first paragraph is the summary and subsequent +paragraphs appear under More detail. diff --git a/docs/images/tour-reading.png b/docs/images/tour-reading.png new file mode 100644 index 0000000..e4f5b20 Binary files /dev/null and b/docs/images/tour-reading.png differ diff --git a/package.json b/package.json index f3b3eed..c3f29bc 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:monaco": "bun scripts/check-monaco.ts", "test:github-suggestions-browser": "bun scripts/check-github-suggestions.ts", "typecheck": "bunx tsc --noEmit", - "verify": "bun run typecheck && bun test && node --check public/theme.js && node --check public/app.js && node --check public/diagram-labels.js && node --check public/briefing.js && node --check public/briefing-diagram.js && node --check public/call-tree.js" + "verify": "bun run typecheck && bun test && node --check public/theme.js && node --check public/app.js && node --check public/diagram-labels.js && node --check public/briefing.js && node --check public/briefing-diagram.js && node --check public/call-tree.js && node --check public/tour-reading.mjs" }, "devDependencies": { "@types/bun": "1.3.14", diff --git a/public/app.js b/public/app.js index 50e97c5..30a76d0 100644 --- a/public/app.js +++ b/public/app.js @@ -1,3 +1,6 @@ +import { createTourReading } from "/tour-reading.mjs"; +let tourReading; +let tourReadingReturn = false; import { refreshSequenceEvidence, sequenceEvidenceRevision, sequenceSourceSelection } from "/sequence-walkthrough-host.js"; import { createMonacoRenderer } from "/monaco-controller.mjs"; import { createBriefingCodeRenderer } from "/briefing-code.mjs"; @@ -976,6 +979,7 @@ function setBusy(busy, label = "Working…") { elements["include-findings"].disabled = busy || state.session?.findingGenerationEnabled === false; elements["include-dry-runs"].disabled = busy; elements["tour-mode-toggle"].disabled = busy; + elements["current-step-return"].disabled = busy; elements["update-button"].disabled = busy || state.updateInProgress || elements["update-button"].classList.contains("updated"); elements["refresh-button"].disabled = busy; elements["checkpoint-button"].disabled = busy; @@ -1718,6 +1722,7 @@ function currentDiffRenderOptions() { } function syncDiffViewControls() { + tourReading?.sync(); state.monacoRenderer?.sync(); if (typeof briefingReader !== "undefined") briefingReader?.syncDiffView(); elements.diff.classList.toggle("wrap-code", state.diffWrap === "wrap"); @@ -2064,6 +2069,7 @@ function visibleDiffFiles() { } async function ensureTourPhaseDiffs() { + if (tourReading?.active()) return; if (!state.tourMode || state.session.comparison.diffMode !== "file") return; const phase = currentPhase(); if (!phase) return; @@ -2170,6 +2176,7 @@ function restoreFileViewport(anchor) { } async function showChangedFile(path, line, side, requestedOffset, requestedContext, requestedFull, viewportOrigin) { + tourReading?.closeCode(); if (state.tourMode && !filteredChangedFiles().some((file) => file.path === path)) { toast("That file belongs to another phase. Exit the tour to browse every changed file.", { duration: 9_000, tone: "warning" }); return false; @@ -2881,7 +2888,7 @@ function renderReviewOverview() { if (outline) { elements["review-overview-title"].textContent = outline.title; elements["review-overview-summary"].textContent = outline.summary; - elements["tour-mode-toggle"].textContent = state.tourMode ? "Exit tour" : "Resume tour"; + elements["tour-mode-toggle"].classList.toggle("hidden", !state.tourMode); elements["tour-mode-toggle"].setAttribute("aria-pressed", String(Boolean(state.tourMode))); } elements["pull-request-description"].classList.toggle("hidden", !pullRequest); @@ -4895,6 +4902,8 @@ function renderMapsNavigation() { } function renderActiveTab() { + tourReading?.sync(); + document.getElementById("tour-reading-return").classList.toggle("hidden", !tourReadingReturn || state.activeTab !== "diff"); if (state.activeTab === "screenshots" && !availableTabs().includes("screenshots")) state.activeTab = "diff"; if (state.activeTab !== "diff") state.briefingSourceReturn = false; for (const tabName of ["briefing", "diff", "call-flows", "structural-blast-radius", "archify", "sequence", "dry-runs", "adaptive", "findings", "activity", "conversations", "screenshots", "pull-request"]) { @@ -5133,17 +5142,24 @@ function updateCurrentStepReturnVisibility() { updateBriefingSourceReturnVisibility(); currentStepReturnFrame = null; const button = elements["current-step-return"]; - if (!state.session || !state.tourMode || state.activeTab !== "diff" || !currentStep()) { + const paused = state.tourMode === false; + if (!state.session || !currentStep() || (paused ? !reviewIsOpen() : state.activeTab !== "diff" || tourReading?.active())) { button.classList.add("hidden"); return; } const panel = elements["diff-panel"]; - const target = elements.diff.querySelector(".tour-code-note.active") - ?? elements.diff.querySelector(".diff-row.tour-anchor, .markdown-source-block.tour-anchor"); const panelBounds = panel.getBoundingClientRect(); button.style.right = `${Math.max(12, window.innerWidth - panelBounds.right + 14)}px`; - button.style.bottom = `${Math.max(12, window.innerHeight - Math.min(window.innerHeight, panelBounds.bottom) + 14)}px`; - button.setAttribute("aria-label", `Return to current tour step ${state.session.currentStepIndex + 1}`); + button.style.bottom = `calc(${Math.max(12, window.innerHeight - Math.min(window.innerHeight, panelBounds.bottom) + 14)}px + max(var(--mobile-actions-height, 0px), var(--review-host-bottom-clearance, 0px)))`; + button.textContent = paused ? "Resume tour" : "↩ Current step"; + button.setAttribute("aria-label", paused ? "Resume tour" : `Return to current tour step ${state.session.currentStepIndex + 1}`); + button.disabled = state.busy; + if (paused) { + button.classList.remove("hidden"); + return; + } + const target = elements.diff.querySelector(".tour-code-note.active") + ?? elements.diff.querySelector(".diff-row.tour-anchor, .markdown-source-block.tour-anchor"); if (state.monacoRenderer?.owns(currentStep().anchor.path)) { button.classList.toggle("hidden", state.monacoRenderer.isStepVisible(currentStep())); return; @@ -5214,6 +5230,7 @@ function renderTourAnnotation() { } async function focusCurrentStep() { + if (tourReading?.active()) { elements["diff-panel"].scrollTop = 0; return; } const step = currentStep(); if (!step || !state.tourMode || state.monacoRenderer?.hasDraft()) return; state.focusedStepId = step.id; @@ -5481,6 +5498,7 @@ function renderTour() { elements["step-title"].textContent = step.title; elements["step-objective"].textContent = step.objective; elements["step-explanation"].innerHTML = renderMarkdown(step.explanation || "Waiting for the agent…"); + tourReading?.render(state.session, step); renderStepDiscussion(); renderAgentActivity(); const planningNotes = outline.planningNotes ?? []; @@ -5659,7 +5677,7 @@ function isInPlaceSessionUpdate(previous, latest) { ...outline, phases: outline.phases.map((phase) => ({ ...phase, - steps: phase.steps.map(({ explanation, ...step }) => step), + steps: phase.steps.map(({ explanation, explanationDetail, notes, ...step }) => step), })), } : outline, }); @@ -5681,7 +5699,7 @@ function isSamePhaseNavigationUpdate(previous, latest, { acrossPhases = false } ...outline, phases: outline.phases.map((phase) => ({ ...phase, - steps: phase.steps.map(({ explanation, ...step }) => step), + steps: phase.steps.map(({ explanation, explanationDetail, notes, ...step }) => step), })), } : outline, }); @@ -5710,6 +5728,7 @@ function renderInPlaceSessionUpdate(previous, { closeCommentEditor = false } = { if (step?.explanation !== oldStep?.explanation) { elements["step-explanation"].innerHTML = renderMarkdown(step.explanation || "Waiting for the agent…"); } + if (step?.explanation !== oldStep?.explanation || step?.explanationDetail !== oldStep?.explanationDetail || JSON.stringify(step?.notes) !== JSON.stringify(oldStep?.notes)) tourReading?.render(state.session, step); if (commentsChanged || threadsChanged) renderComments(); if (threadsChanged) renderPullRequest(); if (JSON.stringify(previous.activities) !== JSON.stringify(state.session.activities)) renderActivities(); @@ -5940,6 +5959,7 @@ async function startTour() { } async function preparePhaseNavigation(session) { + if (tourReading?.active()) return []; if (!state.monacoRenderer?.active || !isSamePhaseNavigationUpdate(state.session, session, { acrossPhases: true }) || isSamePhaseNavigationUpdate(state.session, session)) return []; const step = session.outline.phases.flatMap(phase => phase.steps)[session.currentStepIndex]; const phase = session.outline.phases.find(phase => phase.steps.some(candidate => candidate.id === step.id)); @@ -8400,7 +8420,7 @@ elements["comment-list"].addEventListener("change", async (event) => { }); elements["update-button"].addEventListener("click", installUpdate); -elements["current-step-return"].addEventListener("click", () => void focusCurrentStep()); +elements["current-step-return"].addEventListener("click", () => void (state.tourMode ? focusCurrentStep() : beginPreparedTour())); elements["pull-request-return"].addEventListener("click", returnToPullRequest); elements["briefing-source-return"].addEventListener("click", () => { activateReviewTab("briefing"); @@ -8409,6 +8429,32 @@ elements["briefing-source-return"].addEventListener("click", () => { setFilePanelOpen(false); renderFocusMode(); +tourReading = createTourReading({ + api, markdown: renderMarkdown, tokenHtml: syntaxTokenHtml, + view: () => ({ layout: state.diffLayout, wrap: state.diffWrap, tourMode: state.tourMode, activeTab: state.activeTab, syntaxTheme: state.syntaxTheme }), + highlight: async groups => (await api("/api/highlight", { method: "POST", body: JSON.stringify({ groups, theme: state.syntaxTheme }) })).groups, + canOpen: () => { if (state.busy || state.monacoRenderer?.hasDraft()) { toast("Save or cancel your draft and wait for navigation before opening another view."); return false; } return true; }, + scrollTop: () => { activateReviewTab("diff"); elements["diff-panel"].scrollTop = 0; }, + returnToDiff: () => { renderFilteredDiff(); void focusCurrentStep(); }, + openFile: async anchor => { + const wasTour = state.tourMode; + if (state.tourMode) await toggleTourMode(); + tourReadingReturn = wasTour || tourReadingReturn; + // Coverage and focused excerpts refer to the full comparison, including + // files hidden by a checkpoint filter. + if (state.session.comparisonView === "since-last-review") await switchComparisonView("full"); + if (state.session.comparison.files.some(file => file.path === anchor.path)) await showChangedFile(anchor.path, anchor.startLine, anchor.side, undefined, undefined, true); + else await showRepositoryFile(anchor.path, anchor.startLine); + renderActiveTab(); + }, +}); +document.getElementById("tour-reading-return").addEventListener("click", async () => { + if (state.busy || state.monacoRenderer?.hasDraft()) return toast("Save or cancel your draft before returning to the tour."); + tourReadingReturn = false; + if (!state.tourMode) await toggleTourMode(); + tourReading.showCode(); renderActiveTab(); +}); + state.monacoRenderer = createMonacoRenderer({ container: elements.diff, controls: document.querySelector(".diff-view-controls"), token: state.apiToken, snapshot: () => ({ diff --git a/public/index.html b/public/index.html index e5494c3..8ddcf7f 100644 --- a/public/index.html +++ b/public/index.html @@ -219,6 +219,10 @@

Peek a symbol

+