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:
@@ -0,0 +1,279 @@
|
||||
import "@/test/window-local-storage";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
import {
|
||||
buildReviewAttachmentSnapshot,
|
||||
buildReviewDraftKey,
|
||||
buildReviewDraftScopeKey,
|
||||
useReviewDraftStore,
|
||||
} from "./store";
|
||||
|
||||
const STORE_STORAGE_KEY = "@paseo:review-draft-store";
|
||||
|
||||
function makeFile(): ParsedDiffFile {
|
||||
return {
|
||||
path: "src/example.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "ok",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 40,
|
||||
oldCount: 4,
|
||||
newStart: 40,
|
||||
newCount: 4,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -40,4 +40,4 @@" },
|
||||
{ type: "context", content: "const before = true;" },
|
||||
{ type: "remove", content: "const value = oldValue;" },
|
||||
{ type: "add", content: "const value = newValue;" },
|
||||
{ type: "context", content: "return value;" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildReviewDraftKey", () => {
|
||||
it("scopes by server, workspace-or-cwd, diff mode, base ref, and whitespace mode", () => {
|
||||
const base = buildReviewDraftKey({
|
||||
serverId: " local ",
|
||||
workspaceId: " workspace-1 ",
|
||||
cwd: "/repo",
|
||||
mode: "base",
|
||||
baseRef: " main ",
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
|
||||
expect(base).toBe(
|
||||
"review:server=local:workspace=workspace-1:mode=base:base=main:ignoreWhitespace=false",
|
||||
);
|
||||
expect(
|
||||
buildReviewDraftKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: true,
|
||||
}),
|
||||
).not.toBe(base);
|
||||
expect(
|
||||
buildReviewDraftKey({
|
||||
serverId: "local",
|
||||
workspaceId: null,
|
||||
cwd: "/repo/",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: false,
|
||||
}),
|
||||
).toBe("review:server=local:cwd=%2Frepo:mode=base:base=main:ignoreWhitespace=false");
|
||||
});
|
||||
|
||||
it("builds a mode-free scope key for active review mode sharing", () => {
|
||||
const scope = buildReviewDraftScopeKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
|
||||
expect(scope).toBe(
|
||||
"review:server=local:workspace=workspace-1:base=main:ignoreWhitespace=false",
|
||||
);
|
||||
expect(scope).not.toContain("mode=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("review draft store", () => {
|
||||
it("normalizes persisted active review modes with draft comments", async () => {
|
||||
await AsyncStorage.setItem(
|
||||
STORE_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
version: 0,
|
||||
state: {
|
||||
drafts: {
|
||||
"review:key": [
|
||||
{
|
||||
id: "comment-1",
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body: "Keep me.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
},
|
||||
{ id: "bad", filePath: "src/example.ts" },
|
||||
],
|
||||
},
|
||||
activeModesByScope: {
|
||||
"review:scope:base": "base",
|
||||
"review:scope:dirty": "uncommitted",
|
||||
"review:scope:bad": "other",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await useReviewDraftStore.persist.rehydrate();
|
||||
|
||||
const state = useReviewDraftStore.getState();
|
||||
expect(state.activeModesByScope).toEqual({
|
||||
"review:scope:base": "base",
|
||||
"review:scope:dirty": "uncommitted",
|
||||
});
|
||||
expect(state.drafts["review:key"]).toEqual([
|
||||
{
|
||||
id: "comment-1",
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body: "Keep me.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists compact draft comments separately from workspace attachment wire context", () => {
|
||||
const key = buildReviewDraftKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
mode: "uncommitted",
|
||||
baseRef: null,
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
|
||||
useReviewDraftStore.getState().clearReview({ key });
|
||||
useReviewDraftStore.getState().addComment({
|
||||
key,
|
||||
comment: {
|
||||
id: "comment-1",
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
expect(useReviewDraftStore.getState().drafts[key]).toEqual([
|
||||
{
|
||||
id: "comment-1",
|
||||
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",
|
||||
},
|
||||
]);
|
||||
|
||||
useReviewDraftStore.getState().updateComment({
|
||||
key,
|
||||
id: "comment-1",
|
||||
updates: { body: "Please simplify this condition." },
|
||||
updatedAt: "2026-04-21T00:01:00.000Z",
|
||||
});
|
||||
expect(useReviewDraftStore.getState().drafts[key]?.[0]?.body).toBe(
|
||||
"Please simplify this condition.",
|
||||
);
|
||||
|
||||
useReviewDraftStore.getState().deleteComment({ key, id: "comment-1" });
|
||||
expect(useReviewDraftStore.getState().drafts[key]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReviewAttachmentSnapshot", () => {
|
||||
it("builds a bounded workspace review attachment and skips missing targets", () => {
|
||||
const snapshot = buildReviewAttachmentSnapshot({
|
||||
reviewDraftKey: "review:key",
|
||||
cwd: "/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
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",
|
||||
},
|
||||
{
|
||||
id: "comment-2",
|
||||
filePath: "src/missing.ts",
|
||||
side: "new",
|
||||
lineNumber: 99,
|
||||
body: "This target is stale.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
diffFiles: [makeFile()],
|
||||
});
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
kind: "review",
|
||||
reviewDraftKey: "review:key",
|
||||
commentCount: 1,
|
||||
attachment: {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body: "Please simplify this.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,4 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: 40,
|
||||
newLineNumber: 40,
|
||||
type: "context",
|
||||
content: "const before = true;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: 41,
|
||||
newLineNumber: null,
|
||||
type: "remove",
|
||||
content: "const value = oldValue;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: 42,
|
||||
newLineNumber: 42,
|
||||
type: "context",
|
||||
content: "return value;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user