Add inline review comments to git diff pane (#530)

* Add inline git diff review comments

Adds a review draft store keyed per diff target, inline gutter + thread
rendering in the diff pane, and a generated review attachment that flows
through the composer and agent providers.

* Strip generated review attachments before saving to draft store

Drafts persist only UserComposerAttachment (no review kind), so filter the
auto-generated review attachment out when seeding the draft or restoring
after a failed auto-submit.

* Fix inline review typecheck and test harness drift

* Unslop inline review feature

- Fix stale-closure bug in composer queueMessage: clear drafts for the
  actually-queued attachments, not the latest props
- Drop dead useEffect mirroring initialBody in inline review editor
  (parent already remounts via key)
- Restore typed AgentAttachment signature on renderPromptAttachmentAsText;
  delete schema redispatch helper and drop null-checks across 5 providers
- Inline single-use review prompt formatters and replace marker switch
  with const map
- Drop normalizeComment field-copy redundancy after type guard
- Remove __reviewDraftStoreTestUtils export; test migration via
  rehydrate() public path
- Refactor findTarget to return the target line directly (no non-null
  assertion at call site)
- Drop redundant isGeneratedReviewAttachment helper; inline kind check

* Update review pill to withUnistyles after rebase

Main's unistyles refactor stripped useUnistyles in favor of withUnistyles +
StyleSheet. Convert ReviewAttachmentPill to use ThemedCircleDot + ICON_SIZE so
the icon picks up the foreground-muted mapping like the other pills.

* Fix lint and typecheck after rebase onto main

Resolve drift after main's withUnistyles refactor:
- Composer complexity: dedup voice?.isVoiceSwitching to drop to 20
- Move per-comment Pressable handlers in git-diff-inline-review into
  CommentRow subcomponent so callbacks are stable
- Hoist style/onPress factories to module level (iconButton,
  iconButtonDestructive, ghostButton)
- Memoize style arrays in git-diff-pane review wrappers
- Replace inline {} placeholders with useMemo styles
- review-draft-store: extract applyCommentUpdates to avoid map-spread,
  include input.key in useMemo deps
- Test fixtures: hoist EMPTY_COMMENTS, add buildReviewActions helper

* Drop GitDiffPane default param to keep complexity at 20

After the rebase, main's `enabled = true` default param combined with the
inline review additions pushed cyclomatic complexity to 21. Drop the
default and resolve `enabled !== false` at the call site (comparison
doesn't count toward complexity).

* Reshape inline review behind @/review module

Drop the `generated: true` attachment flag and consolidate inline-review
logic behind a deep `@/review` module so composer and git-diff-pane stop
hand-composing review primitives. Workspace-scoped review snapshots are
now derived at submit/queue time rather than persisted in the draft
store. Composer review references drop from 51 to 4; git-diff-pane drops
~108 lines and now consumes a controller hook + slot components.

* Fix optimistic review attachments
This commit is contained in:
Mohamed Boudra
2026-04-30 21:35:58 +08:00
committed by GitHub
parent 4bcf98a9e3
commit 92afb826d9
39 changed files with 4244 additions and 317 deletions
+320 -8
View File
@@ -4,10 +4,15 @@ import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@server/shared/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
} from "@/attachments/types";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import { Composer } from "./composer";
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
import { addReviewDraftComment, getReviewDraftComments, resetReviewDraftStore } from "@/review";
const keyboardActionHandlerMock = vi.hoisted(() => vi.fn());
@@ -27,6 +32,7 @@ const {
setAgentStreamHeadMock,
setQueuedMessagesMock,
agentDirectoryStatusMock,
appSendBehavior,
} = vi.hoisted(() => {
const hoistedTheme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
@@ -35,6 +41,7 @@ const {
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
fontWeight: { normal: "400", medium: "500" },
lineHeight: { diff: 18 },
shadow: { md: {} },
colors: {
surface0: "#000",
@@ -99,7 +106,12 @@ const {
cancelAgent: vi.fn(async () => {}),
};
const hoistedSetQueuedMessagesMock = vi.fn();
const hoistedSetQueuedMessagesMock = vi.fn(
(serverId: string, updater: (prev: Map<string, unknown[]>) => Map<string, unknown[]>) => {
const session = hoistedMockSessionState.sessions[serverId];
session.queuedMessages = updater(session.queuedMessages);
},
);
const hoistedMockSessionState: {
sessions: Record<
string,
@@ -161,6 +173,7 @@ const {
hoistedMockSessionState.setAgentStreamTail = hoistedSetAgentStreamTailMock;
hoistedMockSessionState.setAgentStreamHead = hoistedSetAgentStreamHeadMock;
const hoistedAgentDirectoryStatusMock = vi.fn(() => "ready");
const hoistedAppSendBehavior = { current: "interrupt" as "interrupt" | "queue" };
return {
theme: hoistedTheme,
@@ -178,6 +191,7 @@ const {
setAgentStreamHeadMock: hoistedSetAgentStreamHeadMock,
setQueuedMessagesMock: hoistedSetQueuedMessagesMock,
agentDirectoryStatusMock: hoistedAgentDirectoryStatusMock,
appSendBehavior: hoistedAppSendBehavior,
};
});
@@ -297,7 +311,7 @@ vi.mock("@/utils/open-external-url", () => ({
}));
vi.mock("@/hooks/use-settings", () => ({
useAppSettings: () => ({ settings: { sendBehavior: "interrupt" } }),
useAppSettings: () => ({ settings: { sendBehavior: appSendBehavior.current } }),
}));
vi.mock("@/hooks/use-agent-autocomplete", () => ({
@@ -556,8 +570,72 @@ let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
let latestAttachments: ComposerAttachment[] = [];
type ReviewComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
type ReviewAttachment = Extract<AgentAttachment, { type: "review" }>;
function reviewAttachment(body: string): ReviewAttachment {
return {
type: "review",
mimeType: "application/paseo-review",
cwd: "/repo",
mode: "uncommitted",
baseRef: null,
comments: [
{
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body,
context: {
hunkHeader: "@@ -40,2 +40,2 @@",
targetLine: {
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
lines: [
{
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
],
},
},
],
};
}
function reviewComposerAttachment(body: string): ReviewComposerAttachment {
return {
kind: "review",
reviewDraftKey: `review:${body}`,
commentCount: 1,
attachment: reviewAttachment(body),
};
}
function seedReviewDraft(key: string) {
addReviewDraftComment({
key,
comment: {
id: `${key}:comment`,
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body: "Please simplify this.",
createdAt: "2026-04-21T00:00:00.000Z",
updatedAt: "2026-04-21T00:00:00.000Z",
},
});
}
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
@@ -570,6 +648,7 @@ beforeEach(() => {
attachEvent: vi.fn(),
detachEvent: vi.fn(),
});
vi.stubGlobal("localStorage", dom.window.localStorage);
container = document.createElement("div");
document.body.appendChild(container);
@@ -590,6 +669,7 @@ beforeEach(() => {
keyboardActionHandlerMock.mockClear();
agentDirectoryStatusMock.mockReset();
agentDirectoryStatusMock.mockReturnValue("ready");
appSendBehavior.current = "interrupt";
mockSessionState.sessions.server.serverInfo = {
serverId: "server",
hostname: "test",
@@ -607,6 +687,10 @@ beforeEach(() => {
mockSessionState.sessions.server.agentStreamHead = new Map();
mockSessionState.sessions.server.agentStreamTail = new Map();
mockSessionState.sessions.server.queuedMessages = new Map();
mockSessionState.sessions.server.agents = new Map([
["agent", { status: "idle", lastUsage: null }],
]);
resetReviewDraftStore();
});
afterEach(() => {
@@ -634,20 +718,30 @@ function imageAttachment(id: string): AttachmentMetadata {
function ComposerHarness({
initialText = "",
initialAttachments = [],
workspaceAttachment = null,
isSubmitLoading = false,
submitBehavior,
}: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
initialAttachments?: UserComposerAttachment[];
workspaceAttachment?: ReviewComposerAttachment | null;
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
}) {
const [text, setText] = useState(initialText);
const [attachments, setAttachments] = useState(initialAttachments);
const workspaceAttachments = React.useMemo(
() => (workspaceAttachment ? [workspaceAttachment] : []),
[workspaceAttachment],
);
latestAttachments = attachments;
const handleChangeAttachments = React.useCallback(
(updater: ComposerAttachment[] | ((current: ComposerAttachment[]) => ComposerAttachment[])) => {
(
updater:
| UserComposerAttachment[]
| ((current: UserComposerAttachment[]) => UserComposerAttachment[]),
) => {
setAttachments((current) => {
const next = typeof updater === "function" ? updater(current) : updater;
latestAttachments = next;
@@ -666,6 +760,7 @@ function ComposerHarness({
value={text}
onChangeText={setText}
attachments={attachments}
workspaceAttachments={workspaceAttachments}
onChangeAttachments={handleChangeAttachments}
isSubmitLoading={isSubmitLoading}
submitBehavior={submitBehavior}
@@ -679,7 +774,8 @@ function ComposerHarness({
function renderComposer(
input: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
initialAttachments?: UserComposerAttachment[];
workspaceAttachment?: ReviewComposerAttachment | null;
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
} = {},
@@ -903,6 +999,222 @@ describe("Composer attachments", () => {
);
});
it("serializes workspace review attachments through the structured attachment path", async () => {
const review = reviewComposerAttachment("Please simplify this.");
expect(splitComposerAttachmentsForSubmit([review])).toEqual({
images: [],
attachments: [review.attachment],
});
});
it("renders and submits a workspace review attachment pill", async () => {
const review = reviewComposerAttachment("Please simplify this.");
renderComposer({
initialText: "review this",
workspaceAttachment: review,
});
expect(queryByTestId("composer-review-attachment-pill")?.textContent).toContain("Review");
expect(queryByTestId("composer-review-attachment-pill")?.textContent).toContain("1 comment");
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"review this",
expect.objectContaining({
attachments: [review.attachment],
}),
);
});
it("clears the included workspace review draft after a successful submit", async () => {
const review = reviewComposerAttachment("Clear submitted review draft.");
seedReviewDraft(review.reviewDraftKey);
renderComposer({
initialText: "review this",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(getReviewDraftComments(review.reviewDraftKey)).toBeUndefined();
});
it("restores only normal attachments when a submit with a workspace review fails", async () => {
const image = imageAttachment("img-failure");
const review = reviewComposerAttachment("This should be sent but not persisted.");
seedReviewDraft(review.reviewDraftKey);
mockClient.sendAgentMessage.mockRejectedValueOnce(new Error("network down"));
renderComposer({
initialText: "review this",
initialAttachments: [{ kind: "image", metadata: image }],
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"review this",
expect.objectContaining({
attachments: [review.attachment],
}),
);
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
expect(getReviewDraftComments(review.reviewDraftKey)).toHaveLength(1);
});
it("clears workspace review suppression after a send lifecycle", async () => {
const review = reviewComposerAttachment("Keep this available for the next message.");
renderComposer({
initialText: "send without review",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Remove review attachment"]')!);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"send without review",
expect.objectContaining({
attachments: [],
}),
);
expect(queryByTestId("composer-review-attachment-pill")).not.toBeNull();
});
it("keeps workspace review suppressed after a failed send", async () => {
const review = reviewComposerAttachment("Do not send this on retry.");
mockClient.sendAgentMessage.mockRejectedValueOnce(new Error("network down"));
renderComposer({
initialText: "retry without review",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Remove review attachment"]')!);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenNthCalledWith(
1,
"agent",
"retry without review",
expect.objectContaining({
attachments: [],
}),
);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenNthCalledWith(
2,
"agent",
"retry without review",
expect.objectContaining({
attachments: [],
}),
);
});
it("captures workspace review attachments at queue time", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const initialReview = reviewComposerAttachment("Initial queued review.");
const editedReview = reviewComposerAttachment("Edited after queue.");
renderComposer({
initialText: "queue this",
workspaceAttachment: initialReview,
});
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
renderComposer({
initialText: "",
workspaceAttachment: editedReview,
});
const queued = mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{
attachments: ComposerAttachment[];
}>;
expect(queued[0]?.attachments).toEqual([initialReview]);
});
it("clears the included workspace review draft after queueing", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const review = reviewComposerAttachment("Clear queued review draft.");
seedReviewDraft(review.reviewDraftKey);
renderComposer({
initialText: "queue this",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
expect(getReviewDraftComments(review.reviewDraftKey)).toBeUndefined();
});
it("clears workspace review suppression after queueing a message", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const review = reviewComposerAttachment("Queue without this review first.");
renderComposer({
initialText: "queue without review",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Remove review attachment"]')!);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
const queued = mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{
attachments: ComposerAttachment[];
}>;
expect(queued[0]?.attachments).toEqual([]);
expect(queryByTestId("composer-review-attachment-pill")).not.toBeNull();
});
it("does not restore queued workspace review attachments into live draft attachments when editing", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const image = imageAttachment("img-queued-edit");
const review = reviewComposerAttachment("Queued snapshot.");
renderComposer({
initialText: "queue this",
initialAttachments: [{ kind: "image", metadata: image }],
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
expect(
(
mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{
attachments: ComposerAttachment[];
}>
)[0]?.attachments,
).toEqual([{ kind: "image", metadata: image }, review]);
click(document.querySelector('[aria-label="Edit queued message"]')!);
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
});
it("submits empty wire arrays when there are no composer attachments", async () => {
renderComposer({ initialText: "plain message" });