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:
@@ -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,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user