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
+110 -35
View File
@@ -70,7 +70,13 @@ import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
import type { GitHubSearchItem } from "@server/shared/messages";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
@@ -86,8 +92,8 @@ interface QueuedMessage {
}
type AttachmentListUpdater =
| ComposerAttachment[]
| ((prev: ComposerAttachment[]) => ComposerAttachment[]);
| UserComposerAttachment[]
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
function noop() {}
@@ -145,7 +151,7 @@ async function pickAndPersistImages(
);
}
function removeAttachmentAtIndex(prev: ComposerAttachment[], index: number): ComposerAttachment[] {
function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], index: number): T[] {
const removed = prev[index];
if (removed?.kind === "image") {
void deleteAttachments([removed.metadata]);
@@ -242,26 +248,28 @@ function findGithubItemByOption(
}
function isAttachmentSelectedForGithubItem(
attachments: readonly ComposerAttachment[],
current: readonly ComposerAttachment[],
item: GitHubSearchItem,
): boolean {
return attachments.some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
return composerWorkspaceAttachment
.userAttachmentsOnly(current)
.some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
}
function buildGithubAttachment(item: GitHubSearchItem): ComposerAttachment {
function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
}
function toggleGithubAttachment(
current: ComposerAttachment[],
current: UserComposerAttachment[],
item: GitHubSearchItem,
): ComposerAttachment[] {
const matches = (attachment: ComposerAttachment) =>
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
@@ -361,6 +369,15 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
/>
);
}
if (composerWorkspaceAttachment.is(attachment)) {
return composerWorkspaceAttachment.renderPill({
attachment,
index,
disabled,
onOpen,
onRemove,
});
}
return (
<GithubAttachmentPill
key={`${attachment.item.kind}:${attachment.item.number}`}
@@ -446,6 +463,7 @@ async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Pro
text,
timestamp: new Date(),
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
appendUserMessageToStream({ ...args, userMessage });
const imagesData = await encodeImages(wirePayload.images);
@@ -459,11 +477,16 @@ async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Pro
function openComposerAttachment(
attachment: ComposerAttachment,
setLightboxMetadata: (metadata: AttachmentMetadata) => void,
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean,
): void {
if (attachment.kind === "image") {
setLightboxMetadata(attachment.metadata);
return;
}
if (composerWorkspaceAttachment.is(attachment)) {
openWorkspaceAttachment({ attachment });
return;
}
void openExternalUrl(attachment.item.url);
}
@@ -596,10 +619,18 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
{item.text}
</Text>
<View style={styles.queueActions}>
<Pressable onPress={handleEdit} style={styles.queueActionButton}>
<Pressable
onPress={handleEdit}
style={styles.queueActionButton}
accessibilityLabel="Edit queued message"
>
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
</Pressable>
<Pressable onPress={handleSendNow} style={QUEUE_SEND_BUTTON_STYLE}>
<Pressable
onPress={handleSendNow}
style={QUEUE_SEND_BUTTON_STYLE}
accessibilityLabel="Send queued message now"
>
<ArrowUp size={ICON_SIZE.sm} color="white" />
</Pressable>
</View>
@@ -652,7 +683,7 @@ function ImageAttachmentPill({
}
interface GithubAttachmentPillProps {
attachment: Exclude<ComposerAttachment, { kind: "image" }>;
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
@@ -759,7 +790,9 @@ interface ComposerProps {
blurOnSubmit?: boolean;
value: string;
onChangeText: (text: string) => void;
attachments: ComposerAttachment[];
attachments: UserComposerAttachment[];
workspaceAttachments?: readonly WorkspaceComposerAttachment[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
onChangeAttachments: (updater: AttachmentListUpdater) => void;
cwd: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
@@ -957,6 +990,8 @@ export function Composer({
value,
onChangeText,
attachments,
workspaceAttachments = [],
onOpenWorkspaceAttachment,
onChangeAttachments,
cwd,
clearDraft,
@@ -1005,7 +1040,19 @@ export function Composer({
const messagePlaceholder = resolveMessagePlaceholder(isDesktopWebBreakpoint);
const userInput = value;
const setUserInput = onChangeText;
const selectedAttachments = attachments;
const {
selectedAttachments,
buildOutgoingAttachments,
removeAttachment,
openAttachment,
clearSentAttachments,
completeSubmit,
resetSuppression,
} = composerWorkspaceAttachment.useBinding({
normalAttachments: attachments,
workspaceAttachments,
onOpenWorkspaceAttachment,
});
const setSelectedAttachments = onChangeAttachments;
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
@@ -1162,8 +1209,18 @@ export function Composer({
setUserInput("");
setSelectedAttachments([]);
resetSuppression();
clearSentAttachments(queuedAttachments);
},
[agentId, serverId, setQueuedMessages, setSelectedAttachments, setUserInput],
[
agentId,
clearSentAttachments,
resetSuppression,
serverId,
setQueuedMessages,
setSelectedAttachments,
setUserInput,
],
);
const sendMessageWithContent = useCallback(
@@ -1172,7 +1229,7 @@ export function Composer({
outgoingAttachments: ComposerAttachment[],
forceSend?: boolean,
) => {
await submitAgentInput({
const result = await submitAgentInput({
message: outgoingMessage,
attachments: outgoingAttachments,
hasExternalContent,
@@ -1192,7 +1249,7 @@ export function Composer({
clearDraft,
setUserInput,
setAttachments: (nextAttachments) => {
setSelectedAttachments(nextAttachments);
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(nextAttachments));
},
setSendError,
setIsProcessing,
@@ -1200,10 +1257,15 @@ export function Composer({
console.error("[AgentInput] Failed to send message:", error);
},
});
completeSubmit({
result,
outgoingAttachments,
});
},
[
allowEmptySubmit,
clearDraft,
completeSubmit,
hasExternalContent,
isAgentRunning,
queueMessage,
@@ -1219,9 +1281,13 @@ export function Composer({
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
void sendMessageWithContent(
payload.text,
buildOutgoingAttachments(attachments),
payload.forceSend,
);
},
[blurOnSubmit, sendMessageWithContent],
[attachments, blurOnSubmit, buildOutgoingAttachments, sendMessageWithContent],
);
const handlePickImage = useCallback(async () => {
@@ -1232,14 +1298,24 @@ export function Composer({
const handleRemoveAttachment = useCallback(
(index: number) => {
const didRemoveWorkspaceAttachment = removeAttachment({
selectedAttachments,
index,
});
if (didRemoveWorkspaceAttachment) {
return;
}
setSelectedAttachments((prev) => removeAttachmentAtIndex(prev, index));
},
[setSelectedAttachments],
[removeAttachment, selectedAttachments, setSelectedAttachments],
);
const handleOpenAttachment = useCallback((attachment: ComposerAttachment) => {
openComposerAttachment(attachment, setLightboxMetadata);
}, []);
const handleOpenAttachment = useCallback(
(attachment: ComposerAttachment) => {
openComposerAttachment(attachment, setLightboxMetadata, openAttachment);
},
[openAttachment],
);
useEffect(() => {
if (!isAgentRunning || !isConnected) {
@@ -1327,7 +1403,7 @@ export function Composer({
updateQueue((current) => current.filter((q) => q.id !== id));
setUserInput(item.text);
setSelectedAttachments(item.attachments);
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(item.attachments));
},
[queuedMessages, setSelectedAttachments, setUserInput, updateQueue],
);
@@ -1353,9 +1429,9 @@ export function Composer({
const handleQueue = useCallback(
(payload: MessagePayload) => {
queueMessage(payload.text, payload.attachments);
queueMessage(payload.text, buildOutgoingAttachments(attachments));
},
[queueMessage],
[attachments, buildOutgoingAttachments, queueMessage],
);
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
@@ -1408,7 +1484,6 @@ export function Composer({
],
);
const isVoiceSwitchingValue = voice?.isVoiceSwitching ?? false;
const rightContent = useMemo(
() => (
<ComposerRightControlsSlot
@@ -1420,7 +1495,7 @@ export function Composer({
buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected}
isVoiceSwitching={isVoiceSwitchingValue}
isVoiceSwitching={isVoiceSwitching}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
voiceToggleKeys={voiceToggleKeys}
cancelButton={cancelButton}
@@ -1436,7 +1511,7 @@ export function Composer({
isConnected,
isProcessing,
isVoiceModeForAgent,
isVoiceSwitchingValue,
isVoiceSwitching,
realtimeVoiceButtonStyle,
voiceToggleKeys,
],