Skip to content

Commit cd5c2eb

Browse files
committed
feat(opencode): improve SDK integration and multi-file diff rendering
- Parse and render multi-file unified diffs in InlineDiffView and prefer normalized metadata in acpToolPayload - Normalize OpenCode apply_patch metadata and files into canonical changes in mapping - Send text-like attachments as text/plain file parts instead of forcing Markdown in sdkSession - Suppress abort-only composer errors in threadErrorState - Disable preview comments and enable webpack for Vercel builds in vercel.json
1 parent 4e1624d commit cd5c2eb

12 files changed

Lines changed: 664 additions & 60 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from "vitest";
2+
import { prepareInlineDiffParts, splitUnifiedDiffFiles } from "./InlineDiffView";
3+
4+
describe("InlineDiffView", () => {
5+
it("splits multi-file unified diffs into per-file chunks", () => {
6+
const diff = [
7+
"diff --git a/src/a.ts b/src/a.ts",
8+
"--- a/src/a.ts",
9+
"+++ b/src/a.ts",
10+
"@@ -1 +1 @@",
11+
"-oldA",
12+
"+newA",
13+
"diff --git a/src/b.ts b/src/b.ts",
14+
"--- a/src/b.ts",
15+
"+++ b/src/b.ts",
16+
"@@ -1 +1 @@",
17+
"-oldB",
18+
"+newB",
19+
"",
20+
].join("\n");
21+
22+
const chunks = splitUnifiedDiffFiles(diff);
23+
24+
expect(chunks).toHaveLength(2);
25+
expect(chunks[0]).toContain("diff --git a/src/a.ts b/src/a.ts");
26+
expect(chunks[0]).toContain("+newA");
27+
expect(chunks[0]).not.toContain("src/b.ts");
28+
expect(chunks[1]).toContain("diff --git a/src/b.ts b/src/b.ts");
29+
expect(chunks[1]).toContain("+newB");
30+
});
31+
32+
it("keeps non-git diff text as a single chunk", () => {
33+
const diff = ["--- a/src/a.ts", "+++ b/src/a.ts", "@@ -1 +1 @@", "-old", "+new"].join("\n");
34+
35+
expect(splitUnifiedDiffFiles(diff)).toEqual([diff]);
36+
});
37+
38+
it("merges repeated same-file chunks and normalizes absolute OpenCode paths", () => {
39+
const absolutePath =
40+
"/Users/serhiivecherenko/work/lightcode/src/renderer/components/thread/threadErrorState.ts";
41+
const diff = [
42+
`diff --git a/${absolutePath} b/${absolutePath}`,
43+
`--- a/${absolutePath}`,
44+
`+++ b/${absolutePath}`,
45+
"@@ -1 +1 @@",
46+
"-const first = false;",
47+
"+const first = true;",
48+
`diff --git a/${absolutePath} b/${absolutePath}`,
49+
`--- a/${absolutePath}`,
50+
`+++ b/${absolutePath}`,
51+
"@@ -20 +20 @@",
52+
"-const second = false;",
53+
"+const second = true;",
54+
"",
55+
].join("\n");
56+
57+
const parts = prepareInlineDiffParts(diff, absolutePath);
58+
59+
expect(parts).toHaveLength(1);
60+
expect(parts[0]?.displayPath).toBe("renderer/components/thread/threadErrorState.ts");
61+
expect(parts[0]?.diff).toContain("@@ -1 +1 @@");
62+
expect(parts[0]?.diff).toContain("@@ -20 +20 @@");
63+
expect(parts[0]?.diff).not.toContain("a//Users");
64+
});
65+
});

src/renderer/components/thread/ChatPane/parts/items/InlineDiffView.tsx

Lines changed: 153 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,8 @@ interface InlineDiffViewProps {
3131
* when the patch is too large or the worker build fails.
3232
*/
3333
export function InlineDiffView({ diffText, filePath, oldText, newText }: InlineDiffViewProps) {
34-
const displayPath = normalizeDiffFilePath(filePath);
3534
const theme = useDiffTheme();
36-
const [diffFile, setDiffFile] = useState<DiffFile | null>(null);
35+
const [diffFiles, setDiffFiles] = useState<InlineDiffFile[]>([]);
3736
const [state, setState] = useState<"building" | "ready" | "fallback">(
3837
diffText.length > MAX_DIFF_LENGTH ? "fallback" : "building",
3938
);
@@ -45,33 +44,31 @@ export function InlineDiffView({ diffText, filePath, oldText, newText }: InlineD
4544
}
4645
let cancelled = false;
4746
setState("building");
48-
setDiffFile(null);
49-
50-
const parsedNames = extractDiffNames(diffText);
51-
const oldName = parsedNames.oldName || (oldText === "" ? "/dev/null" : `a/${displayPath}`);
52-
const newName = parsedNames.newName || `b/${displayPath}`;
53-
const lang = getLang(newName || displayPath);
54-
55-
void buildInWorker(
56-
[
57-
{
58-
key: displayPath,
59-
diff: diffText,
60-
oldName,
61-
newName,
62-
fileLang: lang,
63-
...(oldText !== undefined && newText !== undefined
64-
? { oldContent: oldText, newContent: newText }
65-
: {}),
66-
},
67-
],
68-
theme,
69-
)
47+
setDiffFiles([]);
48+
49+
const parts = prepareInlineDiffParts(diffText, filePath);
50+
const includeContent = parts.length === 1 && oldText !== undefined && newText !== undefined;
51+
const items = parts.map((part, index) => {
52+
const oldName = part.oldName || (oldText === "" ? "/dev/null" : `a/${part.displayPath}`);
53+
const newName = part.newName || `b/${part.displayPath}`;
54+
return {
55+
key: `${index}:${part.displayPath}`,
56+
diff: part.diff,
57+
oldName,
58+
newName,
59+
fileLang: getLang(part.displayPath),
60+
...(includeContent ? { oldContent: oldText, newContent: newText } : {}),
61+
};
62+
});
63+
64+
void buildInWorker(items, theme)
7065
.then((results) => {
7166
if (cancelled) return;
72-
const r = results[0];
73-
if (r?.bundle) {
74-
setDiffFile(diffFileFromBundle(r.data, r.bundle));
67+
const built = results.flatMap((r) =>
68+
r.bundle ? [{ key: r.key, diffFile: diffFileFromBundle(r.data, r.bundle) }] : [],
69+
);
70+
if (built.length === results.length && built.length > 0) {
71+
setDiffFiles(built);
7572
setState("ready");
7673
} else {
7774
setState("fallback");
@@ -84,33 +81,150 @@ export function InlineDiffView({ diffText, filePath, oldText, newText }: InlineD
8481
return () => {
8582
cancelled = true;
8683
};
87-
}, [diffText, displayPath, oldText, newText, theme]);
84+
}, [diffText, filePath, oldText, newText, theme]);
8885

8986
if (state === "fallback") {
9087
return <CommandOutputViewport text={diffText} language="diff" />;
9188
}
9289

93-
if (state === "building" || !diffFile) {
90+
if (state === "building" || diffFiles.length === 0) {
9491
return <div className="py-2 text-xs text-[color:var(--muted)]">Building diff…</div>;
9592
}
9693

9794
return (
9895
<DiffViewErrorBoundary fallback={<CommandOutputViewport text={diffText} language="diff" />}>
99-
<div className="max-h-[min(24rem,50vh)] overflow-auto [scrollbar-gutter:stable]">
100-
<DiffView
101-
diffFile={diffFile}
102-
diffViewMode={UNIFIED_MODE}
103-
diffViewTheme={theme}
104-
diffViewFontSize={12}
105-
registerHighlighter={highlighter}
106-
diffViewHighlight={true}
107-
diffViewWrap={false}
108-
/>
96+
<div className="flex max-h-[min(24rem,50vh)] flex-col gap-2 overflow-auto [scrollbar-gutter:stable]">
97+
{diffFiles.map(({ key, diffFile }) => (
98+
<DiffView
99+
key={key}
100+
diffFile={diffFile}
101+
diffViewMode={UNIFIED_MODE}
102+
diffViewTheme={theme}
103+
diffViewFontSize={12}
104+
registerHighlighter={highlighter}
105+
diffViewHighlight={true}
106+
diffViewWrap={false}
107+
/>
108+
))}
109109
</div>
110110
</DiffViewErrorBoundary>
111111
);
112112
}
113113

114+
interface InlineDiffFile {
115+
key: string;
116+
diffFile: DiffFile;
117+
}
118+
119+
interface DiffNames {
120+
oldName: string;
121+
newName: string;
122+
}
123+
124+
interface InlineDiffPart {
125+
diff: string;
126+
displayPath: string;
127+
oldName: string;
128+
newName: string;
129+
}
130+
131+
export function prepareInlineDiffParts(diffText: string, fallbackPath: string): InlineDiffPart[] {
132+
const parts = splitUnifiedDiffFiles(diffText).map((part) =>
133+
normalizeInlineDiffPart(part, fallbackPath),
134+
);
135+
const merged: InlineDiffPart[] = [];
136+
const byNames = new Map<string, InlineDiffPart>();
137+
138+
for (const part of parts) {
139+
const body = extractDiffBody(part.diff);
140+
if (!body) {
141+
merged.push(part);
142+
continue;
143+
}
144+
const key = `${part.oldName}\0${part.newName}`;
145+
const existing = byNames.get(key);
146+
if (!existing) {
147+
byNames.set(key, part);
148+
merged.push(part);
149+
continue;
150+
}
151+
existing.diff = buildUnifiedDiffText(existing.displayPath, existing.oldName, existing.newName, [
152+
extractDiffBody(existing.diff) ?? "",
153+
body,
154+
]);
155+
}
156+
157+
return merged;
158+
}
159+
160+
export function splitUnifiedDiffFiles(diffText: string): string[] {
161+
const lines = diffText.split(/\r?\n/);
162+
const chunks: string[][] = [];
163+
let current: string[] | null = null;
164+
165+
for (const line of lines) {
166+
if (line.startsWith("diff --git ")) {
167+
if (current && current.length > 0) chunks.push(current);
168+
current = [line];
169+
} else if (current) {
170+
current.push(line);
171+
}
172+
}
173+
174+
if (current && current.length > 0) chunks.push(current);
175+
if (chunks.length === 0) return [diffText];
176+
return chunks.map((chunk) => chunk.join("\n"));
177+
}
178+
179+
function resolveDisplayPath(filePath: string, names: DiffNames): string {
180+
const candidate = names.newName && names.newName !== "/dev/null" ? names.newName : names.oldName;
181+
return normalizeDiffFilePath(candidate && candidate !== "/dev/null" ? candidate : filePath);
182+
}
183+
184+
function normalizeInlineDiffPart(diff: string, fallbackPath: string): InlineDiffPart {
185+
const names = extractDiffNames(diff);
186+
const displayPath = resolveDisplayPath(fallbackPath, names);
187+
const oldName = names.oldName === "/dev/null" ? "/dev/null" : `a/${displayPath}`;
188+
const newName = names.newName === "/dev/null" ? "/dev/null" : `b/${displayPath}`;
189+
const body = extractDiffBody(diff);
190+
if (!body) {
191+
return {
192+
diff,
193+
displayPath,
194+
oldName: names.oldName || oldName,
195+
newName: names.newName || newName,
196+
};
197+
}
198+
return {
199+
diff: buildUnifiedDiffText(displayPath, oldName, newName, [body]),
200+
displayPath,
201+
oldName,
202+
newName,
203+
};
204+
}
205+
206+
function extractDiffBody(diff: string): string | undefined {
207+
const lines = diff.split(/\r?\n/);
208+
const start = lines.findIndex((line) => line.startsWith("@@"));
209+
if (start < 0) return undefined;
210+
return lines.slice(start).join("\n").trimEnd();
211+
}
212+
213+
function buildUnifiedDiffText(
214+
displayPath: string,
215+
oldName: string,
216+
newName: string,
217+
bodies: readonly string[],
218+
): string {
219+
return [
220+
`diff --git a/${displayPath} b/${displayPath}`,
221+
`--- ${oldName}`,
222+
`+++ ${newName}`,
223+
...bodies.map((body) => body.trimEnd()).filter((body) => body.length > 0),
224+
"",
225+
].join("\n");
226+
}
227+
114228
/** Catches render errors from DiffView (e.g. missing canvas in test envs). */
115229
class DiffViewErrorBoundary extends Component<
116230
{ children: ReactNode; fallback: ReactNode },

src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,61 @@ describe("acpToolPayload", () => {
213213
language: "diff",
214214
});
215215
});
216+
217+
it("prefers normalized metadata changes over range-less apply_patch text", () => {
218+
const patch = [
219+
"Index: /Users/serhiivecherenko/work/site-search-ui/README.md",
220+
"===================================================================",
221+
"--- /Users/serhiivecherenko/work/site-search-ui/README.md",
222+
"+++ /Users/serhiivecherenko/work/site-search-ui/README.md",
223+
"@@ -1,7 +1,7 @@",
224+
"-Preact-based embeddable widget that renders AI-powered, streaming search answers.",
225+
"+Preact-based embeddable search widget that renders AI-powered, streaming answers.",
226+
"@@ -24,9 +24,9 @@",
227+
"-The simplest integration uses a single script tag with query parameters:",
228+
"+The simplest integration uses one script tag with query parameters:",
229+
"@@ -201,5 +201,5 @@",
230+
"-Common env vars are described in `AGENTS.md`.",
231+
"+Common environment variables are described in `AGENTS.md`.",
232+
"",
233+
].join("\n");
234+
const payload = {
235+
path: "/Users/serhiivecherenko/work/site-search-ui/README.md",
236+
args: {
237+
patchText: [
238+
"*** Begin Patch",
239+
"*** Update File: /Users/serhiivecherenko/work/site-search-ui/README.md",
240+
"@@",
241+
"-Preact-based embeddable widget that renders AI-powered, streaming search answers.",
242+
"+Preact-based embeddable search widget that renders AI-powered, streaming answers.",
243+
"@@",
244+
"-The simplest integration uses a single script tag with query parameters:",
245+
"+The simplest integration uses one script tag with query parameters:",
246+
"@@",
247+
"-Common env vars are described in `AGENTS.md`.",
248+
"+Common environment variables are described in `AGENTS.md`.",
249+
"*** End Patch",
250+
].join("\n"),
251+
},
252+
metadata: {
253+
changes: [
254+
{
255+
path: "README.md",
256+
kind: { type: "update", move_path: null },
257+
diff: patch,
258+
},
259+
],
260+
},
261+
};
262+
263+
const part = extractAcpDiffResultPart(payload);
264+
265+
expect(part.language).toBe("diff");
266+
expect(part.text).toContain("diff --git a/README.md b/README.md");
267+
expect(part.text).toContain("@@ -1,7 +1,7 @@");
268+
expect(part.text).toContain("@@ -24,9 +24,9 @@");
269+
expect(part.text).toContain("@@ -201,5 +201,5 @@");
270+
expect(part.text).not.toContain("@@ -1 +1 @@");
271+
expect(extractAcpDiffSummary(payload)).toEqual({ added: 3, removed: 3 });
272+
});
216273
});

0 commit comments

Comments
 (0)