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
@@ -0,0 +1,280 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CircleDot } from "lucide-react-native";
import type {
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import { AttachmentPill } from "@/components/attachment-pill";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { AgentAttachment } from "@server/shared/messages";
import { useClearReviewDraft } from "@/review/store";
interface WorkspaceAttachmentBindingInput {
normalAttachments: UserComposerAttachment[];
workspaceAttachments: readonly WorkspaceComposerAttachment[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
}
interface RemoveWorkspaceAttachmentInput {
selectedAttachments: readonly ComposerAttachment[];
index: number;
}
interface OpenWorkspaceAttachmentInput {
attachment: ComposerAttachment;
}
interface CompleteSubmitInput {
result: "noop" | "queued" | "submitted" | "failed";
outgoingAttachments: readonly ComposerAttachment[];
}
interface ComposerWorkspaceAttachmentBinding {
selectedAttachments: ComposerAttachment[];
buildOutgoingAttachments: (normalAttachments: UserComposerAttachment[]) => ComposerAttachment[];
removeAttachment: (input: RemoveWorkspaceAttachmentInput) => boolean;
openAttachment: (input: OpenWorkspaceAttachmentInput) => boolean;
clearSentAttachments: (attachments: readonly ComposerAttachment[]) => void;
completeSubmit: (input: CompleteSubmitInput) => void;
resetSuppression: () => void;
}
function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
return JSON.stringify({
type: "review",
cwd: attachment.attachment.cwd,
mode: attachment.attachment.mode,
baseRef: attachment.attachment.baseRef ?? null,
reviewDraftKey: attachment.reviewDraftKey,
comments: attachment.attachment.comments.map((comment) => ({
filePath: comment.filePath,
side: comment.side,
lineNumber: comment.lineNumber,
body: comment.body,
})),
});
}
function isWorkspaceAttachment(
attachment: ComposerAttachment | undefined,
): attachment is WorkspaceComposerAttachment {
return attachment?.kind === "review";
}
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
return attachments.filter(
(attachment): attachment is UserComposerAttachment => attachment.kind !== "review",
);
}
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
return isWorkspaceAttachment(attachment) ? attachment.attachment : null;
}
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
return (
<WorkspaceAttachmentPill
key={`workspace:${args.attachment.attachment.cwd}:${args.attachment.attachment.mode}`}
{...args}
attachment={args.attachment}
/>
);
}
function useWorkspaceAttachmentBinding({
normalAttachments,
workspaceAttachments,
onOpenWorkspaceAttachment,
}: WorkspaceAttachmentBindingInput): ComposerWorkspaceAttachmentBinding {
const clearReviewDraft = useClearReviewDraft();
const [suppressedKeys, setSuppressedKeys] = useState<readonly string[]>([]);
const workspaceAttachmentKeys = useMemo(
() => workspaceAttachments.map(getAttachmentKey),
[workspaceAttachments],
);
const activeWorkspaceAttachments = useMemo(
() =>
workspaceAttachments.filter(
(attachment, index) => !suppressedKeys.includes(workspaceAttachmentKeys[index] ?? ""),
),
[suppressedKeys, workspaceAttachmentKeys, workspaceAttachments],
);
const selectedAttachments = useMemo<ComposerAttachment[]>(
() =>
activeWorkspaceAttachments.length > 0
? [...normalAttachments, ...activeWorkspaceAttachments]
: normalAttachments,
[activeWorkspaceAttachments, normalAttachments],
);
useEffect(() => {
setSuppressedKeys((current) =>
current.filter((suppressedKey) => workspaceAttachmentKeys.includes(suppressedKey)),
);
}, [workspaceAttachmentKeys]);
const buildOutgoingAttachments = useCallback(
(attachments: UserComposerAttachment[]): ComposerAttachment[] =>
activeWorkspaceAttachments.length > 0
? [...attachments, ...activeWorkspaceAttachments]
: attachments,
[activeWorkspaceAttachments],
);
const suppressWorkspaceAttachment = useCallback((attachment: WorkspaceComposerAttachment) => {
const key = getAttachmentKey(attachment);
setSuppressedKeys((current) => (current.includes(key) ? current : [...current, key]));
}, []);
const clearSentAttachments = useCallback(
(attachments: readonly ComposerAttachment[]) => {
for (const attachment of attachments) {
if (isWorkspaceAttachment(attachment)) {
clearReviewDraft({ key: attachment.reviewDraftKey });
}
}
},
[clearReviewDraft],
);
const removeAttachment = useCallback(
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
const selected = current[index];
if (isWorkspaceAttachment(selected)) {
suppressWorkspaceAttachment(selected);
return true;
}
return false;
},
[suppressWorkspaceAttachment],
);
const openAttachment = useCallback(
({ attachment }: OpenWorkspaceAttachmentInput) => {
if (!isWorkspaceAttachment(attachment)) {
return false;
}
onOpenWorkspaceAttachment?.(attachment);
return true;
},
[onOpenWorkspaceAttachment],
);
const resetSuppression = useCallback(() => {
setSuppressedKeys([]);
}, []);
const completeSubmit = useCallback(
({ result, outgoingAttachments }: CompleteSubmitInput) => {
if (result === "submitted") {
clearSentAttachments(outgoingAttachments);
}
if (result === "queued" || result === "submitted") {
resetSuppression();
}
},
[clearSentAttachments, resetSuppression],
);
return {
selectedAttachments,
buildOutgoingAttachments,
removeAttachment,
openAttachment,
clearSentAttachments,
completeSubmit,
resetSuppression,
};
}
interface RenderWorkspaceAttachmentPillArgs {
attachment: WorkspaceComposerAttachment;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
}
interface WorkspaceAttachmentPillProps extends Omit<
RenderWorkspaceAttachmentPillArgs,
"attachment"
> {
attachment: WorkspaceComposerAttachment;
}
function WorkspaceAttachmentPill({
attachment,
index,
disabled,
onOpen,
onRemove,
}: WorkspaceAttachmentPillProps) {
const label =
attachment.commentCount === 1
? "Review · 1 comment"
: `Review · ${attachment.commentCount} comments`;
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
const handleRemove = useCallback(() => {
onRemove(index);
}, [onRemove, index]);
return (
<AttachmentPill
testID="composer-review-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel="Open review attachment"
removeAccessibilityLabel="Remove review attachment"
disabled={disabled}
>
<View style={styles.pillBody}>
<View style={styles.pillIcon}>
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
</View>
<Text style={styles.pillText} numberOfLines={1}>
{label}
</Text>
</View>
</AttachmentPill>
);
}
export const composerWorkspaceAttachment = {
is: isWorkspaceAttachment,
renderPill,
toSubmitAttachment,
userAttachmentsOnly,
useBinding: useWorkspaceAttachmentBinding,
};
const styles = StyleSheet.create((theme: Theme) => ({
pillBody: {
minHeight: 48,
maxWidth: 260,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
pillIcon: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
pillText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
})) as unknown as Record<string, object>;
const ThemedCircleDot = withUnistyles(CircleDot);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
+12 -2
View File
@@ -1,4 +1,4 @@
import type { GitHubSearchItem } from "@server/shared/messages";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
@@ -20,7 +20,17 @@ export interface AttachmentMetadata {
export type ComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "github_pr"; item: GitHubSearchItem };
| { kind: "github_pr"; item: GitHubSearchItem }
| {
kind: "review";
attachment: Extract<AgentAttachment, { type: "review" }>;
reviewDraftKey: string;
commentCount: number;
};
export type UserComposerAttachment = Exclude<ComposerAttachment, { kind: "review" }>;
export type WorkspaceComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
export type AttachmentDataSource =
| { kind: "base64"; base64: string }
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceComposerAttachment } from "./types";
import {
buildWorkspaceAttachmentScopeKey,
resetWorkspaceAttachmentsStore,
useWorkspaceAttachmentsStore,
} from "./workspace-attachments-store";
function reviewAttachment(body: string): WorkspaceComposerAttachment {
return {
kind: "review",
reviewDraftKey: `review:${body}`,
commentCount: 1,
attachment: {
type: "review",
mimeType: "application/paseo-review",
cwd: "/repo",
mode: "uncommitted",
baseRef: null,
comments: [
{
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body,
context: {
hunkHeader: "@@ -40,1 +40,1 @@",
targetLine: {
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
lines: [
{
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
],
},
},
],
},
};
}
describe("workspace attachments store", () => {
it("scopes workspace attachments by server and workspace before cwd fallback", () => {
expect(
buildWorkspaceAttachmentScopeKey({
serverId: " local ",
workspaceId: " workspace-1 ",
cwd: "/repo",
}),
).toBe("workspace-attachments:server=local:workspace=workspace-1");
expect(
buildWorkspaceAttachmentScopeKey({
serverId: "local",
workspaceId: null,
cwd: "/repo/",
}),
).toBe("workspace-attachments:server=local:cwd=%2Frepo");
});
it("publishes and clears attachments for a workspace scope", () => {
resetWorkspaceAttachmentsStore();
const scopeKey = buildWorkspaceAttachmentScopeKey({
serverId: "local",
workspaceId: "workspace-1",
cwd: "/repo",
});
const attachment = reviewAttachment("Please simplify this.");
useWorkspaceAttachmentsStore
.getState()
.setWorkspaceAttachments({ scopeKey, attachments: [attachment] });
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toEqual([
attachment,
]);
useWorkspaceAttachmentsStore.getState().clearWorkspaceAttachments({ scopeKey });
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toBeUndefined();
});
});
@@ -0,0 +1,115 @@
import { useMemo } from "react";
import { create } from "zustand";
import type { WorkspaceComposerAttachment } from "./types";
const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = [];
export interface WorkspaceAttachmentScopeInput {
serverId: string;
workspaceId?: string | null;
cwd: string;
}
interface WorkspaceAttachmentsStoreState {
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
}
interface WorkspaceAttachmentsStoreActions {
setWorkspaceAttachments: (input: {
scopeKey: string;
attachments: readonly WorkspaceComposerAttachment[];
}) => void;
clearWorkspaceAttachments: (input: { scopeKey: string }) => void;
}
type WorkspaceAttachmentsStore = WorkspaceAttachmentsStoreState & WorkspaceAttachmentsStoreActions;
function encodeScopePart(value: string): string {
return encodeURIComponent(value.trim());
}
function normalizeCwd(cwd: string): string {
const trimmed = cwd.trim();
if (trimmed === "/") {
return trimmed;
}
return trimmed.replace(/\/+$/, "");
}
export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
const workspaceId = input.workspaceId?.trim();
const workspacePart = workspaceId
? `workspace=${encodeScopePart(workspaceId)}`
: `cwd=${encodeScopePart(normalizeCwd(input.cwd))}`;
return ["workspace-attachments", `server=${encodeScopePart(input.serverId)}`, workspacePart].join(
":",
);
}
function areWorkspaceAttachmentsEqual(
left: readonly WorkspaceComposerAttachment[],
right: readonly WorkspaceComposerAttachment[],
): boolean {
if (left === right) {
return true;
}
if (left.length !== right.length) {
return false;
}
return left.every((attachment, index) => attachment === right[index]);
}
export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()((set) => ({
attachmentsByScope: {},
setWorkspaceAttachments: ({ scopeKey, attachments }) => {
set((state) => {
const current = state.attachmentsByScope[scopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS;
if (areWorkspaceAttachmentsEqual(current, attachments)) {
return state;
}
if (attachments.length === 0) {
if (!state.attachmentsByScope[scopeKey]) {
return state;
}
const next = { ...state.attachmentsByScope };
delete next[scopeKey];
return { attachmentsByScope: next };
}
return {
attachmentsByScope: {
...state.attachmentsByScope,
[scopeKey]: attachments,
},
};
});
},
clearWorkspaceAttachments: ({ scopeKey }) => {
set((state) => {
if (!state.attachmentsByScope[scopeKey]) {
return state;
}
const next = { ...state.attachmentsByScope };
delete next[scopeKey];
return { attachmentsByScope: next };
});
},
}));
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
const { serverId, workspaceId, cwd } = input;
return useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
[serverId, workspaceId, cwd],
);
}
export function useWorkspaceAttachments(scopeKey: string): readonly WorkspaceComposerAttachment[] {
return useWorkspaceAttachmentsStore(
(state) => state.attachmentsByScope[scopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS,
);
}
export function resetWorkspaceAttachmentsStore(): void {
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} });
}