Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions docs/guided-reading.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added docs/images/tour-reading.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
64 changes: 55 additions & 9 deletions public/app.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"]) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ?? [];
Expand Down Expand Up @@ -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,
});
Expand All @@ -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,
});
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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");
Expand All @@ -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: () => ({
Expand Down
12 changes: 12 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ <h2 id="symbol-peek-title">Peek a symbol</h2>
<div id="symbol-peek-result" class="symbol-peek-result" aria-live="polite"></div>
</section>
<div id="empty-diff" class="empty hidden">No changes found for this comparison.</div>
<section id="tour-code-view" class="tour-code-view hidden" aria-label="Related code for this tour stop">
<header class="tour-code-heading"><h2 id="tour-code-title"></h2><button id="tour-code-close" class="button secondary" type="button">Back to phase diff</button></header>
<p id="tour-code-status" role="status"></p><div id="tour-code-groups"></div>
</section>
<div id="diff"></div>
</div>
<nav id="maps-navigation" class="maps-navigation hidden" aria-label="Diagrams">
Expand Down Expand Up @@ -289,6 +293,7 @@ <h2>Conversations</h2><p class="muted">Questions across every phase. Opening a c
<span id="structural-source-return" class="hidden"><button id="structural-graph-return" class="button diff-return" type="button">↩ Back to caller graph</button></span>
<button id="sequence-source-return" class="button diff-return hidden" type="button">↩ Back to diagram</button>
<button id="pull-request-return" class="button diff-return pull-request-return hidden" type="button">↩ Return to pull request</button>
<button id="tour-reading-return" class="button diff-return hidden" type="button">↩ Back to tour code</button>
<button id="briefing-source-return" class="button diff-return hidden" type="button">↩ Back to Briefing</button>
<button id="current-step-return" class="button diff-return current-step-return hidden" type="button">↩ Current step</button>
</section>
Expand Down Expand Up @@ -364,11 +369,18 @@ <h3 id="phase-title"></h3>
<div id="phase-finding-list" class="finding-list"></div>
</details>
</section>
<details id="tour-coverage" class="phase-files-details tour-coverage hidden"><summary><span>Not covered</span><span id="tour-coverage-count" class="count"></span></summary><p id="tour-coverage-summary"></p><ul id="tour-coverage-list"></ul></details>
<article class="step-card">
<div id="step-importance" class="eyebrow"></div>
<p id="step-freshness" class="tour-freshness hidden" role="status"></p>
<div id="step-anchor" class="anchor"></div>
<h3 id="step-title"></h3>
<p id="step-objective" class="objective"></p>
<div id="step-explanation" class="explanation"></div>
<details id="step-detail" class="tour-step-detail hidden"><summary>More detail</summary><div id="step-detail-body" class="explanation"></div></details>
<div id="step-notes"></div>
<button id="tour-step-code" class="button secondary" type="button" aria-pressed="false">Read this step’s code</button>
<div id="step-related" class="tour-related"></div>
<details id="agent-activity" class="agent-activity hidden">
<summary><span class="planning-spinner" aria-hidden="true"></span><span id="agent-activity-title">Agent activity</span></summary>
<div id="agent-activity-body" class="agent-activity-body"></div>
Expand Down
Loading
Loading