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";
|
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
|
||||||
|
|
||||||
@@ -20,7 +20,17 @@ export interface AttachmentMetadata {
|
|||||||
export type ComposerAttachment =
|
export type ComposerAttachment =
|
||||||
| { kind: "image"; metadata: AttachmentMetadata }
|
| { kind: "image"; metadata: AttachmentMetadata }
|
||||||
| { kind: "github_issue"; item: GitHubSearchItem }
|
| { 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 =
|
export type AttachmentDataSource =
|
||||||
| { kind: "base64"; base64: string }
|
| { 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
|
<UserMessage
|
||||||
message={item.text}
|
message={item.text}
|
||||||
images={item.images}
|
images={item.images}
|
||||||
|
attachments={item.attachments}
|
||||||
timestamp={item.timestamp.getTime()}
|
timestamp={item.timestamp.getTime()}
|
||||||
isFirstInGroup={isFirstInGroup}
|
isFirstInGroup={isFirstInGroup}
|
||||||
isLastInGroup={isLastInGroup}
|
isLastInGroup={isLastInGroup}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||||
|
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
||||||
import type { AgentAttachment } from "@server/shared/messages";
|
import type { AgentAttachment } from "@server/shared/messages";
|
||||||
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
||||||
|
|
||||||
@@ -17,6 +18,14 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (composerWorkspaceAttachment.is(attachment)) {
|
||||||
|
const workspaceAttachment = composerWorkspaceAttachment.toSubmitAttachment(attachment);
|
||||||
|
if (workspaceAttachment) {
|
||||||
|
reviewAttachments.push(workspaceAttachment);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
|
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
|
||||||
if (reviewAttachment) {
|
if (reviewAttachment) {
|
||||||
reviewAttachments.push(reviewAttachment);
|
reviewAttachments.push(reviewAttachment);
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import { createRoot, type Root } from "react-dom/client";
|
|||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
import type {
|
||||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
AttachmentMetadata,
|
||||||
|
ComposerAttachment,
|
||||||
|
UserComposerAttachment,
|
||||||
|
} from "@/attachments/types";
|
||||||
|
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||||
import { Composer } from "./composer";
|
import { Composer } from "./composer";
|
||||||
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
|
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
|
||||||
|
import { addReviewDraftComment, getReviewDraftComments, resetReviewDraftStore } from "@/review";
|
||||||
|
|
||||||
const keyboardActionHandlerMock = vi.hoisted(() => vi.fn());
|
const keyboardActionHandlerMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
@@ -27,6 +32,7 @@ const {
|
|||||||
setAgentStreamHeadMock,
|
setAgentStreamHeadMock,
|
||||||
setQueuedMessagesMock,
|
setQueuedMessagesMock,
|
||||||
agentDirectoryStatusMock,
|
agentDirectoryStatusMock,
|
||||||
|
appSendBehavior,
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
const hoistedTheme = {
|
const hoistedTheme = {
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
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 },
|
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
|
||||||
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
|
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
|
||||||
fontWeight: { normal: "400", medium: "500" },
|
fontWeight: { normal: "400", medium: "500" },
|
||||||
|
lineHeight: { diff: 18 },
|
||||||
shadow: { md: {} },
|
shadow: { md: {} },
|
||||||
colors: {
|
colors: {
|
||||||
surface0: "#000",
|
surface0: "#000",
|
||||||
@@ -99,7 +106,12 @@ const {
|
|||||||
cancelAgent: vi.fn(async () => {}),
|
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: {
|
const hoistedMockSessionState: {
|
||||||
sessions: Record<
|
sessions: Record<
|
||||||
string,
|
string,
|
||||||
@@ -161,6 +173,7 @@ const {
|
|||||||
hoistedMockSessionState.setAgentStreamTail = hoistedSetAgentStreamTailMock;
|
hoistedMockSessionState.setAgentStreamTail = hoistedSetAgentStreamTailMock;
|
||||||
hoistedMockSessionState.setAgentStreamHead = hoistedSetAgentStreamHeadMock;
|
hoistedMockSessionState.setAgentStreamHead = hoistedSetAgentStreamHeadMock;
|
||||||
const hoistedAgentDirectoryStatusMock = vi.fn(() => "ready");
|
const hoistedAgentDirectoryStatusMock = vi.fn(() => "ready");
|
||||||
|
const hoistedAppSendBehavior = { current: "interrupt" as "interrupt" | "queue" };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme: hoistedTheme,
|
theme: hoistedTheme,
|
||||||
@@ -178,6 +191,7 @@ const {
|
|||||||
setAgentStreamHeadMock: hoistedSetAgentStreamHeadMock,
|
setAgentStreamHeadMock: hoistedSetAgentStreamHeadMock,
|
||||||
setQueuedMessagesMock: hoistedSetQueuedMessagesMock,
|
setQueuedMessagesMock: hoistedSetQueuedMessagesMock,
|
||||||
agentDirectoryStatusMock: hoistedAgentDirectoryStatusMock,
|
agentDirectoryStatusMock: hoistedAgentDirectoryStatusMock,
|
||||||
|
appSendBehavior: hoistedAppSendBehavior,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,7 +311,7 @@ vi.mock("@/utils/open-external-url", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-settings", () => ({
|
vi.mock("@/hooks/use-settings", () => ({
|
||||||
useAppSettings: () => ({ settings: { sendBehavior: "interrupt" } }),
|
useAppSettings: () => ({ settings: { sendBehavior: appSendBehavior.current } }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-agent-autocomplete", () => ({
|
vi.mock("@/hooks/use-agent-autocomplete", () => ({
|
||||||
@@ -556,8 +570,72 @@ let container: HTMLElement | null = null;
|
|||||||
let queryClient: QueryClient | null = null;
|
let queryClient: QueryClient | null = null;
|
||||||
let latestAttachments: ComposerAttachment[] = [];
|
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(() => {
|
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("React", React);
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
vi.stubGlobal("window", dom.window);
|
vi.stubGlobal("window", dom.window);
|
||||||
@@ -570,6 +648,7 @@ beforeEach(() => {
|
|||||||
attachEvent: vi.fn(),
|
attachEvent: vi.fn(),
|
||||||
detachEvent: vi.fn(),
|
detachEvent: vi.fn(),
|
||||||
});
|
});
|
||||||
|
vi.stubGlobal("localStorage", dom.window.localStorage);
|
||||||
|
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
@@ -590,6 +669,7 @@ beforeEach(() => {
|
|||||||
keyboardActionHandlerMock.mockClear();
|
keyboardActionHandlerMock.mockClear();
|
||||||
agentDirectoryStatusMock.mockReset();
|
agentDirectoryStatusMock.mockReset();
|
||||||
agentDirectoryStatusMock.mockReturnValue("ready");
|
agentDirectoryStatusMock.mockReturnValue("ready");
|
||||||
|
appSendBehavior.current = "interrupt";
|
||||||
mockSessionState.sessions.server.serverInfo = {
|
mockSessionState.sessions.server.serverInfo = {
|
||||||
serverId: "server",
|
serverId: "server",
|
||||||
hostname: "test",
|
hostname: "test",
|
||||||
@@ -607,6 +687,10 @@ beforeEach(() => {
|
|||||||
mockSessionState.sessions.server.agentStreamHead = new Map();
|
mockSessionState.sessions.server.agentStreamHead = new Map();
|
||||||
mockSessionState.sessions.server.agentStreamTail = new Map();
|
mockSessionState.sessions.server.agentStreamTail = new Map();
|
||||||
mockSessionState.sessions.server.queuedMessages = new Map();
|
mockSessionState.sessions.server.queuedMessages = new Map();
|
||||||
|
mockSessionState.sessions.server.agents = new Map([
|
||||||
|
["agent", { status: "idle", lastUsage: null }],
|
||||||
|
]);
|
||||||
|
resetReviewDraftStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -634,20 +718,30 @@ function imageAttachment(id: string): AttachmentMetadata {
|
|||||||
function ComposerHarness({
|
function ComposerHarness({
|
||||||
initialText = "",
|
initialText = "",
|
||||||
initialAttachments = [],
|
initialAttachments = [],
|
||||||
|
workspaceAttachment = null,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
submitBehavior,
|
submitBehavior,
|
||||||
}: {
|
}: {
|
||||||
initialText?: string;
|
initialText?: string;
|
||||||
initialAttachments?: ComposerAttachment[];
|
initialAttachments?: UserComposerAttachment[];
|
||||||
|
workspaceAttachment?: ReviewComposerAttachment | null;
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
submitBehavior?: "clear" | "preserve-and-lock";
|
submitBehavior?: "clear" | "preserve-and-lock";
|
||||||
}) {
|
}) {
|
||||||
const [text, setText] = useState(initialText);
|
const [text, setText] = useState(initialText);
|
||||||
const [attachments, setAttachments] = useState(initialAttachments);
|
const [attachments, setAttachments] = useState(initialAttachments);
|
||||||
|
const workspaceAttachments = React.useMemo(
|
||||||
|
() => (workspaceAttachment ? [workspaceAttachment] : []),
|
||||||
|
[workspaceAttachment],
|
||||||
|
);
|
||||||
latestAttachments = attachments;
|
latestAttachments = attachments;
|
||||||
|
|
||||||
const handleChangeAttachments = React.useCallback(
|
const handleChangeAttachments = React.useCallback(
|
||||||
(updater: ComposerAttachment[] | ((current: ComposerAttachment[]) => ComposerAttachment[])) => {
|
(
|
||||||
|
updater:
|
||||||
|
| UserComposerAttachment[]
|
||||||
|
| ((current: UserComposerAttachment[]) => UserComposerAttachment[]),
|
||||||
|
) => {
|
||||||
setAttachments((current) => {
|
setAttachments((current) => {
|
||||||
const next = typeof updater === "function" ? updater(current) : updater;
|
const next = typeof updater === "function" ? updater(current) : updater;
|
||||||
latestAttachments = next;
|
latestAttachments = next;
|
||||||
@@ -666,6 +760,7 @@ function ComposerHarness({
|
|||||||
value={text}
|
value={text}
|
||||||
onChangeText={setText}
|
onChangeText={setText}
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
|
workspaceAttachments={workspaceAttachments}
|
||||||
onChangeAttachments={handleChangeAttachments}
|
onChangeAttachments={handleChangeAttachments}
|
||||||
isSubmitLoading={isSubmitLoading}
|
isSubmitLoading={isSubmitLoading}
|
||||||
submitBehavior={submitBehavior}
|
submitBehavior={submitBehavior}
|
||||||
@@ -679,7 +774,8 @@ function ComposerHarness({
|
|||||||
function renderComposer(
|
function renderComposer(
|
||||||
input: {
|
input: {
|
||||||
initialText?: string;
|
initialText?: string;
|
||||||
initialAttachments?: ComposerAttachment[];
|
initialAttachments?: UserComposerAttachment[];
|
||||||
|
workspaceAttachment?: ReviewComposerAttachment | null;
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
submitBehavior?: "clear" | "preserve-and-lock";
|
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 () => {
|
it("submits empty wire arrays when there are no composer attachments", async () => {
|
||||||
renderComposer({ initialText: "plain message" });
|
renderComposer({ initialText: "plain message" });
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,13 @@ import { submitAgentInput } from "@/components/agent-input-submit";
|
|||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
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 { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||||
@@ -86,8 +92,8 @@ interface QueuedMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AttachmentListUpdater =
|
type AttachmentListUpdater =
|
||||||
| ComposerAttachment[]
|
| UserComposerAttachment[]
|
||||||
| ((prev: ComposerAttachment[]) => ComposerAttachment[]);
|
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
||||||
|
|
||||||
function noop() {}
|
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];
|
const removed = prev[index];
|
||||||
if (removed?.kind === "image") {
|
if (removed?.kind === "image") {
|
||||||
void deleteAttachments([removed.metadata]);
|
void deleteAttachments([removed.metadata]);
|
||||||
@@ -242,26 +248,28 @@ function findGithubItemByOption(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isAttachmentSelectedForGithubItem(
|
function isAttachmentSelectedForGithubItem(
|
||||||
attachments: readonly ComposerAttachment[],
|
current: readonly ComposerAttachment[],
|
||||||
item: GitHubSearchItem,
|
item: GitHubSearchItem,
|
||||||
): boolean {
|
): boolean {
|
||||||
return attachments.some(
|
return composerWorkspaceAttachment
|
||||||
(attachment) =>
|
.userAttachmentsOnly(current)
|
||||||
attachment.kind !== "image" &&
|
.some(
|
||||||
attachment.item.kind === item.kind &&
|
(attachment) =>
|
||||||
attachment.item.number === item.number,
|
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 };
|
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleGithubAttachment(
|
function toggleGithubAttachment(
|
||||||
current: ComposerAttachment[],
|
current: UserComposerAttachment[],
|
||||||
item: GitHubSearchItem,
|
item: GitHubSearchItem,
|
||||||
): ComposerAttachment[] {
|
): UserComposerAttachment[] {
|
||||||
const matches = (attachment: ComposerAttachment) =>
|
const matches = (attachment: UserComposerAttachment) =>
|
||||||
attachment.kind !== "image" &&
|
attachment.kind !== "image" &&
|
||||||
attachment.item.kind === item.kind &&
|
attachment.item.kind === item.kind &&
|
||||||
attachment.item.number === item.number;
|
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 (
|
return (
|
||||||
<GithubAttachmentPill
|
<GithubAttachmentPill
|
||||||
key={`${attachment.item.kind}:${attachment.item.number}`}
|
key={`${attachment.item.kind}:${attachment.item.number}`}
|
||||||
@@ -446,6 +463,7 @@ async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Pro
|
|||||||
text,
|
text,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||||
|
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||||
};
|
};
|
||||||
appendUserMessageToStream({ ...args, userMessage });
|
appendUserMessageToStream({ ...args, userMessage });
|
||||||
const imagesData = await encodeImages(wirePayload.images);
|
const imagesData = await encodeImages(wirePayload.images);
|
||||||
@@ -459,11 +477,16 @@ async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Pro
|
|||||||
function openComposerAttachment(
|
function openComposerAttachment(
|
||||||
attachment: ComposerAttachment,
|
attachment: ComposerAttachment,
|
||||||
setLightboxMetadata: (metadata: AttachmentMetadata) => void,
|
setLightboxMetadata: (metadata: AttachmentMetadata) => void,
|
||||||
|
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean,
|
||||||
): void {
|
): void {
|
||||||
if (attachment.kind === "image") {
|
if (attachment.kind === "image") {
|
||||||
setLightboxMetadata(attachment.metadata);
|
setLightboxMetadata(attachment.metadata);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (composerWorkspaceAttachment.is(attachment)) {
|
||||||
|
openWorkspaceAttachment({ attachment });
|
||||||
|
return;
|
||||||
|
}
|
||||||
void openExternalUrl(attachment.item.url);
|
void openExternalUrl(attachment.item.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,10 +619,18 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
|
|||||||
{item.text}
|
{item.text}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.queueActions}>
|
<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} />
|
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
|
||||||
</Pressable>
|
</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" />
|
<ArrowUp size={ICON_SIZE.sm} color="white" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -652,7 +683,7 @@ function ImageAttachmentPill({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface GithubAttachmentPillProps {
|
interface GithubAttachmentPillProps {
|
||||||
attachment: Exclude<ComposerAttachment, { kind: "image" }>;
|
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
|
||||||
index: number;
|
index: number;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onOpen: (attachment: ComposerAttachment) => void;
|
onOpen: (attachment: ComposerAttachment) => void;
|
||||||
@@ -759,7 +790,9 @@ interface ComposerProps {
|
|||||||
blurOnSubmit?: boolean;
|
blurOnSubmit?: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
attachments: ComposerAttachment[];
|
attachments: UserComposerAttachment[];
|
||||||
|
workspaceAttachments?: readonly WorkspaceComposerAttachment[];
|
||||||
|
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
||||||
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||||
@@ -957,6 +990,8 @@ export function Composer({
|
|||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
attachments,
|
attachments,
|
||||||
|
workspaceAttachments = [],
|
||||||
|
onOpenWorkspaceAttachment,
|
||||||
onChangeAttachments,
|
onChangeAttachments,
|
||||||
cwd,
|
cwd,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
@@ -1005,7 +1040,19 @@ export function Composer({
|
|||||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopWebBreakpoint);
|
const messagePlaceholder = resolveMessagePlaceholder(isDesktopWebBreakpoint);
|
||||||
const userInput = value;
|
const userInput = value;
|
||||||
const setUserInput = onChangeText;
|
const setUserInput = onChangeText;
|
||||||
const selectedAttachments = attachments;
|
const {
|
||||||
|
selectedAttachments,
|
||||||
|
buildOutgoingAttachments,
|
||||||
|
removeAttachment,
|
||||||
|
openAttachment,
|
||||||
|
clearSentAttachments,
|
||||||
|
completeSubmit,
|
||||||
|
resetSuppression,
|
||||||
|
} = composerWorkspaceAttachment.useBinding({
|
||||||
|
normalAttachments: attachments,
|
||||||
|
workspaceAttachments,
|
||||||
|
onOpenWorkspaceAttachment,
|
||||||
|
});
|
||||||
const setSelectedAttachments = onChangeAttachments;
|
const setSelectedAttachments = onChangeAttachments;
|
||||||
const [cursorIndex, setCursorIndex] = useState(0);
|
const [cursorIndex, setCursorIndex] = useState(0);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
@@ -1162,8 +1209,18 @@ export function Composer({
|
|||||||
|
|
||||||
setUserInput("");
|
setUserInput("");
|
||||||
setSelectedAttachments([]);
|
setSelectedAttachments([]);
|
||||||
|
resetSuppression();
|
||||||
|
clearSentAttachments(queuedAttachments);
|
||||||
},
|
},
|
||||||
[agentId, serverId, setQueuedMessages, setSelectedAttachments, setUserInput],
|
[
|
||||||
|
agentId,
|
||||||
|
clearSentAttachments,
|
||||||
|
resetSuppression,
|
||||||
|
serverId,
|
||||||
|
setQueuedMessages,
|
||||||
|
setSelectedAttachments,
|
||||||
|
setUserInput,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendMessageWithContent = useCallback(
|
const sendMessageWithContent = useCallback(
|
||||||
@@ -1172,7 +1229,7 @@ export function Composer({
|
|||||||
outgoingAttachments: ComposerAttachment[],
|
outgoingAttachments: ComposerAttachment[],
|
||||||
forceSend?: boolean,
|
forceSend?: boolean,
|
||||||
) => {
|
) => {
|
||||||
await submitAgentInput({
|
const result = await submitAgentInput({
|
||||||
message: outgoingMessage,
|
message: outgoingMessage,
|
||||||
attachments: outgoingAttachments,
|
attachments: outgoingAttachments,
|
||||||
hasExternalContent,
|
hasExternalContent,
|
||||||
@@ -1192,7 +1249,7 @@ export function Composer({
|
|||||||
clearDraft,
|
clearDraft,
|
||||||
setUserInput,
|
setUserInput,
|
||||||
setAttachments: (nextAttachments) => {
|
setAttachments: (nextAttachments) => {
|
||||||
setSelectedAttachments(nextAttachments);
|
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(nextAttachments));
|
||||||
},
|
},
|
||||||
setSendError,
|
setSendError,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
@@ -1200,10 +1257,15 @@ export function Composer({
|
|||||||
console.error("[AgentInput] Failed to send message:", error);
|
console.error("[AgentInput] Failed to send message:", error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
completeSubmit({
|
||||||
|
result,
|
||||||
|
outgoingAttachments,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
allowEmptySubmit,
|
allowEmptySubmit,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
|
completeSubmit,
|
||||||
hasExternalContent,
|
hasExternalContent,
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
queueMessage,
|
queueMessage,
|
||||||
@@ -1219,9 +1281,13 @@ export function Composer({
|
|||||||
if (blurOnSubmit) {
|
if (blurOnSubmit) {
|
||||||
messageInputRef.current?.blur();
|
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 () => {
|
const handlePickImage = useCallback(async () => {
|
||||||
@@ -1232,14 +1298,24 @@ export function Composer({
|
|||||||
|
|
||||||
const handleRemoveAttachment = useCallback(
|
const handleRemoveAttachment = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
|
const didRemoveWorkspaceAttachment = removeAttachment({
|
||||||
|
selectedAttachments,
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
if (didRemoveWorkspaceAttachment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSelectedAttachments((prev) => removeAttachmentAtIndex(prev, index));
|
setSelectedAttachments((prev) => removeAttachmentAtIndex(prev, index));
|
||||||
},
|
},
|
||||||
[setSelectedAttachments],
|
[removeAttachment, selectedAttachments, setSelectedAttachments],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenAttachment = useCallback((attachment: ComposerAttachment) => {
|
const handleOpenAttachment = useCallback(
|
||||||
openComposerAttachment(attachment, setLightboxMetadata);
|
(attachment: ComposerAttachment) => {
|
||||||
}, []);
|
openComposerAttachment(attachment, setLightboxMetadata, openAttachment);
|
||||||
|
},
|
||||||
|
[openAttachment],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAgentRunning || !isConnected) {
|
if (!isAgentRunning || !isConnected) {
|
||||||
@@ -1327,7 +1403,7 @@ export function Composer({
|
|||||||
|
|
||||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||||
setUserInput(item.text);
|
setUserInput(item.text);
|
||||||
setSelectedAttachments(item.attachments);
|
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(item.attachments));
|
||||||
},
|
},
|
||||||
[queuedMessages, setSelectedAttachments, setUserInput, updateQueue],
|
[queuedMessages, setSelectedAttachments, setUserInput, updateQueue],
|
||||||
);
|
);
|
||||||
@@ -1353,9 +1429,9 @@ export function Composer({
|
|||||||
|
|
||||||
const handleQueue = useCallback(
|
const handleQueue = useCallback(
|
||||||
(payload: MessagePayload) => {
|
(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;
|
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
||||||
@@ -1408,7 +1484,6 @@ export function Composer({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isVoiceSwitchingValue = voice?.isVoiceSwitching ?? false;
|
|
||||||
const rightContent = useMemo(
|
const rightContent = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<ComposerRightControlsSlot
|
<ComposerRightControlsSlot
|
||||||
@@ -1420,7 +1495,7 @@ export function Composer({
|
|||||||
buttonIconSize={buttonIconSize}
|
buttonIconSize={buttonIconSize}
|
||||||
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
|
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
|
||||||
isConnected={isConnected}
|
isConnected={isConnected}
|
||||||
isVoiceSwitching={isVoiceSwitchingValue}
|
isVoiceSwitching={isVoiceSwitching}
|
||||||
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
|
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
|
||||||
voiceToggleKeys={voiceToggleKeys}
|
voiceToggleKeys={voiceToggleKeys}
|
||||||
cancelButton={cancelButton}
|
cancelButton={cancelButton}
|
||||||
@@ -1436,7 +1511,7 @@ export function Composer({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
isVoiceModeForAgent,
|
isVoiceModeForAgent,
|
||||||
isVoiceSwitchingValue,
|
isVoiceSwitching,
|
||||||
realtimeVoiceButtonStyle,
|
realtimeVoiceButtonStyle,
|
||||||
voiceToggleKeys,
|
voiceToggleKeys,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
type FlatListProps,
|
type FlatListProps,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
TextStyle,
|
type TextStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -70,6 +70,7 @@ import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
|||||||
import {
|
import {
|
||||||
buildSplitDiffRows,
|
buildSplitDiffRows,
|
||||||
buildUnifiedDiffLines,
|
buildUnifiedDiffLines,
|
||||||
|
type ReviewableDiffTarget,
|
||||||
type SplitDiffDisplayLine,
|
type SplitDiffDisplayLine,
|
||||||
type SplitDiffRow,
|
type SplitDiffRow,
|
||||||
} from "@/utils/diff-layout";
|
} from "@/utils/diff-layout";
|
||||||
@@ -97,6 +98,26 @@ import {
|
|||||||
hasVisibleDiffTokens,
|
hasVisibleDiffTokens,
|
||||||
} from "@/utils/diff-rendering";
|
} from "@/utils/diff-rendering";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
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";
|
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||||
|
|
||||||
@@ -205,6 +226,10 @@ interface DiffFileSectionProps {
|
|||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_COMMENTS: readonly ReviewDraftComment[] = [];
|
||||||
|
|
||||||
|
function noopStartComment(): void {}
|
||||||
|
|
||||||
function lineTypeBackground(type: DiffLine["type"] | undefined | null) {
|
function lineTypeBackground(type: DiffLine["type"] | undefined | null) {
|
||||||
if (!type) return styles.emptySplitCell;
|
if (!type) return styles.emptySplitCell;
|
||||||
if (type === "add") return styles.addLineContainer;
|
if (type === "add") return styles.addLineContainer;
|
||||||
@@ -217,14 +242,20 @@ function DiffGutterCell({
|
|||||||
lineNumber,
|
lineNumber,
|
||||||
type,
|
type,
|
||||||
gutterWidth,
|
gutterWidth,
|
||||||
|
reviewTarget,
|
||||||
|
reviewActions,
|
||||||
|
style,
|
||||||
}: {
|
}: {
|
||||||
lineNumber: number | null;
|
lineNumber: number | null;
|
||||||
type: DiffLine["type"] | undefined | null;
|
type: DiffLine["type"] | undefined | null;
|
||||||
gutterWidth: number;
|
gutterWidth: number;
|
||||||
|
reviewTarget?: ReviewableDiffTarget | null;
|
||||||
|
reviewActions?: InlineReviewActions;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
}) {
|
}) {
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
() => [styles.gutterCell, lineTypeBackground(type), { width: gutterWidth }],
|
() => [styles.gutterCell, lineTypeBackground(type), { width: gutterWidth }, style],
|
||||||
[type, gutterWidth],
|
[type, gutterWidth, style],
|
||||||
);
|
);
|
||||||
const textStyle = useMemo(
|
const textStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -234,12 +265,29 @@ function DiffGutterCell({
|
|||||||
],
|
],
|
||||||
[type],
|
[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 (
|
return (
|
||||||
<View style={containerStyle}>
|
<InlineReviewGutterCell
|
||||||
|
reviewTarget={reviewTarget}
|
||||||
|
comments={comments}
|
||||||
|
isEditorOpen={isEditorOpen}
|
||||||
|
showPersistentAction={reviewActions?.showPersistentAction ?? false}
|
||||||
|
onStartComment={onStartComment}
|
||||||
|
style={containerStyle}
|
||||||
|
>
|
||||||
<Text numberOfLines={1} style={textStyle}>
|
<Text numberOfLines={1} style={textStyle}>
|
||||||
{formatDiffGutterText(lineNumber)}
|
{formatDiffGutterText(lineNumber)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</InlineReviewGutterCell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,11 +362,15 @@ function DiffLineView({
|
|||||||
lineNumber,
|
lineNumber,
|
||||||
gutterWidth,
|
gutterWidth,
|
||||||
wrapLines,
|
wrapLines,
|
||||||
|
reviewTarget,
|
||||||
|
reviewActions,
|
||||||
}: {
|
}: {
|
||||||
line: DiffLine;
|
line: DiffLine;
|
||||||
lineNumber: number | null;
|
lineNumber: number | null;
|
||||||
gutterWidth: number;
|
gutterWidth: number;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
|
reviewTarget?: ReviewableDiffTarget | null;
|
||||||
|
reviewActions?: InlineReviewActions;
|
||||||
}) {
|
}) {
|
||||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||||
|
|
||||||
@@ -326,18 +378,6 @@ function DiffLineView({
|
|||||||
() => [styles.diffLineContainer, lineTypeBackground(line.type)],
|
() => [styles.diffLineContainer, lineTypeBackground(line.type)],
|
||||||
[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(
|
const textStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
styles.diffLineText,
|
styles.diffLineText,
|
||||||
@@ -352,11 +392,14 @@ function DiffLineView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
<View style={gutterStyle}>
|
<DiffGutterCell
|
||||||
<Text numberOfLines={1} style={gutterTextStyle}>
|
lineNumber={lineNumber}
|
||||||
{formatDiffGutterText(lineNumber)}
|
type={line.type}
|
||||||
</Text>
|
gutterWidth={gutterWidth}
|
||||||
</View>
|
reviewTarget={reviewTarget}
|
||||||
|
reviewActions={reviewActions}
|
||||||
|
style={styles.lineNumberGutter}
|
||||||
|
/>
|
||||||
{line.type !== "header" && visibleTokens ? (
|
{line.type !== "header" && visibleTokens ? (
|
||||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||||
) : (
|
) : (
|
||||||
@@ -370,10 +413,12 @@ function SplitDiffLine({
|
|||||||
line,
|
line,
|
||||||
gutterWidth,
|
gutterWidth,
|
||||||
wrapLines,
|
wrapLines,
|
||||||
|
reviewActions,
|
||||||
}: {
|
}: {
|
||||||
line: SplitDiffDisplayLine | null;
|
line: SplitDiffDisplayLine | null;
|
||||||
gutterWidth: number;
|
gutterWidth: number;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
|
reviewActions?: InlineReviewActions;
|
||||||
}) {
|
}) {
|
||||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||||
|
|
||||||
@@ -381,18 +426,6 @@ function SplitDiffLine({
|
|||||||
() => [styles.diffLineContainer, lineTypeBackground(line?.type)],
|
() => [styles.diffLineContainer, lineTypeBackground(line?.type)],
|
||||||
[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(
|
const textStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
styles.diffLineText,
|
styles.diffLineText,
|
||||||
@@ -407,11 +440,14 @@ function SplitDiffLine({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
<View style={gutterStyle}>
|
<DiffGutterCell
|
||||||
<Text numberOfLines={1} style={gutterTextStyle}>
|
lineNumber={line?.lineNumber ?? null}
|
||||||
{formatDiffGutterText(line?.lineNumber ?? null)}
|
type={line?.type}
|
||||||
</Text>
|
gutterWidth={gutterWidth}
|
||||||
</View>
|
reviewTarget={line?.reviewTarget}
|
||||||
|
reviewActions={reviewActions}
|
||||||
|
style={styles.lineNumberGutter}
|
||||||
|
/>
|
||||||
{visibleTokens ? (
|
{visibleTokens ? (
|
||||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
<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({
|
function SplitDiffColumn({
|
||||||
rows,
|
rows,
|
||||||
side,
|
side,
|
||||||
gutterWidth,
|
gutterWidth,
|
||||||
wrapLines,
|
wrapLines,
|
||||||
|
reviewActions,
|
||||||
showDivider = false,
|
showDivider = false,
|
||||||
}: {
|
}: {
|
||||||
rows: SplitDiffRow[];
|
rows: SplitDiffRow[];
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
gutterWidth: number;
|
gutterWidth: number;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
|
reviewActions?: InlineReviewActions;
|
||||||
showDivider?: boolean;
|
showDivider?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [scrollWidth, setScrollWidth] = useState(0);
|
const [scrollWidth, setScrollWidth] = useState(0);
|
||||||
@@ -463,13 +601,27 @@ function SplitDiffColumn({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const line = side === "left" ? row.left : row.right;
|
||||||
|
const reviewRowState = getSplitInlineReviewThreadState({
|
||||||
|
left: row.left?.reviewTarget,
|
||||||
|
right: row.right?.reviewTarget,
|
||||||
|
reviewActions,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<SplitDiffLine
|
<View key={key}>
|
||||||
key={key}
|
<SplitDiffLine
|
||||||
line={side === "left" ? row.left : row.right}
|
line={line}
|
||||||
gutterWidth={gutterWidth}
|
gutterWidth={gutterWidth}
|
||||||
wrapLines={wrapLines}
|
wrapLines={wrapLines}
|
||||||
/>
|
reviewActions={reviewActions}
|
||||||
|
/>
|
||||||
|
<InlineReviewRow
|
||||||
|
reviewTarget={line?.reviewTarget}
|
||||||
|
reviewActions={reviewActions}
|
||||||
|
gutterWidth={gutterWidth}
|
||||||
|
reservedHeight={reviewRowState?.height}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
@@ -487,13 +639,27 @@ function SplitDiffColumn({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const line = side === "left" ? row.left : row.right;
|
const line = side === "left" ? row.left : row.right;
|
||||||
|
const reviewRowState = getSplitInlineReviewThreadState({
|
||||||
|
left: row.left?.reviewTarget,
|
||||||
|
right: row.right?.reviewTarget,
|
||||||
|
reviewActions,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<DiffGutterCell
|
<View key={key}>
|
||||||
key={key}
|
<DiffGutterCell
|
||||||
lineNumber={line?.lineNumber ?? null}
|
lineNumber={line?.lineNumber ?? null}
|
||||||
type={line?.type}
|
type={line?.type}
|
||||||
gutterWidth={gutterWidth}
|
gutterWidth={gutterWidth}
|
||||||
/>
|
reviewTarget={line?.reviewTarget}
|
||||||
|
reviewActions={reviewActions}
|
||||||
|
/>
|
||||||
|
<InlineReviewGutterSpacer
|
||||||
|
reviewTarget={line?.reviewTarget}
|
||||||
|
reviewActions={reviewActions}
|
||||||
|
gutterWidth={gutterWidth}
|
||||||
|
reservedHeight={reviewRowState?.height}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
@@ -512,12 +678,23 @@ function SplitDiffColumn({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const line = side === "left" ? row.left : row.right;
|
||||||
|
const reviewRowState = getSplitInlineReviewThreadState({
|
||||||
|
left: row.left?.reviewTarget,
|
||||||
|
right: row.right?.reviewTarget,
|
||||||
|
reviewActions,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<SplitTextLine
|
<View key={key}>
|
||||||
key={key}
|
<SplitTextLine line={line} wrapLines={false} />
|
||||||
line={side === "left" ? row.left : row.right}
|
<InlineReviewThreadContent
|
||||||
wrapLines={false}
|
reviewTarget={line?.reviewTarget}
|
||||||
/>
|
reviewActions={reviewActions}
|
||||||
|
reservedHeight={reviewRowState?.height}
|
||||||
|
viewportWidth={scrollWidth}
|
||||||
|
pinToViewport
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
@@ -620,12 +797,14 @@ function DiffFileBody({
|
|||||||
file,
|
file,
|
||||||
layout,
|
layout,
|
||||||
wrapLines,
|
wrapLines,
|
||||||
|
reviewActions,
|
||||||
onBodyHeightChange,
|
onBodyHeightChange,
|
||||||
testID,
|
testID,
|
||||||
}: {
|
}: {
|
||||||
file: ParsedDiffFile;
|
file: ParsedDiffFile;
|
||||||
layout: "unified" | "split";
|
layout: "unified" | "split";
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
|
reviewActions?: InlineReviewActions;
|
||||||
onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void;
|
onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -679,12 +858,14 @@ function DiffFileBody({
|
|||||||
side="left"
|
side="left"
|
||||||
gutterWidth={gutterWidth}
|
gutterWidth={gutterWidth}
|
||||||
wrapLines={wrapLines}
|
wrapLines={wrapLines}
|
||||||
|
reviewActions={reviewActions}
|
||||||
/>
|
/>
|
||||||
<SplitDiffColumn
|
<SplitDiffColumn
|
||||||
rows={rows}
|
rows={rows}
|
||||||
side="right"
|
side="right"
|
||||||
gutterWidth={gutterWidth}
|
gutterWidth={gutterWidth}
|
||||||
wrapLines={wrapLines}
|
wrapLines={wrapLines}
|
||||||
|
reviewActions={reviewActions}
|
||||||
showDivider
|
showDivider
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -697,30 +878,48 @@ function DiffFileBody({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.diffContent}>
|
<View style={styles.diffContent}>
|
||||||
<View style={styles.linesContainer}>
|
<View style={styles.linesContainer}>
|
||||||
{computedLines.map(({ line, lineNumber, key }) => (
|
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
|
||||||
<DiffLineView
|
<View key={key}>
|
||||||
key={key}
|
<DiffLineView
|
||||||
line={line}
|
line={line}
|
||||||
lineNumber={lineNumber}
|
lineNumber={lineNumber}
|
||||||
gutterWidth={gutterWidth}
|
gutterWidth={gutterWidth}
|
||||||
wrapLines={wrapLines}
|
wrapLines={wrapLines}
|
||||||
/>
|
reviewTarget={reviewTarget}
|
||||||
|
reviewActions={reviewActions}
|
||||||
|
/>
|
||||||
|
<InlineReviewRow
|
||||||
|
reviewTarget={reviewTarget}
|
||||||
|
reviewActions={reviewActions}
|
||||||
|
gutterWidth={gutterWidth}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textViewportWidth =
|
||||||
|
scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth);
|
||||||
return (
|
return (
|
||||||
<View style={DIFF_CONTENT_ROW_STYLE}>
|
<View style={DIFF_CONTENT_ROW_STYLE}>
|
||||||
<View style={styles.gutterColumn}>
|
<View style={styles.gutterColumn}>
|
||||||
{computedLines.map(({ line, lineNumber, key }) => (
|
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
|
||||||
<DiffGutterCell
|
<View key={key}>
|
||||||
key={key}
|
<DiffGutterCell
|
||||||
lineNumber={lineNumber}
|
lineNumber={lineNumber}
|
||||||
type={line.type}
|
type={line.type}
|
||||||
gutterWidth={gutterWidth}
|
gutterWidth={gutterWidth}
|
||||||
/>
|
reviewTarget={reviewTarget}
|
||||||
|
reviewActions={reviewActions}
|
||||||
|
/>
|
||||||
|
<InlineReviewGutterSpacer
|
||||||
|
reviewTarget={reviewTarget}
|
||||||
|
reviewActions={reviewActions}
|
||||||
|
gutterWidth={gutterWidth}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
<DiffScroll
|
<DiffScroll
|
||||||
@@ -730,8 +929,16 @@ function DiffFileBody({
|
|||||||
contentContainerStyle={styles.diffContentInner}
|
contentContainerStyle={styles.diffContentInner}
|
||||||
>
|
>
|
||||||
<View style={linesContainerRowStyle}>
|
<View style={linesContainerRowStyle}>
|
||||||
{computedLines.map(({ line, key }) => (
|
{computedLines.map(({ line, key, reviewTarget }) => (
|
||||||
<DiffTextLine key={key} line={line} wrapLines={false} />
|
<View key={key}>
|
||||||
|
<DiffTextLine line={line} wrapLines={false} />
|
||||||
|
<InlineReviewThreadContent
|
||||||
|
reviewTarget={reviewTarget}
|
||||||
|
reviewActions={reviewActions}
|
||||||
|
viewportWidth={textViewportWidth}
|
||||||
|
pinToViewport
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</DiffScroll>
|
</DiffScroll>
|
||||||
@@ -1524,7 +1731,7 @@ export function GitDiffPane({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
cwd,
|
cwd,
|
||||||
hideHeaderRow,
|
hideHeaderRow,
|
||||||
enabled = true,
|
enabled,
|
||||||
}: GitDiffPaneProps) {
|
}: GitDiffPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -1532,7 +1739,7 @@ export function GitDiffPane({
|
|||||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||||
const canUseSplitLayout = isWeb && !isMobile;
|
const canUseSplitLayout = isWeb && !isMobile;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null);
|
||||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||||
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
||||||
@@ -1555,13 +1762,8 @@ export function GitDiffPane({
|
|||||||
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
|
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
|
||||||
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
|
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
|
||||||
|
|
||||||
const handleSelectUncommitted = useCallback(() => {
|
// handleSelectUncommitted/handleSelectBase are defined later, after reviewDraftScopeKey
|
||||||
setDiffModeOverride("uncommitted");
|
// and setActiveReviewMode are available, so they can record the active review mode.
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectBase = useCallback(() => {
|
|
||||||
setDiffModeOverride("base");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLayoutUnified = useCallback(() => {
|
const handleLayoutUnified = useCallback(() => {
|
||||||
handleLayoutChange("unified");
|
handleLayoutChange("unified");
|
||||||
@@ -1617,8 +1819,20 @@ export function GitDiffPane({
|
|||||||
} = statusState;
|
} = statusState;
|
||||||
|
|
||||||
// Auto-select diff mode based on state: uncommitted when dirty, base when clean
|
// Auto-select diff mode based on state: uncommitted when dirty, base when clean
|
||||||
const autoDiffMode = hasUncommittedChanges ? "uncommitted" : "base";
|
const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base";
|
||||||
const diffMode = diffModeOverride ?? autoDiffMode;
|
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 {
|
const {
|
||||||
files,
|
files,
|
||||||
@@ -1630,8 +1844,69 @@ export function GitDiffPane({
|
|||||||
mode: diffMode,
|
mode: diffMode,
|
||||||
baseRef,
|
baseRef,
|
||||||
ignoreWhitespace: changesPreferences.hideWhitespace,
|
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 {
|
const {
|
||||||
status: prStatus,
|
status: prStatus,
|
||||||
githubFeaturesEnabled,
|
githubFeaturesEnabled,
|
||||||
@@ -2026,6 +2301,7 @@ export function GitDiffPane({
|
|||||||
file={item.file}
|
file={item.file}
|
||||||
layout={effectiveLayout}
|
layout={effectiveLayout}
|
||||||
wrapLines={wrapLines}
|
wrapLines={wrapLines}
|
||||||
|
reviewActions={reviewActions}
|
||||||
onBodyHeightChange={handleBodyHeightChange}
|
onBodyHeightChange={handleBodyHeightChange}
|
||||||
testID={`diff-file-${item.fileIndex}-body`}
|
testID={`diff-file-${item.fileIndex}-body`}
|
||||||
/>
|
/>
|
||||||
@@ -2036,6 +2312,7 @@ export function GitDiffPane({
|
|||||||
handleBodyHeightChange,
|
handleBodyHeightChange,
|
||||||
handleHeaderHeightChange,
|
handleHeaderHeightChange,
|
||||||
handleToggleExpanded,
|
handleToggleExpanded,
|
||||||
|
reviewActions,
|
||||||
wrapLines,
|
wrapLines,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -2075,8 +2352,14 @@ export function GitDiffPane({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const flatExtraData = useMemo(
|
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;
|
const hasChanges = files.length > 0;
|
||||||
@@ -2655,6 +2938,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRightColor: theme.colors.border,
|
borderRightColor: theme.colors.border,
|
||||||
justifyContent: "flex-start",
|
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: {
|
textLineContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "stretch",
|
alignItems: "stretch",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
|
|||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
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 type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||||
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
|
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
|
||||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||||
@@ -107,6 +108,7 @@ type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: un
|
|||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
message: string;
|
message: string;
|
||||||
images?: UserMessageImageAttachment[];
|
images?: UserMessageImageAttachment[];
|
||||||
|
attachments?: AgentAttachment[];
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
isFirstInGroup?: boolean;
|
isFirstInGroup?: boolean;
|
||||||
isLastInGroup?: boolean;
|
isLastInGroup?: boolean;
|
||||||
@@ -356,6 +358,11 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
},
|
},
|
||||||
|
attachmentPreviewContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
flexWrap: "wrap",
|
||||||
|
},
|
||||||
imagePreviewSpacing: {
|
imagePreviewSpacing: {
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
@@ -374,6 +381,19 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
height: 48,
|
height: 48,
|
||||||
backgroundColor: theme.colors.surface1,
|
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: {
|
copyButton: {
|
||||||
alignSelf: "flex-end",
|
alignSelf: "flex-end",
|
||||||
padding: theme.spacing[1],
|
padding: theme.spacing[1],
|
||||||
@@ -396,9 +416,23 @@ function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAtta
|
|||||||
return <Image source={imageSource} style={userMessageStylesheet.imageThumbnail} />;
|
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({
|
export const UserMessage = memo(function UserMessage({
|
||||||
message,
|
message,
|
||||||
images = [],
|
images = [],
|
||||||
|
attachments = [],
|
||||||
timestamp: _timestamp,
|
timestamp: _timestamp,
|
||||||
isFirstInGroup = true,
|
isFirstInGroup = true,
|
||||||
isLastInGroup = true,
|
isLastInGroup = true,
|
||||||
@@ -410,6 +444,7 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||||
const hasText = message.trim().length > 0;
|
const hasText = message.trim().length > 0;
|
||||||
const hasImages = images.length > 0;
|
const hasImages = images.length > 0;
|
||||||
|
const hasAttachments = attachments.length > 0;
|
||||||
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
|
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
|
||||||
|
|
||||||
const handleHoverIn = useCallback(() => setMessageHovered(true), []);
|
const handleHoverIn = useCallback(() => setMessageHovered(true), []);
|
||||||
@@ -430,6 +465,13 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
const imagePreviewContainerStyle = useMemo(
|
const imagePreviewContainerStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
userMessageStylesheet.imagePreviewContainer,
|
userMessageStylesheet.imagePreviewContainer,
|
||||||
|
hasText || hasAttachments ? userMessageStylesheet.imagePreviewSpacing : undefined,
|
||||||
|
],
|
||||||
|
[hasAttachments, hasText],
|
||||||
|
);
|
||||||
|
const attachmentPreviewContainerStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
userMessageStylesheet.attachmentPreviewContainer,
|
||||||
hasText ? userMessageStylesheet.imagePreviewSpacing : undefined,
|
hasText ? userMessageStylesheet.imagePreviewSpacing : undefined,
|
||||||
],
|
],
|
||||||
[hasText],
|
[hasText],
|
||||||
@@ -461,6 +503,20 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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 ? (
|
{hasText ? (
|
||||||
<Text selectable style={userMessageStylesheet.text}>
|
<Text selectable style={userMessageStylesheet.text}>
|
||||||
{message}
|
{message}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
||||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
import {
|
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";
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
type AttachmentUpdater =
|
type AttachmentUpdater =
|
||||||
| ComposerAttachment[]
|
| UserComposerAttachment[]
|
||||||
| ((prev: ComposerAttachment[]) => ComposerAttachment[]);
|
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
||||||
|
|
||||||
interface AgentInputDraftComposerOptions {
|
interface AgentInputDraftComposerOptions {
|
||||||
initialServerId: string | null;
|
initialServerId: string | null;
|
||||||
@@ -48,7 +48,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
|||||||
interface AgentInputDraft {
|
interface AgentInputDraft {
|
||||||
text: string;
|
text: string;
|
||||||
setText: (text: string) => void;
|
setText: (text: string) => void;
|
||||||
attachments: ComposerAttachment[];
|
attachments: UserComposerAttachment[];
|
||||||
setAttachments: (updater: AttachmentUpdater) => void;
|
setAttachments: (updater: AttachmentUpdater) => void;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
setCwd: (cwd: string) => void;
|
setCwd: (cwd: string) => void;
|
||||||
@@ -59,7 +59,7 @@ interface AgentInputDraft {
|
|||||||
|
|
||||||
function hasDraftContent(input: {
|
function hasDraftContent(input: {
|
||||||
text: string;
|
text: string;
|
||||||
attachments: ComposerAttachment[];
|
attachments: UserComposerAttachment[];
|
||||||
cwd: string;
|
cwd: string;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -68,8 +68,8 @@ function hasDraftContent(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function areAttachmentsEqual(input: {
|
function areAttachmentsEqual(input: {
|
||||||
left: ComposerAttachment[];
|
left: UserComposerAttachment[];
|
||||||
right: ComposerAttachment[];
|
right: UserComposerAttachment[];
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (input.left.length !== input.right.length) {
|
if (input.left.length !== input.right.length) {
|
||||||
return false;
|
return false;
|
||||||
@@ -190,7 +190,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
[formState.selectedServerId, input.draftKey],
|
[formState.selectedServerId, input.draftKey],
|
||||||
);
|
);
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [attachments, setAttachmentsState] = useState<ComposerAttachment[]>([]);
|
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
|
||||||
const [cwd, setCwd] = useState(input.initialCwd ?? "");
|
const [cwd, setCwd] = useState(input.initialCwd ?? "");
|
||||||
const [isHydrated, setIsHydrated] = useState(false);
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
const draftGenerationRef = useRef(0);
|
const draftGenerationRef = useRef(0);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface CreateAttempt {
|
|||||||
text: string;
|
text: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
images?: UserMessageImageAttachment[];
|
images?: UserMessageImageAttachment[];
|
||||||
|
attachments?: AgentAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type DraftAgentMachineState =
|
type DraftAgentMachineState =
|
||||||
@@ -118,7 +119,11 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
return EMPTY_STREAM_ITEMS;
|
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;
|
return EMPTY_STREAM_ITEMS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +136,9 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
...(machine.attempt.images && machine.attempt.images.length > 0
|
...(machine.attempt.images && machine.attempt.images.length > 0
|
||||||
? { images: machine.attempt.images }
|
? { images: machine.attempt.images }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(machine.attempt.attachments && machine.attempt.attachments.length > 0
|
||||||
|
? { attachments: machine.attempt.attachments }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [machine]);
|
}, [machine]);
|
||||||
@@ -182,6 +190,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
text: trimmedPrompt,
|
text: trimmedPrompt,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
...(images && images.length > 0 ? { images } : {}),
|
...(images && images.length > 0 ? { images } : {}),
|
||||||
|
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
setPendingCreateAttempt({
|
setPendingCreateAttempt({
|
||||||
@@ -192,6 +201,9 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
text: attempt.text,
|
text: attempt.text,
|
||||||
timestamp: attempt.timestamp.getTime(),
|
timestamp: attempt.timestamp.getTime(),
|
||||||
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
|
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
|
||||||
|
...(attempt.attachments && attempt.attachments.length > 0
|
||||||
|
? { attachments: attempt.attachments }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeSubmit?.({
|
onBeforeSubmit?.({
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ vi.mock("react-native-unistyles", () => {
|
|||||||
create: createPanelTestStyles,
|
create: createPanelTestStyles,
|
||||||
},
|
},
|
||||||
withUnistyles: <T,>(component: T) => component,
|
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 type { ImageAttachment } from "@/components/message-input";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
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 { isNative, isWeb } from "@/constants/platform";
|
||||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||||
@@ -43,6 +49,7 @@ import {
|
|||||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { type Agent, useSessionStore } from "@/stores/session-store";
|
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
@@ -1071,6 +1078,9 @@ function AgentStreamSection({
|
|||||||
...(pendingCreate.images && pendingCreate.images.length > 0
|
...(pendingCreate.images && pendingCreate.images.length > 0
|
||||||
? { images: pendingCreate.images }
|
? { images: pendingCreate.images }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(pendingCreate.attachments && pendingCreate.attachments.length > 0
|
||||||
|
? { attachments: pendingCreate.attachments }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [pendingCreate, shouldUseOptimisticStream]);
|
}, [pendingCreate, shouldUseOptimisticStream]);
|
||||||
@@ -1101,7 +1111,10 @@ function AgentStreamSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pendingImages = pendingCreate.images;
|
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) => {
|
setAgentStreamTail(serverId, (previous) => {
|
||||||
const current = previous.get(agentId);
|
const current = previous.get(agentId);
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -1112,6 +1125,7 @@ function AgentStreamSection({
|
|||||||
streamItems: current,
|
streamItems: current,
|
||||||
clientMessageId: pendingCreate.clientMessageId,
|
clientMessageId: pendingCreate.clientMessageId,
|
||||||
images: pendingImages,
|
images: pendingImages,
|
||||||
|
attachments: pendingAttachments,
|
||||||
});
|
});
|
||||||
if (merged === current) {
|
if (merged === current) {
|
||||||
return previous;
|
return previous;
|
||||||
@@ -1231,6 +1245,8 @@ function ActiveAgentComposer({
|
|||||||
onMessageSent: () => void;
|
onMessageSent: () => void;
|
||||||
}) {
|
}) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
|
const { workspaceId } = usePaneContext();
|
||||||
const agentInputDraft = useAgentInputDraft({
|
const agentInputDraft = useAgentInputDraft({
|
||||||
draftKey: buildDraftStoreKey({
|
draftKey: buildDraftStoreKey({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -1238,6 +1254,32 @@ function ActiveAgentComposer({
|
|||||||
}),
|
}),
|
||||||
initialCwd,
|
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(
|
const inputAreaStyle = useMemo(
|
||||||
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }],
|
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }],
|
||||||
@@ -1253,6 +1295,8 @@ function ActiveAgentComposer({
|
|||||||
value={agentInputDraft.text}
|
value={agentInputDraft.text}
|
||||||
onChangeText={agentInputDraft.setText}
|
onChangeText={agentInputDraft.setText}
|
||||||
attachments={agentInputDraft.attachments}
|
attachments={agentInputDraft.attachments}
|
||||||
|
workspaceAttachments={workspaceAttachments}
|
||||||
|
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||||
onChangeAttachments={agentInputDraft.setAttachments}
|
onChangeAttachments={agentInputDraft.setAttachments}
|
||||||
cwd={agentInputDraft.cwd}
|
cwd={agentInputDraft.cwd}
|
||||||
clearDraft={agentInputDraft.clear}
|
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 { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
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 { ImageAttachment, MessagePayload } from "@/components/message-input";
|
||||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
@@ -195,10 +195,10 @@ function pickerItemTriggerLabel(item: PickerItem): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncPickerPrAttachment(input: {
|
function syncPickerPrAttachment(input: {
|
||||||
attachments: ComposerAttachment[];
|
attachments: UserComposerAttachment[];
|
||||||
previousPickerPrNumber: number | null;
|
previousPickerPrNumber: number | null;
|
||||||
item: PickerItem;
|
item: PickerItem;
|
||||||
}): { attachments: ComposerAttachment[]; attachedPrNumber: number | null } {
|
}): { attachments: UserComposerAttachment[]; attachedPrNumber: number | null } {
|
||||||
let nextAttachments = input.attachments;
|
let nextAttachments = input.attachments;
|
||||||
let attachedPrNumber: number | null = null;
|
let attachedPrNumber: number | null = null;
|
||||||
|
|
||||||
@@ -373,7 +373,9 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
|||||||
}),
|
}),
|
||||||
draft: {
|
draft: {
|
||||||
text,
|
text,
|
||||||
attachments,
|
attachments: attachments.filter(
|
||||||
|
(attachment): attachment is UserComposerAttachment => attachment.kind !== "review",
|
||||||
|
),
|
||||||
cwd: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ import { WorkspaceDraftAgentTab } from "./workspace-draft-agent-tab";
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
createAgentMock,
|
createAgentMock,
|
||||||
|
getCheckoutStatusMock,
|
||||||
|
onRuntimeEventMock,
|
||||||
latestComposerText,
|
latestComposerText,
|
||||||
latestComposerDisabled,
|
latestComposerDisabled,
|
||||||
latestStreamText,
|
latestStreamText,
|
||||||
onCreatedMock,
|
onCreatedMock,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
createAgentMock: vi.fn(),
|
createAgentMock: vi.fn(),
|
||||||
|
getCheckoutStatusMock: vi.fn(),
|
||||||
|
onRuntimeEventMock: vi.fn(() => () => {}),
|
||||||
latestComposerText: { current: null as string | null },
|
latestComposerText: { current: null as string | null },
|
||||||
latestComposerDisabled: { current: null as boolean | null },
|
latestComposerDisabled: { current: null as boolean | null },
|
||||||
latestStreamText: { current: null as string | null },
|
latestStreamText: { current: null as string | null },
|
||||||
@@ -43,6 +47,7 @@ vi.mock("react-native-unistyles", () => ({
|
|||||||
})
|
})
|
||||||
: factory,
|
: factory,
|
||||||
},
|
},
|
||||||
|
useUnistyles: () => ({ rt: { breakpoint: "lg" } }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("react-native-safe-area-context", () => ({
|
vi.mock("react-native-safe-area-context", () => ({
|
||||||
@@ -57,6 +62,8 @@ vi.mock("@/constants/platform", () => ({
|
|||||||
vi.mock("@/runtime/host-runtime", () => ({
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
useHostRuntimeClient: () => ({
|
useHostRuntimeClient: () => ({
|
||||||
createAgent: createAgentMock,
|
createAgent: createAgentMock,
|
||||||
|
getCheckoutStatus: getCheckoutStatusMock,
|
||||||
|
on: onRuntimeEventMock,
|
||||||
}),
|
}),
|
||||||
useHostRuntimeIsConnected: () => true,
|
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", () => ({
|
vi.mock("@/components/composer", () => ({
|
||||||
Composer: ({
|
Composer: ({
|
||||||
value,
|
value,
|
||||||
@@ -196,6 +213,23 @@ beforeEach(() => {
|
|||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
createAgentMock.mockReset();
|
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();
|
onCreatedMock.mockReset();
|
||||||
latestComposerText.current = null;
|
latestComposerText.current = null;
|
||||||
latestComposerDisabled.current = null;
|
latestComposerDisabled.current = null;
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import invariant from "tiny-invariant";
|
|||||||
import { Composer } from "@/components/composer";
|
import { Composer } from "@/components/composer";
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
|
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
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 { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
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 type { UserMessageImageAttachment } from "@/types/stream";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||||
@@ -324,6 +331,33 @@ export function WorkspaceDraftAgentTab({
|
|||||||
);
|
);
|
||||||
const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit);
|
const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit);
|
||||||
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
|
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 {
|
const {
|
||||||
formErrorMessage,
|
formErrorMessage,
|
||||||
@@ -408,7 +442,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
cwd: submission.cwd,
|
cwd: submission.cwd,
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setDraftText(submission.text);
|
setDraftText(submission.text);
|
||||||
setDraftAttachments(submission.attachments);
|
setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments));
|
||||||
autoSubmitKeyRef.current = null;
|
autoSubmitKeyRef.current = null;
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
@@ -562,6 +596,8 @@ export function WorkspaceDraftAgentTab({
|
|||||||
value={draftInput.text}
|
value={draftInput.text}
|
||||||
onChangeText={draftInput.setText}
|
onChangeText={draftInput.setText}
|
||||||
attachments={draftInput.attachments}
|
attachments={draftInput.attachments}
|
||||||
|
workspaceAttachments={workspaceAttachments}
|
||||||
|
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||||
onChangeAttachments={draftInput.setAttachments}
|
onChangeAttachments={draftInput.setAttachments}
|
||||||
cwd={draftInput.cwd}
|
cwd={draftInput.cwd}
|
||||||
clearDraft={draftInput.clear}
|
clearDraft={draftInput.clear}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||||
|
import type { AgentAttachment } from "@server/shared/messages";
|
||||||
|
|
||||||
export type CreateFlowLifecycleState = "active" | "abandoned" | "sent";
|
export type CreateFlowLifecycleState = "active" | "abandoned" | "sent";
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ interface PendingCreateAttempt {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
lifecycle: CreateFlowLifecycleState;
|
lifecycle: CreateFlowLifecycleState;
|
||||||
images?: UserMessageImageAttachment[];
|
images?: UserMessageImageAttachment[];
|
||||||
|
attachments?: AgentAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateFlowState {
|
interface CreateFlowState {
|
||||||
|
|||||||
@@ -1,8 +1,53 @@
|
|||||||
import "@/test/window-local-storage";
|
import "@/test/window-local-storage";
|
||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { __draftStoreTestUtils } from "./draft-store";
|
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", () => {
|
describe("draft-store migration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useDraftStore.setState({ drafts: {}, createModalDraft: null });
|
||||||
|
});
|
||||||
|
|
||||||
it("normalizes legacy image metadata into image attachments and strips persisted preview URLs", async () => {
|
it("normalizes legacy image metadata into image attachments and strips persisted preview URLs", async () => {
|
||||||
const migrated = await __draftStoreTestUtils.migratePersistedState({
|
const migrated = await __draftStoreTestUtils.migratePersistedState({
|
||||||
drafts: {
|
drafts: {
|
||||||
@@ -79,4 +124,24 @@ describe("draft-store migration", () => {
|
|||||||
|
|
||||||
expect(twice).toEqual(once);
|
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 { create } from "zustand";
|
||||||
import { createJSONStorage, persist } from "zustand/middleware";
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
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 { GitHubSearchItemSchema } from "@server/shared/messages";
|
||||||
import {
|
import {
|
||||||
garbageCollectAttachments,
|
garbageCollectAttachments,
|
||||||
@@ -23,7 +23,7 @@ type PersistedDraftImage = AttachmentMetadata | LegacyDraftImage;
|
|||||||
|
|
||||||
export interface DraftInput {
|
export interface DraftInput {
|
||||||
text: string;
|
text: string;
|
||||||
attachments: ComposerAttachment[];
|
attachments: UserComposerAttachment[];
|
||||||
cwd: string;
|
cwd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ function normalizePersistedImage(value: unknown): PersistedDraftImage | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isComposerAttachment(value: unknown): value is ComposerAttachment {
|
function isUserComposerAttachment(value: unknown): value is UserComposerAttachment {
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,7 @@ function isComposerAttachment(value: unknown): value is ComposerAttachment {
|
|||||||
return GitHubSearchItemSchema.safeParse(record.item).success;
|
return GitHubSearchItemSchema.safeParse(record.item).success;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeComposerAttachment(attachment: ComposerAttachment): ComposerAttachment {
|
function normalizeComposerAttachment(attachment: UserComposerAttachment): UserComposerAttachment {
|
||||||
if (attachment.kind === "image") {
|
if (attachment.kind === "image") {
|
||||||
return {
|
return {
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -160,14 +160,16 @@ function normalizeComposerAttachment(attachment: ComposerAttachment): ComposerAt
|
|||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePersistedComposerAttachment(value: unknown): ComposerAttachment | null {
|
function normalizePersistedComposerAttachment(value: unknown): UserComposerAttachment | null {
|
||||||
if (!isComposerAttachment(value)) {
|
if (!isUserComposerAttachment(value)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return normalizeComposerAttachment(value);
|
return normalizeComposerAttachment(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function legacyImagesToAttachments(images: readonly AttachmentMetadata[]): ComposerAttachment[] {
|
function legacyImagesToAttachments(
|
||||||
|
images: readonly AttachmentMetadata[],
|
||||||
|
): UserComposerAttachment[] {
|
||||||
return images.map((metadata) => ({
|
return images.map((metadata) => ({
|
||||||
kind: "image",
|
kind: "image",
|
||||||
metadata,
|
metadata,
|
||||||
@@ -182,7 +184,7 @@ function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput {
|
|||||||
return (
|
return (
|
||||||
typeof input.text === "string" &&
|
typeof input.text === "string" &&
|
||||||
Array.isArray(input.attachments) &&
|
Array.isArray(input.attachments) &&
|
||||||
input.attachments.every(isComposerAttachment) &&
|
input.attachments.every(isUserComposerAttachment) &&
|
||||||
typeof input.cwd === "string"
|
typeof input.cwd === "string"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -426,7 +428,7 @@ async function migrateDraftInput(input: {
|
|||||||
const attachments = Array.isArray(rawInput.attachments)
|
const attachments = Array.isArray(rawInput.attachments)
|
||||||
? rawInput.attachments
|
? rawInput.attachments
|
||||||
.map((entry) => normalizePersistedComposerAttachment(entry))
|
.map((entry) => normalizePersistedComposerAttachment(entry))
|
||||||
.filter((entry): entry is ComposerAttachment => entry !== null)
|
.filter((entry): entry is UserComposerAttachment => entry !== null)
|
||||||
: [];
|
: [];
|
||||||
const legacyImages = Array.isArray(rawInput.images)
|
const legacyImages = Array.isArray(rawInput.images)
|
||||||
? rawInput.images
|
? rawInput.images
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AgentProvider, ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
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 type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
|
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
|
||||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||||
@@ -60,6 +60,7 @@ export interface UserMessageItem {
|
|||||||
text: string;
|
text: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
images?: UserMessageImageAttachment[];
|
images?: UserMessageImageAttachment[];
|
||||||
|
attachments?: AgentAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssistantMessageItem {
|
export interface AssistantMessageItem {
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 { 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 {
|
return {
|
||||||
path: "example.ts",
|
path: "example.ts",
|
||||||
isNew: false,
|
isNew: false,
|
||||||
@@ -12,9 +22,9 @@ function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFi
|
|||||||
status: "ok",
|
status: "ok",
|
||||||
hunks: [
|
hunks: [
|
||||||
{
|
{
|
||||||
oldStart: 10,
|
oldStart,
|
||||||
oldCount: 4,
|
oldCount: 4,
|
||||||
newStart: 10,
|
newStart,
|
||||||
newCount: 5,
|
newCount: 5,
|
||||||
lines,
|
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", () => {
|
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", () => {
|
it("pairs replacement runs by index", () => {
|
||||||
const rows = buildSplitDiffRows(
|
const rows = buildSplitDiffRows(
|
||||||
makeFile([
|
makeFile([
|
||||||
@@ -78,6 +120,86 @@ describe("buildSplitDiffRows", () => {
|
|||||||
right: { type: "context", content: "same line", lineNumber: 10 },
|
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", () => {
|
describe("buildUnifiedDiffLines", () => {
|
||||||
@@ -151,4 +273,53 @@ describe("buildUnifiedDiffLines", () => {
|
|||||||
expect(lines[5]?.lineNumber).toBe(166);
|
expect(lines[5]?.lineNumber).toBe(166);
|
||||||
expect(lines[6]?.lineNumber).toBe(167);
|
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";
|
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 {
|
export interface SplitDiffDisplayLine {
|
||||||
type: DiffLine["type"];
|
type: DiffLine["type"];
|
||||||
content: string;
|
content: string;
|
||||||
tokens?: DiffLine["tokens"];
|
tokens?: DiffLine["tokens"];
|
||||||
lineNumber: number | null;
|
lineNumber: number | null;
|
||||||
|
reviewTarget: ReviewableDiffTarget | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnifiedDiffDisplayLine {
|
export interface UnifiedDiffDisplayLine {
|
||||||
key: string;
|
key: string;
|
||||||
line: DiffLine;
|
line: DiffLine;
|
||||||
lineNumber: number | null;
|
lineNumber: number | null;
|
||||||
|
reviewTarget: ReviewableDiffTarget | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SplitDiffRow =
|
export type SplitDiffRow =
|
||||||
@@ -24,95 +77,175 @@ export type SplitDiffRow =
|
|||||||
right: SplitDiffDisplayLine | null;
|
right: SplitDiffDisplayLine | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toDisplayLine(input: {
|
function toSplitDisplayLine(cell: NumberedDiffCell | null): SplitDiffDisplayLine | null {
|
||||||
line: DiffLine;
|
if (!cell) {
|
||||||
oldLineNumber: number | null;
|
|
||||||
newLineNumber: number | null;
|
|
||||||
side: "left" | "right";
|
|
||||||
}): SplitDiffDisplayLine | null {
|
|
||||||
const { line, oldLineNumber, newLineNumber, side } = input;
|
|
||||||
if (line.type === "header") {
|
|
||||||
return null;
|
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 {
|
return {
|
||||||
type: "context",
|
type: cell.lineType,
|
||||||
content: line.content,
|
content: cell.content,
|
||||||
tokens: line.tokens,
|
...(cell.line.tokens ? { tokens: cell.line.tokens } : {}),
|
||||||
lineNumber: side === "left" ? oldLineNumber : newLineNumber,
|
lineNumber: cell.lineNumber,
|
||||||
|
reviewTarget: toReviewTarget(cell),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildUnifiedDiffLines(file: ParsedDiffFile): UnifiedDiffDisplayLine[] {
|
function toReviewTarget(cell: NumberedDiffCell): ReviewableDiffTarget {
|
||||||
const lines: UnifiedDiffDisplayLine[] = [];
|
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()) {
|
for (const [hunkIndex, hunk] of file.hunks.entries()) {
|
||||||
let oldLineNo = hunk.oldStart;
|
let oldLineNo = hunk.oldStart;
|
||||||
let newLineNo = hunk.newStart;
|
let newLineNo = hunk.newStart;
|
||||||
|
const hunkHeader = getHunkHeader(hunk);
|
||||||
|
const lines: NumberedDiffLine[] = [];
|
||||||
|
|
||||||
for (const [lineIndex, line] of hunk.lines.entries()) {
|
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") {
|
if (line.type === "remove") {
|
||||||
lineNumber = oldLineNo;
|
oldLineNumber = oldLineNo;
|
||||||
oldLineNo += 1;
|
oldLineNo += 1;
|
||||||
} else if (line.type === "add") {
|
} else if (line.type === "add") {
|
||||||
lineNumber = newLineNo;
|
newLineNumber = newLineNo;
|
||||||
newLineNo += 1;
|
newLineNo += 1;
|
||||||
} else if (line.type === "context") {
|
} else if (line.type === "context") {
|
||||||
lineNumber = newLineNo;
|
oldLineNumber = oldLineNo;
|
||||||
|
newLineNumber = newLineNo;
|
||||||
oldLineNo += 1;
|
oldLineNo += 1;
|
||||||
newLineNo += 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({
|
lines.push({
|
||||||
key: `${hunkIndex}-${lineIndex}`,
|
key: `${hunkIndex}-${lineIndex}`,
|
||||||
|
filePath: file.path,
|
||||||
|
hunkHeader,
|
||||||
|
hunkIndex,
|
||||||
|
lineIndex,
|
||||||
line,
|
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[] {
|
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
|
||||||
const rows: SplitDiffRow[] = [];
|
const rows: SplitDiffRow[] = [];
|
||||||
|
|
||||||
for (const hunk of file.hunks) {
|
for (const hunk of buildNumberedDiffHunks(file)) {
|
||||||
let oldLineNo = hunk.oldStart;
|
|
||||||
let newLineNo = hunk.newStart;
|
|
||||||
rows.push({
|
rows.push({
|
||||||
kind: "header",
|
kind: "header",
|
||||||
content: hunk.lines[0]?.type === "header" ? hunk.lines[0].content : "@@",
|
content: hunk.hunkHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
let pendingRemovals: Array<{ line: DiffLine; oldLineNumber: number }> = [];
|
let pendingRemovals: NumberedDiffCell[] = [];
|
||||||
let pendingAdditions: Array<{ line: DiffLine; newLineNumber: number }> = [];
|
let pendingAdditions: NumberedDiffCell[] = [];
|
||||||
|
|
||||||
const flushPendingRows = () => {
|
const flushPendingRows = () => {
|
||||||
const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length);
|
const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length);
|
||||||
@@ -121,61 +254,41 @@ export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
|
|||||||
const addition = pendingAdditions[index] ?? null;
|
const addition = pendingAdditions[index] ?? null;
|
||||||
rows.push({
|
rows.push({
|
||||||
kind: "pair",
|
kind: "pair",
|
||||||
left: removal
|
left: toSplitDisplayLine(removal),
|
||||||
? toDisplayLine({
|
right: toSplitDisplayLine(addition),
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
pendingRemovals = [];
|
pendingRemovals = [];
|
||||||
pendingAdditions = [];
|
pendingAdditions = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const line of hunk.lines.slice(1)) {
|
for (const numberedLine of hunk.lines) {
|
||||||
if (line.type === "remove") {
|
if (numberedLine.line.type === "header") {
|
||||||
pendingRemovals.push({ line, oldLineNumber: oldLineNo });
|
|
||||||
oldLineNo += 1;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (line.type === "add") {
|
if (numberedLine.line.type === "remove") {
|
||||||
pendingAdditions.push({ line, newLineNumber: newLineNo });
|
if (numberedLine.oldCell) {
|
||||||
newLineNo += 1;
|
pendingRemovals.push(numberedLine.oldCell);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numberedLine.line.type === "add") {
|
||||||
|
if (numberedLine.newCell) {
|
||||||
|
pendingAdditions.push(numberedLine.newCell);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
flushPendingRows();
|
flushPendingRows();
|
||||||
|
|
||||||
if (line.type === "context") {
|
if (numberedLine.line.type === "context") {
|
||||||
rows.push({
|
rows.push({
|
||||||
kind: "pair",
|
kind: "pair",
|
||||||
left: toDisplayLine({
|
left: toSplitDisplayLine(numberedLine.oldCell),
|
||||||
line,
|
right: toSplitDisplayLine(numberedLine.newCell),
|
||||||
oldLineNumber: oldLineNo,
|
|
||||||
newLineNumber: newLineNo,
|
|
||||||
side: "left",
|
|
||||||
}),
|
|
||||||
right: toDisplayLine({
|
|
||||||
line,
|
|
||||||
oldLineNumber: oldLineNo,
|
|
||||||
newLineNumber: newLineNo,
|
|
||||||
side: "right",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
oldLineNo += 1;
|
|
||||||
newLineNo += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import { mergePendingCreateImages } from "./pending-create-images";
|
import { mergePendingCreateImages } from "./pending-create-images";
|
||||||
|
import type { AgentAttachment } from "@server/shared/messages";
|
||||||
|
|
||||||
function userMessage(params: {
|
function userMessage(params: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,6 +13,7 @@ function userMessage(params: {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}>;
|
}>;
|
||||||
|
attachments?: AgentAttachment[];
|
||||||
}): StreamItem {
|
}): StreamItem {
|
||||||
return {
|
return {
|
||||||
kind: "user_message",
|
kind: "user_message",
|
||||||
@@ -19,6 +21,7 @@ function userMessage(params: {
|
|||||||
text: params.text,
|
text: params.text,
|
||||||
timestamp: new Date("2026-01-01T00:00:00Z"),
|
timestamp: new Date("2026-01-01T00:00:00Z"),
|
||||||
...(params.images ? { images: params.images } : {}),
|
...(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", () => {
|
describe("mergePendingCreateImages", () => {
|
||||||
it("returns same reference when pending images are absent", () => {
|
it("returns same reference when pending images are absent", () => {
|
||||||
const streamItems = [userMessage({ id: "msg-1", text: "hello" })];
|
const streamItems = [userMessage({ id: "msg-1", text: "hello" })];
|
||||||
@@ -63,6 +76,24 @@ describe("mergePendingCreateImages", () => {
|
|||||||
expect(updated.images).toEqual(images);
|
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", () => {
|
it("does not merge when clientMessageId does not match", () => {
|
||||||
const streamItems = [userMessage({ id: "msg-1", text: "same text" })];
|
const streamItems = [userMessage({ id: "msg-1", text: "same text" })];
|
||||||
const images = buildImage("image-2");
|
const images = buildImage("image-2");
|
||||||
@@ -92,4 +123,32 @@ describe("mergePendingCreateImages", () => {
|
|||||||
}
|
}
|
||||||
expect(unchanged.images).toEqual(existingImages);
|
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 { StreamItem, UserMessageImageAttachment } from "@/types/stream";
|
||||||
|
import type { AgentAttachment } from "@server/shared/messages";
|
||||||
|
|
||||||
interface MergePendingCreateImagesParams {
|
interface MergePendingCreateImagesParams {
|
||||||
streamItems: StreamItem[];
|
streamItems: StreamItem[];
|
||||||
clientMessageId: string;
|
clientMessageId: string;
|
||||||
images?: UserMessageImageAttachment[];
|
images?: UserMessageImageAttachment[];
|
||||||
|
attachments?: AgentAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergePendingCreateImages({
|
export function mergePendingCreateImages({
|
||||||
streamItems,
|
streamItems,
|
||||||
clientMessageId,
|
clientMessageId,
|
||||||
images,
|
images,
|
||||||
|
attachments,
|
||||||
}: MergePendingCreateImagesParams): StreamItem[] {
|
}: 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;
|
return streamItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,11 +31,18 @@ export function mergePendingCreateImages({
|
|||||||
if (target.kind !== "user_message") {
|
if (target.kind !== "user_message") {
|
||||||
return streamItems;
|
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;
|
return streamItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = [...streamItems];
|
const next = [...streamItems];
|
||||||
next[targetIndex] = { ...target, images };
|
next[targetIndex] = {
|
||||||
|
...target,
|
||||||
|
...(shouldMergeImages ? { images } : {}),
|
||||||
|
...(shouldMergeAttachments ? { attachments } : {}),
|
||||||
|
};
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,73 @@ describe("prompt attachments", () => {
|
|||||||
).toContain("GitHub PR #123: Fix race in worktree setup");
|
).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", () => {
|
it("finds the first github_pr attachment", () => {
|
||||||
expect(
|
expect(
|
||||||
findGitHubPrAttachment([
|
findGitHubPrAttachment([
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { AgentAttachment } from "../../shared/messages.js";
|
import type { AgentAttachment } from "../../shared/messages.js";
|
||||||
|
|
||||||
|
const REVIEW_LINE_MARKERS = { add: "+", remove: "-", context: " " } as const;
|
||||||
|
|
||||||
export function renderPromptAttachmentAsText(attachment: AgentAttachment): string {
|
export function renderPromptAttachmentAsText(attachment: AgentAttachment): string {
|
||||||
switch (attachment.type) {
|
switch (attachment.type) {
|
||||||
case "github_pr": {
|
case "github_pr": {
|
||||||
@@ -22,9 +24,40 @@ export function renderPromptAttachmentAsText(attachment: AgentAttachment): strin
|
|||||||
}
|
}
|
||||||
return lines.join("\n");
|
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(
|
export function findGitHubPrAttachment(
|
||||||
attachments: readonly AgentAttachment[] | undefined,
|
attachments: readonly AgentAttachment[] | undefined,
|
||||||
): Extract<AgentAttachment, { type: "github_pr" }> | null {
|
): Extract<AgentAttachment, { type: "github_pr" }> | null {
|
||||||
|
|||||||
@@ -2091,19 +2091,21 @@ function toACPContentBlocks(prompt: AgentPromptInput): ContentBlock[] {
|
|||||||
return [{ type: "text", text: prompt }];
|
return [{ type: "text", text: prompt }];
|
||||||
}
|
}
|
||||||
|
|
||||||
return prompt.map((block: AgentPromptContentBlock) => {
|
const contentBlocks: ContentBlock[] = [];
|
||||||
if (block.type === "text") {
|
for (const block of prompt) {
|
||||||
return { type: "text", text: block.text };
|
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 contentBlocks;
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: "image",
|
|
||||||
data: block.data,
|
|
||||||
mimeType: block.mimeType,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPromptText(prompt: AgentPromptInput): string {
|
function extractPromptText(prompt: AgentPromptInput): string {
|
||||||
|
|||||||
@@ -2345,11 +2345,8 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
data: chunk.data,
|
data: chunk.data,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (chunk.type === "github_pr" || chunk.type === "github_issue") {
|
} else {
|
||||||
content.push({
|
content.push({ type: "text", text: renderPromptAttachmentAsText(chunk) });
|
||||||
type: "text",
|
|
||||||
text: renderPromptAttachmentAsText(chunk),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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", () => {
|
test("maps patch notifications with array-style changes and alias diff keys", () => {
|
||||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
||||||
callId: "patch-array-alias",
|
callId: "patch-array-alias",
|
||||||
|
|||||||
@@ -2410,41 +2410,36 @@ export async function codexAppServerTurnInputFromPrompt(
|
|||||||
return [{ type: "text", text: prompt }];
|
return [{ type: "text", text: prompt }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocks = prompt as Array<unknown>;
|
const output: unknown[] = [];
|
||||||
const output = await Promise.all(
|
let previousTextBlock = false;
|
||||||
blocks.map(async (block) => {
|
for (const block of prompt) {
|
||||||
if (!block || typeof block !== "object") {
|
if (block.type === "text") {
|
||||||
return block;
|
output.push(block);
|
||||||
}
|
previousTextBlock = block.text.length > 0;
|
||||||
const record = block as { type?: unknown; mimeType?: unknown; data?: unknown };
|
continue;
|
||||||
if (
|
}
|
||||||
record.type === "image" &&
|
if (block.type === "image") {
|
||||||
typeof record.mimeType === "string" &&
|
try {
|
||||||
typeof record.data === "string"
|
const filePath = await writeImageAttachment(block.mimeType, block.data);
|
||||||
) {
|
output.push({ type: "localImage", path: filePath });
|
||||||
try {
|
} catch (error) {
|
||||||
const filePath = await writeImageAttachment(record.mimeType, record.data);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
return { type: "localImage", path: filePath };
|
logger.warn({ message }, "Failed to write Codex image attachment");
|
||||||
} catch (error) {
|
output.push({
|
||||||
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 {
|
|
||||||
type: "text",
|
type: "text",
|
||||||
text: renderPromptAttachmentAsText(
|
text: `User attached image (failed to write temp file): ${message}`,
|
||||||
record as Extract<AgentPromptContentBlock, { type: "github_pr" | "github_issue" }>,
|
});
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return block;
|
previousTextBlock = false;
|
||||||
}),
|
continue;
|
||||||
);
|
}
|
||||||
|
const attachmentText = renderPromptAttachmentAsText(block);
|
||||||
|
output.push({
|
||||||
|
type: "text",
|
||||||
|
text: previousTextBlock ? `\n\n${attachmentText}` : attachmentText,
|
||||||
|
});
|
||||||
|
previousTextBlock = true;
|
||||||
|
}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -698,20 +698,20 @@ function buildOpenCodePromptParts(
|
|||||||
output.push({ type: "text", text: part.text });
|
output.push({ type: "text", text: part.text });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (part.type === "github_pr" || part.type === "github_issue") {
|
if (part.type === "image") {
|
||||||
output.push({ type: "text", text: renderPromptAttachmentAsText(part) });
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
attachmentOrdinal += 1;
|
output.push({ type: "text", text: renderPromptAttachmentAsText(part) });
|
||||||
const normalized = toOpenCodeDataUrl(part.mimeType, part.data);
|
|
||||||
output.push({
|
|
||||||
type: "file",
|
|
||||||
mime: normalized.mimeType,
|
|
||||||
filename: `attachment-${attachmentOrdinal}.${getOpenCodeAttachmentExtension(
|
|
||||||
normalized.mimeType,
|
|
||||||
)}`,
|
|
||||||
url: normalized.url,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,148 @@ import {
|
|||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
|
|
||||||
describe("shared messages attachments", () => {
|
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", () => {
|
it("keeps known attachments and drops unknown create-agent attachments", () => {
|
||||||
const parsed = CreateAgentRequestMessageSchema.parse({
|
const parsed = CreateAgentRequestMessageSchema.parse({
|
||||||
type: "create_agent_request",
|
type: "create_agent_request",
|
||||||
|
|||||||
@@ -733,9 +733,38 @@ export const GitHubIssueAttachmentSchema = z.object({
|
|||||||
body: z.string().nullable().optional(),
|
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", [
|
export const AgentAttachmentSchema = z.discriminatedUnion("type", [
|
||||||
GitHubPrAttachmentSchema,
|
GitHubPrAttachmentSchema,
|
||||||
GitHubIssueAttachmentSchema,
|
GitHubIssueAttachmentSchema,
|
||||||
|
ReviewAttachmentSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function normalizeAgentAttachments(input: unknown): AgentAttachment[] {
|
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 DictationStreamCancelMessage = z.infer<typeof DictationStreamCancelMessageSchema>;
|
||||||
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
|
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
|
||||||
export type AgentAttachment = z.infer<typeof AgentAttachmentSchema>;
|
export type AgentAttachment = z.infer<typeof AgentAttachmentSchema>;
|
||||||
|
export type ReviewAttachment = z.infer<typeof ReviewAttachmentSchema>;
|
||||||
export type ListProviderModelsRequestMessage = z.infer<
|
export type ListProviderModelsRequestMessage = z.infer<
|
||||||
typeof ListProviderModelsRequestMessageSchema
|
typeof ListProviderModelsRequestMessageSchema
|
||||||
>;
|
>;
|
||||||
|
|||||||
Reference in New Issue
Block a user