mcpField
G
guided-review

guided-review

github.com/jimhigsonTesting & quality039 installsUpdated 2w ago
Repository

About guided-review

guided-review is a skill for producing a locally served HTML review of a commit, PR, or working tree. It groups files by theme and embeds diffs, editable text and image comparisons, notes, and persistent checkboxes in one page, with scripts for review and collaboration workflows.

Key features

  1. 01Builds themed file reading order
  2. 02Embeds diffs in a single HTML page
  3. 03Provides editable text and image comparisons
  4. 04Persists review notes and checkboxes
  5. 05Supports PR stacks and reviewer notes

Use cases

  1. 01Reviewing changes across many files
  2. 02Inspecting a GitHub PR without a local checkout
  3. 03Reviewing merge or rebase conflict resolutions
  4. 04Coordinating notes and edits across a PR stack

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/jimhigson/guided-review --skill guided-review

After installing, type/guided-reviewin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

.gitignore
.npmrc
ackNote.ts
awaitNotes.ts
build.ts
buildPage.ts
buildStack.ts
changedFiles.sh
conflict.ts
conflictFiles.ts
editPlace.ts
github.ts
held.ts
mechanical.ts
mechanicalChanges.ts
moveDetection.ts
moves.ts
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
prComments.ts
remotePr.ts
reply.ts
resolvePr.sh
resolveStack.ts
restack.ts
fixtures/
commitStack.sh
conflictRepo.sh
localStack.sh
monorepo.sh

Details

From the project README

Guided review

Produces a single HTML page, served locally: a reading order over every file in a commit/PR/working tree, grouped by theme (not a strict topological sort — group similar work together even where that means bending dependency order), with a short note per file, that file's diff embedded inline — an editable Monaco editor for text, a pan/zoom compare viewer for images — and a checkbox whose state persists — to the review's own file when served, so you can read it too. The page is the whole review: nothing has to be opened elsewhere to read the change.

Serve it (step 8) rather than publishing it. A served page can be edited and saved back to the working tree and can keep its notes in a file; a published artifact is sealed behind a CSP that blocks it from reaching any endpoint, including localhost, so it can only ever be read. Publish only when there is no way to open a local port — a sandbox, or someone else's machine.

Use when a change is large enough (dozens of files) that reading it in diff/alphabetical order would lose the thread.

Almost all of this is scripted. The only part needing judgement is the one in the middle:

filedoes
resolvePr.sha PR number/branch/url → the local refs to diff, fetched (forks included)
remotePr.tsa pr url (or owner/name#n) → the same, cloning the repo bare and blobless when this machine hasn't got it
resolveStack.tsthe same target, or --stack for a gh stack (pushed or not) → the whole stack it belongs to, for the page's stack bar
buildStack.tsevery authored review of a stack (one groups json per PR, from however many agents) → ONE page with an in-place review switcher
changedFiles.shthe scope's file list as STATUS<TAB>PATH, binaries dropped — except raster images, which get compare viewers
mechanical.tsthe scope's purely mechanical files (imports only, reformatting, a rename whose old name is gone, code moved verbatim), for offering to pre-tick them
mechanicalChanges.tsthe judging behind mechanical.ts, which build.ts also runs to mark those files in the page
moveDetection.tsthe matching behind moves.ts, which build.ts also runs to box moved code in the page
moves.tsthe scope's moved code: blocks of removed lines that reappear elsewhere, verbatim or with the few lines edited on the way, and how much of each file is moved rather than written
(you)author a groups json: the grouping, the order, the per-file notes
snapshotGroups.tsor, for a snapshots-only review, generates that json mechanically — no authoring step at all
build.tscollects every diff and each file's whole before/after, strips/truncates/escapes, captures every version of each changed image, computes GitHub anchors, inlines the font and the built ui, writes the finished HTML
src/the page itself — a preact app in tsx: palette, layout, contents sidebar, diff rendering, the image compare viewer, notes, checkboxes
buildPage.tsbundles src/ to the one script and one stylesheet build.ts inlines
serve.tsserves the built page — how a review is normally delivered; its editors become editable and save back to the working tree
test/e2e.mjsend-to-end checks of a served review in headless Chromium, against throwaway repos (pnpm test:e2e). Run it before committing any change to the skill
fixtures/commitStack.sha throwaway stack of 2 PRs × 5 commits, for trying a per-commit review
prComments.tsa served page → its PRs' comments from GitHub, written beside each review for the page to show
awaitNotes.tstells you each reviewer note as it's written - streaming, or --once per note, however your host can best be woken (see "Keeping the notes channel open") - so you act on it while they are still reading
ackNote.tsconfirms you've seen a note, before you have an answer for it - turns "sent, waiting for an agent" into "the agent is on it" honestly
reply.tsanswers a note in its own thread — what you did, or the one question you need answered
stackEdit.tsheld edits, committing or amending them, and restacking the PRs above - git plumbing only, no checkout
editPlace.tswhere an edit for a note lands (disk, or which PR's held edits), shared by held.ts and reply.ts --edited
held.tsfor an agent acting on a note: makes the edit it asks for where the reviewer's own would go - open (disk, or a scratch worktree of the PR's held edits) then save
restack.tsfor an agent asked to resolve a conflict the page hit: status, resolve (sets it up in a scratch worktree), resolved (records the fix)

Read the scripts' own --help/header comments rather than re-deriving what they do. Don't hand-assemble HTML or paste diff text; if the page needs a change, change src/ so the next review gets it too.

Everything runs on node, against this directory's own package.json — not the reviewed repo's, which may have no node in it at all. The install also brings monaco-editor, which serve.ts serves to the page and will not start without. The first build in a fresh checkout installs it:

cd .claude/skills/guided-review && pnpm install --ignore-workspace

--ignore-workspace and the local pnpm-workspace.yaml both stop pnpm from walking up and treating the skill as a package of whatever repo it is sitting in. node_modules/ is build state and is gitignored.

1. Pick the scope: commit, PR, or working tree

Nothing here is specific to one repo — run it in whichever repo is the working directory. "Guided review of PR 34", "guided review #23", a PR url, a SHA, "my branch" and "what I've got uncommitted" are all normal ways to be asked.

Four supported entry points — decide which the user means before doing anything else, since it changes every git command downstream:

  • A single commit. You have a SHA (or the user said "commit <sha>"). The diff is exactly git show <sha>.

  • A PR on GitHub, with or without the repo here. A url - or "help me review " - is enough. remotePr.ts resolves it: in a checkout of that repo it fetches the PR's refs into it, as resolvePr.sh does; anywhere else (another repo, a home directory, /tmp) it clones the repo bare and blobless into a cache and reads from that - no working tree, nothing checked out, and only the file versions the review opens are ever fetched. A repo that is hundreds of megabytes checked out costs a few hundred kilobytes this way, and the second review of it is a fetch rather than a clone.

    node .claude/skills/guided-review/remotePr.ts https://github.com/owner/name/pull/34
    

    It prints {number, title, url, base, head, repo}. Pass that repo to every later command as --repo, including serve.ts. Served from a cache clone the review is read-only - there is no checkout to save edits into - but notes, ticks, the diffs and the PR's own comments all work. Use this whenever the user names a PR you have no checkout for; do not ask them to clone it first.

  • A whole PR. You have a PR number, a branch name, or the user just said "review this PR"/"review my branch". A PR is usually several commits — the diff is everything since it diverged from its base, which is what GitHub's "Files changed" tab shows and what a reviewer actually needs, not any one commit on it (even if the PR happens to currently be a single commit, treat it as this mode so the workflow doesn't silently drop commits if more land before you finish).

  • Uncommitted work. The branch has no commits of its own yet (common when reviewing your own in-progress work): git log origin/main..HEAD is empty and everything lives in git status. The diff is git diff <base> for tracked files plus the whole content of each untracked file. There is no GitHub page to link to — see step 3.

  • A conflict resolution. A merge, rebase, cherry-pick or revert is paused with its conflicts resolved (staged or not), or the user names a finished merge commit. Build with --mode conflict (plus --ref <merge> for a finished one). See "Conflict reviews" below. It works out current, incoming and ancestor for you, from MERGE_HEAD/REBASE_HEAD/CHERRY_PICK_HEAD/REVERT_HEAD or the merge's parents.

A branch with uncommitted work on top is still a whole PR: build it with --mode pr --base <base> --head <branch name> and serve it from the checkout. The served page's right side is the working directory: every uncommitted edit shows as it's made, files that exist only on disk arrive in the page's live chapter, and the review rebuilds itself on every commit and rebase.

NEVER make a commit to review. No git commit-tree, git stash create, WIP commit, or "working tree snapshot" to fold uncommitted work into a range, and never pass such a commit (or any SHA no branch points at) as --head. A review built on a copy of the working directory is frozen at the moment the copy was taken. It is not the reader's code, so the server can't connect it to the checkout: the editors are read-only, the reader's edits never appear, and it never rebuilds. To the reader it looks like the review has broken. Always pass the branch name as --head (HEAD is fine too: the build records the branch it names). build.ts refuses a head that is on no branch.

Identify which, and resolve it to concrete refs:

# whole-PR mode - takes a number, a branch, or a pr url; fetches what it needs
.claude/skills/guided-review/resolvePr.sh 34

git log -1 <sha> --format="%H%n%s%n%b"                 # single-commit mode
git status -sb && git log --oneline origin/main..HEAD  # to detect working-tree mode

resolvePr.sh prints {number, title, url, base, head}. The head comes from refs/pull/<n>/head, so a PR from a fork resolves exactly like one from a branch on the origin. Pass its base/head to everything downstream, and its number to build.ts --pr so the per-file links land on the right page.

For whole-PR mode, every following step's "the diff" means the three-dot range <base>...<head> (merge-base diff — matches what GitHub shows), never a single commit's SHA even if you found one via git log.

Starting from a SHA, it is worth checking whether a PR covers it (gh pr list --search "<sha or distinctive title words>" --state all --json number,title,url): if one does, review the PR rather than the commit, since that is what a reviewer is being asked about.

Conflict reviews

Offer one after resolving conflicts. When you (or an agent you ran) have just resolved merge/rebase/cherry-pick/revert conflicts, and the resolution was non-trivial, offer a conflict review before --continue or committing. Offer, don't impose: one line, eg "the resolution touched 4 files; want a 3-way review before I continue the rebase?". Non-trivial means anything past: whitespace-only conflicts, a regenerated lockfile, or taking one side wholesale where that was clearly the intent. The moment matters: while the operation is paused, git still holds every side, and the review can still change the resolution. A rebase stops once per conflicting commit, and each stop is its own review.

What goes in it comes from git, not from you:

.claude/skills/guided-review/changedFiles.sh conflict          # paused operation
.claude/skills/guided-review/changedFiles.sh conflict <merge>  # finished merge commit

It re-runs git's merge with git merge-tree (git 2.40+) and lists every file the resolution differs from git's own attempt at: first the files git could not merge (conflicted), then any the resolver changed although git merged them cleanly (edited past merge, eg a call site fixed up after a rename, or a new file the resolver created, staged or not). Files git merged cleanly and nobody touched are left out. They were never a resolver's decision. Which kind each file is goes to stderr and onto the page as a chip on the row. Staging the resolution doesn't lose any of this.

The page then offers a 3-way view beside inline and side-by-side, and opens in it: current | resolution | incoming, lined up. The resolution pane is the editable one, and notes live there. The ancestor never gets a pane of its own, but it drives the colours, which carry the review:

  • resolution lines from current (teal) or from incoming (blue);
  • the resolver's own lines, in neither side (amber) - read every one;
  • side lines left out of the resolution: dropped (red) where they were that side's own change - lost work unless it was meant - or faint where they were ancestor lines the other side removed anyway.

A file moved on one side is followed: each side's pane shows that side's copy from wherever it lived there, found with git's rename detection. The pane's heading names the old path, and the row reads as renamed rather than added. The ancestor's copy comes from whichever side kept the old path, since after the edits git may no longer see the ancestor's copy as a rename of the resolution's.

The 2-way views diff current → resolution. In the notes, say which side each decision favoured and why, and flag anything the resolver invented.

To try the whole flow without a real conflict to hand, fixtures/conflictRepo.sh <new dir> [merge|rebase] builds a throwaway repo paused mid-merge (or mid-rebase) with a resolution already written. Its header comment lists what the resolution does to each file, so you know what the page should show.

Stacks, on the forge or only local

Also check whether the change sits in a stack: a chain of branches, each based on the one below it. There are two ways to resolve one, and a stack does not have to be on a forge to be reviewed:

node .claude/skills/guided-review/resolveStack.ts 34       > stack.json  # from a PR
node .claude/skills/guided-review/resolveStack.ts --stack  > stack.json  # from gh stack
  • From a PR (a number, branch or url): walks the chain of open PRs, each based on the head branch of the one below.
  • --stack: reads gh stack view --json for the branches of this checkout's stack, pushed or not. Use it whenever the repo uses gh stack, and always for a stack whose layers have no PRs yet - reviewing a stack before submitting it is much of the point of building it in layers, and waiting for PRs would mean reviewing it after the fact. A half-submitted stack resolves in one piece: layers that do have PRs keep their numbers and links.

It prints {current, entries} in stack order (trunk end first) and says on stderr whether a stack was found. Each entry carries a key - its PR number, or its branch as a slug where it has no PR - and that key names its files in the stack directory, identifies it in the page's url, and labels it in the stack bar. Read keys out of stack.json rather than guessing them. One entry → forget it, nothing changes. Two or more → the page can carry the stack, in either of two shapes:

  • Reviewing just your PR: build.ts --stack stack.json as normal. The page grows a bar along the top: the whole chain, this PR highlighted, unreviewed siblings greyed with a forge ↗ link. (The diff range needs no special handling — a mid-stack PR's base is the stack-parent's head, so resolvePr.sh already diffs exactly what GitHub shows.)

  • The whole stack in one page: make a stack directory and build with buildStack.ts. The directory is the coordination point between however many agents contribute, one file per PR so nobody ever writes over anyone:

    <stackDir>/stack.json              resolveStack.ts output
    <stackDir>/<key>.groups.json       that layer's authored groups, one author each
    <stackDir>/<key>.instructions.md   optional: a request for another agent to
                                       author the groups json above
    node .claude/skills/guided-review/buildStack.ts --dir <stackDir> --out review.html [--current <key|pr|branch>]
    

    Put the directory somewhere every contributing agent can find from any worktree of the repo — the convention is $(git rev-parse --path-format=absolute --git-common-dir)/guided-review/stack-<trunk-most pr>. buildStack.ts fetches each PR from refs/pull/<n>/head, reads a local-only layer straight from its branch in the checkout (the only copy there is), diffs each layer against the one below it, and writes ONE page: every authored review switchable from the bar, siblings without a groups json shown greyed (marked awaiting review when an instructions file exists). A local stack needs no remote at all: with no origin the trunk is the local branch and nothing is fetched. Contributing is: write your PR's groups json into the directory and re-run the build — the serving process re-reads the html and creates the new review's store without restarting, and an open page sees the new review and reloads itself.

    The stack is followed while it is served. resolveStack.ts records how it resolved the stack (resolvedWith in stack.json), and the server resolves it again the same way - every 10s for a gh stack, every minute for a stack of PRs, which asks the forge. A PR joining it, leaving it or moving in it rewrites stack.json and rebuilds the page; open pages reload (or ask, with an edit unsaved), and the server's output names a PR that joined with no review yet - offer to author it, or leave instructions for its agent, as above. A stack.json from before resolvedWith isn't followed; resolve it again to start.

Admit what you have not read. The agent starting a stack review has usually only studied its own PR. Say so, and offer the two ways to fill the rest in: dispatch reading agents to author a sibling's groups json now, or write <pr>.instructions.md (scope, refs, where to put the json, how to rebuild) for the agent that owns that PR — its author will write better notes than a cold reader. Never silently author a shallow review of a PR you haven't read.

Every layer at once. With two or more authored layers, buildStack.ts also builds an all review, offered as a button at the end of the stack bar. It is the stack read as the single change it will land as: measured from the trunk to the top authored layer, so each file's diff is its whole cumulative change, not one layer's slice of it. Each file appears once, under the first PR that touches it, and its row says which PRs changed it - the answer to "who did this?" for a file only one of them touched. Where more than one wrote a note about a file, the row carries all of them, each behind its own label. The page says "PR 2/3" (the second PR of a stack of three, whether or not every PR has a review) rather than a branch name or the word "layer" - where a change sits in the stack is what a reader needs, and it reads the same whether the stack has been pushed or not; the branch or PR number is the tooltip. It has its own ticks and notes, separate from the layers' (it is its own review), so reading the stack whole and reading it layer by layer don't overwrite each other's progress.

For a local stack the bar shows branch names in place of PR numbers, and a layer with no PR is plain text rather than a link to a page that doesn't exist. fixtures/localStack.sh <new dir> builds a three-layer local stack to try it on.

Every PR of a served page is editable; one of them saves to disk. The review whose head branch the served --repo checkout has on disk (a commit or working-tree review names no branch, so it is that by definition) saves its whole-PR rows, and its uncommitted/working stops, to the files on disk, as always. Every other row of every PR - another branch's, or any commit's - saves into that PR's held edits (see "Editing any PR" under step 9): kept by the server as a ref, committed or amended from the commit bar when the reader says so. Staged changes, and the every-PR review read whole, stay read-only. Notes, replies and ticks work in every review — each has its own notes.json/ticks.json under its own id, so awaitNotes.ts/reply.ts point at whichever review's store the note belongs to. Beside each PR of a served stack the bar says which is checked out, and each PR's pill ends in a … menu (a popover, for actions on the PR itself rather than on reading it) whose Switch working copy to here runs gh stack checkout <branch> in the served checkout and reloads (git's refusal, uncommitted changes in the way say, comes back as a toast). A switch made anywhere else - a terminal, an agent - is noticed too: each poll says which branch is checked out (not while git is mid-rebase or merge, whose detached steps are no switch), and the page reloads for it, or, while an editor holds an edit not yet saved, says so in a toast with a Reload button (checkoutSync.ts).

2. Get the exact file list and status

.claude/skills/guided-review/changedFiles.sh worktree
.claude/skills/guided-review/changedFiles.sh commit <sha>
.claude/skills/guided-review/changedFiles.sh pr origin/<base> origin/<head>
.claude/skills/guided-review/changedFiles.sh conflict [<merge commit>]

It prints STATUS<TAB>PATH, uses the three-dot range for PR mode (diffing against the merge base, matching GitHub's view — two-dot would drag in unrelated commits that landed on the base branch since the fork), maps untracked ?? to A, and drops binaries with no reviewable diff (fonts, audio, archives). Raster images (png/jpg/gif/webp/avif) stay in the list: build.ts gives each one a compare viewer instead of a text diff.

Keep the status column per file — it drives the New/Modified/Deleted/Renamed badge, and tells build.ts which files need diffing against /dev/null.

Find the code that moved rather than changed

Run this on every review with more than a handful of files. It is cheap (well under a second for a few hundred files) and nothing else in the review spots extractions:

node .claude/skills/guided-review/moves.ts commit <sha>
node .claude/skills/guided-review/moves.ts pr <base> <head>
node .claude/skills/guided-review/moves.ts worktree

A refactor that splits a file or lifts code into new modules shows in git as hundreds of deleted lines and hundreds of new ones. A reader who is not told it is the same code will read it all again. moves.ts pairs them up and prints each moved block as old path:lines → new path:lines. It compares tokens, not lines, so re-indenting, prettier re-wrapping a call, and the trailing comma prettier adds or removes all leave a move intact. Each block is either verbatim or comes with its edits: the few lines that changed on the way, old above new. The edits are usually the declaration that changed shape (bubbles: render( becoming export const bubblesAppearance = render() or a call rewritten for its new home, and they are the only part of a moved block that needs reading. An edited line can still be partly moved: this.addFloatingText(pickup, room, ["+2", "LIVES"]) replacing a 4-line addItemToRoom({ … loadFloatingText(["+2", "LIVES"]) }) is an edit whose array came along. The page shades that part. It also gives a per-file summary of how much of each file moved out or in. For a file that is mostly moved code it lists what else is new, imports aside, or says "nothing else new but imports". An edit in place (the removed and added lines in the same hunk of the same file) is never a move. --json gives the same data as structured output.

Use the output in the steps that follow:

  • Reading (step 5): give each reading agent the moves.ts output for its chunk, and tell it that moved blocks are already accounted for: it should read their edits and whatever else is new, not re-read the bodies.
  • Grouping (step 6): put each moved block's source and destination in the same group, deleted-then-new, so the extraction reads as one thing.
  • Notes (step 7): say it was moved and where from, then name the edits. For example: "bubblesAppearance lifted out of appearanceForItem.ts's table unchanged apart from becoming a named export". Or: "the body of handleItemTouchingSlidingItem.ts, now onTouchedBy. Only the solidity check changed, !isSolid(x) → x[itemBehaviourKey].isNonSolid(x)". The reader can then skim the body and look closely at only the lines you named. When many files moved the same way (a table split into one file per entry), one group blurb covers them and the per-file notes can be a line each.

How it works: the removed code is indexed as one token stream, by every 4-token window with at least 2 names or literals in it, and by any comment or string of 20+ characters on its own. Each window of the added code is looked up, and each hit is extended both ways as far as the tokens agree. Longest matches are taken first, with no token used twice.

A match counts on its own when its code occurred once in what was removed and it has at least 24 characters across at least 3 names or literals (--min-chars, --min-words). Since it was unique there, it has nowhere else to have come from, so a single line is enough. Common code (false,, return;) counts only inside a block anchored by such a match, unless it is long enough (120 characters, --min-chars-common) to be no coincidence. Matches no more than 4 lines apart, in order, between the same two files, form one block (--max-gap). Imports never take part.

"Once in what was removed" is checked against the removed lines only, not every file at the old revision. Code also left unchanged elsewhere is a copy rather than a move, and isn't looked for. build.ts runs the same detection itself, so the page boxes moved code and labels where it came from whether or not you run this. Running it is for your notes. Code that was moved and had its names changed throughout won't match. That is safe, because it is then reviewed as new code, the same as without this step.

Offer to pre-tick the mechanical changes

Run this with moves.ts, before reading starts:

node .claude/skills/guided-review/mechanical.ts pr <base> <head>   # or commit <sha> / worktree

It lists the files the change touched only mechanically, with why:

  • imports: every changed line is an import;
  • reformatted: the same code once whitespace and trailing commas are set aside;
  • rename old → new: the same tokens but for one name swapped for another, where the old name is gone from the branch;
  • moved: code moved there verbatim.

A file counts only if every change in it is one of these.

If there are any, ask the reader (with your question tool) whether to tick them as read from the start. Quote the count and what they are, e.g. "54 of 258 files only change import paths (and one also has code moved in verbatim). Pre-tick them as read?". If they agree, set "preTickMechanical": true in the groups json's meta.

The page marks every mechanical file with a "Mechanical" chip, whose tooltip gives the reason, either way. With pre-ticking on, a review nothing has been ticked in yet starts with those files ticked. They're still in the reading order, folded, and if one changes later it unticks like any other read file. Don't spend reading agents on them: give each a one-line note saying what the mechanical change is.

3. Per-file links to the forge

build.ts derives these from the origin remote, so there is normally nothing to do: with --pr <n> it links each file into that PR's Files-changed tab, otherwise commit/<sha> or compare/<base>...<head>, anchored with #diff-<sha256hex(path)> — GitHub's own scheme (the path exactly as git prints it, no a//b/ prefix; renames use the new path, deletions the old). It prints the base url it settled on.

  • --github <url> overrides the derivation; --github none links nothing.
  • A repo with no origin remote gets no links, rather than a failed build.
  • Non-GitHub remotes get no links: every forge anchors diffs differently, and the page carries the diffs anyway, so no link beats a wrong one.
  • Working-tree mode has nothing to link to at all; there the path is a copy-to-clipboard button and the page should say so.

The link is a secondary affordance — a small "GitHub ↗" beside the path, for leaving a review comment — since the diff itself is in the page.

4. Diff capture is build.ts's job

It runs the right command per file for the mode (git show, three-dot git diff, or --no-index /dev/null for untracked files) to count +/− and to warn on any file whose diff came back empty (usually a stale path in your groups json), and prints the file count, line totals and the finished size — a couple of MB is normal with images; if it balloons, something belongs in --max-side-lines/--max-images territory.

What the page renders from is each file's whole before and after (git show <rev>:<path> either side, the working copy for the "after" in working-tree mode) — Monaco computes and folds the diff itself rather than being handed a patch. Files longer than --max-side-lines (default 2000) are not embedded; their row says so and points at the file in the tree.

Image files get versions, not sides. For each raster image in the groups, build.ts captures its bytes at every ref worth comparing — always production (the latest release tag), then per mode: a pr's base and branch, a commit's parent and commit, or a working tree's head/stage/working — dedupes byte-identical versions, and embeds them as one inert json block per image (so the payload itself never carries image data). The page's viewer can then compare any pair, not just the pair the review was built around. --max-images (default 100) caps how many images are embedded; rows past it still appear, marked as left out. All comparison happens in the page — there is no image tooling dependency here.

Generated files and lockfiles are worth leaving out of the groups json entirely, or giving a row and a note without reading their body.

5. Read the diff — delegate to parallel background agents

For anything beyond a handful of files, reading the full diff yourself burns context that's better spent on synthesis. Split the file list into 2-4 coherent chunks (e.g. "the core algorithm/data-structure files" vs "everything that consumes it" — split along the same lines you'd use to group the reading order, not arbitrarily) and dispatch one background Agent (general-purpose, run_in_background: true) per chunk, in a single message so they run concurrently.

Leave image files out of every agent's chunk. Reading pixels is expensive for an agent and worthless to the reviewer, who sees the diff at a glance in the viewer; the page also computes each image's %-of-pixels-changed line itself. Give an image a row (grouped where it belongs) and at most a note on why it changed, never a description of what it looks like.

Skip this step when you already have the change in context — eg you wrote it in this session. Re-deriving what you already know costs more than it returns.

Each agent prompt must be self-contained (the agent has no memory of this conversation) and include:

  • the repo path; in single-commit mode the commit SHA and its one-line message; in whole-PR mode the PR number/title and the exact origin/<base>...origin/<head> range; in working-tree mode that the change is uncommitted and how to diff it; either way, that this is read-only analysis — no edits
  • enough architectural background (quote relevant CLAUDE.md sections) that the agent doesn't have to rediscover the system from scratch
  • the exact file list for its chunk, with the read command matching the mode from step 1, and an instruction to also Read the current full content of the 3-5 meatiest files if the diff hunks alone are confusing
  • what to report back: (a) a short paragraph on the overall shift in its chunk, (b) a 2-4 sentence note per file covering what changed, why, and which other files (in-chunk or in the other agent's chunk, named explicitly) it depends on, (c) its own recommended reading order for its chunk, grouped into named clusters with a sentence on why each cluster is positioned where it is - each test file in the cluster of the code it tests, right after it, never a cluster of tests of its own
  • that chunk's moves.ts output, with the instruction to treat moved blocks as read except for their edits, and to report a moved file as "moved from X, plus Y" rather than describing its body afresh
  • an explicit instruction not to compress away the per-file notes — the report is consumed by you for synthesis, not shown to the user directly, so completeness beats brevity

6. Synthesize one macro reading order

Once both/all agents report back, merge their per-chunk orders into a single sequence. Don't just concatenate chunks — decide where each chunk's material actually belongs relative to the others (e.g. shared low-level primitives first, then the core mechanism, then the integration point that wires it into the rest of the system, then the periphery). Group by theme over strict dependency order where a strict topo-sort would fragment clearly-related work across the document.

Tests go alongside what they test, never in a chapter of their own. A test reads as evidence for the code beside it - what it is meant to do, and the case that broke - and a "Tests" chapter at the end strands it from that, so by the time the reader gets there the code it checks is chapters behind them. Put each test file in the chapter of the code it covers, right after that code. A test that covers several chapters goes with the one it checks most directly; test helpers and fixtures go with the tests that use them.

Within a group, order files start-to-finish the way a reader should actually open them: deleted-then-new pairs read together for contrast, foundational types before the code that consumes them, each test straight after the code it tests, trivial import-path-only renames batched and skimmed rather than given equal weight to substantive logic changes.

7. Author the groups json and build

Write one json file — the whole of your output:

{
  "meta": {
    "title": "the one-line thesis of the change",
    "headerTitle": "short name for the sticky header",
    "eyebrow": "reading order",
    "lede": "a paragraph on what the change is and why this order; html allowed",
    "facts": ["branch <code>x</code> vs <code>origin/main</code>", "…"],
    "footer": "html; eg where the ticks live, what was left out"
  },
  "groups": [
    {
      "title": "group name",
      "blurb": "one sentence on why this group sits here",
      "items": [{ "path": "src/…", "status": "M", "note": "2-4 sentences" }]
      // blurb and note both take html too - <code> especially
    }
  ]
}
Author a PR commit by commit

A PR is a chain of commits the way a stack is a chain of PRs, and the commits are how the change was written: each one a step, with its own reason. Where a PR has more than one commit worth reading apart, author it per commit instead of as one lump - replace groups with commits:

{
  "meta": { "title": "…" },
  "commits": [
    { "ref": "<sha>", "groups": [ { "title": "…", "items": [ { "path": "…", "status": "M", "note": "…" } ] } ] },
    { "ref": "<sha>", "groups": [ … ] }
  ]
}
  • ref is anything git resolves; the build reads its short sha and subject.
  • Each commit's chapters read that commit alone: a file's diff there is git show <sha> -- <path>, not the PR's whole range. The same file changed in three commits is read three times, each time as that commit changed it - which is the point. Write its note for that commit: what this step did and why, not a summary of the file.
  • groups and commits are alternatives. The build refuses both.
  • "ref": "uncommitted" is the one ref that isn't a commit: what the working tree has that is not committed at all, read against HEAD (and against nothing for a file git has never seen). Put it last - it is where the branch has got to since its last commit. Its rows are the files on disk, so unlike a commit's they stay editable and keep in step with disk; everything else in the bar is history.

The page then grows a commit bar under the stack bar: every commit of the PR, plus all, which is where it starts - the PR as a whole is still the thing being reviewed. Reading them all, each commit's chapters sit under a heading naming it; with the every-PR review on too, the PRs are the top heading and the commits sit under them.

A tick is against a file of a commit, not just a file. Reading every commit at once, ticking a file marks it in every commit it appears in (and in the every-PR review, across the PRs too), so the reading order still empties as you go; reading one commit, the tick is that commit's alone. A commit's rows don't save to disk - nothing on disk is that file as that commit had it - but they are editable: an edit is held against that commit, to be committed or amended into it from the commit bar (see "Editing any PR"), and a tick follows its commit when that remakes it. Notes stay keyed by file and line, and are shared by every commit that touches the file.

fixtures/commitStack.sh <new dir> builds a two-PR stack of five commits to try it on.

Each changed file goes in exactly one group — even one that bears on two themes sits in the one where it's best read, and the other group's blurb or notes can point to it. build.ts refuses a groups json that lists a path twice, naming the groups it appears in.

The file and line counts are computed — don't put them in facts. Then:

node .claude/skills/guided-review/build.ts \
  --groups <scratchpad>/review.json \
  --out <scratchpad>/review.html \
  --mode worktree
  # a commit:  --mode commit --ref <sha>
  # a pr:      --mode pr --base <base> --head <head> --pr <n>   (refs from resolvePr.sh)
  # a conflict resolution:  --mode conflict   (--ref <merge> for a finished merge)
Snapshots-only reviews are fully scripted

When the ask is "review the changed screenshot baselines" rather than a whole change, skip authoring entirely — snapshotGroups.ts writes the groups json itself (groups = snapshot directories, alphabetical, no notes: the page computes each image's %-changed line):

node .claude/skills/guided-review/snapshotGroups.ts --mode worktree --out <scratchpad>/groups.json
# or --mode pr --base <base> --head <head>, --mode commit --ref <sha>
# --glob 'e2e/*-snapshots' bounds what counts as a baseline (that is its default)
node .claude/skills/guided-review/build.ts --groups <scratchpad>/groups.json \
  --out <scratchpad>/snapshots.html --mode <same mode and refs>

Then serve as normal. This is the drop-in for "regenerated the baselines — are the diffs right?", including reading on a phone.

Then serve it (step 8) and hand over that URL. Rebuild and refresh to iterate; build.ts overwrites in place, so the server keeps serving the newest build without restarting.

Writing the notes

The per-file notes and group blurbs are for one reader, reading this change now — unlike a code comment, which has to make sense to someone who has never seen the previous version and will read it years from now. So the opposite rule applies here: a review note may be a historian. "This used to load the room a second time", "was a bare pair of refs", "the fix that follows in group 4" are all exactly right in a review and would all be wrong in the source. Say what changed and why, not just what the code now does — the code is on screen next to the note, and repeating it wastes the reader's attention.

Image rows mostly need no authored note at all: the page adds a mechanical WxH — N% of pixels differ line under each one, and the viewer shows the change better than words can. Write a note only when the reason for the change isn't obvious from the surrounding groups.

8. Deliver it — serve when reachable, send a file when not

First decide which mode you are in, using your own judgement about where you are running — there is no environment variable for this, so reason it out from what you know about the session:

  • The reviewer can reach a 127.0.0.1 port you open — an ordinary local terminal session, an SSH session onto their own machine, anything where "open a browser to localhost:<port>" is something they can actually do. Serve, exactly as this section describes: it is the richest form — editable diffs that save back to the working tree, and a live notes channel you act on as they are written.
  • They can't — a cloud sandbox, a CI job, a remote box with no port forwarded to the reviewer's browser (a served URL there is dead on arrival). Do not serve. Switch to non-reachable mode below: the built review.html is already a single file that reads window.__reviewServer and behaves accordingly, so hand it over as-is.

If genuinely unsure which one you are, ask the reviewer rather than guessing: serving somewhere unreachable hands them a dead link, and sending a file when you could have served loses the editable diffs and live notes for nothing.

Everything from here to "Publishing" is the reachable path. Skip it in non-reachable mode and jump to "### Non-reachable mode".

# reachable mode only:
node .claude/skills/guided-review/serve.ts --html <scratchpad>/review.html --repo . --open

--repo must be a checkout of the reviewed head — in pr mode, the branch's own worktree, not wherever you happen to be running from. It is where saves and notes-driven edits land, and the live-file poll compares open editors against its disk. In a stack page, whichever carried review's head branch the checkout has is the editable one; the server works this out itself and the others degrade safely to read-only-with-notes.

The review follows the branch, not a pair of commits.

  • Editing: build.ts records the head as a branch name even when given HEAD or a SHA, and the server matches the checkout by that name. Editing therefore survives new commits, rebases and amends. A head that is still a bare SHA, because no single branch points at it, matches a checkout that contains it.
  • Rebuilt when the branch moves: a commit, a rebase, an amend, or merging the base in moves the head commit or the merge base. The server notices on its next poll, waiting while a rebase or merge is still under way, and runs build.ts again in the background. It uses the recipe the first build embedded in the page (#recipe: its arguments with the head named as a branch, the authored groups json, and the files it left out), keeping the review's id, so notes and ticks carry over. The rebuilt HTML replaces the old one, so a reload or a server restart gets it too. The page takes the new build on its next poll: sides, counts, moved-code boxes, renames, images and links are all exactly what a fresh build would give. Notes, ticks, open diffs, scroll position and unsaved edits stay. A rebuild is the same code as a build, so nothing it works out can be left stale. It only applies to a single PR or working-tree review, not a stack or a per-commit reading.
  • The file list: the server compares the scope as it is now with the reading order. A file that came into scope since the build (new, newly changed, or renamed) gets a row in a last chapter, "Changed since this review was written", with a live diff. The server prints new in scope: <path> - not in the reading order for each one. When you see that, place the file in the groups json with a note and rebuild. Files that were in scope at build time but left out of the reading order (lockfiles, generated code) are recorded as leftOut and never offered. A listed file the change no longer covers (reverted, deleted after being added, renamed away) drops out of view, and comes back to its place if the change returns. The server prints that too. Every listed file reads with the status it has now, so a modification since deleted shows D. Rows are keyed by path, not position, so rows leaving and rejoining never move open diffs or folds.
  • The commit bar follows the branch too. A served review that follows its branch always has a commit bar, even with one commit: "all" (the review as written), each commit of the branch since its base, then staged (HEAD → index) and working (index → disk), each shown only while it has something in it. The server lists them on every poll (stops in /state), so a git add, an edit or a commit shows at once. A stop reads as the reading order filtered to the files it changes, plus a "Not in the reading order" chapter for the rest. Its sides are fetched from the server (/stop-sides) the first time it is read, and again whenever its files change. Working rows are editable and save to disk. Staged rows are read-only and say where to edit instead. A commit's rows hold their edits against it (see "Editing any PR"). A reload on a stop comes back to it. A review authored commit by commit keeps its own bar instead.
  • Read, then changed: a ticked file whose diff changes (an edit on disk, a rebase) is unticked and unfolded, with "Changed since read" until it is ticked again. Line counts follow every change, whether or not the diff is open.
  • Not followed between rebuilds: uncommitted edits on disk update the after side and counts live, but moved-code boxes and images wait for the next rebuild, which only a commit or a rebase triggers. A commit review, or a per-commit review, is history: its commit list and rows are fixed.
  • What an editor saves into: the hint under it says - the disk, the PR's held edits ("edits are held against this commit…"), or why it is read-only (staged changes, the every-PR review read whole).

It prints a http://127.0.0.1:<port>/ — hand that over as the review. Run it in the background so the session isn't blocked, and say which port it's on. Serving buys three things a file (or a published page) can't have:

  • Editable diffs. The modified side of each Monaco editor becomes writable and grows Save and Revert. Every save carries the sha256 the page was built from; a file that moved on disk since then is a 409 with "changed on disk — reload", never a silent clobber. Localhost only, token-gated, paths confined to the repo. Revert restores the content the review was built from in the editor only, leaving it dirty — nothing reaches disk without a deliberate Save, so a mis-click costs nothing.
  • Notes that persist. alt-N (or the editor's context menu) opens a note on the current line; served, notes go to <store>/notes.json and a markdown twin <store>/notes.md, so they survive a rebuild. "Copy notes" lifts them out for a PR comment.
  • Ticks that are a file, not a browser. What has been read goes to <store>/ticks.json — so it survives a rebuild into a different scratchpad or onto a different port, two tabs on one review agree, and you can read how far the reading has got (cat <store>/ticks.json) rather than having to ask. Unserved, ticks fall back to localStorage keyed by review id.

<store> is <the html's directory>/<review id>/, and holds all four of ticks.json, notes.json, notes.md and token. serve.ts prints it at startup, and build.ts prints the id it derived. The id comes from the repo and what is being reviewed (the commit, the pr number, the branch for a working tree) rather than from where the html was written, so the same review rebuilt tomorrow resumes; --id overrides it when two reviews of one scope need to stay apart.

Comments already on the PR

A PR being reviewed here usually has comments on GitHub too - a colleague's, or an automated reviewer's. Bring them into the page:

node .claude/skills/guided-review/prComments.ts --html <scratchpad>/review.html --repo . --watch 60

Each carried review finds its own PR (its number when it is a stacked PR, else whatever PR its head branch has) and its comments are written to <store>/github.json, which the page's existing poll picks up within a couple of seconds. --watch keeps reading, so comments left while the review is open appear in it; run it in the background beside serve.ts. A review with no PR behind it - a commit, a working tree, a stack layer nobody has pushed - is skipped, and the page shows none of this.

In the page:

  • An inline review thread sits on its line, in the diff, marked GitHub and carrying each comment with its author and age, and a link to the thread on the forge.
  • A thread with nowhere to sit - one about a line the change removes, or left behind when the branch moved on (the forge calls that outdated) - goes under the file's row instead, flagged with why.
  • A resolved thread folds to one line, and opens again on a click. It is not hidden: replying to a thread can resolve it on the forge (GitHub does this to its own reviewer's threads), and a thread that vanished the instant you answered it would look like the reply had gone nowhere. A thread you reply to here stays open for the rest of the session.
  • The row's path carries a count of the file's unresolved threads, in the --pr colour, beside the count of local notes.
  • The PR's own conversation and each review's verdict sit in a collapsible strip above the reading order.

Replying posts to GitHub, as whoever gh is signed in as - from the box in a thread, or from the strip for the PR itself. It only ever happens on a deliberate click in the page, and the thread shows the reply straight away because the server re-reads the PR as it posts. Nothing is posted by the agent, by the watch, or by opening the page.

Tell the reviewer the difference between the two kinds of comment in the page: a note is for you, the agent, and stays local; a GitHub reply goes to the PR, where the rest of the team will see it.

Act on notes as they are written

A note is a request, not an archive entry. Do not wait to be asked, and do not batch them up to the end of the review — the reviewer is sitting there, and a note acted on while they are still on that file is worth ten acted on an hour later.

Acknowledge it before you've worked out what to do about it:

node .claude/skills/guided-review/ackNote.ts --notes <store>/notes.json \
  --path src/ui/useStableValue.ts --line 7

The page's "sent, waiting for an agent" turns into "the agent is on it" the moment this lands - a truthful signal instead of an animation that runs the same whether anything is listening or not. Skip this only when you are about to answer outright with reply.ts in the same breath: a real reply already implies it, and acking first would just be a wasted round trip.

When a note arrives, one of two things happens:

  • The ask is clear ("move this into src/util/preact", "this should be a const", "drop this file from the change") — make the edit now, where the reviewer's own edit from that row would go, then say so in the thread. A note remembers what the reviewer was reading when they wrote it; from the PR the served checkout has checked out, read whole, their edit goes to disk, and from anywhere else - another PR, or any commit - into that PR's held edits, for them to commit or amend. Yours goes the same way, so it lands where they will look for it and they still choose what becomes a commit. held.ts works out which:

    node .claude/skills/guided-review/held.ts open --html <review.html> --repo <checkout> \
      --notes <store>/notes.json --path src/ui/useStableValue.ts --line 7
    

    It either says the row saves to disk - edit the files in the checkout as usual - or makes a scratch worktree holding the PR as of that commit, with whatever is already held applied. Edit there with your usual tools, then held.ts save --dir <that worktree> puts every change into the held edits, exactly as a save from the page would, and removes the worktree. Never check the branch out, commit, or amend yourself: committing what is held is the reviewer's call, made from the commit bar. If edits are already held against a different commit of that PR, open refuses - one place per PR - so ask in the thread for them to be committed or amended first. Then reply, naming every file you edited with --edited (once per file) - the reply ends in a list of them, each a link that reads that file in the page, where the edit landed (on disk, or on the held edits' stop):

    node .claude/skills/guided-review/reply.ts --notes <store>/notes.json \
      --path src/ui/useStableValue.ts --line 7 \
      --html <review.html> --repo <checkout> \
      --edited src/util/preact/useStableValue.ts --edited src/ui/Panel.tsx \
      --text "moved to \`src/util/preact\`, import sites updated"
    

    --text is markdown: code, bold, lists, links, fenced blocks. Use it where it helps - name the symbol in backticks, show the two-line snippet in a fence - and keep it short; it reads inside the diff. Pass multi-line text with $'...' or a heredoc.

    The page notices within a couple of seconds: the file's diff reloads from disk, its +N −M updates, a toast says what happened, and your message appears in the reviewer's own note box. Don't ask permission for something already asked for, and don't wait for the end of the review to do it.

  • It needs discussion — ask in the thread, with --asking, and keep it to the one question that unblocks you. The reviewer types their answer into the same box and it comes back to you through awaitNotes.ts. Only after that do you edit. Don't stop the review to ask in the terminal: they are reading the page, not the chat.

Reply in whichever medium the note arrived in, and only that one. A note came in through the page, not the chat — answer it with reply.ts, in its thread, and leave the chat conversation alone. Saying the same thing twice, once in the thread and again in chat, isn't thorough, it's just confusing.

Say what you did in one line, not a paragraph — the note box is small and they are mid-review.

Notes reach you three ways:

  • A watch on notes.json: awaitNotes.ts, run the way your host supports best. See "Keeping the notes channel open" below. This is the one that matters, because it's the only way notes reach you mid-review.
  • Every note also prints live in the server's own output: note <path>:<line> <text>.
  • "Send notes to agent" in the header prints the lot between === N review note(s) handed off === markers and rewrites <store>/notes.md. That's the reviewer's "I'm done, over to you" signal. Tell them it's there.

If a note is ambiguous, ask about that one note rather than stopping the review; the page keeps working while you talk.

A conflict the page hit: offer to resolve it

Committing held edits replays the PR's later commits, and Rebase upstack replays the PRs above. When a replay conflicts, nothing moves and the page puts it to you as a note, on the first conflicting file, in the review of the PR it happened in: which commit, onto what, in which files. Offer to resolve it in your reply, and when the reader agrees (or asked you to just handle such things):

node .claude/skills/guided-review/restack.ts resolve  --html <review.html> --repo <dir> --dir <scratch>
# fix the conflict markers in the files it lists, in <scratch> - each side is labelled
node .claude/skills/guided-review/restack.ts resolved --html <review.html> --repo <dir> --dir <scratch>

resolved records your fix for exactly that commit onto exactly that parent (a ref, so it lasts) and removes the worktree; it touches no branch. Tell the reader in the thread, and that pressing the same button again will now go through. restack.ts status says what is held, what is stale and what the last conflict was.

Keeping the notes channel open

A note nobody reads is worse than no note: the reviewer thinks they've been heard. The watch has to bring you back each time a note is written, for the whole review. How to arrange that depends on what your host can do, and hosts differ a lot. awaitNotes.ts has two modes to cover them:

# streams each new note as it's written, and never exits on its own
node .claude/skills/guided-review/awaitNotes.ts --notes <store>/notes.json
# waits for new notes, prints them and exits - re-run it after every one
node .claude/skills/guided-review/awaitNotes.ts --notes <store>/notes.json --once [--timeout <seconds>]

--once loses nothing between runs. What has been reported is kept in <store>/awaited.json, so a note written while you were busy is still new to the next --once, which returns it straight away. Relaunching is always safe. Don't delete or hand-edit that file mid-review, or the next wait will treat everything already there as seen.

Work out which category your host is in, and use the most reliable one it supports. The hosts named below come from their docs and changelogs as of September 2026. That will date quickly: these tools gain features every few weeks. If your host now has a more reliable way to be woken than the category listed for it here (a streaming watch where only exit notifications used to exist, exit notifications where there were none, a built-in file watch, a push channel), use that instead. Equally, if the listed way doesn't work in your session (the tool is missing or disabled, or a wake never arrives), drop to the next category down rather than trusting the list.

1. A streaming watch that wakes you on each line. Claude Code's interactive CLI, with its Monitor tool. Run the streaming mode under Monitor right after serve.ts, and act on each note as its event arrives. A Monitor watch can end at a deadline. When it does, start it again at once: nothing is missed, because the restart picks up from awaited.json. Monitor isn't available everywhere (for example with some providers, or with nonessential traffic disabled). Without it, use category 2.

2. A background command whose exit wakes you. Claude Code (run_in_background), Cursor (the IDE agent and cursor-agent), GitHub Copilot CLI, GitHub Copilot's agent mode in VS Code (background-terminal notifications, on by default since VS Code 1.116), opencode v2 (a background: true shell call), and Gemini CLI, but only with tools.shell.backgroundCompletionBehavior set to "inject" (its default, silent, wakes nothing).

  • Run --once as a background command. It returns when a note arrives, and that exit is what brings you back. Handle the note (ackNote.ts, then the edit and reply.ts), then relaunch the background --once straight away.
  • These wakes aren't perfectly reliable: some hosts occasionally drop or delay one. VS Code's may only land on your next turn rather than starting one when the chat is idle. So also check at the end of every response that a --once is still waiting, and relaunch it if not. That costs nothing, thanks to awaited.json.
  • The streaming mode is no use here. It never exits, so it never wakes you.

3. Only blocking commands. OpenAI Codex (CLI, IDE extension and app), Zed's agent, Goose, Cline, Windsurf/Cascade, Amp, and Gemini CLI on its default settings. Nothing wakes you between turns. You only see output from a command you are waiting on.

  • Run --once --timeout <seconds> in the foreground, with the timeout under whatever your host kills commands at. Some limits: Gemini CLI kills a command after 300 s without output (and --once prints nothing while it waits), so use --timeout 240 or less there. opencode v1 defaults to 2 minutes. Codex polls a running command for at most 5 minutes at a time. Where the limit isn't known, 240 is a safe choice.
  • On "no new notes", run it again. On notes, handle them and run it again. This holds your turn open for the length of the review, so you can't chat meanwhile. Say so up front, and stop when the reviewer says they're done, or when they interrupt you to talk.
  • Start the next wait as the last thing you do in every response, including replies to chat that has nothing to do with the notes. A response that ends without one is where the review gets lost.
  • Codex can instead run the wait from a Stop hook that blocks with the notes as its reason, which continues the session when notes arrive. Only set this up if the reviewer wants it, since it changes their Codex config.

4. Nothing between turns. Cloud agents (GitHub Copilot's coding agent, Codex cloud) and Aider, which runs no commands on its own. A cloud agent can't serve to the reviewer's machine anyway: use non-reachable mode (below). Otherwise the notes arrive only when the reviewer hands them over, with "Send notes to agent" and a chat message, or by pasting notes.md. Say that plainly at the start, so they don't wait on a reply that isn't coming.

Whatever the category:

  • Run serve.ts in a background terminal of its own, never the one you wait in, so a wait can't block it or be killed along with it.
  • Tell the reviewer, in one line, how their notes reach you: live, between turns, or only when handed over. If a note seems to go unanswered, typing anything in the chat brings you back to it.
  • When the review is done, stop the watch (a streaming watch, or a background --once still waiting) and the server.

Both are Monaco-only, and Monaco comes from serve.ts itself — it serves its own monaco-editor install at /vs and refuses to start without it, so a served review works fully offline (the page shows an offline — notes queue locally chip and note threads say an agent will pick them up later, but nothing stops working). Any static server will serve the page for reading, but only serve.ts answers /vs, /save and /notes.

Non-reachable mode (sandbox): send the file as-is

When the reviewer can't reach a served port, the built review.html is already a single self-contained file (font and ui runtime inlined, every before/after and image embedded) that branches at runtime on window.__reviewServer: only serve.ts injects that, so opened directly the page is cleanly read-only — the per-line note-authoring UI never wires up (no gutter + glyph, no alt-N/context-menu action, no view zones), and Save/Revert/"Send notes to agent" stay hidden. Code rows have no diffs without a server: Monaco is a dependency served only by serve.ts, and an unserved code row states that plainly rather than degrading. Everything else works: reading order, groups, blurbs, the contents sidebar and its draggable split, per-file notes text, status chips, path/GitHub links, the image compare viewers and their %-changed lines, and the ticks, per-file folding + progress bar. There is nothing to strip and no second build — build.ts in step 7 already bundled the page and wrote the finished HTML, and this mode delivers exactly that file.

Deliver <scratchpad>/review.html to the reviewer:

  • SendUserFile (display: "render") is the default — they open it locally and tick through read-only (images in full; code rows say to serve for diffs). If they want to leave you feedback they paste it into the chat.
  • Artifact instead when they want a shareable link for a teammate (see Publishing) — a hosted page rather than a file.
Publishing, only when a port isn't an option

Reach for Artifact only where a local port can't be opened or reached — a cloud sandbox, or a reviewer on another machine. Then publish review.html directly (it is already read-only without a server behind it) with a topic-fitting favicon and a one-sentence description; re-publishing the same path keeps the URL.

Know what is given up: the artifact host's CSP allows same-origin connections only, so a published page cannot reach 127.0.0.1 (nor any tunnel — the block is connect-src, not the scheme, and Chrome's private-network rules would refuse it besides). It is read-only by construction: no saving, notes fall back to that browser's localStorage, and code rows have no diffs (Monaco only comes from serve.ts) — a published review is for its prose, groups and images.

9. Changing the page itself

Only when the page needs to look or behave differently — otherwise skip this section entirely.

Test before you commit: pnpm check:type and pnpm test:e2e, from the skill's directory. The repo's CLAUDE.md says how the suite works and how to add to it. Most of what the page does only shows in a browser, and most bugs here have been regressions in earlier behaviour, so run all of it. Edit src/ so every future review inherits the change. Load the artifact-design skill before doing so. This is a utilitarian reference tool, not an editorial page — polished typographic hierarchy and a real (small) palette, no hero, no flourish.

It is a preact app, built by vite

src/ is a normal preact + tsx app on preact 11, bundled by buildPage.ts (vite, library mode, write: false) into one es module and one stylesheet as strings, which build.ts inlines into the page along with the font and the payload json. Nothing is fetched at runtime but monaco, which comes only from serve.ts's own install at /vs — no cdn, no fallback renderer: a missing monaco is reported as the failure it is, and an unserved page's code rows say to serve the review.

  • pnpm exec tsc --noEmit in the skill directory typechecks it; the whole app is typed, including a hand-written description of the parts of monaco's api the page uses (src/monacoApi.ts), since monaco arrives as a script global rather than a bundled import.
  • The page is assembled in build.ts's page(): charset, <style>, #app, the <!--REVIEW_SERVER--> marker, the payload, the module script. Every < in the payload is escaped to \u003c — a valid json escape the parser undoes for free — so a reviewed file quoting that marker, or a closing script tag, cannot break out of the block it is sitting in.
  • src/page.css carries the whole stylesheet, with FONT_B64 the one placeholder build.ts fills in.

Two shapes carry state between the parts:

  • App owns the reading state — ticked, collapsed, closedGroups, openDiffs, activeId — and hands one state object down to the header, the sidebar and the groups. That single object is why a tick in the sidebar and a tick on a row are the same tick; don't add a second copy anywhere.
  • makeStore/useStore for the things the imperative side owns — notes (written from monaco view zones and the server poll), toasts, and the inline/side-by-side choice (read by every monaco editor, mounted or not). Anything rendered from notesStore re-renders when a note lands, which is how the note counts appear in both the sidebar and the row heads.

Monaco stays imperative — it owns its dom. createDiffEditor builds one editor and reports back through the setters MonacoDiff passes it, and note zones are preact trees rendered into the zone's dom node.

Palette: VS Code's own default theme

This is a code review tool; it borrows its palette from the tool developers already read diffs in all day, rather than inventing one. Every colour traces to a key in VS Code's own default theme - 2026 Dark / 2026 Light, shipped as default since 1.113 - read straight from extensions/theme-defaults/themes/2026-{dark,light}.json in microsoft/vscode:

tokensource keyuse in the review page
--papereditor.backgroundpage ground
--cardsideBar.backgroundchapter cards, header, toasts
--control-bgcheckbox.backgroundneutral button/checkbox fill
--rulepanel.border / sideBar.borderdividers
--ink / --ink-soft / --ink-fainteditor.foreground / descriptionForeground / disabledForegroundtext hierarchy
--accent / --accent-hoverbutton.background / button.hoverBackgroundlinks, primary actions
--accent-inkbutton.foregroundtext on a solid --accent fill
--new / --deleditorGutter.addedBackground / deletedBackgroundNew/Deleted chip text, diff stats
--modnotificationsWarningIcon.foregroundModified chip text
--pr / --pr-bg / --pr-edge / --pr-inkcharts.purplewhich PR of a stack: the stack bar, the "all" switch, the chips on a row, the labels on gathered notes. Used for nothing else, so a PR reference is recognisable wherever it appears - keep it that way
--new-bg / --mod-bg / --del-bgdiffEditor.insertedTextBackground / gauge/warning tint / removedTextBackgroundchip and callout fills, all translucent - VS Code's own colours lean on alpha throughout, so this palette does too

page.css's own :root block carries the full, current set of tokens as comments alongside each value - read that over this table if they disagree, the table is a summary. Borders are back (VS Code's own UI uses them throughout); the page's own monaco diff theme (monacoLoader.ts) pulls the same values for editor.background/diffEditor.*/editorLineNumber.foreground, so a diff on this page looks like a diff in VS Code itself.

There is no custom webfont: --display and --body are both the plain system stack (system-ui, -apple-system, "Segoe UI", Roboto, sans-serif), matching VS Code's own workbench, which doesn't have a separate display face either - headings are told apart by size and weight, not typeface. Paths and diffs stay in ui-monospace.

What the page already does — preserve it if you edit
  • Sticky header: live progress bar and checked/total count, open-everything, close-everything, clear-ticks, and a Diffs select (side by side, which is the default, / inline, plus 3-way on a conflict review only).
  • The contents is opened from the edge it lives on, not from the header: a vertical Contents tab fixed to the left of the viewport, shown only while the contents is closed. Closing it is a chevron inside the sidebar's own head. A toggle at the far end of the toolbar was too far from the thing it opened - closed, the sidebar leaves nothing else behind to say it exists.
  • A stack bar above the header row, only when the shell knows a 2+-entry stack: every PR of the chain in order, the active review highlighted, and - when the build made one - an all button at the end, which reads every PR at once (see "Stacks, on the forge or only local"). It is not a step of the chain and sits outside its arrows, and it is a button rather than a checkbox: every checkbox along this bar means "every file of this one is read", and this means "show me all of them" - the same control saying two different things is worse than two shapes. Clicking any PR goes back to reading that one. Everything in the bar is drawn in --pr, the colour kept for which-PR references. A sibling the page carries is a button that switches the whole page to that review in place — selectReview reparses its inert payload block and the App remounts keyed by review, reloading that review's own notes and ticks and restarting the image sweep; a switch away from unsaved editor changes asks first. A sibling with no review is greyed with a forge ↗ link, plus an awaiting review chip when an instructions file was left for its agent. The bar lives inside header.top, so the measured --header-height — and everything sticky under it — already accounts for it.
  • A contents sidebar, sticky beside the reading order: every group and every file as a tree, each with its own checkbox and done/total, the file ticks and the row ticks being the same state. Its three views - chapters, fs tree, diff size - are one tree widget (see "Every contents view is a tree widget"). Each file, and each chapter's heading, is a real link, so a middle-click or "open in new tab" opens it in a tab of its own; a plain click opens its group and the file itself and scrolls to it; a scroll-spy keeps the file being read highlighted, suppressed for a second after a click so an explicit jump isn't overruled by the rows it travelled past. Under 60rem it becomes an overlay that starts closed and closes again once used.
  • The split between the two is draggable, from react-resizable-panels (a react library, reached through preact/compat — resolve.alias in buildPage.ts and matching paths in tsconfig.json mean one preact renders both it and the page; two copies would leave its hooks with no renderer). Its three components take className, not class. The width persists under autoSaveId, per reader rather than per review.
    • Layout renders the PanelGroup only in the docked case. Overlaid (under 60rem) or with the contents closed there is nothing to divide, so it renders the plain flex .layout instead, and the same 60rem threshold drives the markup (useContentsOverlays) and the stylesheet.
    • The panels must not become scroll containers or the page would stop scrolling as one document: the group is height: auto, both panes are overflow: visible, and the handle is a full-height grab strip drawing the rule that used to be the sidebar's border-right.
    • Switching between the two layouts remounts every row, so the scroll-spy observer lives in rowNodes.ts and observes rows as they register rather than once at app mount.
  • Each group opens by default, with a reading-order number (real structure here, unlike decorative 01/02/03 markers elsewhere), the blurb, and its file rows. Its heading is sticky under the page header (which is measured, so it survives the controls wrapping), and carries a checkbox that ticks the whole group, shows indeterminate when it is part-read, and folds the group away when ticked (unticking brings it back). That checkbox has to stopPropagation, or the heading's own toggle would fight it.
  • Each row: a checkbox keyed by path — persisted to <store>/ticks.json when served and to localStorage (keyed by review id) when not, loaded before the first render either way, coalesced so ticking a group is one write, and re-read from the poll so a second tab agrees — a status chip, the path as a copy-to-clipboard button (plus a "GitHub ↗" link when --github was passed), its +N −M, and a caret. Ticking a file folds it away (unticking opens it again), so the page collapses towards the bare reading order as it is read, and a reload comes back folded exactly where the reading got to. The folded part holds the note and a Show diff / Hide diff button over a panel that starts closed — a real button, because the reading order has to stay scannable and the diff is an action taken when the reader arrives at that file.
  • Diffs render lazily on first open (dozens of them would otherwise all build up front) and then stay mounted, hidden, when closed or folded away — disposing them would throw away unsaved edits every time a file was ticked. They render into a Monaco diff editor, loaded from jsdelivr: syntax highlighting per file extension, and hideUnchangedRegions so long files collapse to their changed parts — the reason for using an editor rather than a static patch. It is read-only, its language services' diagnostics are off (nothing here is being fixed, and it saves a multi-megabyte worker), its themes carry the page's own palette, and scrollbar.alwaysConsumeMouseWheel: false keeps the page scrolling when the pointer crosses an editor.
  • Image rows open a compare viewer instead of Monaco. Its toolbar is a version chooser (one column per captured version — production/main/branch etc — radios above the labels pick the "from" side, below pick "to"; a version can't be both sides at once; byte-identical columns are greyed with a tooltip), a ⇄ swap, the mode strip from | to | diff | swipe | flick, an overlay checkbox that composites the magenta diff over the single version showing (from/to only — diff mode IS the overlay and swipe already shows the pair, so it disables there), zoom diff (frames the diff's bounding box), fit, and a px/% readout (the version label currently on screen, in flick mode). Flick alternates the from/to layers every 600ms instead of showing both at once — a real difference reads as motion, easier to catch than a static overlay for small or subtle diffs. Diffs are computed in the page via canvas — magenta on transparent over the union extent, out-of-bounds pixels counted as differing — and memoised per pair.
  • The viewer's stage pans and zooms freely. Pointer drag pans (in swipe mode a drag starting within 24px of the divider moves the divider instead — anywhere else still pans); two-finger pinch zooms; double-tap resets to fit; ctrl/⌘-wheel zooms at the cursor (macOS trackpad pinch arrives exactly so); other wheel input is split by heuristic — line/page deltas or coarse single-axis pixel steps read as a mouse wheel and zoom, fine or two-axis deltas read as a trackpad and pan, with a 400ms momentum window so a flick's tail can't flip to zooming. The wheel listener is non-passive on a touch-action: none viewport, or none of that could preventDefault.
  • Each image row carries a mechanical summary line — WxH — N% of pixels differ (Npx) — computed by a background sweep after first paint, one image at a time, its pixel data released as it moves on. Image bytes live in one inert <script type="application/json"> block per image, parsed only when that row needs them — the payload and the executable script stay small, which is what keeps phone webviews rendering the page at all.
  • Image notes are file-anchored. No lines to hang a note on, so a served image row has an Add note button opening the same thread UI as a line note, stored at line 0; reply.ts --line 0 answers it and the markdown twin shows it as L0.
  • Side by side or inline is the header's Diffs select, applied to every editor at once. Side by side is the default: before and after are what a reviewer compares, and inline asks them to do that down one column — diffViewStore is read when an editor is built and subscribed to for the ones already on screen, and remembered in localStorage per reader. Side by side needs asking for twice: renderSideBySide: true alone does nothing in a pane this narrow, because monaco's useInlineViewWhenSpaceIsLimited drops back to the inline view below ~900px on its own. Both go in sideBySideOptions.
  • Moved code is boxed in the diff. build.ts runs the same detection as moves.ts (both use moveDetection.ts) for the review's range, and for each commit of a per-commit review. It embeds each file's moved runs as payload.moves, keyed as sides is. wireMoves in createDiffEditor.tsx draws each run as a box shaded in the moved colour (--moved, which is --mod, the colour .tw-moved already used). A tab in the same shading sits on top of the box: "moved here from x.ts:390" on the after side, "moved to" on the before side, and whether the code is verbatim. The far end is a link to that file's row when the review lists it. Lines edited on the way keep the box's sides but not its shading, and get a blue gutter bar, because they are what needs reading. The box is whole-line decorations (sides on every line, a top on the first, a bottom on the last). The tab is a view zone, so a side-by-side diff stays level (monaco pads the other side), and it sits above monaco's text layer as .note-zone does, or the link can't be clicked. A row's stat line adds "N moved in / out", and the contents chip's size bars leave moved lines out, because a moved line costs a glance, not a read. Imports never count towards a move: a split scatters them across every new file. A 3-way surface shows no moves, since a conflict resolution moves nothing of its own.
  • Space ticks the file being read, the one the contents highlights, and Space again unticks it. It doesn't while you're typing into a Monaco editor, a note, or any field. Over a focused button it ticks instead of pressing the button. The row's tick box says so in its tooltip.
  • HEAD is pointed at. The PR whose branch is checked out wears a HEAD chip in the stack bar, and the commit HEAD is at wears one in the commit bar. Both follow every poll (checkoutStore), so a new commit moves it at once; a switch of branch reloads the page.
  • Hovering a PR or a commit says how big it is. The stack bar's PRs (and "all") give their place, commits, files and lines added and removed; the commit bar's commits give their place in the PR, files and lines; a served bar's staged, working and held stops their files, and lines once read. The build records each PR's size on its shell entry (sizeOf: git diff --shortstat from where it left its base, so net across the PR) and each commit's lines in the edit index; barTips.ts says it.
  • Every diff says what it is between. Above each editor: "diff main~2 (52e9aab) → item-behaviours (a84dc34)", plus "+ working tree" when the right side follows the disk. The names are git's own (name-rev), recorded by the build for the review's range (payload.refs) and for each commit of a per-commit review (commits[].refs). A head given as a branch keeps that branch's name. A commit nothing refers to has no name, so it shows as a bare SHA. That makes a review of anything other than what the reader thinks it is, another branch or a stale or made-up commit, obvious at a glance.
  • A file edited elsewhere says where. Under a row's note: "also edited in PR 1/3 commit 3/5", naming each other PR of the stack and each other commit of a PR that changes the same file. Places the page is already showing are left out, so reading a whole PR names only other PRs, and reading every PR and every commit names nothing. Where the stack is one PR, the PR part is dropped. Where a PR is one commit, the commit part is dropped. Each place is a link to the file there: it switches review and commit in place, and lands on the row the way a reload does (?file=). A commit only the served commit bar knows is asked for, and opened when the next poll lists it. A PR whose review the page doesn't carry is plain text. The build gathers an edit index into the shell (shell.edits): every commit of every PR of the stack, reviewed or not, and byPath, each path to the commits that change it. A row looks up its own path and nothing else (alsoEditedIn.ts). buildStack.ts indexes every layer. build.ts --mode pr indexes its own PR, and with --stack every layer too, fetching the siblings; one it can't fetch is a warning, not a failed build. "PR 2/3" is the second PR of a stack of three, reviewed or not, everywhere on the page. fixtures/commitStack.sh edits src/parse/block.ts in both of its PRs to try this on.
  • Editing any PR: held edits. A save from any row the checkout's disk doesn't back - another PR's, or any commit's - goes into that PR's held edits: a commit kept as refs/guided-review/hold/<branch>, whose parent is the commit the row reads (the PR's last commit, for a row read whole). Each save amends it (stackEdit.ts, all plumbing - a scratch index, commit-tree, update-ref with the old value, so two racing saves fail one rather than lose it). They show as a held stop on the commit bar, right after the commit they were made against, with a row per file diffing that commit against them; the row they were made from shows the held content too, so editing on from there edits what is held (heldSync.ts). One place per PR: editing another of its commits is refused, saying where the edits are held. Editing back to what the commit has lets the hold go. Under the commit bar, the held bar offers New commit (a message, then a commit inserted after that one), Amend into that commit, and Discard (kept as a backup ref). The stack bar marks a PR with held edits. Each message a reviewer writes records the commit they were reading (commit: a sha, or held:<branch>; absent for a PR read whole), so an agent acting on a note makes its edit into the same place with held.ts, and awaitNotes.ts says what each note was reading.
  • Committing or amending moves the PR at once; the PRs above wait. The PR's later commits are replayed onto the new one in memory (merge-tree --write-tree), and only if all of them replay cleanly does anything move - in one ref transaction, the old head backed up as refs/guided-review/backup/<branch>/<time>. A branch checked out in the served checkout takes its working tree along (read-tree -m -u, keeping uncommitted changes, refusing where they'd be overwritten); one checked out in any other worktree just has its ref moved. Replayed commits keep their author, message and dates, so the same replay always makes the same commits (and a recorded resolution is found again). The PRs above are left on the old head, noted as refs/guided-review/restack-from/<branch>, and the stack bar offers Rebase N upstack, which replays them all onto the moved PR the same way - all or nothing. This is not gh stack rebase: that can't replace a commit in the middle of a branch, and needs a checkout to stop in. A conflict moves nothing and becomes a note to the agent (see "A conflict the page hit"). After a move the server remaps every review's ticks and the authored groups' commit refs to the new commits, rebuilds the page from its recipe (a stack page has one too, kind: "stack"; a per-commit or stacked review's recipe has follow: false, so it is rebuilt only then, never on its own) and the page reloads.
  • Monaco's diff gutter menu is off (renderGutterMenu: false): it lays its items out from the last diff, so a file cut shorter - Revert, held content arriving - asked it for lines that no longer exist and it threw "Illegal value for lineNumber". Revert is an ordinary edit, so it can be undone.
  • Every contents view is a tree widget (Headless Tree, @headless-tree/core and /react), through one component, ContentsTree.tsx; each view only builds its nodes - files, and folders with their own heading markup (a chapter, a directory run inside one, an fs directory or package; diff size is a flat list). One tab stop; arrows move, left/right fold and unfold, Home/End, Enter goes to the file (or a chapter's first file); click, ctrl/cmd-click, shift-click, shift-arrows and ctrl-A select; Space ticks everything selected (or unticks it, all ticked already), and ticking a selected item's box ticks the selection. A plain click on a chapter goes to its first file and leaves it open; its caret folds it. A folder a shift-range only passes through doesn't pull in its other files - judged by the files it stands for, since a read file moves up out from under its heading. A view hands its nodes over read-first, as it shows them, so the keyboard walks what the eye does. The library owns focus and selection; the rows are the page's own. Ticking moves a row, which drops its focus, so a tick from the tree puts the focus back, and the page's own Space leaves a tree alone. In a tree, ctrl/cmd-click is selection, so a new tab is a middle-click or the link's context menu. Icon glyphs (carets) are written as \uf229-style escapes: the raw private-use characters are invisible, and an edit can drop them unseen.
  • A renamed file is diffed against its old path. build.ts asks git for the scope's renames (--name-status -M) and reads a renamed file's before side, and its line counts, from the path it had then. Its row header says where it came from ("from old/path.ts"), from payload.renamedFrom. Diffed under its new path alone, a renamed file read as all new: an empty before side, and every line added.
  • Paths read from their package in a monorepo. Any directory holding a package.json is a package, except the repo root (in a monorepo that's the workspace). build.ts finds each file's nearest package, reading the package.json as the review sees it, and records packages (directory → name) in the payload. Everywhere a path shows, PathLabel draws the package's name as an outlined, tinted chip and then the path inside the package. When every package in the repo shares one npm scope, the chip leaves it off (@shop/core shows as core), and the full name moves to the chip's tooltip. This is checked against every package.json in the repo, not only the reviewed files' packages, so one unscoped package anywhere keeps the scopes on. That covers the row heads, the chapter view's directory headings and the diff-size view. The tree view makes each package a top-level node, with the tree inside it starting at the package. Where a label truncates, a run heading keeps the chip whole, while a file row gives up the chip before the file's name. fixtures/monorepo.sh <new dir> builds a small monorepo with uncommitted changes to try it on, in worktree mode.
  • 3-way is a different surface, not an option. On a conflict review (payload.conflict, and incoming/ancestor on each side), createDiffEditor builds three plain editors in the host instead of a diff editor. Switching between 2-way and 3-way disposes one surface and builds the other over the same models, so unsaved edits and the save sha survive the switch. Each pane is full height, so the page scrolls all three together and there is no scroll syncing at all: threeWayLayout.ts lines them up. It matches each side against the resolution (lineDiff.ts, Myers), treats lines both sides carry into the resolution as sync points, and pads every stretch between two sync points to the tallest pane with hatched view zones. Note threads count towards the resolution's stretch, so the sides are padded to match them too (noteZonesChanged). Folding and word wrap are off in all three panes, because either would move lines out from under the others. The choice of view is remembered separately for conflict reviews, where it defaults to 3-way.
  • Monaco is a dependency, not an enhancement. It is served only from the review server's own monaco-editor install at /vs (which is why served reviews work fully offline); serve.ts refuses to start without it. There is no cdn and no plain-text fallback renderer — a load failure renders as an error block in the diff's place, and a page opened without a server says its code rows need one. An offline chip appears in the header when the network goes (with note threads switching to "an agent will pick this up later") — notes always persisted server-side, so offline only changes the messaging, not the behaviour.
  • Both themes via the artifact-design token pattern: :root, the prefers-color-scheme media query, and both data-theme overrides. Monaco is re-themed from both signals.
  • The tab icon is inlined into the page as a data uri, from src/favicon.png - the same mark the header links to the game with. It has to be inlined rather than served: a built review.html opened straight from disk has no server to ask, and serve.ts answers /favicon.ico with a 204 precisely so nothing goes looking.
  • <meta charset="utf-8"> is the first line the page is assembled from, and build.ts reads and writes utf-8 explicitly — without both, file:// opens guess windows-1252 and every dash and arrow in the prose turns to mojibake.
  • Served, each editor also carries Revert (restore the build-time content into the buffer, never straight to disk) and the header carries "Send notes to agent"; both are hidden when opened without a server, where there is nothing to write to.
  • Two settings make .tsx legible, and without either the file fills with '>' expected: jsx: JsxEmit.Preserve in the typescript defaults' compiler options, and model URIs that carry the real path — TypeScript picks its script kind from the file extension, so an extensionless inmemory:// uri parses every tag as a type assertion. The two sides need distinct uris.
  • Adding a note is a + in the gutter of the hovered line (a decoration set from onMouseMove, opened from onMouseDown when the target type is GUTTER_GLYPH_MARGIN), because a keyboard shortcut alone is not discoverable. alt-N and the context menu still work.
  • A note is a thread, not a string: {line, messages: [{from, text}]}, with the original single-text shape read as its first message. The zone shows the conversation and a Reply box; the agent's side arrives through reply.ts. An agent's message is markdown, rendered by snarkdown-in-react (Markdown.tsx): it builds elements, never an html string, so raw html in it shows as text. A link to a file of this page (?review=…&commit=…&file=…, which reply.ts --edited writes) reads that file in place, through goToPlace as "also edited in" does; held edits committed since fall back to the PR read whole. Any other link opens in a new tab, so following one never leaves the review, and only http(s), mailto, # and relative ones are links at all (javascript: and the like show as text); images only from http(s) or data:image. The reviewer's own messages show exactly as typed. The package ships its TypeScript source rather than declarations, and that source doesn't pass this project's stricter checks, so tsconfig.json's paths points the typechecker at src/snarkdownInReact.d.ts instead; vite bundles the package itself, its react aliased to preact/compat like react-resizable-panels'. Everything that reads notes must tolerate both shapes and a half-written file — a note file is written while it is being read.
  • A note zone has two states: a textarea while it is being written or replied to, then the thread with Reply/Delete. Without that change of state, saving looks like it did nothing — the zone is torn down and rebuilt identically.
  • Served, the page polls /state every 2s with the paths of its open editors. A file whose sha moved on disk is reloaded through /file (unless that editor has unsaved edits, which win), its counts update, and a toast fires; new agent messages toast too. That is how an edit you make in response to a note shows up without anyone reloading anything.
  • serve.ts answers /favicon.ico with a 204: the browser asks unprompted, and a 404 would put an error in the console of every review for something the page never wanted.
  • The write token is stored beside the review and reused across restarts. It exists only to stop another page in the browser posting to the port behind the reviewer's back; minting a fresh one per run would silently break every tab left open, and losing notes is far worse than the thing it guards.
  • do_POST reads the request body before deciding anything, including auth. Refusing a request without draining it leaves its json in the socket, and the next request on that keep-alive connection is parsed starting halfway through it — which surfaces as a baffling 400 Bad request syntax naming your own note text.
  • Notes live in Monaco view zones. Four things about zones are easy to lose: they paint below the editor's text layer (so the note needs position: relative; z-index, or its buttons are unclickable); they need suppressMouseDown: false or the editor eats the mousedown first; monaco writes an inline display onto the zone's own dom node, so the note's layout has to live on a wrapper inside it; and monaco puts that node into the document on its own render frame rather than when the zone is added, so focusing the new note's box has to be retried across frames until it takes (a zone inside a folded unchanged region never will — it is display: none, and an element that isn't displayed cannot be focused). Zone contents also sit inside an aria-hidden subtree, so the note UI is mouse/keyboard-driven and not reachable by role queries.
  • The page adapts to window.__reviewServer, which only serve.ts injects (into the <!--REVIEW_SERVER--> marker). It gates the whole note-authoring/collaboration surface, not just Save/Revert/handoff: the gutter + glyph, the alt-N/context-menu action, and the view-zone note editors only wire up when it is present, so an opened-without-a-server page is fully read-only. Keep that marker in the template and keep both branches working — the same file serves both the shareable, standalone form and the working, served form.

Comments

Markdown supported