Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
36b69c5
fix(web): keep the review scope across agent turns
jakeleventhal Oct 4, 2026
490f8ac
fix(web): keep diff scroll across review refreshes
jakeleventhal Oct 4, 2026
3b4ffda
fix(web): reread later diff pages and drop failed patches
jakeleventhal Oct 4, 2026
fc023b2
fix(web): preserve review context through refreshed diffs
juliusmarminge Oct 19, 2026
3f37b05
fix(web): retain requested review paths after a failed refresh
juliusmarminge Oct 19, 2026
6990f97
fix(web): discard review drafts when their file changes
juliusmarminge Oct 19, 2026
9332b3c
fix(web): drop the unused file patch scope binding
jakeleventhal Oct 24, 2026
170d57b
fix(review): allow agent selection without review posting access
jakeleventhal Oct 26, 2026
13ebfda
fix: preserve feature behavior after upstream rebase
jakeleventhal Oct 27, 2026
37463ad
fix: preserve feature behavior on current upstream
jakeleventhal Oct 28, 2026
39437d3
fix(web): invalidate local diff drafts after content changes
jakeleventhal Oct 28, 2026
cc6c55e
fix(web): guard diff comments until refreshed content renders
jakeleventhal Oct 28, 2026
a4fda5f
test(web): await diff viewer readiness in comment regressions
jakeleventhal Oct 28, 2026
919cedb
fix(web): preserve current diff navigation and multi-block refreshes
jakeleventhal Nov 12, 2026
9e9aec4
refactor(web): keep proactive review selection guard local
jakeleventhal Nov 12, 2026
5700e75
fix(web): ignore stale pull request refresh cursors
jakeleventhal Nov 12, 2026
dd220d0
fix(web): defer diff links until refresh completes
jakeleventhal Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,8 @@ export default function DiffPanel({
? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}`
: null;

const selectedGitSource = branchDiffPreview.data?.sources.find(
const preview = branchDiffPreview.isSuccess ? branchDiffPreview.data : null;
const selectedGitSource = preview?.sources.find(
(source) =>
source.kind ===
(selectedGitScope === "unstaged"
Expand Down Expand Up @@ -470,15 +471,16 @@ export default function DiffPanel({
const refreshPreviewQuery = branchDiffPreview.refresh;
const refreshDiffFromUserAction = refreshPreviewQuery;

const preview = branchDiffPreview.data;
const gitDiff = selectedGitSource?.diff;

const toolCallPreview =
toolCallSelection && toolCallItem?.type === "file_change" ? toolCallDiff(toolCallItem) : null;
const selectedPatch = toolCallSelection
? toolCallPreview?.patch
: selectedTurn
? activeCheckpointDiff.data?.diff
? activeCheckpointDiff.error === null
? activeCheckpointDiff.data?.diff
: undefined
: gitDiff;
const isSelectedPatchTruncated =
toolCallPreview?.truncated === true ||
Expand Down Expand Up @@ -598,6 +600,7 @@ export default function DiffPanel({
...matchingBaseRefChoices.map(valueForBaseRefChoice),
];
const {
family: filePatchFamily,
isPending: areFilePatchesPending,
fileStates,
retry,
Expand All @@ -606,6 +609,7 @@ export default function DiffPanel({
remainingFileCount,
loadNextFiles,
} = useReviewFilePatches({
reviewKey: collapseScopeKey,
environmentId: activeThread?.environmentId,
cwd: branchDiffPreview.data?.cwd,
source: lazySource,
Expand Down Expand Up @@ -1450,10 +1454,11 @@ export default function DiffPanel({
projectId={activeThread?.projectId}
viewerRef={attachCodeView}
onScroll={codeViewScroll.onScroll}
codeViewKey={codeViewMountKey}
codeViewKey={`${codeViewMountKey}:${filePatchFamily}`}
className="h-full min-h-0 overflow-auto"
files={codeViewFiles}
pendingFiles={pendingFiles}
isRefreshingDiff={isRefreshingDiff}
renderCodeViewFooter={renderLoadingBoundary}
sectionId={reviewSectionId}
sectionTitle={reviewSectionTitle}
Expand Down
282 changes: 181 additions & 101 deletions apps/web/src/components/diffs/AnnotatableCodeView.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,91 +1,60 @@
import type { CodeViewDiffItem, FileDiffMetadata, SelectedLineRange } from "@pierre/diffs";
import type { FileDiffMetadata, SelectedLineRange } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
import { StrictMode, type ComponentProps } from "react";
import { WorkerPoolManager } from "@pierre/diffs/worker";
import { createRef, StrictMode } from "react";
import { describe, expect, it, vi } from "vite-plus/test";

import { act, render } from "../../../test/render";

import { DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { buildFileDiffIdentityKey } from "~/lib/diffRendering";
import { buildFileDiffContentVersion, buildFileDiffIdentityKey } from "~/lib/diffRendering";

type ViewerProps = Omit<ComponentProps<typeof StyledDiffCodeView<unknown>>, "options"> & {
options: {
enableLineSelection: boolean;
enableGutterUtility: boolean;
onGutterUtilityClick: (
range: SelectedLineRange,
context: { item: CodeViewDiffItem<unknown> },
) => void;
};
};

// Replace only the virtualized viewer; comment editing and submission use the real components.
vi.mock("./StyledDiffCodeView", () => ({
StyledDiffCodeView: (props: ViewerProps) =>
props.items
?.filter((item) => item.type === "diff")
.map((item) => (
<section key={item.id}>
<button
disabled={!props.options?.enableLineSelection || !props.options?.enableGutterUtility}
onClick={() => {
props.onSelectedLinesChange?.({ id: item.id, range });
props.options?.onGutterUtilityClick?.(range, { item });
}}
>
Comment on {item.fileDiff.name}
</button>
{props.selectedLines?.id === item.id ? (
<mark>Line {props.selectedLines.range.start}</mark>
) : null}
{item.annotations?.map((annotation) => (
<div key={`${annotation.side}:${annotation.lineNumber}`}>
{props.renderAnnotation?.(annotation, item.fileDiff, item)}
</div>
))}
</section>
)),
}));

import { AnnotatableCodeView } from "./AnnotatableCodeView";
import type { StyledDiffCodeView } from "./StyledDiffCodeView";
import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./AnnotatableCodeView";

const target = DraftId.make("diff-draft-test");
const range: SelectedLineRange = { start: 1, end: 1, side: "additions" };
let container: HTMLDivElement;

function file(
name: string,
fileVersion = 1,
type: FileDiffMetadata["type"] = "change",
prevName?: string,
contents = "new",
) {
const fileDiff = parsePatchFiles(
`diff --git a/${name} b/${name}\n--- a/${name}\n+++ b/${name}\n@@ -1 +1 @@\n-old\n+new\n`,
{ cacheKeyPrefix: name },
`diff --git a/${name} b/${name}\n--- a/${name}\n+++ b/${name}\n@@ -1 +1,${contents.split("\n").length} @@\n-old\n+${contents}\n`,
{ cacheKeyPrefix: `${name}:${contents}` },
)[0]!.files[0]!;
fileDiff.type = type;
if (prevName) fileDiff.prevName = prevName;
return {
fileDiff,
filePath: name,
fileKey: buildFileDiffIdentityKey(fileDiff),
fileVersion,
fileVersion: buildFileDiffContentVersion(fileDiff),
collapsed: false,
};
}

function view(
files: ReturnType<typeof file>[],
codeViewKey = "initial",
pendingFiles: FileDiffMetadata[] = [],
) {
return (
function setup() {
const reset = () => useComposerDraftStore.setState(useComposerDraftStore.getInitialState(), true);
reset();
const container = document.createElement("div");
Object.assign(container.style, { width: "800px", height: "400px", display: "grid" });
const viewerRef = createRef<AnnotatableCodeViewHandle>();
const view = (
files: ReturnType<typeof file>[],
codeViewKey = "initial",
pendingFiles: FileDiffMetadata[] = [],
isRefreshingDiff = false,
) => (
<StrictMode>
<AnnotatableCodeView
viewerRef={viewerRef}
className="h-full min-h-0 overflow-auto"
codeViewKey={codeViewKey}
files={files}
pendingFiles={pendingFiles}
isRefreshingDiff={isRefreshingDiff}
sectionId="unstaged"
sectionTitle="Uncommitted"
composerDraftTarget={target}
Expand All @@ -96,68 +65,179 @@ function view(
/>
</StrictMode>
);
}

function click(label: string) {
const button = Array.from(container.querySelectorAll("button")).find(
(node) => node.textContent === label,
);
if (!button) throw new Error(`Missing button: ${label}`);
return act(() => button.click());
}

function editor() {
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("Comment editor is not available");
return textarea;
}

function typeComment(text: string) {
return act(() => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(
editor(),
text,
const renderedFile = () => {
const item = viewerRef.current?.getInstance()?.getRenderedItems()[0];
if (item?.type !== "diff") throw new Error("Diff is not rendered");
return item;
};
const select = () =>
act(() => {
// Invoke Pierre's own wrappers, which resolve their context from the latest item.
const { instance } = renderedFile();
instance.options.onLineSelectionChange?.(range);
instance.options.onGutterUtilityClick?.(range);
});
const editor = () => {
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("Comment editor is not available");
return textarea;
};
const click = (label: string) => {
const button = Array.from(container.querySelectorAll("button")).find(
(node) => node.textContent === label,
);
editor().dispatchEvent(new Event("input", { bubbles: true }));
});
}

function setup(): Disposable {
const reset = () => useComposerDraftStore.setState(useComposerDraftStore.getInitialState(), true);
reset();
container = document.createElement("div");
return { [Symbol.dispose]: reset };
if (!button) throw new Error(`Missing button: ${label}`);
return act(() => button.click());
};
const typeComment = (text: string) =>
act(() => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(
editor(),
text,
);
editor().dispatchEvent(new Event("input", { bubbles: true }));
});
return {
container,
viewerRef,
view,
renderedFile,
select,
editor,
click,
typeComment,
[Symbol.dispose]: reset,
};
}

describe("diff comment drafts", () => {
it("keeps the text and selected line through refresh and a provisional lazy patch", async () => {
using _drafts = setup();
const original = file("shared.ts", 1, "rename-pure", "old.ts");
await using screen = await render(view([original]), { container });
await click("Comment on shared.ts");
await typeComment("Keep this draft");
const refreshed = file("shared.ts", 2, "rename-pure", "old.ts");
await screen.rerender(view([refreshed], "refreshed"));
expect(editor().value).toBe("Keep this draft");
expect(container.querySelector("mark")?.textContent).toBe("Line 1");
using test = setup();
const original = file("shared.ts", "rename-pure", "old.ts");
await using screen = await render(test.view([original]), { container: test.container });
await expect.element(screen.getByText("new", { exact: true })).toBeVisible();
expect(test.renderedFile().instance.getLineIndex(1)).toBeDefined();
await test.select();
await vi.waitFor(() => expect(test.editor().value).toBe(""));
await test.typeComment("Keep this draft");
const refreshed = file("shared.ts", "rename-pure", "old.ts");
await screen.rerender(test.view([refreshed], "refreshed", [], true));
await vi.waitFor(() => expect(test.editor().value).toBe("Keep this draft"));
expect(test.viewerRef.current?.getSelectedLines()).toEqual(
expect.objectContaining({ id: original.fileKey, range }),
);
await test.click("Comment");
expect(useComposerDraftStore.getState().getComposerDraft(target)?.reviewComments ?? []).toEqual(
[],
);
await act(() => {
test
.editor()
.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }),
);
});
expect(useComposerDraftStore.getState().getComposerDraft(target)?.reviewComments ?? []).toEqual(
[],
);
expect(test.editor().value).toBe("Keep this draft");

await screen.rerender(
view([], "loading", [
test.view([], "loading", [
{ ...refreshed.fileDiff, type: "rename-changed", hunks: [], cacheKey: "shared:pending" },
]),
);
expect(container.querySelector("textarea")).toBeNull();
await screen.rerender(view([refreshed], "loaded"));
expect(editor().value).toBe("Keep this draft");
expect(container.querySelector("mark")?.textContent).toBe("Line 1");
await click("Comment");
expect(test.container.querySelector("textarea")).toBeNull();
await screen.rerender(test.view([refreshed], "loaded"));
await vi.waitFor(() => expect(test.editor().value).toBe("Keep this draft"));
expect(test.viewerRef.current?.getSelectedLines()).toEqual(
expect.objectContaining({ id: original.fileKey, range }),
);
await test.click("Comment");
expect(useComposerDraftStore.getState().getComposerDraft(target)?.reviewComments).toEqual([
expect.objectContaining({
filePath: "shared.ts",
text: "Keep this draft",
selection: { ...range, endSide: "additions" },
}),
]);
expect(container.querySelector("p")?.textContent).toBe("Keep this draft");
});

it("rejects old line ranges and retained submission until Pierre adopts refreshed content", async () => {
using test = setup();
const original = file("adoption.ts");
const refreshed = file("adoption.ts", "change", undefined, "inserted\n+new");
const highlight = WorkerPoolManager.prototype.highlightDiffAST;
let resumeHighlight: (() => void) | undefined;
let pool: WorkerPoolManager | undefined;
using _highlight = vi
.spyOn(WorkerPoolManager.prototype, "highlightDiffAST")
.mockImplementation(function (this: WorkerPoolManager, instance, diff) {
pool = this;
if (diff === refreshed.fileDiff) {
resumeHighlight = () => highlight.call(this, instance, diff);
return;
}
return highlight.call(this, instance, diff);
});
await using screen = await render(test.view([original]), { container: test.container });
await expect.element(screen.getByText("new", { exact: true })).toBeVisible();
const initialPool = pool;
if (!initialPool) throw new Error("Diff worker pool is unavailable");
await act(() => initialPool.primeDiffHighlightCache(original.fileDiff));
expect(initialPool.getDiffResultCache(original.fileDiff)).toBeDefined();
expect(test.renderedFile().instance.getLineIndex(1)).toBeDefined();
await act(() => test.viewerRef.current?.getInstance()?.render(true));
await test.select();
await vi.waitFor(() => expect(test.editor().value).toBe(""));
await test.typeComment("Old coordinates");
const retainedSubmit = Array.from(test.container.querySelectorAll("button")).find(
(node) => node.textContent === "Comment",
)!;
const previousInstance = test.renderedFile().instance;

await screen.rerender(test.view([refreshed]));
await vi.waitFor(() => expect(resumeHighlight).toBeDefined());
const pending = test.renderedFile();
expect(pending.item.fileDiff).toBe(refreshed.fileDiff);
expect(pending.instance).toBe(previousInstance);
expect(pending.element.shadowRoot?.textContent).not.toContain("inserted");
expect(retainedSubmit.isConnected).toBe(true);
expect(
pending.instance.__getNextRenderData({
fileDiff: refreshed.fileDiff,
lineAnnotations: undefined,
}).fileDiff,
).toBe(original.fileDiff);
await test.typeComment("Still visible old draft");
expect(retainedSubmit.disabled).toBe(false);
await act(() => retainedSubmit.click());
expect(useComposerDraftStore.getState().getComposerDraft(target)?.reviewComments ?? []).toEqual(
[],
);
await test.select();
expect(test.viewerRef.current?.getSelectedLines()).toBeNull();

await act(() => resumeHighlight?.());
await vi.waitFor(() => {
expect(
test.renderedFile().instance.__getNextRenderData({
fileDiff: refreshed.fileDiff,
lineAnnotations: undefined,
}).fileDiff,
).toBe(refreshed.fileDiff);
expect(test.renderedFile().element.shadowRoot?.textContent).toContain("inserted");
expect(test.container.querySelector("textarea")).toBeNull();
});
await test.select();
await vi.waitFor(() => expect(test.editor().value).toBe(""));
await test.typeComment("Current coordinates");
await test.click("Comment");
expect(useComposerDraftStore.getState().getComposerDraft(target)?.reviewComments).toEqual([
expect.objectContaining({
text: "Current coordinates",
diff: expect.stringContaining("+inserted"),
}),
]);
});
});
Loading
Loading