Explorar el Código

refactor(snapshot): store unified patches in file diffs (#21244)

Co-authored-by: Adam <[email protected]>
Dax hace 1 semana
padre
commit
b7fab49b64
Se han modificado 30 ficheros con 343 adiciones y 183 borrados
  1. 1 0
      bun.lock
  2. 2 2
      packages/app/src/context/global-sync/event-reducer.ts
  3. 3 3
      packages/app/src/context/global-sync/session-cache.test.ts
  4. 2 2
      packages/app/src/context/global-sync/session-cache.ts
  5. 2 2
      packages/app/src/context/global-sync/types.ts
  6. 34 48
      packages/app/src/pages/session.tsx
  7. 4 2
      packages/app/src/pages/session/review-tab.tsx
  8. 2 2
      packages/app/src/pages/session/session-side-panel.tsx
  9. 2 2
      packages/enterprise/src/core/share.ts
  10. 2 2
      packages/enterprise/src/routes/share/[shareID].tsx
  11. 1 1
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  12. 19 5
      packages/opencode/src/project/vcs.ts
  13. 1 1
      packages/opencode/src/server/instance.ts
  14. 1 1
      packages/opencode/src/share/share-next.ts
  15. 6 9
      packages/opencode/src/snapshot/index.ts
  16. 1 3
      packages/opencode/src/tool/apply_patch.ts
  17. 1 2
      packages/opencode/src/tool/edit.ts
  18. 7 8
      packages/opencode/test/share/share-next.test.ts
  19. 14 21
      packages/opencode/test/snapshot/snapshot.test.ts
  20. 6 8
      packages/opencode/test/tool/apply_patch.test.ts
  21. 17 10
      packages/sdk/js/src/v2/gen/types.gen.ts
  22. 1 0
      packages/ui/package.json
  23. 2 0
      packages/ui/src/components/file-media.tsx
  24. 26 11
      packages/ui/src/components/file-ssr.tsx
  25. 38 6
      packages/ui/src/components/file.tsx
  26. 37 0
      packages/ui/src/components/session-diff.test.ts
  27. 83 0
      packages/ui/src/components/session-diff.ts
  28. 15 21
      packages/ui/src/components/session-review.tsx
  29. 11 9
      packages/ui/src/components/session-turn.tsx
  30. 2 2
      packages/ui/src/context/data.tsx

+ 1 - 0
bun.lock

@@ -533,6 +533,7 @@
         "@solid-primitives/resize-observer": "2.1.3",
         "@solidjs/meta": "catalog:",
         "@solidjs/router": "catalog:",
+        "diff": "catalog:",
         "dompurify": "3.3.1",
         "fuzzysort": "catalog:",
         "katex": "0.16.27",

+ 2 - 2
packages/app/src/context/global-sync/event-reducer.ts

@@ -1,7 +1,6 @@
 import { Binary } from "@opencode-ai/util/binary"
 import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
 import type {
-  FileDiff,
   Message,
   Part,
   PermissionRequest,
@@ -9,6 +8,7 @@ import type {
   QuestionRequest,
   Session,
   SessionStatus,
+  SnapshotFileDiff,
   Todo,
 } from "@opencode-ai/sdk/v2/client"
 import type { State, VcsCache } from "./types"
@@ -161,7 +161,7 @@ export function applyDirectoryEvent(input: {
       break
     }
     case "session.diff": {
-      const props = event.properties as { sessionID: string; diff: FileDiff[] }
+      const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
       input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
       break
     }

+ 3 - 3
packages/app/src/context/global-sync/session-cache.test.ts

@@ -1,11 +1,11 @@
 import { describe, expect, test } from "bun:test"
 import type {
-  FileDiff,
   Message,
   Part,
   PermissionRequest,
   QuestionRequest,
   SessionStatus,
+  SnapshotFileDiff,
   Todo,
 } from "@opencode-ai/sdk/v2/client"
 import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -33,7 +33,7 @@ describe("app session cache", () => {
   test("dropSessionCaches clears orphaned parts without message rows", () => {
     const store: {
       session_status: Record<string, SessionStatus | undefined>
-      session_diff: Record<string, FileDiff[] | undefined>
+      session_diff: Record<string, SnapshotFileDiff[] | undefined>
       todo: Record<string, Todo[] | undefined>
       message: Record<string, Message[] | undefined>
       part: Record<string, Part[] | undefined>
@@ -64,7 +64,7 @@ describe("app session cache", () => {
     const m = msg("msg_1", "ses_1")
     const store: {
       session_status: Record<string, SessionStatus | undefined>
-      session_diff: Record<string, FileDiff[] | undefined>
+      session_diff: Record<string, SnapshotFileDiff[] | undefined>
       todo: Record<string, Todo[] | undefined>
       message: Record<string, Message[] | undefined>
       part: Record<string, Part[] | undefined>

+ 2 - 2
packages/app/src/context/global-sync/session-cache.ts

@@ -1,10 +1,10 @@
 import type {
-  FileDiff,
   Message,
   Part,
   PermissionRequest,
   QuestionRequest,
   SessionStatus,
+  SnapshotFileDiff,
   Todo,
 } from "@opencode-ai/sdk/v2/client"
 
@@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
 
 type SessionCache = {
   session_status: Record<string, SessionStatus | undefined>
-  session_diff: Record<string, FileDiff[] | undefined>
+  session_diff: Record<string, SnapshotFileDiff[] | undefined>
   todo: Record<string, Todo[] | undefined>
   message: Record<string, Message[] | undefined>
   part: Record<string, Part[] | undefined>

+ 2 - 2
packages/app/src/context/global-sync/types.ts

@@ -2,7 +2,6 @@ import type {
   Agent,
   Command,
   Config,
-  FileDiff,
   LspStatus,
   McpStatus,
   Message,
@@ -14,6 +13,7 @@ import type {
   QuestionRequest,
   Session,
   SessionStatus,
+  SnapshotFileDiff,
   Todo,
   VcsInfo,
 } from "@opencode-ai/sdk/v2/client"
@@ -48,7 +48,7 @@ export type State = {
     [sessionID: string]: SessionStatus
   }
   session_diff: {
-    [sessionID: string]: FileDiff[]
+    [sessionID: string]: SnapshotFileDiff[]
   }
   todo: {
     [sessionID: string]: Todo[]

+ 34 - 48
packages/app/src/pages/session.tsx

@@ -1,4 +1,4 @@
-import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
+import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useMutation } from "@tanstack/solid-query"
 import {
@@ -68,7 +68,7 @@ type FollowupItem = FollowupDraft & { id: string }
 type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
 const emptyFollowups: FollowupItem[] = []
 
-type ChangeMode = "git" | "branch" | "session" | "turn"
+type ChangeMode = "git" | "branch" | "turn"
 type VcsMode = "git" | "branch"
 
 type SessionHistoryWindowInput = {
@@ -463,13 +463,6 @@ export default function Page() {
     if (!id) return false
     return sync.session.history.loading(id)
   })
-  const diffsReady = createMemo(() => {
-    const id = params.id
-    if (!id) return true
-    if (!hasSessionReview()) return true
-    return sync.data.session_diff[id] !== undefined
-  })
-
   const userMessages = createMemo(
     () => messages().filter((m) => m.role === "user") as UserMessage[],
     emptyUserMessages,
@@ -527,10 +520,19 @@ export default function Page() {
     deferRender: false,
   })
 
-  const [vcs, setVcs] = createStore({
+  const [vcs, setVcs] = createStore<{
+    diff: {
+      git: VcsFileDiff[]
+      branch: VcsFileDiff[]
+    }
+    ready: {
+      git: boolean
+      branch: boolean
+    }
+  }>({
     diff: {
-      git: [] as FileDiff[],
-      branch: [] as FileDiff[],
+      git: [] as VcsFileDiff[],
+      branch: [] as VcsFileDiff[],
     },
     ready: {
       git: false,
@@ -648,6 +650,7 @@ export default function Page() {
   }, desktopReviewOpen())
 
   const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
+  const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
   const changesOptions = createMemo<ChangeMode[]>(() => {
     const list: ChangeMode[] = []
     if (sync.project?.vcs === "git") list.push("git")
@@ -659,7 +662,7 @@ export default function Page() {
     ) {
       list.push("branch")
     }
-    list.push("session", "turn")
+    list.push("turn")
     return list
   })
   const vcsMode = createMemo<VcsMode | undefined>(() => {
@@ -668,20 +671,17 @@ export default function Page() {
   const reviewDiffs = createMemo(() => {
     if (store.changes === "git") return vcs.diff.git
     if (store.changes === "branch") return vcs.diff.branch
-    if (store.changes === "session") return diffs()
     return turnDiffs()
   })
   const reviewCount = createMemo(() => {
     if (store.changes === "git") return vcs.diff.git.length
     if (store.changes === "branch") return vcs.diff.branch.length
-    if (store.changes === "session") return sessionCount()
     return turnDiffs().length
   })
   const hasReview = createMemo(() => reviewCount() > 0)
   const reviewReady = createMemo(() => {
     if (store.changes === "git") return vcs.ready.git
     if (store.changes === "branch") return vcs.ready.branch
-    if (store.changes === "session") return !hasSessionReview() || diffsReady()
     return true
   })
 
@@ -749,13 +749,6 @@ export default function Page() {
     scrollToMessage(msgs[targetIndex], "auto")
   }
 
-  const sessionEmptyKey = createMemo(() => {
-    const project = sync.project
-    if (project && !project.vcs) return "session.review.noVcs"
-    if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
-    return "session.review.empty"
-  })
-
   function upsert(next: Project) {
     const list = globalSync.data.project
     sync.set("project", next.id)
@@ -1156,7 +1149,6 @@ export default function Page() {
     const label = (option: ChangeMode) => {
       if (option === "git") return language.t("ui.sessionReview.title.git")
       if (option === "branch") return language.t("ui.sessionReview.title.branch")
-      if (option === "session") return language.t("ui.sessionReview.title")
       return language.t("ui.sessionReview.title.lastTurn")
     }
 
@@ -1179,11 +1171,26 @@ export default function Page() {
     </div>
   )
 
+  const createGit = (input: { emptyClass: string }) => (
+    <div class={input.emptyClass}>
+      <div class="flex flex-col gap-3">
+        <div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
+        <div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
+          {language.t("session.review.noVcs.createGit.description")}
+        </div>
+      </div>
+      <Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
+        {gitMutation.isPending
+          ? language.t("session.review.noVcs.createGit.actionLoading")
+          : language.t("session.review.noVcs.createGit.action")}
+      </Button>
+    </div>
+  )
+
   const reviewEmptyText = createMemo(() => {
     if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
     if (store.changes === "branch") return language.t("session.review.noBranchChanges")
-    if (store.changes === "turn") return language.t("session.review.noChanges")
-    return language.t(sessionEmptyKey())
+    return language.t("session.review.noChanges")
   })
 
   const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
@@ -1193,31 +1200,10 @@ export default function Page() {
     }
 
     if (store.changes === "turn") {
+      if (nogit()) return createGit(input)
       return empty(reviewEmptyText())
     }
 
-    if (hasSessionReview() && !diffsReady()) {
-      return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
-    }
-
-    if (sessionEmptyKey() === "session.review.noVcs") {
-      return (
-        <div class={input.emptyClass}>
-          <div class="flex flex-col gap-3">
-            <div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
-            <div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
-              {language.t("session.review.noVcs.createGit.description")}
-            </div>
-          </div>
-          <Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
-            {gitMutation.isPending
-              ? language.t("session.review.noVcs.createGit.actionLoading")
-              : language.t("session.review.noVcs.createGit.action")}
-          </Button>
-        </div>
-      )
-    }
-
     return (
       <div class={input.emptyClass}>
         <div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>

+ 4 - 2
packages/app/src/pages/session/review-tab.tsx

@@ -1,6 +1,6 @@
 import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
 import { makeEventListener } from "@solid-primitives/event-listener"
-import type { FileDiff } from "@opencode-ai/sdk/v2"
+import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import type {
   SessionReviewCommentActions,
@@ -14,10 +14,12 @@ import type { LineComment } from "@/context/comments"
 
 export type DiffStyle = "unified" | "split"
 
+type ReviewDiff = SnapshotFileDiff | VcsFileDiff
+
 export interface SessionReviewTabProps {
   title?: JSX.Element
   empty?: JSX.Element
-  diffs: () => FileDiff[]
+  diffs: () => ReviewDiff[]
   view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
   diffStyle: DiffStyle
   onDiffStyleChange?: (style: DiffStyle) => void

+ 2 - 2
packages/app/src/pages/session/session-side-panel.tsx

@@ -8,7 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Mark } from "@opencode-ai/ui/logo"
 import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
 import type { DragEvent } from "@thisbeyond/solid-dnd"
-import type { FileDiff } from "@opencode-ai/sdk/v2"
+import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 
@@ -27,7 +27,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
 
 export function SessionSidePanel(props: {
   canReview: () => boolean
-  diffs: () => FileDiff[]
+  diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
   diffsReady: () => boolean
   empty: () => string
   hasReview: () => boolean

+ 2 - 2
packages/enterprise/src/core/share.ts

@@ -1,4 +1,4 @@
-import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
+import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
 import { fn } from "@opencode-ai/util/fn"
 import { iife } from "@opencode-ai/util/iife"
 import z from "zod"
@@ -27,7 +27,7 @@ export namespace Share {
     }),
     z.object({
       type: z.literal("session_diff"),
-      data: z.custom<FileDiff[]>(),
+      data: z.custom<SnapshotFileDiff[]>(),
     }),
     z.object({
       type: z.literal("model"),

+ 2 - 2
packages/enterprise/src/routes/share/[shareID].tsx

@@ -1,4 +1,4 @@
-import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
+import { Message, Model, Part, Session, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import { DataProvider } from "@opencode-ai/ui/context"
@@ -51,7 +51,7 @@ const getData = query(async (shareID) => {
     shareID: string
     session: Session[]
     session_diff: {
-      [sessionID: string]: FileDiff[]
+      [sessionID: string]: SnapshotFileDiff[]
     }
     session_status: {
       [sessionID: string]: SessionStatus

+ 1 - 1
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -2124,7 +2124,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
                   </text>
                 }
               >
-                <Diff diff={file.diff} filePath={file.filePath} />
+                <Diff diff={file.patch} filePath={file.filePath} />
                 <Diagnostics diagnostics={props.metadata.diagnostics} filePath={file.movePath ?? file.filePath} />
               </Show>
             </BlockTool>

+ 19 - 5
packages/opencode/src/project/vcs.ts

@@ -1,4 +1,5 @@
 import { Effect, Layer, ServiceMap, Stream } from "effect"
+import { formatPatch, structuredPatch } from "diff"
 import path from "path"
 import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
@@ -7,7 +8,6 @@ import { makeRuntime } from "@/effect/run-service"
 import { AppFileSystem } from "@/filesystem"
 import { FileWatcher } from "@/file/watcher"
 import { Git } from "@/git"
-import { Snapshot } from "@/snapshot"
 import { Log } from "@/util/log"
 import { Instance } from "./instance"
 import z from "zod"
@@ -49,6 +49,8 @@ export namespace Vcs {
     map: Map<string, { additions: number; deletions: number }>,
   ) {
     const base = ref ? yield* git.prefix(cwd) : ""
+    const patch = (file: string, before: string, after: string) =>
+      formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
     const next = yield* Effect.forEach(
       list,
       (item) =>
@@ -58,12 +60,11 @@ export namespace Vcs {
           const stat = map.get(item.file)
           return {
             file: item.file,
-            before,
-            after,
+            patch: patch(item.file, before, after),
             additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
             deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
             status: item.status,
-          } satisfies Snapshot.FileDiff
+          } satisfies FileDiff
         }),
       { concurrency: 8 },
     )
@@ -125,11 +126,24 @@ export namespace Vcs {
     })
   export type Info = z.infer<typeof Info>
 
+  export const FileDiff = z
+    .object({
+      file: z.string(),
+      patch: z.string(),
+      additions: z.number(),
+      deletions: z.number(),
+      status: z.enum(["added", "deleted", "modified"]).optional(),
+    })
+    .meta({
+      ref: "VcsFileDiff",
+    })
+  export type FileDiff = z.infer<typeof FileDiff>
+
   export interface Interface {
     readonly init: () => Effect.Effect<void>
     readonly branch: () => Effect.Effect<string | undefined>
     readonly defaultBranch: () => Effect.Effect<string | undefined>
-    readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
+    readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
   }
 
   interface State {

+ 1 - 1
packages/opencode/src/server/instance.ts

@@ -154,7 +154,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
             description: "VCS diff",
             content: {
               "application/json": {
-                schema: resolver(Snapshot.FileDiff.array()),
+                schema: resolver(Vcs.FileDiff.array()),
               },
             },
           },

+ 1 - 1
packages/opencode/src/share/share-next.ts

@@ -59,7 +59,7 @@ export namespace ShareNext {
       }
     | {
         type: "session_diff"
-        data: SDK.FileDiff[]
+        data: SDK.SnapshotFileDiff[]
       }
     | {
         type: "model"

+ 6 - 9
packages/opencode/src/snapshot/index.ts

@@ -1,6 +1,6 @@
-import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { formatPatch, structuredPatch } from "diff"
 import path from "path"
 import z from "zod"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -22,14 +22,13 @@ export namespace Snapshot {
   export const FileDiff = z
     .object({
       file: z.string(),
-      before: z.string(),
-      after: z.string(),
+      patch: z.string(),
       additions: z.number(),
       deletions: z.number(),
       status: z.enum(["added", "deleted", "modified"]).optional(),
     })
     .meta({
-      ref: "FileDiff",
+      ref: "SnapshotFileDiff",
     })
   export type FileDiff = z.infer<typeof FileDiff>
 
@@ -521,8 +520,6 @@ export namespace Snapshot {
                     const map = new Map<string, { before: string; after: string }>()
                     const dec = new TextDecoder()
                     let i = 0
-                    // Parse the default `git cat-file --batch` stream: one header line,
-                    // then exactly `size` bytes of blob content, then a trailing newline.
                     for (const ref of refs) {
                       let end = i
                       while (end < out.length && out[end] !== 10) end += 1
@@ -620,8 +617,9 @@ export namespace Snapshot {
                     ]
                   })
                 const step = 100
+                const patch = (file: string, before: string, after: string) =>
+                  formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
 
-                // Keep batches bounded so a large diff does not buffer every blob at once.
                 for (let i = 0; i < rows.length; i += step) {
                   const run = rows.slice(i, i + step)
                   const text = yield* load(run)
@@ -631,8 +629,7 @@ export namespace Snapshot {
                     const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row)
                     result.push({
                       file: row.file,
-                      before,
-                      after,
+                      patch: row.binary ? "" : patch(row.file, before, after),
                       additions: row.additions,
                       deletions: row.deletions,
                       status: row.status,

+ 1 - 3
packages/opencode/src/tool/apply_patch.ts

@@ -164,9 +164,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
       filePath: change.filePath,
       relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
       type: change.type,
-      diff: change.diff,
-      before: change.oldContent,
-      after: change.newContent,
+      patch: change.diff,
       additions: change.additions,
       deletions: change.deletions,
       movePath: change.movePath,

+ 1 - 2
packages/opencode/src/tool/edit.ts

@@ -123,8 +123,7 @@ export const EditTool = Tool.define("edit", {
 
     const filediff: Snapshot.FileDiff = {
       file: filePath,
-      before: contentOld,
-      after: contentNew,
+      patch: diff,
       additions: 0,
       deletions: 0,
     }

+ 7 - 8
packages/opencode/test/share/share-next.test.ts

@@ -272,8 +272,8 @@ describe("ShareNext", () => {
             diff: [
               {
                 file: "a.ts",
-                before: "one",
-                after: "two",
+                patch:
+                  "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,1 +1,1 @@\n-one\n\\ No newline at end of file\n+two\n\\ No newline at end of file\n",
                 additions: 1,
                 deletions: 1,
                 status: "modified",
@@ -285,8 +285,8 @@ describe("ShareNext", () => {
             diff: [
               {
                 file: "b.ts",
-                before: "old",
-                after: "new",
+                patch:
+                  "Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n",
                 additions: 2,
                 deletions: 0,
                 status: "modified",
@@ -304,8 +304,7 @@ describe("ShareNext", () => {
               type: string
               data: Array<{
                 file: string
-                before: string
-                after: string
+                patch: string
                 additions: number
                 deletions: number
                 status?: string
@@ -318,8 +317,8 @@ describe("ShareNext", () => {
           expect(body.data[0].data).toEqual([
             {
               file: "b.ts",
-              before: "old",
-              after: "new",
+              patch:
+                "Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n",
               additions: 2,
               deletions: 0,
               status: "modified",

+ 14 - 21
packages/opencode/test/snapshot/snapshot.test.ts

@@ -974,8 +974,7 @@ test("diffFull with new file additions", async () => {
 
       const newFileDiff = diffs[0]
       expect(newFileDiff.file).toBe("new.txt")
-      expect(newFileDiff.before).toBe("")
-      expect(newFileDiff.after).toBe("new content")
+      expect(newFileDiff.patch).toContain("+new content")
       expect(newFileDiff.additions).toBe(1)
       expect(newFileDiff.deletions).toBe(0)
     },
@@ -1020,26 +1019,23 @@ test("diffFull with a large interleaved mixed diff", async () => {
       for (let i = 0; i < ids.length; i++) {
         const m = map.get(fwd("mix", `${ids[i]}-mod.txt`))
         expect(m).toBeDefined()
-        expect(m!.before).toBe(`before-${ids[i]}-é\n🙂\nline`)
-        expect(m!.after).toBe(`after-${ids[i]}-é\n🚀\nline`)
+        expect(m!.patch).toContain(`-before-${ids[i]}-é`)
+        expect(m!.patch).toContain(`+after-${ids[i]}-é`)
         expect(m!.status).toBe("modified")
 
         const d = map.get(fwd("mix", `${ids[i]}-del.txt`))
         expect(d).toBeDefined()
-        expect(d!.before).toBe(`gone-${ids[i]}\n你好`)
-        expect(d!.after).toBe("")
+        expect(d!.patch).toContain(`-gone-${ids[i]}`)
         expect(d!.status).toBe("deleted")
 
         const a = map.get(fwd("mix", `${ids[i]}-add.txt`))
         expect(a).toBeDefined()
-        expect(a!.before).toBe("")
-        expect(a!.after).toBe(`new-${ids[i]}\nこんにちは`)
+        expect(a!.patch).toContain(`+new-${ids[i]}`)
         expect(a!.status).toBe("added")
 
         const b = map.get(fwd("mix", `${ids[i]}-bin.bin`))
         expect(b).toBeDefined()
-        expect(b!.before).toBe("")
-        expect(b!.after).toBe("")
+        expect(b!.patch).toBe("")
         expect(b!.additions).toBe(0)
         expect(b!.deletions).toBe(0)
         expect(b!.status).toBe("modified")
@@ -1092,8 +1088,8 @@ test("diffFull with file modifications", async () => {
 
       const modifiedFileDiff = diffs[0]
       expect(modifiedFileDiff.file).toBe("b.txt")
-      expect(modifiedFileDiff.before).toBe(tmp.extra.bContent)
-      expect(modifiedFileDiff.after).toBe("modified content")
+      expect(modifiedFileDiff.patch).toContain(`-${tmp.extra.bContent}`)
+      expect(modifiedFileDiff.patch).toContain("+modified content")
       expect(modifiedFileDiff.additions).toBeGreaterThan(0)
       expect(modifiedFileDiff.deletions).toBeGreaterThan(0)
     },
@@ -1118,8 +1114,7 @@ test("diffFull with file deletions", async () => {
 
       const removedFileDiff = diffs[0]
       expect(removedFileDiff.file).toBe("a.txt")
-      expect(removedFileDiff.before).toBe(tmp.extra.aContent)
-      expect(removedFileDiff.after).toBe("")
+      expect(removedFileDiff.patch).toContain(`-${tmp.extra.aContent}`)
       expect(removedFileDiff.additions).toBe(0)
       expect(removedFileDiff.deletions).toBe(1)
     },
@@ -1144,8 +1139,8 @@ test("diffFull with multiple line additions", async () => {
 
       const multiDiff = diffs[0]
       expect(multiDiff.file).toBe("multi.txt")
-      expect(multiDiff.before).toBe("")
-      expect(multiDiff.after).toBe("line1\nline2\nline3")
+      expect(multiDiff.patch).toContain("+line1")
+      expect(multiDiff.patch).toContain("+line3")
       expect(multiDiff.additions).toBe(3)
       expect(multiDiff.deletions).toBe(0)
     },
@@ -1171,15 +1166,13 @@ test("diffFull with addition and deletion", async () => {
 
       const addedFileDiff = diffs.find((d) => d.file === "added.txt")
       expect(addedFileDiff).toBeDefined()
-      expect(addedFileDiff!.before).toBe("")
-      expect(addedFileDiff!.after).toBe("added content")
+      expect(addedFileDiff!.patch).toContain("+added content")
       expect(addedFileDiff!.additions).toBe(1)
       expect(addedFileDiff!.deletions).toBe(0)
 
       const removedFileDiff = diffs.find((d) => d.file === "a.txt")
       expect(removedFileDiff).toBeDefined()
-      expect(removedFileDiff!.before).toBe(tmp.extra.aContent)
-      expect(removedFileDiff!.after).toBe("")
+      expect(removedFileDiff!.patch).toContain(`-${tmp.extra.aContent}`)
       expect(removedFileDiff!.additions).toBe(0)
       expect(removedFileDiff!.deletions).toBe(1)
     },
@@ -1263,7 +1256,7 @@ test("diffFull with binary file changes", async () => {
 
       const binaryDiff = diffs[0]
       expect(binaryDiff.file).toBe("binary.bin")
-      expect(binaryDiff.before).toBe("")
+      expect(binaryDiff.patch).toBe("")
     },
   })
 })

+ 6 - 8
packages/opencode/test/tool/apply_patch.test.ts

@@ -27,9 +27,7 @@ type AskInput = {
       filePath: string
       relativePath: string
       type: "add" | "update" | "delete" | "move"
-      diff: string
-      before: string
-      after: string
+      patch: string
       additions: number
       deletions: number
       movePath?: string
@@ -112,12 +110,12 @@ describe("tool.apply_patch freeform", () => {
         const addFile = permissionCall.metadata.files.find((f) => f.type === "add")
         expect(addFile).toBeDefined()
         expect(addFile!.relativePath).toBe("nested/new.txt")
-        expect(addFile!.after).toBe("created\n")
+        expect(addFile!.patch).toContain("+created")
 
         const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
         expect(updateFile).toBeDefined()
-        expect(updateFile!.before).toContain("line2")
-        expect(updateFile!.after).toContain("changed")
+        expect(updateFile!.patch).toContain("-line2")
+        expect(updateFile!.patch).toContain("+changed")
 
         const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
         expect(added).toBe("created\n")
@@ -151,8 +149,8 @@ describe("tool.apply_patch freeform", () => {
         expect(moveFile.type).toBe("move")
         expect(moveFile.relativePath).toBe("renamed/dir/name.txt")
         expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt"))
-        expect(moveFile.before).toBe("old content\n")
-        expect(moveFile.after).toBe("new content\n")
+        expect(moveFile.patch).toContain("-old content")
+        expect(moveFile.patch).toContain("+new content")
       },
     })
   })

+ 17 - 10
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -347,10 +347,9 @@ export type EventCommandExecuted = {
   }
 }
 
-export type FileDiff = {
+export type SnapshotFileDiff = {
   file: string
-  before: string
-  after: string
+  patch: string
   additions: number
   deletions: number
   status?: "added" | "deleted" | "modified"
@@ -360,7 +359,7 @@ export type EventSessionDiff = {
   type: "session.diff"
   properties: {
     sessionID: string
-    diff: Array<FileDiff>
+    diff: Array<SnapshotFileDiff>
   }
 }
 
@@ -542,7 +541,7 @@ export type UserMessage = {
   summary?: {
     title?: string
     body?: string
-    diffs: Array<FileDiff>
+    diffs: Array<SnapshotFileDiff>
   }
   agent: string
   model: {
@@ -917,7 +916,7 @@ export type Session = {
     additions: number
     deletions: number
     files: number
-    diffs?: Array<FileDiff>
+    diffs?: Array<SnapshotFileDiff>
   }
   share?: {
     url: string
@@ -1078,7 +1077,7 @@ export type SyncEventSessionUpdated = {
         additions: number
         deletions: number
         files: number
-        diffs?: Array<FileDiff>
+        diffs?: Array<SnapshotFileDiff>
       } | null
       share?: {
         url: string | null
@@ -1803,7 +1802,7 @@ export type GlobalSession = {
     additions: number
     deletions: number
     files: number
-    diffs?: Array<FileDiff>
+    diffs?: Array<SnapshotFileDiff>
   }
   share?: {
     url: string
@@ -2009,6 +2008,14 @@ export type VcsInfo = {
   default_branch?: string
 }
 
+export type VcsFileDiff = {
+  file: string
+  patch: string
+  additions: number
+  deletions: number
+  status?: "added" | "deleted" | "modified"
+}
+
 export type Command = {
   name: string
   description?: string
@@ -3503,7 +3510,7 @@ export type SessionDiffResponses = {
   /**
    * Successfully retrieved diff
    */
-  200: Array<FileDiff>
+  200: Array<SnapshotFileDiff>
 }
 
 export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses]
@@ -5159,7 +5166,7 @@ export type VcsDiffResponses = {
   /**
    * VCS diff
    */
-  200: Array<FileDiff>
+  200: Array<VcsFileDiff>
 }
 
 export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses]

+ 1 - 0
packages/ui/package.json

@@ -53,6 +53,7 @@
     "@solid-primitives/resize-observer": "2.1.3",
     "@solidjs/meta": "catalog:",
     "@solidjs/router": "catalog:",
+    "diff": "catalog:",
     "dompurify": "3.3.1",
     "fuzzysort": "catalog:",
     "katex": "0.16.27",

+ 2 - 0
packages/ui/src/components/file-media.tsx

@@ -16,6 +16,7 @@ export type FileMediaOptions = {
   current?: unknown
   before?: unknown
   after?: unknown
+  deleted?: boolean
   readFile?: (path: string) => Promise<FileContent | undefined>
   onLoad?: () => void
   onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
@@ -49,6 +50,7 @@ export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX
     const media = cfg()
     const k = kind()
     if (!media || !k) return false
+    if (media.deleted) return true
     if (k === "svg") return false
     if (media.current !== undefined) return false
     return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)

+ 26 - 11
packages/ui/src/components/file-ssr.tsx

@@ -1,5 +1,5 @@
 import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
-import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
+import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
 import { Dynamic, isServer } from "solid-js/web"
 import { useWorkerPool } from "../context/worker-pool"
@@ -16,8 +16,10 @@ import {
 import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
 import { File, type DiffFileProps, type FileProps } from "./file"
 
+type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
+
 type SSRDiffFileProps<T> = DiffFileProps<T> & {
-  preloadedDiff: PreloadMultiFileDiffResult<T>
+  preloadedDiff: DiffPreload<T>
 }
 
 function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
@@ -32,6 +34,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
   const [local, others] = splitProps(props, [
     "mode",
     "media",
+    "fileDiff",
     "before",
     "after",
     "class",
@@ -90,12 +93,13 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
     onCleanup(observeViewerScheme(() => fileDiffRef))
 
     const virtualizer = getVirtualizer()
+    const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
     fileDiffInstance = virtualizer
       ? new VirtualizedFileDiff<T>(
           {
             ...createDefaultOptions(props.diffStyle),
             ...others,
-            ...local.preloadedDiff,
+            ...(local.preloadedDiff.options ?? {}),
           },
           virtualizer,
           virtualMetrics,
@@ -105,7 +109,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
           {
             ...createDefaultOptions(props.diffStyle),
             ...others,
-            ...local.preloadedDiff,
+            ...(local.preloadedDiff.options ?? {}),
           },
           workerPool,
         )
@@ -114,13 +118,24 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
 
     // @ts-expect-error private field required for hydration
     fileDiffInstance.fileContainer = fileDiffRef
-    fileDiffInstance.hydrate({
-      oldFile: local.before,
-      newFile: local.after,
-      lineAnnotations: local.annotations ?? [],
-      fileContainer: fileDiffRef,
-      containerWrapper: container,
-    })
+    fileDiffInstance.hydrate(
+      local.fileDiff
+        ? {
+            fileDiff: local.fileDiff,
+            lineAnnotations: annotations,
+            fileContainer: fileDiffRef,
+            containerWrapper: container,
+            prerenderedHTML: local.preloadedDiff.prerenderedHTML,
+          }
+        : {
+            oldFile: local.before,
+            newFile: local.after,
+            lineAnnotations: annotations,
+            fileContainer: fileDiffRef,
+            containerWrapper: container,
+            prerenderedHTML: local.preloadedDiff.prerenderedHTML,
+          },
+    )
 
     notifyRendered()
   })

+ 38 - 6
packages/ui/src/components/file.tsx

@@ -3,6 +3,7 @@ import {
   DEFAULT_VIRTUAL_FILE_METRICS,
   type DiffLineAnnotation,
   type FileContents,
+  type FileDiffMetadata,
   File as PierreFile,
   type FileDiffOptions,
   FileDiff,
@@ -14,7 +15,7 @@ import {
   VirtualizedFileDiff,
   Virtualizer,
 } from "@pierre/diffs"
-import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
+import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 import { createMediaQuery } from "@solid-primitives/media"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
@@ -80,15 +81,29 @@ export type TextFileProps<T = {}> = FileOptions<T> &
     preloadedDiff?: PreloadMultiFileDiffResult<T>
   }
 
-export type DiffFileProps<T = {}> = FileDiffOptions<T> &
+type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
+
+type DiffBaseProps<T> = FileDiffOptions<T> &
   SharedProps<T> & {
     mode: "diff"
-    before: FileContents
-    after: FileContents
     annotations?: DiffLineAnnotation<T>[]
-    preloadedDiff?: PreloadMultiFileDiffResult<T>
+    preloadedDiff?: DiffPreload<T>
   }
 
+type DiffPairProps<T> = DiffBaseProps<T> & {
+  before: FileContents
+  after: FileContents
+  fileDiff?: undefined
+}
+
+type DiffPatchProps<T> = DiffBaseProps<T> & {
+  fileDiff: FileDiffMetadata
+  before?: undefined
+  after?: undefined
+}
+
+export type DiffFileProps<T = {}> = DiffPairProps<T> | DiffPatchProps<T>
+
 export type FileProps<T = {}> = TextFileProps<T> | DiffFileProps<T>
 
 const sharedKeys = [
@@ -108,7 +123,7 @@ const sharedKeys = [
 ] as const
 
 const textKeys = ["file", ...sharedKeys] as const
-const diffKeys = ["before", "after", ...sharedKeys] as const
+const diffKeys = ["fileDiff", "before", "after", ...sharedKeys] as const
 
 // ---------------------------------------------------------------------------
 // Shared viewer hook
@@ -976,6 +991,12 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
   const virtuals = createSharedVirtualStrategy(() => viewer.container)
 
   const large = createMemo(() => {
+    if (local.fileDiff) {
+      const before = local.fileDiff.deletionLines.join("")
+      const after = local.fileDiff.additionLines.join("")
+      return Math.max(before.length, after.length) > 500_000
+    }
+
     const before = typeof local.before?.contents === "string" ? local.before.contents : ""
     const after = typeof local.after?.contents === "string" ? local.after.contents : ""
     return Math.max(before.length, after.length) > 500_000
@@ -1054,6 +1075,17 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
         instance = value
       },
       draw: (value) => {
+        if (local.fileDiff) {
+          value.render({
+            fileDiff: local.fileDiff,
+            lineAnnotations: [],
+            containerWrapper: viewer.container,
+          })
+          return
+        }
+
+        if (!local.before || !local.after) return
+
         value.render({
           oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
           newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },

+ 37 - 0
packages/ui/src/components/session-diff.test.ts

@@ -0,0 +1,37 @@
+import { describe, expect, test } from "bun:test"
+import { normalize, text } from "./session-diff"
+
+describe("session diff", () => {
+  test("keeps unified patch content", () => {
+    const diff = {
+      file: "a.ts",
+      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,
+      status: "modified" as const,
+    }
+    const view = normalize(diff)
+
+    expect(view.patch).toBe(diff.patch)
+    expect(view.fileDiff.name).toBe("a.ts")
+    expect(text(view, "deletions")).toBe("one\ntwo\n")
+    expect(text(view, "additions")).toBe("one\nthree\n")
+  })
+
+  test("converts legacy content into a patch", () => {
+    const diff = {
+      file: "a.ts",
+      before: "one\n",
+      after: "two\n",
+      additions: 1,
+      deletions: 1,
+      status: "modified" as const,
+    }
+    const view = normalize(diff)
+
+    expect(view.patch).toContain("@@ -1,1 +1,1 @@")
+    expect(text(view, "deletions")).toBe("one\n")
+    expect(text(view, "additions")).toBe("two\n")
+  })
+})

+ 83 - 0
packages/ui/src/components/session-diff.ts

@@ -0,0 +1,83 @@
+import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
+import { sampledChecksum } from "@opencode-ai/util/encode"
+import { formatPatch, structuredPatch } from "diff"
+import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
+
+type LegacyDiff = {
+  file: string
+  patch?: string
+  before?: string
+  after?: string
+  additions: number
+  deletions: number
+  status?: "added" | "deleted" | "modified"
+}
+
+type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff
+
+export type ViewDiff = {
+  file: string
+  patch: string
+  additions: number
+  deletions: number
+  status?: "added" | "deleted" | "modified"
+  fileDiff: FileDiffMetadata
+}
+
+const cache = new Map<string, FileDiffMetadata>()
+
+function empty(file: string, key: string) {
+  return {
+    name: file,
+    type: "change",
+    hunks: [],
+    splitLineCount: 0,
+    unifiedLineCount: 0,
+    isPartial: true,
+    deletionLines: [],
+    additionLines: [],
+    cacheKey: key,
+  } satisfies FileDiffMetadata
+}
+
+function patch(diff: ReviewDiff) {
+  if (typeof diff.patch === "string") return diff.patch
+  return formatPatch(
+    structuredPatch(
+      diff.file,
+      diff.file,
+      "before" in diff && typeof diff.before === "string" ? diff.before : "",
+      "after" in diff && typeof diff.after === "string" ? diff.after : "",
+      "",
+      "",
+      { context: Number.MAX_SAFE_INTEGER },
+    ),
+  )
+}
+
+function file(file: string, patch: string) {
+  const hit = cache.get(patch)
+  if (hit) return hit
+
+  const key = sampledChecksum(patch) ?? file
+  const value = parsePatchFiles(patch, key).flatMap((item) => item.files)[0] ?? empty(file, key)
+  cache.set(patch, value)
+  return value
+}
+
+export function normalize(diff: ReviewDiff): ViewDiff {
+  const next = patch(diff)
+  return {
+    file: diff.file,
+    patch: next,
+    additions: diff.additions,
+    deletions: diff.deletions,
+    status: diff.status,
+    fileDiff: file(diff.file, next),
+  }
+}
+
+export function text(diff: ViewDiff, side: "deletions" | "additions") {
+  if (side === "deletions") return diff.fileDiff.deletionLines.join("")
+  return diff.fileDiff.additionLines.join("")
+}

+ 15 - 21
packages/ui/src/components/session-review.tsx

@@ -15,7 +15,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { checksum } from "@opencode-ai/util/encode"
 import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
 import { createStore } from "solid-js/store"
-import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
+import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2"
 import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 import { type SelectedLineRange } from "@pierre/diffs"
 import { Dynamic } from "solid-js/web"
@@ -23,6 +23,7 @@ import { mediaKindFromPath } from "../pierre/media"
 import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
 import { createLineCommentController } from "./line-comment-annotations"
 import type { LineCommentEditorProps } from "./line-comment"
+import { normalize, text, type ViewDiff } from "./session-diff"
 
 const MAX_DIFF_CHANGED_LINES = 500
 const REVIEW_MOUNT_MARGIN = 300
@@ -61,7 +62,8 @@ export type SessionReviewCommentActions = {
 
 export type SessionReviewFocus = { file: string; id: string }
 
-type ReviewDiff = FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
+type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult<any> }
+type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
 
 export interface SessionReviewProps {
   title?: JSX.Element
@@ -155,8 +157,8 @@ export const SessionReview = (props: SessionReviewProps) => {
   const opened = () => store.opened
 
   const open = () => props.open ?? store.open
-  const files = createMemo(() => props.diffs.map((diff) => diff.file))
-  const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
+  const items = createMemo<Item[]>(() => props.diffs.map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded })))
+  const files = createMemo(() => items().map((diff) => diff.file))
   const grouped = createMemo(() => {
     const next = new Map<string, SessionReviewComment[]>()
     for (const comment of props.comments ?? []) {
@@ -246,10 +248,10 @@ export const SessionReview = (props: SessionReviewProps) => {
 
   const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
 
-  const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
+  const selectionPreview = (diff: ViewDiff, range: SelectedLineRange) => {
     const side = selectionSide(range)
-    const contents = side === "deletions" ? diff.before : diff.after
-    if (typeof contents !== "string" || contents.length === 0) return undefined
+    const contents = text(diff, side)
+    if (contents.length === 0) return undefined
 
     return previewSelectedLines(contents, range)
   }
@@ -359,7 +361,7 @@ export const SessionReview = (props: SessionReviewProps) => {
           <Show when={hasDiffs()} fallback={props.empty}>
             <div class="pb-6">
               <Accordion multiple value={open()} onChange={handleChange}>
-                <For each={props.diffs}>
+                <For each={items()}>
                   {(diff) => {
                     let wrapper: HTMLDivElement | undefined
                     const file = diff.file
@@ -371,8 +373,8 @@ export const SessionReview = (props: SessionReviewProps) => {
                     const comments = createMemo(() => grouped().get(file) ?? [])
                     const commentedLines = createMemo(() => comments().map((c) => c.selection))
 
-                    const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
-                    const afterText = () => (typeof diff.after === "string" ? diff.after : "")
+                    const beforeText = () => text(diff, "deletions")
+                    const afterText = () => text(diff, "additions")
                     const changedLines = () => diff.additions + diff.deletions
                     const mediaKind = createMemo(() => mediaKindFromPath(file))
 
@@ -581,6 +583,7 @@ export const SessionReview = (props: SessionReviewProps) => {
                                   <Dynamic
                                     component={fileComponent}
                                     mode="diff"
+                                    fileDiff={diff.fileDiff}
                                     preloadedDiff={diff.preloaded}
                                     diffStyle={diffStyle()}
                                     onRendered={() => {
@@ -596,20 +599,11 @@ export const SessionReview = (props: SessionReviewProps) => {
                                     renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
                                     selectedLines={selectedLines()}
                                     commentedLines={commentedLines()}
-                                    before={{
-                                      name: file,
-                                      contents: typeof diff.before === "string" ? diff.before : "",
-                                    }}
-                                    after={{
-                                      name: file,
-                                      contents: typeof diff.after === "string" ? diff.after : "",
-                                    }}
                                     media={{
                                       mode: "auto",
                                       path: file,
-                                      before: diff.before,
-                                      after: diff.after,
-                                      readFile: props.readFile,
+                                      deleted: diff.status === "deleted",
+                                      readFile: diff.status === "deleted" ? undefined : props.readFile,
                                     }}
                                   />
                                 </Match>

+ 11 - 9
packages/ui/src/components/session-turn.tsx

@@ -1,4 +1,9 @@
-import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
+import {
+  AssistantMessage,
+  type SnapshotFileDiff,
+  Message as MessageType,
+  Part as PartType,
+} from "@opencode-ai/sdk/v2/client"
 import type { SessionStatus } from "@opencode-ai/sdk/v2"
 import { useData } from "../context"
 import { useFileComponent } from "../context/file"
@@ -19,6 +24,7 @@ import { SessionRetry } from "./session-retry"
 import { TextReveal } from "./text-reveal"
 import { createAutoScroll } from "../hooks"
 import { useI18n } from "../context/i18n"
+import { normalize } from "./session-diff"
 
 function record(value: unknown): value is Record<string, unknown> {
   return !!value && typeof value === "object" && !Array.isArray(value)
@@ -163,7 +169,7 @@ export function SessionTurn(
   const emptyMessages: MessageType[] = []
   const emptyParts: PartType[] = []
   const emptyAssistant: AssistantMessage[] = []
-  const emptyDiffs: FileDiff[] = []
+  const emptyDiffs: SnapshotFileDiff[] = []
   const idle = { type: "idle" as const }
 
   const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages))
@@ -232,7 +238,7 @@ export function SessionTurn(
 
     const seen = new Set<string>()
     return files
-      .reduceRight<FileDiff[]>((result, diff) => {
+      .reduceRight<SnapshotFileDiff[]>((result, diff) => {
         if (seen.has(diff.file)) return result
         seen.add(diff.file)
         result.push(diff)
@@ -447,6 +453,7 @@ export function SessionTurn(
                     >
                       <For each={visible()}>
                         {(diff) => {
+                          const view = normalize(diff)
                           const active = createMemo(() => expanded().includes(diff.file))
                           const [shown, setShown] = createSignal(false)
 
@@ -495,12 +502,7 @@ export function SessionTurn(
                               <Accordion.Content>
                                 <Show when={shown()}>
                                   <div data-slot="session-turn-diff-view" data-scrollable>
-                                    <Dynamic
-                                      component={fileComponent}
-                                      mode="diff"
-                                      before={{ name: diff.file, contents: diff.before }}
-                                      after={{ name: diff.file, contents: diff.after }}
-                                    />
+                                    <Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
                                   </div>
                                 </Show>
                               </Accordion.Content>

+ 2 - 2
packages/ui/src/context/data.tsx

@@ -1,4 +1,4 @@
-import type { Message, Session, Part, FileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2"
+import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2"
 import { createSimpleContext } from "./helper"
 import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 
@@ -13,7 +13,7 @@ type Data = {
     [sessionID: string]: SessionStatus
   }
   session_diff: {
-    [sessionID: string]: FileDiff[]
+    [sessionID: string]: SnapshotFileDiff[]
   }
   session_diff_preload?: {
     [sessionID: string]: PreloadMultiFileDiffResult<any>[]