* 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
457 lines
13 KiB
TypeScript
457 lines
13 KiB
TypeScript
import { useMemo } from "react";
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import { create } from "zustand";
|
|
import { createJSONStorage, persist } from "zustand/middleware";
|
|
import type { ComposerAttachment } from "@/attachments/types";
|
|
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
|
import { generateMessageId } from "@/types/stream";
|
|
import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout";
|
|
import type { AgentAttachment } from "@server/shared/messages";
|
|
|
|
const STORE_VERSION = 1;
|
|
const CONTEXT_RADIUS = 3;
|
|
const EMPTY_REVIEW_DRAFT_COMMENTS: ReviewDraftComment[] = [];
|
|
|
|
type ReviewAttachment = Extract<AgentAttachment, { type: "review" }>;
|
|
type ReviewAttachmentContextLine = ReviewAttachment["comments"][number]["context"]["targetLine"];
|
|
type ReviewComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
|
|
|
|
export type ReviewDraftMode = "uncommitted" | "base";
|
|
export type ReviewDraftSide = "old" | "new";
|
|
|
|
export interface ReviewDraftComment {
|
|
id: string;
|
|
filePath: string;
|
|
side: ReviewDraftSide;
|
|
lineNumber: number;
|
|
body: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface BuildReviewDraftKeyInput {
|
|
serverId: string;
|
|
workspaceId?: string | null;
|
|
cwd: string;
|
|
mode: ReviewDraftMode;
|
|
baseRef?: string | null;
|
|
ignoreWhitespace: boolean;
|
|
}
|
|
|
|
export type BuildReviewDraftScopeKeyInput = Omit<BuildReviewDraftKeyInput, "mode">;
|
|
|
|
export interface BuildReviewAttachmentSnapshotInput {
|
|
reviewDraftKey: string;
|
|
cwd: string;
|
|
mode: ReviewDraftMode;
|
|
baseRef?: string | null;
|
|
comments: readonly ReviewDraftComment[];
|
|
diffFiles: readonly ParsedDiffFile[];
|
|
}
|
|
|
|
interface ReviewDraftStoreState {
|
|
drafts: Record<string, ReviewDraftComment[]>;
|
|
activeModesByScope: Record<string, ReviewDraftMode>;
|
|
}
|
|
|
|
interface ReviewDraftStoreActions {
|
|
setActiveMode: (input: { scopeKey: string; mode: ReviewDraftMode }) => void;
|
|
addComment: (input: { key: string; comment: ReviewDraftCommentInput }) => ReviewDraftComment;
|
|
updateComment: (input: {
|
|
key: string;
|
|
id: string;
|
|
updates: Partial<Pick<ReviewDraftComment, "body">>;
|
|
updatedAt?: string;
|
|
}) => void;
|
|
deleteComment: (input: { key: string; id: string }) => void;
|
|
clearReview: (input: { key: string }) => void;
|
|
}
|
|
|
|
export type ReviewDraftCommentInput = Omit<ReviewDraftComment, "id" | "createdAt" | "updatedAt"> &
|
|
Partial<Pick<ReviewDraftComment, "id" | "createdAt" | "updatedAt">>;
|
|
|
|
type ReviewDraftStore = ReviewDraftStoreState & ReviewDraftStoreActions;
|
|
|
|
function encodeKeyPart(value: string): string {
|
|
return encodeURIComponent(value.trim());
|
|
}
|
|
|
|
function normalizeCwd(cwd: string): string {
|
|
const trimmed = cwd.trim();
|
|
if (trimmed === "/") {
|
|
return trimmed;
|
|
}
|
|
return trimmed.replace(/\/+$/, "");
|
|
}
|
|
|
|
function normalizeBaseRef(baseRef: string | null | undefined): string {
|
|
return baseRef?.trim() ?? "";
|
|
}
|
|
|
|
function buildReviewDraftScopeParts(input: BuildReviewDraftScopeKeyInput): string[] {
|
|
const workspaceId = input.workspaceId?.trim();
|
|
const workspacePart = workspaceId
|
|
? `workspace=${encodeKeyPart(workspaceId)}`
|
|
: `cwd=${encodeKeyPart(normalizeCwd(input.cwd))}`;
|
|
|
|
return [
|
|
"review",
|
|
`server=${encodeKeyPart(input.serverId)}`,
|
|
workspacePart,
|
|
`base=${encodeKeyPart(normalizeBaseRef(input.baseRef))}`,
|
|
`ignoreWhitespace=${input.ignoreWhitespace ? "true" : "false"}`,
|
|
];
|
|
}
|
|
|
|
export function buildReviewDraftScopeKey(input: BuildReviewDraftScopeKeyInput): string {
|
|
return buildReviewDraftScopeParts(input).join(":");
|
|
}
|
|
|
|
export function buildReviewDraftKey(input: BuildReviewDraftKeyInput): string {
|
|
const [prefix, serverPart, workspacePart, basePart, whitespacePart] =
|
|
buildReviewDraftScopeParts(input);
|
|
return [prefix, serverPart, workspacePart, `mode=${input.mode}`, basePart, whitespacePart].join(
|
|
":",
|
|
);
|
|
}
|
|
|
|
function createDraftComment(input: ReviewDraftCommentInput): ReviewDraftComment {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
id: input.id ?? generateMessageId(),
|
|
filePath: input.filePath,
|
|
side: input.side,
|
|
lineNumber: input.lineNumber,
|
|
body: input.body,
|
|
createdAt: input.createdAt ?? now,
|
|
updatedAt: input.updatedAt ?? input.createdAt ?? now,
|
|
};
|
|
}
|
|
|
|
function applyCommentUpdates(
|
|
comment: ReviewDraftComment,
|
|
targetId: string,
|
|
updates: Partial<Pick<ReviewDraftComment, "body">>,
|
|
updatedAt: string,
|
|
): ReviewDraftComment {
|
|
if (comment.id !== targetId) {
|
|
return comment;
|
|
}
|
|
return {
|
|
id: comment.id,
|
|
filePath: comment.filePath,
|
|
side: comment.side,
|
|
lineNumber: comment.lineNumber,
|
|
body: updates.body ?? comment.body,
|
|
createdAt: comment.createdAt,
|
|
updatedAt,
|
|
};
|
|
}
|
|
|
|
function normalizePersistedState(state: unknown): ReviewDraftStoreState {
|
|
if (!state || typeof state !== "object") {
|
|
return { drafts: {}, activeModesByScope: {} };
|
|
}
|
|
const persisted = state as { drafts?: unknown; activeModesByScope?: unknown };
|
|
const drafts = persisted.drafts;
|
|
if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) {
|
|
return { drafts: {}, activeModesByScope: {} };
|
|
}
|
|
|
|
const normalized: Record<string, ReviewDraftComment[]> = {};
|
|
for (const [key, value] of Object.entries(drafts)) {
|
|
if (!Array.isArray(value)) {
|
|
continue;
|
|
}
|
|
normalized[key] = value.filter((comment): comment is ReviewDraftComment =>
|
|
isReviewDraftComment(comment),
|
|
);
|
|
}
|
|
|
|
const activeModesByScope: Record<string, ReviewDraftMode> = {};
|
|
const persistedActiveModes = persisted.activeModesByScope;
|
|
if (
|
|
persistedActiveModes &&
|
|
typeof persistedActiveModes === "object" &&
|
|
!Array.isArray(persistedActiveModes)
|
|
) {
|
|
for (const [key, mode] of Object.entries(persistedActiveModes)) {
|
|
if (mode === "base" || mode === "uncommitted") {
|
|
activeModesByScope[key] = mode;
|
|
}
|
|
}
|
|
}
|
|
return { drafts: normalized, activeModesByScope };
|
|
}
|
|
|
|
function isReviewDraftComment(value: unknown): value is ReviewDraftComment {
|
|
if (!value || typeof value !== "object") {
|
|
return false;
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
return (
|
|
typeof record.id === "string" &&
|
|
typeof record.filePath === "string" &&
|
|
(record.side === "old" || record.side === "new") &&
|
|
typeof record.lineNumber === "number" &&
|
|
Number.isInteger(record.lineNumber) &&
|
|
record.lineNumber > 0 &&
|
|
typeof record.body === "string" &&
|
|
typeof record.createdAt === "string" &&
|
|
typeof record.updatedAt === "string"
|
|
);
|
|
}
|
|
|
|
export const useReviewDraftStore = create<ReviewDraftStore>()(
|
|
persist(
|
|
(set) => ({
|
|
drafts: {},
|
|
activeModesByScope: {},
|
|
setActiveMode: ({ scopeKey, mode }) => {
|
|
set((state) => {
|
|
if (state.activeModesByScope[scopeKey] === mode) {
|
|
return state;
|
|
}
|
|
return {
|
|
activeModesByScope: {
|
|
...state.activeModesByScope,
|
|
[scopeKey]: mode,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
addComment: ({ key, comment }) => {
|
|
const nextComment = createDraftComment(comment);
|
|
set((state) => ({
|
|
drafts: {
|
|
...state.drafts,
|
|
[key]: [...(state.drafts[key] ?? []), nextComment],
|
|
},
|
|
}));
|
|
return nextComment;
|
|
},
|
|
updateComment: ({ key, id, updates, updatedAt }) => {
|
|
set((state) => {
|
|
const comments = state.drafts[key] ?? [];
|
|
if (!comments.some((comment) => comment.id === id)) {
|
|
return state;
|
|
}
|
|
const nextUpdatedAt = updatedAt ?? new Date().toISOString();
|
|
return {
|
|
drafts: {
|
|
...state.drafts,
|
|
[key]: comments.map((comment) =>
|
|
applyCommentUpdates(comment, id, updates, nextUpdatedAt),
|
|
),
|
|
},
|
|
};
|
|
});
|
|
},
|
|
deleteComment: ({ key, id }) => {
|
|
set((state) => {
|
|
const comments = state.drafts[key] ?? [];
|
|
if (!comments.some((comment) => comment.id === id)) {
|
|
return state;
|
|
}
|
|
return {
|
|
drafts: {
|
|
...state.drafts,
|
|
[key]: comments.filter((comment) => comment.id !== id),
|
|
},
|
|
};
|
|
});
|
|
},
|
|
clearReview: ({ key }) => {
|
|
set((state) => {
|
|
if (!state.drafts[key]) {
|
|
return state;
|
|
}
|
|
const next = { ...state.drafts };
|
|
delete next[key];
|
|
return { drafts: next };
|
|
});
|
|
},
|
|
}),
|
|
{
|
|
name: "@paseo:review-draft-store",
|
|
version: STORE_VERSION,
|
|
storage: createJSONStorage(() => AsyncStorage),
|
|
partialize: (state) => ({
|
|
drafts: state.drafts,
|
|
activeModesByScope: state.activeModesByScope,
|
|
}),
|
|
migrate: async (state) => normalizePersistedState(state),
|
|
},
|
|
),
|
|
);
|
|
|
|
function toContextLine(line: NumberedDiffLine): ReviewAttachmentContextLine | null {
|
|
if (line.line.type === "header") {
|
|
return null;
|
|
}
|
|
return {
|
|
oldLineNumber: line.oldLineNumber,
|
|
newLineNumber: line.newLineNumber,
|
|
type: line.line.type,
|
|
content: line.line.content,
|
|
};
|
|
}
|
|
|
|
function findTarget(input: { comment: ReviewDraftComment; diffFiles: readonly ParsedDiffFile[] }): {
|
|
hunkHeader: string;
|
|
hunkLines: NumberedDiffLine[];
|
|
targetIndex: number;
|
|
targetLine: NumberedDiffLine;
|
|
} | null {
|
|
const file = input.diffFiles.find((candidate) => candidate.path === input.comment.filePath);
|
|
if (!file) {
|
|
return null;
|
|
}
|
|
|
|
for (const hunk of buildNumberedDiffHunks(file)) {
|
|
const targetIndex = hunk.lines.findIndex((line) => {
|
|
const cell = input.comment.side === "old" ? line.oldCell : line.newCell;
|
|
return cell?.lineNumber === input.comment.lineNumber;
|
|
});
|
|
const targetLine = hunk.lines[targetIndex];
|
|
if (targetLine) {
|
|
return {
|
|
hunkHeader: hunk.hunkHeader,
|
|
hunkLines: hunk.lines,
|
|
targetIndex,
|
|
targetLine,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function buildReviewAttachmentSnapshot(
|
|
input: BuildReviewAttachmentSnapshotInput,
|
|
): ReviewComposerAttachment | null {
|
|
const comments: ReviewAttachment["comments"] = [];
|
|
|
|
for (const draftComment of input.comments) {
|
|
const target = findTarget({
|
|
comment: draftComment,
|
|
diffFiles: input.diffFiles,
|
|
});
|
|
if (!target) {
|
|
continue;
|
|
}
|
|
|
|
const targetLine = toContextLine(target.targetLine);
|
|
if (!targetLine) {
|
|
continue;
|
|
}
|
|
|
|
const contextStart = Math.max(0, target.targetIndex - CONTEXT_RADIUS);
|
|
const contextEnd = Math.min(target.hunkLines.length, target.targetIndex + CONTEXT_RADIUS + 1);
|
|
const lines = target.hunkLines
|
|
.slice(contextStart, contextEnd)
|
|
.map(toContextLine)
|
|
.filter((line): line is ReviewAttachmentContextLine => line !== null);
|
|
|
|
comments.push({
|
|
filePath: draftComment.filePath,
|
|
side: draftComment.side,
|
|
lineNumber: draftComment.lineNumber,
|
|
body: draftComment.body,
|
|
context: {
|
|
hunkHeader: target.hunkHeader,
|
|
targetLine,
|
|
lines,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (comments.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const attachment: ReviewAttachment = {
|
|
type: "review",
|
|
mimeType: "application/paseo-review",
|
|
cwd: input.cwd,
|
|
mode: input.mode,
|
|
baseRef: normalizeBaseRef(input.baseRef) || null,
|
|
comments,
|
|
};
|
|
|
|
return {
|
|
kind: "review",
|
|
reviewDraftKey: input.reviewDraftKey,
|
|
commentCount: comments.length,
|
|
attachment,
|
|
};
|
|
}
|
|
|
|
export function useReviewDraftComments(key: string): ReviewDraftComment[] {
|
|
return useReviewDraftStore((state) => state.drafts[key] ?? EMPTY_REVIEW_DRAFT_COMMENTS);
|
|
}
|
|
|
|
export function useSetActiveReviewDraftMode(): ReviewDraftStoreActions["setActiveMode"] {
|
|
return useReviewDraftStore((state) => state.setActiveMode);
|
|
}
|
|
|
|
export function useClearReviewDraft(): ReviewDraftStoreActions["clearReview"] {
|
|
return useReviewDraftStore((state) => state.clearReview);
|
|
}
|
|
|
|
export function addReviewDraftComment(input: {
|
|
key: string;
|
|
comment: ReviewDraftCommentInput;
|
|
}): ReviewDraftComment {
|
|
return useReviewDraftStore.getState().addComment(input);
|
|
}
|
|
|
|
export function getReviewDraftComments(key: string): ReviewDraftComment[] | undefined {
|
|
return useReviewDraftStore.getState().drafts[key];
|
|
}
|
|
|
|
export function resetReviewDraftStore(): void {
|
|
useReviewDraftStore.setState({ drafts: {}, activeModesByScope: {} });
|
|
}
|
|
|
|
export function useReviewDraftCommentsForAttachment(input: {
|
|
key: string;
|
|
enabled: boolean;
|
|
}): ReviewDraftComment[] {
|
|
return useReviewDraftStore((state) =>
|
|
input.enabled
|
|
? (state.drafts[input.key] ?? EMPTY_REVIEW_DRAFT_COMMENTS)
|
|
: EMPTY_REVIEW_DRAFT_COMMENTS,
|
|
);
|
|
}
|
|
|
|
export function useReviewCommentCount(key: string): number {
|
|
return useReviewDraftStore((state) => state.drafts[key]?.length ?? 0);
|
|
}
|
|
|
|
export function useActiveReviewDraftMode(input: { scopeKey: string }): ReviewDraftMode | null {
|
|
return useReviewDraftStore((state) => state.activeModesByScope[input.scopeKey] ?? null);
|
|
}
|
|
|
|
export function useReviewAttachmentSnapshot(input: {
|
|
key: string;
|
|
diffFiles: readonly ParsedDiffFile[];
|
|
cwd: string;
|
|
mode: ReviewDraftMode;
|
|
baseRef?: string | null;
|
|
}): ReviewComposerAttachment | null {
|
|
const comments = useReviewDraftComments(input.key);
|
|
return useMemo(
|
|
() =>
|
|
buildReviewAttachmentSnapshot({
|
|
reviewDraftKey: input.key,
|
|
cwd: input.cwd,
|
|
mode: input.mode,
|
|
baseRef: input.baseRef,
|
|
comments,
|
|
diffFiles: input.diffFiles,
|
|
}),
|
|
[comments, input.key, input.cwd, input.mode, input.baseRef, input.diffFiles],
|
|
);
|
|
}
|