From d98be39344b8a39d16b62ce927be71a2c6a61a53 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Wed, 8 Apr 2026 13:49:04 -0500
Subject: [PATCH] fix(app): patch tool diff rendering
---
.../src/components/apply-patch-file.test.ts | 43 ++++++++++
.../ui/src/components/apply-patch-file.ts | 78 +++++++++++++++++++
packages/ui/src/components/message-part.tsx | 29 +------
3 files changed, 125 insertions(+), 25 deletions(-)
create mode 100644 packages/ui/src/components/apply-patch-file.test.ts
create mode 100644 packages/ui/src/components/apply-patch-file.ts
diff --git a/packages/ui/src/components/apply-patch-file.test.ts b/packages/ui/src/components/apply-patch-file.test.ts
new file mode 100644
index 0000000000..6c58581564
--- /dev/null
+++ b/packages/ui/src/components/apply-patch-file.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "bun:test"
+import { patchFiles } from "./apply-patch-file"
+import { text } from "./session-diff"
+
+describe("apply patch file", () => {
+ test("parses patch metadata from the server", () => {
+ const file = patchFiles([
+ {
+ filePath: "/tmp/a.ts",
+ relativePath: "a.ts",
+ type: "update",
+ patch:
+ "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n",
+ additions: 1,
+ deletions: 1,
+ },
+ ])[0]
+
+ expect(file).toBeDefined()
+ expect(file?.view.fileDiff.name).toBe("a.ts")
+ expect(text(file!.view, "deletions")).toBe("one\ntwo\n")
+ expect(text(file!.view, "additions")).toBe("one\nthree\n")
+ })
+
+ test("keeps legacy before and after payloads working", () => {
+ const file = patchFiles([
+ {
+ filePath: "/tmp/a.ts",
+ relativePath: "a.ts",
+ type: "update",
+ before: "one\n",
+ after: "two\n",
+ additions: 1,
+ deletions: 1,
+ },
+ ])[0]
+
+ expect(file).toBeDefined()
+ expect(file?.view.patch).toContain("@@ -1,1 +1,1 @@")
+ expect(text(file!.view, "deletions")).toBe("one\n")
+ expect(text(file!.view, "additions")).toBe("two\n")
+ })
+})
diff --git a/packages/ui/src/components/apply-patch-file.ts b/packages/ui/src/components/apply-patch-file.ts
new file mode 100644
index 0000000000..8e0c540826
--- /dev/null
+++ b/packages/ui/src/components/apply-patch-file.ts
@@ -0,0 +1,78 @@
+import { normalize, type ViewDiff } from "./session-diff"
+
+type Kind = "add" | "update" | "delete" | "move"
+
+type Raw = {
+ filePath?: string
+ relativePath?: string
+ type?: Kind
+ patch?: string
+ diff?: string
+ before?: string
+ after?: string
+ additions?: number
+ deletions?: number
+ movePath?: string
+}
+
+export type ApplyPatchFile = {
+ filePath: string
+ relativePath: string
+ type: Kind
+ additions: number
+ deletions: number
+ movePath?: string
+ view: ViewDiff
+}
+
+function kind(value: unknown) {
+ if (value === "add" || value === "update" || value === "delete" || value === "move") return value
+}
+
+function status(type: Kind): "added" | "deleted" | "modified" {
+ if (type === "add") return "added"
+ if (type === "delete") return "deleted"
+ return "modified"
+}
+
+export function patchFile(raw: unknown): ApplyPatchFile | undefined {
+ if (!raw || typeof raw !== "object") return
+
+ const value = raw as Raw
+ const type = kind(value.type)
+ const filePath = typeof value.filePath === "string" ? value.filePath : undefined
+ const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath
+ const patch = typeof value.patch === "string" ? value.patch : typeof value.diff === "string" ? value.diff : undefined
+ const before = typeof value.before === "string" ? value.before : undefined
+ const after = typeof value.after === "string" ? value.after : undefined
+
+ if (!type || !filePath || !relativePath) return
+ if (!patch && before === undefined && after === undefined) return
+
+ const additions = typeof value.additions === "number" ? value.additions : 0
+ const deletions = typeof value.deletions === "number" ? value.deletions : 0
+ const movePath = typeof value.movePath === "string" ? value.movePath : undefined
+
+ return {
+ filePath,
+ relativePath,
+ type,
+ additions,
+ deletions,
+ movePath,
+ view: normalize({
+ file: relativePath,
+ patch,
+ before,
+ after,
+ additions,
+ deletions,
+ status: status(type),
+ }),
+ }
+}
+
+export function patchFiles(raw: unknown) {
+ if (!Array.isArray(raw)) return []
+ return raw.map(patchFile).filter((file): file is ApplyPatchFile => !!file)
+}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 3627eca409..02bd80ac9c 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -54,6 +54,7 @@ import { Spinner } from "./spinner"
import { TextShimmer } from "./text-shimmer"
import { AnimatedCountList } from "./tool-count-summary"
import { ToolStatusTitle } from "./tool-status-title"
+import { patchFiles } from "./apply-patch-file"
import { animate } from "motion"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind } from "./message-file"
@@ -2014,24 +2015,12 @@ ToolRegistry.register({
},
})
-interface ApplyPatchFile {
- filePath: string
- relativePath: string
- type: "add" | "update" | "delete" | "move"
- diff: string
- before: string
- after: string
- additions: number
- deletions: number
- movePath?: string
-}
-
ToolRegistry.register({
name: "apply_patch",
render(props) {
const i18n = useI18n()
const fileComponent = useFileComponent()
- const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
+ const files = createMemo(() => patchFiles(props.metadata.files))
const pending = createMemo(() => props.status === "pending" || props.status === "running")
const single = createMemo(() => {
const list = files()
@@ -2137,12 +2126,7 @@ ToolRegistry.register({