Files
paseo/packages/app/src/review/surface.test.tsx
T
Mohamed BoudraandGitHub 92afb826d9 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
2026-04-30 21:35:58 +08:00

390 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import "@/test/window-local-storage";
import { act, fireEvent, render, renderHook, cleanup } from "@testing-library/react";
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
import {
getInlineReviewThreadState,
getInlineReviewThreadViewportStyle,
getSplitInlineReviewThreadState,
groupInlineReviewCommentsByTarget,
InlineReviewEditor,
InlineReviewGutterCell,
InlineReviewThread,
SMALL_ACTION_HIT_SLOP,
useInlineReviewController,
type InlineReviewActions,
} from "./index";
const { theme, pressablePropsByLabel } = vi.hoisted(() => {
Object.assign(globalThis, { __DEV__: false });
return {
theme: {
spacing: { 1: 4, 2: 8, 3: 12 },
borderWidth: { 1: 1 },
borderRadius: { base: 4 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500" },
lineHeight: { diff: 18 },
colors: {
accent: "#0a84ff",
accentForeground: "#fff",
border: "#555",
destructive: "#ff453a",
foreground: "#fff",
foregroundMuted: "#aaa",
surface1: "#111",
surface2: "#222",
surface3: "#333",
},
},
pressablePropsByLabel: new Map<string, Record<string, unknown>>(),
};
});
vi.mock("react-native", async (importOriginal) => {
const ReactModule = await import("react");
const actual = await importOriginal<typeof import("react-native")>();
return {
...actual,
Pressable: ({
accessibilityLabel,
children,
onPress,
...props
}: {
accessibilityLabel?: string;
children?:
| React.ReactNode
| ((state: { hovered: boolean; pressed: boolean }) => React.ReactNode);
onPress?: () => void;
[key: string]: unknown;
}) => {
if (accessibilityLabel) {
pressablePropsByLabel.set(accessibilityLabel, props);
}
const resolvedChildren =
typeof children === "function" ? children({ hovered: false, pressed: false }) : children;
return ReactModule.createElement(
"button",
{
"aria-label": accessibilityLabel,
"data-testid": typeof props.testID === "string" ? props.testID : undefined,
disabled: props.disabled === true,
onClick: onPress,
type: "button",
},
resolvedChildren,
);
},
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
isNative: false,
isWeb: true,
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
Check: createIcon("Check"),
CircleDot: createIcon("CircleDot"),
MessageCircle: createIcon("MessageCircle"),
Pencil: createIcon("Pencil"),
Trash2: createIcon("Trash2"),
X: createIcon("X"),
};
});
function target(overrides: Partial<ReviewableDiffTarget> = {}): ReviewableDiffTarget {
return {
filePath: "src/example.ts",
hunkHeader: "@@ -1,2 +1,2 @@",
hunkIndex: 0,
lineIndex: 2,
oldLineNumber: null,
newLineNumber: 2,
side: "new",
lineNumber: 2,
lineType: "add",
content: "const value = next;",
...overrides,
key: buildReviewableDiffTargetKey({
filePath: overrides.filePath ?? "src/example.ts",
side: overrides.side ?? "new",
lineNumber: overrides.lineNumber ?? 2,
}),
};
}
const EMPTY_COMMENTS: ReviewDraftComment[] = [];
function buildReviewActions(overrides: Partial<InlineReviewActions> = {}): InlineReviewActions {
return {
commentsByTarget: new Map(),
editor: null,
showPersistentAction: false,
onStartComment: vi.fn(),
onEditComment: vi.fn(),
onCancelEditor: vi.fn(),
onSaveEditor: vi.fn(),
onDeleteComment: vi.fn(),
...overrides,
};
}
function comment(overrides: Partial<ReviewDraftComment> = {}): ReviewDraftComment {
return {
id: "comment-1",
filePath: "src/example.ts",
side: "new",
lineNumber: 2,
body: "Please simplify this.",
createdAt: "2026-04-21T00:00:00.000Z",
updatedAt: "2026-04-21T00:00:00.000Z",
...overrides,
};
}
describe("useInlineReviewController", () => {
beforeEach(() => {
useReviewDraftStore.setState({ drafts: {}, activeModesByScope: {} });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("owns draft comment add, edit, delete, cancel, and key-change editor reset", () => {
const reviewTarget = target();
const firstKey = "review:key-1";
const secondKey = "review:key-2";
const { result, rerender } = renderHook(
({ reviewDraftKey }) =>
useInlineReviewController({ reviewDraftKey, showPersistentAction: true }),
{ initialProps: { reviewDraftKey: firstKey } },
);
expect(result.current.showPersistentAction).toBe(true);
act(() => result.current.onStartComment(reviewTarget));
expect(result.current.editor).toEqual({ target: reviewTarget, commentId: null, body: "" });
act(() => result.current.onSaveEditor(" first comment "));
const savedComment = useReviewDraftStore.getState().drafts[firstKey]?.[0];
expect(savedComment).toMatchObject({
filePath: "src/example.ts",
side: "new",
lineNumber: 2,
body: "first comment",
});
expect(result.current.editor).toBeNull();
expect(result.current.commentsByTarget.get(reviewTarget.key)?.[0]).toMatchObject({
body: "first comment",
});
act(() => result.current.onEditComment(reviewTarget, savedComment!));
expect(result.current.editor).toEqual({
target: reviewTarget,
commentId: savedComment?.id,
body: "first comment",
});
act(() => result.current.onSaveEditor(" updated comment "));
const updatedComment = useReviewDraftStore.getState().drafts[firstKey]?.[0];
expect(updatedComment).toMatchObject({ id: savedComment?.id, body: "updated comment" });
act(() => result.current.onEditComment(reviewTarget, updatedComment!));
act(() => result.current.onDeleteComment(updatedComment!.id));
expect(useReviewDraftStore.getState().drafts[firstKey]).toEqual([]);
expect(result.current.editor).toBeNull();
act(() => result.current.onStartComment(reviewTarget));
act(() => result.current.onCancelEditor());
expect(result.current.editor).toBeNull();
act(() => result.current.onStartComment(reviewTarget));
rerender({ reviewDraftKey: secondKey });
expect(result.current.editor).toBeNull();
});
});
describe("git diff inline review helpers", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
pressablePropsByLabel.clear();
});
it("maps persisted draft comments to their reviewable diff target", () => {
const comments = [
comment(),
comment({ id: "comment-2", side: "old", lineNumber: 8 }),
comment({ id: "comment-3", filePath: "src/other.ts" }),
];
const commentsByTarget = groupInlineReviewCommentsByTarget(comments);
expect(commentsByTarget.get("src/example.ts:new:2")).toEqual([comments[0]]);
expect(commentsByTarget.get("src/example.ts:old:8")).toEqual([comments[1]]);
expect(commentsByTarget.get("src/other.ts:new:2")).toEqual([comments[2]]);
expect(
getInlineReviewThreadState({
reviewTarget: target(),
reviewActions: {
commentsByTarget,
editor: null,
showPersistentAction: false,
onStartComment: vi.fn(),
onEditComment: vi.fn(),
onCancelEditor: vi.fn(),
onSaveEditor: vi.fn(),
onDeleteComment: vi.fn(),
},
})?.comments,
).toEqual([comments[0]]);
});
it("reserves split inline review height from the taller side", () => {
const leftTarget = target({ side: "old", lineNumber: 8, oldLineNumber: 8 });
const rightTarget = target();
const rightComment = comment();
const actions: InlineReviewActions = {
commentsByTarget: groupInlineReviewCommentsByTarget([rightComment]),
editor: { target: rightTarget, commentId: null, body: "" },
showPersistentAction: false,
onStartComment: vi.fn(),
onEditComment: vi.fn(),
onCancelEditor: vi.fn(),
onSaveEditor: vi.fn(),
onDeleteComment: vi.fn(),
};
const rowState = getSplitInlineReviewThreadState({
left: leftTarget,
right: rightTarget,
reviewActions: actions,
});
expect(rowState?.left).toBeNull();
expect(rowState?.right?.comments).toEqual([rightComment]);
expect(rowState?.height).toBe(210);
});
it("pins no-wrap review threads to the visible diff viewport", () => {
expect(
getInlineReviewThreadViewportStyle({
viewportWidth: 320,
pinToViewport: true,
}),
).toEqual([{ position: "sticky", left: 0 }, { width: 320 }]);
});
it("keeps the gutter add-comment target accessible", () => {
const onStartComment = vi.fn();
const reviewTarget = target();
const { getByLabelText } = render(
<InlineReviewGutterCell
reviewTarget={reviewTarget}
comments={EMPTY_COMMENTS}
isEditorOpen={false}
showPersistentAction={false}
onStartComment={onStartComment}
>
<span>2</span>
</InlineReviewGutterCell>,
);
fireEvent.click(getByLabelText("Add review comment"));
expect(onStartComment).toHaveBeenCalledWith(reviewTarget);
expect(pressablePropsByLabel.get("Add review comment")?.hitSlop).toBe(SMALL_ACTION_HIT_SLOP);
});
it("replaces the line number with the message icon in the same gutter cell", () => {
const reviewTarget = target();
const { container, queryByText } = render(
<InlineReviewGutterCell
reviewTarget={reviewTarget}
comments={EMPTY_COMMENTS}
isEditorOpen={false}
showPersistentAction
onStartComment={vi.fn()}
>
<span>2</span>
</InlineReviewGutterCell>,
);
expect(queryByText("2")).toBeNull();
expect(container.querySelector("[data-icon='MessageCircle']")).toBeTruthy();
});
});
describe("InlineReviewEditor", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("saves trimmed bodies and cancels without saving", () => {
const onCancel = vi.fn();
const onSave = vi.fn();
const { getByTestId } = render(
<InlineReviewEditor
initialBody=" initial "
onCancel={onCancel}
onSave={onSave}
testID="editor"
/>,
);
fireEvent.change(getByTestId("editor-input"), { target: { value: " updated comment " } });
fireEvent.click(getByTestId("editor-save"));
expect(onSave).toHaveBeenCalledWith("updated comment");
fireEvent.click(getByTestId("editor-cancel"));
expect(onCancel).toHaveBeenCalledTimes(1);
});
});
describe("InlineReviewThread", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("exposes edit and delete actions for existing comments", () => {
const reviewTarget = target();
const draftComment = comment();
const actions = buildReviewActions({
commentsByTarget: groupInlineReviewCommentsByTarget([draftComment]),
});
const { getByTestId, getByText } = render(
<InlineReviewThread
reviewTarget={reviewTarget}
reviewActions={actions}
height={76}
testID="thread"
/>,
);
expect(getByText("Please simplify this.")).toBeTruthy();
fireEvent.click(getByTestId("review-comment-edit-comment-1"));
expect(actions.onEditComment).toHaveBeenCalledWith(reviewTarget, draftComment);
fireEvent.click(getByTestId("review-comment-delete-comment-1"));
expect(actions.onDeleteComment).toHaveBeenCalledWith("comment-1");
});
});