Browse Source

fix(app): last turn changes rendered in review pane (#12182)

Adam 3 weeks ago
parent
commit
222bddc41a

+ 86 - 3
packages/app/src/pages/session.tsx

@@ -28,6 +28,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
 import { InlineInput } from "@opencode-ai/ui/inline-input"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Tabs } from "@opencode-ai/ui/tabs"
+import { Select } from "@opencode-ai/ui/select"
 import { useCodeComponent } from "@opencode-ai/ui/context/code"
 import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
@@ -54,7 +55,7 @@ import { useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
 import { useNavigate, useParams } from "@solidjs/router"
 import { UserMessage } from "@opencode-ai/sdk/v2"
-import type { FileDiff } from "@opencode-ai/sdk/v2/client"
+import type { FileDiff } from "@opencode-ai/sdk/v2"
 import { useSDK } from "@/context/sdk"
 import { usePrompt } from "@/context/prompt"
 import { useComments, type LineComment } from "@/context/comments"
@@ -104,6 +105,8 @@ const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
 }
 
 interface SessionReviewTabProps {
+  title?: JSX.Element
+  empty?: JSX.Element
   diffs: () => FileDiff[]
   view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
   diffStyle: DiffStyle
@@ -220,6 +223,8 @@ function SessionReviewTab(props: SessionReviewTabProps) {
 
   return (
     <SessionReview
+      title={props.title}
+      empty={props.empty}
       scrollRef={(el) => {
         scroll = el
         props.onScrollRef?.(el)
@@ -709,10 +714,14 @@ export default function Page() {
     messageId: undefined as string | undefined,
     turnStart: 0,
     mobileTab: "session" as "session" | "changes",
+    changes: "session" as "session" | "turn",
     newSessionWorktree: "main",
     promptHeight: 0,
   })
 
+  const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
+  const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
+
   const renderedUserMessages = createMemo(
     () => {
       const msgs = visibleUserMessages()
@@ -894,6 +903,7 @@ export default function Page() {
       () => {
         setStore("messageId", undefined)
         setStore("expanded", {})
+        setStore("changes", "session")
         setUi("autoCreated", false)
       },
       { defer: true },
@@ -1428,17 +1438,64 @@ export default function Page() {
     setFileTreeTab("all")
   }
 
+  const changesOptions = ["session", "turn"] as const
+  const changesOptionsList = [...changesOptions]
+
+  const changesTitle = () => (
+    <Select
+      options={changesOptionsList}
+      current={store.changes}
+      label={(option) =>
+        option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
+      }
+      onSelect={(option) => option && setStore("changes", option)}
+      variant="ghost"
+      size="large"
+      triggerStyle={{ "font-size": "var(--font-size-large)" }}
+    />
+  )
+
+  const emptyTurn = () => (
+    <div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
+      <Mark class="w-14 opacity-10" />
+      <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
+    </div>
+  )
+
   const reviewPanel = () => (
     <div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
       <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
         <Switch>
+          <Match when={store.changes === "turn" && !!params.id}>
+            <SessionReviewTab
+              title={changesTitle()}
+              empty={emptyTurn()}
+              diffs={reviewDiffs}
+              view={view}
+              diffStyle={layout.review.diffStyle()}
+              onDiffStyleChange={layout.review.setDiffStyle}
+              onScrollRef={(el) => setTree("reviewScroll", el)}
+              focusedFile={tree.activeDiff}
+              onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+              comments={comments.all()}
+              focusedComment={comments.focus()}
+              onFocusedCommentChange={comments.setFocus}
+              onViewFile={(path) => {
+                showAllFiles()
+                const value = file.tab(path)
+                tabs().open(value)
+                file.load(path)
+              }}
+            />
+          </Match>
           <Match when={hasReview()}>
             <Show
               when={diffsReady()}
               fallback={<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>}
             >
               <SessionReviewTab
-                diffs={diffs}
+                title={changesTitle()}
+                diffs={reviewDiffs}
                 view={view}
                 diffStyle={layout.review.diffStyle()}
                 onDiffStyleChange={layout.review.setDiffStyle}
@@ -2138,6 +2195,31 @@ export default function Page() {
                     fallback={
                       <div class="relative h-full overflow-hidden">
                         <Switch>
+                          <Match when={store.changes === "turn" && !!params.id}>
+                            <SessionReviewTab
+                              title={changesTitle()}
+                              empty={emptyTurn()}
+                              diffs={reviewDiffs}
+                              view={view}
+                              diffStyle="unified"
+                              focusedFile={tree.activeDiff}
+                              onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+                              comments={comments.all()}
+                              focusedComment={comments.focus()}
+                              onFocusedCommentChange={comments.setFocus}
+                              onViewFile={(path) => {
+                                showAllFiles()
+                                const value = file.tab(path)
+                                tabs().open(value)
+                                file.load(path)
+                              }}
+                              classes={{
+                                root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
+                                header: "px-4",
+                                container: "px-4",
+                              }}
+                            />
+                          </Match>
                           <Match when={hasReview()}>
                             <Show
                               when={diffsReady()}
@@ -2148,7 +2230,8 @@ export default function Page() {
                               }
                             >
                               <SessionReviewTab
-                                diffs={diffs}
+                                title={changesTitle()}
+                                diffs={reviewDiffs}
                                 view={view}
                                 diffStyle="unified"
                                 focusedFile={tree.activeDiff}

+ 3 - 3
packages/ui/src/components/session-review.css

@@ -10,9 +10,9 @@
     display: none;
   }
 
-  /* [data-slot="session-review-container"] { */
-  /*   height: 100%; */
-  /* } */
+  [data-slot="session-review-container"] {
+    flex: 1 1 auto;
+  }
 
   [data-slot="session-review-header"] {
     position: sticky;

+ 309 - 302
packages/ui/src/components/session-review.tsx

@@ -36,6 +36,8 @@ export type SessionReviewLineComment = {
 export type SessionReviewFocus = { file: string; id: string }
 
 export interface SessionReviewProps {
+  title?: JSX.Element
+  empty?: JSX.Element
   split?: boolean
   diffStyle?: SessionReviewDiffStyle
   onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
@@ -184,6 +186,7 @@ export const SessionReview = (props: SessionReviewProps) => {
 
   const open = () => props.open ?? store.open
   const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
+  const hasDiffs = () => props.diffs.length > 0
 
   const handleChange = (open: string[]) => {
     props.onOpenChange?.(open)
@@ -287,9 +290,9 @@ export const SessionReview = (props: SessionReviewProps) => {
           [props.classes?.header ?? ""]: !!props.classes?.header,
         }}
       >
-        <div data-slot="session-review-title">{i18n.t("ui.sessionReview.title")}</div>
+        <div data-slot="session-review-title">{props.title ?? i18n.t("ui.sessionReview.title")}</div>
         <div data-slot="session-review-actions">
-          <Show when={props.onDiffStyleChange}>
+          <Show when={hasDiffs() && props.onDiffStyleChange}>
             <RadioGroup
               options={["unified", "split"] as const}
               current={diffStyle()}
@@ -300,12 +303,14 @@ export const SessionReview = (props: SessionReviewProps) => {
               onSelect={(style) => style && props.onDiffStyleChange?.(style)}
             />
           </Show>
-          <Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
-            <Switch>
-              <Match when={open().length > 0}>{i18n.t("ui.sessionReview.collapseAll")}</Match>
-              <Match when={true}>{i18n.t("ui.sessionReview.expandAll")}</Match>
-            </Switch>
-          </Button>
+          <Show when={hasDiffs()}>
+            <Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
+              <Switch>
+                <Match when={open().length > 0}>{i18n.t("ui.sessionReview.collapseAll")}</Match>
+                <Match when={true}>{i18n.t("ui.sessionReview.expandAll")}</Match>
+              </Switch>
+            </Button>
+          </Show>
           {props.actions}
         </div>
       </div>
@@ -315,322 +320,324 @@ export const SessionReview = (props: SessionReviewProps) => {
           [props.classes?.container ?? ""]: !!props.classes?.container,
         }}
       >
-        <Accordion multiple value={open()} onChange={handleChange}>
-          <For each={props.diffs}>
-            {(diff) => {
-              let wrapper: HTMLDivElement | undefined
-
-              const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.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 isAdded = () => beforeText().length === 0 && afterText().length > 0
-              const isDeleted = () => afterText().length === 0 && beforeText().length > 0
-              const isImage = () => isImageFile(diff.file)
-              const isAudio = () => isAudioFile(diff.file)
-
-              const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
-              const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
-              const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
-
-              const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
-              const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
-              const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
-              const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
-
-              const selectedLines = createMemo(() => {
-                const current = selection()
-                if (!current || current.file !== diff.file) return null
-                return current.range
-              })
-
-              const draftRange = createMemo(() => {
-                const current = commenting()
-                if (!current || current.file !== diff.file) return null
-                return current.range
-              })
-
-              const [draft, setDraft] = createSignal("")
-              const [positions, setPositions] = createSignal<Record<string, number>>({})
-              const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
-
-              const getRoot = () => {
-                const el = wrapper
-                if (!el) return
-
-                const host = el.querySelector("diffs-container")
-                if (!(host instanceof HTMLElement)) return
-                return host.shadowRoot ?? undefined
-              }
-
-              const updateAnchors = () => {
-                const el = wrapper
-                if (!el) return
-
-                const root = getRoot()
-                if (!root) return
-
-                const next: Record<string, number> = {}
-                for (const item of comments()) {
-                  const marker = findMarker(root, item.selection)
-                  if (!marker) continue
-                  next[item.id] = markerTop(el, marker)
+        <Show when={hasDiffs()} fallback={props.empty}>
+          <Accordion multiple value={open()} onChange={handleChange}>
+            <For each={props.diffs}>
+              {(diff) => {
+                let wrapper: HTMLDivElement | undefined
+
+                const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.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 isAdded = () => beforeText().length === 0 && afterText().length > 0
+                const isDeleted = () => afterText().length === 0 && beforeText().length > 0
+                const isImage = () => isImageFile(diff.file)
+                const isAudio = () => isAudioFile(diff.file)
+
+                const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
+                const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
+                const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
+
+                const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
+                const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
+                const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
+                const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
+
+                const selectedLines = createMemo(() => {
+                  const current = selection()
+                  if (!current || current.file !== diff.file) return null
+                  return current.range
+                })
+
+                const draftRange = createMemo(() => {
+                  const current = commenting()
+                  if (!current || current.file !== diff.file) return null
+                  return current.range
+                })
+
+                const [draft, setDraft] = createSignal("")
+                const [positions, setPositions] = createSignal<Record<string, number>>({})
+                const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
+
+                const getRoot = () => {
+                  const el = wrapper
+                  if (!el) return
+
+                  const host = el.querySelector("diffs-container")
+                  if (!(host instanceof HTMLElement)) return
+                  return host.shadowRoot ?? undefined
                 }
-                setPositions(next)
 
-                const range = draftRange()
-                if (!range) {
-                  setDraftTop(undefined)
-                  return
+                const updateAnchors = () => {
+                  const el = wrapper
+                  if (!el) return
+
+                  const root = getRoot()
+                  if (!root) return
+
+                  const next: Record<string, number> = {}
+                  for (const item of comments()) {
+                    const marker = findMarker(root, item.selection)
+                    if (!marker) continue
+                    next[item.id] = markerTop(el, marker)
+                  }
+                  setPositions(next)
+
+                  const range = draftRange()
+                  if (!range) {
+                    setDraftTop(undefined)
+                    return
+                  }
+
+                  const marker = findMarker(root, range)
+                  if (!marker) {
+                    setDraftTop(undefined)
+                    return
+                  }
+
+                  setDraftTop(markerTop(el, marker))
                 }
 
-                const marker = findMarker(root, range)
-                if (!marker) {
-                  setDraftTop(undefined)
-                  return
+                const scheduleAnchors = () => {
+                  requestAnimationFrame(updateAnchors)
                 }
 
-                setDraftTop(markerTop(el, marker))
-              }
-
-              const scheduleAnchors = () => {
-                requestAnimationFrame(updateAnchors)
-              }
-
-              createEffect(() => {
-                comments()
-                scheduleAnchors()
-              })
-
-              createEffect(() => {
-                const range = draftRange()
-                if (!range) return
-                setDraft("")
-                scheduleAnchors()
-              })
-
-              createEffect(() => {
-                if (!open().includes(diff.file)) return
-                if (!isImage()) return
-                if (imageSrc()) return
-                if (imageStatus() !== "idle") return
-
-                const reader = props.readFile
-                if (!reader) return
-
-                setImageStatus("loading")
-                reader(diff.file)
-                  .then((result) => {
-                    const src = dataUrl(result)
-                    if (!src) {
+                createEffect(() => {
+                  comments()
+                  scheduleAnchors()
+                })
+
+                createEffect(() => {
+                  const range = draftRange()
+                  if (!range) return
+                  setDraft("")
+                  scheduleAnchors()
+                })
+
+                createEffect(() => {
+                  if (!open().includes(diff.file)) return
+                  if (!isImage()) return
+                  if (imageSrc()) return
+                  if (imageStatus() !== "idle") return
+
+                  const reader = props.readFile
+                  if (!reader) return
+
+                  setImageStatus("loading")
+                  reader(diff.file)
+                    .then((result) => {
+                      const src = dataUrl(result)
+                      if (!src) {
+                        setImageStatus("error")
+                        return
+                      }
+                      setImageSrc(src)
+                      setImageStatus("idle")
+                    })
+                    .catch(() => {
                       setImageStatus("error")
-                      return
-                    }
-                    setImageSrc(src)
-                    setImageStatus("idle")
-                  })
-                  .catch(() => {
-                    setImageStatus("error")
-                  })
-              })
-
-              createEffect(() => {
-                if (!open().includes(diff.file)) return
-                if (!isAudio()) return
-                if (audioSrc()) return
-                if (audioStatus() !== "idle") return
-
-                const reader = props.readFile
-                if (!reader) return
-
-                setAudioStatus("loading")
-                reader(diff.file)
-                  .then((result) => {
-                    const src = dataUrl(result)
-                    if (!src) {
+                    })
+                })
+
+                createEffect(() => {
+                  if (!open().includes(diff.file)) return
+                  if (!isAudio()) return
+                  if (audioSrc()) return
+                  if (audioStatus() !== "idle") return
+
+                  const reader = props.readFile
+                  if (!reader) return
+
+                  setAudioStatus("loading")
+                  reader(diff.file)
+                    .then((result) => {
+                      const src = dataUrl(result)
+                      if (!src) {
+                        setAudioStatus("error")
+                        return
+                      }
+                      setAudioMime(normalizeMimeType(result?.mimeType))
+                      setAudioSrc(src)
+                      setAudioStatus("idle")
+                    })
+                    .catch(() => {
                       setAudioStatus("error")
-                      return
-                    }
-                    setAudioMime(normalizeMimeType(result?.mimeType))
-                    setAudioSrc(src)
-                    setAudioStatus("idle")
-                  })
-                  .catch(() => {
-                    setAudioStatus("error")
-                  })
-              })
-
-              const handleLineSelected = (range: SelectedLineRange | null) => {
-                if (!props.onLineComment) return
-
-                if (!range) {
-                  setSelection(null)
-                  return
-                }
+                    })
+                })
 
-                setSelection({ file: diff.file, range })
-              }
+                const handleLineSelected = (range: SelectedLineRange | null) => {
+                  if (!props.onLineComment) return
 
-              const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
-                if (!props.onLineComment) return
+                  if (!range) {
+                    setSelection(null)
+                    return
+                  }
 
-                if (!range) {
-                  setCommenting(null)
-                  return
+                  setSelection({ file: diff.file, range })
                 }
 
-                setSelection({ file: diff.file, range })
-                setCommenting({ file: diff.file, range })
-              }
+                const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
+                  if (!props.onLineComment) return
 
-              const openComment = (comment: SessionReviewComment) => {
-                setOpened({ file: comment.file, id: comment.id })
-                setSelection({ file: comment.file, range: comment.selection })
-              }
+                  if (!range) {
+                    setCommenting(null)
+                    return
+                  }
 
-              const isCommentOpen = (comment: SessionReviewComment) => {
-                const current = opened()
-                if (!current) return false
-                return current.file === comment.file && current.id === comment.id
-              }
+                  setSelection({ file: diff.file, range })
+                  setCommenting({ file: diff.file, range })
+                }
 
-              return (
-                <Accordion.Item
-                  value={diff.file}
-                  id={diffId(diff.file)}
-                  data-file={diff.file}
-                  data-slot="session-review-accordion-item"
-                  data-selected={props.focusedFile === diff.file ? "" : undefined}
-                >
-                  <StickyAccordionHeader>
-                    <Accordion.Trigger>
-                      <div data-slot="session-review-trigger-content">
-                        <div data-slot="session-review-file-info">
-                          <FileIcon node={{ path: diff.file, type: "file" }} />
-                          <div data-slot="session-review-file-name-container">
-                            <Show when={diff.file.includes("/")}>
-                              <span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
-                            </Show>
-                            <span data-slot="session-review-filename">{getFilename(diff.file)}</span>
-                            <Show when={props.onViewFile}>
-                              <button
-                                data-slot="session-review-view-button"
-                                type="button"
-                                onClick={(e) => {
-                                  e.stopPropagation()
-                                  props.onViewFile?.(diff.file)
-                                }}
-                              >
-                                <Icon name="eye" size="small" />
-                              </button>
-                            </Show>
+                const openComment = (comment: SessionReviewComment) => {
+                  setOpened({ file: comment.file, id: comment.id })
+                  setSelection({ file: comment.file, range: comment.selection })
+                }
+
+                const isCommentOpen = (comment: SessionReviewComment) => {
+                  const current = opened()
+                  if (!current) return false
+                  return current.file === comment.file && current.id === comment.id
+                }
+
+                return (
+                  <Accordion.Item
+                    value={diff.file}
+                    id={diffId(diff.file)}
+                    data-file={diff.file}
+                    data-slot="session-review-accordion-item"
+                    data-selected={props.focusedFile === diff.file ? "" : undefined}
+                  >
+                    <StickyAccordionHeader>
+                      <Accordion.Trigger>
+                        <div data-slot="session-review-trigger-content">
+                          <div data-slot="session-review-file-info">
+                            <FileIcon node={{ path: diff.file, type: "file" }} />
+                            <div data-slot="session-review-file-name-container">
+                              <Show when={diff.file.includes("/")}>
+                                <span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
+                              </Show>
+                              <span data-slot="session-review-filename">{getFilename(diff.file)}</span>
+                              <Show when={props.onViewFile}>
+                                <button
+                                  data-slot="session-review-view-button"
+                                  type="button"
+                                  onClick={(e) => {
+                                    e.stopPropagation()
+                                    props.onViewFile?.(diff.file)
+                                  }}
+                                >
+                                  <Icon name="eye" size="small" />
+                                </button>
+                              </Show>
+                            </div>
+                          </div>
+                          <div data-slot="session-review-trigger-actions">
+                            <Switch>
+                              <Match when={isAdded()}>
+                                <span data-slot="session-review-change" data-type="added">
+                                  {i18n.t("ui.sessionReview.change.added")}
+                                </span>
+                              </Match>
+                              <Match when={isDeleted()}>
+                                <span data-slot="session-review-change" data-type="removed">
+                                  {i18n.t("ui.sessionReview.change.removed")}
+                                </span>
+                              </Match>
+                              <Match when={true}>
+                                <DiffChanges changes={diff} />
+                              </Match>
+                            </Switch>
+                            <Icon name="chevron-grabber-vertical" size="small" />
                           </div>
                         </div>
-                        <div data-slot="session-review-trigger-actions">
-                          <Switch>
-                            <Match when={isAdded()}>
-                              <span data-slot="session-review-change" data-type="added">
-                                {i18n.t("ui.sessionReview.change.added")}
-                              </span>
-                            </Match>
-                            <Match when={isDeleted()}>
-                              <span data-slot="session-review-change" data-type="removed">
-                                {i18n.t("ui.sessionReview.change.removed")}
-                              </span>
-                            </Match>
-                            <Match when={true}>
-                              <DiffChanges changes={diff} />
-                            </Match>
-                          </Switch>
-                          <Icon name="chevron-grabber-vertical" size="small" />
-                        </div>
-                      </div>
-                    </Accordion.Trigger>
-                  </StickyAccordionHeader>
-                  <Accordion.Content data-slot="session-review-accordion-content">
-                    <div
-                      data-slot="session-review-diff-wrapper"
-                      ref={(el) => {
-                        wrapper = el
-                        anchors.set(diff.file, el)
-                        scheduleAnchors()
-                      }}
-                    >
-                      <Dynamic
-                        component={diffComponent}
-                        preloadedDiff={diff.preloaded}
-                        diffStyle={diffStyle()}
-                        onRendered={() => {
-                          props.onDiffRendered?.()
+                      </Accordion.Trigger>
+                    </StickyAccordionHeader>
+                    <Accordion.Content data-slot="session-review-accordion-content">
+                      <div
+                        data-slot="session-review-diff-wrapper"
+                        ref={(el) => {
+                          wrapper = el
+                          anchors.set(diff.file, el)
                           scheduleAnchors()
                         }}
-                        enableLineSelection={props.onLineComment != null}
-                        onLineSelected={handleLineSelected}
-                        onLineSelectionEnd={handleLineSelectionEnd}
-                        selectedLines={selectedLines()}
-                        commentedLines={commentedLines()}
-                        before={{
-                          name: diff.file!,
-                          contents: typeof diff.before === "string" ? diff.before : "",
-                        }}
-                        after={{
-                          name: diff.file!,
-                          contents: typeof diff.after === "string" ? diff.after : "",
-                        }}
-                      />
-
-                      <For each={comments()}>
-                        {(comment) => (
-                          <LineComment
-                            id={comment.id}
-                            top={positions()[comment.id]}
-                            onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
-                            onClick={() => {
-                              if (isCommentOpen(comment)) {
-                                setOpened(null)
-                                return
-                              }
-
-                              openComment(comment)
-                            }}
-                            open={isCommentOpen(comment)}
-                            comment={comment.comment}
-                            selection={selectionLabel(comment.selection)}
-                          />
-                        )}
-                      </For>
-
-                      <Show when={draftRange()}>
-                        {(range) => (
-                          <Show when={draftTop() !== undefined}>
-                            <LineCommentEditor
-                              top={draftTop()}
-                              value={draft()}
-                              selection={selectionLabel(range())}
-                              onInput={setDraft}
-                              onCancel={() => setCommenting(null)}
-                              onSubmit={(comment) => {
-                                props.onLineComment?.({
-                                  file: diff.file,
-                                  selection: range(),
-                                  comment,
-                                  preview: selectionPreview(diff, range()),
-                                })
-                                setCommenting(null)
+                      >
+                        <Dynamic
+                          component={diffComponent}
+                          preloadedDiff={diff.preloaded}
+                          diffStyle={diffStyle()}
+                          onRendered={() => {
+                            props.onDiffRendered?.()
+                            scheduleAnchors()
+                          }}
+                          enableLineSelection={props.onLineComment != null}
+                          onLineSelected={handleLineSelected}
+                          onLineSelectionEnd={handleLineSelectionEnd}
+                          selectedLines={selectedLines()}
+                          commentedLines={commentedLines()}
+                          before={{
+                            name: diff.file!,
+                            contents: typeof diff.before === "string" ? diff.before : "",
+                          }}
+                          after={{
+                            name: diff.file!,
+                            contents: typeof diff.after === "string" ? diff.after : "",
+                          }}
+                        />
+
+                        <For each={comments()}>
+                          {(comment) => (
+                            <LineComment
+                              id={comment.id}
+                              top={positions()[comment.id]}
+                              onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
+                              onClick={() => {
+                                if (isCommentOpen(comment)) {
+                                  setOpened(null)
+                                  return
+                                }
+
+                                openComment(comment)
                               }}
+                              open={isCommentOpen(comment)}
+                              comment={comment.comment}
+                              selection={selectionLabel(comment.selection)}
                             />
-                          </Show>
-                        )}
-                      </Show>
-                    </div>
-                  </Accordion.Content>
-                </Accordion.Item>
-              )
-            }}
-          </For>
-        </Accordion>
+                          )}
+                        </For>
+
+                        <Show when={draftRange()}>
+                          {(range) => (
+                            <Show when={draftTop() !== undefined}>
+                              <LineCommentEditor
+                                top={draftTop()}
+                                value={draft()}
+                                selection={selectionLabel(range())}
+                                onInput={setDraft}
+                                onCancel={() => setCommenting(null)}
+                                onSubmit={(comment) => {
+                                  props.onLineComment?.({
+                                    file: diff.file,
+                                    selection: range(),
+                                    comment,
+                                    preview: selectionPreview(diff, range()),
+                                  })
+                                  setCommenting(null)
+                                }}
+                              />
+                            </Show>
+                          )}
+                        </Show>
+                      </div>
+                    </Accordion.Content>
+                  </Accordion.Item>
+                )
+              }}
+            </For>
+          </Accordion>
+        </Show>
       </div>
     </div>
   )

+ 2 - 104
packages/ui/src/components/session-turn.tsx

@@ -8,25 +8,16 @@ import {
   TextPart,
   ToolPart,
 } from "@opencode-ai/sdk/v2/client"
-import { type FileDiff } from "@opencode-ai/sdk/v2"
 import { useData } from "../context"
-import { useDiffComponent } from "../context/diff"
 import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n"
 import { findLast } from "@opencode-ai/util/array"
-import { getDirectory, getFilename } from "@opencode-ai/util/path"
 
 import { Binary } from "@opencode-ai/util/binary"
 import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
-import { DiffChanges } from "./diff-changes"
 import { Message, Part } from "./message-part"
 import { Markdown } from "./markdown"
-import { Accordion } from "./accordion"
-import { StickyAccordionHeader } from "./sticky-accordion-header"
-import { FileIcon } from "./file-icon"
-import { Icon } from "./icon"
 import { IconButton } from "./icon-button"
 import { Card } from "./card"
-import { Dynamic } from "solid-js/web"
 import { Button } from "./button"
 import { Spinner } from "./spinner"
 import { Tooltip } from "./tooltip"
@@ -143,7 +134,6 @@ export function SessionTurn(
 ) {
   const i18n = useI18n()
   const data = useData()
-  const diffComponent = useDiffComponent()
 
   const emptyMessages: MessageType[] = []
   const emptyParts: PartType[] = []
@@ -153,7 +143,6 @@ export function SessionTurn(
   const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
   const emptyQuestions: QuestionRequest[] = []
   const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = []
-  const emptyDiffs: FileDiff[] = []
   const idle = { type: "idle" as const }
 
   const allMessages = createMemo(() => data.store.message[props.sessionID] ?? emptyMessages)
@@ -409,8 +398,7 @@ export function SessionTurn(
 
   const response = createMemo(() => lastTextPart()?.text)
   const responsePartId = createMemo(() => lastTextPart()?.id)
-  const messageDiffs = createMemo(() => message()?.summary?.diffs ?? emptyDiffs)
-  const hasDiffs = createMemo(() => messageDiffs().length > 0)
+  const hasDiffs = createMemo(() => (message()?.summary?.diffs?.length ?? 0) > 0)
   const hideResponsePart = createMemo(() => !working() && !!responsePartId())
 
   const [copied, setCopied] = createSignal(false)
@@ -476,28 +464,12 @@ export function SessionTurn(
     updateStickyHeight(sticky.getBoundingClientRect().height)
   })
 
-  const diffInit = 20
-  const diffBatch = 20
-
   const [store, setStore] = createStore({
     retrySeconds: 0,
-    diffsOpen: [] as string[],
-    diffLimit: diffInit,
     status: rawStatus(),
     duration: duration(),
   })
 
-  createEffect(
-    on(
-      () => message()?.id,
-      () => {
-        setStore("diffsOpen", [])
-        setStore("diffLimit", diffInit)
-      },
-      { defer: true },
-    ),
-  )
-
   createEffect(() => {
     const r = retry()
     if (!r) {
@@ -727,7 +699,7 @@ export function SessionTurn(
                     <div class="sr-only" aria-live="polite">
                       {!working() && response() ? response() : ""}
                     </div>
-                    <Show when={!working() && (response() || hasDiffs())}>
+                    <Show when={!working() && response()}>
                       <div data-slot="session-turn-summary-section">
                         <div data-slot="session-turn-summary-header">
                           <h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2>
@@ -760,80 +732,6 @@ export function SessionTurn(
                             </Show>
                           </div>
                         </div>
-                        <Accordion
-                          data-slot="session-turn-accordion"
-                          multiple
-                          value={store.diffsOpen}
-                          onChange={(value) => {
-                            if (!Array.isArray(value)) return
-                            setStore("diffsOpen", value)
-                          }}
-                        >
-                          <For each={messageDiffs().slice(0, store.diffLimit)}>
-                            {(diff) => (
-                              <Accordion.Item value={diff.file}>
-                                <StickyAccordionHeader>
-                                  <Accordion.Trigger>
-                                    <div data-slot="session-turn-accordion-trigger-content">
-                                      <div data-slot="session-turn-file-info">
-                                        <FileIcon
-                                          node={{ path: diff.file, type: "file" }}
-                                          data-slot="session-turn-file-icon"
-                                        />
-                                        <div data-slot="session-turn-file-path">
-                                          <Show when={diff.file.includes("/")}>
-                                            <span data-slot="session-turn-directory">
-                                              {`\u202A${getDirectory(diff.file)}\u202C`}
-                                            </span>
-                                          </Show>
-                                          <span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
-                                        </div>
-                                      </div>
-                                      <div data-slot="session-turn-accordion-actions">
-                                        <DiffChanges changes={diff} />
-                                        <Icon name="chevron-grabber-vertical" size="small" />
-                                      </div>
-                                    </div>
-                                  </Accordion.Trigger>
-                                </StickyAccordionHeader>
-                                <Accordion.Content data-slot="session-turn-accordion-content">
-                                  <Show when={store.diffsOpen.includes(diff.file!)}>
-                                    <Dynamic
-                                      component={diffComponent}
-                                      before={{
-                                        name: diff.file!,
-                                        contents: diff.before!,
-                                      }}
-                                      after={{
-                                        name: diff.file!,
-                                        contents: diff.after!,
-                                      }}
-                                    />
-                                  </Show>
-                                </Accordion.Content>
-                              </Accordion.Item>
-                            )}
-                          </For>
-                        </Accordion>
-                        <Show when={messageDiffs().length > store.diffLimit}>
-                          <Button
-                            data-slot="session-turn-accordion-more"
-                            variant="ghost"
-                            size="small"
-                            onClick={() => {
-                              const total = messageDiffs().length
-                              setStore("diffLimit", (limit) => {
-                                const next = limit + diffBatch
-                                if (next > total) return total
-                                return next
-                              })
-                            }}
-                          >
-                            {i18n.t("ui.sessionTurn.diff.showMore", {
-                              count: messageDiffs().length - store.diffLimit,
-                            })}
-                          </Button>
-                        </Show>
                       </div>
                     </Show>
                     <Show when={error() && !props.stepsExpanded}>

+ 1 - 0
packages/ui/src/i18n/ar.ts

@@ -1,5 +1,6 @@
 export const dict = {
   "ui.sessionReview.title": "تغييرات الجلسة",
+  "ui.sessionReview.title.lastTurn": "تغييرات آخر دور",
   "ui.sessionReview.diffStyle.unified": "موجد",
   "ui.sessionReview.diffStyle.split": "منقسم",
   "ui.sessionReview.expandAll": "توسيع الكل",

+ 1 - 0
packages/ui/src/i18n/br.ts

@@ -1,5 +1,6 @@
 export const dict = {
   "ui.sessionReview.title": "Alterações da sessão",
+  "ui.sessionReview.title.lastTurn": "Alterações do último turno",
   "ui.sessionReview.diffStyle.unified": "Unificado",
   "ui.sessionReview.diffStyle.split": "Dividido",
   "ui.sessionReview.expandAll": "Expandir tudo",

+ 1 - 0
packages/ui/src/i18n/da.ts

@@ -1,5 +1,6 @@
 export const dict = {
   "ui.sessionReview.title": "Sessionsændringer",
+  "ui.sessionReview.title.lastTurn": "Ændringer fra sidste tur",
   "ui.sessionReview.diffStyle.unified": "Samlet",
   "ui.sessionReview.diffStyle.split": "Opdelt",
   "ui.sessionReview.expandAll": "Udvid alle",

+ 1 - 0
packages/ui/src/i18n/de.ts

@@ -4,6 +4,7 @@ type Keys = keyof typeof en
 
 export const dict = {
   "ui.sessionReview.title": "Sitzungsänderungen",
+  "ui.sessionReview.title.lastTurn": "Änderungen der letzten Runde",
   "ui.sessionReview.diffStyle.unified": "Vereinheitlicht",
   "ui.sessionReview.diffStyle.split": "Geteilt",
   "ui.sessionReview.expandAll": "Alle erweitern",

+ 1 - 0
packages/ui/src/i18n/en.ts

@@ -1,5 +1,6 @@
 export const dict = {
   "ui.sessionReview.title": "Session changes",
+  "ui.sessionReview.title.lastTurn": "Last turn changes",
   "ui.sessionReview.diffStyle.unified": "Unified",
   "ui.sessionReview.diffStyle.split": "Split",
   "ui.sessionReview.expandAll": "Expand all",

+ 1 - 0
packages/ui/src/i18n/es.ts

@@ -1,5 +1,6 @@
 export const dict = {
   "ui.sessionReview.title": "Cambios de la sesión",
+  "ui.sessionReview.title.lastTurn": "Cambios del último turno",
   "ui.sessionReview.diffStyle.unified": "Unificado",
   "ui.sessionReview.diffStyle.split": "Dividido",
   "ui.sessionReview.expandAll": "Expandir todo",

+ 1 - 0
packages/ui/src/i18n/fr.ts

@@ -1,5 +1,6 @@
 export const dict = {
   "ui.sessionReview.title": "Modifications de la session",
+  "ui.sessionReview.title.lastTurn": "Modifications du dernier tour",
   "ui.sessionReview.diffStyle.unified": "Unifié",
   "ui.sessionReview.diffStyle.split": "Divisé",
   "ui.sessionReview.expandAll": "Tout développer",

+ 1 - 0
packages/ui/src/i18n/ja.ts

@@ -1,5 +1,6 @@
 export const dict = {
   "ui.sessionReview.title": "セッションの変更",
+  "ui.sessionReview.title.lastTurn": "前回ターンの変更",
   "ui.sessionReview.diffStyle.unified": "Unified",
   "ui.sessionReview.diffStyle.split": "Split",
   "ui.sessionReview.expandAll": "すべて展開",

+ 1 - 0
packages/ui/src/i18n/ko.ts

@@ -1,5 +1,6 @@
 export const dict = {
   "ui.sessionReview.title": "세션 변경 사항",
+  "ui.sessionReview.title.lastTurn": "마지막 턴 변경 사항",
   "ui.sessionReview.diffStyle.unified": "통합 보기",
   "ui.sessionReview.diffStyle.split": "분할 보기",
   "ui.sessionReview.expandAll": "모두 펼치기",

+ 1 - 0
packages/ui/src/i18n/no.ts

@@ -3,6 +3,7 @@ type Keys = keyof typeof en
 
 export const dict: Record<Keys, string> = {
   "ui.sessionReview.title": "Sesjonsendringer",
+  "ui.sessionReview.title.lastTurn": "Endringer i siste tur",
   "ui.sessionReview.diffStyle.unified": "Samlet",
   "ui.sessionReview.diffStyle.split": "Delt",
   "ui.sessionReview.expandAll": "Utvid alle",

+ 1 - 0
packages/ui/src/i18n/pl.ts

@@ -1,5 +1,6 @@
 export const dict = {
   "ui.sessionReview.title": "Zmiany w sesji",
+  "ui.sessionReview.title.lastTurn": "Zmiany z ostatniej tury",
   "ui.sessionReview.diffStyle.unified": "Ujednolicony",
   "ui.sessionReview.diffStyle.split": "Podzielony",
   "ui.sessionReview.expandAll": "Rozwiń wszystko",

+ 1 - 0
packages/ui/src/i18n/ru.ts

@@ -1,5 +1,6 @@
 export const dict = {
   "ui.sessionReview.title": "Изменения сессии",
+  "ui.sessionReview.title.lastTurn": "Изменения последнего хода",
   "ui.sessionReview.diffStyle.unified": "Объединённый",
   "ui.sessionReview.diffStyle.split": "Разделённый",
   "ui.sessionReview.expandAll": "Развернуть всё",

+ 1 - 0
packages/ui/src/i18n/th.ts

@@ -1,5 +1,6 @@
 export const dict = {
   "ui.sessionReview.title": "การเปลี่ยนแปลงเซสชัน",
+  "ui.sessionReview.title.lastTurn": "การเปลี่ยนแปลงของเทิร์นล่าสุด",
   "ui.sessionReview.diffStyle.unified": "แบบรวม",
   "ui.sessionReview.diffStyle.split": "แบบแยก",
   "ui.sessionReview.expandAll": "ขยายทั้งหมด",

+ 1 - 0
packages/ui/src/i18n/zh.ts

@@ -4,6 +4,7 @@ type Keys = keyof typeof en
 
 export const dict = {
   "ui.sessionReview.title": "会话变更",
+  "ui.sessionReview.title.lastTurn": "上一轮变更",
   "ui.sessionReview.diffStyle.unified": "统一",
   "ui.sessionReview.diffStyle.split": "拆分",
   "ui.sessionReview.expandAll": "全部展开",

+ 1 - 0
packages/ui/src/i18n/zht.ts

@@ -4,6 +4,7 @@ type Keys = keyof typeof en
 
 export const dict = {
   "ui.sessionReview.title": "工作階段變更",
+  "ui.sessionReview.title.lastTurn": "上一輪變更",
   "ui.sessionReview.diffStyle.unified": "整合",
   "ui.sessionReview.diffStyle.split": "拆分",
   "ui.sessionReview.expandAll": "全部展開",