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:
@@ -1,16 +1,69 @@
|
||||
import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
|
||||
type ReviewSide = "old" | "new";
|
||||
type ReviewableLineType = "add" | "remove" | "context";
|
||||
|
||||
export interface ReviewableDiffTargetKeyInput {
|
||||
filePath: string;
|
||||
side: ReviewSide;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
export interface ReviewableDiffTarget {
|
||||
key: string;
|
||||
filePath: string;
|
||||
hunkHeader: string;
|
||||
hunkIndex: number;
|
||||
lineIndex: number;
|
||||
oldLineNumber: number | null;
|
||||
newLineNumber: number | null;
|
||||
side: ReviewSide;
|
||||
lineNumber: number;
|
||||
lineType: ReviewableLineType;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function buildReviewableDiffTargetKey(input: ReviewableDiffTargetKeyInput): string {
|
||||
return `${input.filePath}:${input.side}:${input.lineNumber}`;
|
||||
}
|
||||
|
||||
export interface NumberedDiffCell extends ReviewableDiffTarget {
|
||||
line: DiffLine;
|
||||
}
|
||||
|
||||
export interface NumberedDiffLine {
|
||||
key: string;
|
||||
filePath: string;
|
||||
hunkHeader: string;
|
||||
hunkIndex: number;
|
||||
lineIndex: number;
|
||||
line: DiffLine;
|
||||
oldLineNumber: number | null;
|
||||
newLineNumber: number | null;
|
||||
unifiedCell: NumberedDiffCell | null;
|
||||
oldCell: NumberedDiffCell | null;
|
||||
newCell: NumberedDiffCell | null;
|
||||
}
|
||||
|
||||
export interface NumberedDiffHunk {
|
||||
hunkIndex: number;
|
||||
hunkHeader: string;
|
||||
lines: NumberedDiffLine[];
|
||||
}
|
||||
|
||||
export interface SplitDiffDisplayLine {
|
||||
type: DiffLine["type"];
|
||||
content: string;
|
||||
tokens?: DiffLine["tokens"];
|
||||
lineNumber: number | null;
|
||||
reviewTarget: ReviewableDiffTarget | null;
|
||||
}
|
||||
|
||||
export interface UnifiedDiffDisplayLine {
|
||||
key: string;
|
||||
line: DiffLine;
|
||||
lineNumber: number | null;
|
||||
reviewTarget: ReviewableDiffTarget | null;
|
||||
}
|
||||
|
||||
export type SplitDiffRow =
|
||||
@@ -24,95 +77,175 @@ export type SplitDiffRow =
|
||||
right: SplitDiffDisplayLine | null;
|
||||
};
|
||||
|
||||
function toDisplayLine(input: {
|
||||
line: DiffLine;
|
||||
oldLineNumber: number | null;
|
||||
newLineNumber: number | null;
|
||||
side: "left" | "right";
|
||||
}): SplitDiffDisplayLine | null {
|
||||
const { line, oldLineNumber, newLineNumber, side } = input;
|
||||
if (line.type === "header") {
|
||||
function toSplitDisplayLine(cell: NumberedDiffCell | null): SplitDiffDisplayLine | null {
|
||||
if (!cell) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.type === "remove") {
|
||||
if (side !== "left") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "remove",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: oldLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
if (line.type === "add") {
|
||||
if (side !== "right") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "add",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: newLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "context",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: side === "left" ? oldLineNumber : newLineNumber,
|
||||
type: cell.lineType,
|
||||
content: cell.content,
|
||||
...(cell.line.tokens ? { tokens: cell.line.tokens } : {}),
|
||||
lineNumber: cell.lineNumber,
|
||||
reviewTarget: toReviewTarget(cell),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUnifiedDiffLines(file: ParsedDiffFile): UnifiedDiffDisplayLine[] {
|
||||
const lines: UnifiedDiffDisplayLine[] = [];
|
||||
function toReviewTarget(cell: NumberedDiffCell): ReviewableDiffTarget {
|
||||
return {
|
||||
key: cell.key,
|
||||
filePath: cell.filePath,
|
||||
hunkHeader: cell.hunkHeader,
|
||||
hunkIndex: cell.hunkIndex,
|
||||
lineIndex: cell.lineIndex,
|
||||
oldLineNumber: cell.oldLineNumber,
|
||||
newLineNumber: cell.newLineNumber,
|
||||
side: cell.side,
|
||||
lineNumber: cell.lineNumber,
|
||||
lineType: cell.lineType,
|
||||
content: cell.content,
|
||||
};
|
||||
}
|
||||
|
||||
function getHunkHeader(hunk: ParsedDiffFile["hunks"][number]): string {
|
||||
const headerLine = hunk.lines.find((line) => line.type === "header");
|
||||
return headerLine?.content ?? "@@";
|
||||
}
|
||||
|
||||
export function buildNumberedDiffHunks(file: ParsedDiffFile): NumberedDiffHunk[] {
|
||||
const numberedHunks: NumberedDiffHunk[] = [];
|
||||
for (const [hunkIndex, hunk] of file.hunks.entries()) {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
const hunkHeader = getHunkHeader(hunk);
|
||||
const lines: NumberedDiffLine[] = [];
|
||||
|
||||
for (const [lineIndex, line] of hunk.lines.entries()) {
|
||||
let lineNumber: number | null = null;
|
||||
let oldLineNumber: number | null = null;
|
||||
let newLineNumber: number | null = null;
|
||||
|
||||
if (line.type === "remove") {
|
||||
lineNumber = oldLineNo;
|
||||
oldLineNumber = oldLineNo;
|
||||
oldLineNo += 1;
|
||||
} else if (line.type === "add") {
|
||||
lineNumber = newLineNo;
|
||||
newLineNumber = newLineNo;
|
||||
newLineNo += 1;
|
||||
} else if (line.type === "context") {
|
||||
lineNumber = newLineNo;
|
||||
oldLineNumber = oldLineNo;
|
||||
newLineNumber = newLineNo;
|
||||
oldLineNo += 1;
|
||||
newLineNo += 1;
|
||||
}
|
||||
|
||||
const oldCell = buildNumberedCell({
|
||||
filePath: file.path,
|
||||
hunkHeader,
|
||||
hunkIndex,
|
||||
lineIndex,
|
||||
line,
|
||||
oldLineNumber,
|
||||
newLineNumber,
|
||||
side: "old",
|
||||
});
|
||||
const newCell = buildNumberedCell({
|
||||
filePath: file.path,
|
||||
hunkHeader,
|
||||
hunkIndex,
|
||||
lineIndex,
|
||||
line,
|
||||
oldLineNumber,
|
||||
newLineNumber,
|
||||
side: "new",
|
||||
});
|
||||
|
||||
lines.push({
|
||||
key: `${hunkIndex}-${lineIndex}`,
|
||||
filePath: file.path,
|
||||
hunkHeader,
|
||||
hunkIndex,
|
||||
lineIndex,
|
||||
line,
|
||||
lineNumber,
|
||||
oldLineNumber,
|
||||
newLineNumber,
|
||||
unifiedCell: line.type === "remove" ? oldCell : newCell,
|
||||
oldCell,
|
||||
newCell,
|
||||
});
|
||||
}
|
||||
|
||||
numberedHunks.push({ hunkIndex, hunkHeader, lines });
|
||||
}
|
||||
|
||||
return lines;
|
||||
return numberedHunks;
|
||||
}
|
||||
|
||||
function buildNumberedCell(input: {
|
||||
filePath: string;
|
||||
hunkHeader: string;
|
||||
hunkIndex: number;
|
||||
lineIndex: number;
|
||||
line: DiffLine;
|
||||
oldLineNumber: number | null;
|
||||
newLineNumber: number | null;
|
||||
side: ReviewSide;
|
||||
}): NumberedDiffCell | null {
|
||||
if (input.line.type === "header") {
|
||||
return null;
|
||||
}
|
||||
if (input.line.type === "remove" && input.side !== "old") {
|
||||
return null;
|
||||
}
|
||||
if (input.line.type === "add" && input.side !== "new") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineNumber = input.side === "old" ? input.oldLineNumber : input.newLineNumber;
|
||||
if (lineNumber === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: buildReviewableDiffTargetKey({
|
||||
filePath: input.filePath,
|
||||
side: input.side,
|
||||
lineNumber,
|
||||
}),
|
||||
filePath: input.filePath,
|
||||
hunkHeader: input.hunkHeader,
|
||||
hunkIndex: input.hunkIndex,
|
||||
lineIndex: input.lineIndex,
|
||||
oldLineNumber: input.oldLineNumber,
|
||||
newLineNumber: input.newLineNumber,
|
||||
side: input.side,
|
||||
lineNumber,
|
||||
lineType: input.line.type,
|
||||
content: input.line.content,
|
||||
line: input.line,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUnifiedDiffLines(file: ParsedDiffFile): UnifiedDiffDisplayLine[] {
|
||||
return buildNumberedDiffHunks(file).flatMap((hunk) =>
|
||||
hunk.lines.map((numberedLine) => ({
|
||||
key: numberedLine.key,
|
||||
line: numberedLine.line,
|
||||
lineNumber: numberedLine.unifiedCell?.lineNumber ?? null,
|
||||
reviewTarget: numberedLine.unifiedCell ? toReviewTarget(numberedLine.unifiedCell) : null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
|
||||
const rows: SplitDiffRow[] = [];
|
||||
|
||||
for (const hunk of file.hunks) {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
for (const hunk of buildNumberedDiffHunks(file)) {
|
||||
rows.push({
|
||||
kind: "header",
|
||||
content: hunk.lines[0]?.type === "header" ? hunk.lines[0].content : "@@",
|
||||
content: hunk.hunkHeader,
|
||||
});
|
||||
|
||||
let pendingRemovals: Array<{ line: DiffLine; oldLineNumber: number }> = [];
|
||||
let pendingAdditions: Array<{ line: DiffLine; newLineNumber: number }> = [];
|
||||
let pendingRemovals: NumberedDiffCell[] = [];
|
||||
let pendingAdditions: NumberedDiffCell[] = [];
|
||||
|
||||
const flushPendingRows = () => {
|
||||
const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length);
|
||||
@@ -121,61 +254,41 @@ export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
|
||||
const addition = pendingAdditions[index] ?? null;
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: removal
|
||||
? toDisplayLine({
|
||||
line: removal.line,
|
||||
oldLineNumber: removal.oldLineNumber,
|
||||
newLineNumber: null,
|
||||
side: "left",
|
||||
})
|
||||
: null,
|
||||
right: addition
|
||||
? toDisplayLine({
|
||||
line: addition.line,
|
||||
oldLineNumber: null,
|
||||
newLineNumber: addition.newLineNumber,
|
||||
side: "right",
|
||||
})
|
||||
: null,
|
||||
left: toSplitDisplayLine(removal),
|
||||
right: toSplitDisplayLine(addition),
|
||||
});
|
||||
}
|
||||
pendingRemovals = [];
|
||||
pendingAdditions = [];
|
||||
};
|
||||
|
||||
for (const line of hunk.lines.slice(1)) {
|
||||
if (line.type === "remove") {
|
||||
pendingRemovals.push({ line, oldLineNumber: oldLineNo });
|
||||
oldLineNo += 1;
|
||||
for (const numberedLine of hunk.lines) {
|
||||
if (numberedLine.line.type === "header") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.type === "add") {
|
||||
pendingAdditions.push({ line, newLineNumber: newLineNo });
|
||||
newLineNo += 1;
|
||||
if (numberedLine.line.type === "remove") {
|
||||
if (numberedLine.oldCell) {
|
||||
pendingRemovals.push(numberedLine.oldCell);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (numberedLine.line.type === "add") {
|
||||
if (numberedLine.newCell) {
|
||||
pendingAdditions.push(numberedLine.newCell);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
flushPendingRows();
|
||||
|
||||
if (line.type === "context") {
|
||||
if (numberedLine.line.type === "context") {
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: toDisplayLine({
|
||||
line,
|
||||
oldLineNumber: oldLineNo,
|
||||
newLineNumber: newLineNo,
|
||||
side: "left",
|
||||
}),
|
||||
right: toDisplayLine({
|
||||
line,
|
||||
oldLineNumber: oldLineNo,
|
||||
newLineNumber: newLineNo,
|
||||
side: "right",
|
||||
}),
|
||||
left: toSplitDisplayLine(numberedLine.oldCell),
|
||||
right: toSplitDisplayLine(numberedLine.newCell),
|
||||
});
|
||||
oldLineNo += 1;
|
||||
newLineNo += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user