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
+663
View File
@@ -0,0 +1,663 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { MessageCircle, Pencil, Trash2 } from "lucide-react-native";
import {
Pressable,
type PressableStateCallbackType,
Text,
TextInput,
type TextStyle,
View,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform";
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
type PressableState = PressableStateCallbackType & { hovered?: boolean };
function iconButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
return [styles.iconButton, (hovered || pressed) && styles.iconButtonHovered];
}
function iconButtonDestructiveStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
return [styles.iconButton, (hovered || pressed) && styles.iconButtonDestructiveHovered];
}
function ghostButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
return [styles.ghostButton, (hovered || pressed) && styles.ghostButtonHovered];
}
export const INLINE_REVIEW_COMMENT_HEIGHT = 72;
export const INLINE_REVIEW_EDITOR_HEIGHT = 132;
const INLINE_REVIEW_GAP = 6;
export const SMALL_ACTION_HIT_SLOP = 8;
export interface InlineReviewEditorState {
target: ReviewableDiffTarget;
commentId: string | null;
body: string;
}
export interface InlineReviewActions {
commentsByTarget: ReadonlyMap<string, ReviewDraftComment[]>;
editor: InlineReviewEditorState | null;
showPersistentAction: boolean;
onStartComment: (target: ReviewableDiffTarget) => void;
onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void;
onCancelEditor: () => void;
onSaveEditor: (body: string) => void;
onDeleteComment: (id: string) => void;
}
export function groupInlineReviewCommentsByTarget(
comments: readonly ReviewDraftComment[],
): Map<string, ReviewDraftComment[]> {
const grouped = new Map<string, ReviewDraftComment[]>();
for (const comment of comments) {
const key = buildReviewableDiffTargetKey(comment);
grouped.set(key, [...(grouped.get(key) ?? []), comment]);
}
return grouped;
}
export function useInlineReviewController(input: {
reviewDraftKey: string;
showPersistentAction: boolean;
}): InlineReviewActions {
const reviewComments = useReviewDraftComments(input.reviewDraftKey);
const commentsByTarget = useMemo(
() => groupInlineReviewCommentsByTarget(reviewComments),
[reviewComments],
);
const [editor, setEditor] = useState<InlineReviewEditorState | null>(null);
const addComment = useReviewDraftStore((state) => state.addComment);
const updateComment = useReviewDraftStore((state) => state.updateComment);
const deleteComment = useReviewDraftStore((state) => state.deleteComment);
useEffect(() => {
setEditor(null);
}, [input.reviewDraftKey]);
const handleStartComment = useCallback((target: ReviewableDiffTarget) => {
setEditor({ target, commentId: null, body: "" });
}, []);
const handleEditComment = useCallback(
(target: ReviewableDiffTarget, comment: ReviewDraftComment) => {
setEditor({ target, commentId: comment.id, body: comment.body });
},
[],
);
const handleCancelEditor = useCallback(() => {
setEditor(null);
}, []);
const handleSaveEditor = useCallback(
(body: string) => {
const trimmedBody = body.trim();
if (!editor || trimmedBody.length === 0) {
return;
}
if (editor.commentId) {
updateComment({
key: input.reviewDraftKey,
id: editor.commentId,
updates: { body: trimmedBody },
});
} else {
addComment({
key: input.reviewDraftKey,
comment: {
filePath: editor.target.filePath,
side: editor.target.side,
lineNumber: editor.target.lineNumber,
body: trimmedBody,
},
});
}
setEditor(null);
},
[addComment, editor, input.reviewDraftKey, updateComment],
);
const handleDeleteComment = useCallback(
(id: string) => {
deleteComment({ key: input.reviewDraftKey, id });
setEditor((current) => (current?.commentId === id ? null : current));
},
[deleteComment, input.reviewDraftKey],
);
return useMemo<InlineReviewActions>(
() => ({
commentsByTarget,
editor,
showPersistentAction: input.showPersistentAction,
onStartComment: handleStartComment,
onEditComment: handleEditComment,
onCancelEditor: handleCancelEditor,
onSaveEditor: handleSaveEditor,
onDeleteComment: handleDeleteComment,
}),
[
commentsByTarget,
editor,
handleCancelEditor,
handleDeleteComment,
handleEditComment,
handleSaveEditor,
handleStartComment,
input.showPersistentAction,
],
);
}
export function isInlineReviewEditorForTarget(
editor: InlineReviewEditorState | null,
target: ReviewableDiffTarget | null | undefined,
): boolean {
return Boolean(
editor &&
target &&
buildReviewableDiffTargetKey(editor.target) === buildReviewableDiffTargetKey(target),
);
}
export function getInlineReviewThreadState(input: {
reviewTarget: ReviewableDiffTarget | null | undefined;
reviewActions?: InlineReviewActions;
}): {
comments: ReviewDraftComment[];
hasEditor: boolean;
editingCommentId: string | null;
height: number;
} | null {
const { reviewTarget, reviewActions } = input;
if (!reviewTarget || !reviewActions) {
return null;
}
const comments = reviewActions.commentsByTarget.get(reviewTarget.key) ?? [];
const editorForTarget = isInlineReviewEditorForTarget(reviewActions.editor, reviewTarget)
? reviewActions.editor
: null;
const hasEditor = editorForTarget !== null;
const editingCommentId = editorForTarget?.commentId ?? null;
const editingExisting =
editingCommentId !== null && comments.some((comment) => comment.id === editingCommentId);
const visibleCommentCount = editingExisting ? comments.length - 1 : comments.length;
const editorCount = hasEditor ? 1 : 0;
const visibleBlockCount = visibleCommentCount + editorCount;
if (visibleBlockCount === 0) {
return null;
}
const height =
visibleCommentCount * INLINE_REVIEW_COMMENT_HEIGHT +
editorCount * INLINE_REVIEW_EDITOR_HEIGHT +
Math.max(0, visibleBlockCount - 1) * INLINE_REVIEW_GAP;
return { comments, hasEditor, editingCommentId, height };
}
export function getSplitInlineReviewThreadState(input: {
left: ReviewableDiffTarget | null | undefined;
right: ReviewableDiffTarget | null | undefined;
reviewActions?: InlineReviewActions;
}): {
left: ReturnType<typeof getInlineReviewThreadState>;
right: ReturnType<typeof getInlineReviewThreadState>;
height: number;
} | null {
const left = getInlineReviewThreadState({
reviewTarget: input.left,
reviewActions: input.reviewActions,
});
const right = getInlineReviewThreadState({
reviewTarget: input.right,
reviewActions: input.reviewActions,
});
const height = Math.max(left?.height ?? 0, right?.height ?? 0);
if (height === 0) {
return null;
}
return { left, right, height };
}
export function InlineReviewGutterCell({
children,
reviewTarget,
comments,
isEditorOpen,
showPersistentAction,
onStartComment,
style,
}: {
children: ReactNode;
reviewTarget: ReviewableDiffTarget | null | undefined;
comments: readonly ReviewDraftComment[];
isEditorOpen: boolean;
showPersistentAction: boolean;
onStartComment: (target: ReviewableDiffTarget) => void;
style?: StyleProp<ViewStyle>;
}) {
const { theme } = useUnistyles();
const canComment = Boolean(reviewTarget);
const hasComments = comments.length > 0;
const handlePress = useCallback(() => {
if (reviewTarget) {
onStartComment(reviewTarget);
}
}, [reviewTarget, onStartComment]);
const pressableStyle = useCallback(
({ hovered, pressed }: PressableState): StyleProp<ViewStyle> => [
style,
canComment && (hovered || pressed) && styles.gutterHovered,
],
[style, canComment],
);
const labelStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.gutterLabel, hasComments && styles.gutterLabelActive],
[hasComments],
);
return (
<Pressable
accessibilityRole={canComment ? "button" : undefined}
accessibilityLabel={canComment ? "Add review comment" : undefined}
hitSlop={canComment ? SMALL_ACTION_HIT_SLOP : undefined}
disabled={!canComment}
onPress={handlePress}
style={pressableStyle}
>
{({ hovered, pressed }) => {
const showAction =
canComment &&
(hovered || pressed || isNative || showPersistentAction || hasComments || isEditorOpen);
return (
<View style={styles.gutterInner}>
<View style={labelStyle}>
{showAction ? (
<MessageCircle
size={13}
strokeWidth={hasComments ? 2.25 : 1.75}
color={hasComments ? theme.colors.accent : theme.colors.foregroundMuted}
/>
) : (
children
)}
</View>
</View>
);
}}
</Pressable>
);
}
export function InlineReviewThread({
reviewTarget,
reviewActions,
height,
viewportWidth,
pinToViewport = false,
testID,
}: {
reviewTarget: ReviewableDiffTarget;
reviewActions: InlineReviewActions;
height: number;
viewportWidth?: number;
pinToViewport?: boolean;
testID?: string;
}) {
const comments = reviewActions.commentsByTarget.get(reviewTarget.key) ?? [];
const editor = isInlineReviewEditorForTarget(reviewActions.editor, reviewTarget)
? reviewActions.editor
: null;
const editingCommentId = editor?.commentId ?? null;
const editingExisting =
editingCommentId !== null && comments.some((comment) => comment.id === editingCommentId);
const editorElement = editor ? (
<InlineReviewEditor
key={editingCommentId ?? "new"}
initialBody={editor.body}
onCancel={reviewActions.onCancelEditor}
onSave={reviewActions.onSaveEditor}
testID="inline-review-editor"
/>
) : null;
const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => [
styles.threadContainer,
getInlineReviewThreadViewportStyle({ viewportWidth, pinToViewport }),
{ minHeight: height },
],
[viewportWidth, pinToViewport, height],
);
return (
<View style={containerStyle} testID={testID}>
{comments.map((comment) => {
if (comment.id === editingCommentId) {
return <React.Fragment key={comment.id}>{editorElement}</React.Fragment>;
}
return (
<CommentRow
key={comment.id}
comment={comment}
reviewTarget={reviewTarget}
onEditComment={reviewActions.onEditComment}
onDeleteComment={reviewActions.onDeleteComment}
/>
);
})}
{editor && !editingExisting ? editorElement : null}
</View>
);
}
function CommentRow({
comment,
reviewTarget,
onEditComment,
onDeleteComment,
}: {
comment: ReviewDraftComment;
reviewTarget: ReviewableDiffTarget;
onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void;
onDeleteComment: (id: string) => void;
}) {
const { theme } = useUnistyles();
const handleEdit = useCallback(
() => onEditComment(reviewTarget, comment),
[onEditComment, reviewTarget, comment],
);
const handleDelete = useCallback(
() => onDeleteComment(comment.id),
[onDeleteComment, comment.id],
);
return (
<View style={styles.commentBlock}>
<Text style={styles.commentBody} numberOfLines={2}>
{comment.body}
</Text>
<View style={styles.commentActions}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Edit review comment"
testID={`review-comment-edit-${comment.id}`}
hitSlop={SMALL_ACTION_HIT_SLOP}
onPress={handleEdit}
style={iconButtonStyle}
>
<Pencil size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel="Delete review comment"
testID={`review-comment-delete-${comment.id}`}
hitSlop={SMALL_ACTION_HIT_SLOP}
onPress={handleDelete}
style={iconButtonDestructiveStyle}
>
<Trash2 size={14} color={theme.colors.destructive} />
</Pressable>
</View>
</View>
);
}
export function getInlineReviewThreadViewportStyle({
viewportWidth,
pinToViewport,
}: {
viewportWidth?: number;
pinToViewport: boolean;
}): StyleProp<ViewStyle> {
const widthStyle = viewportWidth && viewportWidth > 0 ? { width: viewportWidth } : null;
if (!pinToViewport || !isWeb) {
return widthStyle;
}
const stickyStyle = { position: "sticky", left: 0 } as unknown as ViewStyle;
return [stickyStyle, widthStyle];
}
export function InlineReviewEditor({
initialBody,
onCancel,
onSave,
testID,
}: {
initialBody: string;
onCancel: () => void;
onSave: (body: string) => void;
testID?: string;
}) {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput | null>(null);
const [body, setBody] = useState(initialBody);
const [isFocused, setIsFocused] = useState(false);
const trimmedBody = body.trim();
const canSave = trimmedBody.length > 0;
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleFocus = useCallback(() => setIsFocused(true), []);
const handleBlur = useCallback(() => setIsFocused(false), []);
const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]);
const inputStyle = useMemo<StyleProp<TextStyle>>(
() => [styles.editorInput, isFocused && styles.editorInputFocused],
[isFocused],
);
const saveButtonStyle = useCallback(
({ hovered, pressed }: PressableState): StyleProp<ViewStyle> => [
styles.saveButton,
!canSave && styles.saveButtonDisabled,
canSave && (hovered || pressed) && styles.saveButtonHovered,
],
[canSave],
);
return (
<View style={styles.editorBlock} testID={testID}>
<TextInput
ref={inputRef}
accessibilityLabel="Review comment"
testID={testID ? `${testID}-input` : undefined}
placeholder="Leave a comment"
placeholderTextColor={theme.colors.foregroundMuted}
multiline
value={body}
onChangeText={setBody}
onFocus={handleFocus}
onBlur={handleBlur}
style={inputStyle}
/>
<View style={styles.editorActions}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Cancel review comment"
testID={testID ? `${testID}-cancel` : undefined}
hitSlop={SMALL_ACTION_HIT_SLOP}
onPress={onCancel}
style={ghostButtonStyle}
>
<Text style={styles.ghostButtonText}>Cancel</Text>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel="Save review comment"
testID={testID ? `${testID}-save` : undefined}
hitSlop={SMALL_ACTION_HIT_SLOP}
disabled={!canSave}
onPress={handleSave}
style={saveButtonStyle}
>
<Text style={styles.saveButtonText}>Save</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
gutterHovered: {
backgroundColor: theme.colors.surface2,
},
gutterInner: {
minHeight: theme.lineHeight.diff,
alignItems: "center",
justifyContent: "center",
},
gutterLabel: {
width: "100%",
minWidth: 0,
height: theme.lineHeight.diff,
alignItems: "center",
justifyContent: "center",
},
gutterLabelActive: {
backgroundColor: theme.colors.surface2,
},
threadContainer: {
flex: 1,
minWidth: 0,
gap: INLINE_REVIEW_GAP,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
commentBlock: {
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
commentBody: {
flex: 1,
minWidth: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
commentActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
iconButton: {
width: 26,
height: 26,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.full,
...(isWeb
? {
transitionProperty: "background-color",
transitionDuration: "120ms",
transitionTimingFunction: "ease-in-out",
}
: {}),
},
iconButtonHovered: {
backgroundColor: theme.colors.surface3,
},
iconButtonDestructiveHovered: {
backgroundColor: theme.colors.surface3,
},
editorBlock: {
minHeight: INLINE_REVIEW_EDITOR_HEIGHT,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
gap: theme.spacing[3],
},
editorInput: {
flex: 1,
minHeight: 0,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
textAlignVertical: "top",
...(isWeb
? {
outlineWidth: 0,
outlineColor: "transparent",
}
: {}),
},
editorInputFocused: {
borderColor: theme.colors.accent,
},
editorActions: {
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
gap: theme.spacing[2],
},
ghostButton: {
height: 28,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
ghostButtonHovered: {
backgroundColor: theme.colors.surface3,
},
ghostButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
saveButton: {
height: 28,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.accent,
},
saveButtonHovered: {
opacity: 0.9,
},
saveButtonDisabled: {
opacity: 0.4,
},
saveButtonText: {
color: theme.colors.accentForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
}));