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:
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { CircleDot } from "lucide-react-native";
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import { AttachmentPill } from "@/components/attachment-pill";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import { useClearReviewDraft } from "@/review/store";
|
||||
|
||||
interface WorkspaceAttachmentBindingInput {
|
||||
normalAttachments: UserComposerAttachment[];
|
||||
workspaceAttachments: readonly WorkspaceComposerAttachment[];
|
||||
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
||||
}
|
||||
|
||||
interface RemoveWorkspaceAttachmentInput {
|
||||
selectedAttachments: readonly ComposerAttachment[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface OpenWorkspaceAttachmentInput {
|
||||
attachment: ComposerAttachment;
|
||||
}
|
||||
|
||||
interface CompleteSubmitInput {
|
||||
result: "noop" | "queued" | "submitted" | "failed";
|
||||
outgoingAttachments: readonly ComposerAttachment[];
|
||||
}
|
||||
|
||||
interface ComposerWorkspaceAttachmentBinding {
|
||||
selectedAttachments: ComposerAttachment[];
|
||||
buildOutgoingAttachments: (normalAttachments: UserComposerAttachment[]) => ComposerAttachment[];
|
||||
removeAttachment: (input: RemoveWorkspaceAttachmentInput) => boolean;
|
||||
openAttachment: (input: OpenWorkspaceAttachmentInput) => boolean;
|
||||
clearSentAttachments: (attachments: readonly ComposerAttachment[]) => void;
|
||||
completeSubmit: (input: CompleteSubmitInput) => void;
|
||||
resetSuppression: () => void;
|
||||
}
|
||||
|
||||
function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
||||
return JSON.stringify({
|
||||
type: "review",
|
||||
cwd: attachment.attachment.cwd,
|
||||
mode: attachment.attachment.mode,
|
||||
baseRef: attachment.attachment.baseRef ?? null,
|
||||
reviewDraftKey: attachment.reviewDraftKey,
|
||||
comments: attachment.attachment.comments.map((comment) => ({
|
||||
filePath: comment.filePath,
|
||||
side: comment.side,
|
||||
lineNumber: comment.lineNumber,
|
||||
body: comment.body,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is WorkspaceComposerAttachment {
|
||||
return attachment?.kind === "review";
|
||||
}
|
||||
|
||||
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
|
||||
return attachments.filter(
|
||||
(attachment): attachment is UserComposerAttachment => attachment.kind !== "review",
|
||||
);
|
||||
}
|
||||
|
||||
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
|
||||
return isWorkspaceAttachment(attachment) ? attachment.attachment : null;
|
||||
}
|
||||
|
||||
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
||||
return (
|
||||
<WorkspaceAttachmentPill
|
||||
key={`workspace:${args.attachment.attachment.cwd}:${args.attachment.attachment.mode}`}
|
||||
{...args}
|
||||
attachment={args.attachment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useWorkspaceAttachmentBinding({
|
||||
normalAttachments,
|
||||
workspaceAttachments,
|
||||
onOpenWorkspaceAttachment,
|
||||
}: WorkspaceAttachmentBindingInput): ComposerWorkspaceAttachmentBinding {
|
||||
const clearReviewDraft = useClearReviewDraft();
|
||||
const [suppressedKeys, setSuppressedKeys] = useState<readonly string[]>([]);
|
||||
const workspaceAttachmentKeys = useMemo(
|
||||
() => workspaceAttachments.map(getAttachmentKey),
|
||||
[workspaceAttachments],
|
||||
);
|
||||
const activeWorkspaceAttachments = useMemo(
|
||||
() =>
|
||||
workspaceAttachments.filter(
|
||||
(attachment, index) => !suppressedKeys.includes(workspaceAttachmentKeys[index] ?? ""),
|
||||
),
|
||||
[suppressedKeys, workspaceAttachmentKeys, workspaceAttachments],
|
||||
);
|
||||
|
||||
const selectedAttachments = useMemo<ComposerAttachment[]>(
|
||||
() =>
|
||||
activeWorkspaceAttachments.length > 0
|
||||
? [...normalAttachments, ...activeWorkspaceAttachments]
|
||||
: normalAttachments,
|
||||
[activeWorkspaceAttachments, normalAttachments],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSuppressedKeys((current) =>
|
||||
current.filter((suppressedKey) => workspaceAttachmentKeys.includes(suppressedKey)),
|
||||
);
|
||||
}, [workspaceAttachmentKeys]);
|
||||
|
||||
const buildOutgoingAttachments = useCallback(
|
||||
(attachments: UserComposerAttachment[]): ComposerAttachment[] =>
|
||||
activeWorkspaceAttachments.length > 0
|
||||
? [...attachments, ...activeWorkspaceAttachments]
|
||||
: attachments,
|
||||
[activeWorkspaceAttachments],
|
||||
);
|
||||
|
||||
const suppressWorkspaceAttachment = useCallback((attachment: WorkspaceComposerAttachment) => {
|
||||
const key = getAttachmentKey(attachment);
|
||||
setSuppressedKeys((current) => (current.includes(key) ? current : [...current, key]));
|
||||
}, []);
|
||||
|
||||
const clearSentAttachments = useCallback(
|
||||
(attachments: readonly ComposerAttachment[]) => {
|
||||
for (const attachment of attachments) {
|
||||
if (isWorkspaceAttachment(attachment)) {
|
||||
clearReviewDraft({ key: attachment.reviewDraftKey });
|
||||
}
|
||||
}
|
||||
},
|
||||
[clearReviewDraft],
|
||||
);
|
||||
|
||||
const removeAttachment = useCallback(
|
||||
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
|
||||
const selected = current[index];
|
||||
if (isWorkspaceAttachment(selected)) {
|
||||
suppressWorkspaceAttachment(selected);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[suppressWorkspaceAttachment],
|
||||
);
|
||||
|
||||
const openAttachment = useCallback(
|
||||
({ attachment }: OpenWorkspaceAttachmentInput) => {
|
||||
if (!isWorkspaceAttachment(attachment)) {
|
||||
return false;
|
||||
}
|
||||
onOpenWorkspaceAttachment?.(attachment);
|
||||
return true;
|
||||
},
|
||||
[onOpenWorkspaceAttachment],
|
||||
);
|
||||
|
||||
const resetSuppression = useCallback(() => {
|
||||
setSuppressedKeys([]);
|
||||
}, []);
|
||||
|
||||
const completeSubmit = useCallback(
|
||||
({ result, outgoingAttachments }: CompleteSubmitInput) => {
|
||||
if (result === "submitted") {
|
||||
clearSentAttachments(outgoingAttachments);
|
||||
}
|
||||
if (result === "queued" || result === "submitted") {
|
||||
resetSuppression();
|
||||
}
|
||||
},
|
||||
[clearSentAttachments, resetSuppression],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedAttachments,
|
||||
buildOutgoingAttachments,
|
||||
removeAttachment,
|
||||
openAttachment,
|
||||
clearSentAttachments,
|
||||
completeSubmit,
|
||||
resetSuppression,
|
||||
};
|
||||
}
|
||||
|
||||
interface RenderWorkspaceAttachmentPillArgs {
|
||||
attachment: WorkspaceComposerAttachment;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
onOpen: (attachment: ComposerAttachment) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
interface WorkspaceAttachmentPillProps extends Omit<
|
||||
RenderWorkspaceAttachmentPillArgs,
|
||||
"attachment"
|
||||
> {
|
||||
attachment: WorkspaceComposerAttachment;
|
||||
}
|
||||
|
||||
function WorkspaceAttachmentPill({
|
||||
attachment,
|
||||
index,
|
||||
disabled,
|
||||
onOpen,
|
||||
onRemove,
|
||||
}: WorkspaceAttachmentPillProps) {
|
||||
const label =
|
||||
attachment.commentCount === 1
|
||||
? "Review · 1 comment"
|
||||
: `Review · ${attachment.commentCount} comments`;
|
||||
const handleOpen = useCallback(() => {
|
||||
onOpen(attachment);
|
||||
}, [onOpen, attachment]);
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(index);
|
||||
}, [onRemove, index]);
|
||||
return (
|
||||
<AttachmentPill
|
||||
testID="composer-review-attachment-pill"
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel="Open review attachment"
|
||||
removeAccessibilityLabel="Remove review attachment"
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={styles.pillBody}>
|
||||
<View style={styles.pillIcon}>
|
||||
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
</View>
|
||||
<Text style={styles.pillText} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
</AttachmentPill>
|
||||
);
|
||||
}
|
||||
|
||||
export const composerWorkspaceAttachment = {
|
||||
is: isWorkspaceAttachment,
|
||||
renderPill,
|
||||
toSubmitAttachment,
|
||||
userAttachmentsOnly,
|
||||
useBinding: useWorkspaceAttachmentBinding,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme: Theme) => ({
|
||||
pillBody: {
|
||||
minHeight: 48,
|
||||
maxWidth: 260,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
pillIcon: {
|
||||
width: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
pillText: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
})) as unknown as Record<string, object>;
|
||||
|
||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||
|
||||
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
|
||||
|
||||
@@ -20,7 +20,17 @@ export interface AttachmentMetadata {
|
||||
export type ComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "github_issue"; item: GitHubSearchItem }
|
||||
| { kind: "github_pr"; item: GitHubSearchItem };
|
||||
| { kind: "github_pr"; item: GitHubSearchItem }
|
||||
| {
|
||||
kind: "review";
|
||||
attachment: Extract<AgentAttachment, { type: "review" }>;
|
||||
reviewDraftKey: string;
|
||||
commentCount: number;
|
||||
};
|
||||
|
||||
export type UserComposerAttachment = Exclude<ComposerAttachment, { kind: "review" }>;
|
||||
|
||||
export type WorkspaceComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
|
||||
|
||||
export type AttachmentDataSource =
|
||||
| { kind: "base64"; base64: string }
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceComposerAttachment } from "./types";
|
||||
import {
|
||||
buildWorkspaceAttachmentScopeKey,
|
||||
resetWorkspaceAttachmentsStore,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "./workspace-attachments-store";
|
||||
|
||||
function reviewAttachment(body: string): WorkspaceComposerAttachment {
|
||||
return {
|
||||
kind: "review",
|
||||
reviewDraftKey: `review:${body}`,
|
||||
commentCount: 1,
|
||||
attachment: {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/repo",
|
||||
mode: "uncommitted",
|
||||
baseRef: null,
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body,
|
||||
context: {
|
||||
hunkHeader: "@@ -40,1 +40,1 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace attachments store", () => {
|
||||
it("scopes workspace attachments by server and workspace before cwd fallback", () => {
|
||||
expect(
|
||||
buildWorkspaceAttachmentScopeKey({
|
||||
serverId: " local ",
|
||||
workspaceId: " workspace-1 ",
|
||||
cwd: "/repo",
|
||||
}),
|
||||
).toBe("workspace-attachments:server=local:workspace=workspace-1");
|
||||
|
||||
expect(
|
||||
buildWorkspaceAttachmentScopeKey({
|
||||
serverId: "local",
|
||||
workspaceId: null,
|
||||
cwd: "/repo/",
|
||||
}),
|
||||
).toBe("workspace-attachments:server=local:cwd=%2Frepo");
|
||||
});
|
||||
|
||||
it("publishes and clears attachments for a workspace scope", () => {
|
||||
resetWorkspaceAttachmentsStore();
|
||||
const scopeKey = buildWorkspaceAttachmentScopeKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
const attachment = reviewAttachment("Please simplify this.");
|
||||
|
||||
useWorkspaceAttachmentsStore
|
||||
.getState()
|
||||
.setWorkspaceAttachments({ scopeKey, attachments: [attachment] });
|
||||
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toEqual([
|
||||
attachment,
|
||||
]);
|
||||
|
||||
useWorkspaceAttachmentsStore.getState().clearWorkspaceAttachments({ scopeKey });
|
||||
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMemo } from "react";
|
||||
import { create } from "zustand";
|
||||
import type { WorkspaceComposerAttachment } from "./types";
|
||||
|
||||
const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = [];
|
||||
|
||||
export interface WorkspaceAttachmentScopeInput {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
interface WorkspaceAttachmentsStoreState {
|
||||
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
|
||||
}
|
||||
|
||||
interface WorkspaceAttachmentsStoreActions {
|
||||
setWorkspaceAttachments: (input: {
|
||||
scopeKey: string;
|
||||
attachments: readonly WorkspaceComposerAttachment[];
|
||||
}) => void;
|
||||
clearWorkspaceAttachments: (input: { scopeKey: string }) => void;
|
||||
}
|
||||
|
||||
type WorkspaceAttachmentsStore = WorkspaceAttachmentsStoreState & WorkspaceAttachmentsStoreActions;
|
||||
|
||||
function encodeScopePart(value: string): string {
|
||||
return encodeURIComponent(value.trim());
|
||||
}
|
||||
|
||||
function normalizeCwd(cwd: string): string {
|
||||
const trimmed = cwd.trim();
|
||||
if (trimmed === "/") {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
|
||||
const workspaceId = input.workspaceId?.trim();
|
||||
const workspacePart = workspaceId
|
||||
? `workspace=${encodeScopePart(workspaceId)}`
|
||||
: `cwd=${encodeScopePart(normalizeCwd(input.cwd))}`;
|
||||
|
||||
return ["workspace-attachments", `server=${encodeScopePart(input.serverId)}`, workspacePart].join(
|
||||
":",
|
||||
);
|
||||
}
|
||||
|
||||
function areWorkspaceAttachmentsEqual(
|
||||
left: readonly WorkspaceComposerAttachment[],
|
||||
right: readonly WorkspaceComposerAttachment[],
|
||||
): boolean {
|
||||
if (left === right) {
|
||||
return true;
|
||||
}
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
return left.every((attachment, index) => attachment === right[index]);
|
||||
}
|
||||
|
||||
export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()((set) => ({
|
||||
attachmentsByScope: {},
|
||||
setWorkspaceAttachments: ({ scopeKey, attachments }) => {
|
||||
set((state) => {
|
||||
const current = state.attachmentsByScope[scopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS;
|
||||
if (areWorkspaceAttachmentsEqual(current, attachments)) {
|
||||
return state;
|
||||
}
|
||||
if (attachments.length === 0) {
|
||||
if (!state.attachmentsByScope[scopeKey]) {
|
||||
return state;
|
||||
}
|
||||
const next = { ...state.attachmentsByScope };
|
||||
delete next[scopeKey];
|
||||
return { attachmentsByScope: next };
|
||||
}
|
||||
return {
|
||||
attachmentsByScope: {
|
||||
...state.attachmentsByScope,
|
||||
[scopeKey]: attachments,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
clearWorkspaceAttachments: ({ scopeKey }) => {
|
||||
set((state) => {
|
||||
if (!state.attachmentsByScope[scopeKey]) {
|
||||
return state;
|
||||
}
|
||||
const next = { ...state.attachmentsByScope };
|
||||
delete next[scopeKey];
|
||||
return { attachmentsByScope: next };
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
|
||||
const { serverId, workspaceId, cwd } = input;
|
||||
return useMemo(
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
|
||||
[serverId, workspaceId, cwd],
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspaceAttachments(scopeKey: string): readonly WorkspaceComposerAttachment[] {
|
||||
return useWorkspaceAttachmentsStore(
|
||||
(state) => state.attachmentsByScope[scopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS,
|
||||
);
|
||||
}
|
||||
|
||||
export function resetWorkspaceAttachmentsStore(): void {
|
||||
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} });
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import {
|
||||
@@ -13,8 +13,8 @@ import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
type AttachmentUpdater =
|
||||
| ComposerAttachment[]
|
||||
| ((prev: ComposerAttachment[]) => ComposerAttachment[]);
|
||||
| UserComposerAttachment[]
|
||||
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
||||
|
||||
interface AgentInputDraftComposerOptions {
|
||||
initialServerId: string | null;
|
||||
@@ -48,7 +48,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
||||
interface AgentInputDraft {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
attachments: ComposerAttachment[];
|
||||
attachments: UserComposerAttachment[];
|
||||
setAttachments: (updater: AttachmentUpdater) => void;
|
||||
cwd: string;
|
||||
setCwd: (cwd: string) => void;
|
||||
@@ -59,7 +59,7 @@ interface AgentInputDraft {
|
||||
|
||||
function hasDraftContent(input: {
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
attachments: UserComposerAttachment[];
|
||||
cwd: string;
|
||||
}): boolean {
|
||||
return (
|
||||
@@ -68,8 +68,8 @@ function hasDraftContent(input: {
|
||||
}
|
||||
|
||||
function areAttachmentsEqual(input: {
|
||||
left: ComposerAttachment[];
|
||||
right: ComposerAttachment[];
|
||||
left: UserComposerAttachment[];
|
||||
right: UserComposerAttachment[];
|
||||
}): boolean {
|
||||
if (input.left.length !== input.right.length) {
|
||||
return false;
|
||||
@@ -190,7 +190,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[formState.selectedServerId, input.draftKey],
|
||||
);
|
||||
const [text, setText] = useState("");
|
||||
const [attachments, setAttachmentsState] = useState<ComposerAttachment[]>([]);
|
||||
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
|
||||
const [cwd, setCwd] = useState(input.initialCwd ?? "");
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const draftGenerationRef = useRef(0);
|
||||
|
||||
@@ -16,6 +16,7 @@ interface CreateAttempt {
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
images?: UserMessageImageAttachment[];
|
||||
attachments?: AgentAttachment[];
|
||||
}
|
||||
|
||||
type DraftAgentMachineState =
|
||||
@@ -118,7 +119,11 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
if (!machine.attempt.text && (!machine.attempt.images || machine.attempt.images.length === 0)) {
|
||||
if (
|
||||
!machine.attempt.text &&
|
||||
(!machine.attempt.images || machine.attempt.images.length === 0) &&
|
||||
(!machine.attempt.attachments || machine.attempt.attachments.length === 0)
|
||||
) {
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
@@ -131,6 +136,9 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
...(machine.attempt.images && machine.attempt.images.length > 0
|
||||
? { images: machine.attempt.images }
|
||||
: {}),
|
||||
...(machine.attempt.attachments && machine.attempt.attachments.length > 0
|
||||
? { attachments: machine.attempt.attachments }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
}, [machine]);
|
||||
@@ -182,6 +190,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
text: trimmedPrompt,
|
||||
timestamp: new Date(),
|
||||
...(images && images.length > 0 ? { images } : {}),
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
};
|
||||
|
||||
setPendingCreateAttempt({
|
||||
@@ -192,6 +201,9 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
text: attempt.text,
|
||||
timestamp: attempt.timestamp.getTime(),
|
||||
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
|
||||
...(attempt.attachments && attempt.attachments.length > 0
|
||||
? { attachments: attempt.attachments }
|
||||
: {}),
|
||||
});
|
||||
|
||||
onBeforeSubmit?.({
|
||||
|
||||
@@ -109,6 +109,7 @@ vi.mock("react-native-unistyles", () => {
|
||||
create: createPanelTestStyles,
|
||||
},
|
||||
withUnistyles: <T,>(component: T) => component,
|
||||
useUnistyles: () => ({ theme, rt: { breakpoint: "lg" } }),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
useWorkspaceAttachments,
|
||||
useWorkspaceAttachmentScopeKey,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
@@ -43,6 +49,7 @@ import {
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
@@ -1071,6 +1078,9 @@ function AgentStreamSection({
|
||||
...(pendingCreate.images && pendingCreate.images.length > 0
|
||||
? { images: pendingCreate.images }
|
||||
: {}),
|
||||
...(pendingCreate.attachments && pendingCreate.attachments.length > 0
|
||||
? { attachments: pendingCreate.attachments }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
}, [pendingCreate, shouldUseOptimisticStream]);
|
||||
@@ -1101,7 +1111,10 @@ function AgentStreamSection({
|
||||
}
|
||||
|
||||
const pendingImages = pendingCreate.images;
|
||||
if (agentId && pendingImages && pendingImages.length > 0) {
|
||||
const pendingAttachments = pendingCreate.attachments;
|
||||
const hasPendingImages = Boolean(pendingImages && pendingImages.length > 0);
|
||||
const hasPendingAttachments = Boolean(pendingAttachments && pendingAttachments.length > 0);
|
||||
if (agentId && (hasPendingImages || hasPendingAttachments)) {
|
||||
setAgentStreamTail(serverId, (previous) => {
|
||||
const current = previous.get(agentId);
|
||||
if (!current) {
|
||||
@@ -1112,6 +1125,7 @@ function AgentStreamSection({
|
||||
streamItems: current,
|
||||
clientMessageId: pendingCreate.clientMessageId,
|
||||
images: pendingImages,
|
||||
attachments: pendingAttachments,
|
||||
});
|
||||
if (merged === current) {
|
||||
return previous;
|
||||
@@ -1231,6 +1245,8 @@ function ActiveAgentComposer({
|
||||
onMessageSent: () => void;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { workspaceId } = usePaneContext();
|
||||
const agentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
@@ -1238,6 +1254,32 @@ function ActiveAgentComposer({
|
||||
}),
|
||||
initialCwd,
|
||||
});
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
serverId,
|
||||
cwd: agentInputDraft.cwd,
|
||||
workspaceId,
|
||||
});
|
||||
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleOpenWorkspaceAttachment = useCallback(
|
||||
(attachment: WorkspaceComposerAttachment) => {
|
||||
const checkout = {
|
||||
serverId,
|
||||
cwd: attachment.attachment.cwd,
|
||||
isGit: true,
|
||||
};
|
||||
openFileExplorerForCheckout({
|
||||
checkout,
|
||||
isCompact,
|
||||
});
|
||||
setExplorerTabForCheckout({
|
||||
...checkout,
|
||||
tab: "changes",
|
||||
});
|
||||
},
|
||||
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
);
|
||||
|
||||
const inputAreaStyle = useMemo(
|
||||
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }],
|
||||
@@ -1253,6 +1295,8 @@ function ActiveAgentComposer({
|
||||
value={agentInputDraft.text}
|
||||
onChangeText={agentInputDraft.setText}
|
||||
attachments={agentInputDraft.attachments}
|
||||
workspaceAttachments={workspaceAttachments}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={agentInputDraft.setAttachments}
|
||||
cwd={agentInputDraft.cwd}
|
||||
clearDraft={agentInputDraft.clear}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export {
|
||||
buildReviewAttachmentSnapshot,
|
||||
buildReviewDraftKey,
|
||||
buildReviewDraftScopeKey,
|
||||
getReviewDraftComments,
|
||||
resetReviewDraftStore,
|
||||
useActiveReviewDraftMode,
|
||||
useClearReviewDraft,
|
||||
useReviewAttachmentSnapshot,
|
||||
useSetActiveReviewDraftMode,
|
||||
addReviewDraftComment,
|
||||
type BuildReviewDraftKeyInput,
|
||||
type BuildReviewDraftScopeKeyInput,
|
||||
type ReviewDraftCommentInput,
|
||||
type ReviewDraftComment,
|
||||
type ReviewDraftMode,
|
||||
type ReviewDraftSide,
|
||||
} from "./store";
|
||||
|
||||
export {
|
||||
getInlineReviewThreadState,
|
||||
getInlineReviewThreadViewportStyle,
|
||||
getSplitInlineReviewThreadState,
|
||||
groupInlineReviewCommentsByTarget,
|
||||
InlineReviewEditor,
|
||||
InlineReviewGutterCell,
|
||||
InlineReviewThread,
|
||||
isInlineReviewEditorForTarget,
|
||||
SMALL_ACTION_HIT_SLOP,
|
||||
useInlineReviewController,
|
||||
type InlineReviewActions,
|
||||
type InlineReviewEditorState,
|
||||
} from "./surface";
|
||||
@@ -0,0 +1,279 @@
|
||||
import "@/test/window-local-storage";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
import {
|
||||
buildReviewAttachmentSnapshot,
|
||||
buildReviewDraftKey,
|
||||
buildReviewDraftScopeKey,
|
||||
useReviewDraftStore,
|
||||
} from "./store";
|
||||
|
||||
const STORE_STORAGE_KEY = "@paseo:review-draft-store";
|
||||
|
||||
function makeFile(): ParsedDiffFile {
|
||||
return {
|
||||
path: "src/example.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "ok",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 40,
|
||||
oldCount: 4,
|
||||
newStart: 40,
|
||||
newCount: 4,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -40,4 +40,4 @@" },
|
||||
{ type: "context", content: "const before = true;" },
|
||||
{ type: "remove", content: "const value = oldValue;" },
|
||||
{ type: "add", content: "const value = newValue;" },
|
||||
{ type: "context", content: "return value;" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildReviewDraftKey", () => {
|
||||
it("scopes by server, workspace-or-cwd, diff mode, base ref, and whitespace mode", () => {
|
||||
const base = buildReviewDraftKey({
|
||||
serverId: " local ",
|
||||
workspaceId: " workspace-1 ",
|
||||
cwd: "/repo",
|
||||
mode: "base",
|
||||
baseRef: " main ",
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
|
||||
expect(base).toBe(
|
||||
"review:server=local:workspace=workspace-1:mode=base:base=main:ignoreWhitespace=false",
|
||||
);
|
||||
expect(
|
||||
buildReviewDraftKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: true,
|
||||
}),
|
||||
).not.toBe(base);
|
||||
expect(
|
||||
buildReviewDraftKey({
|
||||
serverId: "local",
|
||||
workspaceId: null,
|
||||
cwd: "/repo/",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: false,
|
||||
}),
|
||||
).toBe("review:server=local:cwd=%2Frepo:mode=base:base=main:ignoreWhitespace=false");
|
||||
});
|
||||
|
||||
it("builds a mode-free scope key for active review mode sharing", () => {
|
||||
const scope = buildReviewDraftScopeKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
|
||||
expect(scope).toBe(
|
||||
"review:server=local:workspace=workspace-1:base=main:ignoreWhitespace=false",
|
||||
);
|
||||
expect(scope).not.toContain("mode=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("review draft store", () => {
|
||||
it("normalizes persisted active review modes with draft comments", async () => {
|
||||
await AsyncStorage.setItem(
|
||||
STORE_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
version: 0,
|
||||
state: {
|
||||
drafts: {
|
||||
"review:key": [
|
||||
{
|
||||
id: "comment-1",
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body: "Keep me.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
},
|
||||
{ id: "bad", filePath: "src/example.ts" },
|
||||
],
|
||||
},
|
||||
activeModesByScope: {
|
||||
"review:scope:base": "base",
|
||||
"review:scope:dirty": "uncommitted",
|
||||
"review:scope:bad": "other",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await useReviewDraftStore.persist.rehydrate();
|
||||
|
||||
const state = useReviewDraftStore.getState();
|
||||
expect(state.activeModesByScope).toEqual({
|
||||
"review:scope:base": "base",
|
||||
"review:scope:dirty": "uncommitted",
|
||||
});
|
||||
expect(state.drafts["review:key"]).toEqual([
|
||||
{
|
||||
id: "comment-1",
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body: "Keep me.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists compact draft comments separately from workspace attachment wire context", () => {
|
||||
const key = buildReviewDraftKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
mode: "uncommitted",
|
||||
baseRef: null,
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
|
||||
useReviewDraftStore.getState().clearReview({ key });
|
||||
useReviewDraftStore.getState().addComment({
|
||||
key,
|
||||
comment: {
|
||||
id: "comment-1",
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
expect(useReviewDraftStore.getState().drafts[key]).toEqual([
|
||||
{
|
||||
id: "comment-1",
|
||||
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",
|
||||
},
|
||||
]);
|
||||
|
||||
useReviewDraftStore.getState().updateComment({
|
||||
key,
|
||||
id: "comment-1",
|
||||
updates: { body: "Please simplify this condition." },
|
||||
updatedAt: "2026-04-21T00:01:00.000Z",
|
||||
});
|
||||
expect(useReviewDraftStore.getState().drafts[key]?.[0]?.body).toBe(
|
||||
"Please simplify this condition.",
|
||||
);
|
||||
|
||||
useReviewDraftStore.getState().deleteComment({ key, id: "comment-1" });
|
||||
expect(useReviewDraftStore.getState().drafts[key]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReviewAttachmentSnapshot", () => {
|
||||
it("builds a bounded workspace review attachment and skips missing targets", () => {
|
||||
const snapshot = buildReviewAttachmentSnapshot({
|
||||
reviewDraftKey: "review:key",
|
||||
cwd: "/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
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",
|
||||
},
|
||||
{
|
||||
id: "comment-2",
|
||||
filePath: "src/missing.ts",
|
||||
side: "new",
|
||||
lineNumber: 99,
|
||||
body: "This target is stale.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
diffFiles: [makeFile()],
|
||||
});
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
kind: "review",
|
||||
reviewDraftKey: "review:key",
|
||||
commentCount: 1,
|
||||
attachment: {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body: "Please simplify this.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,4 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: 40,
|
||||
newLineNumber: 40,
|
||||
type: "context",
|
||||
content: "const before = true;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: 41,
|
||||
newLineNumber: null,
|
||||
type: "remove",
|
||||
content: "const value = oldValue;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: 42,
|
||||
newLineNumber: 42,
|
||||
type: "context",
|
||||
content: "return value;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
import { useMemo } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
|
||||
const STORE_VERSION = 1;
|
||||
const CONTEXT_RADIUS = 3;
|
||||
const EMPTY_REVIEW_DRAFT_COMMENTS: ReviewDraftComment[] = [];
|
||||
|
||||
type ReviewAttachment = Extract<AgentAttachment, { type: "review" }>;
|
||||
type ReviewAttachmentContextLine = ReviewAttachment["comments"][number]["context"]["targetLine"];
|
||||
type ReviewComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
|
||||
|
||||
export type ReviewDraftMode = "uncommitted" | "base";
|
||||
export type ReviewDraftSide = "old" | "new";
|
||||
|
||||
export interface ReviewDraftComment {
|
||||
id: string;
|
||||
filePath: string;
|
||||
side: ReviewDraftSide;
|
||||
lineNumber: number;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BuildReviewDraftKeyInput {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
mode: ReviewDraftMode;
|
||||
baseRef?: string | null;
|
||||
ignoreWhitespace: boolean;
|
||||
}
|
||||
|
||||
export type BuildReviewDraftScopeKeyInput = Omit<BuildReviewDraftKeyInput, "mode">;
|
||||
|
||||
export interface BuildReviewAttachmentSnapshotInput {
|
||||
reviewDraftKey: string;
|
||||
cwd: string;
|
||||
mode: ReviewDraftMode;
|
||||
baseRef?: string | null;
|
||||
comments: readonly ReviewDraftComment[];
|
||||
diffFiles: readonly ParsedDiffFile[];
|
||||
}
|
||||
|
||||
interface ReviewDraftStoreState {
|
||||
drafts: Record<string, ReviewDraftComment[]>;
|
||||
activeModesByScope: Record<string, ReviewDraftMode>;
|
||||
}
|
||||
|
||||
interface ReviewDraftStoreActions {
|
||||
setActiveMode: (input: { scopeKey: string; mode: ReviewDraftMode }) => void;
|
||||
addComment: (input: { key: string; comment: ReviewDraftCommentInput }) => ReviewDraftComment;
|
||||
updateComment: (input: {
|
||||
key: string;
|
||||
id: string;
|
||||
updates: Partial<Pick<ReviewDraftComment, "body">>;
|
||||
updatedAt?: string;
|
||||
}) => void;
|
||||
deleteComment: (input: { key: string; id: string }) => void;
|
||||
clearReview: (input: { key: string }) => void;
|
||||
}
|
||||
|
||||
export type ReviewDraftCommentInput = Omit<ReviewDraftComment, "id" | "createdAt" | "updatedAt"> &
|
||||
Partial<Pick<ReviewDraftComment, "id" | "createdAt" | "updatedAt">>;
|
||||
|
||||
type ReviewDraftStore = ReviewDraftStoreState & ReviewDraftStoreActions;
|
||||
|
||||
function encodeKeyPart(value: string): string {
|
||||
return encodeURIComponent(value.trim());
|
||||
}
|
||||
|
||||
function normalizeCwd(cwd: string): string {
|
||||
const trimmed = cwd.trim();
|
||||
if (trimmed === "/") {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function normalizeBaseRef(baseRef: string | null | undefined): string {
|
||||
return baseRef?.trim() ?? "";
|
||||
}
|
||||
|
||||
function buildReviewDraftScopeParts(input: BuildReviewDraftScopeKeyInput): string[] {
|
||||
const workspaceId = input.workspaceId?.trim();
|
||||
const workspacePart = workspaceId
|
||||
? `workspace=${encodeKeyPart(workspaceId)}`
|
||||
: `cwd=${encodeKeyPart(normalizeCwd(input.cwd))}`;
|
||||
|
||||
return [
|
||||
"review",
|
||||
`server=${encodeKeyPart(input.serverId)}`,
|
||||
workspacePart,
|
||||
`base=${encodeKeyPart(normalizeBaseRef(input.baseRef))}`,
|
||||
`ignoreWhitespace=${input.ignoreWhitespace ? "true" : "false"}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildReviewDraftScopeKey(input: BuildReviewDraftScopeKeyInput): string {
|
||||
return buildReviewDraftScopeParts(input).join(":");
|
||||
}
|
||||
|
||||
export function buildReviewDraftKey(input: BuildReviewDraftKeyInput): string {
|
||||
const [prefix, serverPart, workspacePart, basePart, whitespacePart] =
|
||||
buildReviewDraftScopeParts(input);
|
||||
return [prefix, serverPart, workspacePart, `mode=${input.mode}`, basePart, whitespacePart].join(
|
||||
":",
|
||||
);
|
||||
}
|
||||
|
||||
function createDraftComment(input: ReviewDraftCommentInput): ReviewDraftComment {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: input.id ?? generateMessageId(),
|
||||
filePath: input.filePath,
|
||||
side: input.side,
|
||||
lineNumber: input.lineNumber,
|
||||
body: input.body,
|
||||
createdAt: input.createdAt ?? now,
|
||||
updatedAt: input.updatedAt ?? input.createdAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCommentUpdates(
|
||||
comment: ReviewDraftComment,
|
||||
targetId: string,
|
||||
updates: Partial<Pick<ReviewDraftComment, "body">>,
|
||||
updatedAt: string,
|
||||
): ReviewDraftComment {
|
||||
if (comment.id !== targetId) {
|
||||
return comment;
|
||||
}
|
||||
return {
|
||||
id: comment.id,
|
||||
filePath: comment.filePath,
|
||||
side: comment.side,
|
||||
lineNumber: comment.lineNumber,
|
||||
body: updates.body ?? comment.body,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePersistedState(state: unknown): ReviewDraftStoreState {
|
||||
if (!state || typeof state !== "object") {
|
||||
return { drafts: {}, activeModesByScope: {} };
|
||||
}
|
||||
const persisted = state as { drafts?: unknown; activeModesByScope?: unknown };
|
||||
const drafts = persisted.drafts;
|
||||
if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) {
|
||||
return { drafts: {}, activeModesByScope: {} };
|
||||
}
|
||||
|
||||
const normalized: Record<string, ReviewDraftComment[]> = {};
|
||||
for (const [key, value] of Object.entries(drafts)) {
|
||||
if (!Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
normalized[key] = value.filter((comment): comment is ReviewDraftComment =>
|
||||
isReviewDraftComment(comment),
|
||||
);
|
||||
}
|
||||
|
||||
const activeModesByScope: Record<string, ReviewDraftMode> = {};
|
||||
const persistedActiveModes = persisted.activeModesByScope;
|
||||
if (
|
||||
persistedActiveModes &&
|
||||
typeof persistedActiveModes === "object" &&
|
||||
!Array.isArray(persistedActiveModes)
|
||||
) {
|
||||
for (const [key, mode] of Object.entries(persistedActiveModes)) {
|
||||
if (mode === "base" || mode === "uncommitted") {
|
||||
activeModesByScope[key] = mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { drafts: normalized, activeModesByScope };
|
||||
}
|
||||
|
||||
function isReviewDraftComment(value: unknown): value is ReviewDraftComment {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof record.id === "string" &&
|
||||
typeof record.filePath === "string" &&
|
||||
(record.side === "old" || record.side === "new") &&
|
||||
typeof record.lineNumber === "number" &&
|
||||
Number.isInteger(record.lineNumber) &&
|
||||
record.lineNumber > 0 &&
|
||||
typeof record.body === "string" &&
|
||||
typeof record.createdAt === "string" &&
|
||||
typeof record.updatedAt === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export const useReviewDraftStore = create<ReviewDraftStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
drafts: {},
|
||||
activeModesByScope: {},
|
||||
setActiveMode: ({ scopeKey, mode }) => {
|
||||
set((state) => {
|
||||
if (state.activeModesByScope[scopeKey] === mode) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
activeModesByScope: {
|
||||
...state.activeModesByScope,
|
||||
[scopeKey]: mode,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
addComment: ({ key, comment }) => {
|
||||
const nextComment = createDraftComment(comment);
|
||||
set((state) => ({
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: [...(state.drafts[key] ?? []), nextComment],
|
||||
},
|
||||
}));
|
||||
return nextComment;
|
||||
},
|
||||
updateComment: ({ key, id, updates, updatedAt }) => {
|
||||
set((state) => {
|
||||
const comments = state.drafts[key] ?? [];
|
||||
if (!comments.some((comment) => comment.id === id)) {
|
||||
return state;
|
||||
}
|
||||
const nextUpdatedAt = updatedAt ?? new Date().toISOString();
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: comments.map((comment) =>
|
||||
applyCommentUpdates(comment, id, updates, nextUpdatedAt),
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
deleteComment: ({ key, id }) => {
|
||||
set((state) => {
|
||||
const comments = state.drafts[key] ?? [];
|
||||
if (!comments.some((comment) => comment.id === id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: comments.filter((comment) => comment.id !== id),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
clearReview: ({ key }) => {
|
||||
set((state) => {
|
||||
if (!state.drafts[key]) {
|
||||
return state;
|
||||
}
|
||||
const next = { ...state.drafts };
|
||||
delete next[key];
|
||||
return { drafts: next };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "@paseo:review-draft-store",
|
||||
version: STORE_VERSION,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
drafts: state.drafts,
|
||||
activeModesByScope: state.activeModesByScope,
|
||||
}),
|
||||
migrate: async (state) => normalizePersistedState(state),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function toContextLine(line: NumberedDiffLine): ReviewAttachmentContextLine | null {
|
||||
if (line.line.type === "header") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
oldLineNumber: line.oldLineNumber,
|
||||
newLineNumber: line.newLineNumber,
|
||||
type: line.line.type,
|
||||
content: line.line.content,
|
||||
};
|
||||
}
|
||||
|
||||
function findTarget(input: { comment: ReviewDraftComment; diffFiles: readonly ParsedDiffFile[] }): {
|
||||
hunkHeader: string;
|
||||
hunkLines: NumberedDiffLine[];
|
||||
targetIndex: number;
|
||||
targetLine: NumberedDiffLine;
|
||||
} | null {
|
||||
const file = input.diffFiles.find((candidate) => candidate.path === input.comment.filePath);
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const hunk of buildNumberedDiffHunks(file)) {
|
||||
const targetIndex = hunk.lines.findIndex((line) => {
|
||||
const cell = input.comment.side === "old" ? line.oldCell : line.newCell;
|
||||
return cell?.lineNumber === input.comment.lineNumber;
|
||||
});
|
||||
const targetLine = hunk.lines[targetIndex];
|
||||
if (targetLine) {
|
||||
return {
|
||||
hunkHeader: hunk.hunkHeader,
|
||||
hunkLines: hunk.lines,
|
||||
targetIndex,
|
||||
targetLine,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildReviewAttachmentSnapshot(
|
||||
input: BuildReviewAttachmentSnapshotInput,
|
||||
): ReviewComposerAttachment | null {
|
||||
const comments: ReviewAttachment["comments"] = [];
|
||||
|
||||
for (const draftComment of input.comments) {
|
||||
const target = findTarget({
|
||||
comment: draftComment,
|
||||
diffFiles: input.diffFiles,
|
||||
});
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetLine = toContextLine(target.targetLine);
|
||||
if (!targetLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const contextStart = Math.max(0, target.targetIndex - CONTEXT_RADIUS);
|
||||
const contextEnd = Math.min(target.hunkLines.length, target.targetIndex + CONTEXT_RADIUS + 1);
|
||||
const lines = target.hunkLines
|
||||
.slice(contextStart, contextEnd)
|
||||
.map(toContextLine)
|
||||
.filter((line): line is ReviewAttachmentContextLine => line !== null);
|
||||
|
||||
comments.push({
|
||||
filePath: draftComment.filePath,
|
||||
side: draftComment.side,
|
||||
lineNumber: draftComment.lineNumber,
|
||||
body: draftComment.body,
|
||||
context: {
|
||||
hunkHeader: target.hunkHeader,
|
||||
targetLine,
|
||||
lines,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (comments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attachment: ReviewAttachment = {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: input.cwd,
|
||||
mode: input.mode,
|
||||
baseRef: normalizeBaseRef(input.baseRef) || null,
|
||||
comments,
|
||||
};
|
||||
|
||||
return {
|
||||
kind: "review",
|
||||
reviewDraftKey: input.reviewDraftKey,
|
||||
commentCount: comments.length,
|
||||
attachment,
|
||||
};
|
||||
}
|
||||
|
||||
export function useReviewDraftComments(key: string): ReviewDraftComment[] {
|
||||
return useReviewDraftStore((state) => state.drafts[key] ?? EMPTY_REVIEW_DRAFT_COMMENTS);
|
||||
}
|
||||
|
||||
export function useSetActiveReviewDraftMode(): ReviewDraftStoreActions["setActiveMode"] {
|
||||
return useReviewDraftStore((state) => state.setActiveMode);
|
||||
}
|
||||
|
||||
export function useClearReviewDraft(): ReviewDraftStoreActions["clearReview"] {
|
||||
return useReviewDraftStore((state) => state.clearReview);
|
||||
}
|
||||
|
||||
export function addReviewDraftComment(input: {
|
||||
key: string;
|
||||
comment: ReviewDraftCommentInput;
|
||||
}): ReviewDraftComment {
|
||||
return useReviewDraftStore.getState().addComment(input);
|
||||
}
|
||||
|
||||
export function getReviewDraftComments(key: string): ReviewDraftComment[] | undefined {
|
||||
return useReviewDraftStore.getState().drafts[key];
|
||||
}
|
||||
|
||||
export function resetReviewDraftStore(): void {
|
||||
useReviewDraftStore.setState({ drafts: {}, activeModesByScope: {} });
|
||||
}
|
||||
|
||||
export function useReviewDraftCommentsForAttachment(input: {
|
||||
key: string;
|
||||
enabled: boolean;
|
||||
}): ReviewDraftComment[] {
|
||||
return useReviewDraftStore((state) =>
|
||||
input.enabled
|
||||
? (state.drafts[input.key] ?? EMPTY_REVIEW_DRAFT_COMMENTS)
|
||||
: EMPTY_REVIEW_DRAFT_COMMENTS,
|
||||
);
|
||||
}
|
||||
|
||||
export function useReviewCommentCount(key: string): number {
|
||||
return useReviewDraftStore((state) => state.drafts[key]?.length ?? 0);
|
||||
}
|
||||
|
||||
export function useActiveReviewDraftMode(input: { scopeKey: string }): ReviewDraftMode | null {
|
||||
return useReviewDraftStore((state) => state.activeModesByScope[input.scopeKey] ?? null);
|
||||
}
|
||||
|
||||
export function useReviewAttachmentSnapshot(input: {
|
||||
key: string;
|
||||
diffFiles: readonly ParsedDiffFile[];
|
||||
cwd: string;
|
||||
mode: ReviewDraftMode;
|
||||
baseRef?: string | null;
|
||||
}): ReviewComposerAttachment | null {
|
||||
const comments = useReviewDraftComments(input.key);
|
||||
return useMemo(
|
||||
() =>
|
||||
buildReviewAttachmentSnapshot({
|
||||
reviewDraftKey: input.key,
|
||||
cwd: input.cwd,
|
||||
mode: input.mode,
|
||||
baseRef: input.baseRef,
|
||||
comments,
|
||||
diffFiles: input.diffFiles,
|
||||
}),
|
||||
[comments, input.key, input.cwd, input.mode, input.baseRef, input.diffFiles],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@/test/window-local-storage";
|
||||
import { act, fireEvent, render, renderHook, cleanup } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useReviewDraftStore, type ReviewDraftComment } from "./store";
|
||||
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||
import {
|
||||
getInlineReviewThreadState,
|
||||
getInlineReviewThreadViewportStyle,
|
||||
getSplitInlineReviewThreadState,
|
||||
groupInlineReviewCommentsByTarget,
|
||||
InlineReviewEditor,
|
||||
InlineReviewGutterCell,
|
||||
InlineReviewThread,
|
||||
SMALL_ACTION_HIT_SLOP,
|
||||
useInlineReviewController,
|
||||
type InlineReviewActions,
|
||||
} from "./index";
|
||||
|
||||
const { theme, pressablePropsByLabel } = vi.hoisted(() => {
|
||||
Object.assign(globalThis, { __DEV__: false });
|
||||
return {
|
||||
theme: {
|
||||
spacing: { 1: 4, 2: 8, 3: 12 },
|
||||
borderWidth: { 1: 1 },
|
||||
borderRadius: { base: 4 },
|
||||
fontSize: { xs: 11, sm: 13 },
|
||||
fontWeight: { medium: "500" },
|
||||
lineHeight: { diff: 18 },
|
||||
colors: {
|
||||
accent: "#0a84ff",
|
||||
accentForeground: "#fff",
|
||||
border: "#555",
|
||||
destructive: "#ff453a",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
surface1: "#111",
|
||||
surface2: "#222",
|
||||
surface3: "#333",
|
||||
},
|
||||
},
|
||||
pressablePropsByLabel: new Map<string, Record<string, unknown>>(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native", async (importOriginal) => {
|
||||
const ReactModule = await import("react");
|
||||
const actual = await importOriginal<typeof import("react-native")>();
|
||||
return {
|
||||
...actual,
|
||||
Pressable: ({
|
||||
accessibilityLabel,
|
||||
children,
|
||||
onPress,
|
||||
...props
|
||||
}: {
|
||||
accessibilityLabel?: string;
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((state: { hovered: boolean; pressed: boolean }) => React.ReactNode);
|
||||
onPress?: () => void;
|
||||
[key: string]: unknown;
|
||||
}) => {
|
||||
if (accessibilityLabel) {
|
||||
pressablePropsByLabel.set(accessibilityLabel, props);
|
||||
}
|
||||
const resolvedChildren =
|
||||
typeof children === "function" ? children({ hovered: false, pressed: false }) : children;
|
||||
return ReactModule.createElement(
|
||||
"button",
|
||||
{
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": typeof props.testID === "string" ? props.testID : undefined,
|
||||
disabled: props.disabled === true,
|
||||
onClick: onPress,
|
||||
type: "button",
|
||||
},
|
||||
resolvedChildren,
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
withUnistyles: <T,>(component: T) => component,
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isNative: false,
|
||||
isWeb: true,
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
return {
|
||||
Check: createIcon("Check"),
|
||||
CircleDot: createIcon("CircleDot"),
|
||||
MessageCircle: createIcon("MessageCircle"),
|
||||
Pencil: createIcon("Pencil"),
|
||||
Trash2: createIcon("Trash2"),
|
||||
X: createIcon("X"),
|
||||
};
|
||||
});
|
||||
|
||||
function target(overrides: Partial<ReviewableDiffTarget> = {}): ReviewableDiffTarget {
|
||||
return {
|
||||
filePath: "src/example.ts",
|
||||
hunkHeader: "@@ -1,2 +1,2 @@",
|
||||
hunkIndex: 0,
|
||||
lineIndex: 2,
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 2,
|
||||
side: "new",
|
||||
lineNumber: 2,
|
||||
lineType: "add",
|
||||
content: "const value = next;",
|
||||
...overrides,
|
||||
key: buildReviewableDiffTargetKey({
|
||||
filePath: overrides.filePath ?? "src/example.ts",
|
||||
side: overrides.side ?? "new",
|
||||
lineNumber: overrides.lineNumber ?? 2,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_COMMENTS: ReviewDraftComment[] = [];
|
||||
|
||||
function buildReviewActions(overrides: Partial<InlineReviewActions> = {}): InlineReviewActions {
|
||||
return {
|
||||
commentsByTarget: new Map(),
|
||||
editor: null,
|
||||
showPersistentAction: false,
|
||||
onStartComment: vi.fn(),
|
||||
onEditComment: vi.fn(),
|
||||
onCancelEditor: vi.fn(),
|
||||
onSaveEditor: vi.fn(),
|
||||
onDeleteComment: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function comment(overrides: Partial<ReviewDraftComment> = {}): ReviewDraftComment {
|
||||
return {
|
||||
id: "comment-1",
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 2,
|
||||
body: "Please simplify this.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useInlineReviewController", () => {
|
||||
beforeEach(() => {
|
||||
useReviewDraftStore.setState({ drafts: {}, activeModesByScope: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("owns draft comment add, edit, delete, cancel, and key-change editor reset", () => {
|
||||
const reviewTarget = target();
|
||||
const firstKey = "review:key-1";
|
||||
const secondKey = "review:key-2";
|
||||
const { result, rerender } = renderHook(
|
||||
({ reviewDraftKey }) =>
|
||||
useInlineReviewController({ reviewDraftKey, showPersistentAction: true }),
|
||||
{ initialProps: { reviewDraftKey: firstKey } },
|
||||
);
|
||||
|
||||
expect(result.current.showPersistentAction).toBe(true);
|
||||
|
||||
act(() => result.current.onStartComment(reviewTarget));
|
||||
expect(result.current.editor).toEqual({ target: reviewTarget, commentId: null, body: "" });
|
||||
|
||||
act(() => result.current.onSaveEditor(" first comment "));
|
||||
const savedComment = useReviewDraftStore.getState().drafts[firstKey]?.[0];
|
||||
expect(savedComment).toMatchObject({
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 2,
|
||||
body: "first comment",
|
||||
});
|
||||
expect(result.current.editor).toBeNull();
|
||||
expect(result.current.commentsByTarget.get(reviewTarget.key)?.[0]).toMatchObject({
|
||||
body: "first comment",
|
||||
});
|
||||
|
||||
act(() => result.current.onEditComment(reviewTarget, savedComment!));
|
||||
expect(result.current.editor).toEqual({
|
||||
target: reviewTarget,
|
||||
commentId: savedComment?.id,
|
||||
body: "first comment",
|
||||
});
|
||||
|
||||
act(() => result.current.onSaveEditor(" updated comment "));
|
||||
const updatedComment = useReviewDraftStore.getState().drafts[firstKey]?.[0];
|
||||
expect(updatedComment).toMatchObject({ id: savedComment?.id, body: "updated comment" });
|
||||
|
||||
act(() => result.current.onEditComment(reviewTarget, updatedComment!));
|
||||
act(() => result.current.onDeleteComment(updatedComment!.id));
|
||||
expect(useReviewDraftStore.getState().drafts[firstKey]).toEqual([]);
|
||||
expect(result.current.editor).toBeNull();
|
||||
|
||||
act(() => result.current.onStartComment(reviewTarget));
|
||||
act(() => result.current.onCancelEditor());
|
||||
expect(result.current.editor).toBeNull();
|
||||
|
||||
act(() => result.current.onStartComment(reviewTarget));
|
||||
rerender({ reviewDraftKey: secondKey });
|
||||
expect(result.current.editor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("git diff inline review helpers", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
pressablePropsByLabel.clear();
|
||||
});
|
||||
|
||||
it("maps persisted draft comments to their reviewable diff target", () => {
|
||||
const comments = [
|
||||
comment(),
|
||||
comment({ id: "comment-2", side: "old", lineNumber: 8 }),
|
||||
comment({ id: "comment-3", filePath: "src/other.ts" }),
|
||||
];
|
||||
const commentsByTarget = groupInlineReviewCommentsByTarget(comments);
|
||||
|
||||
expect(commentsByTarget.get("src/example.ts:new:2")).toEqual([comments[0]]);
|
||||
expect(commentsByTarget.get("src/example.ts:old:8")).toEqual([comments[1]]);
|
||||
expect(commentsByTarget.get("src/other.ts:new:2")).toEqual([comments[2]]);
|
||||
expect(
|
||||
getInlineReviewThreadState({
|
||||
reviewTarget: target(),
|
||||
reviewActions: {
|
||||
commentsByTarget,
|
||||
editor: null,
|
||||
showPersistentAction: false,
|
||||
onStartComment: vi.fn(),
|
||||
onEditComment: vi.fn(),
|
||||
onCancelEditor: vi.fn(),
|
||||
onSaveEditor: vi.fn(),
|
||||
onDeleteComment: vi.fn(),
|
||||
},
|
||||
})?.comments,
|
||||
).toEqual([comments[0]]);
|
||||
});
|
||||
|
||||
it("reserves split inline review height from the taller side", () => {
|
||||
const leftTarget = target({ side: "old", lineNumber: 8, oldLineNumber: 8 });
|
||||
const rightTarget = target();
|
||||
const rightComment = comment();
|
||||
const actions: InlineReviewActions = {
|
||||
commentsByTarget: groupInlineReviewCommentsByTarget([rightComment]),
|
||||
editor: { target: rightTarget, commentId: null, body: "" },
|
||||
showPersistentAction: false,
|
||||
onStartComment: vi.fn(),
|
||||
onEditComment: vi.fn(),
|
||||
onCancelEditor: vi.fn(),
|
||||
onSaveEditor: vi.fn(),
|
||||
onDeleteComment: vi.fn(),
|
||||
};
|
||||
|
||||
const rowState = getSplitInlineReviewThreadState({
|
||||
left: leftTarget,
|
||||
right: rightTarget,
|
||||
reviewActions: actions,
|
||||
});
|
||||
|
||||
expect(rowState?.left).toBeNull();
|
||||
expect(rowState?.right?.comments).toEqual([rightComment]);
|
||||
expect(rowState?.height).toBe(210);
|
||||
});
|
||||
|
||||
it("pins no-wrap review threads to the visible diff viewport", () => {
|
||||
expect(
|
||||
getInlineReviewThreadViewportStyle({
|
||||
viewportWidth: 320,
|
||||
pinToViewport: true,
|
||||
}),
|
||||
).toEqual([{ position: "sticky", left: 0 }, { width: 320 }]);
|
||||
});
|
||||
|
||||
it("keeps the gutter add-comment target accessible", () => {
|
||||
const onStartComment = vi.fn();
|
||||
const reviewTarget = target();
|
||||
const { getByLabelText } = render(
|
||||
<InlineReviewGutterCell
|
||||
reviewTarget={reviewTarget}
|
||||
comments={EMPTY_COMMENTS}
|
||||
isEditorOpen={false}
|
||||
showPersistentAction={false}
|
||||
onStartComment={onStartComment}
|
||||
>
|
||||
<span>2</span>
|
||||
</InlineReviewGutterCell>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByLabelText("Add review comment"));
|
||||
expect(onStartComment).toHaveBeenCalledWith(reviewTarget);
|
||||
expect(pressablePropsByLabel.get("Add review comment")?.hitSlop).toBe(SMALL_ACTION_HIT_SLOP);
|
||||
});
|
||||
|
||||
it("replaces the line number with the message icon in the same gutter cell", () => {
|
||||
const reviewTarget = target();
|
||||
const { container, queryByText } = render(
|
||||
<InlineReviewGutterCell
|
||||
reviewTarget={reviewTarget}
|
||||
comments={EMPTY_COMMENTS}
|
||||
isEditorOpen={false}
|
||||
showPersistentAction
|
||||
onStartComment={vi.fn()}
|
||||
>
|
||||
<span>2</span>
|
||||
</InlineReviewGutterCell>,
|
||||
);
|
||||
|
||||
expect(queryByText("2")).toBeNull();
|
||||
expect(container.querySelector("[data-icon='MessageCircle']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineReviewEditor", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("saves trimmed bodies and cancels without saving", () => {
|
||||
const onCancel = vi.fn();
|
||||
const onSave = vi.fn();
|
||||
const { getByTestId } = render(
|
||||
<InlineReviewEditor
|
||||
initialBody=" initial "
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
testID="editor"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(getByTestId("editor-input"), { target: { value: " updated comment " } });
|
||||
fireEvent.click(getByTestId("editor-save"));
|
||||
expect(onSave).toHaveBeenCalledWith("updated comment");
|
||||
|
||||
fireEvent.click(getByTestId("editor-cancel"));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineReviewThread", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("exposes edit and delete actions for existing comments", () => {
|
||||
const reviewTarget = target();
|
||||
const draftComment = comment();
|
||||
const actions = buildReviewActions({
|
||||
commentsByTarget: groupInlineReviewCommentsByTarget([draftComment]),
|
||||
});
|
||||
|
||||
const { getByTestId, getByText } = render(
|
||||
<InlineReviewThread
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={actions}
|
||||
height={76}
|
||||
testID="thread"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText("Please simplify this.")).toBeTruthy();
|
||||
fireEvent.click(getByTestId("review-comment-edit-comment-1"));
|
||||
expect(actions.onEditComment).toHaveBeenCalledWith(reviewTarget, draftComment);
|
||||
fireEvent.click(getByTestId("review-comment-delete-comment-1"));
|
||||
expect(actions.onDeleteComment).toHaveBeenCalledWith("comment-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,663 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MessageCircle, Pencil, Trash2 } from "lucide-react-native";
|
||||
import {
|
||||
Pressable,
|
||||
type PressableStateCallbackType,
|
||||
Text,
|
||||
TextInput,
|
||||
type TextStyle,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
|
||||
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||
|
||||
type PressableState = PressableStateCallbackType & { hovered?: boolean };
|
||||
|
||||
function iconButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
|
||||
return [styles.iconButton, (hovered || pressed) && styles.iconButtonHovered];
|
||||
}
|
||||
|
||||
function iconButtonDestructiveStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
|
||||
return [styles.iconButton, (hovered || pressed) && styles.iconButtonDestructiveHovered];
|
||||
}
|
||||
|
||||
function ghostButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
|
||||
return [styles.ghostButton, (hovered || pressed) && styles.ghostButtonHovered];
|
||||
}
|
||||
|
||||
export const INLINE_REVIEW_COMMENT_HEIGHT = 72;
|
||||
export const INLINE_REVIEW_EDITOR_HEIGHT = 132;
|
||||
const INLINE_REVIEW_GAP = 6;
|
||||
export const SMALL_ACTION_HIT_SLOP = 8;
|
||||
|
||||
export interface InlineReviewEditorState {
|
||||
target: ReviewableDiffTarget;
|
||||
commentId: string | null;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface InlineReviewActions {
|
||||
commentsByTarget: ReadonlyMap<string, ReviewDraftComment[]>;
|
||||
editor: InlineReviewEditorState | null;
|
||||
showPersistentAction: boolean;
|
||||
onStartComment: (target: ReviewableDiffTarget) => void;
|
||||
onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void;
|
||||
onCancelEditor: () => void;
|
||||
onSaveEditor: (body: string) => void;
|
||||
onDeleteComment: (id: string) => void;
|
||||
}
|
||||
|
||||
export function groupInlineReviewCommentsByTarget(
|
||||
comments: readonly ReviewDraftComment[],
|
||||
): Map<string, ReviewDraftComment[]> {
|
||||
const grouped = new Map<string, ReviewDraftComment[]>();
|
||||
for (const comment of comments) {
|
||||
const key = buildReviewableDiffTargetKey(comment);
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), comment]);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function useInlineReviewController(input: {
|
||||
reviewDraftKey: string;
|
||||
showPersistentAction: boolean;
|
||||
}): InlineReviewActions {
|
||||
const reviewComments = useReviewDraftComments(input.reviewDraftKey);
|
||||
const commentsByTarget = useMemo(
|
||||
() => groupInlineReviewCommentsByTarget(reviewComments),
|
||||
[reviewComments],
|
||||
);
|
||||
const [editor, setEditor] = useState<InlineReviewEditorState | null>(null);
|
||||
const addComment = useReviewDraftStore((state) => state.addComment);
|
||||
const updateComment = useReviewDraftStore((state) => state.updateComment);
|
||||
const deleteComment = useReviewDraftStore((state) => state.deleteComment);
|
||||
|
||||
useEffect(() => {
|
||||
setEditor(null);
|
||||
}, [input.reviewDraftKey]);
|
||||
|
||||
const handleStartComment = useCallback((target: ReviewableDiffTarget) => {
|
||||
setEditor({ target, commentId: null, body: "" });
|
||||
}, []);
|
||||
|
||||
const handleEditComment = useCallback(
|
||||
(target: ReviewableDiffTarget, comment: ReviewDraftComment) => {
|
||||
setEditor({ target, commentId: comment.id, body: comment.body });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCancelEditor = useCallback(() => {
|
||||
setEditor(null);
|
||||
}, []);
|
||||
|
||||
const handleSaveEditor = useCallback(
|
||||
(body: string) => {
|
||||
const trimmedBody = body.trim();
|
||||
if (!editor || trimmedBody.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor.commentId) {
|
||||
updateComment({
|
||||
key: input.reviewDraftKey,
|
||||
id: editor.commentId,
|
||||
updates: { body: trimmedBody },
|
||||
});
|
||||
} else {
|
||||
addComment({
|
||||
key: input.reviewDraftKey,
|
||||
comment: {
|
||||
filePath: editor.target.filePath,
|
||||
side: editor.target.side,
|
||||
lineNumber: editor.target.lineNumber,
|
||||
body: trimmedBody,
|
||||
},
|
||||
});
|
||||
}
|
||||
setEditor(null);
|
||||
},
|
||||
[addComment, editor, input.reviewDraftKey, updateComment],
|
||||
);
|
||||
|
||||
const handleDeleteComment = useCallback(
|
||||
(id: string) => {
|
||||
deleteComment({ key: input.reviewDraftKey, id });
|
||||
setEditor((current) => (current?.commentId === id ? null : current));
|
||||
},
|
||||
[deleteComment, input.reviewDraftKey],
|
||||
);
|
||||
|
||||
return useMemo<InlineReviewActions>(
|
||||
() => ({
|
||||
commentsByTarget,
|
||||
editor,
|
||||
showPersistentAction: input.showPersistentAction,
|
||||
onStartComment: handleStartComment,
|
||||
onEditComment: handleEditComment,
|
||||
onCancelEditor: handleCancelEditor,
|
||||
onSaveEditor: handleSaveEditor,
|
||||
onDeleteComment: handleDeleteComment,
|
||||
}),
|
||||
[
|
||||
commentsByTarget,
|
||||
editor,
|
||||
handleCancelEditor,
|
||||
handleDeleteComment,
|
||||
handleEditComment,
|
||||
handleSaveEditor,
|
||||
handleStartComment,
|
||||
input.showPersistentAction,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function isInlineReviewEditorForTarget(
|
||||
editor: InlineReviewEditorState | null,
|
||||
target: ReviewableDiffTarget | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
editor &&
|
||||
target &&
|
||||
buildReviewableDiffTargetKey(editor.target) === buildReviewableDiffTargetKey(target),
|
||||
);
|
||||
}
|
||||
|
||||
export function getInlineReviewThreadState(input: {
|
||||
reviewTarget: ReviewableDiffTarget | null | undefined;
|
||||
reviewActions?: InlineReviewActions;
|
||||
}): {
|
||||
comments: ReviewDraftComment[];
|
||||
hasEditor: boolean;
|
||||
editingCommentId: string | null;
|
||||
height: number;
|
||||
} | null {
|
||||
const { reviewTarget, reviewActions } = input;
|
||||
if (!reviewTarget || !reviewActions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const comments = reviewActions.commentsByTarget.get(reviewTarget.key) ?? [];
|
||||
const editorForTarget = isInlineReviewEditorForTarget(reviewActions.editor, reviewTarget)
|
||||
? reviewActions.editor
|
||||
: null;
|
||||
const hasEditor = editorForTarget !== null;
|
||||
const editingCommentId = editorForTarget?.commentId ?? null;
|
||||
const editingExisting =
|
||||
editingCommentId !== null && comments.some((comment) => comment.id === editingCommentId);
|
||||
|
||||
const visibleCommentCount = editingExisting ? comments.length - 1 : comments.length;
|
||||
const editorCount = hasEditor ? 1 : 0;
|
||||
const visibleBlockCount = visibleCommentCount + editorCount;
|
||||
if (visibleBlockCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const height =
|
||||
visibleCommentCount * INLINE_REVIEW_COMMENT_HEIGHT +
|
||||
editorCount * INLINE_REVIEW_EDITOR_HEIGHT +
|
||||
Math.max(0, visibleBlockCount - 1) * INLINE_REVIEW_GAP;
|
||||
|
||||
return { comments, hasEditor, editingCommentId, height };
|
||||
}
|
||||
|
||||
export function getSplitInlineReviewThreadState(input: {
|
||||
left: ReviewableDiffTarget | null | undefined;
|
||||
right: ReviewableDiffTarget | null | undefined;
|
||||
reviewActions?: InlineReviewActions;
|
||||
}): {
|
||||
left: ReturnType<typeof getInlineReviewThreadState>;
|
||||
right: ReturnType<typeof getInlineReviewThreadState>;
|
||||
height: number;
|
||||
} | null {
|
||||
const left = getInlineReviewThreadState({
|
||||
reviewTarget: input.left,
|
||||
reviewActions: input.reviewActions,
|
||||
});
|
||||
const right = getInlineReviewThreadState({
|
||||
reviewTarget: input.right,
|
||||
reviewActions: input.reviewActions,
|
||||
});
|
||||
const height = Math.max(left?.height ?? 0, right?.height ?? 0);
|
||||
if (height === 0) {
|
||||
return null;
|
||||
}
|
||||
return { left, right, height };
|
||||
}
|
||||
|
||||
export function InlineReviewGutterCell({
|
||||
children,
|
||||
reviewTarget,
|
||||
comments,
|
||||
isEditorOpen,
|
||||
showPersistentAction,
|
||||
onStartComment,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
reviewTarget: ReviewableDiffTarget | null | undefined;
|
||||
comments: readonly ReviewDraftComment[];
|
||||
isEditorOpen: boolean;
|
||||
showPersistentAction: boolean;
|
||||
onStartComment: (target: ReviewableDiffTarget) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const canComment = Boolean(reviewTarget);
|
||||
const hasComments = comments.length > 0;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (reviewTarget) {
|
||||
onStartComment(reviewTarget);
|
||||
}
|
||||
}, [reviewTarget, onStartComment]);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableState): StyleProp<ViewStyle> => [
|
||||
style,
|
||||
canComment && (hovered || pressed) && styles.gutterHovered,
|
||||
],
|
||||
[style, canComment],
|
||||
);
|
||||
|
||||
const labelStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.gutterLabel, hasComments && styles.gutterLabelActive],
|
||||
[hasComments],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole={canComment ? "button" : undefined}
|
||||
accessibilityLabel={canComment ? "Add review comment" : undefined}
|
||||
hitSlop={canComment ? SMALL_ACTION_HIT_SLOP : undefined}
|
||||
disabled={!canComment}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
>
|
||||
{({ hovered, pressed }) => {
|
||||
const showAction =
|
||||
canComment &&
|
||||
(hovered || pressed || isNative || showPersistentAction || hasComments || isEditorOpen);
|
||||
return (
|
||||
<View style={styles.gutterInner}>
|
||||
<View style={labelStyle}>
|
||||
{showAction ? (
|
||||
<MessageCircle
|
||||
size={13}
|
||||
strokeWidth={hasComments ? 2.25 : 1.75}
|
||||
color={hasComments ? theme.colors.accent : theme.colors.foregroundMuted}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineReviewThread({
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
height,
|
||||
viewportWidth,
|
||||
pinToViewport = false,
|
||||
testID,
|
||||
}: {
|
||||
reviewTarget: ReviewableDiffTarget;
|
||||
reviewActions: InlineReviewActions;
|
||||
height: number;
|
||||
viewportWidth?: number;
|
||||
pinToViewport?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
const comments = reviewActions.commentsByTarget.get(reviewTarget.key) ?? [];
|
||||
const editor = isInlineReviewEditorForTarget(reviewActions.editor, reviewTarget)
|
||||
? reviewActions.editor
|
||||
: null;
|
||||
const editingCommentId = editor?.commentId ?? null;
|
||||
const editingExisting =
|
||||
editingCommentId !== null && comments.some((comment) => comment.id === editingCommentId);
|
||||
|
||||
const editorElement = editor ? (
|
||||
<InlineReviewEditor
|
||||
key={editingCommentId ?? "new"}
|
||||
initialBody={editor.body}
|
||||
onCancel={reviewActions.onCancelEditor}
|
||||
onSave={reviewActions.onSaveEditor}
|
||||
testID="inline-review-editor"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
styles.threadContainer,
|
||||
getInlineReviewThreadViewportStyle({ viewportWidth, pinToViewport }),
|
||||
{ minHeight: height },
|
||||
],
|
||||
[viewportWidth, pinToViewport, height],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle} testID={testID}>
|
||||
{comments.map((comment) => {
|
||||
if (comment.id === editingCommentId) {
|
||||
return <React.Fragment key={comment.id}>{editorElement}</React.Fragment>;
|
||||
}
|
||||
return (
|
||||
<CommentRow
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
reviewTarget={reviewTarget}
|
||||
onEditComment={reviewActions.onEditComment}
|
||||
onDeleteComment={reviewActions.onDeleteComment}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{editor && !editingExisting ? editorElement : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentRow({
|
||||
comment,
|
||||
reviewTarget,
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
}: {
|
||||
comment: ReviewDraftComment;
|
||||
reviewTarget: ReviewableDiffTarget;
|
||||
onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void;
|
||||
onDeleteComment: (id: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const handleEdit = useCallback(
|
||||
() => onEditComment(reviewTarget, comment),
|
||||
[onEditComment, reviewTarget, comment],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
() => onDeleteComment(comment.id),
|
||||
[onDeleteComment, comment.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.commentBlock}>
|
||||
<Text style={styles.commentBody} numberOfLines={2}>
|
||||
{comment.body}
|
||||
</Text>
|
||||
<View style={styles.commentActions}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Edit review comment"
|
||||
testID={`review-comment-edit-${comment.id}`}
|
||||
hitSlop={SMALL_ACTION_HIT_SLOP}
|
||||
onPress={handleEdit}
|
||||
style={iconButtonStyle}
|
||||
>
|
||||
<Pencil size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Delete review comment"
|
||||
testID={`review-comment-delete-${comment.id}`}
|
||||
hitSlop={SMALL_ACTION_HIT_SLOP}
|
||||
onPress={handleDelete}
|
||||
style={iconButtonDestructiveStyle}
|
||||
>
|
||||
<Trash2 size={14} color={theme.colors.destructive} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function getInlineReviewThreadViewportStyle({
|
||||
viewportWidth,
|
||||
pinToViewport,
|
||||
}: {
|
||||
viewportWidth?: number;
|
||||
pinToViewport: boolean;
|
||||
}): StyleProp<ViewStyle> {
|
||||
const widthStyle = viewportWidth && viewportWidth > 0 ? { width: viewportWidth } : null;
|
||||
if (!pinToViewport || !isWeb) {
|
||||
return widthStyle;
|
||||
}
|
||||
const stickyStyle = { position: "sticky", left: 0 } as unknown as ViewStyle;
|
||||
return [stickyStyle, widthStyle];
|
||||
}
|
||||
|
||||
export function InlineReviewEditor({
|
||||
initialBody,
|
||||
onCancel,
|
||||
onSave,
|
||||
testID,
|
||||
}: {
|
||||
initialBody: string;
|
||||
onCancel: () => void;
|
||||
onSave: (body: string) => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
const [body, setBody] = useState(initialBody);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const trimmedBody = body.trim();
|
||||
const canSave = trimmedBody.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleFocus = useCallback(() => setIsFocused(true), []);
|
||||
const handleBlur = useCallback(() => setIsFocused(false), []);
|
||||
const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]);
|
||||
|
||||
const inputStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [styles.editorInput, isFocused && styles.editorInputFocused],
|
||||
[isFocused],
|
||||
);
|
||||
|
||||
const saveButtonStyle = useCallback(
|
||||
({ hovered, pressed }: PressableState): StyleProp<ViewStyle> => [
|
||||
styles.saveButton,
|
||||
!canSave && styles.saveButtonDisabled,
|
||||
canSave && (hovered || pressed) && styles.saveButtonHovered,
|
||||
],
|
||||
[canSave],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.editorBlock} testID={testID}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
accessibilityLabel="Review comment"
|
||||
testID={testID ? `${testID}-input` : undefined}
|
||||
placeholder="Leave a comment"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
multiline
|
||||
value={body}
|
||||
onChangeText={setBody}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<View style={styles.editorActions}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel review comment"
|
||||
testID={testID ? `${testID}-cancel` : undefined}
|
||||
hitSlop={SMALL_ACTION_HIT_SLOP}
|
||||
onPress={onCancel}
|
||||
style={ghostButtonStyle}
|
||||
>
|
||||
<Text style={styles.ghostButtonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Save review comment"
|
||||
testID={testID ? `${testID}-save` : undefined}
|
||||
hitSlop={SMALL_ACTION_HIT_SLOP}
|
||||
disabled={!canSave}
|
||||
onPress={handleSave}
|
||||
style={saveButtonStyle}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>Save</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
gutterHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
gutterInner: {
|
||||
minHeight: theme.lineHeight.diff,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
gutterLabel: {
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
height: theme.lineHeight.diff,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
gutterLabelActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
threadContainer: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: INLINE_REVIEW_GAP,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
commentBlock: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
commentBody: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
commentActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconButton: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
...(isWeb
|
||||
? {
|
||||
transitionProperty: "background-color",
|
||||
transitionDuration: "120ms",
|
||||
transitionTimingFunction: "ease-in-out",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
iconButtonDestructiveHovered: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
editorBlock: {
|
||||
minHeight: INLINE_REVIEW_EDITOR_HEIGHT,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
editorInput: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
color: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
textAlignVertical: "top",
|
||||
...(isWeb
|
||||
? {
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
editorInputFocused: {
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
editorActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
ghostButton: {
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
ghostButtonHovered: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
ghostButtonText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
saveButton: {
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
saveButtonHovered: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
saveButtonText: {
|
||||
color: theme.colors.accentForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
}));
|
||||
@@ -26,7 +26,7 @@ import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
||||
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
@@ -195,10 +195,10 @@ function pickerItemTriggerLabel(item: PickerItem): string {
|
||||
}
|
||||
|
||||
function syncPickerPrAttachment(input: {
|
||||
attachments: ComposerAttachment[];
|
||||
attachments: UserComposerAttachment[];
|
||||
previousPickerPrNumber: number | null;
|
||||
item: PickerItem;
|
||||
}): { attachments: ComposerAttachment[]; attachedPrNumber: number | null } {
|
||||
}): { attachments: UserComposerAttachment[]; attachedPrNumber: number | null } {
|
||||
let nextAttachments = input.attachments;
|
||||
let attachedPrNumber: number | null = null;
|
||||
|
||||
@@ -373,7 +373,9 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
}),
|
||||
draft: {
|
||||
text,
|
||||
attachments,
|
||||
attachments: attachments.filter(
|
||||
(attachment): attachment is UserComposerAttachment => attachment.kind !== "review",
|
||||
),
|
||||
cwd: workspaceDirectory,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,12 +15,16 @@ import { WorkspaceDraftAgentTab } from "./workspace-draft-agent-tab";
|
||||
|
||||
const {
|
||||
createAgentMock,
|
||||
getCheckoutStatusMock,
|
||||
onRuntimeEventMock,
|
||||
latestComposerText,
|
||||
latestComposerDisabled,
|
||||
latestStreamText,
|
||||
onCreatedMock,
|
||||
} = vi.hoisted(() => ({
|
||||
createAgentMock: vi.fn(),
|
||||
getCheckoutStatusMock: vi.fn(),
|
||||
onRuntimeEventMock: vi.fn(() => () => {}),
|
||||
latestComposerText: { current: null as string | null },
|
||||
latestComposerDisabled: { current: null as boolean | null },
|
||||
latestStreamText: { current: null as string | null },
|
||||
@@ -43,6 +47,7 @@ vi.mock("react-native-unistyles", () => ({
|
||||
})
|
||||
: factory,
|
||||
},
|
||||
useUnistyles: () => ({ rt: { breakpoint: "lg" } }),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
@@ -57,6 +62,8 @@ vi.mock("@/constants/platform", () => ({
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: () => ({
|
||||
createAgent: createAgentMock,
|
||||
getCheckoutStatus: getCheckoutStatusMock,
|
||||
on: onRuntimeEventMock,
|
||||
}),
|
||||
useHostRuntimeIsConnected: () => true,
|
||||
}));
|
||||
@@ -131,6 +138,16 @@ vi.mock("@/components/composer-attachments", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/attachments/composer-workspace-attachments", () => ({
|
||||
composerWorkspaceAttachment: {
|
||||
userAttachmentsOnly: (attachments: ComposerAttachment[]) =>
|
||||
attachments.filter(
|
||||
(attachment): attachment is Exclude<ComposerAttachment, { kind: "review" }> =>
|
||||
attachment.kind !== "review",
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/composer", () => ({
|
||||
Composer: ({
|
||||
value,
|
||||
@@ -196,6 +213,23 @@ beforeEach(() => {
|
||||
root = createRoot(container);
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
createAgentMock.mockReset();
|
||||
getCheckoutStatusMock.mockResolvedValue({
|
||||
cwd: "/repo/.paseo/worktrees/workspace-1",
|
||||
error: null,
|
||||
requestId: "checkout-status-1",
|
||||
isGit: false,
|
||||
isPaseoOwnedWorktree: true,
|
||||
repoRoot: null,
|
||||
currentBranch: null,
|
||||
isDirty: false,
|
||||
baseRef: null,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
hasRemote: false,
|
||||
remoteUrl: null,
|
||||
});
|
||||
onRuntimeEventMock.mockClear();
|
||||
onCreatedMock.mockReset();
|
||||
latestComposerText.current = null;
|
||||
latestComposerDisabled.current = null;
|
||||
|
||||
@@ -6,12 +6,14 @@ import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
@@ -20,8 +22,13 @@ import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/works
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import type { ComposerAttachment, WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
useWorkspaceAttachments,
|
||||
useWorkspaceAttachmentScopeKey,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
@@ -324,6 +331,33 @@ export function WorkspaceDraftAgentTab({
|
||||
);
|
||||
const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit);
|
||||
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
serverId,
|
||||
cwd: draftInput.cwd,
|
||||
workspaceId,
|
||||
});
|
||||
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleOpenWorkspaceAttachment = useCallback(
|
||||
(attachment: WorkspaceComposerAttachment) => {
|
||||
const checkout = {
|
||||
serverId,
|
||||
cwd: attachment.attachment.cwd,
|
||||
isGit: true,
|
||||
};
|
||||
openFileExplorerForCheckout({
|
||||
checkout,
|
||||
isCompact,
|
||||
});
|
||||
setExplorerTabForCheckout({
|
||||
...checkout,
|
||||
tab: "changes",
|
||||
});
|
||||
},
|
||||
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
);
|
||||
|
||||
const {
|
||||
formErrorMessage,
|
||||
@@ -408,7 +442,7 @@ export function WorkspaceDraftAgentTab({
|
||||
cwd: submission.cwd,
|
||||
}).catch(() => {
|
||||
setDraftText(submission.text);
|
||||
setDraftAttachments(submission.attachments);
|
||||
setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments));
|
||||
autoSubmitKeyRef.current = null;
|
||||
});
|
||||
}, [
|
||||
@@ -562,6 +596,8 @@ export function WorkspaceDraftAgentTab({
|
||||
value={draftInput.text}
|
||||
onChangeText={draftInput.setText}
|
||||
attachments={draftInput.attachments}
|
||||
workspaceAttachments={workspaceAttachments}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={draftInput.cwd}
|
||||
clearDraft={draftInput.clear}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
|
||||
export type CreateFlowLifecycleState = "active" | "abandoned" | "sent";
|
||||
|
||||
@@ -12,6 +13,7 @@ interface PendingCreateAttempt {
|
||||
timestamp: number;
|
||||
lifecycle: CreateFlowLifecycleState;
|
||||
images?: UserMessageImageAttachment[];
|
||||
attachments?: AgentAttachment[];
|
||||
}
|
||||
|
||||
interface CreateFlowState {
|
||||
|
||||
@@ -1,8 +1,53 @@
|
||||
import "@/test/window-local-storage";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __draftStoreTestUtils } from "./draft-store";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import { __draftStoreTestUtils, useDraftStore } from "./draft-store";
|
||||
|
||||
function workspaceReviewAttachment(): Extract<ComposerAttachment, { kind: "review" }> {
|
||||
return {
|
||||
kind: "review",
|
||||
reviewDraftKey: "review:key",
|
||||
commentCount: 1,
|
||||
attachment: {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/repo",
|
||||
mode: "uncommitted",
|
||||
baseRef: null,
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body: "Please simplify this.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,1 +40,1 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("draft-store migration", () => {
|
||||
beforeEach(() => {
|
||||
useDraftStore.setState({ drafts: {}, createModalDraft: null });
|
||||
});
|
||||
|
||||
it("normalizes legacy image metadata into image attachments and strips persisted preview URLs", async () => {
|
||||
const migrated = await __draftStoreTestUtils.migratePersistedState({
|
||||
drafts: {
|
||||
@@ -79,4 +124,24 @@ describe("draft-store migration", () => {
|
||||
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
|
||||
it("rejects workspace review attachments from migrated draft attachments", async () => {
|
||||
const migrated = await __draftStoreTestUtils.migratePersistedState({
|
||||
drafts: {
|
||||
"agent:server:agent": {
|
||||
input: {
|
||||
text: "hello",
|
||||
attachments: [workspaceReviewAttachment()],
|
||||
cwd: "/repo",
|
||||
},
|
||||
lifecycle: "active",
|
||||
updatedAt: 1700000000001,
|
||||
version: 2,
|
||||
},
|
||||
},
|
||||
createModalDraft: null,
|
||||
});
|
||||
|
||||
expect(migrated.drafts["agent:server:agent"]?.input.attachments).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||
import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types";
|
||||
import { GitHubSearchItemSchema } from "@server/shared/messages";
|
||||
import {
|
||||
garbageCollectAttachments,
|
||||
@@ -23,7 +23,7 @@ type PersistedDraftImage = AttachmentMetadata | LegacyDraftImage;
|
||||
|
||||
export interface DraftInput {
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
attachments: UserComposerAttachment[];
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ function normalizePersistedImage(value: unknown): PersistedDraftImage | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isComposerAttachment(value: unknown): value is ComposerAttachment {
|
||||
function isUserComposerAttachment(value: unknown): value is UserComposerAttachment {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
@@ -150,7 +150,7 @@ function isComposerAttachment(value: unknown): value is ComposerAttachment {
|
||||
return GitHubSearchItemSchema.safeParse(record.item).success;
|
||||
}
|
||||
|
||||
function normalizeComposerAttachment(attachment: ComposerAttachment): ComposerAttachment {
|
||||
function normalizeComposerAttachment(attachment: UserComposerAttachment): UserComposerAttachment {
|
||||
if (attachment.kind === "image") {
|
||||
return {
|
||||
kind: "image",
|
||||
@@ -160,14 +160,16 @@ function normalizeComposerAttachment(attachment: ComposerAttachment): ComposerAt
|
||||
return attachment;
|
||||
}
|
||||
|
||||
function normalizePersistedComposerAttachment(value: unknown): ComposerAttachment | null {
|
||||
if (!isComposerAttachment(value)) {
|
||||
function normalizePersistedComposerAttachment(value: unknown): UserComposerAttachment | null {
|
||||
if (!isUserComposerAttachment(value)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeComposerAttachment(value);
|
||||
}
|
||||
|
||||
function legacyImagesToAttachments(images: readonly AttachmentMetadata[]): ComposerAttachment[] {
|
||||
function legacyImagesToAttachments(
|
||||
images: readonly AttachmentMetadata[],
|
||||
): UserComposerAttachment[] {
|
||||
return images.map((metadata) => ({
|
||||
kind: "image",
|
||||
metadata,
|
||||
@@ -182,7 +184,7 @@ function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput {
|
||||
return (
|
||||
typeof input.text === "string" &&
|
||||
Array.isArray(input.attachments) &&
|
||||
input.attachments.every(isComposerAttachment) &&
|
||||
input.attachments.every(isUserComposerAttachment) &&
|
||||
typeof input.cwd === "string"
|
||||
);
|
||||
}
|
||||
@@ -426,7 +428,7 @@ async function migrateDraftInput(input: {
|
||||
const attachments = Array.isArray(rawInput.attachments)
|
||||
? rawInput.attachments
|
||||
.map((entry) => normalizePersistedComposerAttachment(entry))
|
||||
.filter((entry): entry is ComposerAttachment => entry !== null)
|
||||
.filter((entry): entry is UserComposerAttachment => entry !== null)
|
||||
: [];
|
||||
const legacyImages = Array.isArray(rawInput.images)
|
||||
? rawInput.images
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentProvider, ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import type { AgentAttachment, AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
@@ -60,6 +60,7 @@ export interface UserMessageItem {
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
images?: UserMessageImageAttachment[];
|
||||
attachments?: AgentAttachment[];
|
||||
}
|
||||
|
||||
export interface AssistantMessageItem {
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSplitDiffRows, buildUnifiedDiffLines } from "./diff-layout";
|
||||
import {
|
||||
buildReviewableDiffTargetKey,
|
||||
buildSplitDiffRows,
|
||||
buildUnifiedDiffLines,
|
||||
} from "./diff-layout";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
import type { ReviewableDiffTarget } from "./diff-layout";
|
||||
|
||||
function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile {
|
||||
function makeFile(
|
||||
lines: ParsedDiffFile["hunks"][number]["lines"],
|
||||
options: { oldStart?: number; newStart?: number } = {},
|
||||
): ParsedDiffFile {
|
||||
const oldStart = options.oldStart ?? 10;
|
||||
const newStart = options.newStart ?? 10;
|
||||
return {
|
||||
path: "example.ts",
|
||||
isNew: false,
|
||||
@@ -12,9 +22,9 @@ function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFi
|
||||
status: "ok",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 10,
|
||||
oldStart,
|
||||
oldCount: 4,
|
||||
newStart: 10,
|
||||
newStart,
|
||||
newCount: 5,
|
||||
lines,
|
||||
},
|
||||
@@ -22,7 +32,39 @@ function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFi
|
||||
};
|
||||
}
|
||||
|
||||
function expectReviewTarget(
|
||||
target: ReviewableDiffTarget | null | undefined,
|
||||
expected: Omit<ReviewableDiffTarget, "key" | "filePath">,
|
||||
) {
|
||||
expect(target).toMatchObject({
|
||||
filePath: "example.ts",
|
||||
...expected,
|
||||
});
|
||||
expect(target?.key).toBe(
|
||||
buildReviewableDiffTargetKey({
|
||||
filePath: "example.ts",
|
||||
side: expected.side,
|
||||
lineNumber: expected.lineNumber,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("buildSplitDiffRows", () => {
|
||||
it("uses one canonical persisted key for rendered review targets", () => {
|
||||
const rows = buildSplitDiffRows(
|
||||
makeFile([
|
||||
{ type: "header", content: "@@ -10,1 +20,1 @@" },
|
||||
{ type: "context", content: "same line" },
|
||||
]),
|
||||
);
|
||||
|
||||
if (rows[1]?.kind !== "pair") {
|
||||
throw new Error("Expected split pair row");
|
||||
}
|
||||
expect(rows[1].left?.reviewTarget?.key).toBe("example.ts:old:10");
|
||||
expect(rows[1].right?.reviewTarget?.key).toBe("example.ts:new:10");
|
||||
});
|
||||
|
||||
it("pairs replacement runs by index", () => {
|
||||
const rows = buildSplitDiffRows(
|
||||
makeFile([
|
||||
@@ -78,6 +120,86 @@ describe("buildSplitDiffRows", () => {
|
||||
right: { type: "context", content: "same line", lineNumber: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it("emits old and new review targets for split context cells", () => {
|
||||
const rows = buildSplitDiffRows(
|
||||
makeFile(
|
||||
[
|
||||
{ type: "header", content: "@@ -10,1 +20,1 @@" },
|
||||
{ type: "context", content: "same line" },
|
||||
],
|
||||
{ newStart: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
expect(rows[0]).toEqual({
|
||||
kind: "header",
|
||||
content: "@@ -10,1 +20,1 @@",
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: { type: "context", content: "same line", lineNumber: 10 },
|
||||
right: { type: "context", content: "same line", lineNumber: 20 },
|
||||
});
|
||||
if (rows[1]?.kind !== "pair") {
|
||||
throw new Error("Expected split pair row");
|
||||
}
|
||||
expectReviewTarget(rows[1].left?.reviewTarget, {
|
||||
hunkHeader: "@@ -10,1 +20,1 @@",
|
||||
hunkIndex: 0,
|
||||
lineIndex: 1,
|
||||
oldLineNumber: 10,
|
||||
newLineNumber: 20,
|
||||
side: "old",
|
||||
lineNumber: 10,
|
||||
lineType: "context",
|
||||
content: "same line",
|
||||
});
|
||||
expectReviewTarget(rows[1].right?.reviewTarget, {
|
||||
hunkHeader: "@@ -10,1 +20,1 @@",
|
||||
hunkIndex: 0,
|
||||
lineIndex: 1,
|
||||
oldLineNumber: 10,
|
||||
newLineNumber: 20,
|
||||
side: "new",
|
||||
lineNumber: 20,
|
||||
lineType: "context",
|
||||
content: "same line",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit review targets for split headers or empty cells", () => {
|
||||
const rows = buildSplitDiffRows(
|
||||
makeFile([
|
||||
{ type: "header", content: "@@ -10,1 +10,2 @@" },
|
||||
{ type: "add", content: "after" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows[0]).toEqual({
|
||||
kind: "header",
|
||||
content: "@@ -10,1 +10,2 @@",
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: null,
|
||||
right: { type: "add", content: "after", lineNumber: 10 },
|
||||
});
|
||||
if (rows[1]?.kind !== "pair") {
|
||||
throw new Error("Expected split pair row");
|
||||
}
|
||||
expectReviewTarget(rows[1].right?.reviewTarget, {
|
||||
hunkHeader: "@@ -10,1 +10,2 @@",
|
||||
hunkIndex: 0,
|
||||
lineIndex: 1,
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 10,
|
||||
side: "new",
|
||||
lineNumber: 10,
|
||||
lineType: "add",
|
||||
content: "after",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUnifiedDiffLines", () => {
|
||||
@@ -151,4 +273,53 @@ describe("buildUnifiedDiffLines", () => {
|
||||
expect(lines[5]?.lineNumber).toBe(166);
|
||||
expect(lines[6]?.lineNumber).toBe(167);
|
||||
});
|
||||
|
||||
it("emits canonical review targets for unified add, remove, and context lines", () => {
|
||||
const lines = buildUnifiedDiffLines(
|
||||
makeFile(
|
||||
[
|
||||
{ type: "header", content: "@@ -10,2 +20,2 @@" },
|
||||
{ type: "context", content: "before" },
|
||||
{ type: "remove", content: "removed" },
|
||||
{ type: "add", content: "inserted" },
|
||||
],
|
||||
{ newStart: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
expect(lines[0]?.reviewTarget).toBeNull();
|
||||
expectReviewTarget(lines[1]?.reviewTarget, {
|
||||
hunkHeader: "@@ -10,2 +20,2 @@",
|
||||
hunkIndex: 0,
|
||||
lineIndex: 1,
|
||||
oldLineNumber: 10,
|
||||
newLineNumber: 20,
|
||||
side: "new",
|
||||
lineNumber: 20,
|
||||
lineType: "context",
|
||||
content: "before",
|
||||
});
|
||||
expectReviewTarget(lines[2]?.reviewTarget, {
|
||||
hunkHeader: "@@ -10,2 +20,2 @@",
|
||||
hunkIndex: 0,
|
||||
lineIndex: 2,
|
||||
oldLineNumber: 11,
|
||||
newLineNumber: null,
|
||||
side: "old",
|
||||
lineNumber: 11,
|
||||
lineType: "remove",
|
||||
content: "removed",
|
||||
});
|
||||
expectReviewTarget(lines[3]?.reviewTarget, {
|
||||
hunkHeader: "@@ -10,2 +20,2 @@",
|
||||
hunkIndex: 0,
|
||||
lineIndex: 3,
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 21,
|
||||
side: "new",
|
||||
lineNumber: 21,
|
||||
lineType: "add",
|
||||
content: "inserted",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,69 @@
|
||||
import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
|
||||
type ReviewSide = "old" | "new";
|
||||
type ReviewableLineType = "add" | "remove" | "context";
|
||||
|
||||
export interface ReviewableDiffTargetKeyInput {
|
||||
filePath: string;
|
||||
side: ReviewSide;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
export interface ReviewableDiffTarget {
|
||||
key: string;
|
||||
filePath: string;
|
||||
hunkHeader: string;
|
||||
hunkIndex: number;
|
||||
lineIndex: number;
|
||||
oldLineNumber: number | null;
|
||||
newLineNumber: number | null;
|
||||
side: ReviewSide;
|
||||
lineNumber: number;
|
||||
lineType: ReviewableLineType;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function buildReviewableDiffTargetKey(input: ReviewableDiffTargetKeyInput): string {
|
||||
return `${input.filePath}:${input.side}:${input.lineNumber}`;
|
||||
}
|
||||
|
||||
export interface NumberedDiffCell extends ReviewableDiffTarget {
|
||||
line: DiffLine;
|
||||
}
|
||||
|
||||
export interface NumberedDiffLine {
|
||||
key: string;
|
||||
filePath: string;
|
||||
hunkHeader: string;
|
||||
hunkIndex: number;
|
||||
lineIndex: number;
|
||||
line: DiffLine;
|
||||
oldLineNumber: number | null;
|
||||
newLineNumber: number | null;
|
||||
unifiedCell: NumberedDiffCell | null;
|
||||
oldCell: NumberedDiffCell | null;
|
||||
newCell: NumberedDiffCell | null;
|
||||
}
|
||||
|
||||
export interface NumberedDiffHunk {
|
||||
hunkIndex: number;
|
||||
hunkHeader: string;
|
||||
lines: NumberedDiffLine[];
|
||||
}
|
||||
|
||||
export interface SplitDiffDisplayLine {
|
||||
type: DiffLine["type"];
|
||||
content: string;
|
||||
tokens?: DiffLine["tokens"];
|
||||
lineNumber: number | null;
|
||||
reviewTarget: ReviewableDiffTarget | null;
|
||||
}
|
||||
|
||||
export interface UnifiedDiffDisplayLine {
|
||||
key: string;
|
||||
line: DiffLine;
|
||||
lineNumber: number | null;
|
||||
reviewTarget: ReviewableDiffTarget | null;
|
||||
}
|
||||
|
||||
export type SplitDiffRow =
|
||||
@@ -24,95 +77,175 @@ export type SplitDiffRow =
|
||||
right: SplitDiffDisplayLine | null;
|
||||
};
|
||||
|
||||
function toDisplayLine(input: {
|
||||
line: DiffLine;
|
||||
oldLineNumber: number | null;
|
||||
newLineNumber: number | null;
|
||||
side: "left" | "right";
|
||||
}): SplitDiffDisplayLine | null {
|
||||
const { line, oldLineNumber, newLineNumber, side } = input;
|
||||
if (line.type === "header") {
|
||||
function toSplitDisplayLine(cell: NumberedDiffCell | null): SplitDiffDisplayLine | null {
|
||||
if (!cell) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.type === "remove") {
|
||||
if (side !== "left") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "remove",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: oldLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
if (line.type === "add") {
|
||||
if (side !== "right") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "add",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: newLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "context",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: side === "left" ? oldLineNumber : newLineNumber,
|
||||
type: cell.lineType,
|
||||
content: cell.content,
|
||||
...(cell.line.tokens ? { tokens: cell.line.tokens } : {}),
|
||||
lineNumber: cell.lineNumber,
|
||||
reviewTarget: toReviewTarget(cell),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUnifiedDiffLines(file: ParsedDiffFile): UnifiedDiffDisplayLine[] {
|
||||
const lines: UnifiedDiffDisplayLine[] = [];
|
||||
function toReviewTarget(cell: NumberedDiffCell): ReviewableDiffTarget {
|
||||
return {
|
||||
key: cell.key,
|
||||
filePath: cell.filePath,
|
||||
hunkHeader: cell.hunkHeader,
|
||||
hunkIndex: cell.hunkIndex,
|
||||
lineIndex: cell.lineIndex,
|
||||
oldLineNumber: cell.oldLineNumber,
|
||||
newLineNumber: cell.newLineNumber,
|
||||
side: cell.side,
|
||||
lineNumber: cell.lineNumber,
|
||||
lineType: cell.lineType,
|
||||
content: cell.content,
|
||||
};
|
||||
}
|
||||
|
||||
function getHunkHeader(hunk: ParsedDiffFile["hunks"][number]): string {
|
||||
const headerLine = hunk.lines.find((line) => line.type === "header");
|
||||
return headerLine?.content ?? "@@";
|
||||
}
|
||||
|
||||
export function buildNumberedDiffHunks(file: ParsedDiffFile): NumberedDiffHunk[] {
|
||||
const numberedHunks: NumberedDiffHunk[] = [];
|
||||
for (const [hunkIndex, hunk] of file.hunks.entries()) {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
const hunkHeader = getHunkHeader(hunk);
|
||||
const lines: NumberedDiffLine[] = [];
|
||||
|
||||
for (const [lineIndex, line] of hunk.lines.entries()) {
|
||||
let lineNumber: number | null = null;
|
||||
let oldLineNumber: number | null = null;
|
||||
let newLineNumber: number | null = null;
|
||||
|
||||
if (line.type === "remove") {
|
||||
lineNumber = oldLineNo;
|
||||
oldLineNumber = oldLineNo;
|
||||
oldLineNo += 1;
|
||||
} else if (line.type === "add") {
|
||||
lineNumber = newLineNo;
|
||||
newLineNumber = newLineNo;
|
||||
newLineNo += 1;
|
||||
} else if (line.type === "context") {
|
||||
lineNumber = newLineNo;
|
||||
oldLineNumber = oldLineNo;
|
||||
newLineNumber = newLineNo;
|
||||
oldLineNo += 1;
|
||||
newLineNo += 1;
|
||||
}
|
||||
|
||||
const oldCell = buildNumberedCell({
|
||||
filePath: file.path,
|
||||
hunkHeader,
|
||||
hunkIndex,
|
||||
lineIndex,
|
||||
line,
|
||||
oldLineNumber,
|
||||
newLineNumber,
|
||||
side: "old",
|
||||
});
|
||||
const newCell = buildNumberedCell({
|
||||
filePath: file.path,
|
||||
hunkHeader,
|
||||
hunkIndex,
|
||||
lineIndex,
|
||||
line,
|
||||
oldLineNumber,
|
||||
newLineNumber,
|
||||
side: "new",
|
||||
});
|
||||
|
||||
lines.push({
|
||||
key: `${hunkIndex}-${lineIndex}`,
|
||||
filePath: file.path,
|
||||
hunkHeader,
|
||||
hunkIndex,
|
||||
lineIndex,
|
||||
line,
|
||||
lineNumber,
|
||||
oldLineNumber,
|
||||
newLineNumber,
|
||||
unifiedCell: line.type === "remove" ? oldCell : newCell,
|
||||
oldCell,
|
||||
newCell,
|
||||
});
|
||||
}
|
||||
|
||||
numberedHunks.push({ hunkIndex, hunkHeader, lines });
|
||||
}
|
||||
|
||||
return lines;
|
||||
return numberedHunks;
|
||||
}
|
||||
|
||||
function buildNumberedCell(input: {
|
||||
filePath: string;
|
||||
hunkHeader: string;
|
||||
hunkIndex: number;
|
||||
lineIndex: number;
|
||||
line: DiffLine;
|
||||
oldLineNumber: number | null;
|
||||
newLineNumber: number | null;
|
||||
side: ReviewSide;
|
||||
}): NumberedDiffCell | null {
|
||||
if (input.line.type === "header") {
|
||||
return null;
|
||||
}
|
||||
if (input.line.type === "remove" && input.side !== "old") {
|
||||
return null;
|
||||
}
|
||||
if (input.line.type === "add" && input.side !== "new") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineNumber = input.side === "old" ? input.oldLineNumber : input.newLineNumber;
|
||||
if (lineNumber === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: buildReviewableDiffTargetKey({
|
||||
filePath: input.filePath,
|
||||
side: input.side,
|
||||
lineNumber,
|
||||
}),
|
||||
filePath: input.filePath,
|
||||
hunkHeader: input.hunkHeader,
|
||||
hunkIndex: input.hunkIndex,
|
||||
lineIndex: input.lineIndex,
|
||||
oldLineNumber: input.oldLineNumber,
|
||||
newLineNumber: input.newLineNumber,
|
||||
side: input.side,
|
||||
lineNumber,
|
||||
lineType: input.line.type,
|
||||
content: input.line.content,
|
||||
line: input.line,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUnifiedDiffLines(file: ParsedDiffFile): UnifiedDiffDisplayLine[] {
|
||||
return buildNumberedDiffHunks(file).flatMap((hunk) =>
|
||||
hunk.lines.map((numberedLine) => ({
|
||||
key: numberedLine.key,
|
||||
line: numberedLine.line,
|
||||
lineNumber: numberedLine.unifiedCell?.lineNumber ?? null,
|
||||
reviewTarget: numberedLine.unifiedCell ? toReviewTarget(numberedLine.unifiedCell) : null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
|
||||
const rows: SplitDiffRow[] = [];
|
||||
|
||||
for (const hunk of file.hunks) {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
for (const hunk of buildNumberedDiffHunks(file)) {
|
||||
rows.push({
|
||||
kind: "header",
|
||||
content: hunk.lines[0]?.type === "header" ? hunk.lines[0].content : "@@",
|
||||
content: hunk.hunkHeader,
|
||||
});
|
||||
|
||||
let pendingRemovals: Array<{ line: DiffLine; oldLineNumber: number }> = [];
|
||||
let pendingAdditions: Array<{ line: DiffLine; newLineNumber: number }> = [];
|
||||
let pendingRemovals: NumberedDiffCell[] = [];
|
||||
let pendingAdditions: NumberedDiffCell[] = [];
|
||||
|
||||
const flushPendingRows = () => {
|
||||
const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length);
|
||||
@@ -121,61 +254,41 @@ export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
|
||||
const addition = pendingAdditions[index] ?? null;
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: removal
|
||||
? toDisplayLine({
|
||||
line: removal.line,
|
||||
oldLineNumber: removal.oldLineNumber,
|
||||
newLineNumber: null,
|
||||
side: "left",
|
||||
})
|
||||
: null,
|
||||
right: addition
|
||||
? toDisplayLine({
|
||||
line: addition.line,
|
||||
oldLineNumber: null,
|
||||
newLineNumber: addition.newLineNumber,
|
||||
side: "right",
|
||||
})
|
||||
: null,
|
||||
left: toSplitDisplayLine(removal),
|
||||
right: toSplitDisplayLine(addition),
|
||||
});
|
||||
}
|
||||
pendingRemovals = [];
|
||||
pendingAdditions = [];
|
||||
};
|
||||
|
||||
for (const line of hunk.lines.slice(1)) {
|
||||
if (line.type === "remove") {
|
||||
pendingRemovals.push({ line, oldLineNumber: oldLineNo });
|
||||
oldLineNo += 1;
|
||||
for (const numberedLine of hunk.lines) {
|
||||
if (numberedLine.line.type === "header") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.type === "add") {
|
||||
pendingAdditions.push({ line, newLineNumber: newLineNo });
|
||||
newLineNo += 1;
|
||||
if (numberedLine.line.type === "remove") {
|
||||
if (numberedLine.oldCell) {
|
||||
pendingRemovals.push(numberedLine.oldCell);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (numberedLine.line.type === "add") {
|
||||
if (numberedLine.newCell) {
|
||||
pendingAdditions.push(numberedLine.newCell);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
flushPendingRows();
|
||||
|
||||
if (line.type === "context") {
|
||||
if (numberedLine.line.type === "context") {
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: toDisplayLine({
|
||||
line,
|
||||
oldLineNumber: oldLineNo,
|
||||
newLineNumber: newLineNo,
|
||||
side: "left",
|
||||
}),
|
||||
right: toDisplayLine({
|
||||
line,
|
||||
oldLineNumber: oldLineNo,
|
||||
newLineNumber: newLineNo,
|
||||
side: "right",
|
||||
}),
|
||||
left: toSplitDisplayLine(numberedLine.oldCell),
|
||||
right: toSplitDisplayLine(numberedLine.newCell),
|
||||
});
|
||||
oldLineNo += 1;
|
||||
newLineNo += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { mergePendingCreateImages } from "./pending-create-images";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
|
||||
function userMessage(params: {
|
||||
id: string;
|
||||
@@ -12,6 +13,7 @@ function userMessage(params: {
|
||||
mimeType: string;
|
||||
createdAt: number;
|
||||
}>;
|
||||
attachments?: AgentAttachment[];
|
||||
}): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
@@ -19,6 +21,7 @@ function userMessage(params: {
|
||||
text: params.text,
|
||||
timestamp: new Date("2026-01-01T00:00:00Z"),
|
||||
...(params.images ? { images: params.images } : {}),
|
||||
...(params.attachments ? { attachments: params.attachments } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +37,16 @@ function buildImage(id: string) {
|
||||
];
|
||||
}
|
||||
|
||||
function buildReviewAttachment(): AgentAttachment {
|
||||
return {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/repo",
|
||||
mode: "base",
|
||||
comments: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("mergePendingCreateImages", () => {
|
||||
it("returns same reference when pending images are absent", () => {
|
||||
const streamItems = [userMessage({ id: "msg-1", text: "hello" })];
|
||||
@@ -63,6 +76,24 @@ describe("mergePendingCreateImages", () => {
|
||||
expect(updated.images).toEqual(images);
|
||||
});
|
||||
|
||||
it("merges attachments by clientMessageId when matched message has none", () => {
|
||||
const streamItems = [userMessage({ id: "msg-1", text: "hello" })];
|
||||
const attachments = [buildReviewAttachment()];
|
||||
const result = mergePendingCreateImages({
|
||||
streamItems,
|
||||
clientMessageId: "msg-1",
|
||||
attachments,
|
||||
});
|
||||
|
||||
expect(result).not.toBe(streamItems);
|
||||
const updated = result[0];
|
||||
expect(updated?.kind).toBe("user_message");
|
||||
if (updated?.kind !== "user_message") {
|
||||
throw new Error("Expected user_message item");
|
||||
}
|
||||
expect(updated.attachments).toEqual(attachments);
|
||||
});
|
||||
|
||||
it("does not merge when clientMessageId does not match", () => {
|
||||
const streamItems = [userMessage({ id: "msg-1", text: "same text" })];
|
||||
const images = buildImage("image-2");
|
||||
@@ -92,4 +123,32 @@ describe("mergePendingCreateImages", () => {
|
||||
}
|
||||
expect(unchanged.images).toEqual(existingImages);
|
||||
});
|
||||
|
||||
it("does not overwrite existing user message attachments", () => {
|
||||
const existingAttachments = [buildReviewAttachment()];
|
||||
const streamItems = [
|
||||
userMessage({ id: "msg-1", text: "hello", attachments: existingAttachments }),
|
||||
];
|
||||
const result = mergePendingCreateImages({
|
||||
streamItems,
|
||||
clientMessageId: "msg-1",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 7,
|
||||
title: "Issue",
|
||||
url: "https://github.com/getpaseo/paseo/issues/7",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toBe(streamItems);
|
||||
const unchanged = result[0];
|
||||
expect(unchanged?.kind).toBe("user_message");
|
||||
if (unchanged?.kind !== "user_message") {
|
||||
throw new Error("Expected user_message item");
|
||||
}
|
||||
expect(unchanged.attachments).toEqual(existingAttachments);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import type { StreamItem, UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
|
||||
interface MergePendingCreateImagesParams {
|
||||
streamItems: StreamItem[];
|
||||
clientMessageId: string;
|
||||
images?: UserMessageImageAttachment[];
|
||||
attachments?: AgentAttachment[];
|
||||
}
|
||||
|
||||
export function mergePendingCreateImages({
|
||||
streamItems,
|
||||
clientMessageId,
|
||||
images,
|
||||
attachments,
|
||||
}: MergePendingCreateImagesParams): StreamItem[] {
|
||||
if (!images || images.length === 0) {
|
||||
const hasPendingImages = Boolean(images && images.length > 0);
|
||||
const hasPendingAttachments = Boolean(attachments && attachments.length > 0);
|
||||
if (!hasPendingImages && !hasPendingAttachments) {
|
||||
return streamItems;
|
||||
}
|
||||
|
||||
@@ -26,11 +31,18 @@ export function mergePendingCreateImages({
|
||||
if (target.kind !== "user_message") {
|
||||
return streamItems;
|
||||
}
|
||||
if (target.images && target.images.length > 0) {
|
||||
const shouldMergeImages = hasPendingImages && (!target.images || target.images.length === 0);
|
||||
const shouldMergeAttachments =
|
||||
hasPendingAttachments && (!target.attachments || target.attachments.length === 0);
|
||||
if (!shouldMergeImages && !shouldMergeAttachments) {
|
||||
return streamItems;
|
||||
}
|
||||
|
||||
const next = [...streamItems];
|
||||
next[targetIndex] = { ...target, images };
|
||||
next[targetIndex] = {
|
||||
...target,
|
||||
...(shouldMergeImages ? { images } : {}),
|
||||
...(shouldMergeAttachments ? { attachments } : {}),
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,73 @@ describe("prompt attachments", () => {
|
||||
).toContain("GitHub PR #123: Fix race in worktree setup");
|
||||
});
|
||||
|
||||
it("renders review attachments with compact file, line, comment, and context details", () => {
|
||||
expect(
|
||||
renderPromptAttachmentAsText({
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/tmp/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: 42,
|
||||
body: "Please guard this nullable value.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,3 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: 41,
|
||||
newLineNumber: 41,
|
||||
type: "context",
|
||||
content: "const before = true;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"Paseo review attachment (base)",
|
||||
"CWD: /tmp/repo",
|
||||
"Base: main",
|
||||
"",
|
||||
"Comment 1: src/index.ts:new:42",
|
||||
"Please guard this nullable value.",
|
||||
"@@ -40,3 +40,4 @@",
|
||||
" 41 41 const before = true;",
|
||||
"> - 42 +const value = maybeNull.name;",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders github_issue attachments as readable text", () => {
|
||||
expect(
|
||||
renderPromptAttachmentAsText({
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Issue",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
}),
|
||||
).toContain("GitHub Issue #55: Issue");
|
||||
});
|
||||
|
||||
it("finds the first github_pr attachment", () => {
|
||||
expect(
|
||||
findGitHubPrAttachment([
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AgentAttachment } from "../../shared/messages.js";
|
||||
|
||||
const REVIEW_LINE_MARKERS = { add: "+", remove: "-", context: " " } as const;
|
||||
|
||||
export function renderPromptAttachmentAsText(attachment: AgentAttachment): string {
|
||||
switch (attachment.type) {
|
||||
case "github_pr": {
|
||||
@@ -22,9 +24,40 @@ export function renderPromptAttachmentAsText(attachment: AgentAttachment): strin
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
case "review": {
|
||||
const lines = [`Paseo review attachment (${attachment.mode})`, `CWD: ${attachment.cwd}`];
|
||||
if (attachment.baseRef) {
|
||||
lines.push(`Base: ${attachment.baseRef}`);
|
||||
}
|
||||
attachment.comments.forEach((comment, index) => {
|
||||
lines.push(
|
||||
"",
|
||||
`Comment ${index + 1}: ${comment.filePath}:${comment.side}:${comment.lineNumber}`,
|
||||
comment.body,
|
||||
comment.context.hunkHeader,
|
||||
);
|
||||
const target = comment.context.targetLine;
|
||||
for (const line of comment.context.lines) {
|
||||
const isTarget =
|
||||
line.oldLineNumber === target.oldLineNumber &&
|
||||
line.newLineNumber === target.newLineNumber &&
|
||||
line.type === target.type &&
|
||||
line.content === target.content;
|
||||
const prefix = isTarget ? "> " : " ";
|
||||
const oldLn = padLineNumber(line.oldLineNumber);
|
||||
const newLn = padLineNumber(line.newLineNumber);
|
||||
lines.push(`${prefix}${oldLn} ${newLn} ${REVIEW_LINE_MARKERS[line.type]}${line.content}`);
|
||||
}
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function padLineNumber(lineNumber: number | null): string {
|
||||
return (lineNumber?.toString() ?? "-").padStart(2);
|
||||
}
|
||||
|
||||
export function findGitHubPrAttachment(
|
||||
attachments: readonly AgentAttachment[] | undefined,
|
||||
): Extract<AgentAttachment, { type: "github_pr" }> | null {
|
||||
|
||||
@@ -2091,19 +2091,21 @@ function toACPContentBlocks(prompt: AgentPromptInput): ContentBlock[] {
|
||||
return [{ type: "text", text: prompt }];
|
||||
}
|
||||
|
||||
return prompt.map((block: AgentPromptContentBlock) => {
|
||||
if (block.type === "text") {
|
||||
return { type: "text", text: block.text };
|
||||
const contentBlocks: ContentBlock[] = [];
|
||||
for (const block of prompt) {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
contentBlocks.push({ type: "text", text: block.text });
|
||||
break;
|
||||
case "image":
|
||||
contentBlocks.push({ type: "image", data: block.data, mimeType: block.mimeType });
|
||||
break;
|
||||
default:
|
||||
contentBlocks.push({ type: "text", text: renderPromptAttachmentAsText(block) });
|
||||
break;
|
||||
}
|
||||
if (block.type === "github_pr" || block.type === "github_issue") {
|
||||
return { type: "text", text: renderPromptAttachmentAsText(block) };
|
||||
}
|
||||
return {
|
||||
type: "image",
|
||||
data: block.data,
|
||||
mimeType: block.mimeType,
|
||||
};
|
||||
});
|
||||
}
|
||||
return contentBlocks;
|
||||
}
|
||||
|
||||
function extractPromptText(prompt: AgentPromptInput): string {
|
||||
|
||||
@@ -2345,11 +2345,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
data: chunk.data,
|
||||
},
|
||||
});
|
||||
} else if (chunk.type === "github_pr" || chunk.type === "github_issue") {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: renderPromptAttachmentAsText(chunk),
|
||||
});
|
||||
} else {
|
||||
content.push({ type: "text", text: renderPromptAttachmentAsText(chunk) });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -357,6 +357,52 @@ describe("Codex app-server provider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("separates Codex text prompts from rendered attachment text", async () => {
|
||||
const input = await codexAppServerTurnInputFromPrompt(
|
||||
[
|
||||
{ type: "text", text: "Please review this" },
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 456,
|
||||
title: "Attachment spacing",
|
||||
url: "https://github.com/getpaseo/paseo/issues/456",
|
||||
},
|
||||
],
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(input).toEqual([
|
||||
{ type: "text", text: "Please review this" },
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringMatching(/^\n\nGitHub Issue #456: Attachment spacing/),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not prefix Codex attachment-only prompts with a blank line", async () => {
|
||||
const input = await codexAppServerTurnInputFromPrompt(
|
||||
[
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 456,
|
||||
title: "Attachment spacing",
|
||||
url: "https://github.com/getpaseo/paseo/issues/456",
|
||||
},
|
||||
],
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(input).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringMatching(/^GitHub Issue #456: Attachment spacing/),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps patch notifications with array-style changes and alias diff keys", () => {
|
||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
||||
callId: "patch-array-alias",
|
||||
|
||||
@@ -2410,41 +2410,36 @@ export async function codexAppServerTurnInputFromPrompt(
|
||||
return [{ type: "text", text: prompt }];
|
||||
}
|
||||
|
||||
const blocks = prompt as Array<unknown>;
|
||||
const output = await Promise.all(
|
||||
blocks.map(async (block) => {
|
||||
if (!block || typeof block !== "object") {
|
||||
return block;
|
||||
}
|
||||
const record = block as { type?: unknown; mimeType?: unknown; data?: unknown };
|
||||
if (
|
||||
record.type === "image" &&
|
||||
typeof record.mimeType === "string" &&
|
||||
typeof record.data === "string"
|
||||
) {
|
||||
try {
|
||||
const filePath = await writeImageAttachment(record.mimeType, record.data);
|
||||
return { type: "localImage", path: filePath };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn({ message }, "Failed to write Codex image attachment");
|
||||
return {
|
||||
type: "text",
|
||||
text: `User attached image (failed to write temp file): ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (record.type === "github_pr" || record.type === "github_issue") {
|
||||
return {
|
||||
const output: unknown[] = [];
|
||||
let previousTextBlock = false;
|
||||
for (const block of prompt) {
|
||||
if (block.type === "text") {
|
||||
output.push(block);
|
||||
previousTextBlock = block.text.length > 0;
|
||||
continue;
|
||||
}
|
||||
if (block.type === "image") {
|
||||
try {
|
||||
const filePath = await writeImageAttachment(block.mimeType, block.data);
|
||||
output.push({ type: "localImage", path: filePath });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn({ message }, "Failed to write Codex image attachment");
|
||||
output.push({
|
||||
type: "text",
|
||||
text: renderPromptAttachmentAsText(
|
||||
record as Extract<AgentPromptContentBlock, { type: "github_pr" | "github_issue" }>,
|
||||
),
|
||||
};
|
||||
text: `User attached image (failed to write temp file): ${message}`,
|
||||
});
|
||||
}
|
||||
return block;
|
||||
}),
|
||||
);
|
||||
previousTextBlock = false;
|
||||
continue;
|
||||
}
|
||||
const attachmentText = renderPromptAttachmentAsText(block);
|
||||
output.push({
|
||||
type: "text",
|
||||
text: previousTextBlock ? `\n\n${attachmentText}` : attachmentText,
|
||||
});
|
||||
previousTextBlock = true;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -698,20 +698,20 @@ function buildOpenCodePromptParts(
|
||||
output.push({ type: "text", text: part.text });
|
||||
continue;
|
||||
}
|
||||
if (part.type === "github_pr" || part.type === "github_issue") {
|
||||
output.push({ type: "text", text: renderPromptAttachmentAsText(part) });
|
||||
if (part.type === "image") {
|
||||
attachmentOrdinal += 1;
|
||||
const normalized = toOpenCodeDataUrl(part.mimeType, part.data);
|
||||
output.push({
|
||||
type: "file",
|
||||
mime: normalized.mimeType,
|
||||
filename: `attachment-${attachmentOrdinal}.${getOpenCodeAttachmentExtension(
|
||||
normalized.mimeType,
|
||||
)}`,
|
||||
url: normalized.url,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
attachmentOrdinal += 1;
|
||||
const normalized = toOpenCodeDataUrl(part.mimeType, part.data);
|
||||
output.push({
|
||||
type: "file",
|
||||
mime: normalized.mimeType,
|
||||
filename: `attachment-${attachmentOrdinal}.${getOpenCodeAttachmentExtension(
|
||||
normalized.mimeType,
|
||||
)}`,
|
||||
url: normalized.url,
|
||||
});
|
||||
output.push({ type: "text", text: renderPromptAttachmentAsText(part) });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,148 @@ import {
|
||||
} from "./messages.js";
|
||||
|
||||
describe("shared messages attachments", () => {
|
||||
it("keeps valid review attachments", () => {
|
||||
const parsed = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "req-review",
|
||||
agentId: "agent-1",
|
||||
text: "Please address these comments",
|
||||
attachments: [
|
||||
{
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/tmp/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: 42,
|
||||
body: "Please guard this nullable value.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,3 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: 41,
|
||||
newLineNumber: 41,
|
||||
type: "context",
|
||||
content: "const before = true;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.attachments).toEqual([
|
||||
{
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/tmp/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: 42,
|
||||
body: "Please guard this nullable value.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,3 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: 41,
|
||||
newLineNumber: 41,
|
||||
type: "context",
|
||||
content: "const before = true;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops malformed review attachments while keeping valid attachments", () => {
|
||||
const parsed = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "req-bad-review",
|
||||
agentId: "agent-1",
|
||||
text: "Review",
|
||||
attachments: [
|
||||
{
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/tmp/repo",
|
||||
mode: "uncommitted",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: "42",
|
||||
body: "This line number is malformed.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,3 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
lines: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.attachments).toEqual([
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps known attachments and drops unknown create-agent attachments", () => {
|
||||
const parsed = CreateAgentRequestMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
|
||||
@@ -733,9 +733,38 @@ export const GitHubIssueAttachmentSchema = z.object({
|
||||
body: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const ReviewAttachmentContextLineSchema = z.object({
|
||||
oldLineNumber: z.number().int().positive().nullable(),
|
||||
newLineNumber: z.number().int().positive().nullable(),
|
||||
type: z.enum(["add", "remove", "context"]),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const ReviewAttachmentCommentSchema = z.object({
|
||||
filePath: z.string(),
|
||||
side: z.enum(["old", "new"]),
|
||||
lineNumber: z.number().int().positive(),
|
||||
body: z.string(),
|
||||
context: z.object({
|
||||
hunkHeader: z.string(),
|
||||
targetLine: ReviewAttachmentContextLineSchema,
|
||||
lines: z.array(ReviewAttachmentContextLineSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ReviewAttachmentSchema = z.object({
|
||||
type: z.literal("review"),
|
||||
mimeType: z.literal("application/paseo-review"),
|
||||
cwd: z.string(),
|
||||
mode: z.enum(["uncommitted", "base"]),
|
||||
baseRef: z.string().nullable().optional(),
|
||||
comments: z.array(ReviewAttachmentCommentSchema),
|
||||
});
|
||||
|
||||
export const AgentAttachmentSchema = z.discriminatedUnion("type", [
|
||||
GitHubPrAttachmentSchema,
|
||||
GitHubIssueAttachmentSchema,
|
||||
ReviewAttachmentSchema,
|
||||
]);
|
||||
|
||||
function normalizeAgentAttachments(input: unknown): AgentAttachment[] {
|
||||
@@ -3471,6 +3500,7 @@ export type DictationStreamFinishMessage = z.infer<typeof DictationStreamFinishM
|
||||
export type DictationStreamCancelMessage = z.infer<typeof DictationStreamCancelMessageSchema>;
|
||||
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
|
||||
export type AgentAttachment = z.infer<typeof AgentAttachmentSchema>;
|
||||
export type ReviewAttachment = z.infer<typeof ReviewAttachmentSchema>;
|
||||
export type ListProviderModelsRequestMessage = z.infer<
|
||||
typeof ListProviderModelsRequestMessageSchema
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user