Skip to content

Commit 46ff0b1

Browse files
OpenSource03claude
andcommitted
fix: render all files in multi-file Codex edits
Codex bundles multiple file changes into a single tool_call message via structuredPatch, but the entire rendering pipeline only processed the first entry. Now all patch entries are iterated in EditContent, WriteContent, turn-changes, file-access, and tool-formatting. Extract shared patch-utils module (StructuredPatchEntry type, getStructuredPatches, getPatchPath, filterValidPatches) to eliminate duplicate extraction logic across 5 consumer files. Remove duplicate firstDefinedString from turn-changes.ts, add React.memo to PatchEntryDiff/PatchEntryWrite components, and replace untyped Record<string, unknown> with the proper StructuredPatchEntry interface. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6a6971a commit 46ff0b1

6 files changed

Lines changed: 236 additions & 55 deletions

File tree

src/components/lib/tool-formatting.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { UIMessage, SubagentToolStep } from "@/types";
22
import { getMcpCompactSummary } from "@/components/McpToolContent";
33
import { getTodoItems } from "@/lib/todo-utils";
4+
import { getStructuredPatches } from "@/lib/patch-utils";
45

56
// ── Compact summary for collapsed tool line ──
67

@@ -62,6 +63,11 @@ export function formatCompactSummary(message: UIMessage): string {
6263
}
6364

6465
if (input.command) return String(input.command).split("\n")[0];
66+
// Multi-file Codex edits: show file count instead of single filename
67+
const patches = getStructuredPatches(result);
68+
if (input.file_path && patches.length > 1) {
69+
return `${patches.length} files`;
70+
}
6571
if (input.file_path) return String(input.file_path).split("/").pop() ?? "";
6672
if (filePathFromResult) return filePathFromResult.split("/").pop() ?? filePathFromResult;
6773
if (input.pattern) {

src/components/tool-renderers/EditContent.tsx

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,67 @@
1+
import { memo } from "react";
12
import type { UIMessage } from "@/types";
23
import { parseUnifiedDiffFromUnknown } from "@/lib/unified-diff";
34
import { DiffViewer } from "@/components/DiffViewer";
45
import { UnifiedPatchViewer } from "@/components/UnifiedPatchViewer";
56
import { firstDefinedString } from "@/components/lib/tool-formatting";
7+
import { getStructuredPatches, getPatchPath, filterValidPatches, type StructuredPatchEntry } from "@/lib/patch-utils";
68
import { GenericContent } from "./GenericContent";
79

10+
// ── Multi-file rendering (Codex fileChange with N > 1 changes) ──
11+
12+
/** Render a single patch entry from a structuredPatch array. */
13+
const PatchEntryDiff = memo(function PatchEntryDiff({ patch }: { patch: StructuredPatchEntry }) {
14+
const filePath = getPatchPath(patch);
15+
const diffText = patch.diff ?? "";
16+
const parsedDiff = diffText ? parseUnifiedDiffFromUnknown(diffText) : null;
17+
const oldStr = firstDefinedString(patch.oldString, parsedDiff?.oldString);
18+
const newStr = firstDefinedString(patch.newString, parsedDiff?.newString);
19+
20+
if (oldStr || newStr) {
21+
return (
22+
<DiffViewer
23+
oldString={oldStr}
24+
newString={newStr}
25+
filePath={filePath}
26+
unifiedDiff={hasUnifiedDiffMarkers(diffText) ? diffText : undefined}
27+
/>
28+
);
29+
}
30+
31+
if (diffText) {
32+
return <UnifiedPatchViewer diffText={diffText} filePath={filePath} />;
33+
}
34+
35+
return null;
36+
});
37+
38+
// ── Main component ──
39+
840
export function EditContent({ message }: { message: UIMessage }) {
9-
const structuredPatch = Array.isArray(message.toolResult?.structuredPatch)
10-
? (message.toolResult.structuredPatch as Array<Record<string, unknown>>)
11-
: [];
41+
const structuredPatch = getStructuredPatches(message.toolResult);
42+
43+
// Multi-file Codex fileChange: render each file's diff separately
44+
if (structuredPatch.length > 1) {
45+
const validPatches = filterValidPatches(structuredPatch);
46+
if (validPatches.length === 0) return <GenericContent message={message} />;
47+
return (
48+
<div className="space-y-2">
49+
{validPatches.map((patch, i) => (
50+
<PatchEntryDiff
51+
key={`${getPatchPath(patch)}-${i}`}
52+
patch={patch}
53+
/>
54+
))}
55+
</div>
56+
);
57+
}
58+
59+
// Single-file: existing logic with full fallback chain
60+
// (Claude engine, ACP engine, single-file Codex edits)
1261
const matchingPatch =
1362
structuredPatch.find((entry) => {
14-
const entryPath = entry.filePath ?? entry.path;
15-
return typeof entryPath === "string"
16-
&& entryPath
63+
const entryPath = getPatchPath(entry);
64+
return entryPath
1765
&& entryPath === String(message.toolInput?.file_path ?? message.toolResult?.filePath ?? "");
1866
}) ?? structuredPatch[0];
1967
const resultContent = typeof message.toolResult?.content === "string"
@@ -22,12 +70,12 @@ export function EditContent({ message }: { message: UIMessage }) {
2270
const detailedContent = typeof message.toolResult?.detailedContent === "string"
2371
? message.toolResult.detailedContent
2472
: "";
25-
const patchDiffText = typeof matchingPatch?.diff === "string" ? matchingPatch.diff : "";
73+
const patchDiffText = matchingPatch?.diff ?? "";
2674
const candidateDiffText = selectUnifiedDiffText(patchDiffText, detailedContent, resultContent);
2775
const filePath = String(
2876
message.toolInput?.file_path
2977
?? message.toolResult?.filePath
30-
?? (typeof matchingPatch?.filePath === "string" ? matchingPatch.filePath : "")
78+
?? matchingPatch?.filePath
3179
?? extractFilePathFromDiff(candidateDiffText)
3280
?? "",
3381
);
@@ -38,15 +86,15 @@ export function EditContent({ message }: { message: UIMessage }) {
3886
const unifiedDiffText = candidateDiffText;
3987
// Prefer parsed/structured patch text first; toolInput can be a lossy representation.
4088
const oldStr = firstDefinedString(
41-
typeof matchingPatch?.oldString === "string" ? matchingPatch.oldString : undefined,
89+
matchingPatch?.oldString,
4290
parsedStructuredDiff?.oldString,
4391
parsedDiff?.oldString,
4492
parsedDetailedDiff?.oldString,
4593
message.toolResult?.oldString,
4694
message.toolInput?.old_string,
4795
);
4896
const newStr = firstDefinedString(
49-
typeof matchingPatch?.newString === "string" ? matchingPatch.newString : undefined,
97+
matchingPatch?.newString,
5098
parsedStructuredDiff?.newString,
5199
parsedDiff?.newString,
52100
parsedDetailedDiff?.newString,

src/components/tool-renderers/WriteContent.tsx

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { memo } from "react";
12
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
23
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
34
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
@@ -7,6 +8,7 @@ import { useResolvedThemeClass } from "@/hooks/useResolvedThemeClass";
78
import { parseUnifiedDiff } from "@/lib/unified-diff";
89
import { UnifiedPatchViewer } from "@/components/UnifiedPatchViewer";
910
import { OpenInEditorButton } from "@/components/OpenInEditorButton";
11+
import { getStructuredPatches, getPatchPath, filterValidPatches, type StructuredPatchEntry } from "@/lib/patch-utils";
1012
import { GenericContent } from "./GenericContent";
1113

1214
// ── Stable style constants (avoid re-creating on every render) ──
@@ -27,20 +29,55 @@ const WRITE_LINE_NUMBER_STYLE: React.CSSProperties = {
2729
paddingRight: "1em",
2830
};
2931

32+
// ── Multi-file rendering (Codex fileChange where all changes are "add") ──
33+
34+
const PatchEntryWrite = memo(function PatchEntryWrite({ patch }: { patch: StructuredPatchEntry }) {
35+
const filePath = getPatchPath(patch);
36+
37+
// Codex reports new-file diffs in unified format — use the patch viewer
38+
if (patch.diff) {
39+
return <UnifiedPatchViewer diffText={patch.diff} filePath={filePath} />;
40+
}
41+
42+
// Fallback: show newString content if available
43+
if (patch.newString) {
44+
return <UnifiedPatchViewer diffText={patch.newString} filePath={filePath} />;
45+
}
46+
47+
return null;
48+
});
49+
50+
// ── Main component ──
51+
3052
export function WriteContent({ message }: { message: UIMessage }) {
3153
const resolvedTheme = useResolvedThemeClass();
3254
const syntaxStyle = resolvedTheme === "dark" ? oneDark : oneLight;
55+
const structuredPatch = getStructuredPatches(message.toolResult);
56+
57+
// Multi-file Codex fileChange: render each new file separately
58+
if (structuredPatch.length > 1) {
59+
const validPatches = filterValidPatches(structuredPatch);
60+
if (validPatches.length === 0) return <GenericContent message={message} />;
61+
return (
62+
<div className="space-y-2">
63+
{validPatches.map((patch, i) => (
64+
<PatchEntryWrite
65+
key={`${getPatchPath(patch)}-${i}`}
66+
patch={patch}
67+
/>
68+
))}
69+
</div>
70+
);
71+
}
72+
73+
// Single-file: existing logic
3374
const filePath = String(
3475
message.toolInput?.file_path
3576
?? message.toolResult?.filePath
3677
?? "",
3778
);
3879
// Codex "wrote" may have a structuredPatch with a unified diff
39-
const structuredPatch = Array.isArray(message.toolResult?.structuredPatch)
40-
? (message.toolResult.structuredPatch as Array<Record<string, unknown>>)
41-
: [];
42-
const patchDiff = structuredPatch.length > 0
43-
&& typeof structuredPatch[0].diff === "string"
80+
const patchDiff = structuredPatch.length > 0 && structuredPatch[0].diff
4481
? structuredPatch[0].diff
4582
: null;
4683
// Use UnifiedPatchViewer when the patch is a proper unified diff

src/lib/file-access.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { Eye, Pencil, Plus } from "lucide-react";
77
import type { UIMessage } from "@/types";
8+
import { getStructuredPatches, getPatchPath } from "@/lib/patch-utils";
89

910
export type AccessType = "read" | "modified" | "created";
1011

@@ -206,6 +207,16 @@ export function extractFiles(messages: UIMessage[], cwd?: string, includeClaudeM
206207
const totalLines = msg.toolResult?.file?.totalLines;
207208
recordAccess(filePath, access, msg.timestamp, range, totalLines);
208209
}
210+
// Multi-file Codex edits: record additional file paths from structuredPatch
211+
if (access) {
212+
for (const patch of getStructuredPatches(msg.toolResult)) {
213+
const patchPath = getPatchPath(patch);
214+
if (patchPath && patchPath !== filePath) {
215+
const patchAccess: AccessType = patch.kind === "add" ? "created" : access;
216+
recordAccess(patchPath, patchAccess, msg.timestamp, null);
217+
}
218+
}
219+
}
209220
}
210221

211222
// Subagent steps — skip failed ones

src/lib/patch-utils.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Shared utilities for working with structuredPatch entries.
3+
*
4+
* Codex bundles multiple file changes into a single tool_call message.
5+
* The per-file changes are stored in `toolResult.structuredPatch`.
6+
* These helpers provide typed access and deduplication across the
7+
* rendering pipeline (renderers, summaries, file tracking, formatting).
8+
*/
9+
10+
import type { UIMessage } from "@/types";
11+
12+
// ── Types ──
13+
14+
/** A single entry from toolResult.structuredPatch. */
15+
export interface StructuredPatchEntry {
16+
filePath?: string;
17+
path?: string; // Legacy / ACP fallback for filePath
18+
kind?: string; // "add" | "delete" | "update"
19+
diff?: string;
20+
oldString?: string;
21+
newString?: string;
22+
}
23+
24+
// ── Extraction ──
25+
26+
/** Extract the structuredPatch array from a tool result, safely typed. */
27+
export function getStructuredPatches(
28+
toolResult: UIMessage["toolResult"],
29+
): StructuredPatchEntry[] {
30+
return Array.isArray(toolResult?.structuredPatch)
31+
? (toolResult.structuredPatch as StructuredPatchEntry[])
32+
: [];
33+
}
34+
35+
/** Get the file path from a patch entry (filePath preferred, path as fallback). */
36+
export function getPatchPath(patch: StructuredPatchEntry): string {
37+
return patch.filePath || patch.path || "";
38+
}
39+
40+
/** Filter patches to only those with a non-empty file path. */
41+
export function filterValidPatches(
42+
patches: StructuredPatchEntry[],
43+
): StructuredPatchEntry[] {
44+
return patches.filter((p) => getPatchPath(p) !== "");
45+
}

0 commit comments

Comments
 (0)