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