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
@@ -382,6 +382,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<UserMessage
message={item.text}
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
@@ -1,4 +1,5 @@
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import type { AgentAttachment } from "@server/shared/messages";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
@@ -17,6 +18,14 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
continue;
}
if (composerWorkspaceAttachment.is(attachment)) {
const workspaceAttachment = composerWorkspaceAttachment.toSubmitAttachment(attachment);
if (workspaceAttachment) {
reviewAttachments.push(workspaceAttachment);
}
continue;
}
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
if (reviewAttachment) {
reviewAttachments.push(reviewAttachment);
+320 -8
View File
@@ -4,10 +4,15 @@ import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@server/shared/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
} from "@/attachments/types";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import { Composer } from "./composer";
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
import { addReviewDraftComment, getReviewDraftComments, resetReviewDraftStore } from "@/review";
const keyboardActionHandlerMock = vi.hoisted(() => vi.fn());
@@ -27,6 +32,7 @@ const {
setAgentStreamHeadMock,
setQueuedMessagesMock,
agentDirectoryStatusMock,
appSendBehavior,
} = vi.hoisted(() => {
const hoistedTheme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
@@ -35,6 +41,7 @@ const {
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
fontWeight: { normal: "400", medium: "500" },
lineHeight: { diff: 18 },
shadow: { md: {} },
colors: {
surface0: "#000",
@@ -99,7 +106,12 @@ const {
cancelAgent: vi.fn(async () => {}),
};
const hoistedSetQueuedMessagesMock = vi.fn();
const hoistedSetQueuedMessagesMock = vi.fn(
(serverId: string, updater: (prev: Map<string, unknown[]>) => Map<string, unknown[]>) => {
const session = hoistedMockSessionState.sessions[serverId];
session.queuedMessages = updater(session.queuedMessages);
},
);
const hoistedMockSessionState: {
sessions: Record<
string,
@@ -161,6 +173,7 @@ const {
hoistedMockSessionState.setAgentStreamTail = hoistedSetAgentStreamTailMock;
hoistedMockSessionState.setAgentStreamHead = hoistedSetAgentStreamHeadMock;
const hoistedAgentDirectoryStatusMock = vi.fn(() => "ready");
const hoistedAppSendBehavior = { current: "interrupt" as "interrupt" | "queue" };
return {
theme: hoistedTheme,
@@ -178,6 +191,7 @@ const {
setAgentStreamHeadMock: hoistedSetAgentStreamHeadMock,
setQueuedMessagesMock: hoistedSetQueuedMessagesMock,
agentDirectoryStatusMock: hoistedAgentDirectoryStatusMock,
appSendBehavior: hoistedAppSendBehavior,
};
});
@@ -297,7 +311,7 @@ vi.mock("@/utils/open-external-url", () => ({
}));
vi.mock("@/hooks/use-settings", () => ({
useAppSettings: () => ({ settings: { sendBehavior: "interrupt" } }),
useAppSettings: () => ({ settings: { sendBehavior: appSendBehavior.current } }),
}));
vi.mock("@/hooks/use-agent-autocomplete", () => ({
@@ -556,8 +570,72 @@ let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
let latestAttachments: ComposerAttachment[] = [];
type ReviewComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
type ReviewAttachment = Extract<AgentAttachment, { type: "review" }>;
function reviewAttachment(body: string): ReviewAttachment {
return {
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,2 +40,2 @@",
targetLine: {
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
lines: [
{
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
],
},
},
],
};
}
function reviewComposerAttachment(body: string): ReviewComposerAttachment {
return {
kind: "review",
reviewDraftKey: `review:${body}`,
commentCount: 1,
attachment: reviewAttachment(body),
};
}
function seedReviewDraft(key: string) {
addReviewDraftComment({
key,
comment: {
id: `${key}:comment`,
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body: "Please simplify this.",
createdAt: "2026-04-21T00:00:00.000Z",
updatedAt: "2026-04-21T00:00:00.000Z",
},
});
}
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
@@ -570,6 +648,7 @@ beforeEach(() => {
attachEvent: vi.fn(),
detachEvent: vi.fn(),
});
vi.stubGlobal("localStorage", dom.window.localStorage);
container = document.createElement("div");
document.body.appendChild(container);
@@ -590,6 +669,7 @@ beforeEach(() => {
keyboardActionHandlerMock.mockClear();
agentDirectoryStatusMock.mockReset();
agentDirectoryStatusMock.mockReturnValue("ready");
appSendBehavior.current = "interrupt";
mockSessionState.sessions.server.serverInfo = {
serverId: "server",
hostname: "test",
@@ -607,6 +687,10 @@ beforeEach(() => {
mockSessionState.sessions.server.agentStreamHead = new Map();
mockSessionState.sessions.server.agentStreamTail = new Map();
mockSessionState.sessions.server.queuedMessages = new Map();
mockSessionState.sessions.server.agents = new Map([
["agent", { status: "idle", lastUsage: null }],
]);
resetReviewDraftStore();
});
afterEach(() => {
@@ -634,20 +718,30 @@ function imageAttachment(id: string): AttachmentMetadata {
function ComposerHarness({
initialText = "",
initialAttachments = [],
workspaceAttachment = null,
isSubmitLoading = false,
submitBehavior,
}: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
initialAttachments?: UserComposerAttachment[];
workspaceAttachment?: ReviewComposerAttachment | null;
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
}) {
const [text, setText] = useState(initialText);
const [attachments, setAttachments] = useState(initialAttachments);
const workspaceAttachments = React.useMemo(
() => (workspaceAttachment ? [workspaceAttachment] : []),
[workspaceAttachment],
);
latestAttachments = attachments;
const handleChangeAttachments = React.useCallback(
(updater: ComposerAttachment[] | ((current: ComposerAttachment[]) => ComposerAttachment[])) => {
(
updater:
| UserComposerAttachment[]
| ((current: UserComposerAttachment[]) => UserComposerAttachment[]),
) => {
setAttachments((current) => {
const next = typeof updater === "function" ? updater(current) : updater;
latestAttachments = next;
@@ -666,6 +760,7 @@ function ComposerHarness({
value={text}
onChangeText={setText}
attachments={attachments}
workspaceAttachments={workspaceAttachments}
onChangeAttachments={handleChangeAttachments}
isSubmitLoading={isSubmitLoading}
submitBehavior={submitBehavior}
@@ -679,7 +774,8 @@ function ComposerHarness({
function renderComposer(
input: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
initialAttachments?: UserComposerAttachment[];
workspaceAttachment?: ReviewComposerAttachment | null;
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
} = {},
@@ -903,6 +999,222 @@ describe("Composer attachments", () => {
);
});
it("serializes workspace review attachments through the structured attachment path", async () => {
const review = reviewComposerAttachment("Please simplify this.");
expect(splitComposerAttachmentsForSubmit([review])).toEqual({
images: [],
attachments: [review.attachment],
});
});
it("renders and submits a workspace review attachment pill", async () => {
const review = reviewComposerAttachment("Please simplify this.");
renderComposer({
initialText: "review this",
workspaceAttachment: review,
});
expect(queryByTestId("composer-review-attachment-pill")?.textContent).toContain("Review");
expect(queryByTestId("composer-review-attachment-pill")?.textContent).toContain("1 comment");
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"review this",
expect.objectContaining({
attachments: [review.attachment],
}),
);
});
it("clears the included workspace review draft after a successful submit", async () => {
const review = reviewComposerAttachment("Clear submitted review draft.");
seedReviewDraft(review.reviewDraftKey);
renderComposer({
initialText: "review this",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(getReviewDraftComments(review.reviewDraftKey)).toBeUndefined();
});
it("restores only normal attachments when a submit with a workspace review fails", async () => {
const image = imageAttachment("img-failure");
const review = reviewComposerAttachment("This should be sent but not persisted.");
seedReviewDraft(review.reviewDraftKey);
mockClient.sendAgentMessage.mockRejectedValueOnce(new Error("network down"));
renderComposer({
initialText: "review this",
initialAttachments: [{ kind: "image", metadata: image }],
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"review this",
expect.objectContaining({
attachments: [review.attachment],
}),
);
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
expect(getReviewDraftComments(review.reviewDraftKey)).toHaveLength(1);
});
it("clears workspace review suppression after a send lifecycle", async () => {
const review = reviewComposerAttachment("Keep this available for the next message.");
renderComposer({
initialText: "send without review",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Remove review attachment"]')!);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"send without review",
expect.objectContaining({
attachments: [],
}),
);
expect(queryByTestId("composer-review-attachment-pill")).not.toBeNull();
});
it("keeps workspace review suppressed after a failed send", async () => {
const review = reviewComposerAttachment("Do not send this on retry.");
mockClient.sendAgentMessage.mockRejectedValueOnce(new Error("network down"));
renderComposer({
initialText: "retry without review",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Remove review attachment"]')!);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenNthCalledWith(
1,
"agent",
"retry without review",
expect.objectContaining({
attachments: [],
}),
);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenNthCalledWith(
2,
"agent",
"retry without review",
expect.objectContaining({
attachments: [],
}),
);
});
it("captures workspace review attachments at queue time", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const initialReview = reviewComposerAttachment("Initial queued review.");
const editedReview = reviewComposerAttachment("Edited after queue.");
renderComposer({
initialText: "queue this",
workspaceAttachment: initialReview,
});
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
renderComposer({
initialText: "",
workspaceAttachment: editedReview,
});
const queued = mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{
attachments: ComposerAttachment[];
}>;
expect(queued[0]?.attachments).toEqual([initialReview]);
});
it("clears the included workspace review draft after queueing", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const review = reviewComposerAttachment("Clear queued review draft.");
seedReviewDraft(review.reviewDraftKey);
renderComposer({
initialText: "queue this",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
expect(getReviewDraftComments(review.reviewDraftKey)).toBeUndefined();
});
it("clears workspace review suppression after queueing a message", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const review = reviewComposerAttachment("Queue without this review first.");
renderComposer({
initialText: "queue without review",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Remove review attachment"]')!);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
const queued = mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{
attachments: ComposerAttachment[];
}>;
expect(queued[0]?.attachments).toEqual([]);
expect(queryByTestId("composer-review-attachment-pill")).not.toBeNull();
});
it("does not restore queued workspace review attachments into live draft attachments when editing", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const image = imageAttachment("img-queued-edit");
const review = reviewComposerAttachment("Queued snapshot.");
renderComposer({
initialText: "queue this",
initialAttachments: [{ kind: "image", metadata: image }],
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
expect(
(
mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{
attachments: ComposerAttachment[];
}>
)[0]?.attachments,
).toEqual([{ kind: "image", metadata: image }, review]);
click(document.querySelector('[aria-label="Edit queued message"]')!);
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
});
it("submits empty wire arrays when there are no composer attachments", async () => {
renderComposer({ initialText: "plain message" });
+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,
],
+381 -87
View File
@@ -23,7 +23,7 @@ import {
type FlatListProps,
type StyleProp,
type ViewStyle,
TextStyle,
type TextStyle,
} from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -70,6 +70,7 @@ import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
import {
buildSplitDiffRows,
buildUnifiedDiffLines,
type ReviewableDiffTarget,
type SplitDiffDisplayLine,
type SplitDiffRow,
} from "@/utils/diff-layout";
@@ -97,6 +98,26 @@ import {
hasVisibleDiffTokens,
} from "@/utils/diff-rendering";
import { isWeb, isNative } from "@/constants/platform";
import {
buildWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import {
buildReviewDraftScopeKey,
buildReviewDraftKey,
useActiveReviewDraftMode,
useReviewAttachmentSnapshot,
useSetActiveReviewDraftMode,
type ReviewDraftComment,
type ReviewDraftMode,
getInlineReviewThreadState,
getSplitInlineReviewThreadState,
InlineReviewGutterCell,
InlineReviewThread,
isInlineReviewEditorForTarget,
useInlineReviewController,
type InlineReviewActions,
} from "@/review";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -205,6 +226,10 @@ interface DiffFileSectionProps {
testID?: string;
}
const EMPTY_COMMENTS: readonly ReviewDraftComment[] = [];
function noopStartComment(): void {}
function lineTypeBackground(type: DiffLine["type"] | undefined | null) {
if (!type) return styles.emptySplitCell;
if (type === "add") return styles.addLineContainer;
@@ -217,14 +242,20 @@ function DiffGutterCell({
lineNumber,
type,
gutterWidth,
reviewTarget,
reviewActions,
style,
}: {
lineNumber: number | null;
type: DiffLine["type"] | undefined | null;
gutterWidth: number;
reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions;
style?: StyleProp<ViewStyle>;
}) {
const containerStyle = useMemo(
() => [styles.gutterCell, lineTypeBackground(type), { width: gutterWidth }],
[type, gutterWidth],
() => [styles.gutterCell, lineTypeBackground(type), { width: gutterWidth }, style],
[type, gutterWidth, style],
);
const textStyle = useMemo(
() => [
@@ -234,12 +265,29 @@ function DiffGutterCell({
],
[type],
);
const comments = useMemo(
() =>
reviewTarget
? (reviewActions?.commentsByTarget.get(reviewTarget.key) ?? EMPTY_COMMENTS)
: EMPTY_COMMENTS,
[reviewTarget, reviewActions?.commentsByTarget],
);
const isEditorOpen = isInlineReviewEditorForTarget(reviewActions?.editor ?? null, reviewTarget);
const onStartComment = reviewActions?.onStartComment ?? noopStartComment;
return (
<View style={containerStyle}>
<InlineReviewGutterCell
reviewTarget={reviewTarget}
comments={comments}
isEditorOpen={isEditorOpen}
showPersistentAction={reviewActions?.showPersistentAction ?? false}
onStartComment={onStartComment}
style={containerStyle}
>
<Text numberOfLines={1} style={textStyle}>
{formatDiffGutterText(lineNumber)}
</Text>
</View>
</InlineReviewGutterCell>
);
}
@@ -314,11 +362,15 @@ function DiffLineView({
lineNumber,
gutterWidth,
wrapLines,
reviewTarget,
reviewActions,
}: {
line: DiffLine;
lineNumber: number | null;
gutterWidth: number;
wrapLines: boolean;
reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions;
}) {
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
@@ -326,18 +378,6 @@ function DiffLineView({
() => [styles.diffLineContainer, lineTypeBackground(line.type)],
[line.type],
);
const gutterStyle = useMemo(
() => [styles.lineNumberGutter, { width: gutterWidth }],
[gutterWidth],
);
const gutterTextStyle = useMemo(
() => [
styles.lineNumberText,
line.type === "add" && styles.addLineNumberText,
line.type === "remove" && styles.removeLineNumberText,
],
[line.type],
);
const textStyle = useMemo(
() => [
styles.diffLineText,
@@ -352,11 +392,14 @@ function DiffLineView({
return (
<View style={containerStyle}>
<View style={gutterStyle}>
<Text numberOfLines={1} style={gutterTextStyle}>
{formatDiffGutterText(lineNumber)}
</Text>
</View>
<DiffGutterCell
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
style={styles.lineNumberGutter}
/>
{line.type !== "header" && visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
) : (
@@ -370,10 +413,12 @@ function SplitDiffLine({
line,
gutterWidth,
wrapLines,
reviewActions,
}: {
line: SplitDiffDisplayLine | null;
gutterWidth: number;
wrapLines: boolean;
reviewActions?: InlineReviewActions;
}) {
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
@@ -381,18 +426,6 @@ function SplitDiffLine({
() => [styles.diffLineContainer, lineTypeBackground(line?.type)],
[line?.type],
);
const gutterStyle = useMemo(
() => [styles.lineNumberGutter, { width: gutterWidth }],
[gutterWidth],
);
const gutterTextStyle = useMemo(
() => [
styles.lineNumberText,
line?.type === "add" && styles.addLineNumberText,
line?.type === "remove" && styles.removeLineNumberText,
],
[line?.type],
);
const textStyle = useMemo(
() => [
styles.diffLineText,
@@ -407,11 +440,14 @@ function SplitDiffLine({
return (
<View style={containerStyle}>
<View style={gutterStyle}>
<Text numberOfLines={1} style={gutterTextStyle}>
{formatDiffGutterText(line?.lineNumber ?? null)}
</Text>
</View>
<DiffGutterCell
lineNumber={line?.lineNumber ?? null}
type={line?.type}
gutterWidth={gutterWidth}
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
style={styles.lineNumberGutter}
/>
{visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
) : (
@@ -421,17 +457,119 @@ function SplitDiffLine({
);
}
function InlineReviewThreadContent({
reviewTarget,
reviewActions,
reservedHeight,
viewportWidth,
pinToViewport,
}: {
reviewTarget: ReviewableDiffTarget | null | undefined;
reviewActions?: InlineReviewActions;
reservedHeight?: number;
viewportWidth?: number;
pinToViewport?: boolean;
}) {
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const placeholderStyle = useMemo<ViewStyle>(() => ({ minHeight: height }), [height]);
if (height === 0) {
return null;
}
if (!reviewTarget || !reviewActions || !threadState) {
return <View style={placeholderStyle} />;
}
return (
<InlineReviewThread
reviewTarget={reviewTarget}
reviewActions={reviewActions}
height={height}
viewportWidth={viewportWidth}
pinToViewport={pinToViewport}
testID={`review-thread-${reviewTarget.key}`}
/>
);
}
function InlineReviewGutterSpacer({
reviewTarget,
reviewActions,
gutterWidth,
reservedHeight,
style,
}: {
reviewTarget: ReviewableDiffTarget | null | undefined;
reviewActions?: InlineReviewActions;
gutterWidth: number;
reservedHeight?: number;
style?: StyleProp<ViewStyle>;
}) {
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const spacerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.inlineReviewGutterSpacer, { width: gutterWidth, minHeight: height }, style],
[gutterWidth, height, style],
);
if (height === 0) {
return null;
}
return <View style={spacerStyle} />;
}
function InlineReviewRow({
reviewTarget,
reviewActions,
gutterWidth,
reservedHeight,
}: {
reviewTarget: ReviewableDiffTarget | null | undefined;
reviewActions?: InlineReviewActions;
gutterWidth: number;
reservedHeight?: number;
}) {
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const gutterSpacerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.inlineReviewGutterSpacer, { width: gutterWidth }],
[gutterWidth],
);
const placeholderStyle = useMemo<ViewStyle>(() => ({ minHeight: height }), [height]);
if (height === 0) {
return null;
}
return (
<View style={styles.inlineReviewRow}>
<View style={gutterSpacerStyle} />
{reviewTarget && reviewActions && threadState ? (
<InlineReviewThread
reviewTarget={reviewTarget}
reviewActions={reviewActions}
height={height}
testID={`review-thread-${reviewTarget.key}`}
/>
) : (
<View style={placeholderStyle} />
)}
</View>
);
}
function SplitDiffColumn({
rows,
side,
gutterWidth,
wrapLines,
reviewActions,
showDivider = false,
}: {
rows: SplitDiffRow[];
side: "left" | "right";
gutterWidth: number;
wrapLines: boolean;
reviewActions?: InlineReviewActions;
showDivider?: boolean;
}) {
const [scrollWidth, setScrollWidth] = useState(0);
@@ -463,13 +601,27 @@ function SplitDiffColumn({
</View>
);
}
const line = side === "left" ? row.left : row.right;
const reviewRowState = getSplitInlineReviewThreadState({
left: row.left?.reviewTarget,
right: row.right?.reviewTarget,
reviewActions,
});
return (
<SplitDiffLine
key={key}
line={side === "left" ? row.left : row.right}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
/>
<View key={key}>
<SplitDiffLine
line={line}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
reviewActions={reviewActions}
/>
<InlineReviewRow
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
reservedHeight={reviewRowState?.height}
/>
</View>
);
})}
</View>
@@ -487,13 +639,27 @@ function SplitDiffColumn({
);
}
const line = side === "left" ? row.left : row.right;
const reviewRowState = getSplitInlineReviewThreadState({
left: row.left?.reviewTarget,
right: row.right?.reviewTarget,
reviewActions,
});
return (
<DiffGutterCell
key={key}
lineNumber={line?.lineNumber ?? null}
type={line?.type}
gutterWidth={gutterWidth}
/>
<View key={key}>
<DiffGutterCell
lineNumber={line?.lineNumber ?? null}
type={line?.type}
gutterWidth={gutterWidth}
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
/>
<InlineReviewGutterSpacer
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
reservedHeight={reviewRowState?.height}
/>
</View>
);
})}
</View>
@@ -512,12 +678,23 @@ function SplitDiffColumn({
</View>
);
}
const line = side === "left" ? row.left : row.right;
const reviewRowState = getSplitInlineReviewThreadState({
left: row.left?.reviewTarget,
right: row.right?.reviewTarget,
reviewActions,
});
return (
<SplitTextLine
key={key}
line={side === "left" ? row.left : row.right}
wrapLines={false}
/>
<View key={key}>
<SplitTextLine line={line} wrapLines={false} />
<InlineReviewThreadContent
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
reservedHeight={reviewRowState?.height}
viewportWidth={scrollWidth}
pinToViewport
/>
</View>
);
})}
</View>
@@ -620,12 +797,14 @@ function DiffFileBody({
file,
layout,
wrapLines,
reviewActions,
onBodyHeightChange,
testID,
}: {
file: ParsedDiffFile;
layout: "unified" | "split";
wrapLines: boolean;
reviewActions?: InlineReviewActions;
onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void;
testID?: string;
}) {
@@ -679,12 +858,14 @@ function DiffFileBody({
side="left"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
reviewActions={reviewActions}
/>
<SplitDiffColumn
rows={rows}
side="right"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
reviewActions={reviewActions}
showDivider
/>
</View>
@@ -697,30 +878,48 @@ function DiffFileBody({
return (
<View style={styles.diffContent}>
<View style={styles.linesContainer}>
{computedLines.map(({ line, lineNumber, key }) => (
<DiffLineView
key={key}
line={line}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
/>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
<View key={key}>
<DiffLineView
line={line}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
/>
<InlineReviewRow
reviewTarget={reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
/>
</View>
))}
</View>
</View>
);
}
const textViewportWidth =
scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth);
return (
<View style={DIFF_CONTENT_ROW_STYLE}>
<View style={styles.gutterColumn}>
{computedLines.map(({ line, lineNumber, key }) => (
<DiffGutterCell
key={key}
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
/>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
<View key={key}>
<DiffGutterCell
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
/>
<InlineReviewGutterSpacer
reviewTarget={reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
/>
</View>
))}
</View>
<DiffScroll
@@ -730,8 +929,16 @@ function DiffFileBody({
contentContainerStyle={styles.diffContentInner}
>
<View style={linesContainerRowStyle}>
{computedLines.map(({ line, key }) => (
<DiffTextLine key={key} line={line} wrapLines={false} />
{computedLines.map(({ line, key, reviewTarget }) => (
<View key={key}>
<DiffTextLine line={line} wrapLines={false} />
<InlineReviewThreadContent
reviewTarget={reviewTarget}
reviewActions={reviewActions}
viewportWidth={textViewportWidth}
pinToViewport
/>
</View>
))}
</View>
</DiffScroll>
@@ -1524,7 +1731,7 @@ export function GitDiffPane({
workspaceId,
cwd,
hideHeaderRow,
enabled = true,
enabled,
}: GitDiffPaneProps) {
const { theme } = useUnistyles();
const toast = useToast();
@@ -1532,7 +1739,7 @@ export function GitDiffPane({
const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = isWeb && !isMobile;
const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
@@ -1555,13 +1762,8 @@ export function GitDiffPane({
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
const handleSelectUncommitted = useCallback(() => {
setDiffModeOverride("uncommitted");
}, []);
const handleSelectBase = useCallback(() => {
setDiffModeOverride("base");
}, []);
// handleSelectUncommitted/handleSelectBase are defined later, after reviewDraftScopeKey
// and setActiveReviewMode are available, so they can record the active review mode.
const handleLayoutUnified = useCallback(() => {
handleLayoutChange("unified");
@@ -1617,8 +1819,20 @@ export function GitDiffPane({
} = statusState;
// Auto-select diff mode based on state: uncommitted when dirty, base when clean
const autoDiffMode = hasUncommittedChanges ? "uncommitted" : "base";
const diffMode = diffModeOverride ?? autoDiffMode;
const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base";
const reviewDraftScopeKey = useMemo(
() =>
buildReviewDraftScopeKey({
serverId,
workspaceId,
cwd,
baseRef,
ignoreWhitespace: changesPreferences.hideWhitespace,
}),
[baseRef, changesPreferences.hideWhitespace, cwd, serverId, workspaceId],
);
const activeReviewMode = useActiveReviewDraftMode({ scopeKey: reviewDraftScopeKey });
const diffMode = diffModeOverride ?? activeReviewMode ?? autoDiffMode;
const {
files,
@@ -1630,8 +1844,69 @@ export function GitDiffPane({
mode: diffMode,
baseRef,
ignoreWhitespace: changesPreferences.hideWhitespace,
enabled: shouldEnableCheckoutDiff({ paneEnabled: enabled, isGit }),
enabled: shouldEnableCheckoutDiff({ paneEnabled: enabled !== false, isGit }),
});
const reviewDraftKey = useMemo(
() =>
buildReviewDraftKey({
serverId,
workspaceId,
cwd,
mode: diffMode,
baseRef,
ignoreWhitespace: changesPreferences.hideWhitespace,
}),
[baseRef, changesPreferences.hideWhitespace, cwd, diffMode, serverId, workspaceId],
);
const setActiveReviewMode = useSetActiveReviewDraftMode();
const handleSelectUncommitted = useCallback(() => {
setDiffModeOverride("uncommitted");
setActiveReviewMode({ scopeKey: reviewDraftScopeKey, mode: "uncommitted" });
}, [reviewDraftScopeKey, setActiveReviewMode]);
const handleSelectBase = useCallback(() => {
setDiffModeOverride("base");
setActiveReviewMode({ scopeKey: reviewDraftScopeKey, mode: "base" });
}, [reviewDraftScopeKey, setActiveReviewMode]);
const reviewActions = useInlineReviewController({
reviewDraftKey,
showPersistentAction: isMobile,
});
const reviewAttachment = useReviewAttachmentSnapshot({
key: reviewDraftKey,
diffFiles: files,
cwd,
mode: diffMode,
baseRef,
});
const workspaceAttachmentScopeKey = useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
[cwd, serverId, workspaceId],
);
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.setWorkspaceAttachments,
);
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.clearWorkspaceAttachments,
);
useEffect(() => {
setWorkspaceAttachments({
scopeKey: workspaceAttachmentScopeKey,
attachments: reviewAttachment ? [reviewAttachment] : [],
});
return () => {
clearWorkspaceAttachments({ scopeKey: workspaceAttachmentScopeKey });
};
}, [
clearWorkspaceAttachments,
reviewAttachment,
setWorkspaceAttachments,
workspaceAttachmentScopeKey,
]);
const {
status: prStatus,
githubFeaturesEnabled,
@@ -2026,6 +2301,7 @@ export function GitDiffPane({
file={item.file}
layout={effectiveLayout}
wrapLines={wrapLines}
reviewActions={reviewActions}
onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`}
/>
@@ -2036,6 +2312,7 @@ export function GitDiffPane({
handleBodyHeightChange,
handleHeaderHeightChange,
handleToggleExpanded,
reviewActions,
wrapLines,
],
);
@@ -2075,8 +2352,14 @@ export function GitDiffPane({
);
const flatExtraData = useMemo(
() => ({ expandedPathsArray, effectiveLayout, heightVersion, wrapLines }),
[expandedPathsArray, effectiveLayout, heightVersion, wrapLines],
() => ({
expandedPathsArray,
effectiveLayout,
heightVersion,
wrapLines,
reviewActions,
}),
[expandedPathsArray, effectiveLayout, heightVersion, wrapLines, reviewActions],
);
const hasChanges = files.length > 0;
@@ -2655,6 +2938,17 @@ const styles = StyleSheet.create((theme) => ({
borderRightColor: theme.colors.border,
justifyContent: "flex-start",
},
inlineReviewRow: {
flexDirection: "row",
alignItems: "stretch",
backgroundColor: theme.colors.surface1,
},
inlineReviewGutterSpacer: {
borderRightWidth: theme.borderWidth[1],
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
flexShrink: 0,
},
textLineContainer: {
flexDirection: "row",
alignItems: "stretch",
+56
View File
@@ -64,6 +64,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@server/shared/messages";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
@@ -107,6 +108,7 @@ type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: un
interface UserMessageProps {
message: string;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
timestamp: number;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
@@ -356,6 +358,11 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
flexWrap: "wrap",
},
attachmentPreviewContainer: {
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: "wrap",
},
imagePreviewSpacing: {
marginBottom: theme.spacing[2],
},
@@ -374,6 +381,19 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
height: 48,
backgroundColor: theme.colors.surface1,
},
structuredAttachmentPill: {
maxWidth: 220,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
structuredAttachmentText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
copyButton: {
alignSelf: "flex-end",
padding: theme.spacing[1],
@@ -396,9 +416,23 @@ function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAtta
return <Image source={imageSource} style={userMessageStylesheet.imageThumbnail} />;
}
function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
switch (attachment.type) {
case "review": {
const count = attachment.comments.length;
return count === 1 ? "Review · 1 comment" : `Review · ${count} comments`;
}
case "github_pr":
return `PR #${attachment.number}`;
case "github_issue":
return `Issue #${attachment.number}`;
}
}
export const UserMessage = memo(function UserMessage({
message,
images = [],
attachments = [],
timestamp: _timestamp,
isFirstInGroup = true,
isLastInGroup = true,
@@ -410,6 +444,7 @@ export const UserMessage = memo(function UserMessage({
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const hasAttachments = attachments.length > 0;
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
const handleHoverIn = useCallback(() => setMessageHovered(true), []);
@@ -430,6 +465,13 @@ export const UserMessage = memo(function UserMessage({
const imagePreviewContainerStyle = useMemo(
() => [
userMessageStylesheet.imagePreviewContainer,
hasText || hasAttachments ? userMessageStylesheet.imagePreviewSpacing : undefined,
],
[hasAttachments, hasText],
);
const attachmentPreviewContainerStyle = useMemo(
() => [
userMessageStylesheet.attachmentPreviewContainer,
hasText ? userMessageStylesheet.imagePreviewSpacing : undefined,
],
[hasText],
@@ -461,6 +503,20 @@ export const UserMessage = memo(function UserMessage({
))}
</View>
) : null}
{hasAttachments ? (
<View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => (
<View
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
style={userMessageStylesheet.structuredAttachmentPill}
>
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
{getUserMessageAttachmentLabel(attachment)}
</Text>
</View>
))}
</View>
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text}>
{message}