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:
@@ -18,6 +18,73 @@ describe("prompt attachments", () => {
|
||||
).toContain("GitHub PR #123: Fix race in worktree setup");
|
||||
});
|
||||
|
||||
it("renders review attachments with compact file, line, comment, and context details", () => {
|
||||
expect(
|
||||
renderPromptAttachmentAsText({
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/tmp/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: 42,
|
||||
body: "Please guard this nullable value.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,3 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: 41,
|
||||
newLineNumber: 41,
|
||||
type: "context",
|
||||
content: "const before = true;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"Paseo review attachment (base)",
|
||||
"CWD: /tmp/repo",
|
||||
"Base: main",
|
||||
"",
|
||||
"Comment 1: src/index.ts:new:42",
|
||||
"Please guard this nullable value.",
|
||||
"@@ -40,3 +40,4 @@",
|
||||
" 41 41 const before = true;",
|
||||
"> - 42 +const value = maybeNull.name;",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders github_issue attachments as readable text", () => {
|
||||
expect(
|
||||
renderPromptAttachmentAsText({
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Issue",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
}),
|
||||
).toContain("GitHub Issue #55: Issue");
|
||||
});
|
||||
|
||||
it("finds the first github_pr attachment", () => {
|
||||
expect(
|
||||
findGitHubPrAttachment([
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AgentAttachment } from "../../shared/messages.js";
|
||||
|
||||
const REVIEW_LINE_MARKERS = { add: "+", remove: "-", context: " " } as const;
|
||||
|
||||
export function renderPromptAttachmentAsText(attachment: AgentAttachment): string {
|
||||
switch (attachment.type) {
|
||||
case "github_pr": {
|
||||
@@ -22,9 +24,40 @@ export function renderPromptAttachmentAsText(attachment: AgentAttachment): strin
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
case "review": {
|
||||
const lines = [`Paseo review attachment (${attachment.mode})`, `CWD: ${attachment.cwd}`];
|
||||
if (attachment.baseRef) {
|
||||
lines.push(`Base: ${attachment.baseRef}`);
|
||||
}
|
||||
attachment.comments.forEach((comment, index) => {
|
||||
lines.push(
|
||||
"",
|
||||
`Comment ${index + 1}: ${comment.filePath}:${comment.side}:${comment.lineNumber}`,
|
||||
comment.body,
|
||||
comment.context.hunkHeader,
|
||||
);
|
||||
const target = comment.context.targetLine;
|
||||
for (const line of comment.context.lines) {
|
||||
const isTarget =
|
||||
line.oldLineNumber === target.oldLineNumber &&
|
||||
line.newLineNumber === target.newLineNumber &&
|
||||
line.type === target.type &&
|
||||
line.content === target.content;
|
||||
const prefix = isTarget ? "> " : " ";
|
||||
const oldLn = padLineNumber(line.oldLineNumber);
|
||||
const newLn = padLineNumber(line.newLineNumber);
|
||||
lines.push(`${prefix}${oldLn} ${newLn} ${REVIEW_LINE_MARKERS[line.type]}${line.content}`);
|
||||
}
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function padLineNumber(lineNumber: number | null): string {
|
||||
return (lineNumber?.toString() ?? "-").padStart(2);
|
||||
}
|
||||
|
||||
export function findGitHubPrAttachment(
|
||||
attachments: readonly AgentAttachment[] | undefined,
|
||||
): Extract<AgentAttachment, { type: "github_pr" }> | null {
|
||||
|
||||
@@ -2091,19 +2091,21 @@ function toACPContentBlocks(prompt: AgentPromptInput): ContentBlock[] {
|
||||
return [{ type: "text", text: prompt }];
|
||||
}
|
||||
|
||||
return prompt.map((block: AgentPromptContentBlock) => {
|
||||
if (block.type === "text") {
|
||||
return { type: "text", text: block.text };
|
||||
const contentBlocks: ContentBlock[] = [];
|
||||
for (const block of prompt) {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
contentBlocks.push({ type: "text", text: block.text });
|
||||
break;
|
||||
case "image":
|
||||
contentBlocks.push({ type: "image", data: block.data, mimeType: block.mimeType });
|
||||
break;
|
||||
default:
|
||||
contentBlocks.push({ type: "text", text: renderPromptAttachmentAsText(block) });
|
||||
break;
|
||||
}
|
||||
if (block.type === "github_pr" || block.type === "github_issue") {
|
||||
return { type: "text", text: renderPromptAttachmentAsText(block) };
|
||||
}
|
||||
return {
|
||||
type: "image",
|
||||
data: block.data,
|
||||
mimeType: block.mimeType,
|
||||
};
|
||||
});
|
||||
}
|
||||
return contentBlocks;
|
||||
}
|
||||
|
||||
function extractPromptText(prompt: AgentPromptInput): string {
|
||||
|
||||
@@ -2345,11 +2345,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
data: chunk.data,
|
||||
},
|
||||
});
|
||||
} else if (chunk.type === "github_pr" || chunk.type === "github_issue") {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: renderPromptAttachmentAsText(chunk),
|
||||
});
|
||||
} else {
|
||||
content.push({ type: "text", text: renderPromptAttachmentAsText(chunk) });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -357,6 +357,52 @@ describe("Codex app-server provider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("separates Codex text prompts from rendered attachment text", async () => {
|
||||
const input = await codexAppServerTurnInputFromPrompt(
|
||||
[
|
||||
{ type: "text", text: "Please review this" },
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 456,
|
||||
title: "Attachment spacing",
|
||||
url: "https://github.com/getpaseo/paseo/issues/456",
|
||||
},
|
||||
],
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(input).toEqual([
|
||||
{ type: "text", text: "Please review this" },
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringMatching(/^\n\nGitHub Issue #456: Attachment spacing/),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not prefix Codex attachment-only prompts with a blank line", async () => {
|
||||
const input = await codexAppServerTurnInputFromPrompt(
|
||||
[
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 456,
|
||||
title: "Attachment spacing",
|
||||
url: "https://github.com/getpaseo/paseo/issues/456",
|
||||
},
|
||||
],
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(input).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringMatching(/^GitHub Issue #456: Attachment spacing/),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps patch notifications with array-style changes and alias diff keys", () => {
|
||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
||||
callId: "patch-array-alias",
|
||||
|
||||
@@ -2410,41 +2410,36 @@ export async function codexAppServerTurnInputFromPrompt(
|
||||
return [{ type: "text", text: prompt }];
|
||||
}
|
||||
|
||||
const blocks = prompt as Array<unknown>;
|
||||
const output = await Promise.all(
|
||||
blocks.map(async (block) => {
|
||||
if (!block || typeof block !== "object") {
|
||||
return block;
|
||||
}
|
||||
const record = block as { type?: unknown; mimeType?: unknown; data?: unknown };
|
||||
if (
|
||||
record.type === "image" &&
|
||||
typeof record.mimeType === "string" &&
|
||||
typeof record.data === "string"
|
||||
) {
|
||||
try {
|
||||
const filePath = await writeImageAttachment(record.mimeType, record.data);
|
||||
return { type: "localImage", path: filePath };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn({ message }, "Failed to write Codex image attachment");
|
||||
return {
|
||||
type: "text",
|
||||
text: `User attached image (failed to write temp file): ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (record.type === "github_pr" || record.type === "github_issue") {
|
||||
return {
|
||||
const output: unknown[] = [];
|
||||
let previousTextBlock = false;
|
||||
for (const block of prompt) {
|
||||
if (block.type === "text") {
|
||||
output.push(block);
|
||||
previousTextBlock = block.text.length > 0;
|
||||
continue;
|
||||
}
|
||||
if (block.type === "image") {
|
||||
try {
|
||||
const filePath = await writeImageAttachment(block.mimeType, block.data);
|
||||
output.push({ type: "localImage", path: filePath });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn({ message }, "Failed to write Codex image attachment");
|
||||
output.push({
|
||||
type: "text",
|
||||
text: renderPromptAttachmentAsText(
|
||||
record as Extract<AgentPromptContentBlock, { type: "github_pr" | "github_issue" }>,
|
||||
),
|
||||
};
|
||||
text: `User attached image (failed to write temp file): ${message}`,
|
||||
});
|
||||
}
|
||||
return block;
|
||||
}),
|
||||
);
|
||||
previousTextBlock = false;
|
||||
continue;
|
||||
}
|
||||
const attachmentText = renderPromptAttachmentAsText(block);
|
||||
output.push({
|
||||
type: "text",
|
||||
text: previousTextBlock ? `\n\n${attachmentText}` : attachmentText,
|
||||
});
|
||||
previousTextBlock = true;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -698,20 +698,20 @@ function buildOpenCodePromptParts(
|
||||
output.push({ type: "text", text: part.text });
|
||||
continue;
|
||||
}
|
||||
if (part.type === "github_pr" || part.type === "github_issue") {
|
||||
output.push({ type: "text", text: renderPromptAttachmentAsText(part) });
|
||||
if (part.type === "image") {
|
||||
attachmentOrdinal += 1;
|
||||
const normalized = toOpenCodeDataUrl(part.mimeType, part.data);
|
||||
output.push({
|
||||
type: "file",
|
||||
mime: normalized.mimeType,
|
||||
filename: `attachment-${attachmentOrdinal}.${getOpenCodeAttachmentExtension(
|
||||
normalized.mimeType,
|
||||
)}`,
|
||||
url: normalized.url,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
attachmentOrdinal += 1;
|
||||
const normalized = toOpenCodeDataUrl(part.mimeType, part.data);
|
||||
output.push({
|
||||
type: "file",
|
||||
mime: normalized.mimeType,
|
||||
filename: `attachment-${attachmentOrdinal}.${getOpenCodeAttachmentExtension(
|
||||
normalized.mimeType,
|
||||
)}`,
|
||||
url: normalized.url,
|
||||
});
|
||||
output.push({ type: "text", text: renderPromptAttachmentAsText(part) });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,148 @@ import {
|
||||
} from "./messages.js";
|
||||
|
||||
describe("shared messages attachments", () => {
|
||||
it("keeps valid review attachments", () => {
|
||||
const parsed = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "req-review",
|
||||
agentId: "agent-1",
|
||||
text: "Please address these comments",
|
||||
attachments: [
|
||||
{
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/tmp/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: 42,
|
||||
body: "Please guard this nullable value.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,3 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: 41,
|
||||
newLineNumber: 41,
|
||||
type: "context",
|
||||
content: "const before = true;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.attachments).toEqual([
|
||||
{
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/tmp/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: 42,
|
||||
body: "Please guard this nullable value.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,3 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: 41,
|
||||
newLineNumber: 41,
|
||||
type: "context",
|
||||
content: "const before = true;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops malformed review attachments while keeping valid attachments", () => {
|
||||
const parsed = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "req-bad-review",
|
||||
agentId: "agent-1",
|
||||
text: "Review",
|
||||
attachments: [
|
||||
{
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/tmp/repo",
|
||||
mode: "uncommitted",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: "42",
|
||||
body: "This line number is malformed.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,3 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
lines: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.attachments).toEqual([
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps known attachments and drops unknown create-agent attachments", () => {
|
||||
const parsed = CreateAgentRequestMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
|
||||
@@ -733,9 +733,38 @@ export const GitHubIssueAttachmentSchema = z.object({
|
||||
body: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const ReviewAttachmentContextLineSchema = z.object({
|
||||
oldLineNumber: z.number().int().positive().nullable(),
|
||||
newLineNumber: z.number().int().positive().nullable(),
|
||||
type: z.enum(["add", "remove", "context"]),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const ReviewAttachmentCommentSchema = z.object({
|
||||
filePath: z.string(),
|
||||
side: z.enum(["old", "new"]),
|
||||
lineNumber: z.number().int().positive(),
|
||||
body: z.string(),
|
||||
context: z.object({
|
||||
hunkHeader: z.string(),
|
||||
targetLine: ReviewAttachmentContextLineSchema,
|
||||
lines: z.array(ReviewAttachmentContextLineSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ReviewAttachmentSchema = z.object({
|
||||
type: z.literal("review"),
|
||||
mimeType: z.literal("application/paseo-review"),
|
||||
cwd: z.string(),
|
||||
mode: z.enum(["uncommitted", "base"]),
|
||||
baseRef: z.string().nullable().optional(),
|
||||
comments: z.array(ReviewAttachmentCommentSchema),
|
||||
});
|
||||
|
||||
export const AgentAttachmentSchema = z.discriminatedUnion("type", [
|
||||
GitHubPrAttachmentSchema,
|
||||
GitHubIssueAttachmentSchema,
|
||||
ReviewAttachmentSchema,
|
||||
]);
|
||||
|
||||
function normalizeAgentAttachments(input: unknown): AgentAttachment[] {
|
||||
@@ -3471,6 +3500,7 @@ export type DictationStreamFinishMessage = z.infer<typeof DictationStreamFinishM
|
||||
export type DictationStreamCancelMessage = z.infer<typeof DictationStreamCancelMessageSchema>;
|
||||
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
|
||||
export type AgentAttachment = z.infer<typeof AgentAttachmentSchema>;
|
||||
export type ReviewAttachment = z.infer<typeof ReviewAttachmentSchema>;
|
||||
export type ListProviderModelsRequestMessage = z.infer<
|
||||
typeof ListProviderModelsRequestMessageSchema
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user