feat(review): author suggestions by editing code in place (experimental, flag-gated) - #1193
Conversation
…ult off) Registers the flag in the settings registry and surfaces it under a new Experimental section of the review Display tab. Not synced to server config while experimental; when off, no edit UI renders anywhere.
…ion controller) pierreEditAdapter.ts is the single module allowed to reference @pierre/diffs/edit: the editor chunk loads via dynamic import on first use, types are derived structurally, and the EditProvider factory declines attaches until the module is ready (CodeView retries). deriveSuggestions.ts diffs the session's final contents against the pre-edit new-side content with the diff package and emits one minimal hunk per contiguous changed region, expanding pure inserts/deletes to include an unchanged anchor line so originalCode and suggestedCode stay non-empty. CRLF input is normalized. cloneDiff.ts deep-clones FileDiffMetadata before a session starts; Pierre's editor mutates it in place (additionLines, hunks, editSessionDirty), and the clone is what gets republished when the session ends. useEditSession.ts owns the lifecycle: one file at a time, full-content hydration before entry (partial diffs throw in applyDocumentChange), suggestion creation on completion, and session end as ONE combined item write (edit off plus restored pristine fileDiff with a fresh cacheKey), which is upstream's documented commit pattern and avoids racing CodeView's teardown re-render. persistState is deliberately unused (upstream bug, open PR #1048). Existing annotations are projected to editor severity markers best-effort via onAttach.
…flag-gated) The plain all-files dock panel is the only surface that opts in; Guided Review deliberately stays off because its viewport manager evicts CodeViews beyond ~8 mounted, which would destroy an active editor session. When the flag is off the surface renders byte-identical to before: no EditProvider, no edit props, no header buttons. FileHeader gains the Edit entry button (disabled with a tooltip when content cannot be fetched) and the in-session Editing badge with Suggest/Discard controls. Slot portals republish before React commits state, so header rendering reads the session refs, not state. AllFilesCodeView routes every collapse path through finishIfEditing so a collapse ends the session on our one code path, blocks the annotation toolbar and augmentation applies for the file being edited, and drops session state on fileSetKey remounts (diff switches are user-initiated; in-progress edits are discarded, documented v1 behavior). App.tsx converts derived hunks into ordinary suggestion annotations (type comment with suggestedCode/originalCode, side new, anchored to new-side line numbers), the same shape SuggestionModal produces, so rendering, sidebar, drafts, and feedback export work unchanged. The browser never writes files; the agent applies suggestions.
…dapter wall, and flag-off UI deriveSuggestions: no-op edit, single and multi region edits, whole-line add and delete with anchor expansion, insertion at file start, file emptied, CRLF vs LF, trailing newline. cloneDiff: pristine clone stays byte-identical after the live object is mutated the way Pierre's edit session mutates it. adapterWall: only pierreEditAdapter.ts may reference @pierre/diffs/edit, and only via import type or dynamic import. FileHeader DOM tests (DOM_TESTS=1): zero edit UI without the feature, entry button, disabled tooltip, and the Suggest/Discard session controls; registered in test.yml's DOM step.
Adversarial review (at
|
deriveSuggestionHunks could emit overlapping hunks when adjacent regions claimed the same anchor line (a file-start region expanding forward into a line the next region's backward expansion also claimed). An agent applying the suggestions against original line numbers would silently drop one or duplicate code. Regions are now emitted left to right with collision-aware anchoring: backward expansion is preferred but only when the preceding line is not already claimed by the previous hunk; otherwise the region anchors forward (always an unchanged line, and later regions see it as claimed); when both directions are unavailable (previous hunk adjacent and region runs to end of file) the region merges into the previous hunk as one spanning suggestion. A defensive fold enforces the non-overlap invariant (lineStart > previous lineEnd) as a safety net. Tests: unit cases for both reproduced collision shapes plus adjacent regions at file start and end, and a 5000-case seeded fuzz asserting zero overlaps, exact originalCode anchors, and full reconstruction of the edited text under a sequential offset-tracking applier. A 20,000-case run of the same generator reports zero overlaps, zero anchor mismatches, and zero round-trip failures.
Suggestions carried originalCode internally but the export never emitted it, so the applying agent had no way to validate an anchor before applying. Each suggestion now exports a fenced Replaces block (the exact current lines being swapped out) ahead of the existing Suggested code block, for both line and file scoped annotations. SuggestionModal-authored and edit-session-derived suggestions flow through the same single format. A deletion-only suggestion (no suggestedCode) still emits its Replaces block so the anchor stays verifiable. Tests assert the block pairing per annotation, the deletion-only case, and that annotations without originalCode are unchanged.
…change fileSetKey changes on sort-order and collapse-default flips as well as diff switches and refreshes, and the remount tears the editor down without a completion callback. A dirty session was dropped silently there, destroying in-progress work, while the file-switch path prompted. The session now keeps the FileContents delivered with each change event (its contents getter is lazy and stays readable after editor cleanup), and handleFileSetChange recovers the last-known document text, derives the suggestions, and prompts to keep them, matching the dirty file-switch pattern. The call site is a post-commit effect, so the synchronous confirm is safe. Declining discards; a clean session or a no-op edit still drops without a prompt. New DOM-gated test (registered in test.yml's DOM step) covers the confirm, decline, clean, and no-op paths through the real hook, plus non-DOM units for the pure recovery helper.
Single-line annotation markers were projected as collapsed ranges (start and end both at character 0 of the same line); upstream's overlay renderer skips zero-width blocks, so markers never painted. Marker ranges now end at character 0 of the line after the last annotated line (exclusive-end LSP form), covering every annotated line in full; the editor's TextDocument clamps a past-the-end position back to end-of-document. Verified via CDP against a live review server: the severity squiggle renders across the annotated line during an active edit session and the hover popover shows the annotation source and message. Also corrects overstating comments: the flag-off claim that the editor module is never imported (the single-file build inlines dynamic imports, so the namespace exists at page load but stays inert) and the createEditor decline-and-retry framing (the React wrapper throws on an undefined return; the controller awaits the chunk before any item enters edit mode, and onAttach delivers once per editor rather than re-firing across virtualization re-attaches).
Delta re-review (at
|
…ile header Suggestion cards keep the colored SUGGESTION header as their sole identity marker; the green left border on the card body is removed for all suggestion cards (inline and sidebar, including modal-authored ones). The Edit entry button moves to the far right of the file header action row, after the Sem badge and adjacent to the file actions dropdown, so the experimental affordance stays out of the everyday Viewed/Add/Comment cluster. Responsive isVeryTight behavior is unchanged (icon-only at narrow widths).
While an edit session is active, a slim strip renders directly below the file header (inside the file card, above the content) carrying the session controls and state: Suggest, Discard, a debounced net change count (for example 3 changes), and the experimental label. The header no longer renders in-session controls; the Edit entry button still lives there when no session is active. The HUD rides Pierre's memoized custom-header slot portal, so the change count is delivered through a small external store (useSyncExternalStore) that useEditSession updates on a 250ms debounce per change event; slot height re-measures via the version-bumped updateItem that session start and end already perform. Prototype for design review; revert this commit to drop the HUD.
Window-level keydown handlers in SectionsPanel and FileTree guarded only against e.target being an input or textarea. Events from inside the Pierre editor's shadow DOM retarget to the host element, so Home/End/arrow keys typed during an edit session fell through to file navigation and switched the visible file mid-edit. Use composedPath()[0] and isContentEditable, matching the guard AllFilesCodeView already uses.
Selecting text in a Pierre edit session now shows the editor's Selection Action popover with a single Make annotation button (plain DOM, inline styles, colors via inherited theme tokens so light/dark and theme switches work through the shadow boundary). Clicking it snapshots the selection and opens the app's existing CommentPopover anchored at the button rect; the submitted comment becomes a normal line-scoped CodeAnnotation. Anchoring: the selection lives in the edited buffer, but annotations anchor to the rendered diff's new side, which is the session's pre-edit content. mapEditedRangeToPristine (edit/selectionAnchor.ts) maps the selected line range through diffLines(preEdit, edited) at click time. Unedited regions map exactly; ranges overlapping session edits anchor to the pristine lines those edits replace and are flagged approximate; pure insertions anchor to the adjacent pristine line. Pre-edit coordinates are session-invariant, so the anchor stays correct whether the session completes or is discarded. The captured selection text is stored on the annotation (selectedText, plus selectedTextFromEdits when approximate) and exported in feedback as a Highlighted text block with an honesty note for approximate anchors. New comments are re-projected into editor markers mid-session, and the editor selection collapses after submit so the popover does not reopen over the annotated lines. Entry UX note: the comment entry deliberately lives outside the editor's popover. Focusing an input inside it would blur the editor, collapse the selection, and tear the popover down mid-typing, so the popover only snapshots and hands off to the app-side CommentPopover. Tests: anchor-mapping unit tests (exact mapping through shifts, edited and inserted regions, deletions inside a selection, clamping, CRLF), export coverage for the Highlighted text block, and a DOM-gated popover builder test registered in the CI DOM step. Adapter wall unchanged and passing.
Review of the four commits since
|
Wavy underlines read as errors, which misrepresents comments. Annotations render in their normal slots below the code instead.
#1200) The AllFilesCodeView lifecycle test stubs '@pierre/diffs/react' with mock.module, which replaces the whole specifier. #1193 added an EditProvider import to AllFilesCodeView, but the stub was never extended, so the file throws at import when run on its own. The three DOM tests #1158 added for Guided Review virtualization were also never registered in the DOM_TESTS=1 CI step, so nothing enforced the 8-viewer mount cap and nothing surfaced the broken stub.
What this is
A reviewer in the all-files code review view can now enter edit mode on ONE file at a time, fix the code in place using Pierre's experimental editor, and on finishing, the net change becomes ordinary suggestion annotations (
suggestedCode/originalCodeon aCodeAnnotation, the exact shape SuggestionModal produces today). The suggestions render inline, appear in the sidebar, and flow through the existing feedback export, which now also emits each suggestion'soriginalCodeas a fenced "Replaces:" block ahead of the "Suggested code:" block so the applying agent can validate the anchor before applying (SuggestionModal-authored and edit-derived suggestions use the same format). The browser never writes files; the agent applies suggestions.The edit-mode UI is OFF by default. It is gated behind a new cookie-only setting,
editSuggestions(Settings > Display > Experimental > "Edit Code to Suggest"). With the flag off, zero new UI renders and no editor is ever constructed (in code-split hosts the editor chunk is never fetched; see Bundle numbers for the single-file build's inlining). One clarification: the flag gates only the edit-mode UI. The "Replaces:" block in the feedback export is not flag-gated; it is emitted for every suggestion that carriesoriginalCode, including SuggestionModal suggestions authored with the flag off.v1 boundaries and why
GuideViewportManager(perf(review): virtualize Guided Review file cards #1158) evicts CodeView instances beyond ~8 mounted, and an evicted card destroys editor state. Scoping the entry point to the plain all-files panel (the only surface that passes the new props) was the simple safe option; pinning edited cards in the guide is possible later.persistState. Deliberately unused (upstream bug, open PR pierre#1048; it is also file-item-only upstream, so diff items never persist state anyway). Session state lives only while the editor is mounted.useDiffFreshnessonly shows a non-blocking "Diff out of date" chip and refresh is always user-initiated, so there is nothing to block. If the user refreshes, switches diff type, or changes the sort order or collapse default mid-session (all of these remount the CodeView;fileSetKeycovers them), Pierre tears the editor down without a completion callback and the session cannot continue. A dirty session is never silently discarded there: the controller keeps the live document handle from the last change event, recovers the final text, and prompts to keep the edits as suggestions (the same pattern as the dirty file-switch prompt). Declining discards; a clean session drops silently.Design: the adapter wall
Every reference to
@pierre/diffs/editlives in ONE module,packages/review-editor/edit/pierreEditAdapter.ts:import('@pierre/diffs/edit')on the first Edit click, never before. The session controller awaits the load BEFORE flipping any item into edit mode: the React wrapper throws if theEditProviderfactory returns undefined, so the factory's undefined return is a defensive guard rather than a retry contract.Marker, editor options, the editor instance) are derived structurally from theEditorclass type because upstream exports no public paths for them.packages/review-editor/edit/adapterWall.test.tsenforces the wall: any new import of the edit entry outside the adapter fails CI.Around it:
edit/cloneDiff.ts: deep-clonesFileDiffMetadatabefore a session (Pierre's editor mutatesadditionLines,hunks,editSessionDirtyin place). The clone is the restore target.edit/deriveSuggestions.ts: our own diff (thediffpackage) of final contents vs pre-session content; one minimal hunk per contiguous changed region; pure inserts and deletes are expanded by one unchanged anchor line sooriginalCodeandsuggestedCodestay non-empty; CRLF normalized. Anchor expansion is collision-aware: regions are emitted left to right, backward expansion is only taken when the preceding line is not already claimed, forward expansion is used otherwise, and a region with no available anchor merges into the previous hunk as one spanning suggestion. A hard non-overlap invariant (lineStartstrictly after the previouslineEnd) is enforced by a defensive fold. Fuzzed at 20,000 seeded multi-region edits (12,629 multi-hunk): zero overlapping ranges, zerooriginalCodeanchor mismatches, zero round-trip failures under a sequential offset-tracking applier. A 5,000-case seeded fuzz with the same assertions is part of the unit suite.edit/useEditSession.ts: the lifecycle controller (hydration gate, clone, session end as one combined item write, suggestion creation, dirty-switch prompt, dirty remount recovery, collapse routing, remount cleanup).The five research questions, answered by the build
DiffHunksRenderer.applyDocumentChangein 1.3.1 throws"Could not apply document change for partial diff"whenisPartialis true. The session therefore hard-gates on full content: it fetches/api/file-content, verifiesisContentConsistentWithPatch, reparses withprocessFile, and only then setsitem.edit = true. If content cannot be fetched (binary, demo, changed on disk, stale snapshot) the Edit button becomes disabled with a tooltip naming the reason. The throw was never observed in any CDP run because of the gate.longtask, 50 ms threshold). Keystroke cost grows with file size but stays comfortably interactive.updateItem. They even render inside a subsequent active edit session on the same file (screenshot evidence). The editor-marker projection of existing annotations (setMarkersviaonAttach) now renders: single-line annotations previously produced a collapsed zero-width range that upstream's overlay renderer skips, so markers never painted. Marker ranges now end at character 0 of the line after the last annotated line, and a CDP run against a live review server confirms the severity squiggle renders across the annotated line during an active edit session, with the hover popover showing the annotation source and message.editSessionDirtysemantics), and the completion payload is only aFileContents; there is no stable emitted hunk artifact to trust. Diffing the final contents against the cloned pre-session content with thediffpackage gives deterministic minimal hunks, CRLF handling, collision-resolved anchors with a machine-checked non-overlap invariant, and is unit-tested and fuzzed in isolation.One additional finding worth recording: ending a session as two writes (edit off first, restore later) races CodeView's own teardown re-render, and on large files the teardown render lands last, leaving edited content on screen. Upstream's documented commit pattern (ONE combined
updateItemcarryingedit: falseplus the finalfileDiffwith a freshcacheKey) is what the controller does, and the completion callback still fires from inside that write.CDP evidence (live review server with a real git repo, plus vite dev server)
Five scripted scenarios, all green (screenshots and logs captured during the runs):
calc.ts, type a change, Suggest; the annotation appears with the correctsuggestedCode, the edited text exists ONLY inside the suggestion UI (diff restored pristine), and Send Feedback produces an export containing the**Suggested code:**block with the edited line anchored at### Line 8 (new).@pierre_diffs_edit.jsis fetched only after clicking Edit.Bundle numbers
inlineDynamicImports: true(viteSingleFile), so the lazy chunk is inlined:review.htmlgrows +167 KB raw / +54 KB gzip (18,132 KB to 18,299 KB raw; 5,429 KB to 5,483 KB gzip, about 1 percent), paid even with the flag off. This means the edit module namespace is constructed at page load in the single-file build (audited: no top-level side effects across all 21 modules); it stays functionally inert with the flag off. This is a property of the single-file build, not the adapter; serving the editor as a sidecar asset from both servers would restore true zero cost and is a reasonable follow-up if the feature graduates.EditProvider, no edit props, and no editor construction path is reachable with the flag off.Verification
bun run typecheck(CI set)bun test(full, 3,048 tests)DOM_TESTS=1CI DOM step includingFileHeader.edit.test.tsxand the newuseEditSession.recovery.test.tsxapps/reviewbuild,build:hook,build:opencodeThe DOM-gated tests are registered in
test.yml's DOM step.Honest remaining risks
@pierre/diffs/edit, the CodeViewedititem flag). The adapter wall confines upstream renames to one file and the adapter-wall test keeps it that way, but upstream behavior changes (teardown ordering, completion semantics) could still surface as session bugs. The flag stays OFF by default for exactly this reason.UI polish round (maintainer design feedback)
Two design changes after trying the feature live:
.suggestion-blockCSS), the change intentionally applies to ALL suggestion cards for consistency: the inline (below-line) card and the compact sidebar card both lose the left edge line. Comment cards and every other annotation surface are untouched.isVeryTightresponsive behavior is unchanged (icon-only at narrow widths, verified in a live CDP run at a 464 px header), and the FileHeader DOM test now asserts the ordering.Make annotation from an editor selection
Selecting text during an edit session now shows Pierre's Selection Action popover with one action, "Make annotation". Clicking it opens the app's existing CommentPopover (anchored at the popover's screen position) and the submitted comment becomes a normal line-scoped comment annotation.
edit/selectionAnchor.tsmaps the selected line range throughdiffLines(preEdit, edited)at click time: unedited regions map exactly (the common case, including line shifts from edits above), ranges overlapping session edits anchor to the pristine lines those edits replace and are flagged approximate, and pure insertions anchor to the adjacent pristine line. Pre-edit coordinates never change during a session, so the anchor is correct after both Suggest and Discard.selectedText) and exports as a "Highlighted text" block; approximate anchors add an explicit note. A new comment also re-projects into editor markers immediately, and the editor selection collapses after submit so the popover does not reopen over the annotated lines.composedPath()[0]guard AllFilesCodeView already had.Edit session HUD
While a session is active, a slim strip renders below the file header with the session state (Editing, experimental chip, debounced change count) and the Suggest and Discard actions. The header carries only the Edit entry button when idle, so session controls have one home. Later commits also rename the review Settings Display tab to Editor with the edit toggle first, and remove the projection of annotations as editor markers (wavy underlines read as errors, which misrepresents comments; annotations render in their normal slots instead).