Repository navigation
fix(review): keep unsent PR review comments across new commits (#1590) - #1592
Merged
Merged
Conversation
PR-mode review drafts are now also stored under a stable target key (platform + host + repo + PR number + diff scope), so a draft saved before a push is found after it. The content-hash key stays the first lookup; an unchanged diff restores exactly as before. Local reviews are unchanged. Line comments record the text of their anchored diff lines. A draft served for a different patch marks comments whose lines no longer match as outdated (never dropped, never moved); outdated comments stay in the sidebar with an Outdated chip and an edit action, are labelled in the export, and post to the PR review body instead of inline. Submit, approve and close clear the draft under both keys, and the target key's tombstone rejects late saves under both keys. Mirrored in the Pi server via the vendored shared module.
- Decisions clear every PR draft target the server session saved to or restored from, so an in-place PR or scope switch cannot leave the earlier target's comments to reappear after a push. - /api/pr-switch and /api/pr-diff-scope report draftState for the new target; the client adopts its generation floor and offers any draft there as a merge. Rejected PR-mode saves answer 409 instead of ok. - Line comments also record two lines of context each side and the review snapshot they were anchored on; re-checks require text and context to match, run on restore and whenever the snapshot changes, and only comments stamped with the PR's known snapshot are posted inline. Everything else goes to the review body with the code it was written on. - Target copies remember every patch key they were saved under, so deletes without a generation still remove older patch copies. - Clicking an outdated comment opens its file without a dead scroll. - prFetcher test seam on both review servers for in-place switch tests.
…ding (#1590) - After an in-place PR or scope switch onto a target that holds a draft, autosave neither saves nor deletes until the reviewer answers the merge offer, so the switch's viewed-files update can no longer overwrite or tombstone the new target's draft unseen. - Comments deleted during the session are never offered back by a later merge offer. - Anchor context also compares the hunk header's function context, so an identical block in a different function is outdated, not still valid.
…utosave (#1590) Replace the merge-offer hold with a simpler shape. After an in-place PR or scope switch onto a target that holds a draft, the draft hook loads it and hands its new items (skipping ids already held or deleted this session) to the app, which merges them, re-checks their anchors and shows a small toast; autosave then saves normally. No banner after a switch. The only wait left: while that one load is in flight, autosave skips writing under the new target and resumes when the load settles on success, failure, or a newer switch. A per-switch counter ignores stale loads, and every switch resets the previous switch's state. The page-load restore banner is unchanged.
- Each read of a switched-onto target's draft is bounded by a 5s timeout, so a hung GET /api/draft takes the failure path instead of pausing autosave. - A failed read is retried once. If both attempts fail, autosave never writes over the unread draft; it retries the read on each later save attempt and saves normally once a read succeeds. The next switch or unmount supersedes the read, so nothing can stick. - A debounced save armed before the read settled is dropped and redone from the merged state.
backnotprop
added a commit
that referenced
this pull request
Sep 22, 2026
…1595) * fix: v0.27.18 QA regressions in PR drafts and discovered model catalogs - review: a comment on a PR whose snapshot the page has not seen (after a reload) posts inline again; only anchor-checked comments get the Outdated label (#1592) - review: restoring a draft after a push no longer re-marks changed files Viewed; the platform-path status post carries draftGeneration - catalog: always offer opus/sonnet/haiku, reading the default row's family and version, so Claude Code 2.1.141 no longer drops Opus (#1593) - settings: Codex Fast/reasoning re-key to the shown model when the saved one was replaced - codex: read fast mode from serviceTiers (priority/fast) - ai: Claude sessions no longer wait on model discovery (Bun + Pi share createDeferredModelDiscovery); capabilities mark modelsSource so the client retries a fallback answer * fix(ai): wait for discovery when the fallback lacks the requested Claude model A first Claude session resolved opus[1m] / claude-fable-5-1[1m] onto the fallback's opus / fable (another context window and billing than later sessions). In session mode the session now waits for discovery when the list is still the fallback and does not offer the pick; picks the fallback offers stay instant. Also stop reading a context size ("Opus 1M context") as the default row's version.
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #1590. PR reviews only. Local, working-tree, jj, GitButler, P4 and workspace reviews behave byte-identically to today.
Problem
Code-review drafts are keyed by
contentHash(rawPatch). When a teammate pushes to a PR, the key changes and the unsent comments become unreachable, even though the file is still on disk under the old hash.Design
Key format. In PR mode the draft is also stored under
prDraftTargetKey(meta, scope):scopeislayerorfull-stack. On a stacked PR these are two different patches with different line coordinates, so each is its own target.pr-key can never collide with a 16-hex content hash.Storage runs on the server. All of it is in one shared module,
packages/shared/review-draft.ts, vendored to Pi. Each review server holds onecreateReviewDraftSession()./api/draft,/api/feedbackand/api/exitgo through it. Without a target key, every call is the plaindraft.tscall, including the historical always-okresponse to a save.patchKey(the patch it was saved on) andpatchKeys(every patch it was ever saved on). Any client-supplied values for these stamps (or forpatchChanged) are stripped.draftGeneration, which covers a force-push back to an earlier patch whose old file is stale. Otherwise it returns the target copy. If that copy was saved on a different patch, the response includespatchChanged: true. The patch-key lists never leave the server./api/feedback,/api/exit) also clear every PR target this server session saved to or restored from. A clientDELETE(clear-all, dismiss) touches only the target on screen.409 { ok: false, found, draftGeneration }instead of swallowing it./api/pr-switchand/api/pr-diff-scoperesponses carrydraftState: { found, draftGeneration }for the new keys. The client'sadoptDraftTargetraises its generation counter to that value; without this, every save after switching onto a previously submitted target would be refused. When the target holds a draft, the hook loads it and auto-merges its items into the session: no banner, just a small non-blocking toast ("Restored N unsent comments for this PR"). Ids the session already holds, or deleted earlier in this session, are skipped. The merged comments are re-checked against the diff on screen, and autosave then saves the merge normally. The only wait is while that read is in flight: autosave skips writing under the new target, because it would overwrite a draft it has not read yet. Each read makes up to two attempts, each with a 5s timeout, so a hungGET /api/draftcounts as a failure instead of pausing autosave. If both attempts fail, autosave still never writes over the unread draft. Instead it retries the read on each later save attempt, and saves normally once a read succeeds. A newer switch or an unmount supersedes the read, and a per-switch counter turns a late load from an earlier target into a no-op, so nothing can get stuck. The page-load restore banner is unchanged.Anchor checks run on the client, which already restores drafts and holds the parsed diff. In PR mode,
withPRContext(the single path every code annotation is created through) records three fields on each line comment:anchorText: the anchored lines' text, taken from the patch hunks.anchorContext: two lines before and two after on the same side, plus the hunk header's function context (for examplefunction load() {). A common line like}orreturn null;is therefore not "still valid" by coincidence, even when the same block appears in another function.anchorSnapshot: the review snapshot id whose line coordinates the comment uses.reanchorCodeAnnotationsruns on restore, and again whenever the snapshot on screen changes (a push picked up, a layer/full-stack switch, an in-place PR switch):outdated: trueand keeps its old line numbers. Comments are never dropped and never moved to a guessed line.patchChanged. With no anchor fields, they are marked outdated. That is the safe side: nothing can confirm their lines still hold the same code, and an unchanged patch still restores exactly as today.scope:'general'comments, and comments bound to another PR or scope carry over unchanged.What gets posted where.
buildReviewSubmissionposts a line comment inline only ifcanPostInlineholds: the comment is not outdated, and it is stamped with that PR's known snapshot. An unstamped comment is trusted only for the PR on screen.anchorText).Outdated comments in the UI:
[Outdated — the code changed after this comment].Migration. Drafts saved only under a content hash still restore when the patch is unchanged. The first autosave in a PR session also writes the target key. Hash-only drafts are not scanned or migrated, because nothing on disk says which PR they belong to.
Out of scope:
Review fixes (second commit)
Addresses the independent review:
draftState,adoptDraftTargetplus the merge banner, and 409 on a rejected save.canPostInlinefix this, and outdated comments posted in the body now include the code they were written on.Review fixes (third and fourth commits)
The third commit held autosave while a merge offer was pending after a switch. Review found two ways that hold could get stuck: an unanswered banner stopped all autosave for the rest of the session, and a second switch while an offer was pending carried the stale offer onto the new PR. The fourth commit replaces the hold with a simpler design:
Final items (fifth commit)
The branch first merges the latest
origin/mainin a merge commit (no force-push).TARGET_LOAD_TIMEOUT_MS(5s). A hung read takes the same failure path as an error.To test in-place switches without a platform CLI or network, both review servers accept a
prFetcheroverride, the same kind of seam as the existingprReviewSubmitter.Tests
packages/shared/review-draft.test.ts(19):patchChangedreviewDraftStatereports the floor and a live draftpackages/server/review-draft-pr.test.tsand its Pi mirrorapps/pi-extension/serverReview-draft-pr.test.ts(9 each, real servers, tempPLANNOTATOR_DATA_DIR,PLANNOTATOR_BROWSER=/usr/bin/true):patchChangedpackages/review-editor/utils/codeAnnotationAnchor.test.ts(12):}whose surroundings changed is marked outdatedcanPostInlinerulespackages/review-editor/utils/outdatedAnnotations.test.ts(4):packages/ui/codeAnnotationDraftPersistence.test.tsx(+12, DOM):patchChangedpasses through the hookpackages/review-editor/components/ReviewSidebar.outdated.test.tsx(3, DOM, in theDOM_TESTSstep): chip, edit, delete.Results on the branch head:
bun run typecheck: clean. It covers core, shared, ai, server, ui, guide-viewer, guides-show, strict-consumer and pi-extension, aftervendor.sh.bun test: 5047 pass, 1204 skip, 0 fail (6251 tests, 542 files).DOM_TESTS=1 bun test --isolateover the workflow's list): 1283 pass, 0 fail (173 files).apps/reviewbuild: succeeds.tsc -p packages/review-editoris not part of CI and already reports many errors on main (bun:testtypes,import.meta.env, pierre type drift). It reports nothing new in the changed code beyond the missingbun:testtypes in the new test files.Risks and open questions
prUrl, which is left out.