Просмотр исходного кода

feat(app): better diff/code comments (#14621)

Co-authored-by: adamelmore <[email protected]>
Co-authored-by: David Hill <[email protected]>
Adam 1 месяц назад
Родитель
Сommit
fc52e4b2d3
70 измененных файлов с 6450 добавлено и 3147 удалено
  1. 3 3
      packages/app/e2e/files/file-tree.spec.ts
  2. 57 3
      packages/app/e2e/files/file-viewer.spec.ts
  3. 3 7
      packages/app/src/app.tsx
  4. 89 11
      packages/app/src/components/prompt-input.tsx
  5. 9 0
      packages/app/src/components/prompt-input/build-request-parts.test.ts
  6. 9 13
      packages/app/src/components/prompt-input/build-request-parts.ts
  7. 51 1
      packages/app/src/components/prompt-input/history.test.ts
  8. 106 19
      packages/app/src/components/prompt-input/history.ts
  9. 3 2
      packages/app/src/components/session/session-context-tab.tsx
  10. 33 0
      packages/app/src/context/comments.test.ts
  11. 55 0
      packages/app/src/context/comments.tsx
  12. 1 1
      packages/app/src/context/file/view-cache.ts
  13. 20 0
      packages/app/src/context/layout-scroll.test.ts
  14. 10 2
      packages/app/src/context/layout-scroll.ts
  15. 28 0
      packages/app/src/context/prompt.tsx
  16. 58 2
      packages/app/src/pages/session.tsx
  17. 185 276
      packages/app/src/pages/session/file-tabs.tsx
  18. 92 28
      packages/app/src/pages/session/message-timeline.tsx
  19. 74 25
      packages/app/src/pages/session/review-tab.tsx
  20. 88 0
      packages/app/src/utils/comment-note.ts
  21. 211 222
      packages/enterprise/src/routes/share/[shareID].tsx
  22. 0 4
      packages/ui/src/components/code.css
  23. 0 1097
      packages/ui/src/components/code.tsx
  24. 0 317
      packages/ui/src/components/diff-ssr.tsx
  25. 0 652
      packages/ui/src/components/diff.tsx
  26. 265 0
      packages/ui/src/components/file-media.tsx
  27. 69 0
      packages/ui/src/components/file-search.tsx
  28. 178 0
      packages/ui/src/components/file-ssr.tsx
  29. 8 1
      packages/ui/src/components/file.css
  30. 1176 0
      packages/ui/src/components/file.tsx
  31. 586 0
      packages/ui/src/components/line-comment-annotations.tsx
  32. 111 3
      packages/ui/src/components/line-comment-styles.ts
  33. 160 32
      packages/ui/src/components/line-comment.tsx
  34. 12 9
      packages/ui/src/components/message-part.tsx
  35. 39 0
      packages/ui/src/components/session-review-search.test.ts
  36. 59 0
      packages/ui/src/components/session-review-search.ts
  37. 0 44
      packages/ui/src/components/session-review.css
  38. 499 348
      packages/ui/src/components/session-review.tsx
  39. 4 3
      packages/ui/src/components/session-turn.tsx
  40. 0 10
      packages/ui/src/context/diff.tsx
  41. 3 3
      packages/ui/src/context/file.tsx
  42. 1 1
      packages/ui/src/context/index.ts
  43. 9 0
      packages/ui/src/i18n/ar.ts
  44. 9 0
      packages/ui/src/i18n/br.ts
  45. 9 0
      packages/ui/src/i18n/bs.ts
  46. 9 0
      packages/ui/src/i18n/da.ts
  47. 11 0
      packages/ui/src/i18n/de.ts
  48. 10 0
      packages/ui/src/i18n/en.ts
  49. 9 0
      packages/ui/src/i18n/es.ts
  50. 9 0
      packages/ui/src/i18n/fr.ts
  51. 9 0
      packages/ui/src/i18n/ja.ts
  52. 9 0
      packages/ui/src/i18n/ko.ts
  53. 9 0
      packages/ui/src/i18n/no.ts
  54. 9 0
      packages/ui/src/i18n/pl.ts
  55. 9 0
      packages/ui/src/i18n/ru.ts
  56. 9 0
      packages/ui/src/i18n/th.ts
  57. 9 0
      packages/ui/src/i18n/zh.ts
  58. 9 0
      packages/ui/src/i18n/zht.ts
  59. 74 0
      packages/ui/src/pierre/comment-hover.ts
  60. 91 0
      packages/ui/src/pierre/commented-lines.ts
  61. 71 0
      packages/ui/src/pierre/diff-selection.ts
  62. 576 0
      packages/ui/src/pierre/file-find.ts
  63. 114 0
      packages/ui/src/pierre/file-runtime.ts
  64. 85 0
      packages/ui/src/pierre/file-selection.ts
  65. 37 5
      packages/ui/src/pierre/index.ts
  66. 110 0
      packages/ui/src/pierre/media.ts
  67. 129 0
      packages/ui/src/pierre/selection-bridge.ts
  68. 1 3
      packages/ui/src/styles/index.css
  69. 426 0
      specs/file-component-unification-plan.md
  70. 234 0
      specs/session-review-cross-diff-search-plan.md

+ 3 - 3
packages/app/e2e/files/file-tree.spec.ts

@@ -43,7 +43,7 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
   await tab.click()
   await expect(tab).toHaveAttribute("aria-selected", "true")
 
-  const code = page.locator('[data-component="code"]').first()
-  await expect(code).toBeVisible()
-  await expect(code).toContainText("export default function FileTree")
+  const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
+  await expect(viewer).toBeVisible()
+  await expect(viewer).toContainText("export default function FileTree")
 })

+ 57 - 3
packages/app/e2e/files/file-viewer.spec.ts

@@ -1,5 +1,6 @@
 import { test, expect } from "../fixtures"
 import { promptSelector } from "../selectors"
+import { modKey } from "../utils"
 
 test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
   await gotoSession()
@@ -43,7 +44,60 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
   await expect(tab).toBeVisible()
   await tab.click()
 
-  const code = page.locator('[data-component="code"]').first()
-  await expect(code).toBeVisible()
-  await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
+  const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
+  await expect(viewer).toBeVisible()
+  await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
+})
+
+test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  await page.locator(promptSelector).click()
+  await page.keyboard.type("/open")
+
+  const command = page.locator('[data-slash-id="file.open"]').first()
+  await expect(command).toBeVisible()
+  await page.keyboard.press("Enter")
+
+  const dialog = page
+    .getByRole("dialog")
+    .filter({ has: page.getByPlaceholder(/search files/i) })
+    .first()
+  await expect(dialog).toBeVisible()
+
+  const input = dialog.getByRole("textbox").first()
+  await input.fill("package.json")
+
+  const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
+  let index = -1
+  await expect
+    .poll(
+      async () => {
+        const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
+        index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
+        return index >= 0
+      },
+      { timeout: 30_000 },
+    )
+    .toBe(true)
+
+  const item = items.nth(index)
+  await expect(item).toBeVisible()
+  await item.click()
+
+  await expect(dialog).toHaveCount(0)
+
+  const tab = page.getByRole("tab", { name: "package.json" })
+  await expect(tab).toBeVisible()
+  await tab.click()
+
+  const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
+  await expect(viewer).toBeVisible()
+
+  await page.locator(promptSelector).click()
+  await page.keyboard.press(`${modKey}+f`)
+
+  const findInput = page.getByPlaceholder("Find")
+  await expect(findInput).toBeVisible()
+  await expect(findInput).toBeFocused()
 })

+ 3 - 7
packages/app/src/app.tsx

@@ -1,11 +1,9 @@
 import "@/index.css"
-import { Code } from "@opencode-ai/ui/code"
+import { File } from "@opencode-ai/ui/file"
 import { I18nProvider } from "@opencode-ai/ui/context"
-import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
 import { DialogProvider } from "@opencode-ai/ui/context/dialog"
-import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
+import { FileComponentProvider } from "@opencode-ai/ui/context/file"
 import { MarkedProvider } from "@opencode-ai/ui/context/marked"
-import { Diff } from "@opencode-ai/ui/diff"
 import { Font } from "@opencode-ai/ui/font"
 import { ThemeProvider } from "@opencode-ai/ui/theme"
 import { MetaProvider } from "@solidjs/meta"
@@ -122,9 +120,7 @@ export function AppBaseProviders(props: ParentProps) {
             <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
               <DialogProvider>
                 <MarkedProviderWithNativeParser>
-                  <DiffComponentProvider component={Diff}>
-                    <CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
-                  </DiffComponentProvider>
+                  <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
                 </MarkedProviderWithNativeParser>
               </DialogProvider>
             </ErrorBoundary>

+ 89 - 11
packages/app/src/components/prompt-input.tsx

@@ -3,7 +3,7 @@ import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo
 import { createStore } from "solid-js/store"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
-import { useFile } from "@/context/file"
+import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
 import {
   ContentPart,
   DEFAULT_PROMPT,
@@ -43,6 +43,9 @@ import {
   canNavigateHistoryAtCursor,
   navigatePromptHistory,
   prependHistoryEntry,
+  type PromptHistoryComment,
+  type PromptHistoryEntry,
+  type PromptHistoryStoredEntry,
   promptLength,
 } from "./prompt-input/history"
 import { createPromptSubmit } from "./prompt-input/submit"
@@ -170,12 +173,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     const focus = { file: item.path, id: item.commentID }
     comments.setActive(focus)
 
+    const queueCommentFocus = (attempts = 6) => {
+      const schedule = (left: number) => {
+        requestAnimationFrame(() => {
+          comments.setFocus({ ...focus })
+          if (left <= 0) return
+          requestAnimationFrame(() => {
+            const current = comments.focus()
+            if (!current) return
+            if (current.file !== focus.file || current.id !== focus.id) return
+            schedule(left - 1)
+          })
+        })
+      }
+
+      schedule(attempts)
+    }
+
     const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
     if (wantsReview) {
       if (!view().reviewPanel.opened()) view().reviewPanel.open()
       layout.fileTree.setTab("changes")
       tabs().setActive("review")
-      requestAnimationFrame(() => comments.setFocus(focus))
+      queueCommentFocus()
       return
     }
 
@@ -183,8 +203,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     layout.fileTree.setTab("all")
     const tab = files.tab(item.path)
     tabs().open(tab)
-    files.load(item.path)
-    requestAnimationFrame(() => comments.setFocus(focus))
+    tabs().setActive(tab)
+    Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
   }
 
   const recent = createMemo(() => {
@@ -219,7 +239,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const [store, setStore] = createStore<{
     popover: "at" | "slash" | null
     historyIndex: number
-    savedPrompt: Prompt | null
+    savedPrompt: PromptHistoryEntry | null
     placeholder: number
     draggingType: "image" | "@mention" | null
     mode: "normal" | "shell"
@@ -227,7 +247,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }>({
     popover: null,
     historyIndex: -1,
-    savedPrompt: null,
+    savedPrompt: null as PromptHistoryEntry | null,
     placeholder: Math.floor(Math.random() * EXAMPLES.length),
     draggingType: null,
     mode: "normal",
@@ -256,7 +276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const [history, setHistory] = persisted(
     Persist.global("prompt-history", ["prompt-history.v1"]),
     createStore<{
-      entries: Prompt[]
+      entries: PromptHistoryStoredEntry[]
     }>({
       entries: [],
     }),
@@ -264,7 +284,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const [shellHistory, setShellHistory] = persisted(
     Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
     createStore<{
-      entries: Prompt[]
+      entries: PromptHistoryStoredEntry[]
     }>({
       entries: [],
     }),
@@ -282,9 +302,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }),
   )
 
-  const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
+  const historyComments = () => {
+    const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
+    return prompt.context.items().flatMap((item) => {
+      if (item.type !== "file") return []
+      const comment = item.comment?.trim()
+      if (!comment) return []
+
+      const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
+      const nextSelection =
+        selection ??
+        (item.selection
+          ? ({
+              start: item.selection.startLine,
+              end: item.selection.endLine,
+            } satisfies SelectedLineRange)
+          : undefined)
+      if (!nextSelection) return []
+
+      return [
+        {
+          id: item.commentID ?? item.key,
+          path: item.path,
+          selection: { ...nextSelection },
+          comment,
+          time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
+          origin: item.commentOrigin,
+          preview: item.preview,
+        } satisfies PromptHistoryComment,
+      ]
+    })
+  }
+
+  const applyHistoryComments = (items: PromptHistoryComment[]) => {
+    comments.replace(
+      items.map((item) => ({
+        id: item.id,
+        file: item.path,
+        selection: { ...item.selection },
+        comment: item.comment,
+        time: item.time,
+      })),
+    )
+    prompt.context.replaceComments(
+      items.map((item) => ({
+        type: "file" as const,
+        path: item.path,
+        selection: selectionFromLines(item.selection),
+        comment: item.comment,
+        commentID: item.id,
+        commentOrigin: item.origin,
+        preview: item.preview,
+      })),
+    )
+  }
+
+  const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => {
+    const p = entry.prompt
     const length = position === "start" ? 0 : promptLength(p)
     setStore("applyingHistory", true)
+    applyHistoryComments(entry.comments)
     prompt.set(p, length)
     requestAnimationFrame(() => {
       editorRef.focus()
@@ -846,7 +923,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
     const currentHistory = mode === "shell" ? shellHistory : history
     const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
-    const next = prependHistoryEntry(currentHistory.entries, prompt)
+    const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
     if (next === currentHistory.entries) return
     setCurrentHistory("entries", next)
   }
@@ -857,12 +934,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       entries: store.mode === "shell" ? shellHistory.entries : history.entries,
       historyIndex: store.historyIndex,
       currentPrompt: prompt.current(),
+      currentComments: historyComments(),
       savedPrompt: store.savedPrompt,
     })
     if (!result.handled) return false
     setStore("historyIndex", result.historyIndex)
     setStore("savedPrompt", result.savedPrompt)
-    applyHistoryPrompt(result.prompt, result.cursor)
+    applyHistoryPrompt(result.entry, result.cursor)
     return true
   }
 

+ 9 - 0
packages/app/src/components/prompt-input/build-request-parts.test.ts

@@ -35,6 +35,15 @@ describe("buildRequestParts", () => {
       result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
     ).toBe(true)
     expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
+    expect(
+      result.requestParts.some(
+        (part) =>
+          part.type === "text" &&
+          part.synthetic &&
+          part.metadata?.opencodeComment &&
+          (part.metadata.opencodeComment as { comment?: string }).comment === "check this",
+      ),
+    ).toBe(true)
 
     expect(result.optimisticParts).toHaveLength(result.requestParts.length)
     expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)

+ 9 - 13
packages/app/src/components/prompt-input/build-request-parts.ts

@@ -4,6 +4,7 @@ import type { FileSelection } from "@/context/file"
 import { encodeFilePath } from "@/context/file/path"
 import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
 import { Identifier } from "@/utils/id"
+import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
 
 type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
 
@@ -41,18 +42,6 @@ const fileQuery = (selection: FileSelection | undefined) =>
 const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
 const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
 
-const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
-  const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
-  const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
-  const range =
-    start === undefined || end === undefined
-      ? "this file"
-      : start === end
-        ? `line ${start}`
-        : `lines ${start} through ${end}`
-  return `The user made the following comment regarding ${range} of ${path}: ${comment}`
-}
-
 const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
   if (part.type === "text") {
     return {
@@ -153,8 +142,15 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
       {
         id: Identifier.ascending("part"),
         type: "text",
-        text: commentNote(item.path, item.selection, comment),
+        text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
         synthetic: true,
+        metadata: createCommentMetadata({
+          path: item.path,
+          selection: item.selection,
+          comment,
+          preview: item.preview,
+          origin: item.commentOrigin,
+        }),
       } satisfies PromptRequestPart,
       filePart,
     ]

+ 51 - 1
packages/app/src/components/prompt-input/history.test.ts

@@ -3,25 +3,42 @@ import type { Prompt } from "@/context/prompt"
 import {
   canNavigateHistoryAtCursor,
   clonePromptParts,
+  normalizePromptHistoryEntry,
   navigatePromptHistory,
   prependHistoryEntry,
   promptLength,
+  type PromptHistoryComment,
 } from "./history"
 
 const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
 
 const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
+const comment = (id: string, value = "note"): PromptHistoryComment => ({
+  id,
+  path: "src/a.ts",
+  selection: { start: 2, end: 4 },
+  comment: value,
+  time: 1,
+  origin: "review",
+  preview: "const a = 1",
+})
 
 describe("prompt-input history", () => {
   test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
     const first = prependHistoryEntry([], DEFAULT_PROMPT)
     expect(first).toEqual([])
 
+    const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")])
+    expect(commentsOnly).toHaveLength(1)
+
     const withOne = prependHistoryEntry([], text("hello"))
     expect(withOne).toHaveLength(1)
 
     const deduped = prependHistoryEntry(withOne, text("hello"))
     expect(deduped).toBe(withOne)
+
+    const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")])
+    expect(dedupedComments).toBe(commentsOnly)
   })
 
   test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
@@ -31,24 +48,57 @@ describe("prompt-input history", () => {
       entries,
       historyIndex: -1,
       currentPrompt: text("draft"),
+      currentComments: [comment("draft")],
       savedPrompt: null,
     })
     expect(up.handled).toBe(true)
     if (!up.handled) throw new Error("expected handled")
     expect(up.historyIndex).toBe(0)
     expect(up.cursor).toBe("start")
+    expect(up.entry.comments).toEqual([])
 
     const down = navigatePromptHistory({
       direction: "down",
       entries,
       historyIndex: up.historyIndex,
       currentPrompt: text("ignored"),
+      currentComments: [],
       savedPrompt: up.savedPrompt,
     })
     expect(down.handled).toBe(true)
     if (!down.handled) throw new Error("expected handled")
     expect(down.historyIndex).toBe(-1)
-    expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
+    expect(down.entry.prompt[0]?.type === "text" ? down.entry.prompt[0].content : "").toBe("draft")
+    expect(down.entry.comments).toEqual([comment("draft")])
+  })
+
+  test("navigatePromptHistory keeps entry comments when moving through history", () => {
+    const entries = [
+      {
+        prompt: text("with comment"),
+        comments: [comment("c1")],
+      },
+    ]
+
+    const up = navigatePromptHistory({
+      direction: "up",
+      entries,
+      historyIndex: -1,
+      currentPrompt: text("draft"),
+      currentComments: [],
+      savedPrompt: null,
+    })
+
+    expect(up.handled).toBe(true)
+    if (!up.handled) throw new Error("expected handled")
+    expect(up.entry.prompt[0]?.type === "text" ? up.entry.prompt[0].content : "").toBe("with comment")
+    expect(up.entry.comments).toEqual([comment("c1")])
+  })
+
+  test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
+    const entry = normalizePromptHistoryEntry(text("legacy"))
+    expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
+    expect(entry.comments).toEqual([])
   })
 
   test("helpers clone prompt and count text content length", () => {

+ 106 - 19
packages/app/src/components/prompt-input/history.ts

@@ -1,9 +1,27 @@
 import type { Prompt } from "@/context/prompt"
+import type { SelectedLineRange } from "@/context/file"
 
 const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
 
 export const MAX_HISTORY = 100
 
+export type PromptHistoryComment = {
+  id: string
+  path: string
+  selection: SelectedLineRange
+  comment: string
+  time: number
+  origin?: "review" | "file"
+  preview?: string
+}
+
+export type PromptHistoryEntry = {
+  prompt: Prompt
+  comments: PromptHistoryComment[]
+}
+
+export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
+
 export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
   const position = Math.max(0, Math.min(cursor, text.length))
   const atStart = position === 0
@@ -25,29 +43,82 @@ export function clonePromptParts(prompt: Prompt): Prompt {
   })
 }
 
+function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
+  return {
+    start: selection.start,
+    end: selection.end,
+    ...(selection.side ? { side: selection.side } : {}),
+    ...(selection.endSide ? { endSide: selection.endSide } : {}),
+  }
+}
+
+export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
+  return comments.map((comment) => ({
+    ...comment,
+    selection: cloneSelection(comment.selection),
+  }))
+}
+
+export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
+  if (Array.isArray(entry)) {
+    return {
+      prompt: clonePromptParts(entry),
+      comments: [],
+    }
+  }
+  return {
+    prompt: clonePromptParts(entry.prompt),
+    comments: clonePromptHistoryComments(entry.comments),
+  }
+}
+
 export function promptLength(prompt: Prompt) {
   return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
 }
 
-export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
+export function prependHistoryEntry(
+  entries: PromptHistoryStoredEntry[],
+  prompt: Prompt,
+  comments: PromptHistoryComment[] = [],
+  max = MAX_HISTORY,
+) {
   const text = prompt
     .map((part) => ("content" in part ? part.content : ""))
     .join("")
     .trim()
   const hasImages = prompt.some((part) => part.type === "image")
-  if (!text && !hasImages) return entries
+  const hasComments = comments.some((comment) => !!comment.comment.trim())
+  if (!text && !hasImages && !hasComments) return entries
 
-  const entry = clonePromptParts(prompt)
+  const entry = {
+    prompt: clonePromptParts(prompt),
+    comments: clonePromptHistoryComments(comments),
+  } satisfies PromptHistoryEntry
   const last = entries[0]
   if (last && isPromptEqual(last, entry)) return entries
   return [entry, ...entries].slice(0, max)
 }
 
-function isPromptEqual(promptA: Prompt, promptB: Prompt) {
-  if (promptA.length !== promptB.length) return false
-  for (let i = 0; i < promptA.length; i++) {
-    const partA = promptA[i]
-    const partB = promptB[i]
+function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
+  return (
+    commentA.path === commentB.path &&
+    commentA.comment === commentB.comment &&
+    commentA.origin === commentB.origin &&
+    commentA.preview === commentB.preview &&
+    commentA.selection.start === commentB.selection.start &&
+    commentA.selection.end === commentB.selection.end &&
+    commentA.selection.side === commentB.selection.side &&
+    commentA.selection.endSide === commentB.selection.endSide
+  )
+}
+
+function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
+  const entryA = normalizePromptHistoryEntry(promptA)
+  const entryB = normalizePromptHistoryEntry(promptB)
+  if (entryA.prompt.length !== entryB.prompt.length) return false
+  for (let i = 0; i < entryA.prompt.length; i++) {
+    const partA = entryA.prompt[i]
+    const partB = entryB.prompt[i]
     if (partA.type !== partB.type) return false
     if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
     if (partA.type === "file") {
@@ -67,28 +138,35 @@ function isPromptEqual(promptA: Prompt, promptB: Prompt) {
     if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
     if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
   }
+  if (entryA.comments.length !== entryB.comments.length) return false
+  for (let i = 0; i < entryA.comments.length; i++) {
+    const commentA = entryA.comments[i]
+    const commentB = entryB.comments[i]
+    if (!commentA || !commentB || !isCommentEqual(commentA, commentB)) return false
+  }
   return true
 }
 
 type HistoryNavInput = {
   direction: "up" | "down"
-  entries: Prompt[]
+  entries: PromptHistoryStoredEntry[]
   historyIndex: number
   currentPrompt: Prompt
-  savedPrompt: Prompt | null
+  currentComments: PromptHistoryComment[]
+  savedPrompt: PromptHistoryEntry | null
 }
 
 type HistoryNavResult =
   | {
       handled: false
       historyIndex: number
-      savedPrompt: Prompt | null
+      savedPrompt: PromptHistoryEntry | null
     }
   | {
       handled: true
       historyIndex: number
-      savedPrompt: Prompt | null
-      prompt: Prompt
+      savedPrompt: PromptHistoryEntry | null
+      entry: PromptHistoryEntry
       cursor: "start" | "end"
     }
 
@@ -103,22 +181,27 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
     }
 
     if (input.historyIndex === -1) {
+      const entry = normalizePromptHistoryEntry(input.entries[0])
       return {
         handled: true,
         historyIndex: 0,
-        savedPrompt: clonePromptParts(input.currentPrompt),
-        prompt: input.entries[0],
+        savedPrompt: {
+          prompt: clonePromptParts(input.currentPrompt),
+          comments: clonePromptHistoryComments(input.currentComments),
+        },
+        entry,
         cursor: "start",
       }
     }
 
     if (input.historyIndex < input.entries.length - 1) {
       const next = input.historyIndex + 1
+      const entry = normalizePromptHistoryEntry(input.entries[next])
       return {
         handled: true,
         historyIndex: next,
         savedPrompt: input.savedPrompt,
-        prompt: input.entries[next],
+        entry,
         cursor: "start",
       }
     }
@@ -132,11 +215,12 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
 
   if (input.historyIndex > 0) {
     const next = input.historyIndex - 1
+    const entry = normalizePromptHistoryEntry(input.entries[next])
     return {
       handled: true,
       historyIndex: next,
       savedPrompt: input.savedPrompt,
-      prompt: input.entries[next],
+      entry,
       cursor: "end",
     }
   }
@@ -147,7 +231,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
         handled: true,
         historyIndex: -1,
         savedPrompt: null,
-        prompt: input.savedPrompt,
+        entry: input.savedPrompt,
         cursor: "end",
       }
     }
@@ -156,7 +240,10 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
       handled: true,
       historyIndex: -1,
       savedPrompt: null,
-      prompt: DEFAULT_PROMPT,
+      entry: {
+        prompt: DEFAULT_PROMPT,
+        comments: [],
+      },
       cursor: "end",
     }
   }

+ 3 - 2
packages/app/src/components/session/session-context-tab.tsx

@@ -9,7 +9,7 @@ import { same } from "@/utils/same"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Accordion } from "@opencode-ai/ui/accordion"
 import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
-import { Code } from "@opencode-ai/ui/code"
+import { File } from "@opencode-ai/ui/file"
 import { Markdown } from "@opencode-ai/ui/markdown"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
@@ -47,7 +47,8 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
   })
 
   return (
-    <Code
+    <File
+      mode="text"
       file={file()}
       overflow="wrap"
       class="select-text"

+ 33 - 0
packages/app/src/context/comments.test.ts

@@ -150,4 +150,37 @@ describe("comments session indexing", () => {
       dispose()
     })
   })
+
+  test("update changes only the targeted comment body", () => {
+    createRoot((dispose) => {
+      const comments = createCommentSessionForTest({
+        "a.ts": [line("a.ts", "a1", 10), line("a.ts", "a2", 20)],
+      })
+
+      comments.update("a.ts", "a2", "edited")
+
+      expect(comments.list("a.ts").map((item) => item.comment)).toEqual(["a1", "edited"])
+
+      dispose()
+    })
+  })
+
+  test("replace swaps comment state and clears focus state", () => {
+    createRoot((dispose) => {
+      const comments = createCommentSessionForTest({
+        "a.ts": [line("a.ts", "a1", 10)],
+      })
+
+      comments.setFocus({ file: "a.ts", id: "a1" })
+      comments.setActive({ file: "a.ts", id: "a1" })
+      comments.replace([line("b.ts", "b1", 30)])
+
+      expect(comments.list("a.ts")).toEqual([])
+      expect(comments.list("b.ts").map((item) => item.id)).toEqual(["b1"])
+      expect(comments.focus()).toBeNull()
+      expect(comments.active()).toBeNull()
+
+      dispose()
+    })
+  })
 })

+ 55 - 0
packages/app/src/context/comments.tsx

@@ -44,6 +44,37 @@ function aggregate(comments: Record<string, LineComment[]>) {
     .sort((a, b) => a.time - b.time)
 }
 
+function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
+  const next: SelectedLineRange = {
+    start: selection.start,
+    end: selection.end,
+  }
+
+  if (selection.side) next.side = selection.side
+  if (selection.endSide) next.endSide = selection.endSide
+  return next
+}
+
+function cloneComment(comment: LineComment): LineComment {
+  return {
+    ...comment,
+    selection: cloneSelection(comment.selection),
+  }
+}
+
+function group(comments: LineComment[]) {
+  return comments.reduce<Record<string, LineComment[]>>((acc, comment) => {
+    const list = acc[comment.file]
+    const next = cloneComment(comment)
+    if (list) {
+      list.push(next)
+      return acc
+    }
+    acc[comment.file] = [next]
+    return acc
+  }, {})
+}
+
 function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
   const [state, setState] = createStore({
     focus: null as CommentFocus | null,
@@ -70,6 +101,7 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
       id: uuid(),
       time: Date.now(),
       ...input,
+      selection: cloneSelection(input.selection),
     }
 
     batch(() => {
@@ -87,6 +119,23 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
     })
   }
 
+  const update = (file: string, id: string, comment: string) => {
+    setStore("comments", file, (items) =>
+      (items ?? []).map((item) => {
+        if (item.id !== id) return item
+        return { ...item, comment }
+      }),
+    )
+  }
+
+  const replace = (comments: LineComment[]) => {
+    batch(() => {
+      setStore("comments", reconcile(group(comments)))
+      setFocus(null)
+      setActive(null)
+    })
+  }
+
   const clear = () => {
     batch(() => {
       setStore("comments", reconcile({}))
@@ -100,6 +149,8 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
     all,
     add,
     remove,
+    update,
+    replace,
     clear,
     focus: () => state.focus,
     setFocus,
@@ -132,6 +183,8 @@ function createCommentSession(dir: string, id: string | undefined) {
     all: session.all,
     add: session.add,
     remove: session.remove,
+    update: session.update,
+    replace: session.replace,
     clear: session.clear,
     focus: session.focus,
     setFocus: session.setFocus,
@@ -176,6 +229,8 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
       all: () => session().all(),
       add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
       remove: (file: string, id: string) => session().remove(file, id),
+      update: (file: string, id: string, comment: string) => session().update(file, id, comment),
+      replace: (comments: LineComment[]) => session().replace(comments),
       clear: () => session().clear(),
       focus: () => session().focus(),
       setFocus: (focus: CommentFocus | null) => session().setFocus(focus),

+ 1 - 1
packages/app/src/context/file/view-cache.ts

@@ -9,7 +9,7 @@ const MAX_FILE_VIEW_SESSIONS = 20
 const MAX_VIEW_FILES = 500
 
 function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
-  if (range.start <= range.end) return range
+  if (range.start <= range.end) return { ...range }
 
   const startSide = range.side
   const endSide = range.endSide ?? startSide

+ 20 - 0
packages/app/src/context/layout-scroll.test.ts

@@ -41,4 +41,24 @@ describe("createScrollPersistence", () => {
       vi.useRealTimers()
     }
   })
+
+  test("reseeds empty cache after persisted snapshot loads", () => {
+    const snapshot = {
+      session: {},
+    } as Record<string, Record<string, { x: number; y: number }>>
+
+    const scroll = createScrollPersistence({
+      getSnapshot: (sessionKey) => snapshot[sessionKey],
+      onFlush: () => {},
+    })
+
+    expect(scroll.scroll("session", "review")).toBeUndefined()
+
+    snapshot.session = {
+      review: { x: 12, y: 34 },
+    }
+
+    expect(scroll.scroll("session", "review")).toEqual({ x: 12, y: 34 })
+    scroll.dispose()
+  })
 })

+ 10 - 2
packages/app/src/context/layout-scroll.ts

@@ -33,8 +33,16 @@ export function createScrollPersistence(opts: Options) {
   }
 
   function seed(sessionKey: string) {
-    if (cache[sessionKey]) return
-    setCache(sessionKey, clone(opts.getSnapshot(sessionKey)))
+    const next = clone(opts.getSnapshot(sessionKey))
+    const current = cache[sessionKey]
+    if (!current) {
+      setCache(sessionKey, next)
+      return
+    }
+
+    if (Object.keys(current).length > 0) return
+    if (Object.keys(next).length === 0) return
+    setCache(sessionKey, next)
   }
 
   function scroll(sessionKey: string, tab: string) {

+ 28 - 0
packages/app/src/context/prompt.tsx

@@ -116,6 +116,10 @@ function contextItemKey(item: ContextItem) {
   return `${key}:c=${digest.slice(0, 8)}`
 }
 
+function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
+  return item.type === "file" && !!item.comment?.trim()
+}
+
 function createPromptActions(
   setStore: SetStoreFunction<{
     prompt: Prompt
@@ -189,6 +193,26 @@ function createPromptSession(dir: string, id: string | undefined) {
       remove(key: string) {
         setStore("context", "items", (items) => items.filter((x) => x.key !== key))
       },
+      removeComment(path: string, commentID: string) {
+        setStore("context", "items", (items) =>
+          items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
+        )
+      },
+      updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
+        setStore("context", "items", (items) =>
+          items.map((item) => {
+            if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
+            const value = { ...item, ...next }
+            return { ...value, key: contextItemKey(value) }
+          }),
+        )
+      },
+      replaceComments(items: FileContextItem[]) {
+        setStore("context", "items", (current) => [
+          ...current.filter((item) => !isCommentItem(item)),
+          ...items.map((item) => ({ ...item, key: contextItemKey(item) })),
+        ])
+      },
     },
     set: actions.set,
     reset: actions.reset,
@@ -251,6 +275,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
         items: () => session().context.items(),
         add: (item: ContextItem) => session().context.add(item),
         remove: (key: string) => session().context.remove(key),
+        removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
+        updateComment: (path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) =>
+          session().context.updateComment(path, commentID, next),
+        replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
       },
       set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
       reset: () => session().reset(),

+ 58 - 2
packages/app/src/pages/session.tsx

@@ -379,11 +379,58 @@ export default function Page() {
     })
   }
 
+  const updateCommentInContext = (input: {
+    id: string
+    file: string
+    selection: SelectedLineRange
+    comment: string
+    preview?: string
+  }) => {
+    comments.update(input.file, input.id, input.comment)
+    prompt.context.updateComment(input.file, input.id, {
+      comment: input.comment,
+      ...(input.preview ? { preview: input.preview } : {}),
+    })
+  }
+
+  const removeCommentFromContext = (input: { id: string; file: string }) => {
+    comments.remove(input.file, input.id)
+    prompt.context.removeComment(input.file, input.id)
+  }
+
+  const reviewCommentActions = createMemo(() => ({
+    moreLabel: language.t("common.moreOptions"),
+    editLabel: language.t("common.edit"),
+    deleteLabel: language.t("common.delete"),
+    saveLabel: language.t("common.save"),
+  }))
+
+  const isEditableTarget = (target: EventTarget | null | undefined) => {
+    if (!(target instanceof HTMLElement)) return false
+    return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
+  }
+
+  const deepActiveElement = () => {
+    let current: Element | null = document.activeElement
+    while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
+      current = current.shadowRoot.activeElement
+    }
+    return current instanceof HTMLElement ? current : undefined
+  }
+
   const handleKeyDown = (event: KeyboardEvent) => {
-    const activeElement = document.activeElement as HTMLElement | undefined
+    const path = event.composedPath()
+    const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
+    const activeElement = deepActiveElement()
+
+    const protectedTarget = path.some(
+      (item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null,
+    )
+    if (protectedTarget || isEditableTarget(target)) return
+
     if (activeElement) {
       const isProtected = activeElement.closest("[data-prevent-autofocus]")
-      const isInput = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(activeElement.tagName) || activeElement.isContentEditable
+      const isInput = isEditableTarget(activeElement)
       if (isProtected || isInput) return
     }
     if (dialog.active) return
@@ -500,6 +547,9 @@ export default function Page() {
           onScrollRef={(el) => setTree("reviewScroll", el)}
           focusedFile={tree.activeDiff}
           onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+          onLineCommentUpdate={updateCommentInContext}
+          onLineCommentDelete={removeCommentFromContext}
+          lineCommentActions={reviewCommentActions()}
           comments={comments.all()}
           focusedComment={comments.focus()}
           onFocusedCommentChange={comments.setFocus}
@@ -521,6 +571,9 @@ export default function Page() {
             onScrollRef={(el) => setTree("reviewScroll", el)}
             focusedFile={tree.activeDiff}
             onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+            onLineCommentUpdate={updateCommentInContext}
+            onLineCommentDelete={removeCommentFromContext}
+            lineCommentActions={reviewCommentActions()}
             comments={comments.all()}
             focusedComment={comments.focus()}
             onFocusedCommentChange={comments.setFocus}
@@ -549,6 +602,9 @@ export default function Page() {
           onScrollRef={(el) => setTree("reviewScroll", el)}
           focusedFile={tree.activeDiff}
           onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+          onLineCommentUpdate={updateCommentInContext}
+          onLineCommentDelete={removeCommentFromContext}
+          lineCommentActions={reviewCommentActions()}
           comments={comments.all()}
           focusedComment={comments.focus()}
           onFocusedCommentChange={comments.setFocus}

+ 185 - 276
packages/app/src/pages/session/file-tabs.tsx

@@ -1,15 +1,17 @@
-import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
-import { createStore, produce } from "solid-js/store"
+import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
+import { createStore } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
 import { useParams } from "@solidjs/router"
-import { useCodeComponent } from "@opencode-ai/ui/context/code"
+import type { FileSearchHandle } from "@opencode-ai/ui/file"
+import { useFileComponent } from "@opencode-ai/ui/context/file"
+import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
+import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations"
 import { sampledChecksum } from "@opencode-ai/util/encode"
-import { decode64 } from "@/utils/base64"
-import { showToast } from "@opencode-ai/ui/toast"
-import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
-import { Mark } from "@opencode-ai/ui/logo"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { showToast } from "@opencode-ai/ui/toast"
 import { useLayout } from "@/context/layout"
 import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
 import { useComments } from "@/context/comments"
@@ -17,11 +19,37 @@ import { useLanguage } from "@/context/language"
 import { usePrompt } from "@/context/prompt"
 import { getSessionHandoff } from "@/pages/session/handoff"
 
-const formatCommentLabel = (range: SelectedLineRange) => {
-  const start = Math.min(range.start, range.end)
-  const end = Math.max(range.start, range.end)
-  if (start === end) return `line ${start}`
-  return `lines ${start}-${end}`
+function FileCommentMenu(props: {
+  moreLabel: string
+  editLabel: string
+  deleteLabel: string
+  onEdit: VoidFunction
+  onDelete: VoidFunction
+}) {
+  return (
+    <div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
+      <DropdownMenu gutter={4} placement="bottom-end">
+        <DropdownMenu.Trigger
+          as={IconButton}
+          icon="dot-grid"
+          variant="ghost"
+          size="small"
+          class="size-6 rounded-md"
+          aria-label={props.moreLabel}
+        />
+        <DropdownMenu.Portal>
+          <DropdownMenu.Content>
+            <DropdownMenu.Item onSelect={props.onEdit}>
+              <DropdownMenu.ItemLabel>{props.editLabel}</DropdownMenu.ItemLabel>
+            </DropdownMenu.Item>
+            <DropdownMenu.Item onSelect={props.onDelete}>
+              <DropdownMenu.ItemLabel>{props.deleteLabel}</DropdownMenu.ItemLabel>
+            </DropdownMenu.Item>
+          </DropdownMenu.Content>
+        </DropdownMenu.Portal>
+      </DropdownMenu>
+    </div>
+  )
 }
 
 export function FileTabContent(props: { tab: string }) {
@@ -31,7 +59,7 @@ export function FileTabContent(props: { tab: string }) {
   const comments = useComments()
   const language = useLanguage()
   const prompt = usePrompt()
-  const codeComponent = useCodeComponent()
+  const fileComponent = useFileComponent()
 
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey))
@@ -41,6 +69,13 @@ export function FileTabContent(props: { tab: string }) {
   let scrollFrame: number | undefined
   let pending: { x: number; y: number } | undefined
   let codeScroll: HTMLElement[] = []
+  let find: FileSearchHandle | null = null
+
+  const search = {
+    register: (handle: FileSearchHandle | null) => {
+      find = handle
+    },
+  }
 
   const path = createMemo(() => file.pathFromTab(props.tab))
   const state = createMemo(() => {
@@ -50,66 +85,18 @@ export function FileTabContent(props: { tab: string }) {
   })
   const contents = createMemo(() => state()?.content?.content ?? "")
   const cacheKey = createMemo(() => sampledChecksum(contents()))
-  const isImage = createMemo(() => {
-    const c = state()?.content
-    return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
-  })
-  const isSvg = createMemo(() => {
-    const c = state()?.content
-    return c?.mimeType === "image/svg+xml"
-  })
-  const isBinary = createMemo(() => state()?.content?.type === "binary")
-  const svgContent = createMemo(() => {
-    if (!isSvg()) return
-    const c = state()?.content
-    if (!c) return
-    if (c.encoding !== "base64") return c.content
-    return decode64(c.content)
-  })
-
-  const svgDecodeFailed = createMemo(() => {
-    if (!isSvg()) return false
-    const c = state()?.content
-    if (!c) return false
-    if (c.encoding !== "base64") return false
-    return svgContent() === undefined
-  })
-
-  const svgToast = { shown: false }
-  createEffect(() => {
-    if (!svgDecodeFailed()) return
-    if (svgToast.shown) return
-    svgToast.shown = true
-    showToast({
-      variant: "error",
-      title: language.t("toast.file.loadFailed.title"),
-    })
-  })
-  const svgPreviewUrl = createMemo(() => {
-    if (!isSvg()) return
-    const c = state()?.content
-    if (!c) return
-    if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
-    return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
-  })
-  const imageDataUrl = createMemo(() => {
-    if (!isImage()) return
-    const c = state()?.content
-    return `data:${c?.mimeType};base64,${c?.content}`
-  })
-  const selectedLines = createMemo(() => {
+  const selectedLines = createMemo<SelectedLineRange | null>(() => {
     const p = path()
     if (!p) return null
-    if (file.ready()) return file.selectedLines(p) ?? null
-    return getSessionHandoff(sessionKey())?.files[p] ?? null
+    if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
+    return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
   })
 
   const selectionPreview = (source: string, selection: FileSelection) => {
-    const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
-    const end = Math.max(selection.startLine, selection.endLine)
-    const lines = source.split("\n").slice(start - 1, end)
-    if (lines.length === 0) return undefined
-    return lines.slice(0, 2).join("\n")
+    return previewSelectedLines(source, {
+      start: selection.startLine,
+      end: selection.endLine,
+    })
   }
 
   const addCommentToContext = (input: {
@@ -145,7 +132,25 @@ export function FileTabContent(props: { tab: string }) {
     })
   }
 
-  let wrap: HTMLDivElement | undefined
+  const updateCommentInContext = (input: {
+    id: string
+    file: string
+    selection: SelectedLineRange
+    comment: string
+  }) => {
+    comments.update(input.file, input.id, input.comment)
+    const preview =
+      input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
+    prompt.context.updateComment(input.file, input.id, {
+      comment: input.comment,
+      ...(preview ? { preview } : {}),
+    })
+  }
+
+  const removeCommentFromContext = (input: { id: string; file: string }) => {
+    comments.remove(input.file, input.id)
+    prompt.context.removeComment(input.file, input.id)
+  }
 
   const fileComments = createMemo(() => {
     const p = path()
@@ -153,121 +158,105 @@ export function FileTabContent(props: { tab: string }) {
     return comments.list(p)
   })
 
-  const commentLayout = createMemo(() => {
-    return fileComments()
-      .map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`)
-      .join("|")
-  })
-
   const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
 
   const [note, setNote] = createStore({
     openedComment: null as string | null,
     commenting: null as SelectedLineRange | null,
-    draft: "",
-    positions: {} as Record<string, number>,
-    draftTop: undefined as number | undefined,
+    selected: null as SelectedLineRange | null,
   })
 
-  const setCommenting = (range: SelectedLineRange | null) => {
-    setNote("commenting", range)
-    scheduleComments()
-    if (!range) return
-    setNote("draft", "")
-  }
-
-  const getRoot = () => {
-    const el = wrap
-    if (!el) return
-
-    const host = el.querySelector("diffs-container")
-    if (!(host instanceof HTMLElement)) return
-
-    const root = host.shadowRoot
-    if (!root) return
-
-    return root
-  }
-
-  const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
-    const line = Math.max(range.start, range.end)
-    const node = root.querySelector(`[data-line="${line}"]`)
-    if (!(node instanceof HTMLElement)) return
-    return node
-  }
-
-  const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
-    const wrapperRect = wrapper.getBoundingClientRect()
-    const rect = marker.getBoundingClientRect()
-    return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
+  const syncSelected = (range: SelectedLineRange | null) => {
+    const p = path()
+    if (!p) return
+    file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null)
   }
 
-  const updateComments = () => {
-    const el = wrap
-    const root = getRoot()
-    if (!el || !root) {
-      setNote("positions", {})
-      setNote("draftTop", undefined)
-      return
-    }
-
-    const estimateTop = (range: SelectedLineRange) => {
-      const line = Math.max(range.start, range.end)
-      const height = 24
-      const offset = 2
-      return Math.max(0, (line - 1) * height + offset)
-    }
-
-    const large = contents().length > 500_000
+  const activeSelection = () => note.selected ?? selectedLines()
+
+  const commentsUi = createLineCommentController({
+    comments: fileComments,
+    label: language.t("ui.lineComment.submit"),
+    draftKey: () => path() ?? props.tab,
+    state: {
+      opened: () => note.openedComment,
+      setOpened: (id) => setNote("openedComment", id),
+      selected: () => note.selected,
+      setSelected: (range) => setNote("selected", range),
+      commenting: () => note.commenting,
+      setCommenting: (range) => setNote("commenting", range),
+      syncSelected,
+      hoverSelected: syncSelected,
+    },
+    getHoverSelectedRange: activeSelection,
+    cancelDraftOnCommentToggle: true,
+    clearSelectionOnSelectionEndNull: true,
+    onSubmit: ({ comment, selection }) => {
+      const p = path()
+      if (!p) return
+      addCommentToContext({ file: p, selection, comment, origin: "file" })
+    },
+    onUpdate: ({ id, comment, selection }) => {
+      const p = path()
+      if (!p) return
+      updateCommentInContext({ id, file: p, selection, comment })
+    },
+    onDelete: (comment) => {
+      const p = path()
+      if (!p) return
+      removeCommentFromContext({ id: comment.id, file: p })
+    },
+    editSubmitLabel: language.t("common.save"),
+    renderCommentActions: (_, controls) => (
+      <FileCommentMenu
+        moreLabel={language.t("common.moreOptions")}
+        editLabel={language.t("common.edit")}
+        deleteLabel={language.t("common.delete")}
+        onEdit={controls.edit}
+        onDelete={controls.remove}
+      />
+    ),
+    onDraftPopoverFocusOut: (e: FocusEvent) => {
+      const current = e.currentTarget as HTMLDivElement
+      const target = e.relatedTarget
+      if (target instanceof Node && current.contains(target)) return
+
+      setTimeout(() => {
+        if (!document.activeElement || !current.contains(document.activeElement)) {
+          setNote("commenting", null)
+        }
+      }, 0)
+    },
+  })
 
-    const next: Record<string, number> = {}
-    for (const comment of fileComments()) {
-      const marker = findMarker(root, comment.selection)
-      if (marker) next[comment.id] = markerTop(el, marker)
-      else if (large) next[comment.id] = estimateTop(comment.selection)
-    }
+  createEffect(() => {
+    if (typeof window === "undefined") return
 
-    const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
-    const changed = Object.entries(next).filter(([id, top]) => note.positions[id] !== top)
-    if (removed.length > 0 || changed.length > 0) {
-      setNote(
-        "positions",
-        produce((draft) => {
-          for (const id of removed) {
-            delete draft[id]
-          }
-
-          for (const [id, top] of changed) {
-            draft[id] = top
-          }
-        }),
-      )
-    }
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (event.defaultPrevented) return
+      if (tabs().active() !== props.tab) return
+      if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
+      if (event.key.toLowerCase() !== "f") return
 
-    const range = note.commenting
-    if (!range) {
-      setNote("draftTop", undefined)
-      return
+      event.preventDefault()
+      event.stopPropagation()
+      find?.focus()
     }
 
-    const marker = findMarker(root, range)
-    if (marker) {
-      setNote("draftTop", markerTop(el, marker))
-      return
-    }
-
-    setNote("draftTop", large ? estimateTop(range) : undefined)
-  }
-
-  const scheduleComments = () => {
-    requestAnimationFrame(updateComments)
-  }
-
-  createEffect(() => {
-    commentLayout()
-    scheduleComments()
+    window.addEventListener("keydown", onKeyDown, { capture: true })
+    onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
   })
 
+  createEffect(
+    on(
+      path,
+      () => {
+        commentsUi.note.reset()
+      },
+      { defer: true },
+    ),
+  )
+
   createEffect(() => {
     const focus = comments.focus()
     const p = path()
@@ -278,9 +267,7 @@ export function FileTabContent(props: { tab: string }) {
     const target = fileComments().find((comment) => comment.id === focus.id)
     if (!target) return
 
-    setNote("openedComment", target.id)
-    setCommenting(null)
-    file.setSelectedLines(p, target.selection)
+    commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true })
     requestAnimationFrame(() => comments.clearFocus())
   })
 
@@ -419,99 +406,50 @@ export function FileTabContent(props: { tab: string }) {
     cancelAnimationFrame(scrollFrame)
   })
 
-  const renderCode = (source: string, wrapperClass: string) => (
-    <div
-      ref={(el) => {
-        wrap = el
-        scheduleComments()
-      }}
-      class={`relative overflow-hidden ${wrapperClass}`}
-    >
+  const renderFile = (source: string) => (
+    <div class="relative overflow-hidden pb-40">
       <Dynamic
-        component={codeComponent}
+        component={fileComponent}
+        mode="text"
         file={{
           name: path() ?? "",
           contents: source,
           cacheKey: cacheKey(),
         }}
         enableLineSelection
-        selectedLines={selectedLines()}
+        enableHoverUtility
+        selectedLines={activeSelection()}
         commentedLines={commentedLines()}
         onRendered={() => {
           requestAnimationFrame(restoreScroll)
-          requestAnimationFrame(scheduleComments)
         }}
+        annotations={commentsUi.annotations()}
+        renderAnnotation={commentsUi.renderAnnotation}
+        renderHoverUtility={commentsUi.renderHoverUtility}
         onLineSelected={(range: SelectedLineRange | null) => {
-          const p = path()
-          if (!p) return
-          file.setSelectedLines(p, range)
-          if (!range) setCommenting(null)
+          commentsUi.onLineSelected(range)
         }}
+        onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
         onLineSelectionEnd={(range: SelectedLineRange | null) => {
-          if (!range) {
-            setCommenting(null)
-            return
-          }
-
-          setNote("openedComment", null)
-          setCommenting(range)
+          commentsUi.onLineSelectionEnd(range)
         }}
+        search={search}
         overflow="scroll"
         class="select-text"
+        media={{
+          mode: "auto",
+          path: path(),
+          current: state()?.content,
+          onLoad: () => requestAnimationFrame(restoreScroll),
+          onError: (args: { kind: "image" | "audio" | "svg" }) => {
+            if (args.kind !== "svg") return
+            showToast({
+              variant: "error",
+              title: language.t("toast.file.loadFailed.title"),
+            })
+          },
+        }}
       />
-      <For each={fileComments()}>
-        {(comment) => (
-          <LineCommentView
-            id={comment.id}
-            top={note.positions[comment.id]}
-            open={note.openedComment === comment.id}
-            comment={comment.comment}
-            selection={formatCommentLabel(comment.selection)}
-            onMouseEnter={() => {
-              const p = path()
-              if (!p) return
-              file.setSelectedLines(p, comment.selection)
-            }}
-            onClick={() => {
-              const p = path()
-              if (!p) return
-              setCommenting(null)
-              setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
-              file.setSelectedLines(p, comment.selection)
-            }}
-          />
-        )}
-      </For>
-      <Show when={note.commenting}>
-        {(range) => (
-          <Show when={note.draftTop !== undefined}>
-            <LineCommentEditor
-              top={note.draftTop}
-              value={note.draft}
-              selection={formatCommentLabel(range())}
-              onInput={(value) => setNote("draft", value)}
-              onCancel={cancelCommenting}
-              onSubmit={(value) => {
-                const p = path()
-                if (!p) return
-                addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
-                setCommenting(null)
-              }}
-              onPopoverFocusOut={(e: FocusEvent) => {
-                const current = e.currentTarget as HTMLDivElement
-                const target = e.relatedTarget
-                if (target instanceof Node && current.contains(target)) return
-
-                setTimeout(() => {
-                  if (!document.activeElement || !current.contains(document.activeElement)) {
-                    cancelCommenting()
-                  }
-                }, 0)
-              }}
-            />
-          </Show>
-        )}
-      </Show>
     </div>
   )
 
@@ -526,36 +464,7 @@ export function FileTabContent(props: { tab: string }) {
         onScroll={handleScroll as any}
       >
         <Switch>
-          <Match when={state()?.loaded && isImage()}>
-            <div class="px-6 py-4 pb-40">
-              <img
-                src={imageDataUrl()}
-                alt={path()}
-                class="max-w-full"
-                onLoad={() => requestAnimationFrame(restoreScroll)}
-              />
-            </div>
-          </Match>
-          <Match when={state()?.loaded && isSvg()}>
-            <div class="flex flex-col gap-4 px-6 py-4">
-              {renderCode(svgContent() ?? "", "")}
-              <Show when={svgPreviewUrl()}>
-                <div class="flex justify-center pb-40">
-                  <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
-                </div>
-              </Show>
-            </div>
-          </Match>
-          <Match when={state()?.loaded && isBinary()}>
-            <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
-              <Mark class="w-14 opacity-10" />
-              <div class="flex flex-col gap-2 max-w-md">
-                <div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
-                <div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
-              </div>
-            </div>
-          </Match>
-          <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
+          <Match when={state()?.loaded}>{renderFile(contents())}</Match>
           <Match when={state()?.loading}>
             <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
           </Match>

+ 92 - 28
packages/app/src/pages/session/message-timeline.tsx

@@ -2,6 +2,7 @@ import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "so
 import { createStore, produce } from "solid-js/store"
 import { useNavigate, useParams } from "@solidjs/router"
 import { Button } from "@opencode-ai/ui/button"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
@@ -9,8 +10,9 @@ import { Dialog } from "@opencode-ai/ui/dialog"
 import { InlineInput } from "@opencode-ai/ui/inline-input"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
-import type { UserMessage } from "@opencode-ai/sdk/v2"
+import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
 import { showToast } from "@opencode-ai/ui/toast"
+import { getFilename } from "@opencode-ai/util/path"
 import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
 import { SessionContextUsage } from "@/components/session-context-usage"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -18,6 +20,35 @@ import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
+import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
+
+type MessageComment = {
+  path: string
+  comment: string
+  selection?: {
+    startLine: number
+    endLine: number
+  }
+}
+
+const messageComments = (parts: Part[]): MessageComment[] =>
+  parts.flatMap((part) => {
+    if (part.type !== "text" || !(part as TextPart).synthetic) return []
+    const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
+    if (!next) return []
+    return [
+      {
+        path: next.path,
+        comment: next.comment,
+        selection: next.selection
+          ? {
+              startLine: next.selection.startLine,
+              endLine: next.selection.endLine,
+            }
+          : undefined,
+      },
+    ]
+  })
 
 const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
   const current = target instanceof Element ? target : undefined
@@ -522,34 +553,67 @@ export function MessageTimeline(props: {
               </div>
             </Show>
             <For each={props.renderedUserMessages}>
-              {(message) => (
-                <div
-                  id={props.anchor(message.id)}
-                  data-message-id={message.id}
-                  ref={(el) => {
-                    props.onRegisterMessage(el, message.id)
-                    onCleanup(() => props.onUnregisterMessage(message.id))
-                  }}
-                  classList={{
-                    "min-w-0 w-full max-w-full": true,
-                    "md:max-w-200 2xl:max-w-[1000px]": props.centered,
-                  }}
-                >
-                  <SessionTurn
-                    sessionID={sessionID() ?? ""}
-                    messageID={message.id}
-                    lastUserMessageID={props.lastUserMessageID}
-                    showReasoningSummaries={settings.general.showReasoningSummaries()}
-                    shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
-                    editToolDefaultOpen={settings.general.editToolPartsExpanded()}
-                    classes={{
-                      root: "min-w-0 w-full relative",
-                      content: "flex flex-col justify-between !overflow-visible",
-                      container: "w-full px-4 md:px-5",
+              {(message) => {
+                const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
+                return (
+                  <div
+                    id={props.anchor(message.id)}
+                    data-message-id={message.id}
+                    ref={(el) => {
+                      props.onRegisterMessage(el, message.id)
+                      onCleanup(() => props.onUnregisterMessage(message.id))
                     }}
-                  />
-                </div>
-              )}
+                    classList={{
+                      "min-w-0 w-full max-w-full": true,
+                      "md:max-w-200 2xl:max-w-[1000px]": props.centered,
+                    }}
+                  >
+                    <Show when={comments().length > 0}>
+                      <div class="w-full px-4 md:px-5 pb-2">
+                        <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
+                          <div class="flex w-max min-w-full justify-end gap-2">
+                            <For each={comments()}>
+                              {(comment) => (
+                                <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
+                                  <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
+                                    <FileIcon node={{ path: comment.path, type: "file" }} class="size-3.5 shrink-0" />
+                                    <span class="truncate">{getFilename(comment.path)}</span>
+                                    <Show when={comment.selection}>
+                                      {(selection) => (
+                                        <span class="shrink-0 text-text-weak">
+                                          {selection().startLine === selection().endLine
+                                            ? `:${selection().startLine}`
+                                            : `:${selection().startLine}-${selection().endLine}`}
+                                        </span>
+                                      )}
+                                    </Show>
+                                  </div>
+                                  <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
+                                    {comment.comment}
+                                  </div>
+                                </div>
+                              )}
+                            </For>
+                          </div>
+                        </div>
+                      </div>
+                    </Show>
+                    <SessionTurn
+                      sessionID={sessionID() ?? ""}
+                      messageID={message.id}
+                      lastUserMessageID={props.lastUserMessageID}
+                      showReasoningSummaries={settings.general.showReasoningSummaries()}
+                      shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
+                      editToolDefaultOpen={settings.general.editToolPartsExpanded()}
+                      classes={{
+                        root: "min-w-0 w-full relative",
+                        content: "flex flex-col justify-between !overflow-visible",
+                        container: "w-full px-4 md:px-5",
+                      }}
+                    />
+                  </div>
+                )
+              }}
             </For>
           </div>
         </ScrollView>

+ 74 - 25
packages/app/src/pages/session/review-tab.tsx

@@ -1,6 +1,11 @@
 import { createEffect, on, onCleanup, type JSX } from "solid-js"
 import type { FileDiff } from "@opencode-ai/sdk/v2"
 import { SessionReview } from "@opencode-ai/ui/session-review"
+import type {
+  SessionReviewCommentActions,
+  SessionReviewCommentDelete,
+  SessionReviewCommentUpdate,
+} from "@opencode-ai/ui/session-review"
 import type { SelectedLineRange } from "@/context/file"
 import { useSDK } from "@/context/sdk"
 import { useLayout } from "@/context/layout"
@@ -17,6 +22,9 @@ export interface SessionReviewTabProps {
   onDiffStyleChange?: (style: DiffStyle) => void
   onViewFile?: (file: string) => void
   onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
+  onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
+  onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
+  lineCommentActions?: SessionReviewCommentActions
   comments?: LineComment[]
   focusedComment?: { file: string; id: string } | null
   onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
@@ -39,10 +47,11 @@ export function StickyAddButton(props: { children: JSX.Element }) {
 
 export function SessionReviewTab(props: SessionReviewTabProps) {
   let scroll: HTMLDivElement | undefined
-  let frame: number | undefined
-  let pending: { x: number; y: number } | undefined
+  let restoreFrame: number | undefined
+  let userInteracted = false
 
   const sdk = useSDK()
+  const layout = useLayout()
 
   const readFile = async (path: string) => {
     return sdk.client.file
@@ -54,48 +63,81 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
       })
   }
 
-  const restoreScroll = () => {
+  const handleInteraction = () => {
+    userInteracted = true
+  }
+
+  const doRestore = () => {
+    restoreFrame = undefined
     const el = scroll
-    if (!el) return
+    if (!el || !layout.ready() || userInteracted) return
+    if (el.clientHeight === 0 || el.clientWidth === 0) return
 
     const s = props.view().scroll("review")
-    if (!s) return
+    if (!s || (s.x === 0 && s.y === 0)) return
+
+    const maxY = Math.max(0, el.scrollHeight - el.clientHeight)
+    const maxX = Math.max(0, el.scrollWidth - el.clientWidth)
 
-    if (el.scrollTop !== s.y) el.scrollTop = s.y
-    if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+    const targetY = Math.min(s.y, maxY)
+    const targetX = Math.min(s.x, maxX)
+
+    if (el.scrollTop !== targetY) el.scrollTop = targetY
+    if (el.scrollLeft !== targetX) el.scrollLeft = targetX
   }
 
-  const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
-    pending = {
-      x: event.currentTarget.scrollLeft,
-      y: event.currentTarget.scrollTop,
-    }
-    if (frame !== undefined) return
+  const queueRestore = () => {
+    if (userInteracted || restoreFrame !== undefined) return
+    restoreFrame = requestAnimationFrame(doRestore)
+  }
 
-    frame = requestAnimationFrame(() => {
-      frame = undefined
+  const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+    if (!layout.ready() || !userInteracted) return
 
-      const next = pending
-      pending = undefined
-      if (!next) return
+    const el = event.currentTarget
+    if (el.clientHeight === 0 || el.clientWidth === 0) return
 
-      props.view().setScroll("review", next)
+    props.view().setScroll("review", {
+      x: el.scrollLeft,
+      y: el.scrollTop,
     })
   }
 
   createEffect(
     on(
       () => props.diffs().length,
-      () => {
-        requestAnimationFrame(restoreScroll)
+      () => queueRestore(),
+      { defer: true },
+    ),
+  )
+
+  createEffect(
+    on(
+      () => props.diffStyle,
+      () => queueRestore(),
+      { defer: true },
+    ),
+  )
+
+  createEffect(
+    on(
+      () => layout.ready(),
+      (ready) => {
+        if (!ready) return
+        queueRestore()
       },
       { defer: true },
     ),
   )
 
   onCleanup(() => {
-    if (frame === undefined) return
-    cancelAnimationFrame(frame)
+    if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
+    if (scroll) {
+      scroll.removeEventListener("wheel", handleInteraction)
+      scroll.removeEventListener("pointerdown", handleInteraction)
+      scroll.removeEventListener("touchstart", handleInteraction)
+      scroll.removeEventListener("keydown", handleInteraction)
+    }
   })
 
   return (
@@ -104,11 +146,15 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
       empty={props.empty}
       scrollRef={(el) => {
         scroll = el
+        el.addEventListener("wheel", handleInteraction, { passive: true, capture: true })
+        el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true })
+        el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true })
+        el.addEventListener("keydown", handleInteraction, { passive: true, capture: true })
         props.onScrollRef?.(el)
-        restoreScroll()
+        queueRestore()
       }}
       onScroll={handleScroll}
-      onDiffRendered={() => requestAnimationFrame(restoreScroll)}
+      onDiffRendered={queueRestore}
       open={props.view().review.open()}
       onOpenChange={props.view().review.setOpen}
       classes={{
@@ -123,6 +169,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
       focusedFile={props.focusedFile}
       readFile={readFile}
       onLineComment={props.onLineComment}
+      onLineCommentUpdate={props.onLineCommentUpdate}
+      onLineCommentDelete={props.onLineCommentDelete}
+      lineCommentActions={props.lineCommentActions}
       comments={props.comments}
       focusedComment={props.focusedComment}
       onFocusedCommentChange={props.onFocusedCommentChange}

+ 88 - 0
packages/app/src/utils/comment-note.ts

@@ -0,0 +1,88 @@
+import type { FileSelection } from "@/context/file"
+
+export type PromptComment = {
+  path: string
+  selection?: FileSelection
+  comment: string
+  preview?: string
+  origin?: "review" | "file"
+}
+
+function selection(selection: unknown) {
+  if (!selection || typeof selection !== "object") return undefined
+  const startLine = Number((selection as FileSelection).startLine)
+  const startChar = Number((selection as FileSelection).startChar)
+  const endLine = Number((selection as FileSelection).endLine)
+  const endChar = Number((selection as FileSelection).endChar)
+  if (![startLine, startChar, endLine, endChar].every(Number.isFinite)) return undefined
+  return {
+    startLine,
+    startChar,
+    endLine,
+    endChar,
+  } satisfies FileSelection
+}
+
+export function createCommentMetadata(input: PromptComment) {
+  return {
+    opencodeComment: {
+      path: input.path,
+      selection: input.selection,
+      comment: input.comment,
+      preview: input.preview,
+      origin: input.origin,
+    },
+  }
+}
+
+export function readCommentMetadata(value: unknown) {
+  if (!value || typeof value !== "object") return
+  const meta = (value as { opencodeComment?: unknown }).opencodeComment
+  if (!meta || typeof meta !== "object") return
+  const path = (meta as { path?: unknown }).path
+  const comment = (meta as { comment?: unknown }).comment
+  if (typeof path !== "string" || typeof comment !== "string") return
+  const preview = (meta as { preview?: unknown }).preview
+  const origin = (meta as { origin?: unknown }).origin
+  return {
+    path,
+    selection: selection((meta as { selection?: unknown }).selection),
+    comment,
+    preview: typeof preview === "string" ? preview : undefined,
+    origin: origin === "review" || origin === "file" ? origin : undefined,
+  } satisfies PromptComment
+}
+
+export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) {
+  const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined
+  const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined
+  const range =
+    start === undefined || end === undefined
+      ? "this file"
+      : start === end
+        ? `line ${start}`
+        : `lines ${start} through ${end}`
+  return `The user made the following comment regarding ${range} of ${input.path}: ${input.comment}`
+}
+
+export function parseCommentNote(text: string) {
+  const match = text.match(
+    /^The user made the following comment regarding (this file|line (\d+)|lines (\d+) through (\d+)) of (.+?): ([\s\S]+)$/,
+  )
+  if (!match) return
+  const start = match[2] ? Number(match[2]) : match[3] ? Number(match[3]) : undefined
+  const end = match[2] ? Number(match[2]) : match[4] ? Number(match[4]) : undefined
+  return {
+    path: match[5],
+    selection:
+      start !== undefined && end !== undefined
+        ? {
+            startLine: start,
+            startChar: 0,
+            endLine: end,
+            endChar: 0,
+          }
+        : undefined,
+    comment: match[6],
+  } satisfies PromptComment
+}

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

@@ -2,8 +2,7 @@ import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } f
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import { DataProvider } from "@opencode-ai/ui/context"
-import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
-import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
+import { FileComponentProvider } from "@opencode-ai/ui/context/file"
 import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
 import { createAsync, query, useParams } from "@solidjs/router"
 import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
@@ -22,14 +21,12 @@ import NotFound from "../[...404]"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { MessageNav } from "@opencode-ai/ui/message-nav"
 import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
-import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
+import { FileSSR } from "@opencode-ai/ui/file-ssr"
 import { clientOnly } from "@solidjs/start"
 import { type IconName } from "@opencode-ai/ui/icons/provider"
 import { Meta, Title } from "@solidjs/meta"
 import { Base64 } from "js-base64"
 
-const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff })))
-const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m) => ({ default: m.Code })))
 const ClientOnlyWorkerPoolProvider = clientOnly(() =>
   import("@opencode-ai/ui/pierre/worker").then((m) => ({
     default: (props: { children: any }) => (
@@ -218,252 +215,244 @@ export default function () {
               <Meta property="og:image" content={ogImage()} />
               <Meta name="twitter:image" content={ogImage()} />
               <ClientOnlyWorkerPoolProvider>
-                <DiffComponentProvider component={ClientOnlyDiff}>
-                  <CodeComponentProvider component={ClientOnlyCode}>
-                    <DataProvider data={data()} directory={info().directory}>
-                      {iife(() => {
-                        const [store, setStore] = createStore({
-                          messageId: undefined as string | undefined,
-                        })
-                        const messages = createMemo(() =>
-                          data().sessionID
-                            ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
-                                (a, b) => a.time.created - b.time.created,
-                              )
-                            : [],
-                        )
-                        const firstUserMessage = createMemo(() => messages().at(0))
-                        const activeMessage = createMemo(
-                          () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
-                        )
-                        function setActiveMessage(message: UserMessage | undefined) {
-                          if (message) {
-                            setStore("messageId", message.id)
-                          } else {
-                            setStore("messageId", undefined)
-                          }
+                <FileComponentProvider component={FileSSR}>
+                  <DataProvider data={data()} directory={info().directory}>
+                    {iife(() => {
+                      const [store, setStore] = createStore({
+                        messageId: undefined as string | undefined,
+                      })
+                      const messages = createMemo(() =>
+                        data().sessionID
+                          ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
+                              (a, b) => a.time.created - b.time.created,
+                            )
+                          : [],
+                      )
+                      const firstUserMessage = createMemo(() => messages().at(0))
+                      const activeMessage = createMemo(
+                        () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
+                      )
+                      function setActiveMessage(message: UserMessage | undefined) {
+                        if (message) {
+                          setStore("messageId", message.id)
+                        } else {
+                          setStore("messageId", undefined)
                         }
-                        const provider = createMemo(() => activeMessage()?.model?.providerID)
-                        const modelID = createMemo(() => activeMessage()?.model?.modelID)
-                        const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
-                        const diffs = createMemo(() => {
-                          const diffs = data().session_diff[data().sessionID] ?? []
-                          const preloaded = data().session_diff_preload[data().sessionID] ?? []
-                          return diffs.map((diff) => ({
-                            ...diff,
-                            preloaded: preloaded.find((d) => d.newFile.name === diff.file),
-                          }))
-                        })
-                        const splitDiffs = createMemo(() => {
-                          const diffs = data().session_diff[data().sessionID] ?? []
-                          const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
-                          return diffs.map((diff) => ({
-                            ...diff,
-                            preloaded: preloaded.find((d) => d.newFile.name === diff.file),
-                          }))
-                        })
+                      }
+                      const provider = createMemo(() => activeMessage()?.model?.providerID)
+                      const modelID = createMemo(() => activeMessage()?.model?.modelID)
+                      const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
+                      const diffs = createMemo(() => {
+                        const diffs = data().session_diff[data().sessionID] ?? []
+                        const preloaded = data().session_diff_preload[data().sessionID] ?? []
+                        return diffs.map((diff) => ({
+                          ...diff,
+                          preloaded: preloaded.find((d) => d.newFile.name === diff.file),
+                        }))
+                      })
+                      const splitDiffs = createMemo(() => {
+                        const diffs = data().session_diff[data().sessionID] ?? []
+                        const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
+                        return diffs.map((diff) => ({
+                          ...diff,
+                          preloaded: preloaded.find((d) => d.newFile.name === diff.file),
+                        }))
+                      })
 
-                        const title = () => (
-                          <div class="flex flex-col gap-4">
-                            <div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center sm:h-8 justify-start self-stretch">
-                              <div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base w-fit">
-                                <Mark class="shrink-0 w-3 my-0.5" />
-                                <div class="text-12-mono text-text-base">v{info().version}</div>
+                      const title = () => (
+                        <div class="flex flex-col gap-4">
+                          <div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center sm:h-8 justify-start self-stretch">
+                            <div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base w-fit">
+                              <Mark class="shrink-0 w-3 my-0.5" />
+                              <div class="text-12-mono text-text-base">v{info().version}</div>
+                            </div>
+                            <div class="flex gap-4 items-center">
+                              <div class="flex gap-2 items-center">
+                                <ProviderIcon
+                                  id={provider() as IconName}
+                                  class="size-3.5 shrink-0 text-icon-strong-base"
+                                />
+                                <div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
                               </div>
-                              <div class="flex gap-4 items-center">
-                                <div class="flex gap-2 items-center">
-                                  <ProviderIcon
-                                    id={provider() as IconName}
-                                    class="size-3.5 shrink-0 text-icon-strong-base"
-                                  />
-                                  <div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
-                                </div>
-                                <div class="text-12-regular text-text-weaker">
-                                  {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
-                                </div>
+                              <div class="text-12-regular text-text-weaker">
+                                {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
                               </div>
                             </div>
-                            <div class="text-left text-16-medium text-text-strong">{info().title}</div>
                           </div>
-                        )
+                          <div class="text-left text-16-medium text-text-strong">{info().title}</div>
+                        </div>
+                      )
 
-                        const turns = () => (
-                          <div class="relative mt-2 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
-                            <div class="px-4 py-6">{title()}</div>
-                            <div class="flex flex-col gap-15 items-start justify-start mt-4">
-                              <For each={messages()}>
-                                {(message) => (
-                                  <SessionTurn
-                                    sessionID={data().sessionID}
-                                    messageID={message.id}
-                                    classes={{
-                                      root: "min-w-0 w-full relative",
-                                      content: "flex flex-col justify-between !overflow-visible",
-                                      container: "px-4",
-                                    }}
-                                  />
-                                )}
-                              </For>
-                            </div>
-                            <div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
-                              <Logo class="w-58.5 opacity-12" />
-                            </div>
+                      const turns = () => (
+                        <div class="relative mt-2 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
+                          <div class="px-4 py-6">{title()}</div>
+                          <div class="flex flex-col gap-15 items-start justify-start mt-4">
+                            <For each={messages()}>
+                              {(message) => (
+                                <SessionTurn
+                                  sessionID={data().sessionID}
+                                  messageID={message.id}
+                                  classes={{
+                                    root: "min-w-0 w-full relative",
+                                    content: "flex flex-col justify-between !overflow-visible",
+                                    container: "px-4",
+                                  }}
+                                />
+                              )}
+                            </For>
                           </div>
-                        )
+                          <div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
+                            <Logo class="w-58.5 opacity-12" />
+                          </div>
+                        </div>
+                      )
 
-                        const wide = createMemo(() => diffs().length === 0)
+                      const wide = createMemo(() => diffs().length === 0)
 
-                        return (
-                          <div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
-                            <header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
-                              <div class="">
-                                <a href="https://opencode.ai">
-                                  <Mark />
-                                </a>
-                              </div>
-                              <div class="flex gap-3 items-center">
-                                <IconButton
-                                  as={"a"}
-                                  href="https://github.com/anomalyco/opencode"
-                                  target="_blank"
-                                  icon="github"
-                                  variant="ghost"
-                                />
-                                <IconButton
-                                  as={"a"}
-                                  href="https://opencode.ai/discord"
-                                  target="_blank"
-                                  icon="discord"
-                                  variant="ghost"
-                                />
-                              </div>
-                            </header>
-                            <div class="select-text flex flex-col flex-1 min-h-0">
+                      return (
+                        <div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
+                          <header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
+                            <div class="">
+                              <a href="https://opencode.ai">
+                                <Mark />
+                              </a>
+                            </div>
+                            <div class="flex gap-3 items-center">
+                              <IconButton
+                                as={"a"}
+                                href="https://github.com/anomalyco/opencode"
+                                target="_blank"
+                                icon="github"
+                                variant="ghost"
+                              />
+                              <IconButton
+                                as={"a"}
+                                href="https://opencode.ai/discord"
+                                target="_blank"
+                                icon="discord"
+                                variant="ghost"
+                              />
+                            </div>
+                          </header>
+                          <div class="select-text flex flex-col flex-1 min-h-0">
+                            <div
+                              classList={{
+                                "hidden w-full flex-1 min-h-0": true,
+                                "md:flex": wide(),
+                                "lg:flex": !wide(),
+                              }}
+                            >
                               <div
                                 classList={{
-                                  "hidden w-full flex-1 min-h-0": true,
-                                  "md:flex": wide(),
-                                  "lg:flex": !wide(),
+                                  "@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
                                 }}
                               >
                                 <div
                                   classList={{
-                                    "@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
+                                    "w-full flex justify-start items-start min-w-0 px-6": true,
                                   }}
                                 >
-                                  <div
-                                    classList={{
-                                      "w-full flex justify-start items-start min-w-0 px-6": true,
+                                  {title()}
+                                </div>
+                                <div class="flex items-start justify-start h-full min-h-0">
+                                  <Show when={messages().length > 1}>
+                                    <MessageNav
+                                      class="sticky top-0 shrink-0 py-2 pl-4"
+                                      messages={messages()}
+                                      current={activeMessage()}
+                                      size="compact"
+                                      onMessageSelect={setActiveMessage}
+                                    />
+                                  </Show>
+                                  <SessionTurn
+                                    sessionID={data().sessionID}
+                                    messageID={store.messageId ?? firstUserMessage()!.id!}
+                                    classes={{
+                                      root: "grow",
+                                      content: "flex flex-col justify-between",
+                                      container: "w-full pb-20 px-6",
                                     }}
                                   >
-                                    {title()}
-                                  </div>
-                                  <div class="flex items-start justify-start h-full min-h-0">
-                                    <Show when={messages().length > 1}>
-                                      <MessageNav
-                                        class="sticky top-0 shrink-0 py-2 pl-4"
-                                        messages={messages()}
-                                        current={activeMessage()}
-                                        size="compact"
-                                        onMessageSelect={setActiveMessage}
-                                      />
-                                    </Show>
-                                    <SessionTurn
-                                      sessionID={data().sessionID}
-                                      messageID={store.messageId ?? firstUserMessage()!.id!}
-                                      classes={{
-                                        root: "grow",
-                                        content: "flex flex-col justify-between",
-                                        container: "w-full pb-20 px-6",
-                                      }}
-                                    >
-                                      <div
-                                        classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}
-                                      >
-                                        <Logo class="w-58.5 opacity-12" />
-                                      </div>
-                                    </SessionTurn>
-                                  </div>
+                                    <div classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}>
+                                      <Logo class="w-58.5 opacity-12" />
+                                    </div>
+                                  </SessionTurn>
                                 </div>
-                                <Show when={diffs().length > 0}>
-                                  <DiffComponentProvider component={SSRDiff}>
-                                    <div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
+                              </div>
+                              <Show when={diffs().length > 0}>
+                                <div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
+                                  <SessionReview
+                                    class="@4xl:hidden"
+                                    diffs={diffs()}
+                                    classes={{
+                                      root: "pb-20",
+                                      header: "px-6",
+                                      container: "px-6",
+                                    }}
+                                  />
+                                  <SessionReview
+                                    split
+                                    class="hidden @4xl:flex"
+                                    diffs={splitDiffs()}
+                                    classes={{
+                                      root: "pb-20",
+                                      header: "px-6",
+                                      container: "px-6",
+                                    }}
+                                  />
+                                </div>
+                              </Show>
+                            </div>
+                            <Switch>
+                              <Match when={diffs().length > 0}>
+                                <Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
+                                  <Tabs.List>
+                                    <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
+                                      Session
+                                    </Tabs.Trigger>
+                                    <Tabs.Trigger
+                                      value="review"
+                                      class="w-1/2 !border-r-0"
+                                      classes={{ button: "w-full" }}
+                                    >
+                                      {diffs().length} Files Changed
+                                    </Tabs.Trigger>
+                                  </Tabs.List>
+                                  <Tabs.Content value="session" class="!overflow-hidden">
+                                    {turns()}
+                                  </Tabs.Content>
+                                  <Tabs.Content
+                                    forceMount
+                                    value="review"
+                                    class="!overflow-hidden hidden data-[selected]:block"
+                                  >
+                                    <div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
                                       <SessionReview
-                                        class="@4xl:hidden"
                                         diffs={diffs()}
                                         classes={{
                                           root: "pb-20",
-                                          header: "px-6",
-                                          container: "px-6",
-                                        }}
-                                      />
-                                      <SessionReview
-                                        split
-                                        class="hidden @4xl:flex"
-                                        diffs={splitDiffs()}
-                                        classes={{
-                                          root: "pb-20",
-                                          header: "px-6",
-                                          container: "px-6",
+                                          header: "px-4",
+                                          container: "px-4",
                                         }}
                                       />
                                     </div>
-                                  </DiffComponentProvider>
-                                </Show>
-                              </div>
-                              <Switch>
-                                <Match when={diffs().length > 0}>
-                                  <Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
-                                    <Tabs.List>
-                                      <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
-                                        Session
-                                      </Tabs.Trigger>
-                                      <Tabs.Trigger
-                                        value="review"
-                                        class="w-1/2 !border-r-0"
-                                        classes={{ button: "w-full" }}
-                                      >
-                                        {diffs().length} Files Changed
-                                      </Tabs.Trigger>
-                                    </Tabs.List>
-                                    <Tabs.Content value="session" class="!overflow-hidden">
-                                      {turns()}
-                                    </Tabs.Content>
-                                    <Tabs.Content
-                                      forceMount
-                                      value="review"
-                                      class="!overflow-hidden hidden data-[selected]:block"
-                                    >
-                                      <div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
-                                        <DiffComponentProvider component={SSRDiff}>
-                                          <SessionReview
-                                            diffs={diffs()}
-                                            classes={{
-                                              root: "pb-20",
-                                              header: "px-4",
-                                              container: "px-4",
-                                            }}
-                                          />
-                                        </DiffComponentProvider>
-                                      </div>
-                                    </Tabs.Content>
-                                  </Tabs>
-                                </Match>
-                                <Match when={true}>
-                                  <div
-                                    classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}
-                                  >
-                                    {turns()}
-                                  </div>
-                                </Match>
-                              </Switch>
-                            </div>
+                                  </Tabs.Content>
+                                </Tabs>
+                              </Match>
+                              <Match when={true}>
+                                <div
+                                  classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}
+                                >
+                                  {turns()}
+                                </div>
+                              </Match>
+                            </Switch>
                           </div>
-                        )
-                      })}
-                    </DataProvider>
-                  </CodeComponentProvider>
-                </DiffComponentProvider>
+                        </div>
+                      )
+                    })}
+                  </DataProvider>
+                </FileComponentProvider>
               </ClientOnlyWorkerPoolProvider>
             </>
           )

+ 0 - 4
packages/ui/src/components/code.css

@@ -1,4 +0,0 @@
-[data-component="code"] {
-  content-visibility: auto;
-  overflow: hidden;
-}

+ 0 - 1097
packages/ui/src/components/code.tsx

@@ -1,1097 +0,0 @@
-import {
-  DEFAULT_VIRTUAL_FILE_METRICS,
-  type FileContents,
-  File,
-  FileOptions,
-  LineAnnotation,
-  type SelectedLineRange,
-  type VirtualFileMetrics,
-  VirtualizedFile,
-  Virtualizer,
-} from "@pierre/diffs"
-import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
-import { Portal } from "solid-js/web"
-import { createDefaultOptions, styleVariables } from "../pierre"
-import { getWorkerPool } from "../pierre/worker"
-import { Icon } from "./icon"
-
-const VIRTUALIZE_BYTES = 500_000
-const codeMetrics = {
-  ...DEFAULT_VIRTUAL_FILE_METRICS,
-  lineHeight: 24,
-  fileGap: 0,
-} satisfies Partial<VirtualFileMetrics>
-
-type SelectionSide = "additions" | "deletions"
-
-export type CodeProps<T = {}> = FileOptions<T> & {
-  file: FileContents
-  annotations?: LineAnnotation<T>[]
-  selectedLines?: SelectedLineRange | null
-  commentedLines?: SelectedLineRange[]
-  onRendered?: () => void
-  onLineSelectionEnd?: (selection: SelectedLineRange | null) => void
-  class?: string
-  classList?: ComponentProps<"div">["classList"]
-}
-
-function findElement(node: Node | null): HTMLElement | undefined {
-  if (!node) return
-  if (node instanceof HTMLElement) return node
-  return node.parentElement ?? undefined
-}
-
-function findLineNumber(node: Node | null): number | undefined {
-  const element = findElement(node)
-  if (!element) return
-
-  const line = element.closest("[data-line]")
-  if (!(line instanceof HTMLElement)) return
-
-  const value = parseInt(line.dataset.line ?? "", 10)
-  if (Number.isNaN(value)) return
-
-  return value
-}
-
-function findSide(node: Node | null): SelectionSide | undefined {
-  const element = findElement(node)
-  if (!element) return
-
-  const code = element.closest("[data-code]")
-  if (!(code instanceof HTMLElement)) return
-
-  if (code.hasAttribute("data-deletions")) return "deletions"
-  return "additions"
-}
-
-type FindHost = {
-  element: () => HTMLElement | undefined
-  open: () => void
-  close: () => void
-  next: (dir: 1 | -1) => void
-  isOpen: () => boolean
-}
-
-const findHosts = new Set<FindHost>()
-let findTarget: FindHost | undefined
-let findCurrent: FindHost | undefined
-let findInstalled = false
-
-function isEditable(node: unknown): boolean {
-  if (!(node instanceof HTMLElement)) return false
-  if (node.closest("[data-prevent-autofocus]")) return true
-  if (node.isContentEditable) return true
-  return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
-}
-
-function hostForNode(node: unknown): FindHost | undefined {
-  if (!(node instanceof Node)) return
-  for (const host of findHosts) {
-    const el = host.element()
-    if (el && el.isConnected && el.contains(node)) return host
-  }
-}
-
-function installFindShortcuts() {
-  if (findInstalled) return
-  if (typeof window === "undefined") return
-  findInstalled = true
-
-  window.addEventListener(
-    "keydown",
-    (event) => {
-      if (event.defaultPrevented) return
-
-      const mod = event.metaKey || event.ctrlKey
-      if (!mod) return
-
-      const key = event.key.toLowerCase()
-
-      if (key === "g") {
-        const host = findCurrent
-        if (!host || !host.isOpen()) return
-        event.preventDefault()
-        event.stopPropagation()
-        host.next(event.shiftKey ? -1 : 1)
-        return
-      }
-
-      if (key !== "f") return
-
-      const current = findCurrent
-      if (current && current.isOpen()) {
-        event.preventDefault()
-        event.stopPropagation()
-        current.open()
-        return
-      }
-
-      const host =
-        hostForNode(document.activeElement) ?? hostForNode(event.target) ?? findTarget ?? Array.from(findHosts)[0]
-      if (!host) return
-
-      event.preventDefault()
-      event.stopPropagation()
-      host.open()
-    },
-    { capture: true },
-  )
-}
-
-export function Code<T>(props: CodeProps<T>) {
-  let wrapper!: HTMLDivElement
-  let container!: HTMLDivElement
-  let findInput: HTMLInputElement | undefined
-  let findOverlay!: HTMLDivElement
-  let findOverlayFrame: number | undefined
-  let findOverlayScroll: HTMLElement[] = []
-  let observer: MutationObserver | undefined
-  let renderToken = 0
-  let selectionFrame: number | undefined
-  let dragFrame: number | undefined
-  let dragStart: number | undefined
-  let dragEnd: number | undefined
-  let dragMoved = false
-  let lastSelection: SelectedLineRange | null = null
-  let pendingSelectionEnd = false
-
-  const [local, others] = splitProps(props, [
-    "file",
-    "class",
-    "classList",
-    "annotations",
-    "selectedLines",
-    "commentedLines",
-    "onRendered",
-  ])
-
-  const [rendered, setRendered] = createSignal(0)
-
-  const [findOpen, setFindOpen] = createSignal(false)
-  const [findQuery, setFindQuery] = createSignal("")
-  const [findIndex, setFindIndex] = createSignal(0)
-  const [findCount, setFindCount] = createSignal(0)
-  let findMode: "highlights" | "overlay" = "overlay"
-  let findHits: Range[] = []
-
-  const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 })
-
-  let instance: File<T> | VirtualizedFile<T> | undefined
-  let virtualizer: Virtualizer | undefined
-  let virtualRoot: Document | HTMLElement | undefined
-
-  const bytes = createMemo(() => {
-    const value = local.file.contents as unknown
-    if (typeof value === "string") return value.length
-    if (Array.isArray(value)) {
-      return value.reduce(
-        (acc, part) => acc + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
-        0,
-      )
-    }
-    if (value == null) return 0
-    return String(value).length
-  })
-  const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
-
-  const options = createMemo(() => ({
-    ...createDefaultOptions<T>("unified"),
-    ...others,
-  }))
-
-  const getRoot = () => {
-    const host = container.querySelector("diffs-container")
-    if (!(host instanceof HTMLElement)) return
-
-    const root = host.shadowRoot
-    if (!root) return
-
-    return root
-  }
-
-  const applyScheme = () => {
-    const host = container.querySelector("diffs-container")
-    if (!(host instanceof HTMLElement)) return
-
-    const scheme = document.documentElement.dataset.colorScheme
-    if (scheme === "dark" || scheme === "light") {
-      host.dataset.colorScheme = scheme
-      return
-    }
-
-    host.removeAttribute("data-color-scheme")
-  }
-
-  const supportsHighlights = () => {
-    const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
-    return typeof g.Highlight === "function" && g.CSS?.highlights != null
-  }
-
-  const clearHighlightFind = () => {
-    const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
-    if (!api) return
-    api.delete("opencode-find")
-    api.delete("opencode-find-current")
-  }
-
-  const clearOverlayScroll = () => {
-    for (const el of findOverlayScroll) el.removeEventListener("scroll", scheduleOverlay)
-    findOverlayScroll = []
-  }
-
-  const clearOverlay = () => {
-    if (findOverlayFrame !== undefined) {
-      cancelAnimationFrame(findOverlayFrame)
-      findOverlayFrame = undefined
-    }
-    findOverlay.innerHTML = ""
-  }
-
-  const renderOverlay = () => {
-    if (findMode !== "overlay") {
-      clearOverlay()
-      return
-    }
-
-    clearOverlay()
-    if (findHits.length === 0) return
-
-    const base = wrapper.getBoundingClientRect()
-    const current = findIndex()
-
-    const frag = document.createDocumentFragment()
-    for (let i = 0; i < findHits.length; i++) {
-      const range = findHits[i]
-      const active = i === current
-
-      for (const rect of Array.from(range.getClientRects())) {
-        if (!rect.width || !rect.height) continue
-
-        const el = document.createElement("div")
-        el.style.position = "absolute"
-        el.style.left = `${Math.round(rect.left - base.left)}px`
-        el.style.top = `${Math.round(rect.top - base.top)}px`
-        el.style.width = `${Math.round(rect.width)}px`
-        el.style.height = `${Math.round(rect.height)}px`
-        el.style.borderRadius = "2px"
-        el.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
-        el.style.opacity = active ? "0.55" : "0.35"
-        if (active) el.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
-        frag.appendChild(el)
-      }
-    }
-
-    findOverlay.appendChild(frag)
-  }
-
-  function scheduleOverlay() {
-    if (findMode !== "overlay") return
-    if (!findOpen()) return
-    if (findOverlayFrame !== undefined) return
-
-    findOverlayFrame = requestAnimationFrame(() => {
-      findOverlayFrame = undefined
-      renderOverlay()
-    })
-  }
-
-  const syncOverlayScroll = () => {
-    if (findMode !== "overlay") return
-    const root = getRoot()
-
-    const next = root
-      ? Array.from(root.querySelectorAll("[data-code]")).filter(
-          (node): node is HTMLElement => node instanceof HTMLElement,
-        )
-      : []
-    if (next.length === findOverlayScroll.length && next.every((el, i) => el === findOverlayScroll[i])) return
-
-    clearOverlayScroll()
-    findOverlayScroll = next
-    for (const el of findOverlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true })
-  }
-
-  const clearFind = () => {
-    clearHighlightFind()
-    clearOverlay()
-    clearOverlayScroll()
-    findHits = []
-    setFindCount(0)
-    setFindIndex(0)
-  }
-
-  const getScrollParent = (el: HTMLElement): HTMLElement | undefined => {
-    let parent = el.parentElement
-    while (parent) {
-      const style = getComputedStyle(parent)
-      if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
-      parent = parent.parentElement
-    }
-  }
-
-  const positionFindBar = () => {
-    if (typeof window === "undefined") return
-
-    const root = getScrollParent(wrapper) ?? wrapper
-    const rect = root.getBoundingClientRect()
-    const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
-    const header = Number.isNaN(title) ? 0 : title
-    setFindPos({
-      top: Math.round(rect.top) + header - 4,
-      right: Math.round(window.innerWidth - rect.right) + 8,
-    })
-  }
-
-  const scanFind = (root: ShadowRoot, query: string) => {
-    const needle = query.toLowerCase()
-    const out: Range[] = []
-
-    const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
-      (node): node is HTMLElement => node instanceof HTMLElement,
-    )
-
-    for (const col of cols) {
-      const text = col.textContent
-      if (!text) continue
-
-      const hay = text.toLowerCase()
-      let idx = hay.indexOf(needle)
-      if (idx === -1) continue
-
-      const nodes: Text[] = []
-      const ends: number[] = []
-      const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
-      let node = walker.nextNode()
-      let pos = 0
-
-      while (node) {
-        if (node instanceof Text) {
-          pos += node.data.length
-          nodes.push(node)
-          ends.push(pos)
-        }
-        node = walker.nextNode()
-      }
-
-      if (nodes.length === 0) continue
-
-      const locate = (at: number) => {
-        let lo = 0
-        let hi = ends.length - 1
-        while (lo < hi) {
-          const mid = (lo + hi) >> 1
-          if (ends[mid] >= at) hi = mid
-          else lo = mid + 1
-        }
-        const prev = lo === 0 ? 0 : ends[lo - 1]
-        return { node: nodes[lo], offset: at - prev }
-      }
-
-      while (idx !== -1) {
-        const start = locate(idx)
-        const end = locate(idx + query.length)
-        const range = document.createRange()
-        range.setStart(start.node, start.offset)
-        range.setEnd(end.node, end.offset)
-        out.push(range)
-        idx = hay.indexOf(needle, idx + query.length)
-      }
-    }
-
-    return out
-  }
-
-  const scrollToRange = (range: Range) => {
-    const start = range.startContainer
-    const el = start instanceof Element ? start : start.parentElement
-    el?.scrollIntoView({ block: "center", inline: "center" })
-  }
-
-  const setHighlights = (ranges: Range[], index: number) => {
-    const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
-    const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
-    if (!api || typeof Highlight !== "function") return false
-
-    api.delete("opencode-find")
-    api.delete("opencode-find-current")
-
-    const active = ranges[index]
-    if (active) api.set("opencode-find-current", new Highlight(active))
-
-    const rest = ranges.filter((_, i) => i !== index)
-    if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
-    return true
-  }
-
-  const applyFind = (opts?: { reset?: boolean; scroll?: boolean }) => {
-    if (!findOpen()) return
-
-    const query = findQuery().trim()
-    if (!query) {
-      clearFind()
-      return
-    }
-
-    const root = getRoot()
-    if (!root) return
-
-    findMode = supportsHighlights() ? "highlights" : "overlay"
-
-    const ranges = scanFind(root, query)
-    const total = ranges.length
-    const desired = opts?.reset ? 0 : findIndex()
-    const index = total ? Math.min(desired, total - 1) : 0
-
-    findHits = ranges
-    setFindCount(total)
-    setFindIndex(index)
-
-    const active = ranges[index]
-    if (findMode === "highlights") {
-      clearOverlay()
-      clearOverlayScroll()
-      if (!setHighlights(ranges, index)) {
-        findMode = "overlay"
-        clearHighlightFind()
-        syncOverlayScroll()
-        scheduleOverlay()
-      }
-      if (opts?.scroll && active) {
-        scrollToRange(active)
-      }
-      return
-    }
-
-    clearHighlightFind()
-    syncOverlayScroll()
-    if (opts?.scroll && active) {
-      scrollToRange(active)
-    }
-    scheduleOverlay()
-  }
-
-  const closeFind = () => {
-    setFindOpen(false)
-    clearFind()
-    if (findCurrent === host) findCurrent = undefined
-  }
-
-  const stepFind = (dir: 1 | -1) => {
-    if (!findOpen()) return
-    const total = findCount()
-    if (total <= 0) return
-
-    const index = (findIndex() + dir + total) % total
-    setFindIndex(index)
-
-    const active = findHits[index]
-    if (!active) return
-
-    if (findMode === "highlights") {
-      if (!setHighlights(findHits, index)) {
-        findMode = "overlay"
-        applyFind({ reset: true, scroll: true })
-        return
-      }
-      scrollToRange(active)
-      return
-    }
-
-    clearHighlightFind()
-    syncOverlayScroll()
-    scrollToRange(active)
-    scheduleOverlay()
-  }
-
-  const host: FindHost = {
-    element: () => wrapper,
-    isOpen: () => findOpen(),
-    next: stepFind,
-    open: () => {
-      if (findCurrent && findCurrent !== host) findCurrent.close()
-      findCurrent = host
-      findTarget = host
-
-      if (!findOpen()) setFindOpen(true)
-      requestAnimationFrame(() => {
-        applyFind({ scroll: true })
-        findInput?.focus()
-        findInput?.select()
-      })
-    },
-    close: closeFind,
-  }
-
-  onMount(() => {
-    findMode = supportsHighlights() ? "highlights" : "overlay"
-    installFindShortcuts()
-    findHosts.add(host)
-    if (!findTarget) findTarget = host
-
-    onCleanup(() => {
-      findHosts.delete(host)
-      if (findCurrent === host) {
-        findCurrent = undefined
-        clearHighlightFind()
-      }
-      if (findTarget === host) findTarget = undefined
-    })
-  })
-
-  createEffect(() => {
-    if (!findOpen()) return
-
-    const update = () => positionFindBar()
-    requestAnimationFrame(update)
-    window.addEventListener("resize", update, { passive: true })
-
-    const root = getScrollParent(wrapper) ?? wrapper
-    const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update())
-    observer?.observe(root)
-
-    onCleanup(() => {
-      window.removeEventListener("resize", update)
-      observer?.disconnect()
-    })
-  })
-
-  const applyCommentedLines = (ranges: SelectedLineRange[]) => {
-    const root = getRoot()
-    if (!root) return
-
-    const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
-    for (const node of existing) {
-      if (!(node instanceof HTMLElement)) continue
-      node.removeAttribute("data-comment-selected")
-    }
-
-    const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
-      (node): node is HTMLElement => node instanceof HTMLElement,
-    )
-
-    for (const range of ranges) {
-      const start = Math.max(1, Math.min(range.start, range.end))
-      const end = Math.max(range.start, range.end)
-
-      for (let line = start; line <= end; line++) {
-        const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
-        for (const node of nodes) {
-          if (!(node instanceof HTMLElement)) continue
-          node.setAttribute("data-comment-selected", "")
-        }
-      }
-
-      for (const annotation of annotations) {
-        const line = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
-        if (Number.isNaN(line)) continue
-        if (line < start || line > end) continue
-        annotation.setAttribute("data-comment-selected", "")
-      }
-    }
-  }
-
-  const text = () => {
-    const value = local.file.contents as unknown
-    if (typeof value === "string") return value
-    if (Array.isArray(value)) return value.join("\n")
-    if (value == null) return ""
-    return String(value)
-  }
-
-  const lineCount = () => {
-    const value = text()
-    const total = value.split("\n").length - (value.endsWith("\n") ? 1 : 0)
-    return Math.max(1, total)
-  }
-
-  const applySelection = (range: SelectedLineRange | null) => {
-    const current = instance
-    if (!current) return false
-
-    if (virtual()) {
-      current.setSelectedLines(range)
-      return true
-    }
-
-    const root = getRoot()
-    if (!root) return false
-
-    const lines = lineCount()
-    if (root.querySelectorAll("[data-line]").length < lines) return false
-
-    if (!range) {
-      current.setSelectedLines(null)
-      return true
-    }
-
-    const start = Math.min(range.start, range.end)
-    const end = Math.max(range.start, range.end)
-
-    if (start < 1 || end > lines) {
-      current.setSelectedLines(null)
-      return true
-    }
-
-    if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
-      current.setSelectedLines(null)
-      return true
-    }
-
-    const normalized = (() => {
-      if (range.endSide != null) return { start: range.start, end: range.end }
-      if (range.side !== "deletions") return range
-      if (root.querySelector("[data-deletions]") != null) return range
-      return { start: range.start, end: range.end }
-    })()
-
-    current.setSelectedLines(normalized)
-    return true
-  }
-
-  const notifyRendered = () => {
-    observer?.disconnect()
-    observer = undefined
-    renderToken++
-
-    const token = renderToken
-
-    const lines = virtual() ? undefined : lineCount()
-
-    const isReady = (root: ShadowRoot) =>
-      virtual()
-        ? root.querySelector("[data-line]") != null
-        : root.querySelectorAll("[data-line]").length >= (lines ?? 0)
-
-    const notify = () => {
-      if (token !== renderToken) return
-
-      observer?.disconnect()
-      observer = undefined
-      requestAnimationFrame(() => {
-        if (token !== renderToken) return
-        applySelection(lastSelection)
-        applyFind({ reset: true })
-        local.onRendered?.()
-      })
-    }
-
-    const root = getRoot()
-    if (root && isReady(root)) {
-      notify()
-      return
-    }
-
-    if (typeof MutationObserver === "undefined") return
-
-    const observeRoot = (root: ShadowRoot) => {
-      if (isReady(root)) {
-        notify()
-        return
-      }
-
-      observer?.disconnect()
-      observer = new MutationObserver(() => {
-        if (token !== renderToken) return
-        if (!isReady(root)) return
-
-        notify()
-      })
-
-      observer.observe(root, { childList: true, subtree: true })
-    }
-
-    if (root) {
-      observeRoot(root)
-      return
-    }
-
-    observer = new MutationObserver(() => {
-      if (token !== renderToken) return
-
-      const root = getRoot()
-      if (!root) return
-
-      observeRoot(root)
-    })
-
-    observer.observe(container, { childList: true, subtree: true })
-  }
-
-  const updateSelection = () => {
-    const root = getRoot()
-    if (!root) return
-
-    const selection =
-      (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
-    if (!selection || selection.isCollapsed) return
-
-    const domRange =
-      (
-        selection as unknown as {
-          getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
-        }
-      ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
-      (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
-
-    const startNode = domRange?.startContainer ?? selection.anchorNode
-    const endNode = domRange?.endContainer ?? selection.focusNode
-    if (!startNode || !endNode) return
-
-    if (!root.contains(startNode) || !root.contains(endNode)) return
-
-    const start = findLineNumber(startNode)
-    const end = findLineNumber(endNode)
-    if (start === undefined || end === undefined) return
-
-    const startSide = findSide(startNode)
-    const endSide = findSide(endNode)
-    const side = startSide ?? endSide
-
-    const selected: SelectedLineRange = {
-      start,
-      end,
-    }
-
-    if (side) selected.side = side
-    if (endSide && side && endSide !== side) selected.endSide = endSide
-
-    setSelectedLines(selected)
-  }
-
-  const setSelectedLines = (range: SelectedLineRange | null) => {
-    lastSelection = range
-    applySelection(range)
-  }
-
-  const scheduleSelectionUpdate = () => {
-    if (selectionFrame !== undefined) return
-
-    selectionFrame = requestAnimationFrame(() => {
-      selectionFrame = undefined
-      updateSelection()
-
-      if (!pendingSelectionEnd) return
-      pendingSelectionEnd = false
-      props.onLineSelectionEnd?.(lastSelection)
-    })
-  }
-
-  const updateDragSelection = () => {
-    if (dragStart === undefined || dragEnd === undefined) return
-
-    const start = Math.min(dragStart, dragEnd)
-    const end = Math.max(dragStart, dragEnd)
-
-    setSelectedLines({ start, end })
-  }
-
-  const scheduleDragUpdate = () => {
-    if (dragFrame !== undefined) return
-
-    dragFrame = requestAnimationFrame(() => {
-      dragFrame = undefined
-      updateDragSelection()
-    })
-  }
-
-  const lineFromMouseEvent = (event: MouseEvent) => {
-    const path = event.composedPath()
-
-    let numberColumn = false
-    let line: number | undefined
-
-    for (const item of path) {
-      if (!(item instanceof HTMLElement)) continue
-
-      numberColumn = numberColumn || item.dataset.columnNumber != null
-
-      if (line === undefined && item.dataset.line) {
-        const parsed = parseInt(item.dataset.line, 10)
-        if (!Number.isNaN(parsed)) line = parsed
-      }
-
-      if (numberColumn && line !== undefined) break
-    }
-
-    return { line, numberColumn }
-  }
-
-  const handleMouseDown = (event: MouseEvent) => {
-    if (props.enableLineSelection !== true) return
-    if (event.button !== 0) return
-
-    const { line, numberColumn } = lineFromMouseEvent(event)
-    if (numberColumn) return
-    if (line === undefined) return
-
-    dragStart = line
-    dragEnd = line
-    dragMoved = false
-  }
-
-  const handleMouseMove = (event: MouseEvent) => {
-    if (props.enableLineSelection !== true) return
-    if (dragStart === undefined) return
-
-    if ((event.buttons & 1) === 0) {
-      dragStart = undefined
-      dragEnd = undefined
-      dragMoved = false
-      return
-    }
-
-    const { line } = lineFromMouseEvent(event)
-    if (line === undefined) return
-
-    dragEnd = line
-    dragMoved = true
-    scheduleDragUpdate()
-  }
-
-  const handleMouseUp = () => {
-    if (props.enableLineSelection !== true) return
-    if (dragStart === undefined) return
-
-    if (!dragMoved) {
-      pendingSelectionEnd = false
-      const line = dragStart
-      setSelectedLines({ start: line, end: line })
-      props.onLineSelectionEnd?.(lastSelection)
-      dragStart = undefined
-      dragEnd = undefined
-      dragMoved = false
-      return
-    }
-
-    pendingSelectionEnd = true
-    scheduleDragUpdate()
-    scheduleSelectionUpdate()
-
-    dragStart = undefined
-    dragEnd = undefined
-    dragMoved = false
-  }
-
-  const handleSelectionChange = () => {
-    if (props.enableLineSelection !== true) return
-    if (dragStart === undefined) return
-
-    const selection = window.getSelection()
-    if (!selection || selection.isCollapsed) return
-
-    scheduleSelectionUpdate()
-  }
-
-  createEffect(() => {
-    const opts = options()
-    const workerPool = getWorkerPool("unified")
-    const isVirtual = virtual()
-
-    observer?.disconnect()
-    observer = undefined
-
-    instance?.cleanUp()
-    instance = undefined
-
-    if (!isVirtual && virtualizer) {
-      virtualizer.cleanUp()
-      virtualizer = undefined
-      virtualRoot = undefined
-    }
-
-    const v = (() => {
-      if (!isVirtual) return
-      if (typeof document === "undefined") return
-
-      const root = getScrollParent(wrapper) ?? document
-      if (virtualizer && virtualRoot === root) return virtualizer
-
-      virtualizer?.cleanUp()
-      virtualizer = new Virtualizer()
-      virtualRoot = root
-      virtualizer.setup(root, root instanceof Document ? undefined : wrapper)
-      return virtualizer
-    })()
-
-    instance = isVirtual && v ? new VirtualizedFile<T>(opts, v, codeMetrics, workerPool) : new File<T>(opts, workerPool)
-
-    container.innerHTML = ""
-    const value = text()
-    instance.render({
-      file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value },
-      lineAnnotations: local.annotations,
-      containerWrapper: container,
-    })
-
-    applyScheme()
-
-    setRendered((value) => value + 1)
-    notifyRendered()
-  })
-
-  createEffect(() => {
-    if (typeof document === "undefined") return
-    if (typeof MutationObserver === "undefined") return
-
-    const root = document.documentElement
-    const monitor = new MutationObserver(() => applyScheme())
-    monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
-    applyScheme()
-
-    onCleanup(() => monitor.disconnect())
-  })
-
-  createEffect(() => {
-    rendered()
-    const ranges = local.commentedLines ?? []
-    requestAnimationFrame(() => applyCommentedLines(ranges))
-  })
-
-  createEffect(() => {
-    setSelectedLines(local.selectedLines ?? null)
-  })
-
-  createEffect(() => {
-    if (props.enableLineSelection !== true) return
-
-    container.addEventListener("mousedown", handleMouseDown)
-    container.addEventListener("mousemove", handleMouseMove)
-    window.addEventListener("mouseup", handleMouseUp)
-    document.addEventListener("selectionchange", handleSelectionChange)
-
-    onCleanup(() => {
-      container.removeEventListener("mousedown", handleMouseDown)
-      container.removeEventListener("mousemove", handleMouseMove)
-      window.removeEventListener("mouseup", handleMouseUp)
-      document.removeEventListener("selectionchange", handleSelectionChange)
-    })
-  })
-
-  onCleanup(() => {
-    observer?.disconnect()
-
-    instance?.cleanUp()
-    instance = undefined
-
-    virtualizer?.cleanUp()
-    virtualizer = undefined
-    virtualRoot = undefined
-
-    clearOverlayScroll()
-    clearOverlay()
-    if (findCurrent === host) {
-      findCurrent = undefined
-      clearHighlightFind()
-    }
-
-    if (selectionFrame !== undefined) {
-      cancelAnimationFrame(selectionFrame)
-      selectionFrame = undefined
-    }
-
-    if (dragFrame !== undefined) {
-      cancelAnimationFrame(dragFrame)
-      dragFrame = undefined
-    }
-
-    dragStart = undefined
-    dragEnd = undefined
-    dragMoved = false
-    lastSelection = null
-    pendingSelectionEnd = false
-  })
-
-  const FindBar = (barProps: { class: string; style?: ComponentProps<"div">["style"] }) => (
-    <div class={barProps.class} style={barProps.style} onPointerDown={(e) => e.stopPropagation()}>
-      <Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
-      <input
-        ref={findInput}
-        placeholder="Find"
-        value={findQuery()}
-        class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
-        onInput={(e) => {
-          setFindQuery(e.currentTarget.value)
-          setFindIndex(0)
-          applyFind({ reset: true, scroll: true })
-        }}
-        onKeyDown={(e) => {
-          if (e.key === "Escape") {
-            e.preventDefault()
-            closeFind()
-            return
-          }
-          if (e.key !== "Enter") return
-          e.preventDefault()
-          stepFind(e.shiftKey ? -1 : 1)
-        }}
-      />
-      <div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
-        {findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
-      </div>
-      <div class="flex items-center">
-        <button
-          type="button"
-          class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
-          disabled={findCount() === 0}
-          aria-label="Previous match"
-          onClick={() => stepFind(-1)}
-        >
-          <Icon name="chevron-down" size="small" class="rotate-180" />
-        </button>
-        <button
-          type="button"
-          class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
-          disabled={findCount() === 0}
-          aria-label="Next match"
-          onClick={() => stepFind(1)}
-        >
-          <Icon name="chevron-down" size="small" />
-        </button>
-      </div>
-      <button
-        type="button"
-        class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
-        aria-label="Close search"
-        onClick={closeFind}
-      >
-        <Icon name="close-small" size="small" />
-      </button>
-    </div>
-  )
-
-  return (
-    <div
-      data-component="code"
-      style={styleVariables}
-      class="relative outline-none"
-      classList={{
-        ...(local.classList || {}),
-        [local.class ?? ""]: !!local.class,
-      }}
-      ref={wrapper}
-      tabIndex={0}
-      onPointerDown={() => {
-        findTarget = host
-        wrapper.focus({ preventScroll: true })
-      }}
-      onFocus={() => {
-        findTarget = host
-      }}
-    >
-      <Show when={findOpen()}>
-        <Portal>
-          <FindBar
-            class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
-            style={{
-              top: `${findPos().top}px`,
-              right: `${findPos().right}px`,
-            }}
-          />
-        </Portal>
-      </Show>
-      <div ref={container} />
-      <div ref={findOverlay} class="pointer-events-none absolute inset-0 z-0" />
-    </div>
-  )
-}

+ 0 - 317
packages/ui/src/components/diff-ssr.tsx

@@ -1,317 +0,0 @@
-import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
-import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
-import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
-import { Dynamic, isServer } from "solid-js/web"
-import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
-import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
-import { useWorkerPool } from "../context/worker-pool"
-
-export type SSRDiffProps<T = {}> = DiffProps<T> & {
-  preloadedDiff: PreloadMultiFileDiffResult<T>
-}
-
-export function Diff<T>(props: SSRDiffProps<T>) {
-  let container!: HTMLDivElement
-  let fileDiffRef!: HTMLElement
-  const [local, others] = splitProps(props, [
-    "before",
-    "after",
-    "class",
-    "classList",
-    "annotations",
-    "selectedLines",
-    "commentedLines",
-  ])
-  const workerPool = useWorkerPool(props.diffStyle)
-
-  let fileDiffInstance: FileDiff<T> | undefined
-  let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
-  const cleanupFunctions: Array<() => void> = []
-
-  const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
-
-  const getVirtualizer = () => {
-    if (sharedVirtualizer) return sharedVirtualizer.virtualizer
-
-    const result = acquireVirtualizer(container)
-    if (!result) return
-
-    sharedVirtualizer = result
-    return result.virtualizer
-  }
-
-  const applyScheme = () => {
-    const scheme = document.documentElement.dataset.colorScheme
-    if (scheme === "dark" || scheme === "light") {
-      fileDiffRef.dataset.colorScheme = scheme
-      return
-    }
-
-    fileDiffRef.removeAttribute("data-color-scheme")
-  }
-
-  const lineIndex = (split: boolean, element: HTMLElement) => {
-    const raw = element.dataset.lineIndex
-    if (!raw) return
-    const values = raw
-      .split(",")
-      .map((value) => parseInt(value, 10))
-      .filter((value) => !Number.isNaN(value))
-    if (values.length === 0) return
-    if (!split) return values[0]
-    if (values.length === 2) return values[1]
-    return values[0]
-  }
-
-  const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: "additions" | "deletions" | undefined) => {
-    const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
-      (node): node is HTMLElement => node instanceof HTMLElement,
-    )
-    if (nodes.length === 0) return
-
-    const targetSide = side ?? "additions"
-
-    for (const node of nodes) {
-      if (findSide(node) === targetSide) return lineIndex(split, node)
-      if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
-    }
-  }
-
-  const fixSelection = (range: SelectedLineRange | null) => {
-    if (!range) return range
-    const root = getRoot()
-    if (!root) return
-
-    const diffs = root.querySelector("[data-diff]")
-    if (!(diffs instanceof HTMLElement)) return
-
-    const split = diffs.dataset.diffType === "split"
-
-    const start = rowIndex(root, split, range.start, range.side)
-    const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
-
-    if (start === undefined || end === undefined) {
-      if (root.querySelector("[data-line], [data-alt-line]") == null) return
-      return null
-    }
-    if (start <= end) return range
-
-    const side = range.endSide ?? range.side
-    const swapped: SelectedLineRange = {
-      start: range.end,
-      end: range.start,
-    }
-    if (side) swapped.side = side
-    if (range.endSide && range.side) swapped.endSide = range.side
-
-    return swapped
-  }
-
-  const setSelectedLines = (range: SelectedLineRange | null, attempt = 0) => {
-    const diff = fileDiffInstance
-    if (!diff) return
-
-    const fixed = fixSelection(range)
-    if (fixed === undefined) {
-      if (attempt >= 120) return
-      requestAnimationFrame(() => setSelectedLines(range, attempt + 1))
-      return
-    }
-
-    diff.setSelectedLines(fixed)
-  }
-
-  const findSide = (element: HTMLElement): "additions" | "deletions" => {
-    const line = element.closest("[data-line], [data-alt-line]")
-    if (line instanceof HTMLElement) {
-      const type = line.dataset.lineType
-      if (type === "change-deletion") return "deletions"
-      if (type === "change-addition" || type === "change-additions") return "additions"
-    }
-
-    const code = element.closest("[data-code]")
-    if (!(code instanceof HTMLElement)) return "additions"
-    return code.hasAttribute("data-deletions") ? "deletions" : "additions"
-  }
-
-  const applyCommentedLines = (ranges: SelectedLineRange[]) => {
-    const root = getRoot()
-    if (!root) return
-
-    const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
-    for (const node of existing) {
-      if (!(node instanceof HTMLElement)) continue
-      node.removeAttribute("data-comment-selected")
-    }
-
-    const diffs = root.querySelector("[data-diff]")
-    if (!(diffs instanceof HTMLElement)) return
-
-    const split = diffs.dataset.diffType === "split"
-
-    const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
-      (node): node is HTMLElement => node instanceof HTMLElement,
-    )
-    if (rows.length === 0) return
-
-    const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
-      (node): node is HTMLElement => node instanceof HTMLElement,
-    )
-
-    const lineIndex = (element: HTMLElement) => {
-      const raw = element.dataset.lineIndex
-      if (!raw) return
-      const values = raw
-        .split(",")
-        .map((value) => parseInt(value, 10))
-        .filter((value) => !Number.isNaN(value))
-      if (values.length === 0) return
-      if (!split) return values[0]
-      if (values.length === 2) return values[1]
-      return values[0]
-    }
-
-    const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => {
-      const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
-        (node): node is HTMLElement => node instanceof HTMLElement,
-      )
-      if (nodes.length === 0) return
-
-      const targetSide = side ?? "additions"
-
-      for (const node of nodes) {
-        if (findSide(node) === targetSide) return lineIndex(node)
-        if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node)
-      }
-    }
-
-    for (const range of ranges) {
-      const start = rowIndex(range.start, range.side)
-      if (start === undefined) continue
-
-      const end = (() => {
-        const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
-        if (same) return start
-        return rowIndex(range.end, range.endSide ?? range.side)
-      })()
-      if (end === undefined) continue
-
-      const first = Math.min(start, end)
-      const last = Math.max(start, end)
-
-      for (const row of rows) {
-        const idx = lineIndex(row)
-        if (idx === undefined) continue
-        if (idx < first || idx > last) continue
-        row.setAttribute("data-comment-selected", "")
-      }
-
-      for (const annotation of annotations) {
-        const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
-        if (Number.isNaN(idx)) continue
-        if (idx < first || idx > last) continue
-        annotation.setAttribute("data-comment-selected", "")
-      }
-    }
-  }
-
-  onMount(() => {
-    if (isServer || !props.preloadedDiff) return
-
-    applyScheme()
-
-    if (typeof MutationObserver !== "undefined") {
-      const root = document.documentElement
-      const monitor = new MutationObserver(() => applyScheme())
-      monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
-      onCleanup(() => monitor.disconnect())
-    }
-
-    const virtualizer = getVirtualizer()
-
-    fileDiffInstance = virtualizer
-      ? new VirtualizedFileDiff<T>(
-          {
-            ...createDefaultOptions(props.diffStyle),
-            ...others,
-            ...props.preloadedDiff,
-          },
-          virtualizer,
-          virtualMetrics,
-          workerPool,
-        )
-      : new FileDiff<T>(
-          {
-            ...createDefaultOptions(props.diffStyle),
-            ...others,
-            ...props.preloadedDiff,
-          },
-          workerPool,
-        )
-    // @ts-expect-error - fileContainer is private but needed for SSR hydration
-    fileDiffInstance.fileContainer = fileDiffRef
-    fileDiffInstance.hydrate({
-      oldFile: local.before,
-      newFile: local.after,
-      lineAnnotations: local.annotations,
-      fileContainer: fileDiffRef,
-      containerWrapper: container,
-    })
-
-    setSelectedLines(local.selectedLines ?? null)
-
-    createEffect(() => {
-      fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
-    })
-
-    createEffect(() => {
-      setSelectedLines(local.selectedLines ?? null)
-    })
-
-    createEffect(() => {
-      const ranges = local.commentedLines ?? []
-      requestAnimationFrame(() => applyCommentedLines(ranges))
-    })
-
-    // Hydrate annotation slots with interactive SolidJS components
-    // if (props.annotations.length > 0 && props.renderAnnotation != null) {
-    //   for (const annotation of props.annotations) {
-    //     const slotName = `annotation-${annotation.side}-${annotation.lineNumber}`;
-    //     const slotElement = fileDiffRef.querySelector(
-    //       `[slot="${slotName}"]`
-    //     ) as HTMLElement;
-    //
-    //     if (slotElement != null) {
-    //       // Clear the static server-rendered content from the slot
-    //       slotElement.innerHTML = '';
-    //
-    //       // Mount a fresh SolidJS component into this slot using render().
-    //       // This enables full SolidJS reactivity (signals, effects, etc.)
-    //       const dispose = render(
-    //         () => props.renderAnnotation!(annotation),
-    //         slotElement
-    //       );
-    //       cleanupFunctions.push(dispose);
-    //     }
-    //   }
-    // }
-  })
-
-  onCleanup(() => {
-    // Clean up FileDiff event handlers and dispose SolidJS components
-    fileDiffInstance?.cleanUp()
-    cleanupFunctions.forEach((dispose) => dispose())
-    sharedVirtualizer?.release()
-    sharedVirtualizer = undefined
-  })
-
-  return (
-    <div data-component="diff" style={styleVariables} ref={container}>
-      <Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
-        <Show when={isServer}>
-          <template shadowrootmode="open" innerHTML={props.preloadedDiff.prerenderedHTML} />
-        </Show>
-      </Dynamic>
-    </div>
-  )
-}

+ 0 - 652
packages/ui/src/components/diff.tsx

@@ -1,652 +0,0 @@
-import { sampledChecksum } from "@opencode-ai/util/encode"
-import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
-import { createMediaQuery } from "@solid-primitives/media"
-import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
-import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
-import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
-import { getWorkerPool } from "../pierre/worker"
-
-type SelectionSide = "additions" | "deletions"
-
-function findElement(node: Node | null): HTMLElement | undefined {
-  if (!node) return
-  if (node instanceof HTMLElement) return node
-  return node.parentElement ?? undefined
-}
-
-function findLineNumber(node: Node | null): number | undefined {
-  const element = findElement(node)
-  if (!element) return
-
-  const line = element.closest("[data-line], [data-alt-line]")
-  if (!(line instanceof HTMLElement)) return
-
-  const value = (() => {
-    const primary = parseInt(line.dataset.line ?? "", 10)
-    if (!Number.isNaN(primary)) return primary
-
-    const alt = parseInt(line.dataset.altLine ?? "", 10)
-    if (!Number.isNaN(alt)) return alt
-  })()
-
-  return value
-}
-
-function findSide(node: Node | null): SelectionSide | undefined {
-  const element = findElement(node)
-  if (!element) return
-
-  const line = element.closest("[data-line], [data-alt-line]")
-  if (line instanceof HTMLElement) {
-    const type = line.dataset.lineType
-    if (type === "change-deletion") return "deletions"
-    if (type === "change-addition" || type === "change-additions") return "additions"
-  }
-
-  const code = element.closest("[data-code]")
-  if (!(code instanceof HTMLElement)) return
-
-  if (code.hasAttribute("data-deletions")) return "deletions"
-  return "additions"
-}
-
-export function Diff<T>(props: DiffProps<T>) {
-  let container!: HTMLDivElement
-  let observer: MutationObserver | undefined
-  let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
-  let renderToken = 0
-  let selectionFrame: number | undefined
-  let dragFrame: number | undefined
-  let dragStart: number | undefined
-  let dragEnd: number | undefined
-  let dragSide: SelectionSide | undefined
-  let dragEndSide: SelectionSide | undefined
-  let dragMoved = false
-  let lastSelection: SelectedLineRange | null = null
-  let pendingSelectionEnd = false
-
-  const [local, others] = splitProps(props, [
-    "before",
-    "after",
-    "class",
-    "classList",
-    "annotations",
-    "selectedLines",
-    "commentedLines",
-    "onRendered",
-  ])
-
-  const mobile = createMediaQuery("(max-width: 640px)")
-
-  const large = createMemo(() => {
-    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
-  })
-
-  const largeOptions = {
-    lineDiffType: "none",
-    maxLineDiffLength: 0,
-    tokenizeMaxLineLength: 1,
-  } satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
-
-  const options = createMemo<FileDiffOptions<T>>(() => {
-    const base = {
-      ...createDefaultOptions(props.diffStyle),
-      ...others,
-    }
-
-    const perf = large() ? { ...base, ...largeOptions } : base
-    if (!mobile()) return perf
-
-    return {
-      ...perf,
-      disableLineNumbers: true,
-    }
-  })
-
-  let instance: FileDiff<T> | undefined
-  const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
-  const [rendered, setRendered] = createSignal(0)
-
-  const getVirtualizer = () => {
-    if (sharedVirtualizer) return sharedVirtualizer.virtualizer
-
-    const result = acquireVirtualizer(container)
-    if (!result) return
-
-    sharedVirtualizer = result
-    return result.virtualizer
-  }
-
-  const getRoot = () => {
-    const host = container.querySelector("diffs-container")
-    if (!(host instanceof HTMLElement)) return
-
-    const root = host.shadowRoot
-    if (!root) return
-
-    return root
-  }
-
-  const applyScheme = () => {
-    const host = container.querySelector("diffs-container")
-    if (!(host instanceof HTMLElement)) return
-
-    const scheme = document.documentElement.dataset.colorScheme
-    if (scheme === "dark" || scheme === "light") {
-      host.dataset.colorScheme = scheme
-      return
-    }
-
-    host.removeAttribute("data-color-scheme")
-  }
-
-  const lineIndex = (split: boolean, element: HTMLElement) => {
-    const raw = element.dataset.lineIndex
-    if (!raw) return
-    const values = raw
-      .split(",")
-      .map((value) => parseInt(value, 10))
-      .filter((value) => !Number.isNaN(value))
-    if (values.length === 0) return
-    if (!split) return values[0]
-    if (values.length === 2) return values[1]
-    return values[0]
-  }
-
-  const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: SelectionSide | undefined) => {
-    const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
-      (node): node is HTMLElement => node instanceof HTMLElement,
-    )
-    if (nodes.length === 0) return
-
-    const targetSide = side ?? "additions"
-
-    for (const node of nodes) {
-      if (findSide(node) === targetSide) return lineIndex(split, node)
-      if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
-    }
-  }
-
-  const fixSelection = (range: SelectedLineRange | null) => {
-    if (!range) return range
-    const root = getRoot()
-    if (!root) return
-
-    const diffs = root.querySelector("[data-diff]")
-    if (!(diffs instanceof HTMLElement)) return
-
-    const split = diffs.dataset.diffType === "split"
-
-    const start = rowIndex(root, split, range.start, range.side)
-    const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
-    if (start === undefined || end === undefined) {
-      if (root.querySelector("[data-line], [data-alt-line]") == null) return
-      return null
-    }
-    if (start <= end) return range
-
-    const side = range.endSide ?? range.side
-    const swapped: SelectedLineRange = {
-      start: range.end,
-      end: range.start,
-    }
-
-    if (side) swapped.side = side
-    if (range.endSide && range.side) swapped.endSide = range.side
-
-    return swapped
-  }
-
-  const notifyRendered = () => {
-    observer?.disconnect()
-    observer = undefined
-    renderToken++
-
-    const token = renderToken
-    let settle = 0
-
-    const isReady = (root: ShadowRoot) => root.querySelector("[data-line]") != null
-
-    const notify = () => {
-      if (token !== renderToken) return
-
-      observer?.disconnect()
-      observer = undefined
-      requestAnimationFrame(() => {
-        if (token !== renderToken) return
-        setSelectedLines(lastSelection)
-        local.onRendered?.()
-      })
-    }
-
-    const schedule = () => {
-      settle++
-      const current = settle
-
-      requestAnimationFrame(() => {
-        if (token !== renderToken) return
-        if (current !== settle) return
-
-        requestAnimationFrame(() => {
-          if (token !== renderToken) return
-          if (current !== settle) return
-
-          notify()
-        })
-      })
-    }
-
-    const observeRoot = (root: ShadowRoot) => {
-      observer?.disconnect()
-      observer = new MutationObserver(() => {
-        if (token !== renderToken) return
-        if (!isReady(root)) return
-
-        schedule()
-      })
-
-      observer.observe(root, { childList: true, subtree: true })
-
-      if (!isReady(root)) return
-      schedule()
-    }
-
-    const root = getRoot()
-    if (typeof MutationObserver === "undefined") {
-      if (!root || !isReady(root)) return
-      setSelectedLines(lastSelection)
-      local.onRendered?.()
-      return
-    }
-
-    if (root) {
-      observeRoot(root)
-      return
-    }
-
-    observer = new MutationObserver(() => {
-      if (token !== renderToken) return
-
-      const root = getRoot()
-      if (!root) return
-
-      observeRoot(root)
-    })
-
-    observer.observe(container, { childList: true, subtree: true })
-  }
-
-  const applyCommentedLines = (ranges: SelectedLineRange[]) => {
-    const root = getRoot()
-    if (!root) return
-
-    const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
-    for (const node of existing) {
-      if (!(node instanceof HTMLElement)) continue
-      node.removeAttribute("data-comment-selected")
-    }
-
-    const diffs = root.querySelector("[data-diff]")
-    if (!(diffs instanceof HTMLElement)) return
-
-    const split = diffs.dataset.diffType === "split"
-
-    const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
-      (node): node is HTMLElement => node instanceof HTMLElement,
-    )
-    if (rows.length === 0) return
-
-    const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
-      (node): node is HTMLElement => node instanceof HTMLElement,
-    )
-
-    for (const range of ranges) {
-      const start = rowIndex(root, split, range.start, range.side)
-      if (start === undefined) continue
-
-      const end = (() => {
-        const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
-        if (same) return start
-        return rowIndex(root, split, range.end, range.endSide ?? range.side)
-      })()
-      if (end === undefined) continue
-
-      const first = Math.min(start, end)
-      const last = Math.max(start, end)
-
-      for (const row of rows) {
-        const idx = lineIndex(split, row)
-        if (idx === undefined) continue
-        if (idx < first || idx > last) continue
-        row.setAttribute("data-comment-selected", "")
-      }
-
-      for (const annotation of annotations) {
-        const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
-        if (Number.isNaN(idx)) continue
-        if (idx < first || idx > last) continue
-        annotation.setAttribute("data-comment-selected", "")
-      }
-    }
-  }
-
-  const setSelectedLines = (range: SelectedLineRange | null) => {
-    const active = current()
-    if (!active) return
-
-    const fixed = fixSelection(range)
-    if (fixed === undefined) {
-      lastSelection = range
-      return
-    }
-
-    lastSelection = fixed
-    active.setSelectedLines(fixed)
-  }
-
-  const updateSelection = () => {
-    const root = getRoot()
-    if (!root) return
-
-    const selection =
-      (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
-    if (!selection || selection.isCollapsed) return
-
-    const domRange =
-      (
-        selection as unknown as {
-          getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
-        }
-      ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
-      (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
-
-    const startNode = domRange?.startContainer ?? selection.anchorNode
-    const endNode = domRange?.endContainer ?? selection.focusNode
-    if (!startNode || !endNode) return
-
-    if (!root.contains(startNode) || !root.contains(endNode)) return
-
-    const start = findLineNumber(startNode)
-    const end = findLineNumber(endNode)
-    if (start === undefined || end === undefined) return
-
-    const startSide = findSide(startNode)
-    const endSide = findSide(endNode)
-    const side = startSide ?? endSide
-
-    const selected: SelectedLineRange = {
-      start,
-      end,
-    }
-
-    if (side) selected.side = side
-    if (endSide && side && endSide !== side) selected.endSide = endSide
-
-    setSelectedLines(selected)
-  }
-
-  const scheduleSelectionUpdate = () => {
-    if (selectionFrame !== undefined) return
-
-    selectionFrame = requestAnimationFrame(() => {
-      selectionFrame = undefined
-      updateSelection()
-
-      if (!pendingSelectionEnd) return
-      pendingSelectionEnd = false
-      props.onLineSelectionEnd?.(lastSelection)
-    })
-  }
-
-  const updateDragSelection = () => {
-    if (dragStart === undefined || dragEnd === undefined) return
-
-    const selected: SelectedLineRange = {
-      start: dragStart,
-      end: dragEnd,
-    }
-
-    if (dragSide) selected.side = dragSide
-    if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
-
-    setSelectedLines(selected)
-  }
-
-  const scheduleDragUpdate = () => {
-    if (dragFrame !== undefined) return
-
-    dragFrame = requestAnimationFrame(() => {
-      dragFrame = undefined
-      updateDragSelection()
-    })
-  }
-
-  const lineFromMouseEvent = (event: MouseEvent) => {
-    const path = event.composedPath()
-
-    let numberColumn = false
-    let line: number | undefined
-    let side: SelectionSide | undefined
-
-    for (const item of path) {
-      if (!(item instanceof HTMLElement)) continue
-
-      numberColumn = numberColumn || item.dataset.columnNumber != null
-
-      if (side === undefined) {
-        const type = item.dataset.lineType
-        if (type === "change-deletion") side = "deletions"
-        if (type === "change-addition" || type === "change-additions") side = "additions"
-      }
-
-      if (side === undefined && item.dataset.code != null) {
-        side = item.hasAttribute("data-deletions") ? "deletions" : "additions"
-      }
-
-      if (line === undefined) {
-        const primary = item.dataset.line ? parseInt(item.dataset.line, 10) : Number.NaN
-        if (!Number.isNaN(primary)) {
-          line = primary
-        } else {
-          const alt = item.dataset.altLine ? parseInt(item.dataset.altLine, 10) : Number.NaN
-          if (!Number.isNaN(alt)) line = alt
-        }
-      }
-
-      if (numberColumn && line !== undefined && side !== undefined) break
-    }
-
-    return { line, numberColumn, side }
-  }
-
-  const handleMouseDown = (event: MouseEvent) => {
-    if (props.enableLineSelection !== true) return
-    if (event.button !== 0) return
-
-    const { line, numberColumn, side } = lineFromMouseEvent(event)
-    if (numberColumn) return
-    if (line === undefined) return
-
-    dragStart = line
-    dragEnd = line
-    dragSide = side
-    dragEndSide = side
-    dragMoved = false
-  }
-
-  const handleMouseMove = (event: MouseEvent) => {
-    if (props.enableLineSelection !== true) return
-    if (dragStart === undefined) return
-
-    if ((event.buttons & 1) === 0) {
-      dragStart = undefined
-      dragEnd = undefined
-      dragSide = undefined
-      dragEndSide = undefined
-      dragMoved = false
-      return
-    }
-
-    const { line, side } = lineFromMouseEvent(event)
-    if (line === undefined) return
-
-    dragEnd = line
-    dragEndSide = side
-    dragMoved = true
-    scheduleDragUpdate()
-  }
-
-  const handleMouseUp = () => {
-    if (props.enableLineSelection !== true) return
-    if (dragStart === undefined) return
-
-    if (!dragMoved) {
-      pendingSelectionEnd = false
-      const line = dragStart
-      const selected: SelectedLineRange = {
-        start: line,
-        end: line,
-      }
-      if (dragSide) selected.side = dragSide
-      setSelectedLines(selected)
-      props.onLineSelectionEnd?.(lastSelection)
-      dragStart = undefined
-      dragEnd = undefined
-      dragSide = undefined
-      dragEndSide = undefined
-      dragMoved = false
-      return
-    }
-
-    pendingSelectionEnd = true
-    scheduleDragUpdate()
-    scheduleSelectionUpdate()
-
-    dragStart = undefined
-    dragEnd = undefined
-    dragSide = undefined
-    dragEndSide = undefined
-    dragMoved = false
-  }
-
-  const handleSelectionChange = () => {
-    if (props.enableLineSelection !== true) return
-    if (dragStart === undefined) return
-
-    const selection = window.getSelection()
-    if (!selection || selection.isCollapsed) return
-
-    scheduleSelectionUpdate()
-  }
-
-  createEffect(() => {
-    const opts = options()
-    const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
-    const virtualizer = getVirtualizer()
-    const annotations = local.annotations
-    const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
-    const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
-
-    const cacheKey = (contents: string) => {
-      if (!large()) return sampledChecksum(contents, contents.length)
-      return sampledChecksum(contents)
-    }
-
-    instance?.cleanUp()
-    instance = virtualizer
-      ? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
-      : new FileDiff<T>(opts, workerPool)
-    setCurrent(instance)
-
-    container.innerHTML = ""
-    instance.render({
-      oldFile: {
-        ...local.before,
-        contents: beforeContents,
-        cacheKey: cacheKey(beforeContents),
-      },
-      newFile: {
-        ...local.after,
-        contents: afterContents,
-        cacheKey: cacheKey(afterContents),
-      },
-      lineAnnotations: annotations,
-      containerWrapper: container,
-    })
-
-    applyScheme()
-
-    setRendered((value) => value + 1)
-    notifyRendered()
-  })
-
-  createEffect(() => {
-    if (typeof document === "undefined") return
-    if (typeof MutationObserver === "undefined") return
-
-    const root = document.documentElement
-    const monitor = new MutationObserver(() => applyScheme())
-    monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
-    applyScheme()
-
-    onCleanup(() => monitor.disconnect())
-  })
-
-  createEffect(() => {
-    rendered()
-    const ranges = local.commentedLines ?? []
-    requestAnimationFrame(() => applyCommentedLines(ranges))
-  })
-
-  createEffect(() => {
-    const selected = local.selectedLines ?? null
-    setSelectedLines(selected)
-  })
-
-  createEffect(() => {
-    if (props.enableLineSelection !== true) return
-
-    container.addEventListener("mousedown", handleMouseDown)
-    container.addEventListener("mousemove", handleMouseMove)
-    window.addEventListener("mouseup", handleMouseUp)
-    document.addEventListener("selectionchange", handleSelectionChange)
-
-    onCleanup(() => {
-      container.removeEventListener("mousedown", handleMouseDown)
-      container.removeEventListener("mousemove", handleMouseMove)
-      window.removeEventListener("mouseup", handleMouseUp)
-      document.removeEventListener("selectionchange", handleSelectionChange)
-    })
-  })
-
-  onCleanup(() => {
-    observer?.disconnect()
-
-    if (selectionFrame !== undefined) {
-      cancelAnimationFrame(selectionFrame)
-      selectionFrame = undefined
-    }
-
-    if (dragFrame !== undefined) {
-      cancelAnimationFrame(dragFrame)
-      dragFrame = undefined
-    }
-
-    dragStart = undefined
-    dragEnd = undefined
-    dragSide = undefined
-    dragEndSide = undefined
-    dragMoved = false
-    lastSelection = null
-    pendingSelectionEnd = false
-
-    instance?.cleanUp()
-    setCurrent(undefined)
-    sharedVirtualizer?.release()
-    sharedVirtualizer = undefined
-  })
-
-  return <div data-component="diff" style={styleVariables} ref={container} />
-}

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

@@ -0,0 +1,265 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js"
+import { useI18n } from "../context/i18n"
+import {
+  dataUrlFromMediaValue,
+  hasMediaValue,
+  isBinaryContent,
+  mediaKindFromPath,
+  normalizeMimeType,
+  svgTextFromValue,
+} from "../pierre/media"
+
+export type FileMediaOptions = {
+  mode?: "auto" | "off"
+  path?: string
+  current?: unknown
+  before?: unknown
+  after?: unknown
+  readFile?: (path: string) => Promise<FileContent | undefined>
+  onLoad?: () => void
+  onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
+}
+
+function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") {
+  if (cfg.current !== undefined) return cfg.current
+  if (mode === "image") return cfg.after ?? cfg.before
+  return cfg.after ?? cfg.before
+}
+
+export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) {
+  const i18n = useI18n()
+  const cfg = () => props.media
+  const kind = createMemo(() => {
+    const media = cfg()
+    if (!media || media.mode === "off") return
+    return mediaKindFromPath(media.path)
+  })
+
+  const isBinary = createMemo(() => {
+    const media = cfg()
+    if (!media || media.mode === "off") return false
+    if (kind()) return false
+    return isBinaryContent(media.current as any)
+  })
+
+  const onLoad = () => props.media?.onLoad?.()
+
+  const deleted = createMemo(() => {
+    const media = cfg()
+    const k = kind()
+    if (!media || !k) return false
+    if (k === "svg") return false
+    if (media.current !== undefined) return false
+    return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)
+  })
+
+  const direct = createMemo(() => {
+    const media = cfg()
+    const k = kind()
+    if (!media || (k !== "image" && k !== "audio")) return
+    return dataUrlFromMediaValue(mediaValue(media, k), k)
+  })
+
+  const request = createMemo(() => {
+    const media = cfg()
+    const k = kind()
+    if (!media || (k !== "image" && k !== "audio")) return
+    if (media.current !== undefined) return
+    if (deleted()) return
+    if (direct()) return
+    if (!media.path || !media.readFile) return
+
+    return {
+      key: `${k}:${media.path}`,
+      kind: k,
+      path: media.path,
+      readFile: media.readFile,
+      onError: media.onError,
+    }
+  })
+
+  const [loaded] = createResource(request, async (input) => {
+    return input.readFile(input.path).then(
+      (result) => {
+        const src = dataUrlFromMediaValue(result as any, input.kind)
+        if (!src) {
+          input.onError?.({ kind: input.kind })
+          return { key: input.key, error: true as const }
+        }
+
+        return {
+          key: input.key,
+          src,
+          mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined,
+        }
+      },
+      () => {
+        input.onError?.({ kind: input.kind })
+        return { key: input.key, error: true as const }
+      },
+    )
+  })
+
+  const remote = createMemo(() => {
+    const input = request()
+    const value = loaded()
+    if (!input || !value || value.key !== input.key) return
+    return value
+  })
+
+  const src = createMemo(() => {
+    const value = remote()
+    return direct() ?? (value && "src" in value ? value.src : undefined)
+  })
+  const status = createMemo(() => {
+    if (direct()) return "ready" as const
+    if (!request()) return "idle" as const
+    if (loaded.loading) return "loading" as const
+    if (remote()?.error) return "error" as const
+    if (src()) return "ready" as const
+    return "idle" as const
+  })
+  const audioMime = createMemo(() => {
+    const value = remote()
+    return value && "mime" in value ? value.mime : undefined
+  })
+
+  const svgSource = createMemo(() => {
+    const media = cfg()
+    if (!media || kind() !== "svg") return
+    return svgTextFromValue(media.current as any)
+  })
+  const svgSrc = createMemo(() => {
+    const media = cfg()
+    if (!media || kind() !== "svg") return
+    return dataUrlFromMediaValue(media.current as any, "svg")
+  })
+  const svgInvalid = createMemo(() => {
+    const media = cfg()
+    if (!media || kind() !== "svg") return
+    if (svgSource() !== undefined) return
+    if (!hasMediaValue(media.current as any)) return
+    return [media.path, media.current] as const
+  })
+
+  createEffect(
+    on(
+      svgInvalid,
+      (value) => {
+        if (!value) return
+        cfg()?.onError?.({ kind: "svg" })
+      },
+      { defer: true },
+    ),
+  )
+
+  const kindLabel = (value: "image" | "audio") =>
+    i18n.t(value === "image" ? "ui.fileMedia.kind.image" : "ui.fileMedia.kind.audio")
+
+  return (
+    <Switch>
+      <Match when={kind() === "image" || kind() === "audio"}>
+        <Show
+          when={src()}
+          fallback={(() => {
+            const media = cfg()
+            const k = kind()
+            if (!media || (k !== "image" && k !== "audio")) return props.fallback()
+            const label = kindLabel(k)
+
+            if (deleted()) {
+              return (
+                <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
+                  {i18n.t("ui.fileMedia.state.removed", { kind: label })}
+                </div>
+              )
+            }
+            if (status() === "loading") {
+              return (
+                <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
+                  {i18n.t("ui.fileMedia.state.loading", { kind: label })}
+                </div>
+              )
+            }
+            if (status() === "error") {
+              return (
+                <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
+                  {i18n.t("ui.fileMedia.state.error", { kind: label })}
+                </div>
+              )
+            }
+            return (
+              <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
+                {i18n.t("ui.fileMedia.state.unavailable", { kind: label })}
+              </div>
+            )
+          })()}
+        >
+          {(value) => {
+            const k = kind()
+            if (k !== "image" && k !== "audio") return props.fallback()
+            if (k === "image") {
+              return (
+                <div class="flex justify-center bg-background-stronger px-6 py-4">
+                  <img
+                    src={value()}
+                    alt={cfg()?.path}
+                    class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
+                    onLoad={onLoad}
+                  />
+                </div>
+              )
+            }
+
+            return (
+              <div class="flex justify-center bg-background-stronger px-6 py-4">
+                <audio class="w-full max-w-xl" controls preload="metadata" onLoadedMetadata={onLoad}>
+                  <source src={value()} type={audioMime()} />
+                </audio>
+              </div>
+            )
+          }}
+        </Show>
+      </Match>
+      <Match when={kind() === "svg"}>
+        {(() => {
+          if (svgSource() === undefined && svgSrc() == null) return props.fallback()
+
+          return (
+            <div class="flex flex-col gap-4 px-6 py-4">
+              <Show when={svgSource() !== undefined}>{props.fallback()}</Show>
+              <Show when={svgSrc()}>
+                {(value) => (
+                  <div class="flex justify-center">
+                    <img
+                      src={value()}
+                      alt={cfg()?.path}
+                      class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
+                      onLoad={onLoad}
+                    />
+                  </div>
+                )}
+              </Show>
+            </div>
+          )
+        })()}
+      </Match>
+      <Match when={isBinary()}>
+        <div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 py-10 text-center">
+          <div class="text-14-semibold text-text-strong">
+            {cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")}
+          </div>
+          <div class="text-14-regular text-text-weak">
+            {(() => {
+              const path = cfg()?.path
+              if (!path) return i18n.t("ui.fileMedia.binary.description.default")
+              return i18n.t("ui.fileMedia.binary.description.path", { path })
+            })()}
+          </div>
+        </div>
+      </Match>
+      <Match when={true}>{props.fallback()}</Match>
+    </Switch>
+  )
+}

+ 69 - 0
packages/ui/src/components/file-search.tsx

@@ -0,0 +1,69 @@
+import { Portal } from "solid-js/web"
+import { Icon } from "./icon"
+
+export function FileSearchBar(props: {
+  pos: () => { top: number; right: number }
+  query: () => string
+  index: () => number
+  count: () => number
+  setInput: (el: HTMLInputElement) => void
+  onInput: (value: string) => void
+  onKeyDown: (event: KeyboardEvent) => void
+  onClose: () => void
+  onPrev: () => void
+  onNext: () => void
+}) {
+  return (
+    <Portal>
+      <div
+        class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
+        style={{
+          top: `${props.pos().top}px`,
+          right: `${props.pos().right}px`,
+        }}
+        onPointerDown={(e) => e.stopPropagation()}
+      >
+        <Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
+        <input
+          ref={props.setInput}
+          placeholder="Find"
+          value={props.query()}
+          class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
+          onInput={(e) => props.onInput(e.currentTarget.value)}
+          onKeyDown={(e) => props.onKeyDown(e as KeyboardEvent)}
+        />
+        <div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
+          {props.count() ? `${props.index() + 1}/${props.count()}` : "0/0"}
+        </div>
+        <div class="flex items-center">
+          <button
+            type="button"
+            class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
+            disabled={props.count() === 0}
+            aria-label="Previous match"
+            onClick={props.onPrev}
+          >
+            <Icon name="chevron-down" size="small" class="rotate-180" />
+          </button>
+          <button
+            type="button"
+            class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
+            disabled={props.count() === 0}
+            aria-label="Next match"
+            onClick={props.onNext}
+          >
+            <Icon name="chevron-down" size="small" />
+          </button>
+        </div>
+        <button
+          type="button"
+          class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
+          aria-label="Close search"
+          onClick={props.onClose}
+        >
+          <Icon name="close-small" size="small" />
+        </button>
+      </div>
+    </Portal>
+  )
+}

+ 178 - 0
packages/ui/src/components/file-ssr.tsx

@@ -0,0 +1,178 @@
+import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
+import { 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"
+import { createDefaultOptions, styleVariables } from "../pierre"
+import { markCommentedDiffLines } from "../pierre/commented-lines"
+import { fixDiffSelection } from "../pierre/diff-selection"
+import {
+  applyViewerScheme,
+  clearReadyWatcher,
+  createReadyWatcher,
+  notifyShadowReady,
+  observeViewerScheme,
+} from "../pierre/file-runtime"
+import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
+import { File, type DiffFileProps, type FileProps } from "./file"
+
+type SSRDiffFileProps<T> = DiffFileProps<T> & {
+  preloadedDiff: PreloadMultiFileDiffResult<T>
+}
+
+function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
+  let container!: HTMLDivElement
+  let fileDiffRef!: HTMLElement
+  let fileDiffInstance: FileDiff<T> | undefined
+  let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
+
+  const ready = createReadyWatcher()
+  const workerPool = useWorkerPool(props.diffStyle)
+
+  const [local, others] = splitProps(props, [
+    "mode",
+    "media",
+    "before",
+    "after",
+    "class",
+    "classList",
+    "annotations",
+    "selectedLines",
+    "commentedLines",
+    "onLineSelected",
+    "onLineSelectionEnd",
+    "onLineNumberSelectionEnd",
+    "onRendered",
+    "preloadedDiff",
+  ])
+
+  const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
+
+  const getVirtualizer = () => {
+    if (sharedVirtualizer) return sharedVirtualizer.virtualizer
+    const result = acquireVirtualizer(container)
+    if (!result) return
+    sharedVirtualizer = result
+    return result.virtualizer
+  }
+
+  const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
+    const diff = fileDiffInstance
+    if (!diff) return
+
+    const fixed = fixDiffSelection(getRoot(), range ?? null)
+    if (fixed === undefined) {
+      if (attempt >= 120) return
+      requestAnimationFrame(() => setSelectedLines(range ?? null, attempt + 1))
+      return
+    }
+
+    diff.setSelectedLines(fixed)
+  }
+
+  const notifyRendered = () => {
+    notifyShadowReady({
+      state: ready,
+      container,
+      getRoot,
+      isReady: (root) => root.querySelector("[data-line]") != null,
+      settleFrames: 1,
+      onReady: () => {
+        setSelectedLines(local.selectedLines ?? null)
+        local.onRendered?.()
+      },
+    })
+  }
+
+  onMount(() => {
+    if (isServer) return
+
+    onCleanup(observeViewerScheme(() => fileDiffRef))
+
+    const virtualizer = getVirtualizer()
+    fileDiffInstance = virtualizer
+      ? new VirtualizedFileDiff<T>(
+          {
+            ...createDefaultOptions(props.diffStyle),
+            ...others,
+            ...local.preloadedDiff,
+          },
+          virtualizer,
+          virtualMetrics,
+          workerPool,
+        )
+      : new FileDiff<T>(
+          {
+            ...createDefaultOptions(props.diffStyle),
+            ...others,
+            ...local.preloadedDiff,
+          },
+          workerPool,
+        )
+
+    applyViewerScheme(fileDiffRef)
+
+    // @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,
+    })
+
+    notifyRendered()
+  })
+
+  createEffect(() => {
+    const diff = fileDiffInstance
+    if (!diff) return
+    diff.setLineAnnotations(local.annotations ?? [])
+    diff.rerender()
+  })
+
+  createEffect(() => {
+    setSelectedLines(local.selectedLines ?? null)
+  })
+
+  createEffect(() => {
+    const ranges = local.commentedLines ?? []
+    requestAnimationFrame(() => {
+      const root = getRoot()
+      if (!root) return
+      markCommentedDiffLines(root, ranges)
+    })
+  })
+
+  onCleanup(() => {
+    clearReadyWatcher(ready)
+    fileDiffInstance?.cleanUp()
+    sharedVirtualizer?.release()
+    sharedVirtualizer = undefined
+  })
+
+  return (
+    <div
+      data-component="file"
+      data-mode="diff"
+      style={styleVariables}
+      class={local.class}
+      classList={local.classList}
+      ref={container}
+    >
+      <Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
+        <Show when={isServer}>
+          <template shadowrootmode="open" innerHTML={local.preloadedDiff.prerenderedHTML} />
+        </Show>
+      </Dynamic>
+    </div>
+  )
+}
+
+export type FileSSRProps<T = {}> = FileProps<T>
+
+export function FileSSR<T>(props: FileSSRProps<T>) {
+  if (props.mode !== "diff" || !props.preloadedDiff) return File(props)
+  return DiffSSRViewer(props as SSRDiffFileProps<T>)
+}

+ 8 - 1
packages/ui/src/components/diff.css → packages/ui/src/components/file.css

@@ -1,6 +1,12 @@
-[data-component="diff"] {
+[data-component="file"] {
   content-visibility: auto;
+}
+
+[data-component="file"][data-mode="text"] {
+  overflow: hidden;
+}
 
+[data-component="file"][data-mode="diff"] {
   [data-slot="diff-hunk-separator-line-number"] {
     position: sticky;
     left: 0;
@@ -17,6 +23,7 @@
       color: var(--icon-strong-base);
     }
   }
+
   [data-slot="diff-hunk-separator-content"] {
     position: sticky;
     background-color: var(--surface-diff-hidden-base);

+ 1176 - 0
packages/ui/src/components/file.tsx

@@ -0,0 +1,1176 @@
+import { sampledChecksum } from "@opencode-ai/util/encode"
+import {
+  DEFAULT_VIRTUAL_FILE_METRICS,
+  type ExpansionDirections,
+  type DiffLineAnnotation,
+  type FileContents,
+  type FileDiffMetadata,
+  File as PierreFile,
+  type FileDiffOptions,
+  FileDiff,
+  type FileOptions,
+  type LineAnnotation,
+  type SelectedLineRange,
+  type VirtualFileMetrics,
+  VirtualizedFile,
+  VirtualizedFileDiff,
+  Virtualizer,
+} from "@pierre/diffs"
+import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
+import { createMediaQuery } from "@solid-primitives/media"
+import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
+import { createDefaultOptions, styleVariables } from "../pierre"
+import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
+import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection"
+import { createFileFind, type FileFindReveal } from "../pierre/file-find"
+import {
+  applyViewerScheme,
+  clearReadyWatcher,
+  createReadyWatcher,
+  getViewerHost,
+  getViewerRoot,
+  notifyShadowReady,
+  observeViewerScheme,
+} from "../pierre/file-runtime"
+import {
+  findCodeSelectionSide,
+  findDiffLineNumber,
+  findElement,
+  findFileLineNumber,
+  readShadowLineSelection,
+} from "../pierre/file-selection"
+import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
+import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
+import { getWorkerPool } from "../pierre/worker"
+import { FileMedia, type FileMediaOptions } from "./file-media"
+import { FileSearchBar } from "./file-search"
+
+const VIRTUALIZE_BYTES = 500_000
+
+const codeMetrics = {
+  ...DEFAULT_VIRTUAL_FILE_METRICS,
+  lineHeight: 24,
+  fileGap: 0,
+} satisfies Partial<VirtualFileMetrics>
+
+type SharedProps<T> = {
+  annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
+  selectedLines?: SelectedLineRange | null
+  commentedLines?: SelectedLineRange[]
+  onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
+  onRendered?: () => void
+  class?: string
+  classList?: ComponentProps<"div">["classList"]
+  media?: FileMediaOptions
+  search?: FileSearchControl
+}
+
+export type FileSearchReveal = FileFindReveal
+
+export type FileSearchHandle = {
+  focus: () => void
+  setQuery: (value: string) => void
+  clear: () => void
+  reveal: (hit: FileSearchReveal) => boolean
+  expand: (hit: FileSearchReveal) => boolean
+  refresh: () => void
+}
+
+export type FileSearchControl = {
+  shortcuts?: "global" | "disabled"
+  showBar?: boolean
+  disableVirtualization?: boolean
+  register: (handle: FileSearchHandle | null) => void
+}
+
+export type TextFileProps<T = {}> = FileOptions<T> &
+  SharedProps<T> & {
+    mode: "text"
+    file: FileContents
+    annotations?: LineAnnotation<T>[]
+    preloadedDiff?: PreloadMultiFileDiffResult<T>
+  }
+
+export type DiffFileProps<T = {}> = FileDiffOptions<T> &
+  SharedProps<T> & {
+    mode: "diff"
+    before: FileContents
+    after: FileContents
+    annotations?: DiffLineAnnotation<T>[]
+    preloadedDiff?: PreloadMultiFileDiffResult<T>
+  }
+
+export type FileProps<T = {}> = TextFileProps<T> | DiffFileProps<T>
+
+const sharedKeys = [
+  "mode",
+  "media",
+  "class",
+  "classList",
+  "annotations",
+  "selectedLines",
+  "commentedLines",
+  "search",
+  "onLineSelected",
+  "onLineSelectionEnd",
+  "onLineNumberSelectionEnd",
+  "onRendered",
+  "preloadedDiff",
+] as const
+
+const textKeys = ["file", ...sharedKeys] as const
+const diffKeys = ["before", "after", ...sharedKeys] as const
+
+function expansionForHit(diff: FileDiffMetadata, hit: FileSearchReveal) {
+  if (diff.isPartial || diff.hunks.length === 0) return
+
+  const side =
+    hit.side === "deletions"
+      ? {
+          start: (hunk: FileDiffMetadata["hunks"][number]) => hunk.deletionStart,
+          count: (hunk: FileDiffMetadata["hunks"][number]) => hunk.deletionCount,
+        }
+      : {
+          start: (hunk: FileDiffMetadata["hunks"][number]) => hunk.additionStart,
+          count: (hunk: FileDiffMetadata["hunks"][number]) => hunk.additionCount,
+        }
+
+  for (let i = 0; i < diff.hunks.length; i++) {
+    const hunk = diff.hunks[i]
+    const start = side.start(hunk)
+    if (hit.line < start) {
+      return {
+        index: i,
+        direction: i === 0 ? "down" : "both",
+      } satisfies { index: number; direction: ExpansionDirections }
+    }
+
+    const end = start + Math.max(side.count(hunk) - 1, -1)
+    if (hit.line <= end) return
+  }
+
+  return {
+    index: diff.hunks.length,
+    direction: "up",
+  } satisfies { index: number; direction: ExpansionDirections }
+}
+
+// ---------------------------------------------------------------------------
+// Shared viewer hook
+// ---------------------------------------------------------------------------
+
+type MouseHit = {
+  line: number | undefined
+  numberColumn: boolean
+  side?: DiffSelectionSide
+}
+
+type ViewerConfig = {
+  enableLineSelection: () => boolean
+  search: () => FileSearchControl | undefined
+  selectedLines: () => SelectedLineRange | null | undefined
+  commentedLines: () => SelectedLineRange[]
+  onLineSelectionEnd: (range: SelectedLineRange | null) => void
+
+  // mode-specific callbacks
+  lineFromMouseEvent: (event: MouseEvent) => MouseHit
+  setSelectedLines: (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => void
+  updateSelection: (preserveTextSelection: boolean) => void
+  buildDragSelection: () => SelectedLineRange | undefined
+  buildClickSelection: () => SelectedLineRange | undefined
+  onDragStart: (hit: MouseHit) => void
+  onDragMove: (hit: MouseHit) => void
+  onDragReset: () => void
+  markCommented: (root: ShadowRoot, ranges: SelectedLineRange[]) => void
+}
+
+function useFileViewer(config: ViewerConfig) {
+  let wrapper!: HTMLDivElement
+  let container!: HTMLDivElement
+  let overlay!: HTMLDivElement
+  let selectionFrame: number | undefined
+  let dragFrame: number | undefined
+  let dragStart: number | undefined
+  let dragEnd: number | undefined
+  let dragMoved = false
+  let lastSelection: SelectedLineRange | null = null
+  let pendingSelectionEnd = false
+
+  const ready = createReadyWatcher()
+  const bridge = createLineNumberSelectionBridge()
+  const [rendered, setRendered] = createSignal(0)
+
+  const getRoot = () => getViewerRoot(container)
+  const getHost = () => getViewerHost(container)
+
+  const find = createFileFind({
+    wrapper: () => wrapper,
+    overlay: () => overlay,
+    getRoot,
+    shortcuts: config.search()?.shortcuts,
+  })
+
+  // -- selection scheduling --
+
+  const scheduleSelectionUpdate = () => {
+    if (selectionFrame !== undefined) return
+    selectionFrame = requestAnimationFrame(() => {
+      selectionFrame = undefined
+      const finishing = pendingSelectionEnd
+      config.updateSelection(finishing)
+      if (!pendingSelectionEnd) return
+      pendingSelectionEnd = false
+      config.onLineSelectionEnd(lastSelection)
+    })
+  }
+
+  const scheduleDragUpdate = () => {
+    if (dragFrame !== undefined) return
+    dragFrame = requestAnimationFrame(() => {
+      dragFrame = undefined
+      const selected = config.buildDragSelection()
+      if (selected) config.setSelectedLines(selected)
+    })
+  }
+
+  // -- mouse handlers --
+
+  const handleMouseDown = (event: MouseEvent) => {
+    if (!config.enableLineSelection()) return
+    if (event.button !== 0) return
+
+    const hit = config.lineFromMouseEvent(event)
+    if (hit.numberColumn) {
+      bridge.begin(true, hit.line)
+      return
+    }
+    if (hit.line === undefined) return
+
+    bridge.begin(false, hit.line)
+    dragStart = hit.line
+    dragEnd = hit.line
+    dragMoved = false
+    config.onDragStart(hit)
+  }
+
+  const handleMouseMove = (event: MouseEvent) => {
+    if (!config.enableLineSelection()) return
+
+    const hit = config.lineFromMouseEvent(event)
+    if (bridge.track(event.buttons, hit.line)) return
+    if (dragStart === undefined) return
+
+    if ((event.buttons & 1) === 0) {
+      dragStart = undefined
+      dragEnd = undefined
+      dragMoved = false
+      config.onDragReset()
+      bridge.finish()
+      return
+    }
+
+    if (hit.line === undefined) return
+    dragEnd = hit.line
+    dragMoved = true
+    config.onDragMove(hit)
+    scheduleDragUpdate()
+  }
+
+  const handleMouseUp = () => {
+    if (!config.enableLineSelection()) return
+    if (bridge.finish() === "numbers") return
+    if (dragStart === undefined) return
+
+    if (!dragMoved) {
+      pendingSelectionEnd = false
+      const selected = config.buildClickSelection()
+      if (selected) config.setSelectedLines(selected)
+      config.onLineSelectionEnd(lastSelection)
+      dragStart = undefined
+      dragEnd = undefined
+      dragMoved = false
+      config.onDragReset()
+      return
+    }
+
+    pendingSelectionEnd = true
+    scheduleDragUpdate()
+    scheduleSelectionUpdate()
+
+    dragStart = undefined
+    dragEnd = undefined
+    dragMoved = false
+    config.onDragReset()
+  }
+
+  const handleSelectionChange = () => {
+    if (!config.enableLineSelection()) return
+    if (dragStart === undefined) return
+    const selection = window.getSelection()
+    if (!selection || selection.isCollapsed) return
+    scheduleSelectionUpdate()
+  }
+
+  // -- shared effects --
+
+  onMount(() => {
+    onCleanup(observeViewerScheme(getHost))
+  })
+
+  createEffect(() => {
+    rendered()
+    const ranges = config.commentedLines()
+    requestAnimationFrame(() => {
+      const root = getRoot()
+      if (!root) return
+      config.markCommented(root, ranges)
+    })
+  })
+
+  createEffect(() => {
+    config.setSelectedLines(config.selectedLines() ?? null)
+  })
+
+  createEffect(() => {
+    if (!config.enableLineSelection()) return
+
+    container.addEventListener("mousedown", handleMouseDown)
+    container.addEventListener("mousemove", handleMouseMove)
+    window.addEventListener("mouseup", handleMouseUp)
+    document.addEventListener("selectionchange", handleSelectionChange)
+
+    onCleanup(() => {
+      container.removeEventListener("mousedown", handleMouseDown)
+      container.removeEventListener("mousemove", handleMouseMove)
+      window.removeEventListener("mouseup", handleMouseUp)
+      document.removeEventListener("selectionchange", handleSelectionChange)
+    })
+  })
+
+  onCleanup(() => {
+    clearReadyWatcher(ready)
+
+    if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame)
+    if (dragFrame !== undefined) cancelAnimationFrame(dragFrame)
+
+    selectionFrame = undefined
+    dragFrame = undefined
+    dragStart = undefined
+    dragEnd = undefined
+    dragMoved = false
+    bridge.reset()
+    lastSelection = null
+    pendingSelectionEnd = false
+  })
+
+  return {
+    get wrapper() {
+      return wrapper
+    },
+    set wrapper(v: HTMLDivElement) {
+      wrapper = v
+    },
+    get container() {
+      return container
+    },
+    set container(v: HTMLDivElement) {
+      container = v
+    },
+    get overlay() {
+      return overlay
+    },
+    set overlay(v: HTMLDivElement) {
+      overlay = v
+    },
+    get dragStart() {
+      return dragStart
+    },
+    get dragEnd() {
+      return dragEnd
+    },
+    get lastSelection() {
+      return lastSelection
+    },
+    set lastSelection(v: SelectedLineRange | null) {
+      lastSelection = v
+    },
+    ready,
+    bridge,
+    rendered,
+    setRendered,
+    getRoot,
+    getHost,
+    find,
+    scheduleSelectionUpdate,
+  }
+}
+
+type Viewer = ReturnType<typeof useFileViewer>
+
+type ModeAdapter = Omit<
+  ViewerConfig,
+  "enableLineSelection" | "search" | "selectedLines" | "commentedLines" | "onLineSelectionEnd"
+>
+
+type ModeConfig = {
+  enableLineSelection: () => boolean
+  search: () => FileSearchControl | undefined
+  selectedLines: () => SelectedLineRange | null | undefined
+  commentedLines: () => SelectedLineRange[] | undefined
+  onLineSelectionEnd: (range: SelectedLineRange | null) => void
+}
+
+type RenderTarget = {
+  cleanUp: () => void
+}
+
+type AnnotationTarget<A> = {
+  setLineAnnotations: (annotations: A[]) => void
+  rerender: () => void
+}
+
+type VirtualStrategy = {
+  get: () => Virtualizer | undefined
+  cleanup: () => void
+}
+
+function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
+  return useFileViewer({
+    enableLineSelection: config.enableLineSelection,
+    search: config.search,
+    selectedLines: config.selectedLines,
+    commentedLines: () => config.commentedLines() ?? [],
+    onLineSelectionEnd: config.onLineSelectionEnd,
+    ...adapter,
+  })
+}
+
+function useSearchHandle(opts: {
+  search: () => FileSearchControl | undefined
+  find: ReturnType<typeof createFileFind>
+  expand?: (hit: FileSearchReveal) => boolean
+}) {
+  createEffect(() => {
+    const search = opts.search()
+    if (!search) return
+
+    const handle = {
+      focus: () => {
+        opts.find.focus()
+      },
+      setQuery: (value: string) => {
+        opts.find.activate()
+        opts.find.setQuery(value, { scroll: false })
+      },
+      clear: () => {
+        opts.find.clear()
+      },
+      reveal: (hit: FileSearchReveal) => {
+        opts.find.activate()
+        return opts.find.reveal(hit)
+      },
+      expand: (hit: FileSearchReveal) => opts.expand?.(hit) ?? false,
+      refresh: () => {
+        opts.find.activate()
+        opts.find.refresh()
+      },
+    } satisfies FileSearchHandle
+
+    search.register(handle)
+    onCleanup(() => search.register(null))
+  })
+}
+
+function createLineCallbacks(opts: {
+  viewer: Viewer
+  normalize?: (range: SelectedLineRange | null) => SelectedLineRange | null | undefined
+  onLineSelected?: (range: SelectedLineRange | null) => void
+  onLineSelectionEnd?: (range: SelectedLineRange | null) => void
+  onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
+}) {
+  const select = (range: SelectedLineRange | null) => {
+    if (!opts.normalize) return range
+    const next = opts.normalize(range)
+    if (next !== undefined) return next
+    return range
+  }
+
+  return {
+    onLineSelected: (range: SelectedLineRange | null) => {
+      const next = select(range)
+      opts.viewer.lastSelection = next
+      opts.onLineSelected?.(next)
+    },
+    onLineSelectionEnd: (range: SelectedLineRange | null) => {
+      const next = select(range)
+      opts.viewer.lastSelection = next
+      opts.onLineSelectionEnd?.(next)
+      if (!opts.viewer.bridge.consume(next)) return
+      requestAnimationFrame(() => opts.onLineNumberSelectionEnd?.(next))
+    },
+  }
+}
+
+function useAnnotationRerender<A>(opts: {
+  viewer: Viewer
+  current: () => AnnotationTarget<A> | undefined
+  annotations: () => A[]
+}) {
+  createEffect(() => {
+    opts.viewer.rendered()
+    const active = opts.current()
+    if (!active) return
+    active.setLineAnnotations(opts.annotations())
+    active.rerender()
+    requestAnimationFrame(() => opts.viewer.find.refresh({ reset: true }))
+  })
+}
+
+function notifyRendered(opts: {
+  viewer: Viewer
+  isReady: (root: ShadowRoot) => boolean
+  settleFrames?: number
+  onReady: () => void
+}) {
+  notifyShadowReady({
+    state: opts.viewer.ready,
+    container: opts.viewer.container,
+    getRoot: opts.viewer.getRoot,
+    isReady: opts.isReady,
+    settleFrames: opts.settleFrames,
+    onReady: opts.onReady,
+  })
+}
+
+function renderViewer<I extends RenderTarget>(opts: {
+  viewer: Viewer
+  current: I | undefined
+  create: () => I
+  assign: (value: I) => void
+  draw: (value: I) => void
+  onReady: () => void
+}) {
+  clearReadyWatcher(opts.viewer.ready)
+  opts.current?.cleanUp()
+  const next = opts.create()
+  opts.assign(next)
+
+  opts.viewer.container.innerHTML = ""
+  opts.draw(next)
+
+  applyViewerScheme(opts.viewer.getHost())
+  opts.viewer.setRendered((value) => value + 1)
+  opts.onReady()
+}
+
+function scrollParent(el: HTMLElement): HTMLElement | undefined {
+  let parent = el.parentElement
+  while (parent) {
+    const style = getComputedStyle(parent)
+    if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
+    parent = parent.parentElement
+  }
+}
+
+function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
+  let virtualizer: Virtualizer | undefined
+  let root: Document | HTMLElement | undefined
+
+  const release = () => {
+    virtualizer?.cleanUp()
+    virtualizer = undefined
+    root = undefined
+  }
+
+  return {
+    get: () => {
+      if (!enabled()) {
+        release()
+        return
+      }
+      if (typeof document === "undefined") return
+
+      const wrapper = host()
+      if (!wrapper) return
+
+      const next = scrollParent(wrapper) ?? document
+      if (virtualizer && root === next) return virtualizer
+
+      release()
+      virtualizer = new Virtualizer()
+      root = next
+      virtualizer.setup(next, next instanceof Document ? undefined : wrapper)
+      return virtualizer
+    },
+    cleanup: release,
+  }
+}
+
+function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
+  let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
+
+  const release = () => {
+    shared?.release()
+    shared = undefined
+  }
+
+  return {
+    get: () => {
+      if (!enabled()) {
+        release()
+        return
+      }
+      if (shared) return shared.virtualizer
+
+      const container = host()
+      if (!container) return
+
+      const result = acquireVirtualizer(container)
+      if (!result) return
+      shared = result
+      return result.virtualizer
+    },
+    cleanup: release,
+  }
+}
+
+function parseLine(node: HTMLElement) {
+  if (!node.dataset.line) return
+  const value = parseInt(node.dataset.line, 10)
+  if (Number.isNaN(value)) return
+  return value
+}
+
+function mouseHit(
+  event: MouseEvent,
+  line: (node: HTMLElement) => number | undefined,
+  side?: (node: HTMLElement) => DiffSelectionSide | undefined,
+): MouseHit {
+  const path = event.composedPath()
+  let numberColumn = false
+  let value: number | undefined
+  let branch: DiffSelectionSide | undefined
+
+  for (const item of path) {
+    if (!(item instanceof HTMLElement)) continue
+
+    numberColumn = numberColumn || item.dataset.columnNumber != null
+    if (value === undefined) value = line(item)
+    if (branch === undefined && side) branch = side(item)
+
+    if (numberColumn && value !== undefined && (side == null || branch !== undefined)) break
+  }
+
+  return {
+    line: value,
+    numberColumn,
+    side: branch,
+  }
+}
+
+function diffMouseSide(node: HTMLElement) {
+  const type = node.dataset.lineType
+  if (type === "change-deletion") return "deletions" satisfies DiffSelectionSide
+  if (type === "change-addition" || type === "change-additions") return "additions" satisfies DiffSelectionSide
+  if (node.dataset.code == null) return
+  return node.hasAttribute("data-deletions") ? "deletions" : "additions"
+}
+
+function diffSelectionSide(node: Node | null) {
+  const el = findElement(node)
+  if (!el) return
+  return findDiffSide(el)
+}
+
+// ---------------------------------------------------------------------------
+// Shared JSX shell
+// ---------------------------------------------------------------------------
+
+function ViewerShell(props: {
+  mode: "text" | "diff"
+  viewer: ReturnType<typeof useFileViewer>
+  search: FileSearchControl | undefined
+  class: string | undefined
+  classList: ComponentProps<"div">["classList"] | undefined
+}) {
+  return (
+    <div
+      data-component="file"
+      data-mode={props.mode}
+      style={styleVariables}
+      class="relative outline-none"
+      classList={{
+        ...(props.classList || {}),
+        [props.class ?? ""]: !!props.class,
+      }}
+      ref={(el) => (props.viewer.wrapper = el)}
+      tabIndex={0}
+      onPointerDown={props.viewer.find.onPointerDown}
+      onFocus={props.viewer.find.onFocus}
+    >
+      <Show when={(props.search?.showBar ?? true) && props.viewer.find.open()}>
+        <FileSearchBar
+          pos={props.viewer.find.pos}
+          query={props.viewer.find.query}
+          count={props.viewer.find.count}
+          index={props.viewer.find.index}
+          setInput={props.viewer.find.setInput}
+          onInput={props.viewer.find.setQuery}
+          onKeyDown={props.viewer.find.onInputKeyDown}
+          onClose={props.viewer.find.close}
+          onPrev={() => props.viewer.find.next(-1)}
+          onNext={() => props.viewer.find.next(1)}
+        />
+      </Show>
+      <div ref={(el) => (props.viewer.container = el)} />
+      <div ref={(el) => (props.viewer.overlay = el)} class="pointer-events-none absolute inset-0 z-0" />
+    </div>
+  )
+}
+
+// ---------------------------------------------------------------------------
+// TextViewer
+// ---------------------------------------------------------------------------
+
+function TextViewer<T>(props: TextFileProps<T>) {
+  let instance: PierreFile<T> | VirtualizedFile<T> | undefined
+  let viewer!: Viewer
+
+  const [local, others] = splitProps(props, textKeys)
+
+  const text = () => {
+    const value = local.file.contents as unknown
+    if (typeof value === "string") return value
+    if (Array.isArray(value)) return value.join("\n")
+    if (value == null) return ""
+    return String(value)
+  }
+
+  const lineCount = () => {
+    const value = text()
+    const total = value.split("\n").length - (value.endsWith("\n") ? 1 : 0)
+    return Math.max(1, total)
+  }
+
+  const bytes = createMemo(() => {
+    const value = local.file.contents as unknown
+    if (typeof value === "string") return value.length
+    if (Array.isArray(value)) {
+      return value.reduce(
+        (sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
+        0,
+      )
+    }
+    if (value == null) return 0
+    return String(value).length
+  })
+
+  const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
+
+  const virtuals = createLocalVirtualStrategy(() => viewer.wrapper, virtual)
+
+  const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine)
+
+  const applySelection = (range: SelectedLineRange | null) => {
+    const current = instance
+    if (!current) return false
+
+    if (virtual()) {
+      current.setSelectedLines(range)
+      return true
+    }
+
+    const root = viewer.getRoot()
+    if (!root) return false
+
+    const total = lineCount()
+    if (root.querySelectorAll("[data-line]").length < total) return false
+
+    if (!range) {
+      current.setSelectedLines(null)
+      return true
+    }
+
+    const start = Math.min(range.start, range.end)
+    const end = Math.max(range.start, range.end)
+    if (start < 1 || end > total) {
+      current.setSelectedLines(null)
+      return true
+    }
+
+    if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
+      current.setSelectedLines(null)
+      return true
+    }
+
+    const normalized = (() => {
+      if (range.endSide != null) return { start: range.start, end: range.end }
+      if (range.side !== "deletions") return range
+      if (root.querySelector("[data-deletions]") != null) return range
+      return { start: range.start, end: range.end }
+    })()
+
+    current.setSelectedLines(normalized)
+    return true
+  }
+
+  const setSelectedLines = (range: SelectedLineRange | null) => {
+    viewer.lastSelection = range
+    applySelection(range)
+  }
+
+  const adapter: ModeAdapter = {
+    lineFromMouseEvent,
+    setSelectedLines,
+    updateSelection: (preserveTextSelection) => {
+      const root = viewer.getRoot()
+      if (!root) return
+
+      const selected = readShadowLineSelection({
+        root,
+        lineForNode: findFileLineNumber,
+        sideForNode: findCodeSelectionSide,
+        preserveTextSelection,
+      })
+      if (!selected) return
+
+      setSelectedLines(selected.range)
+      if (!preserveTextSelection || !selected.text) return
+      restoreShadowTextSelection(root, selected.text)
+    },
+    buildDragSelection: () => {
+      if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
+      return { start: Math.min(viewer.dragStart, viewer.dragEnd), end: Math.max(viewer.dragStart, viewer.dragEnd) }
+    },
+    buildClickSelection: () => {
+      if (viewer.dragStart === undefined) return
+      return { start: viewer.dragStart, end: viewer.dragStart }
+    },
+    onDragStart: () => {},
+    onDragMove: () => {},
+    onDragReset: () => {},
+    markCommented: markCommentedFileLines,
+  }
+
+  viewer = useModeViewer(
+    {
+      enableLineSelection: () => props.enableLineSelection === true,
+      search: () => local.search,
+      selectedLines: () => local.selectedLines,
+      commentedLines: () => local.commentedLines,
+      onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
+    },
+    adapter,
+  )
+
+  const lineCallbacks = createLineCallbacks({
+    viewer,
+    onLineSelected: (range) => local.onLineSelected?.(range),
+    onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
+    onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
+  })
+
+  const options = createMemo(() => ({
+    ...createDefaultOptions<T>("unified"),
+    ...others,
+    ...lineCallbacks,
+  }))
+
+  const notify = () => {
+    notifyRendered({
+      viewer,
+      isReady: (root) => {
+        if (virtual()) return root.querySelector("[data-line]") != null
+        return root.querySelectorAll("[data-line]").length >= lineCount()
+      },
+      onReady: () => {
+        applySelection(viewer.lastSelection)
+        viewer.find.refresh({ reset: true })
+        local.onRendered?.()
+      },
+    })
+  }
+
+  useSearchHandle({
+    search: () => local.search,
+    find: viewer.find,
+  })
+
+  // -- render instance --
+
+  createEffect(() => {
+    const opts = options()
+    const workerPool = getWorkerPool("unified")
+    const isVirtual = virtual()
+
+    const virtualizer = virtuals.get()
+
+    renderViewer({
+      viewer,
+      current: instance,
+      create: () =>
+        isVirtual && virtualizer
+          ? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
+          : new PierreFile<T>(opts, workerPool),
+      assign: (value) => {
+        instance = value
+      },
+      draw: (value) => {
+        const contents = text()
+        value.render({
+          file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents },
+          lineAnnotations: [],
+          containerWrapper: viewer.container,
+        })
+      },
+      onReady: notify,
+    })
+  })
+
+  useAnnotationRerender<LineAnnotation<T>>({
+    viewer,
+    current: () => instance,
+    annotations: () => (local.annotations as LineAnnotation<T>[] | undefined) ?? [],
+  })
+
+  // -- cleanup --
+
+  onCleanup(() => {
+    instance?.cleanUp()
+    instance = undefined
+    virtuals.cleanup()
+  })
+
+  return (
+    <ViewerShell mode="text" viewer={viewer} search={local.search} class={local.class} classList={local.classList} />
+  )
+}
+
+// ---------------------------------------------------------------------------
+// DiffViewer
+// ---------------------------------------------------------------------------
+
+function DiffViewer<T>(props: DiffFileProps<T>) {
+  let instance: FileDiff<T> | undefined
+  let dragSide: DiffSelectionSide | undefined
+  let dragEndSide: DiffSelectionSide | undefined
+  let viewer!: Viewer
+
+  const [local, others] = splitProps(props, diffKeys)
+
+  const mobile = createMediaQuery("(max-width: 640px)")
+
+  const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, findDiffLineNumber, diffMouseSide)
+
+  const setSelectedLines = (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => {
+    const active = instance
+    if (!active) return
+
+    const fixed = fixDiffSelection(viewer.getRoot(), range)
+    if (fixed === undefined) {
+      viewer.lastSelection = range
+      return
+    }
+
+    viewer.lastSelection = fixed
+    active.setSelectedLines(fixed)
+    restoreShadowTextSelection(preserve?.root, preserve?.text)
+  }
+
+  const adapter: ModeAdapter = {
+    lineFromMouseEvent,
+    setSelectedLines,
+    updateSelection: (preserveTextSelection) => {
+      const root = viewer.getRoot()
+      if (!root) return
+
+      const selected = readShadowLineSelection({
+        root,
+        lineForNode: findDiffLineNumber,
+        sideForNode: diffSelectionSide,
+        preserveTextSelection,
+      })
+      if (!selected) return
+
+      if (selected.text) {
+        setSelectedLines(selected.range, { root, text: selected.text })
+        return
+      }
+
+      setSelectedLines(selected.range)
+    },
+    buildDragSelection: () => {
+      if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
+      const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragEnd }
+      if (dragSide) selected.side = dragSide
+      if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
+      return selected
+    },
+    buildClickSelection: () => {
+      if (viewer.dragStart === undefined) return
+      const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragStart }
+      if (dragSide) selected.side = dragSide
+      return selected
+    },
+    onDragStart: (hit) => {
+      dragSide = hit.side
+      dragEndSide = hit.side
+    },
+    onDragMove: (hit) => {
+      dragEndSide = hit.side
+    },
+    onDragReset: () => {
+      dragSide = undefined
+      dragEndSide = undefined
+    },
+    markCommented: markCommentedDiffLines,
+  }
+
+  viewer = useModeViewer(
+    {
+      enableLineSelection: () => props.enableLineSelection === true,
+      search: () => local.search,
+      selectedLines: () => local.selectedLines,
+      commentedLines: () => local.commentedLines,
+      onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
+    },
+    adapter,
+  )
+
+  const virtuals = createSharedVirtualStrategy(
+    () => viewer.container,
+    () => local.search?.disableVirtualization !== true,
+  )
+
+  const large = createMemo(() => {
+    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
+  })
+
+  const largeOptions = {
+    lineDiffType: "none",
+    maxLineDiffLength: 0,
+    tokenizeMaxLineLength: 1,
+  } satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
+
+  const lineCallbacks = createLineCallbacks({
+    viewer,
+    normalize: (range) => fixDiffSelection(viewer.getRoot(), range),
+    onLineSelected: (range) => local.onLineSelected?.(range),
+    onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
+    onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
+  })
+
+  const options = createMemo<FileDiffOptions<T>>(() => {
+    const base = {
+      ...createDefaultOptions(props.diffStyle),
+      ...others,
+      ...lineCallbacks,
+    }
+
+    const perf = large() ? { ...base, ...largeOptions } : base
+    if (!mobile()) return perf
+    return { ...perf, disableLineNumbers: true }
+  })
+
+  const notify = () => {
+    notifyRendered({
+      viewer,
+      isReady: (root) => root.querySelector("[data-line]") != null,
+      settleFrames: 1,
+      onReady: () => {
+        setSelectedLines(viewer.lastSelection)
+        viewer.find.refresh({ reset: true })
+        local.onRendered?.()
+      },
+    })
+  }
+
+  useSearchHandle({
+    search: () => local.search,
+    find: viewer.find,
+    expand: (hit) => {
+      const active = instance as
+        | ((FileDiff<T> | VirtualizedFileDiff<T>) & {
+            fileDiff?: FileDiffMetadata
+          })
+        | undefined
+      if (!active?.fileDiff) return false
+
+      const next = expansionForHit(active.fileDiff, hit)
+      if (!next) return false
+
+      active.expandHunk(next.index, next.direction)
+      return true
+    },
+  })
+
+  // -- render instance --
+
+  createEffect(() => {
+    const opts = options()
+    const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
+    const virtualizer = virtuals.get()
+    const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
+    const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
+
+    const cacheKey = (contents: string) => {
+      if (!large()) return sampledChecksum(contents, contents.length)
+      return sampledChecksum(contents)
+    }
+
+    renderViewer({
+      viewer,
+      current: instance,
+      create: () =>
+        virtualizer
+          ? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
+          : new FileDiff<T>(opts, workerPool),
+      assign: (value) => {
+        instance = value
+      },
+      draw: (value) => {
+        value.render({
+          oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
+          newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },
+          lineAnnotations: [],
+          containerWrapper: viewer.container,
+        })
+      },
+      onReady: notify,
+    })
+  })
+
+  useAnnotationRerender<DiffLineAnnotation<T>>({
+    viewer,
+    current: () => instance,
+    annotations: () => (local.annotations as DiffLineAnnotation<T>[] | undefined) ?? [],
+  })
+
+  // -- cleanup --
+
+  onCleanup(() => {
+    instance?.cleanUp()
+    instance = undefined
+    virtuals.cleanup()
+    dragSide = undefined
+    dragEndSide = undefined
+  })
+
+  return (
+    <ViewerShell mode="diff" viewer={viewer} search={local.search} class={local.class} classList={local.classList} />
+  )
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+export function File<T>(props: FileProps<T>) {
+  if (props.mode === "text") {
+    return <FileMedia media={props.media} fallback={() => TextViewer(props)} />
+  }
+
+  return <FileMedia media={props.media} fallback={() => DiffViewer(props)} />
+}

+ 586 - 0
packages/ui/src/components/line-comment-annotations.tsx

@@ -0,0 +1,586 @@
+import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
+import { createEffect, createMemo, createSignal, onCleanup, Show, type Accessor, type JSX } from "solid-js"
+import { render as renderSolid } from "solid-js/web"
+import { createHoverCommentUtility } from "../pierre/comment-hover"
+import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
+import { LineComment, LineCommentEditor } from "./line-comment"
+
+export type LineCommentAnnotationMeta<T> =
+  | { kind: "comment"; key: string; comment: T }
+  | { kind: "draft"; key: string; range: SelectedLineRange }
+
+export type LineCommentAnnotation<T> = {
+  lineNumber: number
+  side?: "additions" | "deletions"
+  metadata: LineCommentAnnotationMeta<T>
+}
+
+type LineCommentAnnotationsProps<T> = {
+  comments: Accessor<T[]>
+  getCommentId: (comment: T) => string
+  getCommentSelection: (comment: T) => SelectedLineRange
+  draftRange: Accessor<SelectedLineRange | null>
+  draftKey: Accessor<string>
+}
+
+type LineCommentAnnotationsWithSideProps<T> = LineCommentAnnotationsProps<T> & {
+  getSide: (range: SelectedLineRange) => "additions" | "deletions"
+}
+
+type HoverCommentLine = {
+  lineNumber: number
+  side?: "additions" | "deletions"
+}
+
+type LineCommentStateProps<T> = {
+  opened: Accessor<T | null>
+  setOpened: (id: T | null) => void
+  selected: Accessor<SelectedLineRange | null>
+  setSelected: (range: SelectedLineRange | null) => void
+  commenting: Accessor<SelectedLineRange | null>
+  setCommenting: (range: SelectedLineRange | null) => void
+  syncSelected?: (range: SelectedLineRange | null) => void
+  hoverSelected?: (range: SelectedLineRange) => void
+}
+
+type LineCommentShape = {
+  id: string
+  selection: SelectedLineRange
+  comment: string
+}
+
+type LineCommentControllerProps<T extends LineCommentShape> = {
+  comments: Accessor<T[]>
+  draftKey: Accessor<string>
+  label: string
+  state: LineCommentStateProps<string>
+  onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
+  onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
+  onDelete?: (comment: T) => void
+  renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
+  editSubmitLabel?: string
+  onDraftPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
+  getHoverSelectedRange?: Accessor<SelectedLineRange | null>
+  cancelDraftOnCommentToggle?: boolean
+  clearSelectionOnSelectionEndNull?: boolean
+}
+
+type LineCommentControllerWithSideProps<T extends LineCommentShape> = LineCommentControllerProps<T> & {
+  getSide: (range: SelectedLineRange) => "additions" | "deletions"
+}
+
+type CommentProps = {
+  id?: string
+  open: boolean
+  comment: JSX.Element
+  selection: JSX.Element
+  actions?: JSX.Element
+  editor?: DraftProps
+  onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
+  onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
+}
+
+type DraftProps = {
+  value: string
+  selection: JSX.Element
+  onInput: (value: string) => void
+  onCancel: VoidFunction
+  onSubmit: (value: string) => void
+  onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
+  cancelLabel?: string
+  submitLabel?: string
+}
+
+export function createLineCommentAnnotationRenderer<T>(props: {
+  renderComment: (comment: T) => CommentProps
+  renderDraft: (range: SelectedLineRange) => DraftProps
+}) {
+  const nodes = new Map<
+    string,
+    {
+      host: HTMLDivElement
+      dispose: VoidFunction
+      setMeta: (meta: LineCommentAnnotationMeta<T>) => void
+    }
+  >()
+
+  const mount = (meta: LineCommentAnnotationMeta<T>) => {
+    if (typeof document === "undefined") return
+
+    const host = document.createElement("div")
+    host.setAttribute("data-prevent-autofocus", "")
+    const [current, setCurrent] = createSignal(meta)
+
+    const dispose = renderSolid(() => {
+      const active = current()
+      if (active.kind === "comment") {
+        const view = createMemo(() => {
+          const next = current()
+          if (next.kind !== "comment") return props.renderComment(active.comment)
+          return props.renderComment(next.comment)
+        })
+        return (
+          <Show
+            when={view().editor}
+            fallback={
+              <LineComment
+                inline
+                id={view().id}
+                open={view().open}
+                comment={view().comment}
+                selection={view().selection}
+                actions={view().actions}
+                onClick={view().onClick}
+                onMouseEnter={view().onMouseEnter}
+              />
+            }
+          >
+            <LineCommentEditor
+              inline
+              id={view().id}
+              value={view().editor!.value}
+              selection={view().editor!.selection}
+              onInput={view().editor!.onInput}
+              onCancel={view().editor!.onCancel}
+              onSubmit={view().editor!.onSubmit}
+              onPopoverFocusOut={view().editor!.onPopoverFocusOut}
+              cancelLabel={view().editor!.cancelLabel}
+              submitLabel={view().editor!.submitLabel}
+            />
+          </Show>
+        )
+      }
+
+      const view = createMemo(() => {
+        const next = current()
+        if (next.kind !== "draft") return props.renderDraft(active.range)
+        return props.renderDraft(next.range)
+      })
+      return (
+        <LineCommentEditor
+          inline
+          value={view().value}
+          selection={view().selection}
+          onInput={view().onInput}
+          onCancel={view().onCancel}
+          onSubmit={view().onSubmit}
+          onPopoverFocusOut={view().onPopoverFocusOut}
+        />
+      )
+    }, host)
+
+    const node = { host, dispose, setMeta: setCurrent }
+    nodes.set(meta.key, node)
+    return node
+  }
+
+  const render = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotation: A) => {
+    const meta = annotation.metadata
+    const node = nodes.get(meta.key) ?? mount(meta)
+    if (!node) return
+    node.setMeta(meta)
+    return node.host
+  }
+
+  const reconcile = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotations: A[]) => {
+    const next = new Set(annotations.map((annotation) => annotation.metadata.key))
+    for (const [key, node] of nodes) {
+      if (next.has(key)) continue
+      node.dispose()
+      nodes.delete(key)
+    }
+  }
+
+  const cleanup = () => {
+    for (const [, node] of nodes) node.dispose()
+    nodes.clear()
+  }
+
+  return { render, reconcile, cleanup }
+}
+
+export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
+  const [draft, setDraft] = createSignal("")
+  const [editing, setEditing] = createSignal<T | null>(null)
+
+  const toRange = (range: SelectedLineRange | null) => (range ? cloneSelectedLineRange(range) : null)
+  const setSelected = (range: SelectedLineRange | null) => {
+    const next = toRange(range)
+    props.setSelected(next)
+    props.syncSelected?.(toRange(next))
+    return next
+  }
+
+  const setCommenting = (range: SelectedLineRange | null) => {
+    const next = toRange(range)
+    props.setCommenting(next)
+    return next
+  }
+
+  const closeComment = () => {
+    props.setOpened(null)
+  }
+
+  const cancelDraft = () => {
+    setDraft("")
+    setEditing(null)
+    setCommenting(null)
+  }
+
+  const reset = () => {
+    setDraft("")
+    setEditing(null)
+    props.setOpened(null)
+    props.setSelected(null)
+    props.setCommenting(null)
+  }
+
+  const openComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
+    if (options?.cancelDraft) cancelDraft()
+    props.setOpened(id)
+    setSelected(range)
+  }
+
+  const toggleComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
+    if (options?.cancelDraft) cancelDraft()
+    const next = props.opened() === id ? null : id
+    props.setOpened(next)
+    setSelected(range)
+  }
+
+  const openDraft = (range: SelectedLineRange) => {
+    const next = toRange(range)
+    setDraft("")
+    setEditing(null)
+    closeComment()
+    setSelected(next)
+    setCommenting(next)
+  }
+
+  const openEditor = (id: T, range: SelectedLineRange, value: string) => {
+    closeComment()
+    setSelected(range)
+    props.setCommenting(null)
+    setEditing(() => id)
+    setDraft(value)
+  }
+
+  const hoverComment = (range: SelectedLineRange) => {
+    const next = toRange(range)
+    if (!next) return
+    if (props.hoverSelected) {
+      props.hoverSelected(next)
+      return
+    }
+
+    setSelected(next)
+  }
+
+  const finishSelection = (range: SelectedLineRange) => {
+    closeComment()
+    setSelected(range)
+    cancelDraft()
+  }
+
+  createEffect(() => {
+    props.commenting()
+    setDraft("")
+  })
+
+  return {
+    draft,
+    setDraft,
+    editing,
+    opened: props.opened,
+    selected: props.selected,
+    commenting: props.commenting,
+    isOpen: (id: T) => props.opened() === id,
+    isEditing: (id: T) => editing() === id,
+    closeComment,
+    openComment,
+    toggleComment,
+    openDraft,
+    openEditor,
+    hoverComment,
+    cancelDraft,
+    finishSelection,
+    select: setSelected,
+    reset,
+  }
+}
+
+export function createLineCommentController<T extends LineCommentShape>(
+  props: LineCommentControllerWithSideProps<T>,
+): {
+  note: ReturnType<typeof createLineCommentState<string>>
+  annotations: Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
+  renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
+  renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
+  onLineSelected: (range: SelectedLineRange | null) => void
+  onLineSelectionEnd: (range: SelectedLineRange | null) => void
+  onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
+}
+export function createLineCommentController<T extends LineCommentShape>(
+  props: LineCommentControllerProps<T>,
+): {
+  note: ReturnType<typeof createLineCommentState<string>>
+  annotations: Accessor<LineCommentAnnotation<T>[]>
+  renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
+  renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
+  onLineSelected: (range: SelectedLineRange | null) => void
+  onLineSelectionEnd: (range: SelectedLineRange | null) => void
+  onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
+}
+export function createLineCommentController<T extends LineCommentShape>(
+  props: LineCommentControllerProps<T> | LineCommentControllerWithSideProps<T>,
+) {
+  const note = createLineCommentState<string>(props.state)
+
+  const annotations =
+    "getSide" in props
+      ? createLineCommentAnnotations({
+          comments: props.comments,
+          getCommentId: (comment) => comment.id,
+          getCommentSelection: (comment) => comment.selection,
+          draftRange: note.commenting,
+          draftKey: props.draftKey,
+          getSide: props.getSide,
+        })
+      : createLineCommentAnnotations({
+          comments: props.comments,
+          getCommentId: (comment) => comment.id,
+          getCommentSelection: (comment) => comment.selection,
+          draftRange: note.commenting,
+          draftKey: props.draftKey,
+        })
+
+  const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T>({
+    annotations,
+    renderComment: (comment) => {
+      const edit = () => note.openEditor(comment.id, comment.selection, comment.comment)
+      const remove = () => {
+        note.reset()
+        props.onDelete?.(comment)
+      }
+
+      return {
+        id: comment.id,
+        get open() {
+          return note.isOpen(comment.id) || note.isEditing(comment.id)
+        },
+        comment: comment.comment,
+        selection: formatSelectedLineLabel(comment.selection),
+        get actions() {
+          return props.renderCommentActions?.(comment, { edit, remove })
+        },
+        get editor() {
+          return note.isEditing(comment.id)
+            ? {
+                get value() {
+                  return note.draft()
+                },
+                selection: formatSelectedLineLabel(comment.selection),
+                onInput: note.setDraft,
+                onCancel: note.cancelDraft,
+                onSubmit: (value: string) => {
+                  props.onUpdate?.({
+                    id: comment.id,
+                    comment: value,
+                    selection: cloneSelectedLineRange(comment.selection),
+                  })
+                  note.cancelDraft()
+                },
+                submitLabel: props.editSubmitLabel,
+              }
+            : undefined
+        },
+        onMouseEnter: () => note.hoverComment(comment.selection),
+        onClick: () => {
+          if (note.isEditing(comment.id)) return
+          note.toggleComment(comment.id, comment.selection, { cancelDraft: props.cancelDraftOnCommentToggle })
+        },
+      }
+    },
+    renderDraft: (range) => ({
+      get value() {
+        return note.draft()
+      },
+      selection: formatSelectedLineLabel(range),
+      onInput: note.setDraft,
+      onCancel: note.cancelDraft,
+      onSubmit: (comment) => {
+        props.onSubmit({ comment, selection: cloneSelectedLineRange(range) })
+        note.cancelDraft()
+      },
+      onPopoverFocusOut: props.onDraftPopoverFocusOut,
+    }),
+  })
+
+  const renderHoverUtility = createLineCommentHoverRenderer({
+    label: props.label,
+    getSelectedRange: () => {
+      if (note.opened()) return null
+      return props.getHoverSelectedRange?.() ?? note.selected()
+    },
+    onOpenDraft: note.openDraft,
+  })
+
+  const onLineSelected = (range: SelectedLineRange | null) => {
+    if (!range) {
+      note.select(null)
+      note.cancelDraft()
+      return
+    }
+
+    note.select(range)
+  }
+
+  const onLineSelectionEnd = (range: SelectedLineRange | null) => {
+    if (!range) {
+      if (props.clearSelectionOnSelectionEndNull) note.select(null)
+      note.cancelDraft()
+      return
+    }
+
+    note.finishSelection(range)
+  }
+
+  const onLineNumberSelectionEnd = (range: SelectedLineRange | null) => {
+    if (!range) return
+    note.openDraft(range)
+  }
+
+  return {
+    note,
+    annotations,
+    renderAnnotation,
+    renderHoverUtility,
+    onLineSelected,
+    onLineSelectionEnd,
+    onLineNumberSelectionEnd,
+  }
+}
+
+export function createLineCommentAnnotations<T>(
+  props: LineCommentAnnotationsWithSideProps<T>,
+): Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
+export function createLineCommentAnnotations<T>(
+  props: LineCommentAnnotationsProps<T>,
+): Accessor<LineCommentAnnotation<T>[]>
+export function createLineCommentAnnotations<T>(
+  props: LineCommentAnnotationsProps<T> | LineCommentAnnotationsWithSideProps<T>,
+) {
+  const line = (range: SelectedLineRange) => Math.max(range.start, range.end)
+
+  if ("getSide" in props) {
+    return createMemo<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>(() => {
+      const list = props.comments().map((comment) => {
+        const range = props.getCommentSelection(comment)
+        return {
+          side: props.getSide(range),
+          lineNumber: line(range),
+          metadata: {
+            kind: "comment",
+            key: `comment:${props.getCommentId(comment)}`,
+            comment,
+          } satisfies LineCommentAnnotationMeta<T>,
+        }
+      })
+
+      const range = props.draftRange()
+      if (!range) return list
+
+      return [
+        ...list,
+        {
+          side: props.getSide(range),
+          lineNumber: line(range),
+          metadata: {
+            kind: "draft",
+            key: `draft:${props.draftKey()}`,
+            range,
+          } satisfies LineCommentAnnotationMeta<T>,
+        },
+      ]
+    })
+  }
+
+  return createMemo<LineCommentAnnotation<T>[]>(() => {
+    const list = props.comments().map((comment) => {
+      const range = props.getCommentSelection(comment)
+      const entry: LineCommentAnnotation<T> = {
+        lineNumber: line(range),
+        metadata: {
+          kind: "comment",
+          key: `comment:${props.getCommentId(comment)}`,
+          comment,
+        },
+      }
+
+      return entry
+    })
+
+    const range = props.draftRange()
+    if (!range) return list
+
+    const draft: LineCommentAnnotation<T> = {
+      lineNumber: line(range),
+      metadata: {
+        kind: "draft",
+        key: `draft:${props.draftKey()}`,
+        range,
+      },
+    }
+
+    return [...list, draft]
+  })
+}
+
+export function createManagedLineCommentAnnotationRenderer<T>(props: {
+  annotations: Accessor<LineCommentAnnotation<T>[]>
+  renderComment: (comment: T) => CommentProps
+  renderDraft: (range: SelectedLineRange) => DraftProps
+}) {
+  const renderer = createLineCommentAnnotationRenderer<T>({
+    renderComment: props.renderComment,
+    renderDraft: props.renderDraft,
+  })
+
+  createEffect(() => {
+    renderer.reconcile(props.annotations())
+  })
+
+  onCleanup(() => {
+    renderer.cleanup()
+  })
+
+  return {
+    renderAnnotation: renderer.render,
+  }
+}
+
+export function createLineCommentHoverRenderer(props: {
+  label: string
+  getSelectedRange: Accessor<SelectedLineRange | null>
+  onOpenDraft: (range: SelectedLineRange) => void
+}) {
+  return (getHoveredLine: () => HoverCommentLine | undefined) =>
+    createHoverCommentUtility({
+      label: props.label,
+      getHoveredLine,
+      onSelect: (hovered) => {
+        const current = props.getSelectedRange()
+        if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) {
+          props.onOpenDraft(cloneSelectedLineRange(current))
+          return
+        }
+
+        const range: SelectedLineRange = {
+          start: hovered.lineNumber,
+          end: hovered.lineNumber,
+        }
+        if (hovered.side) range.side = hovered.side
+        props.onOpenDraft(range)
+      },
+    })
+}

+ 111 - 3
packages/ui/src/components/line-comment.css → packages/ui/src/components/line-comment-styles.ts

@@ -1,9 +1,23 @@
+export const lineCommentStyles = `
+[data-annotation-slot] {
+  padding: 12px;
+  box-sizing: border-box;
+}
+
 [data-component="line-comment"] {
   position: absolute;
   right: 24px;
   z-index: var(--line-comment-z, 30);
 }
 
+[data-component="line-comment"][data-inline] {
+  position: relative;
+  right: auto;
+  display: flex;
+  width: 100%;
+  align-items: flex-start;
+}
+
 [data-component="line-comment"][data-open] {
   z-index: var(--line-comment-open-z, 100);
 }
@@ -21,10 +35,20 @@
   border: none;
 }
 
+[data-component="line-comment"][data-variant="add"] [data-slot="line-comment-button"] {
+  background: var(--syntax-diff-add);
+}
+
 [data-component="line-comment"] [data-component="icon"] {
   color: var(--white);
 }
 
+[data-component="line-comment"] [data-slot="line-comment-icon"] {
+  width: 12px;
+  height: 12px;
+  color: var(--white);
+}
+
 [data-component="line-comment"] [data-slot="line-comment-button"]:focus {
   outline: none;
 }
@@ -39,27 +63,56 @@
   right: -8px;
   z-index: var(--line-comment-popover-z, 40);
   min-width: 200px;
-  max-width: min(320px, calc(100vw - 48px));
+  max-width: none;
   border-radius: 8px;
   background: var(--surface-raised-stronger-non-alpha);
-  box-shadow: var(--shadow-lg-border-base);
+  box-shadow: var(--shadow-xxs-border);
   padding: 12px;
 }
 
+[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"] {
+  position: relative;
+  top: auto;
+  right: auto;
+  margin-left: 8px;
+  flex: 0 1 600px;
+  width: min(100%, 600px);
+  max-width: min(100%, 600px);
+}
+
+[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] {
+  margin-left: 0;
+}
+
+[data-component="line-comment"][data-inline][data-variant="default"] [data-slot="line-comment-popover"][data-inline-body] {
+  cursor: pointer;
+}
+
 [data-component="line-comment"][data-variant="editor"] [data-slot="line-comment-popover"] {
   width: 380px;
-  max-width: min(380px, calc(100vw - 48px));
+  max-width: none;
   padding: 8px;
   border-radius: 14px;
 }
 
+[data-component="line-comment"][data-inline][data-variant="editor"] [data-slot="line-comment-popover"] {
+  flex-basis: 600px;
+}
+
 [data-component="line-comment"] [data-slot="line-comment-content"] {
   display: flex;
   flex-direction: column;
   gap: 6px;
 }
 
+[data-component="line-comment"] [data-slot="line-comment-head"] {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+}
+
 [data-component="line-comment"] [data-slot="line-comment-text"] {
+  flex: 1;
   font-family: var(--font-family-sans);
   font-size: var(--font-size-base);
   font-weight: var(--font-weight-regular);
@@ -69,6 +122,13 @@
   white-space: pre-wrap;
 }
 
+[data-component="line-comment"] [data-slot="line-comment-tools"] {
+  flex: 0 0 auto;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+}
+
 [data-component="line-comment"] [data-slot="line-comment-label"],
 [data-component="line-comment"] [data-slot="line-comment-editor-label"] {
   font-family: var(--font-family-sans);
@@ -108,8 +168,56 @@
   display: flex;
   align-items: center;
   gap: 8px;
+  padding-left: 8px;
 }
 
 [data-component="line-comment"] [data-slot="line-comment-editor-label"] {
   margin-right: auto;
 }
+
+[data-component="line-comment"] [data-slot="line-comment-action"] {
+  border: 1px solid var(--border-base);
+  background: var(--surface-base);
+  color: var(--text-strong);
+  border-radius: var(--radius-md);
+  height: 28px;
+  padding: 0 10px;
+  font-family: var(--font-family-sans);
+  font-size: var(--font-size-small);
+  font-weight: var(--font-weight-medium);
+}
+
+[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="ghost"] {
+  background: transparent;
+}
+
+[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="primary"] {
+  background: var(--text-strong);
+  border-color: var(--text-strong);
+  color: var(--background-base);
+}
+
+[data-component="line-comment"] [data-slot="line-comment-action"]:disabled {
+  opacity: 0.5;
+  pointer-events: none;
+}
+`
+
+let installed = false
+
+export function installLineCommentStyles() {
+  if (installed) return
+  if (typeof document === "undefined") return
+
+  const id = "opencode-line-comment-styles"
+  if (document.getElementById(id)) {
+    installed = true
+    return
+  }
+
+  const style = document.createElement("style")
+  style.id = id
+  style.textContent = lineCommentStyles
+  document.head.appendChild(style)
+  installed = true
+}

+ 160 - 32
packages/ui/src/components/line-comment.tsx

@@ -1,52 +1,121 @@
-import { onMount, Show, splitProps, type JSX } from "solid-js"
+import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js"
 import { Button } from "./button"
 import { Icon } from "./icon"
+import { installLineCommentStyles } from "./line-comment-styles"
 import { useI18n } from "../context/i18n"
 
-export type LineCommentVariant = "default" | "editor"
+installLineCommentStyles()
+
+export type LineCommentVariant = "default" | "editor" | "add"
+
+function InlineGlyph(props: { icon: "comment" | "plus" }) {
+  return (
+    <svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
+      <Show
+        when={props.icon === "comment"}
+        fallback={
+          <path
+            d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
+            stroke="currentColor"
+            stroke-linecap="square"
+          />
+        }
+      >
+        <path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
+      </Show>
+    </svg>
+  )
+}
 
 export type LineCommentAnchorProps = {
   id?: string
   top?: number
+  inline?: boolean
+  hideButton?: boolean
   open: boolean
   variant?: LineCommentVariant
+  icon?: "comment" | "plus"
+  buttonLabel?: string
   onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
   onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
   onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
   class?: string
   popoverClass?: string
-  children: JSX.Element
+  children?: JSX.Element
 }
 
 export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
-  const hidden = () => props.top === undefined
+  const hidden = () => !props.inline && props.top === undefined
   const variant = () => props.variant ?? "default"
+  const icon = () => props.icon ?? "comment"
+  const inlineBody = () => props.inline && props.hideButton
 
   return (
     <div
       data-component="line-comment"
+      data-prevent-autofocus=""
       data-variant={variant()}
       data-comment-id={props.id}
       data-open={props.open ? "" : undefined}
+      data-inline={props.inline ? "" : undefined}
       classList={{
         [props.class ?? ""]: !!props.class,
       }}
-      style={{
-        top: `${props.top ?? 0}px`,
-        opacity: hidden() ? 0 : 1,
-        "pointer-events": hidden() ? "none" : "auto",
-      }}
+      style={
+        props.inline
+          ? undefined
+          : {
+              top: `${props.top ?? 0}px`,
+              opacity: hidden() ? 0 : 1,
+              "pointer-events": hidden() ? "none" : "auto",
+            }
+      }
     >
-      <button type="button" data-slot="line-comment-button" onClick={props.onClick} onMouseEnter={props.onMouseEnter}>
-        <Icon name="comment" size="small" />
-      </button>
-      <Show when={props.open}>
+      <Show
+        when={inlineBody()}
+        fallback={
+          <>
+            <button
+              type="button"
+              aria-label={props.buttonLabel}
+              data-slot="line-comment-button"
+              on:mousedown={(e) => e.stopPropagation()}
+              on:mouseup={(e) => e.stopPropagation()}
+              on:click={props.onClick as any}
+              on:mouseenter={props.onMouseEnter as any}
+            >
+              <Show
+                when={props.inline}
+                fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
+              >
+                <InlineGlyph icon={icon()} />
+              </Show>
+            </button>
+            <Show when={props.open}>
+              <div
+                data-slot="line-comment-popover"
+                classList={{
+                  [props.popoverClass ?? ""]: !!props.popoverClass,
+                }}
+                on:mousedown={(e) => e.stopPropagation()}
+                on:focusout={props.onPopoverFocusOut as any}
+              >
+                {props.children}
+              </div>
+            </Show>
+          </>
+        }
+      >
         <div
           data-slot="line-comment-popover"
+          data-inline-body=""
           classList={{
             [props.popoverClass ?? ""]: !!props.popoverClass,
           }}
-          onFocusOut={props.onPopoverFocusOut}
+          on:mousedown={(e) => e.stopPropagation()}
+          on:click={props.onClick as any}
+          on:mouseenter={props.onMouseEnter as any}
+          on:focusout={props.onPopoverFocusOut as any}
         >
           {props.children}
         </div>
@@ -58,16 +127,22 @@ export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
 export type LineCommentProps = Omit<LineCommentAnchorProps, "children" | "variant"> & {
   comment: JSX.Element
   selection: JSX.Element
+  actions?: JSX.Element
 }
 
 export const LineComment = (props: LineCommentProps) => {
   const i18n = useI18n()
-  const [split, rest] = splitProps(props, ["comment", "selection"])
+  const [split, rest] = splitProps(props, ["comment", "selection", "actions"])
 
   return (
-    <LineCommentAnchor {...rest} variant="default">
+    <LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
       <div data-slot="line-comment-content">
-        <div data-slot="line-comment-text">{split.comment}</div>
+        <div data-slot="line-comment-head">
+          <div data-slot="line-comment-text">{split.comment}</div>
+          <Show when={split.actions}>
+            <div data-slot="line-comment-tools">{split.actions}</div>
+          </Show>
+        </div>
         <div data-slot="line-comment-label">
           {i18n.t("ui.lineComment.label.prefix")}
           {split.selection}
@@ -78,6 +153,25 @@ export const LineComment = (props: LineCommentProps) => {
   )
 }
 
+export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
+  label?: string
+}
+
+export const LineCommentAdd = (props: LineCommentAddProps) => {
+  const [split, rest] = splitProps(props, ["label"])
+  const i18n = useI18n()
+
+  return (
+    <LineCommentAnchor
+      {...rest}
+      open={false}
+      variant="add"
+      icon="plus"
+      buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
+    />
+  )
+}
+
 export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
   value: string
   selection: JSX.Element
@@ -109,11 +203,16 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
   const refs = {
     textarea: undefined as HTMLTextAreaElement | undefined,
   }
+  const [text, setText] = createSignal(split.value)
 
   const focus = () => refs.textarea?.focus()
 
+  createEffect(() => {
+    setText(split.value)
+  })
+
   const submit = () => {
-    const value = split.value.trim()
+    const value = text().trim()
     if (!value) return
     split.onSubmit(value)
   }
@@ -124,7 +223,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
   })
 
   return (
-    <LineCommentAnchor {...rest} open={true} variant="editor" onClick={() => focus()}>
+    <LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
       <div data-slot="line-comment-editor">
         <textarea
           ref={(el) => {
@@ -133,19 +232,23 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
           data-slot="line-comment-textarea"
           rows={split.rows ?? 3}
           placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
-          value={split.value}
-          onInput={(e) => split.onInput(e.currentTarget.value)}
-          onKeyDown={(e) => {
+          value={text()}
+          on:input={(e) => {
+            const value = (e.currentTarget as HTMLTextAreaElement).value
+            setText(value)
+            split.onInput(value)
+          }}
+          on:keydown={(e) => {
+            const event = e as KeyboardEvent
+            event.stopPropagation()
             if (e.key === "Escape") {
-              e.preventDefault()
-              e.stopPropagation()
+              event.preventDefault()
               split.onCancel()
               return
             }
             if (e.key !== "Enter") return
             if (e.shiftKey) return
-            e.preventDefault()
-            e.stopPropagation()
+            event.preventDefault()
             submit()
           }}
         />
@@ -155,12 +258,37 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
             {split.selection}
             {i18n.t("ui.lineComment.editorLabel.suffix")}
           </div>
-          <Button size="small" variant="ghost" onClick={split.onCancel}>
-            {split.cancelLabel ?? i18n.t("ui.common.cancel")}
-          </Button>
-          <Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
-            {split.submitLabel ?? i18n.t("ui.lineComment.submit")}
-          </Button>
+          <Show
+            when={!props.inline}
+            fallback={
+              <>
+                <button
+                  type="button"
+                  data-slot="line-comment-action"
+                  data-variant="ghost"
+                  on:click={split.onCancel as any}
+                >
+                  {split.cancelLabel ?? i18n.t("ui.common.cancel")}
+                </button>
+                <button
+                  type="button"
+                  data-slot="line-comment-action"
+                  data-variant="primary"
+                  disabled={text().trim().length === 0}
+                  on:click={submit as any}
+                >
+                  {split.submitLabel ?? i18n.t("ui.lineComment.submit")}
+                </button>
+              </>
+            }
+          >
+            <Button size="small" variant="ghost" onClick={split.onCancel}>
+              {split.cancelLabel ?? i18n.t("ui.common.cancel")}
+            </Button>
+            <Button size="small" variant="primary" disabled={text().trim().length === 0} onClick={submit}>
+              {split.submitLabel ?? i18n.t("ui.lineComment.submit")}
+            </Button>
+          </Show>
         </div>
       </div>
     </LineCommentAnchor>

+ 12 - 9
packages/ui/src/components/message-part.tsx

@@ -27,8 +27,7 @@ import {
   QuestionInfo,
 } from "@opencode-ai/sdk/v2"
 import { useData } from "../context"
-import { useDiffComponent } from "../context/diff"
-import { useCodeComponent } from "../context/code"
+import { useFileComponent } from "../context/file"
 import { useDialog } from "../context/dialog"
 import { useI18n } from "../context/i18n"
 import { BasicTool } from "./basic-tool"
@@ -1452,7 +1451,7 @@ ToolRegistry.register({
   name: "edit",
   render(props) {
     const i18n = useI18n()
-    const diffComponent = useDiffComponent()
+    const fileComponent = useFileComponent()
     const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
     const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
     const filename = () => getFilename(props.input.filePath ?? "")
@@ -1499,7 +1498,8 @@ ToolRegistry.register({
             >
               <div data-component="edit-content">
                 <Dynamic
-                  component={diffComponent}
+                  component={fileComponent}
+                  mode="diff"
                   before={{
                     name: props.metadata?.filediff?.file || props.input.filePath,
                     contents: props.metadata?.filediff?.before || props.input.oldString,
@@ -1523,7 +1523,7 @@ ToolRegistry.register({
   name: "write",
   render(props) {
     const i18n = useI18n()
-    const codeComponent = useCodeComponent()
+    const fileComponent = useFileComponent()
     const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
     const path = createMemo(() => props.input.filePath || "")
     const filename = () => getFilename(props.input.filePath ?? "")
@@ -1561,7 +1561,8 @@ ToolRegistry.register({
             <ToolFileAccordion path={path()}>
               <div data-component="write-content">
                 <Dynamic
-                  component={codeComponent}
+                  component={fileComponent}
+                  mode="text"
                   file={{
                     name: props.input.filePath,
                     contents: props.input.content,
@@ -1595,7 +1596,7 @@ ToolRegistry.register({
   name: "apply_patch",
   render(props) {
     const i18n = useI18n()
-    const diffComponent = useDiffComponent()
+    const fileComponent = useFileComponent()
     const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
     const pending = createMemo(() => props.status === "pending" || props.status === "running")
     const single = createMemo(() => {
@@ -1703,7 +1704,8 @@ ToolRegistry.register({
                             <Show when={visible()}>
                               <div data-component="apply-patch-file-diff">
                                 <Dynamic
-                                  component={diffComponent}
+                                  component={fileComponent}
+                                  mode="diff"
                                   before={{ name: file.filePath, contents: file.before }}
                                   after={{ name: file.movePath ?? file.filePath, contents: file.after }}
                                 />
@@ -1780,7 +1782,8 @@ ToolRegistry.register({
               >
                 <div data-component="apply-patch-file-diff">
                   <Dynamic
-                    component={diffComponent}
+                    component={fileComponent}
+                    mode="diff"
                     before={{ name: file().filePath, contents: file().before }}
                     after={{ name: file().movePath ?? file().filePath, contents: file().after }}
                   />

+ 39 - 0
packages/ui/src/components/session-review-search.test.ts

@@ -0,0 +1,39 @@
+import { describe, expect, test } from "bun:test"
+import { buildSessionSearchHits, stepSessionSearchIndex } from "./session-review-search"
+
+describe("session review search", () => {
+  test("builds hits with line, col, and side", () => {
+    const hits = buildSessionSearchHits({
+      query: "alpha",
+      files: [
+        {
+          file: "a.txt",
+          before: "alpha\nbeta alpha",
+          after: "ALPHA",
+        },
+      ],
+    })
+
+    expect(hits).toEqual([
+      { file: "a.txt", side: "deletions", line: 1, col: 1, len: 5 },
+      { file: "a.txt", side: "deletions", line: 2, col: 6, len: 5 },
+      { file: "a.txt", side: "additions", line: 1, col: 1, len: 5 },
+    ])
+  })
+
+  test("uses non-overlapping matches", () => {
+    const hits = buildSessionSearchHits({
+      query: "aa",
+      files: [{ file: "a.txt", after: "aaaa" }],
+    })
+
+    expect(hits.map((hit) => hit.col)).toEqual([1, 3])
+  })
+
+  test("wraps next and previous navigation", () => {
+    expect(stepSessionSearchIndex(5, 0, -1)).toBe(4)
+    expect(stepSessionSearchIndex(5, 4, 1)).toBe(0)
+    expect(stepSessionSearchIndex(5, 2, 1)).toBe(3)
+    expect(stepSessionSearchIndex(0, 0, 1)).toBe(0)
+  })
+})

+ 59 - 0
packages/ui/src/components/session-review-search.ts

@@ -0,0 +1,59 @@
+export type SessionSearchHit = {
+  file: string
+  side: "additions" | "deletions"
+  line: number
+  col: number
+  len: number
+}
+
+type SessionSearchFile = {
+  file: string
+  before?: string
+  after?: string
+}
+
+function hitsForSide(args: { file: string; side: SessionSearchHit["side"]; text: string; needle: string }) {
+  return args.text.split("\n").flatMap((line, i) => {
+    if (!line) return []
+
+    const hay = line.toLowerCase()
+    let at = hay.indexOf(args.needle)
+    if (at < 0) return []
+
+    const out: SessionSearchHit[] = []
+    while (at >= 0) {
+      out.push({
+        file: args.file,
+        side: args.side,
+        line: i + 1,
+        col: at + 1,
+        len: args.needle.length,
+      })
+      at = hay.indexOf(args.needle, at + args.needle.length)
+    }
+
+    return out
+  })
+}
+
+export function buildSessionSearchHits(args: { query: string; files: SessionSearchFile[] }) {
+  const value = args.query.trim().toLowerCase()
+  if (!value) return []
+
+  return args.files.flatMap((file) => {
+    const out: SessionSearchHit[] = []
+    if (typeof file.before === "string") {
+      out.push(...hitsForSide({ file: file.file, side: "deletions", text: file.before, needle: value }))
+    }
+    if (typeof file.after === "string") {
+      out.push(...hitsForSide({ file: file.file, side: "additions", text: file.after, needle: value }))
+    }
+    return out
+  })
+}
+
+export function stepSessionSearchIndex(total: number, current: number, dir: 1 | -1) {
+  if (total <= 0) return 0
+  if (current < 0 || current >= total) return dir > 0 ? 0 : total - 1
+  return (current + dir + total) % total
+}

+ 0 - 44
packages/ui/src/components/session-review.css

@@ -200,50 +200,6 @@
     color: var(--icon-diff-modified-base);
   }
 
-  [data-slot="session-review-file-container"] {
-    padding: 0;
-  }
-
-  [data-slot="session-review-image-container"] {
-    padding: 12px;
-    display: flex;
-    justify-content: center;
-    background: var(--background-stronger);
-  }
-
-  [data-slot="session-review-image"] {
-    max-width: 100%;
-    max-height: 60vh;
-    object-fit: contain;
-    border-radius: 8px;
-    border: 1px solid var(--border-weak-base);
-    background: var(--background-base);
-  }
-
-  [data-slot="session-review-image-placeholder"] {
-    font-family: var(--font-family-sans);
-    font-size: var(--font-size-small);
-    color: var(--text-weak);
-  }
-
-  [data-slot="session-review-audio-container"] {
-    padding: 12px;
-    display: flex;
-    justify-content: center;
-    background: var(--background-stronger);
-  }
-
-  [data-slot="session-review-audio"] {
-    width: 100%;
-    max-width: 560px;
-  }
-
-  [data-slot="session-review-audio-placeholder"] {
-    font-family: var(--font-family-sans);
-    font-size: var(--font-size-small);
-    color: var(--text-weak);
-  }
-
   [data-slot="session-review-diff-wrapper"] {
     position: relative;
     overflow: hidden;

+ 499 - 348
packages/ui/src/components/session-review.tsx

@@ -1,23 +1,30 @@
 import { Accordion } from "./accordion"
 import { Button } from "./button"
+import { DropdownMenu } from "./dropdown-menu"
 import { RadioGroup } from "./radio-group"
 import { DiffChanges } from "./diff-changes"
 import { FileIcon } from "./file-icon"
 import { Icon } from "./icon"
-import { LineComment, LineCommentEditor } from "./line-comment"
+import { IconButton } from "./icon-button"
 import { StickyAccordionHeader } from "./sticky-accordion-header"
 import { Tooltip } from "./tooltip"
 import { ScrollView } from "./scroll-view"
-import { useDiffComponent } from "../context/diff"
+import { FileSearchBar } from "./file-search"
+import type { FileSearchHandle } from "./file"
+import { buildSessionSearchHits, stepSessionSearchIndex, type SessionSearchHit } from "./session-review-search"
+import { useFileComponent } from "../context/file"
 import { useI18n } from "../context/i18n"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { checksum } from "@opencode-ai/util/encode"
-import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
+import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch, type JSX } from "solid-js"
 import { createStore } from "solid-js/store"
 import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
 import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 import { type SelectedLineRange } from "@pierre/diffs"
 import { Dynamic } from "solid-js/web"
+import { mediaKindFromPath } from "../pierre/media"
+import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
+import { createLineCommentController } from "./line-comment-annotations"
 
 const MAX_DIFF_CHANGED_LINES = 500
 
@@ -37,6 +44,22 @@ export type SessionReviewLineComment = {
   preview?: string
 }
 
+export type SessionReviewCommentUpdate = SessionReviewLineComment & {
+  id: string
+}
+
+export type SessionReviewCommentDelete = {
+  id: string
+  file: string
+}
+
+export type SessionReviewCommentActions = {
+  moreLabel: string
+  editLabel: string
+  deleteLabel: string
+  saveLabel: string
+}
+
 export type SessionReviewFocus = { file: string; id: string }
 
 export interface SessionReviewProps {
@@ -47,6 +70,9 @@ export interface SessionReviewProps {
   onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
   onDiffRendered?: () => void
   onLineComment?: (comment: SessionReviewLineComment) => void
+  onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
+  onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
+  lineCommentActions?: SessionReviewCommentActions
   comments?: SessionReviewComment[]
   focusedComment?: SessionReviewFocus | null
   onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
@@ -64,66 +90,35 @@ export interface SessionReviewProps {
   readFile?: (path: string) => Promise<FileContent | undefined>
 }
 
-const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
-const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
-
-function normalizeMimeType(type: string | undefined): string | undefined {
-  if (!type) return
-
-  const mime = type.split(";", 1)[0]?.trim().toLowerCase()
-  if (!mime) return
-
-  if (mime === "audio/x-aac") return "audio/aac"
-  if (mime === "audio/x-m4a") return "audio/mp4"
-
-  return mime
-}
-
-function getExtension(file: string): string {
-  const idx = file.lastIndexOf(".")
-  if (idx === -1) return ""
-  return file.slice(idx + 1).toLowerCase()
-}
-
-function isImageFile(file: string): boolean {
-  return imageExtensions.has(getExtension(file))
-}
-
-function isAudioFile(file: string): boolean {
-  return audioExtensions.has(getExtension(file))
-}
-
-function dataUrl(content: FileContent | undefined): string | undefined {
-  if (!content) return
-  if (content.encoding !== "base64") return
-  const mime = normalizeMimeType(content.mimeType)
-  if (!mime) return
-  if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return
-  return `data:${mime};base64,${content.content}`
-}
-
-function dataUrlFromValue(value: unknown): string | undefined {
-  if (typeof value === "string") {
-    if (value.startsWith("data:image/")) return value
-    if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;")
-    if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;")
-    if (value.startsWith("data:audio/")) return value
-    return
-  }
-  if (!value || typeof value !== "object") return
-
-  const content = (value as { content?: unknown }).content
-  const encoding = (value as { encoding?: unknown }).encoding
-  const mimeType = (value as { mimeType?: unknown }).mimeType
-
-  if (typeof content !== "string") return
-  if (encoding !== "base64") return
-  if (typeof mimeType !== "string") return
-  const mime = normalizeMimeType(mimeType)
-  if (!mime) return
-  if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return
-
-  return `data:${mime};base64,${content}`
+function ReviewCommentMenu(props: {
+  labels: SessionReviewCommentActions
+  onEdit: VoidFunction
+  onDelete: VoidFunction
+}) {
+  return (
+    <div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
+      <DropdownMenu gutter={4} placement="bottom-end">
+        <DropdownMenu.Trigger
+          as={IconButton}
+          icon="dot-grid"
+          variant="ghost"
+          size="small"
+          class="size-6 rounded-md"
+          aria-label={props.labels.moreLabel}
+        />
+        <DropdownMenu.Portal>
+          <DropdownMenu.Content>
+            <DropdownMenu.Item onSelect={props.onEdit}>
+              <DropdownMenu.ItemLabel>{props.labels.editLabel}</DropdownMenu.ItemLabel>
+            </DropdownMenu.Item>
+            <DropdownMenu.Item onSelect={props.onDelete}>
+              <DropdownMenu.ItemLabel>{props.labels.deleteLabel}</DropdownMenu.ItemLabel>
+            </DropdownMenu.Item>
+          </DropdownMenu.Content>
+        </DropdownMenu.Portal>
+      </DropdownMenu>
+    </div>
+  )
 }
 
 function diffId(file: string): string | undefined {
@@ -137,62 +132,37 @@ type SessionReviewSelection = {
   range: SelectedLineRange
 }
 
-function findSide(element: HTMLElement): "additions" | "deletions" | undefined {
-  const typed = element.closest("[data-line-type]")
-  if (typed instanceof HTMLElement) {
-    const type = typed.dataset.lineType
-    if (type === "change-deletion") return "deletions"
-    if (type === "change-addition" || type === "change-additions") return "additions"
-  }
-
-  const code = element.closest("[data-code]")
-  if (!(code instanceof HTMLElement)) return
-  return code.hasAttribute("data-deletions") ? "deletions" : "additions"
-}
-
-function findMarker(root: ShadowRoot, range: SelectedLineRange) {
-  const marker = (line: number, side?: "additions" | "deletions") => {
-    const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
-      (node): node is HTMLElement => node instanceof HTMLElement,
-    )
-    if (nodes.length === 0) return
-    if (!side) return nodes[0]
-    const match = nodes.find((node) => findSide(node) === side)
-    return match ?? nodes[0]
-  }
-
-  const a = marker(range.start, range.side)
-  const b = marker(range.end, range.endSide ?? range.side)
-  if (!a) return b
-  if (!b) return a
-  return a.getBoundingClientRect().top > b.getBoundingClientRect().top ? a : b
-}
-
-function markerTop(wrapper: HTMLElement, marker: HTMLElement) {
-  const wrapperRect = wrapper.getBoundingClientRect()
-  const rect = marker.getBoundingClientRect()
-  return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
-}
-
 export const SessionReview = (props: SessionReviewProps) => {
   let scroll: HTMLDivElement | undefined
+  let searchInput: HTMLInputElement | undefined
   let focusToken = 0
+  let revealToken = 0
+  let highlightedFile: string | undefined
   const i18n = useI18n()
-  const diffComponent = useDiffComponent()
+  const fileComponent = useFileComponent()
   const anchors = new Map<string, HTMLElement>()
-  const [store, setStore] = createStore({
+  const searchHandles = new Map<string, FileSearchHandle>()
+  const readyFiles = new Set<string>()
+  const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({
     open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
+    force: {},
   })
 
   const [selection, setSelection] = createSignal<SessionReviewSelection | null>(null)
   const [commenting, setCommenting] = createSignal<SessionReviewSelection | null>(null)
   const [opened, setOpened] = createSignal<SessionReviewFocus | null>(null)
+  const [searchOpen, setSearchOpen] = createSignal(false)
+  const [searchQuery, setSearchQuery] = createSignal("")
+  const [searchActive, setSearchActive] = createSignal(0)
+  const [searchPos, setSearchPos] = createSignal({ top: 8, right: 8 })
 
   const open = () => props.open ?? store.open
   const files = createMemo(() => props.diffs.map((d) => d.file))
   const diffs = createMemo(() => new Map(props.diffs.map((d) => [d.file, d] as const)))
   const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
   const hasDiffs = () => files().length > 0
+  const searchValue = createMemo(() => searchQuery().trim())
+  const searchExpanded = createMemo(() => searchValue().length > 0)
 
   const handleChange = (open: string[]) => {
     props.onOpenChange?.(open)
@@ -205,13 +175,259 @@ export const SessionReview = (props: SessionReviewProps) => {
     handleChange(next)
   }
 
-  const selectionLabel = (range: SelectedLineRange) => {
-    const start = Math.min(range.start, range.end)
-    const end = Math.max(range.start, range.end)
-    if (start === end) return `line ${start}`
-    return `lines ${start}-${end}`
+  const clearViewerSearch = () => {
+    for (const handle of searchHandles.values()) handle.clear()
+    highlightedFile = undefined
+  }
+
+  const focusSearch = () => {
+    if (!hasDiffs()) return
+    setSearchOpen(true)
+    requestAnimationFrame(() => {
+      searchInput?.focus()
+      searchInput?.select()
+    })
+  }
+
+  const closeSearch = () => {
+    revealToken++
+    setSearchOpen(false)
+    setSearchQuery("")
+    setSearchActive(0)
+    clearViewerSearch()
   }
 
+  const positionSearchBar = () => {
+    if (typeof window === "undefined") return
+    if (!scroll) return
+
+    const rect = scroll.getBoundingClientRect()
+    const title = parseFloat(getComputedStyle(scroll).getPropertyValue("--session-title-height"))
+    const header = Number.isNaN(title) ? 0 : title
+    setSearchPos({
+      top: Math.round(rect.top) + header - 4,
+      right: Math.round(window.innerWidth - rect.right) + 8,
+    })
+  }
+
+  const searchHits = createMemo(() =>
+    buildSessionSearchHits({
+      query: searchQuery(),
+      files: props.diffs.flatMap((diff) => {
+        if (mediaKindFromPath(diff.file)) return []
+
+        return [
+          {
+            file: diff.file,
+            before: typeof diff.before === "string" ? diff.before : undefined,
+            after: typeof diff.after === "string" ? diff.after : undefined,
+          },
+        ]
+      }),
+    }),
+  )
+
+  const waitForViewer = (file: string, token: number) =>
+    new Promise<FileSearchHandle | undefined>((resolve) => {
+      let attempt = 0
+
+      const tick = () => {
+        if (token !== revealToken) {
+          resolve(undefined)
+          return
+        }
+
+        const handle = searchHandles.get(file)
+        if (handle && readyFiles.has(file)) {
+          resolve(handle)
+          return
+        }
+
+        if (attempt >= 180) {
+          resolve(undefined)
+          return
+        }
+
+        attempt++
+        requestAnimationFrame(tick)
+      }
+
+      tick()
+    })
+
+  const waitForFrames = (count: number, token: number) =>
+    new Promise<boolean>((resolve) => {
+      const tick = (left: number) => {
+        if (token !== revealToken) {
+          resolve(false)
+          return
+        }
+
+        if (left <= 0) {
+          resolve(true)
+          return
+        }
+
+        requestAnimationFrame(() => tick(left - 1))
+      }
+
+      tick(count)
+    })
+
+  const revealSearchHit = async (token: number, hit: SessionSearchHit, query: string) => {
+    const diff = diffs().get(hit.file)
+    if (!diff) return
+
+    if (!open().includes(hit.file)) {
+      handleChange([...open(), hit.file])
+    }
+
+    if (!mediaKindFromPath(hit.file) && diff.additions + diff.deletions > MAX_DIFF_CHANGED_LINES) {
+      setStore("force", hit.file, true)
+    }
+
+    const handle = await waitForViewer(hit.file, token)
+    if (!handle || token !== revealToken) return
+    if (searchValue() !== query) return
+    if (!(await waitForFrames(2, token))) return
+
+    if (highlightedFile && highlightedFile !== hit.file) {
+      searchHandles.get(highlightedFile)?.clear()
+      highlightedFile = undefined
+    }
+
+    anchors.get(hit.file)?.scrollIntoView({ block: "nearest" })
+
+    let done = false
+    for (let i = 0; i < 4; i++) {
+      if (token !== revealToken) return
+      if (searchValue() !== query) return
+
+      handle.setQuery(query)
+      if (handle.reveal(hit)) {
+        done = true
+        break
+      }
+
+      const expanded = handle.expand(hit)
+      handle.refresh()
+      if (!(await waitForFrames(expanded ? 2 : 1, token))) return
+    }
+
+    if (!done) return
+
+    if (!(await waitForFrames(1, token))) return
+    handle.reveal(hit)
+
+    highlightedFile = hit.file
+  }
+
+  const navigateSearch = (dir: 1 | -1) => {
+    const total = searchHits().length
+    if (total <= 0) return
+    setSearchActive((value) => stepSessionSearchIndex(total, value, dir))
+  }
+
+  const inReview = (node: unknown, path?: unknown[]) => {
+    if (node === searchInput) return true
+    if (path?.some((item) => item === scroll || item === searchInput)) return true
+    if (path?.some((item) => item instanceof HTMLElement && item.dataset.component === "session-review")) {
+      return true
+    }
+    if (!(node instanceof Node)) return false
+    if (searchInput?.contains(node)) return true
+    if (node instanceof HTMLElement && node.closest("[data-component='session-review']")) return true
+    if (!scroll) return false
+    return scroll.contains(node)
+  }
+
+  createEffect(() => {
+    if (typeof window === "undefined") return
+
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (event.defaultPrevented) return
+
+      const mod = event.metaKey || event.ctrlKey
+      if (!mod) return
+
+      const key = event.key.toLowerCase()
+      if (key !== "f" && key !== "g") return
+
+      if (key === "f") {
+        if (!hasDiffs()) return
+        event.preventDefault()
+        event.stopPropagation()
+        focusSearch()
+        return
+      }
+
+      const path = typeof event.composedPath === "function" ? event.composedPath() : undefined
+      if (!inReview(event.target, path) && !inReview(document.activeElement, path)) return
+      if (!searchOpen()) return
+      event.preventDefault()
+      event.stopPropagation()
+      navigateSearch(event.shiftKey ? -1 : 1)
+    }
+
+    window.addEventListener("keydown", onKeyDown, { capture: true })
+    onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
+  })
+
+  createEffect(() => {
+    diffStyle()
+    searchExpanded()
+    readyFiles.clear()
+  })
+
+  createEffect(() => {
+    if (!searchOpen()) return
+    if (!scroll) return
+
+    const root = scroll
+
+    requestAnimationFrame(positionSearchBar)
+    window.addEventListener("resize", positionSearchBar, { passive: true })
+    const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(positionSearchBar)
+    observer?.observe(root)
+
+    onCleanup(() => {
+      window.removeEventListener("resize", positionSearchBar)
+      observer?.disconnect()
+    })
+  })
+
+  createEffect(() => {
+    const total = searchHits().length
+    if (total === 0) {
+      if (searchActive() !== 0) setSearchActive(0)
+      return
+    }
+
+    if (searchActive() >= total) setSearchActive(total - 1)
+  })
+
+  createEffect(() => {
+    diffStyle()
+    const query = searchValue()
+    const hits = searchHits()
+    const token = ++revealToken
+    if (!query || hits.length === 0) {
+      clearViewerSearch()
+      return
+    }
+
+    const hit = hits[Math.min(searchActive(), hits.length - 1)]
+    if (!hit) return
+    void revealSearchHit(token, hit, query)
+  })
+
+  onCleanup(() => {
+    revealToken++
+    clearViewerSearch()
+    readyFiles.clear()
+    searchHandles.clear()
+  })
+
   const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
 
   const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
@@ -219,11 +435,7 @@ export const SessionReview = (props: SessionReviewProps) => {
     const contents = side === "deletions" ? diff.before : diff.after
     if (typeof contents !== "string" || contents.length === 0) return undefined
 
-    const start = Math.max(1, Math.min(range.start, range.end))
-    const end = Math.max(range.start, range.end)
-    const lines = contents.split("\n").slice(start - 1, end)
-    if (lines.length === 0) return undefined
-    return lines.slice(0, 2).join("\n")
+    return previewSelectedLines(contents, range)
   }
 
   createEffect(() => {
@@ -236,7 +448,7 @@ export const SessionReview = (props: SessionReviewProps) => {
     setOpened(focus)
 
     const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
-    if (comment) setSelection({ file: comment.file, range: comment.selection })
+    if (comment) setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) })
 
     const current = open()
     if (!current.includes(focus.file)) {
@@ -249,11 +461,11 @@ export const SessionReview = (props: SessionReviewProps) => {
       const root = scroll
       if (!root) return
 
-      const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`)
-      const ready =
-        anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0"
+      const wrapper = anchors.get(focus.file)
+      const anchor = wrapper?.querySelector(`[data-comment-id="${focus.id}"]`)
+      const ready = anchor instanceof HTMLElement
 
-      const target = ready ? anchor : anchors.get(focus.file)
+      const target = ready ? anchor : wrapper
       if (!target) {
         if (attempt >= 120) return
         requestAnimationFrame(() => scrollTo(attempt + 1))
@@ -276,6 +488,58 @@ export const SessionReview = (props: SessionReviewProps) => {
     requestAnimationFrame(() => props.onFocusedCommentChange?.(null))
   })
 
+  const handleReviewKeyDown = (event: KeyboardEvent) => {
+    if (event.defaultPrevented) return
+
+    const mod = event.metaKey || event.ctrlKey
+    const key = event.key.toLowerCase()
+    const target = event.target
+    if (mod && key === "f") {
+      event.preventDefault()
+      event.stopPropagation()
+      focusSearch()
+      return
+    }
+
+    if (mod && key === "g") {
+      if (!searchOpen()) return
+      event.preventDefault()
+      event.stopPropagation()
+      navigateSearch(event.shiftKey ? -1 : 1)
+    }
+  }
+
+  const handleSearchInputKeyDown = (event: KeyboardEvent) => {
+    const mod = event.metaKey || event.ctrlKey
+    const key = event.key.toLowerCase()
+
+    if (mod && key === "g") {
+      event.preventDefault()
+      event.stopPropagation()
+      navigateSearch(event.shiftKey ? -1 : 1)
+      return
+    }
+
+    if (mod && key === "f") {
+      event.preventDefault()
+      event.stopPropagation()
+      focusSearch()
+      return
+    }
+
+    if (event.key === "Escape") {
+      event.preventDefault()
+      event.stopPropagation()
+      closeSearch()
+      return
+    }
+
+    if (event.key !== "Enter") return
+    event.preventDefault()
+    event.stopPropagation()
+    navigateSearch(event.shiftKey ? -1 : 1)
+  }
+
   return (
     <ScrollView
       data-component="session-review"
@@ -284,6 +548,7 @@ export const SessionReview = (props: SessionReviewProps) => {
         props.scrollRef?.(el)
       }}
       onScroll={props.onScroll as any}
+      onKeyDown={handleReviewKeyDown}
       classList={{
         ...(props.classList ?? {}),
         [props.classes?.root ?? ""]: !!props.classes?.root,
@@ -321,6 +586,25 @@ export const SessionReview = (props: SessionReviewProps) => {
           {props.actions}
         </div>
       </div>
+      <Show when={searchOpen()}>
+        <FileSearchBar
+          pos={searchPos}
+          query={searchQuery}
+          index={() => (searchHits().length ? Math.min(searchActive(), searchHits().length - 1) : 0)}
+          count={() => searchHits().length}
+          setInput={(el) => {
+            searchInput = el
+          }}
+          onInput={(value) => {
+            setSearchQuery(value)
+            setSearchActive(0)
+          }}
+          onKeyDown={(event) => handleSearchInputKeyDown(event)}
+          onClose={closeSearch}
+          onPrev={() => navigateSearch(-1)}
+          onNext={() => navigateSearch(1)}
+        />
+      </Show>
       <div data-slot="session-review-container" class={props.classes?.container}>
         <Show when={hasDiffs()} fallback={props.empty}>
           <Accordion multiple value={open()} onChange={handleChange}>
@@ -332,7 +616,7 @@ export const SessionReview = (props: SessionReviewProps) => {
                 const item = () => diff()!
 
                 const expanded = createMemo(() => open().includes(file))
-                const [force, setForce] = createSignal(false)
+                const force = () => !!store.force[file]
 
                 const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
                 const commentedLines = createMemo(() => comments().map((c) => c.selection))
@@ -340,28 +624,18 @@ export const SessionReview = (props: SessionReviewProps) => {
                 const beforeText = () => (typeof item().before === "string" ? item().before : "")
                 const afterText = () => (typeof item().after === "string" ? item().after : "")
                 const changedLines = () => item().additions + item().deletions
+                const mediaKind = createMemo(() => mediaKindFromPath(file))
 
                 const tooLarge = createMemo(() => {
                   if (!expanded()) return false
                   if (force()) return false
-                  if (isImageFile(file)) return false
+                  if (mediaKind()) return false
                   return changedLines() > MAX_DIFF_CHANGED_LINES
                 })
 
                 const isAdded = () => item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
                 const isDeleted = () =>
                   item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
-                const isImage = () => isImageFile(file)
-                const isAudio = () => isAudioFile(file)
-
-                const diffImageSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before))
-                const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc())
-                const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
-
-                const diffAudioSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().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()
@@ -375,164 +649,74 @@ export const SessionReview = (props: SessionReviewProps) => {
                   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)
-                  }
-                  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 scheduleAnchors = () => {
-                  requestAnimationFrame(updateAnchors)
-                }
-
-                createEffect(() => {
-                  if (!isImage()) return
-                  const src = diffImageSrc()
-                  setImageSrc(src)
-                  setImageStatus("idle")
-                })
-
-                createEffect(() => {
-                  if (!isAudio()) return
-                  const src = diffAudioSrc()
-                  setAudioSrc(src)
-                  setAudioStatus("idle")
-                  setAudioMime(undefined)
-                })
-
-                createEffect(() => {
-                  comments()
-                  scheduleAnchors()
-                })
-
-                createEffect(() => {
-                  const range = draftRange()
-                  if (!range) return
-                  setDraft("")
-                  scheduleAnchors()
-                })
-
-                createEffect(() => {
-                  if (!open().includes(file)) return
-                  if (!isImage()) return
-                  if (imageSrc()) return
-                  if (imageStatus() !== "idle") return
-                  if (isDeleted()) return
-
-                  const reader = props.readFile
-                  if (!reader) return
-
-                  setImageStatus("loading")
-                  reader(file)
-                    .then((result) => {
-                      const src = dataUrl(result)
-                      if (!src) {
-                        setImageStatus("error")
-                        return
-                      }
-                      setImageSrc(src)
-                      setImageStatus("idle")
+                const commentsUi = createLineCommentController<SessionReviewComment>({
+                  comments,
+                  label: i18n.t("ui.lineComment.submit"),
+                  draftKey: () => file,
+                  state: {
+                    opened: () => {
+                      const current = opened()
+                      if (!current || current.file !== file) return null
+                      return current.id
+                    },
+                    setOpened: (id) => setOpened(id ? { file, id } : null),
+                    selected: selectedLines,
+                    setSelected: (range) => setSelection(range ? { file, range } : null),
+                    commenting: draftRange,
+                    setCommenting: (range) => setCommenting(range ? { file, range } : null),
+                  },
+                  getSide: selectionSide,
+                  clearSelectionOnSelectionEndNull: false,
+                  onSubmit: ({ comment, selection }) => {
+                    props.onLineComment?.({
+                      file,
+                      selection,
+                      comment,
+                      preview: selectionPreview(item(), selection),
+                    })
+                  },
+                  onUpdate: ({ id, comment, selection }) => {
+                    props.onLineCommentUpdate?.({
+                      id,
+                      file,
+                      selection,
+                      comment,
+                      preview: selectionPreview(item(), selection),
                     })
-                    .catch(() => {
-                      setImageStatus("error")
+                  },
+                  onDelete: (comment) => {
+                    props.onLineCommentDelete?.({
+                      id: comment.id,
+                      file,
                     })
+                  },
+                  editSubmitLabel: props.lineCommentActions?.saveLabel,
+                  renderCommentActions: props.lineCommentActions
+                    ? (comment, controls) => (
+                        <ReviewCommentMenu
+                          labels={props.lineCommentActions!}
+                          onEdit={controls.edit}
+                          onDelete={controls.remove}
+                        />
+                      )
+                    : undefined,
                 })
 
-                createEffect(() => {
-                  if (!open().includes(file)) return
-                  if (!isAudio()) return
-                  if (audioSrc()) return
-                  if (audioStatus() !== "idle") return
-
-                  const reader = props.readFile
-                  if (!reader) return
-
-                  setAudioStatus("loading")
-                  reader(file)
-                    .then((result) => {
-                      const src = dataUrl(result)
-                      if (!src) {
-                        setAudioStatus("error")
-                        return
-                      }
-                      setAudioMime(normalizeMimeType(result?.mimeType))
-                      setAudioSrc(src)
-                      setAudioStatus("idle")
-                    })
-                    .catch(() => {
-                      setAudioStatus("error")
-                    })
+                onCleanup(() => {
+                  anchors.delete(file)
+                  readyFiles.delete(file)
+                  searchHandles.delete(file)
+                  if (highlightedFile === file) highlightedFile = undefined
                 })
 
                 const handleLineSelected = (range: SelectedLineRange | null) => {
                   if (!props.onLineComment) return
-
-                  if (!range) {
-                    setSelection(null)
-                    return
-                  }
-
-                  setSelection({ file, range })
+                  commentsUi.onLineSelected(range)
                 }
 
                 const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
                   if (!props.onLineComment) return
-
-                  if (!range) {
-                    setCommenting(null)
-                    return
-                  }
-
-                  setSelection({ file, range })
-                  setCommenting({ file, range })
-                }
-
-                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
+                  commentsUi.onLineSelectionEnd(range)
                 }
 
                 return (
@@ -585,7 +769,7 @@ export const SessionReview = (props: SessionReviewProps) => {
                                   {i18n.t("ui.sessionReview.change.removed")}
                                 </span>
                               </Match>
-                              <Match when={isImage()}>
+                              <Match when={!!mediaKind()}>
                                 <span data-slot="session-review-change" data-type="modified">
                                   {i18n.t("ui.sessionReview.change.modified")}
                                 </span>
@@ -607,33 +791,11 @@ export const SessionReview = (props: SessionReviewProps) => {
                         ref={(el) => {
                           wrapper = el
                           anchors.set(file, el)
-                          scheduleAnchors()
                         }}
                       >
                         <Show when={expanded()}>
                           <Switch>
-                            <Match when={isImage() && imageSrc()}>
-                              <div data-slot="session-review-image-container">
-                                <img data-slot="session-review-image" src={imageSrc()} alt={file} />
-                              </div>
-                            </Match>
-                            <Match when={isImage() && isDeleted()}>
-                              <div data-slot="session-review-image-container" data-removed>
-                                <span data-slot="session-review-image-placeholder">
-                                  {i18n.t("ui.sessionReview.change.removed")}
-                                </span>
-                              </div>
-                            </Match>
-                            <Match when={isImage() && !imageSrc()}>
-                              <div data-slot="session-review-image-container">
-                                <span data-slot="session-review-image-placeholder">
-                                  {imageStatus() === "loading"
-                                    ? i18n.t("ui.sessionReview.image.loading")
-                                    : i18n.t("ui.sessionReview.image.placeholder")}
-                                </span>
-                              </div>
-                            </Match>
-                            <Match when={!isImage() && tooLarge()}>
+                            <Match when={tooLarge()}>
                               <div data-slot="session-review-large-diff">
                                 <div data-slot="session-review-large-diff-title">
                                   {i18n.t("ui.sessionReview.largeDiff.title")}
@@ -645,26 +807,52 @@ export const SessionReview = (props: SessionReviewProps) => {
                                   })}
                                 </div>
                                 <div data-slot="session-review-large-diff-actions">
-                                  <Button size="normal" variant="secondary" onClick={() => setForce(true)}>
+                                  <Button
+                                    size="normal"
+                                    variant="secondary"
+                                    onClick={() => setStore("force", file, true)}
+                                  >
                                     {i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
                                   </Button>
                                 </div>
                               </div>
                             </Match>
-                            <Match when={!isImage()}>
+                            <Match when={true}>
                               <Dynamic
-                                component={diffComponent}
+                                component={fileComponent}
+                                mode="diff"
                                 preloadedDiff={item().preloaded}
                                 diffStyle={diffStyle()}
+                                expansionLineCount={searchExpanded() ? Number.MAX_SAFE_INTEGER : 20}
                                 onRendered={() => {
+                                  readyFiles.add(file)
                                   props.onDiffRendered?.()
-                                  scheduleAnchors()
                                 }}
                                 enableLineSelection={props.onLineComment != null}
+                                enableHoverUtility={props.onLineComment != null}
                                 onLineSelected={handleLineSelected}
                                 onLineSelectionEnd={handleLineSelectionEnd}
+                                onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
+                                annotations={commentsUi.annotations()}
+                                renderAnnotation={commentsUi.renderAnnotation}
+                                renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
                                 selectedLines={selectedLines()}
                                 commentedLines={commentedLines()}
+                                search={{
+                                  shortcuts: "disabled",
+                                  showBar: false,
+                                  disableVirtualization: searchExpanded(),
+                                  register: (handle: FileSearchHandle | null) => {
+                                    if (!handle) {
+                                      searchHandles.delete(file)
+                                      readyFiles.delete(file)
+                                      if (highlightedFile === file) highlightedFile = undefined
+                                      return
+                                    }
+
+                                    searchHandles.set(file, handle)
+                                  },
+                                }}
                                 before={{
                                   name: file,
                                   contents: typeof item().before === "string" ? item().before : "",
@@ -673,53 +861,16 @@ export const SessionReview = (props: SessionReviewProps) => {
                                   name: file,
                                   contents: typeof item().after === "string" ? item().after : "",
                                 }}
+                                media={{
+                                  mode: "auto",
+                                  path: file,
+                                  before: item().before,
+                                  after: item().after,
+                                  readFile: props.readFile,
+                                }}
                               />
                             </Match>
                           </Switch>
-
-                          <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,
-                                      selection: range(),
-                                      comment,
-                                      preview: selectionPreview(item(), range()),
-                                    })
-                                    setCommenting(null)
-                                  }}
-                                />
-                              </Show>
-                            )}
-                          </Show>
                         </Show>
                       </div>
                     </Accordion.Content>

+ 4 - 3
packages/ui/src/components/session-turn.tsx

@@ -1,6 +1,6 @@
 import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
 import { useData } from "../context"
-import { useDiffComponent } from "../context/diff"
+import { useFileComponent } from "../context/file"
 
 import { Binary } from "@opencode-ai/util/binary"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
@@ -152,7 +152,7 @@ export function SessionTurn(
 ) {
   const data = useData()
   const i18n = useI18n()
-  const diffComponent = useDiffComponent()
+  const fileComponent = useFileComponent()
 
   const emptyMessages: MessageType[] = []
   const emptyParts: PartType[] = []
@@ -465,7 +465,8 @@ export function SessionTurn(
                                         <Show when={visible()}>
                                           <div data-slot="session-turn-diff-view" data-scrollable>
                                             <Dynamic
-                                              component={diffComponent}
+                                              component={fileComponent}
+                                              mode="diff"
                                               before={{ name: diff.file, contents: diff.before }}
                                               after={{ name: diff.file, contents: diff.after }}
                                             />

+ 0 - 10
packages/ui/src/context/diff.tsx

@@ -1,10 +0,0 @@
-import type { ValidComponent } from "solid-js"
-import { createSimpleContext } from "./helper"
-
-const ctx = createSimpleContext<ValidComponent, { component: ValidComponent }>({
-  name: "DiffComponent",
-  init: (props) => props.component,
-})
-
-export const DiffComponentProvider = ctx.provider
-export const useDiffComponent = ctx.use

+ 3 - 3
packages/ui/src/context/code.tsx → packages/ui/src/context/file.tsx

@@ -2,9 +2,9 @@ import type { ValidComponent } from "solid-js"
 import { createSimpleContext } from "./helper"
 
 const ctx = createSimpleContext<ValidComponent, { component: ValidComponent }>({
-  name: "CodeComponent",
+  name: "FileComponent",
   init: (props) => props.component,
 })
 
-export const CodeComponentProvider = ctx.provider
-export const useCodeComponent = ctx.use
+export const FileComponentProvider = ctx.provider
+export const useFileComponent = ctx.use

+ 1 - 1
packages/ui/src/context/index.ts

@@ -1,5 +1,5 @@
 export * from "./helper"
 export * from "./data"
-export * from "./diff"
+export * from "./file"
 export * from "./dialog"
 export * from "./i18n"

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

@@ -13,6 +13,15 @@ export const dict = {
   "ui.sessionReview.largeDiff.title": "Diff كبير جدا لعرضه",
   "ui.sessionReview.largeDiff.meta": "الحد: {{limit}} سطرًا متغيرًا. الحالي: {{current}} سطرًا متغيرًا.",
   "ui.sessionReview.largeDiff.renderAnyway": "اعرض على أي حال",
+  "ui.fileMedia.kind.image": "صورة",
+  "ui.fileMedia.kind.audio": "صوت",
+  "ui.fileMedia.state.removed": "تمت إزالة {{kind}}",
+  "ui.fileMedia.state.loading": "جاري تحميل {{kind}}...",
+  "ui.fileMedia.state.error": "خطأ في تحميل {{kind}}",
+  "ui.fileMedia.state.unavailable": "{{kind}} غير متوفر",
+  "ui.fileMedia.binary.title": "ملف ثنائي",
+  "ui.fileMedia.binary.description.path": "{{path}} عبارة عن ملف ثنائي ولا يمكن عرضه.",
+  "ui.fileMedia.binary.description.default": "هذا ملف ثنائي ولا يمكن عرضه.",
 
   "ui.lineComment.label.prefix": "تعليق على ",
   "ui.lineComment.label.suffix": "",

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

@@ -13,6 +13,15 @@ export const dict = {
   "ui.sessionReview.largeDiff.title": "Diff grande demais para renderizar",
   "ui.sessionReview.largeDiff.meta": "Limite: {{limit}} linhas alteradas. Atual: {{current}} linhas alteradas.",
   "ui.sessionReview.largeDiff.renderAnyway": "Renderizar mesmo assim",
+  "ui.fileMedia.kind.image": "imagem",
+  "ui.fileMedia.kind.audio": "áudio",
+  "ui.fileMedia.state.removed": "Removido: {{kind}}",
+  "ui.fileMedia.state.loading": "Carregando {{kind}}...",
+  "ui.fileMedia.state.error": "Erro ao carregar {{kind}}",
+  "ui.fileMedia.state.unavailable": "{{kind}} indisponível",
+  "ui.fileMedia.binary.title": "Arquivo binário",
+  "ui.fileMedia.binary.description.path": "Não é possível exibir {{path}} porque é um arquivo binário.",
+  "ui.fileMedia.binary.description.default": "Não é possível exibir o arquivo porque ele é binário.",
 
   "ui.lineComment.label.prefix": "Comentar em ",
   "ui.lineComment.label.suffix": "",

+ 9 - 0
packages/ui/src/i18n/bs.ts

@@ -17,6 +17,15 @@ export const dict = {
   "ui.sessionReview.largeDiff.title": "Diff je prevelik za prikaz",
   "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} izmijenjenih linija. Trenutno: {{current}} izmijenjenih linija.",
   "ui.sessionReview.largeDiff.renderAnyway": "Prikaži svejedno",
+  "ui.fileMedia.kind.image": "slika",
+  "ui.fileMedia.kind.audio": "audio",
+  "ui.fileMedia.state.removed": "Uklonjeno: {{kind}}",
+  "ui.fileMedia.state.loading": "Učitavanje: {{kind}}...",
+  "ui.fileMedia.state.error": "Greška pri učitavanju: {{kind}}",
+  "ui.fileMedia.state.unavailable": "Nedostupno: {{kind}}",
+  "ui.fileMedia.binary.title": "Binarni fajl",
+  "ui.fileMedia.binary.description.path": "{{path}} se ne može prikazati jer je binarni fajl.",
+  "ui.fileMedia.binary.description.default": "Ovaj fajl se ne može prikazati jer je binarni.",
 
   "ui.lineComment.label.prefix": "Komentar na ",
   "ui.lineComment.label.suffix": "",

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

@@ -14,6 +14,15 @@ export const dict = {
   "ui.sessionReview.largeDiff.title": "Diff er for stor til at blive vist",
   "ui.sessionReview.largeDiff.meta": "Grænse: {{limit}} ændrede linjer. Nuværende: {{current}} ændrede linjer.",
   "ui.sessionReview.largeDiff.renderAnyway": "Vis alligevel",
+  "ui.fileMedia.kind.image": "billede",
+  "ui.fileMedia.kind.audio": "lyd",
+  "ui.fileMedia.state.removed": "Fjernet: {{kind}}",
+  "ui.fileMedia.state.loading": "Indlæser {{kind}}...",
+  "ui.fileMedia.state.error": "Fejl ved indlæsning: {{kind}}",
+  "ui.fileMedia.state.unavailable": "Utilgængelig: {{kind}}",
+  "ui.fileMedia.binary.title": "Binær fil",
+  "ui.fileMedia.binary.description.path": "{{path}} kan ikke vises, fordi det er en binær fil.",
+  "ui.fileMedia.binary.description.default": "Denne fil kan ikke vises, fordi det er en binær fil.",
   "ui.lineComment.label.prefix": "Kommenter på ",
   "ui.lineComment.label.suffix": "",
   "ui.lineComment.editorLabel.prefix": "Kommenterer på ",

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

@@ -18,6 +18,17 @@ export const dict = {
   "ui.sessionReview.largeDiff.title": "Diff zu groß zum Rendern",
   "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} geänderte Zeilen. Aktuell: {{current}} geänderte Zeilen.",
   "ui.sessionReview.largeDiff.renderAnyway": "Trotzdem rendern",
+  "ui.fileMedia.kind.image": "bild",
+  "ui.fileMedia.kind.audio": "audio",
+  "ui.fileMedia.state.removed": "{{kind}} entfernt",
+  "ui.fileMedia.state.loading": "{{kind}} wird geladen",
+  "ui.fileMedia.state.error": "Fehler bei {{kind}}",
+  "ui.fileMedia.state.unavailable": "{{kind}} nicht verfügbar",
+  "ui.fileMedia.binary.title": "Binärdatei",
+  "ui.fileMedia.binary.description.path":
+    "{{path}} kann nicht angezeigt werden, da es sich um eine Binärdatei handelt.",
+  "ui.fileMedia.binary.description.default":
+    "Diese Datei kann nicht angezeigt werden, da es sich um eine Binärdatei handelt.",
   "ui.lineComment.label.prefix": "Kommentar zu ",
   "ui.lineComment.label.suffix": "",
   "ui.lineComment.editorLabel.prefix": "Kommentiere ",

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

@@ -14,6 +14,16 @@ export const dict = {
   "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} changed lines. Current: {{current}} changed lines.",
   "ui.sessionReview.largeDiff.renderAnyway": "Render anyway",
 
+  "ui.fileMedia.kind.image": "image",
+  "ui.fileMedia.kind.audio": "audio",
+  "ui.fileMedia.state.removed": "Removed {{kind}} file.",
+  "ui.fileMedia.state.loading": "Loading {{kind}}...",
+  "ui.fileMedia.state.error": "Unable to load {{kind}}.",
+  "ui.fileMedia.state.unavailable": "{{kind}} preview unavailable.",
+  "ui.fileMedia.binary.title": "Binary file",
+  "ui.fileMedia.binary.description.path": "{{path}} is binary.",
+  "ui.fileMedia.binary.description.default": "Binary content",
+
   "ui.lineComment.label.prefix": "Comment on ",
   "ui.lineComment.label.suffix": "",
   "ui.lineComment.editorLabel.prefix": "Commenting on ",

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

@@ -13,6 +13,15 @@ export const dict = {
   "ui.sessionReview.largeDiff.title": "Diff demasiado grande para renderizar",
   "ui.sessionReview.largeDiff.meta": "Límite: {{limit}} líneas modificadas. Actual: {{current}} líneas modificadas.",
   "ui.sessionReview.largeDiff.renderAnyway": "Renderizar de todos modos",
+  "ui.fileMedia.kind.image": "imagen",
+  "ui.fileMedia.kind.audio": "audio",
+  "ui.fileMedia.state.removed": "Archivo de {{kind}} eliminado",
+  "ui.fileMedia.state.loading": "Cargando archivo de {{kind}}",
+  "ui.fileMedia.state.error": "Error en el archivo de {{kind}}",
+  "ui.fileMedia.state.unavailable": "Archivo de {{kind}} no disponible",
+  "ui.fileMedia.binary.title": "Archivo binario",
+  "ui.fileMedia.binary.description.path": "No se puede mostrar {{path}} porque es un archivo binario.",
+  "ui.fileMedia.binary.description.default": "No se puede mostrar este archivo porque es un archivo binario.",
 
   "ui.lineComment.label.prefix": "Comentar en ",
   "ui.lineComment.label.suffix": "",

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

@@ -13,6 +13,15 @@ export const dict = {
   "ui.sessionReview.largeDiff.title": "Diff trop volumineux pour être affiché",
   "ui.sessionReview.largeDiff.meta": "Limite : {{limit}} lignes modifiées. Actuel : {{current}} lignes modifiées.",
   "ui.sessionReview.largeDiff.renderAnyway": "Afficher quand même",
+  "ui.fileMedia.kind.image": "image",
+  "ui.fileMedia.kind.audio": "audio",
+  "ui.fileMedia.state.removed": "Fichier {{kind}} supprimé",
+  "ui.fileMedia.state.loading": "Chargement du fichier {{kind}}",
+  "ui.fileMedia.state.error": "Erreur avec le fichier {{kind}}",
+  "ui.fileMedia.state.unavailable": "Fichier {{kind}} indisponible",
+  "ui.fileMedia.binary.title": "Fichier binaire",
+  "ui.fileMedia.binary.description.path": "Impossible d'afficher {{path}} car il s'agit d'un fichier binaire.",
+  "ui.fileMedia.binary.description.default": "Impossible d'afficher ce fichier car il s'agit d'un fichier binaire.",
 
   "ui.lineComment.label.prefix": "Commenter sur ",
   "ui.lineComment.label.suffix": "",

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

@@ -14,6 +14,15 @@ export const dict = {
   "ui.sessionReview.largeDiff.title": "差分が大きすぎて表示できません",
   "ui.sessionReview.largeDiff.meta": "上限: {{limit}} 変更行。現在: {{current}} 変更行。",
   "ui.sessionReview.largeDiff.renderAnyway": "それでも表示する",
+  "ui.fileMedia.kind.image": "画像",
+  "ui.fileMedia.kind.audio": "音声",
+  "ui.fileMedia.state.removed": "{{kind}}は削除されました",
+  "ui.fileMedia.state.loading": "{{kind}}を読み込んでいます...",
+  "ui.fileMedia.state.error": "{{kind}}の読み込みに失敗しました",
+  "ui.fileMedia.state.unavailable": "{{kind}}は表示できません",
+  "ui.fileMedia.binary.title": "バイナリファイル",
+  "ui.fileMedia.binary.description.path": "{{path}} はバイナリファイルのため表示できません。",
+  "ui.fileMedia.binary.description.default": "このファイルはバイナリファイルのため表示できません。",
   "ui.lineComment.label.prefix": "",
   "ui.lineComment.label.suffix": "へのコメント",
   "ui.lineComment.editorLabel.prefix": "",

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

@@ -13,6 +13,15 @@ export const dict = {
   "ui.sessionReview.largeDiff.title": "차이가 너무 커서 렌더링할 수 없습니다",
   "ui.sessionReview.largeDiff.meta": "제한: {{limit}} 변경 줄. 현재: {{current}} 변경 줄.",
   "ui.sessionReview.largeDiff.renderAnyway": "그래도 렌더링",
+  "ui.fileMedia.kind.image": "이미지",
+  "ui.fileMedia.kind.audio": "오디오",
+  "ui.fileMedia.state.removed": "{{kind}} 제거됨",
+  "ui.fileMedia.state.loading": "{{kind}} 로드 중...",
+  "ui.fileMedia.state.error": "{{kind}} 로드 오류",
+  "ui.fileMedia.state.unavailable": "{{kind}} 사용 불가",
+  "ui.fileMedia.binary.title": "바이너리 파일",
+  "ui.fileMedia.binary.description.path": "{{path}}은(는) 바이너리 파일이므로 표시할 수 없습니다.",
+  "ui.fileMedia.binary.description.default": "바이너리 파일이므로 표시할 수 없습니다.",
 
   "ui.lineComment.label.prefix": "",
   "ui.lineComment.label.suffix": "에 댓글 달기",

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

@@ -16,6 +16,15 @@ export const dict: Record<Keys, string> = {
   "ui.sessionReview.largeDiff.title": "Diff er for stor til å gjengi",
   "ui.sessionReview.largeDiff.meta": "Grense: {{limit}} endrede linjer. Nåværende: {{current}} endrede linjer.",
   "ui.sessionReview.largeDiff.renderAnyway": "Gjengi likevel",
+  "ui.fileMedia.kind.image": "bilde",
+  "ui.fileMedia.kind.audio": "lyd",
+  "ui.fileMedia.state.removed": "Fjernet: {{kind}}",
+  "ui.fileMedia.state.loading": "Laster inn {{kind}}...",
+  "ui.fileMedia.state.error": "Feil ved innlasting: {{kind}}",
+  "ui.fileMedia.state.unavailable": "Ikke tilgjengelig: {{kind}}",
+  "ui.fileMedia.binary.title": "Binærfil",
+  "ui.fileMedia.binary.description.path": "{{path}} kan ikke vises fordi det er en binærfil.",
+  "ui.fileMedia.binary.description.default": "Denne filen kan ikke vises fordi det er en binærfil.",
 
   "ui.lineComment.label.prefix": "Kommenter på ",
   "ui.lineComment.label.suffix": "",

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

@@ -14,6 +14,15 @@ export const dict = {
   "ui.sessionReview.largeDiff.title": "Diff jest zbyt duży, aby go wyrenderować",
   "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} zmienionych linii. Obecnie: {{current}} zmienionych linii.",
   "ui.sessionReview.largeDiff.renderAnyway": "Renderuj mimo to",
+  "ui.fileMedia.kind.image": "obraz",
+  "ui.fileMedia.kind.audio": "dźwięk",
+  "ui.fileMedia.state.removed": "{{kind}} usunięty",
+  "ui.fileMedia.state.loading": "Wczytywanie: {{kind}}...",
+  "ui.fileMedia.state.error": "Błąd wczytywania: {{kind}}",
+  "ui.fileMedia.state.unavailable": "{{kind}} niedostępny",
+  "ui.fileMedia.binary.title": "Plik binarny",
+  "ui.fileMedia.binary.description.path": "Nie można wyświetlić pliku {{path}}, ponieważ jest to plik binarny.",
+  "ui.fileMedia.binary.description.default": "Nie można wyświetlić tego pliku, ponieważ jest to plik binarny.",
   "ui.lineComment.label.prefix": "Komentarz do ",
   "ui.lineComment.label.suffix": "",
   "ui.lineComment.editorLabel.prefix": "Komentowanie: ",

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

@@ -14,6 +14,15 @@ export const dict = {
   "ui.sessionReview.largeDiff.title": "Diff слишком большой для отображения",
   "ui.sessionReview.largeDiff.meta": "Лимит: {{limit}} изменённых строк. Текущий: {{current}} изменённых строк.",
   "ui.sessionReview.largeDiff.renderAnyway": "Отобразить всё равно",
+  "ui.fileMedia.kind.image": "изображение",
+  "ui.fileMedia.kind.audio": "аудио",
+  "ui.fileMedia.state.removed": "{{kind}} удалено",
+  "ui.fileMedia.state.loading": "Загружается {{kind}}...",
+  "ui.fileMedia.state.error": "Не удалось загрузить {{kind}}",
+  "ui.fileMedia.state.unavailable": "{{kind}} недоступно",
+  "ui.fileMedia.binary.title": "Бинарный файл",
+  "ui.fileMedia.binary.description.path": "Невозможно отобразить {{path}}, так как это бинарный файл.",
+  "ui.fileMedia.binary.description.default": "Невозможно отобразить этот файл, так как он бинарный.",
   "ui.lineComment.label.prefix": "Комментарий к ",
   "ui.lineComment.label.suffix": "",
   "ui.lineComment.editorLabel.prefix": "Комментирование: ",

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

@@ -14,6 +14,15 @@ export const dict = {
   "ui.sessionReview.largeDiff.meta":
     "ขีดจำกัด: {{limit}} บรรทัดที่เปลี่ยนแปลง. ปัจจุบัน: {{current}} บรรทัดที่เปลี่ยนแปลง.",
   "ui.sessionReview.largeDiff.renderAnyway": "แสดงผลต่อไป",
+  "ui.fileMedia.kind.image": "รูปภาพ",
+  "ui.fileMedia.kind.audio": "เสียง",
+  "ui.fileMedia.state.removed": "ลบ{{kind}}แล้ว",
+  "ui.fileMedia.state.loading": "กำลังโหลด{{kind}}...",
+  "ui.fileMedia.state.error": "เกิดข้อผิดพลาดในการโหลด{{kind}}",
+  "ui.fileMedia.state.unavailable": "{{kind}}ไม่พร้อมใช้งาน",
+  "ui.fileMedia.binary.title": "ไฟล์ไบนารี",
+  "ui.fileMedia.binary.description.path": "{{path}} เป็นไฟล์ไบนารีและไม่สามารถแสดงผลได้",
+  "ui.fileMedia.binary.description.default": "ไฟล์ไบนารีไม่สามารถแสดงผลได้",
 
   "ui.lineComment.label.prefix": "แสดงความคิดเห็นบน ",
   "ui.lineComment.label.suffix": "",

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

@@ -17,6 +17,15 @@ export const dict = {
   "ui.sessionReview.largeDiff.title": "差异过大,无法渲染",
   "ui.sessionReview.largeDiff.meta": "限制:{{limit}} 行变更。当前:{{current}} 行变更。",
   "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
+  "ui.fileMedia.kind.image": "图片",
+  "ui.fileMedia.kind.audio": "音频",
+  "ui.fileMedia.state.removed": "{{kind}}已移除",
+  "ui.fileMedia.state.loading": "正在加载{{kind}}...",
+  "ui.fileMedia.state.error": "加载{{kind}}失败",
+  "ui.fileMedia.state.unavailable": "{{kind}}不可预览",
+  "ui.fileMedia.binary.title": "二进制文件",
+  "ui.fileMedia.binary.description.path": "无法显示 {{path}},因为它是二进制文件。",
+  "ui.fileMedia.binary.description.default": "无法显示此文件,因为它是二进制文件。",
 
   "ui.lineComment.label.prefix": "评论 ",
   "ui.lineComment.label.suffix": "",

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

@@ -17,6 +17,15 @@ export const dict = {
   "ui.sessionReview.largeDiff.title": "差異過大,無法渲染",
   "ui.sessionReview.largeDiff.meta": "限制:{{limit}} 行變更。目前:{{current}} 行變更。",
   "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
+  "ui.fileMedia.kind.image": "圖片",
+  "ui.fileMedia.kind.audio": "音訊",
+  "ui.fileMedia.state.removed": "{{kind}}已移除",
+  "ui.fileMedia.state.loading": "正在載入{{kind}}...",
+  "ui.fileMedia.state.error": "載入{{kind}}失敗",
+  "ui.fileMedia.state.unavailable": "{{kind}}無法預覽",
+  "ui.fileMedia.binary.title": "二進位檔案",
+  "ui.fileMedia.binary.description.path": "無法顯示 {{path}},因為它是二進位檔案。",
+  "ui.fileMedia.binary.description.default": "無法顯示此檔案,因為它是二進位檔案。",
 
   "ui.lineComment.label.prefix": "評論 ",
   "ui.lineComment.label.suffix": "",

+ 74 - 0
packages/ui/src/pierre/comment-hover.ts

@@ -0,0 +1,74 @@
+export type HoverCommentLine = {
+  lineNumber: number
+  side?: "additions" | "deletions"
+}
+
+export function createHoverCommentUtility(props: {
+  label: string
+  getHoveredLine: () => HoverCommentLine | undefined
+  onSelect: (line: HoverCommentLine) => void
+}) {
+  if (typeof document === "undefined") return
+
+  const button = document.createElement("button")
+  button.type = "button"
+  button.ariaLabel = props.label
+  button.textContent = "+"
+  button.style.width = "20px"
+  button.style.height = "20px"
+  button.style.display = "flex"
+  button.style.alignItems = "center"
+  button.style.justifyContent = "center"
+  button.style.border = "none"
+  button.style.borderRadius = "var(--radius-md)"
+  button.style.background = "var(--icon-interactive-base)"
+  button.style.color = "var(--white)"
+  button.style.boxShadow = "var(--shadow-xs)"
+  button.style.fontSize = "14px"
+  button.style.lineHeight = "1"
+  button.style.cursor = "pointer"
+  button.style.position = "relative"
+  button.style.left = "30px"
+  button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
+
+  let line: HoverCommentLine | undefined
+
+  const sync = () => {
+    const next = props.getHoveredLine()
+    if (!next) return
+    line = next
+  }
+
+  const loop = () => {
+    if (!button.isConnected) return
+    sync()
+    requestAnimationFrame(loop)
+  }
+
+  const open = () => {
+    const next = props.getHoveredLine() ?? line
+    if (!next) return
+    props.onSelect(next)
+  }
+
+  requestAnimationFrame(loop)
+  button.addEventListener("mouseenter", sync)
+  button.addEventListener("mousemove", sync)
+  button.addEventListener("pointerdown", (event) => {
+    event.preventDefault()
+    event.stopPropagation()
+    sync()
+  })
+  button.addEventListener("mousedown", (event) => {
+    event.preventDefault()
+    event.stopPropagation()
+    sync()
+  })
+  button.addEventListener("click", (event) => {
+    event.preventDefault()
+    event.stopPropagation()
+    open()
+  })
+
+  return button
+}

+ 91 - 0
packages/ui/src/pierre/commented-lines.ts

@@ -0,0 +1,91 @@
+import { type SelectedLineRange } from "@pierre/diffs"
+import { diffLineIndex, diffRowIndex, findDiffSide } from "./diff-selection"
+
+export type CommentSide = "additions" | "deletions"
+
+function annotationIndex(node: HTMLElement) {
+  const value = node.dataset.lineAnnotation?.split(",")[1]
+  if (!value) return
+  const line = parseInt(value, 10)
+  if (Number.isNaN(line)) return
+  return line
+}
+
+function clear(root: ShadowRoot) {
+  const marked = Array.from(root.querySelectorAll("[data-comment-selected]"))
+  for (const node of marked) {
+    if (!(node instanceof HTMLElement)) continue
+    node.removeAttribute("data-comment-selected")
+  }
+}
+
+export function markCommentedDiffLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
+  clear(root)
+
+  const diffs = root.querySelector("[data-diff]")
+  if (!(diffs instanceof HTMLElement)) return
+
+  const split = diffs.dataset.diffType === "split"
+  const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
+    (node): node is HTMLElement => node instanceof HTMLElement,
+  )
+  if (rows.length === 0) return
+
+  const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
+    (node): node is HTMLElement => node instanceof HTMLElement,
+  )
+
+  for (const range of ranges) {
+    const start = diffRowIndex(root, split, range.start, range.side as CommentSide | undefined)
+    if (start === undefined) continue
+
+    const end = (() => {
+      const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
+      if (same) return start
+      return diffRowIndex(root, split, range.end, (range.endSide ?? range.side) as CommentSide | undefined)
+    })()
+    if (end === undefined) continue
+
+    const first = Math.min(start, end)
+    const last = Math.max(start, end)
+
+    for (const row of rows) {
+      const idx = diffLineIndex(split, row)
+      if (idx === undefined || idx < first || idx > last) continue
+      row.setAttribute("data-comment-selected", "")
+    }
+
+    for (const annotation of annotations) {
+      const idx = annotationIndex(annotation)
+      if (idx === undefined || idx < first || idx > last) continue
+      annotation.setAttribute("data-comment-selected", "")
+    }
+  }
+}
+
+export function markCommentedFileLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
+  clear(root)
+
+  const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
+    (node): node is HTMLElement => node instanceof HTMLElement,
+  )
+
+  for (const range of ranges) {
+    const start = Math.max(1, Math.min(range.start, range.end))
+    const end = Math.max(range.start, range.end)
+
+    for (let line = start; line <= end; line++) {
+      const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
+      for (const node of nodes) {
+        if (!(node instanceof HTMLElement)) continue
+        node.setAttribute("data-comment-selected", "")
+      }
+    }
+
+    for (const annotation of annotations) {
+      const line = annotationIndex(annotation)
+      if (line === undefined || line < start || line > end) continue
+      annotation.setAttribute("data-comment-selected", "")
+    }
+  }
+}

+ 71 - 0
packages/ui/src/pierre/diff-selection.ts

@@ -0,0 +1,71 @@
+import { type SelectedLineRange } from "@pierre/diffs"
+
+export type DiffSelectionSide = "additions" | "deletions"
+
+export function findDiffSide(node: HTMLElement): DiffSelectionSide {
+  const line = node.closest("[data-line], [data-alt-line]")
+  if (line instanceof HTMLElement) {
+    const type = line.dataset.lineType
+    if (type === "change-deletion") return "deletions"
+    if (type === "change-addition" || type === "change-additions") return "additions"
+  }
+
+  const code = node.closest("[data-code]")
+  if (!(code instanceof HTMLElement)) return "additions"
+  return code.hasAttribute("data-deletions") ? "deletions" : "additions"
+}
+
+export function diffLineIndex(split: boolean, node: HTMLElement) {
+  const raw = node.dataset.lineIndex
+  if (!raw) return
+
+  const values = raw
+    .split(",")
+    .map((x) => parseInt(x, 10))
+    .filter((x) => !Number.isNaN(x))
+  if (values.length === 0) return
+  if (!split) return values[0]
+  if (values.length === 2) return values[1]
+  return values[0]
+}
+
+export function diffRowIndex(root: ShadowRoot, split: boolean, line: number, side: DiffSelectionSide | undefined) {
+  const rows = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
+    (node): node is HTMLElement => node instanceof HTMLElement,
+  )
+  if (rows.length === 0) return
+
+  const target = side ?? "additions"
+  for (const row of rows) {
+    if (findDiffSide(row) === target) return diffLineIndex(split, row)
+    if (parseInt(row.dataset.altLine ?? "", 10) === line) return diffLineIndex(split, row)
+  }
+}
+
+export function fixDiffSelection(root: ShadowRoot | undefined, range: SelectedLineRange | null) {
+  if (!range) return range
+  if (!root) return
+
+  const diffs = root.querySelector("[data-diff]")
+  if (!(diffs instanceof HTMLElement)) return
+
+  const split = diffs.dataset.diffType === "split"
+  const start = diffRowIndex(root, split, range.start, range.side)
+  const end = diffRowIndex(root, split, range.end, range.endSide ?? range.side)
+
+  if (start === undefined || end === undefined) {
+    if (root.querySelector("[data-line], [data-alt-line]") == null) return
+    return null
+  }
+  if (start <= end) return range
+
+  const side = range.endSide ?? range.side
+  const swapped: SelectedLineRange = {
+    start: range.end,
+    end: range.start,
+  }
+
+  if (side) swapped.side = side
+  if (range.endSide && range.side) swapped.endSide = range.side
+  return swapped
+}

+ 576 - 0
packages/ui/src/pierre/file-find.ts

@@ -0,0 +1,576 @@
+import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
+
+export type FindHost = {
+  element: () => HTMLElement | undefined
+  open: () => void
+  close: () => void
+  next: (dir: 1 | -1) => void
+  isOpen: () => boolean
+}
+
+type FileFindSide = "additions" | "deletions"
+
+export type FileFindReveal = {
+  side: FileFindSide
+  line: number
+  col: number
+  len: number
+}
+
+type FileFindHit = FileFindReveal & {
+  range: Range
+  alt?: number
+}
+
+const hosts = new Set<FindHost>()
+let target: FindHost | undefined
+let current: FindHost | undefined
+let installed = false
+
+function isEditable(node: unknown): boolean {
+  if (!(node instanceof HTMLElement)) return false
+  if (node.closest("[data-prevent-autofocus]")) return true
+  if (node.isContentEditable) return true
+  return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
+}
+
+function hostForNode(node: unknown) {
+  if (!(node instanceof Node)) return
+  for (const host of hosts) {
+    const el = host.element()
+    if (el && el.isConnected && el.contains(node)) return host
+  }
+}
+
+function installShortcuts() {
+  if (installed) return
+  if (typeof window === "undefined") return
+  installed = true
+
+  window.addEventListener(
+    "keydown",
+    (event) => {
+      if (event.defaultPrevented) return
+      if (isEditable(event.target)) return
+
+      const mod = event.metaKey || event.ctrlKey
+      if (!mod) return
+
+      const key = event.key.toLowerCase()
+      if (key === "g") {
+        const host = current
+        if (!host || !host.isOpen()) return
+        event.preventDefault()
+        event.stopPropagation()
+        host.next(event.shiftKey ? -1 : 1)
+        return
+      }
+
+      if (key !== "f") return
+
+      const active = current
+      if (active && active.isOpen()) {
+        event.preventDefault()
+        event.stopPropagation()
+        active.open()
+        return
+      }
+
+      const host = hostForNode(document.activeElement) ?? hostForNode(event.target) ?? target ?? Array.from(hosts)[0]
+      if (!host) return
+
+      event.preventDefault()
+      event.stopPropagation()
+      host.open()
+    },
+    { capture: true },
+  )
+}
+
+function clearHighlightFind() {
+  const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
+  if (!api) return
+  api.delete("opencode-find")
+  api.delete("opencode-find-current")
+}
+
+function supportsHighlights() {
+  const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
+  return typeof g.Highlight === "function" && g.CSS?.highlights != null
+}
+
+function scrollParent(el: HTMLElement): HTMLElement | undefined {
+  let parent = el.parentElement
+  while (parent) {
+    const style = getComputedStyle(parent)
+    if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
+    parent = parent.parentElement
+  }
+}
+
+type CreateFileFindOptions = {
+  wrapper: () => HTMLElement | undefined
+  overlay: () => HTMLDivElement | undefined
+  getRoot: () => ShadowRoot | undefined
+  shortcuts?: "global" | "disabled"
+}
+
+export function createFileFind(opts: CreateFileFindOptions) {
+  let input: HTMLInputElement | undefined
+  let overlayFrame: number | undefined
+  let overlayScroll: HTMLElement[] = []
+  let mode: "highlights" | "overlay" = "overlay"
+  let hits: FileFindHit[] = []
+
+  const [open, setOpen] = createSignal(false)
+  const [query, setQuery] = createSignal("")
+  const [index, setIndex] = createSignal(0)
+  const [count, setCount] = createSignal(0)
+  const [pos, setPos] = createSignal({ top: 8, right: 8 })
+
+  const clearOverlayScroll = () => {
+    for (const el of overlayScroll) el.removeEventListener("scroll", scheduleOverlay)
+    overlayScroll = []
+  }
+
+  const clearOverlay = () => {
+    const el = opts.overlay()
+    if (!el) return
+    if (overlayFrame !== undefined) {
+      cancelAnimationFrame(overlayFrame)
+      overlayFrame = undefined
+    }
+    el.innerHTML = ""
+  }
+
+  const renderOverlay = () => {
+    if (mode !== "overlay") {
+      clearOverlay()
+      return
+    }
+
+    const wrapper = opts.wrapper()
+    const overlay = opts.overlay()
+    if (!wrapper || !overlay) return
+
+    clearOverlay()
+    if (hits.length === 0) return
+
+    const base = wrapper.getBoundingClientRect()
+    const currentIndex = index()
+    const frag = document.createDocumentFragment()
+
+    for (let i = 0; i < hits.length; i++) {
+      const range = hits[i].range
+      const active = i === currentIndex
+      for (const rect of Array.from(range.getClientRects())) {
+        if (!rect.width || !rect.height) continue
+
+        const mark = document.createElement("div")
+        mark.style.position = "absolute"
+        mark.style.left = `${Math.round(rect.left - base.left)}px`
+        mark.style.top = `${Math.round(rect.top - base.top)}px`
+        mark.style.width = `${Math.round(rect.width)}px`
+        mark.style.height = `${Math.round(rect.height)}px`
+        mark.style.borderRadius = "2px"
+        mark.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
+        mark.style.opacity = active ? "0.55" : "0.35"
+        if (active) mark.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
+        frag.appendChild(mark)
+      }
+    }
+
+    overlay.appendChild(frag)
+  }
+
+  function scheduleOverlay() {
+    if (mode !== "overlay") return
+    if (!open()) return
+    if (overlayFrame !== undefined) return
+
+    overlayFrame = requestAnimationFrame(() => {
+      overlayFrame = undefined
+      renderOverlay()
+    })
+  }
+
+  const syncOverlayScroll = () => {
+    if (mode !== "overlay") return
+    const root = opts.getRoot()
+
+    const next = root
+      ? Array.from(root.querySelectorAll("[data-code]")).filter(
+          (node): node is HTMLElement => node instanceof HTMLElement,
+        )
+      : []
+    if (next.length === overlayScroll.length && next.every((el, i) => el === overlayScroll[i])) return
+
+    clearOverlayScroll()
+    overlayScroll = next
+    for (const el of overlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true })
+  }
+
+  const clearFind = () => {
+    clearHighlightFind()
+    clearOverlay()
+    clearOverlayScroll()
+    hits = []
+    setCount(0)
+    setIndex(0)
+  }
+
+  const positionBar = () => {
+    if (typeof window === "undefined") return
+    const wrapper = opts.wrapper()
+    if (!wrapper) return
+
+    const root = scrollParent(wrapper) ?? wrapper
+    const rect = root.getBoundingClientRect()
+    const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
+    const header = Number.isNaN(title) ? 0 : title
+
+    setPos({
+      top: Math.round(rect.top) + header - 4,
+      right: Math.round(window.innerWidth - rect.right) + 8,
+    })
+  }
+
+  const scan = (root: ShadowRoot, value: string) => {
+    const needle = value.toLowerCase()
+    const ranges: FileFindHit[] = []
+    const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
+      (node): node is HTMLElement => node instanceof HTMLElement,
+    )
+
+    for (const col of cols) {
+      const text = col.textContent
+      if (!text) continue
+
+      const hay = text.toLowerCase()
+      let at = hay.indexOf(needle)
+      if (at === -1) continue
+
+      const row = col.closest("[data-line], [data-alt-line]")
+      if (!(row instanceof HTMLElement)) continue
+
+      const primary = parseInt(row.dataset.line ?? "", 10)
+      const alt = parseInt(row.dataset.altLine ?? "", 10)
+      const line = (() => {
+        if (!Number.isNaN(primary)) return primary
+        if (!Number.isNaN(alt)) return alt
+      })()
+      if (line === undefined) continue
+
+      const side = (() => {
+        const code = col.closest("[data-code]")
+        if (code instanceof HTMLElement) return code.hasAttribute("data-deletions") ? "deletions" : "additions"
+
+        const row = col.closest("[data-line-type]")
+        if (!(row instanceof HTMLElement)) return "additions"
+        const type = row.dataset.lineType
+        if (type === "change-deletion") return "deletions"
+        return "additions"
+      })() as FileFindSide
+
+      const nodes: Text[] = []
+      const ends: number[] = []
+      const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
+      let node = walker.nextNode()
+      let pos = 0
+      while (node) {
+        if (node instanceof Text) {
+          pos += node.data.length
+          nodes.push(node)
+          ends.push(pos)
+        }
+        node = walker.nextNode()
+      }
+      if (nodes.length === 0) continue
+
+      const locate = (offset: number) => {
+        let lo = 0
+        let hi = ends.length - 1
+        while (lo < hi) {
+          const mid = (lo + hi) >> 1
+          if (ends[mid] >= offset) hi = mid
+          else lo = mid + 1
+        }
+        const prev = lo === 0 ? 0 : ends[lo - 1]
+        return { node: nodes[lo], offset: offset - prev }
+      }
+
+      while (at !== -1) {
+        const start = locate(at)
+        const end = locate(at + value.length)
+        const range = document.createRange()
+        range.setStart(start.node, start.offset)
+        range.setEnd(end.node, end.offset)
+        ranges.push({
+          range,
+          side,
+          line,
+          alt: Number.isNaN(alt) ? undefined : alt,
+          col: at + 1,
+          len: value.length,
+        })
+        at = hay.indexOf(needle, at + value.length)
+      }
+    }
+
+    return ranges
+  }
+
+  const scrollToRange = (range: Range) => {
+    const scroll = () => {
+      const start = range.startContainer
+      const el = start instanceof Element ? start : start.parentElement
+      el?.scrollIntoView({ block: "center", inline: "center" })
+    }
+
+    scroll()
+    requestAnimationFrame(scroll)
+  }
+
+  const setHighlights = (ranges: FileFindHit[], currentIndex: number) => {
+    const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
+    const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
+    if (!api || typeof Highlight !== "function") return false
+
+    api.delete("opencode-find")
+    api.delete("opencode-find-current")
+
+    const active = ranges[currentIndex]?.range
+    if (active) api.set("opencode-find-current", new Highlight(active))
+
+    const rest = ranges.flatMap((hit, i) => (i === currentIndex ? [] : [hit.range]))
+    if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
+    return true
+  }
+
+  const select = (currentIndex: number, scroll: boolean) => {
+    const active = hits[currentIndex]?.range
+    if (!active) return false
+
+    setIndex(currentIndex)
+
+    if (mode === "highlights") {
+      if (!setHighlights(hits, currentIndex)) {
+        mode = "overlay"
+        apply({ reset: true, scroll })
+        return false
+      }
+      if (scroll) scrollToRange(active)
+      return true
+    }
+
+    clearHighlightFind()
+    syncOverlayScroll()
+    if (scroll) scrollToRange(active)
+    scheduleOverlay()
+    return true
+  }
+
+  const apply = (args?: { reset?: boolean; scroll?: boolean }) => {
+    if (!open()) return
+
+    const value = query().trim()
+    if (!value) {
+      clearFind()
+      return
+    }
+
+    const root = opts.getRoot()
+    if (!root) return
+
+    mode = supportsHighlights() ? "highlights" : "overlay"
+
+    const ranges = scan(root, value)
+    const total = ranges.length
+    const desired = args?.reset ? 0 : index()
+    const currentIndex = total ? Math.min(desired, total - 1) : 0
+
+    hits = ranges
+    setCount(total)
+    setIndex(currentIndex)
+
+    const active = ranges[currentIndex]?.range
+    if (mode === "highlights") {
+      clearOverlay()
+      clearOverlayScroll()
+      if (!setHighlights(ranges, currentIndex)) {
+        mode = "overlay"
+        clearHighlightFind()
+        syncOverlayScroll()
+        scheduleOverlay()
+      }
+      if (args?.scroll && active) scrollToRange(active)
+      return
+    }
+
+    clearHighlightFind()
+    syncOverlayScroll()
+    if (args?.scroll && active) scrollToRange(active)
+    scheduleOverlay()
+  }
+
+  const close = () => {
+    setOpen(false)
+    setQuery("")
+    clearFind()
+    if (current === host) current = undefined
+  }
+
+  const clear = () => {
+    setQuery("")
+    clearFind()
+  }
+
+  const activate = () => {
+    if (opts.shortcuts !== "disabled") {
+      if (current && current !== host) current.close()
+      current = host
+      target = host
+    }
+
+    if (!open()) setOpen(true)
+  }
+
+  const focus = () => {
+    activate()
+    requestAnimationFrame(() => {
+      apply({ scroll: true })
+      input?.focus()
+      input?.select()
+    })
+  }
+
+  const next = (dir: 1 | -1) => {
+    if (!open()) return
+    const total = count()
+    if (total <= 0) return
+
+    const currentIndex = (index() + dir + total) % total
+    select(currentIndex, true)
+  }
+
+  const reveal = (targetHit: FileFindReveal) => {
+    if (!open()) return false
+    if (hits.length === 0) return false
+
+    const exact = hits.findIndex(
+      (hit) =>
+        hit.side === targetHit.side &&
+        hit.line === targetHit.line &&
+        hit.col === targetHit.col &&
+        hit.len === targetHit.len,
+    )
+    const fallback = hits.findIndex(
+      (hit) =>
+        (hit.line === targetHit.line || hit.alt === targetHit.line) &&
+        hit.col === targetHit.col &&
+        hit.len === targetHit.len,
+    )
+
+    const nextIndex = exact >= 0 ? exact : fallback
+    if (nextIndex < 0) return false
+    return select(nextIndex, true)
+  }
+
+  const host: FindHost = {
+    element: opts.wrapper,
+    isOpen: () => open(),
+    next,
+    open: focus,
+    close,
+  }
+
+  onMount(() => {
+    mode = supportsHighlights() ? "highlights" : "overlay"
+    if (opts.shortcuts !== "disabled") {
+      installShortcuts()
+      hosts.add(host)
+      if (!target) target = host
+    }
+
+    onCleanup(() => {
+      if (opts.shortcuts !== "disabled") {
+        hosts.delete(host)
+        if (current === host) {
+          current = undefined
+          clearHighlightFind()
+        }
+        if (target === host) target = undefined
+      }
+    })
+  })
+
+  createEffect(() => {
+    if (!open()) return
+
+    const update = () => positionBar()
+    requestAnimationFrame(update)
+    window.addEventListener("resize", update, { passive: true })
+
+    const wrapper = opts.wrapper()
+    if (!wrapper) return
+    const root = scrollParent(wrapper) ?? wrapper
+    const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update())
+    observer?.observe(root)
+
+    onCleanup(() => {
+      window.removeEventListener("resize", update)
+      observer?.disconnect()
+    })
+  })
+
+  onCleanup(() => {
+    clearOverlayScroll()
+    clearOverlay()
+    if (current === host) {
+      current = undefined
+      clearHighlightFind()
+    }
+  })
+
+  return {
+    open,
+    query,
+    count,
+    index,
+    pos,
+    setInput: (el: HTMLInputElement) => {
+      input = el
+    },
+    setQuery: (value: string, args?: { scroll?: boolean }) => {
+      setQuery(value)
+      setIndex(0)
+      apply({ reset: true, scroll: args?.scroll ?? true })
+    },
+    clear,
+    activate,
+    focus,
+    close,
+    next,
+    reveal,
+    refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args),
+    onPointerDown: () => {
+      if (opts.shortcuts === "disabled") return
+      target = host
+      opts.wrapper()?.focus({ preventScroll: true })
+    },
+    onFocus: () => {
+      if (opts.shortcuts === "disabled") return
+      target = host
+    },
+    onInputKeyDown: (event: KeyboardEvent) => {
+      if (event.key === "Escape") {
+        event.preventDefault()
+        close()
+        return
+      }
+      if (event.key !== "Enter") return
+      event.preventDefault()
+      next(event.shiftKey ? -1 : 1)
+    },
+  }
+}

+ 114 - 0
packages/ui/src/pierre/file-runtime.ts

@@ -0,0 +1,114 @@
+type ReadyWatcher = {
+  observer?: MutationObserver
+  token: number
+}
+
+export function createReadyWatcher(): ReadyWatcher {
+  return { token: 0 }
+}
+
+export function clearReadyWatcher(state: ReadyWatcher) {
+  state.observer?.disconnect()
+  state.observer = undefined
+}
+
+export function getViewerHost(container: HTMLElement | undefined) {
+  if (!container) return
+  const host = container.querySelector("diffs-container")
+  if (!(host instanceof HTMLElement)) return
+  return host
+}
+
+export function getViewerRoot(container: HTMLElement | undefined) {
+  return getViewerHost(container)?.shadowRoot ?? undefined
+}
+
+export function applyViewerScheme(host: HTMLElement | undefined) {
+  if (!host) return
+  if (typeof document === "undefined") return
+
+  const scheme = document.documentElement.dataset.colorScheme
+  if (scheme === "dark" || scheme === "light") {
+    host.dataset.colorScheme = scheme
+    return
+  }
+
+  host.removeAttribute("data-color-scheme")
+}
+
+export function observeViewerScheme(getHost: () => HTMLElement | undefined) {
+  if (typeof document === "undefined") return () => {}
+
+  applyViewerScheme(getHost())
+  if (typeof MutationObserver === "undefined") return () => {}
+
+  const root = document.documentElement
+  const monitor = new MutationObserver(() => applyViewerScheme(getHost()))
+  monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
+  return () => monitor.disconnect()
+}
+
+export function notifyShadowReady(opts: {
+  state: ReadyWatcher
+  container: HTMLElement
+  getRoot: () => ShadowRoot | undefined
+  isReady: (root: ShadowRoot) => boolean
+  onReady: () => void
+  settleFrames?: number
+}) {
+  clearReadyWatcher(opts.state)
+  opts.state.token += 1
+
+  const token = opts.state.token
+  const settle = Math.max(0, opts.settleFrames ?? 0)
+
+  const runReady = () => {
+    const step = (left: number) => {
+      if (token !== opts.state.token) return
+      if (left <= 0) {
+        opts.onReady()
+        return
+      }
+      requestAnimationFrame(() => step(left - 1))
+    }
+
+    requestAnimationFrame(() => step(settle))
+  }
+
+  const observeRoot = (root: ShadowRoot) => {
+    if (opts.isReady(root)) {
+      runReady()
+      return
+    }
+
+    if (typeof MutationObserver === "undefined") return
+
+    clearReadyWatcher(opts.state)
+    opts.state.observer = new MutationObserver(() => {
+      if (token !== opts.state.token) return
+      if (!opts.isReady(root)) return
+
+      clearReadyWatcher(opts.state)
+      runReady()
+    })
+    opts.state.observer.observe(root, { childList: true, subtree: true })
+  }
+
+  const root = opts.getRoot()
+  if (!root) {
+    if (typeof MutationObserver === "undefined") return
+
+    opts.state.observer = new MutationObserver(() => {
+      if (token !== opts.state.token) return
+
+      const next = opts.getRoot()
+      if (!next) return
+
+      observeRoot(next)
+    })
+    opts.state.observer.observe(opts.container, { childList: true, subtree: true })
+    return
+  }
+
+  observeRoot(root)
+}

+ 85 - 0
packages/ui/src/pierre/file-selection.ts

@@ -0,0 +1,85 @@
+import { type SelectedLineRange } from "@pierre/diffs"
+import { toRange } from "./selection-bridge"
+
+export function findElement(node: Node | null): HTMLElement | undefined {
+  if (!node) return
+  if (node instanceof HTMLElement) return node
+  return node.parentElement ?? undefined
+}
+
+export function findFileLineNumber(node: Node | null): number | undefined {
+  const el = findElement(node)
+  if (!el) return
+
+  const line = el.closest("[data-line]")
+  if (!(line instanceof HTMLElement)) return
+
+  const value = parseInt(line.dataset.line ?? "", 10)
+  if (Number.isNaN(value)) return
+  return value
+}
+
+export function findDiffLineNumber(node: Node | null): number | undefined {
+  const el = findElement(node)
+  if (!el) return
+
+  const line = el.closest("[data-line], [data-alt-line]")
+  if (!(line instanceof HTMLElement)) return
+
+  const primary = parseInt(line.dataset.line ?? "", 10)
+  if (!Number.isNaN(primary)) return primary
+
+  const alt = parseInt(line.dataset.altLine ?? "", 10)
+  if (!Number.isNaN(alt)) return alt
+}
+
+export function findCodeSelectionSide(node: Node | null): SelectedLineRange["side"] {
+  const el = findElement(node)
+  if (!el) return
+
+  const code = el.closest("[data-code]")
+  if (!(code instanceof HTMLElement)) return
+  if (code.hasAttribute("data-deletions")) return "deletions"
+  return "additions"
+}
+
+export function readShadowLineSelection(opts: {
+  root: ShadowRoot
+  lineForNode: (node: Node | null) => number | undefined
+  sideForNode?: (node: Node | null) => SelectedLineRange["side"]
+  preserveTextSelection?: boolean
+}) {
+  const selection =
+    (opts.root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
+  if (!selection || selection.isCollapsed) return
+
+  const domRange =
+    (
+      selection as unknown as {
+        getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => StaticRange[]
+      }
+    ).getComposedRanges?.({ shadowRoots: [opts.root] })?.[0] ??
+    (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
+
+  const startNode = domRange?.startContainer ?? selection.anchorNode
+  const endNode = domRange?.endContainer ?? selection.focusNode
+  if (!startNode || !endNode) return
+  if (!opts.root.contains(startNode) || !opts.root.contains(endNode)) return
+
+  const start = opts.lineForNode(startNode)
+  const end = opts.lineForNode(endNode)
+  if (start === undefined || end === undefined) return
+
+  const startSide = opts.sideForNode?.(startNode)
+  const endSide = opts.sideForNode?.(endNode)
+  const side = startSide ?? endSide
+
+  const range: SelectedLineRange = { start, end }
+  if (side) range.side = side
+  if (endSide && side && endSide !== side) range.endSide = endSide
+
+  return {
+    range,
+    text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
+  }
+}

+ 37 - 5
packages/ui/src/pierre/index.ts

@@ -1,5 +1,6 @@
 import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
 import { ComponentProps } from "solid-js"
+import { lineCommentStyles } from "../components/line-comment-styles"
 
 export type DiffProps<T = {}> = FileDiffOptions<T> & {
   before: FileContents
@@ -7,13 +8,15 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
   annotations?: DiffLineAnnotation<T>[]
   selectedLines?: SelectedLineRange | null
   commentedLines?: SelectedLineRange[]
+  onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
   onRendered?: () => void
   class?: string
   classList?: ComponentProps<"div">["classList"]
 }
 
 const unsafeCSS = `
-[data-diff] {
+[data-diff],
+[data-file] {
   --diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg));
   --diffs-bg-buffer: var(--diffs-bg-buffer-override, light-dark( color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer))));
   --diffs-bg-hover: var(--diffs-bg-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer))));
@@ -44,7 +47,8 @@ const unsafeCSS = `
   --diffs-bg-selection-text: rgb(from var(--surface-warning-strong) r g b / 0.2);
 }
 
-:host([data-color-scheme='dark']) [data-diff] {
+:host([data-color-scheme='dark']) [data-diff],
+:host([data-color-scheme='dark']) [data-file] {
   --diffs-selection-number-fg: #fdfbfb;
   --diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--solaris-dark-6) r g b / 0.65));
   --diffs-bg-selection-number: var(
@@ -53,7 +57,8 @@ const unsafeCSS = `
   );
 }
 
-[data-diff] ::selection {
+[data-diff] ::selection,
+[data-file] ::selection {
   background-color: var(--diffs-bg-selection-text);
 }
 
@@ -69,25 +74,48 @@ const unsafeCSS = `
   box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
 }
 
+[data-file] [data-line][data-comment-selected]:not([data-selected-line]) {
+  box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
+}
+
 [data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) {
   box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
   color: var(--diffs-selection-number-fg);
 }
 
+[data-file] [data-column-number][data-comment-selected]:not([data-selected-line]) {
+  box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
+  color: var(--diffs-selection-number-fg);
+}
+
 [data-diff] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
   box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
 }
 
+[data-file] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
+  box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
+}
+
 [data-diff] [data-line][data-selected-line] {
   background-color: var(--diffs-bg-selection);
   box-shadow: inset 2px 0 0 var(--diffs-selection-border);
 }
 
+[data-file] [data-line][data-selected-line] {
+  background-color: var(--diffs-bg-selection);
+  box-shadow: inset 2px 0 0 var(--diffs-selection-border);
+}
+
 [data-diff] [data-column-number][data-selected-line] {
   background-color: var(--diffs-bg-selection-number);
   color: var(--diffs-selection-number-fg);
 }
 
+[data-file] [data-column-number][data-selected-line] {
+  background-color: var(--diffs-bg-selection-number);
+  color: var(--diffs-selection-number-fg);
+}
+
 [data-diff] [data-column-number][data-line-type='context'][data-selected-line],
 [data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
 [data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
@@ -123,9 +151,13 @@ const unsafeCSS = `
   }
   [data-code] {
     overflow-x: auto !important;
-    overflow-y: hidden !important;
+    overflow-y: clip !important;
   }
-}`
+}
+
+${lineCommentStyles}
+
+`
 
 export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
   return {

+ 110 - 0
packages/ui/src/pierre/media.ts

@@ -0,0 +1,110 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+
+export type MediaKind = "image" | "audio" | "svg"
+
+const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
+const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
+
+type MediaValue = unknown
+
+function mediaRecord(value: unknown) {
+  if (!value || typeof value !== "object") return
+  return value as Partial<FileContent> & {
+    content?: unknown
+    encoding?: unknown
+    mimeType?: unknown
+    type?: unknown
+  }
+}
+
+export function normalizeMimeType(type: string | undefined) {
+  if (!type) return
+  const mime = type.split(";", 1)[0]?.trim().toLowerCase()
+  if (!mime) return
+  if (mime === "audio/x-aac") return "audio/aac"
+  if (mime === "audio/x-m4a") return "audio/mp4"
+  return mime
+}
+
+export function fileExtension(path: string | undefined) {
+  if (!path) return ""
+  const idx = path.lastIndexOf(".")
+  if (idx === -1) return ""
+  return path.slice(idx + 1).toLowerCase()
+}
+
+export function mediaKindFromPath(path: string | undefined): MediaKind | undefined {
+  const ext = fileExtension(path)
+  if (ext === "svg") return "svg"
+  if (imageExtensions.has(ext)) return "image"
+  if (audioExtensions.has(ext)) return "audio"
+}
+
+export function isBinaryContent(value: MediaValue) {
+  return mediaRecord(value)?.type === "binary"
+}
+
+function validDataUrl(value: string, kind: MediaKind) {
+  if (kind === "svg") return value.startsWith("data:image/svg+xml") ? value : undefined
+  if (kind === "image") return value.startsWith("data:image/") ? value : undefined
+  if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;")
+  if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;")
+  if (value.startsWith("data:audio/")) return value
+}
+
+export function dataUrlFromMediaValue(value: MediaValue, kind: MediaKind) {
+  if (!value) return
+
+  if (typeof value === "string") {
+    return validDataUrl(value, kind)
+  }
+
+  const record = mediaRecord(value)
+  if (!record) return
+
+  if (typeof record.content !== "string") return
+
+  const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
+  if (!mime) return
+
+  if (kind === "svg") {
+    if (mime !== "image/svg+xml") return
+    if (record.encoding === "base64") return `data:image/svg+xml;base64,${record.content}`
+    return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(record.content)}`
+  }
+
+  if (kind === "image" && !mime.startsWith("image/")) return
+  if (kind === "audio" && !mime.startsWith("audio/")) return
+  if (record.encoding !== "base64") return
+
+  return `data:${mime};base64,${record.content}`
+}
+
+function decodeBase64Utf8(value: string) {
+  if (typeof atob !== "function") return
+
+  try {
+    const raw = atob(value)
+    const bytes = Uint8Array.from(raw, (x) => x.charCodeAt(0))
+    if (typeof TextDecoder === "function") return new TextDecoder().decode(bytes)
+    return raw
+  } catch {}
+}
+
+export function svgTextFromValue(value: MediaValue) {
+  const record = mediaRecord(value)
+  if (!record) return
+  if (typeof record.content !== "string") return
+
+  const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
+  if (mime !== "image/svg+xml") return
+  if (record.encoding === "base64") return decodeBase64Utf8(record.content)
+  return record.content
+}
+
+export function hasMediaValue(value: MediaValue) {
+  if (typeof value === "string") return value.length > 0
+  const record = mediaRecord(value)
+  if (!record) return false
+  return typeof record.content === "string" && record.content.length > 0
+}

+ 129 - 0
packages/ui/src/pierre/selection-bridge.ts

@@ -0,0 +1,129 @@
+import { type SelectedLineRange } from "@pierre/diffs"
+
+type PointerMode = "none" | "text" | "numbers"
+type Side = SelectedLineRange["side"]
+type LineSpan = Pick<SelectedLineRange, "start" | "end">
+
+export function formatSelectedLineLabel(range: LineSpan) {
+  const start = Math.min(range.start, range.end)
+  const end = Math.max(range.start, range.end)
+  if (start === end) return `line ${start}`
+  return `lines ${start}-${end}`
+}
+
+export function previewSelectedLines(source: string, range: LineSpan) {
+  const start = Math.max(1, Math.min(range.start, range.end))
+  const end = Math.max(range.start, range.end)
+  const lines = source.split("\n").slice(start - 1, end)
+  if (lines.length === 0) return
+  return lines.slice(0, 2).join("\n")
+}
+
+export function cloneSelectedLineRange(range: SelectedLineRange): SelectedLineRange {
+  const next: SelectedLineRange = {
+    start: range.start,
+    end: range.end,
+  }
+
+  if (range.side) next.side = range.side
+  if (range.endSide) next.endSide = range.endSide
+  return next
+}
+
+export function lineInSelectedRange(range: SelectedLineRange | null | undefined, line: number, side?: Side) {
+  if (!range) return false
+
+  const start = Math.min(range.start, range.end)
+  const end = Math.max(range.start, range.end)
+  if (line < start || line > end) return false
+  if (!side) return true
+
+  const first = range.side
+  const last = range.endSide ?? first
+  if (!first && !last) return true
+  if (!first || !last) return (first ?? last) === side
+  if (first === last) return first === side
+  if (line === start) return first === side
+  if (line === end) return last === side
+  return true
+}
+
+export function isSingleLineSelection(range: SelectedLineRange | null) {
+  if (!range) return false
+  return range.start === range.end && (range.endSide == null || range.endSide === range.side)
+}
+
+export function toRange(source: Range | StaticRange): Range {
+  if (source instanceof Range) return source
+  const range = new Range()
+  range.setStart(source.startContainer, source.startOffset)
+  range.setEnd(source.endContainer, source.endOffset)
+  return range
+}
+
+export function restoreShadowTextSelection(root: ShadowRoot | undefined, range: Range | undefined) {
+  if (!root || !range) return
+
+  requestAnimationFrame(() => {
+    const selection =
+      (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
+    if (!selection) return
+
+    try {
+      selection.removeAllRanges()
+      selection.addRange(range)
+    } catch {}
+  })
+}
+
+export function createLineNumberSelectionBridge() {
+  let mode: PointerMode = "none"
+  let line: number | undefined
+  let moved = false
+  let pending = false
+
+  const clear = () => {
+    mode = "none"
+    line = undefined
+    moved = false
+  }
+
+  return {
+    begin(numberColumn: boolean, next: number | undefined) {
+      if (!numberColumn) {
+        mode = "text"
+        return
+      }
+
+      mode = "numbers"
+      line = next
+      moved = false
+    },
+    track(buttons: number, next: number | undefined) {
+      if (mode !== "numbers") return false
+
+      if ((buttons & 1) === 0) {
+        clear()
+        return true
+      }
+
+      if (next !== undefined && line !== undefined && next !== line) moved = true
+      return true
+    },
+    finish() {
+      const current = mode
+      pending = current === "numbers" && moved
+      clear()
+      return current
+    },
+    consume(range: SelectedLineRange | null) {
+      const result = pending && !isSingleLineSelection(range)
+      pending = false
+      return result
+    },
+    reset() {
+      pending = false
+      clear()
+    },
+  }
+}

+ 1 - 3
packages/ui/src/styles/index.css

@@ -13,9 +13,8 @@
 @import "../components/button.css" layer(components);
 @import "../components/card.css" layer(components);
 @import "../components/checkbox.css" layer(components);
-@import "../components/code.css" layer(components);
+@import "../components/file.css" layer(components);
 @import "../components/collapsible.css" layer(components);
-@import "../components/diff.css" layer(components);
 @import "../components/diff-changes.css" layer(components);
 @import "../components/context-menu.css" layer(components);
 @import "../components/dropdown-menu.css" layer(components);
@@ -28,7 +27,6 @@
 @import "../components/icon-button.css" layer(components);
 @import "../components/image-preview.css" layer(components);
 @import "../components/keybind.css" layer(components);
-@import "../components/line-comment.css" layer(components);
 @import "../components/text-field.css" layer(components);
 @import "../components/inline-input.css" layer(components);
 @import "../components/list.css" layer(components);

+ 426 - 0
specs/file-component-unification-plan.md

@@ -0,0 +1,426 @@
+# File Component Unification Plan
+
+Single path for text, diff, and media
+
+---
+
+## Define goal
+
+Introduce one public UI component API that renders plain text files or diffs from the same entry point, so selection, comments, search, theming, and media behavior are maintained once.
+
+### Goal
+
+- Add a unified `File` component in `packages/ui/src/components/file.tsx` that chooses plain or diff rendering from props.
+- Centralize shared behavior now split between `packages/ui/src/components/code.tsx` and `packages/ui/src/components/diff.tsx`.
+- Bring the existing find/search UX to diff rendering through a shared engine.
+- Consolidate media rendering logic currently split across `packages/ui/src/components/session-review.tsx` and `packages/app/src/pages/session/file-tabs.tsx`.
+- Provide a clear SSR path for preloaded diffs without keeping a third independent implementation.
+
+### Non-goal
+
+- Do not change `@pierre/diffs` behavior or fork its internals.
+- Do not redesign line comment UX, diff visuals, or keyboard shortcuts.
+- Do not remove legacy `Code`/`Diff` APIs in the first pass.
+- Do not add new media types beyond parity unless explicitly approved.
+- Do not refactor unrelated session review or file tab layout code outside integration points.
+
+---
+
+## Audit duplication
+
+The current split duplicates runtime logic and makes feature parity drift likely.
+
+### Duplicate categories
+
+- Rendering lifecycle is duplicated in `code.tsx` and `diff.tsx`, including instance creation, cleanup, `onRendered` readiness, and shadow root lookup.
+- Theme sync is duplicated in `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` through similar `applyScheme` and `MutationObserver` code.
+- Line selection wiring is duplicated in `code.tsx` and `diff.tsx`, including drag state, shadow selection reads, and line-number bridge integration.
+- Comment annotation rerender flow is duplicated in `code.tsx`, `diff.tsx`, and `diff-ssr.tsx`.
+- Commented line marking is split across `markCommentedFileLines` and `markCommentedDiffLines`, with similar timing and effect wiring.
+- Diff selection normalization (`fixSelection`) exists twice in `diff.tsx` and `diff-ssr.tsx`.
+- Search exists only in `code.tsx`, so diff lacks find and the feature cannot be maintained in one place.
+- Contexts are split (`context/code.tsx`, `context/diff.tsx`), which forces consumers to choose paths early.
+- Media rendering is duplicated outside the core viewers in `session-review.tsx` and `file-tabs.tsx`.
+
+### Drift pain points
+
+- Any change to comments, theming, or selection requires touching multiple files.
+- Diff SSR and client diff can drift because they carry separate normalization and marking code.
+- Search cannot be added to diff cleanly without more duplication unless the viewer runtime is unified.
+
+---
+
+## Design architecture
+
+Use one public component with a discriminated prop shape and split shared behavior into small runtime modules.
+
+### Public API proposal
+
+- Add `packages/ui/src/components/file.tsx` as the primary client entry point.
+- Export a single `File` component that accepts a discriminated union with two primary modes.
+- Use an explicit `mode` prop (`"text"` or `"diff"`) to avoid ambiguous prop inference and keep type errors clear.
+
+### Proposed prop shape
+
+- Shared props:
+  - `annotations`
+  - `selectedLines`
+  - `commentedLines`
+  - `onLineSelected`
+  - `onLineSelectionEnd`
+  - `onLineNumberSelectionEnd`
+  - `onRendered`
+  - `class`
+  - `classList`
+  - selection and hover flags already supported by current viewers
+- Text mode props:
+  - `mode: "text"`
+  - `file` (`FileContents`)
+  - text renderer options from `@pierre/diffs` `FileOptions`
+- Diff mode props:
+  - `mode: "diff"`
+  - `before`
+  - `after`
+  - `diffStyle`
+  - diff renderer options from `FileDiffOptions`
+  - optional `preloadedDiff` only for SSR-aware entry or hydration adapter
+- Media props (shared, optional):
+  - `media` config for `"auto" | "off"` behavior
+  - path/name metadata
+  - optional lazy loader (`readFile`) for session review use
+  - optional custom placeholders for binary or removed content
+
+### Internal module split
+
+- `packages/ui/src/components/file.tsx`
+  Public unified component and mode routing.
+- `packages/ui/src/components/file-ssr.tsx`
+  Unified SSR entry for preloaded diff hydration.
+- `packages/ui/src/components/file-search.tsx`
+  Shared find bar UI and host registration.
+- `packages/ui/src/components/file-media.tsx`
+  Shared image/audio/svg/binary rendering shell.
+- `packages/ui/src/pierre/file-runtime.ts`
+  Common render lifecycle, instance setup, cleanup, scheme sync, and readiness notification.
+- `packages/ui/src/pierre/file-selection.ts`
+  Shared selection/drag/line-number bridge controller with mode adapters.
+- `packages/ui/src/pierre/diff-selection.ts`
+  Diff-specific `fixSelection` and row/side normalization reused by client and SSR.
+- `packages/ui/src/pierre/file-find.ts`
+  Shared find engine (scan, highlight API, overlay fallback, match navigation).
+- `packages/ui/src/pierre/media.ts`
+  MIME normalization, data URL helpers, and media type detection.
+
+### Wrapper strategy
+
+- Keep `packages/ui/src/components/code.tsx` as a thin compatibility wrapper over unified `File` in text mode.
+- Keep `packages/ui/src/components/diff.tsx` as a thin compatibility wrapper over unified `File` in diff mode.
+- Keep `packages/ui/src/components/diff-ssr.tsx` as a thin compatibility wrapper over unified SSR entry.
+
+---
+
+## Phase delivery
+
+Ship this in small phases so each step is reviewable and reversible.
+
+### Phase 0: Align interfaces
+
+- Document the final prop contract and adapter behavior before moving logic.
+- Add a short migration note in the plan PR description so reviewers know wrappers stay in place.
+
+#### Acceptance
+
+- Final prop names and mode shape are agreed up front.
+- No runtime code changes land yet.
+
+### Phase 1: Extract shared runtime pieces
+
+- Move duplicated theme sync and render readiness logic from `code.tsx` and `diff.tsx` into shared runtime helpers.
+- Move diff selection normalization (`fixSelection` and helpers) out of both `diff.tsx` and `diff-ssr.tsx` into `packages/ui/src/pierre/diff-selection.ts`.
+- Extract shared selection controller flow into `packages/ui/src/pierre/file-selection.ts` with mode callbacks for line parsing and normalization.
+- Keep `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` behavior unchanged from the outside.
+
+#### Acceptance
+
+- `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` are smaller and call shared helpers.
+- Line selection, comments, and theme sync still work in current consumers.
+- No consumer imports change yet.
+
+### Phase 2: Introduce unified client entry
+
+- Create `packages/ui/src/components/file.tsx` and wire it to shared runtime pieces.
+- Route text mode to `@pierre/diffs` `File` or `VirtualizedFile` and diff mode to `FileDiff` or `VirtualizedFileDiff`.
+- Preserve current performance rules, including virtualization thresholds and large-diff options.
+- Keep search out of this phase if it risks scope creep, but leave extension points in place.
+
+#### Acceptance
+
+- New unified component renders text and diff with parity to existing components.
+- `code.tsx` and `diff.tsx` can be rewritten as thin adapters without behavior changes.
+- Existing consumers still work through old `Code` and `Diff` exports.
+
+### Phase 3: Add unified context path
+
+- Add `packages/ui/src/context/file.tsx` with `FileComponentProvider` and `useFileComponent`.
+- Update `packages/ui/src/context/index.ts` to export the new context.
+- Keep `context/code.tsx` and `context/diff.tsx` as compatibility shims that adapt to `useFileComponent`.
+- Migrate `packages/app/src/app.tsx` and `packages/enterprise/src/routes/share/[shareID].tsx` to provide the unified component once wrappers are stable.
+
+#### Acceptance
+
+- New consumers can use one context path.
+- Existing `useCodeComponent` and `useDiffComponent` hooks still resolve and render correctly.
+- Provider wiring in app and enterprise stays compatible during transition.
+
+### Phase 4: Share find and enable diff search
+
+- Extract the find engine and find bar UI from `code.tsx` into shared modules.
+- Hook the shared find host into unified `File` for both text and diff modes.
+- Keep current shortcuts (`Ctrl/Cmd+F`, `Ctrl/Cmd+G`, `Shift+Ctrl/Cmd+G`) and active-host behavior.
+- Preserve CSS Highlight API support with overlay fallback.
+
+#### Acceptance
+
+- Text mode search behaves the same as today.
+- Diff mode now supports the same find UI and shortcuts.
+- Multiple viewer instances still route shortcuts to the focused/active host correctly.
+
+### Phase 5: Consolidate media rendering
+
+- Extract media type detection and data URL helpers from `session-review.tsx` and `file-tabs.tsx` into shared UI helpers.
+- Add `file-media.tsx` and let unified `File` optionally render media or binary placeholders before falling back to text/diff.
+- Migrate `session-review.tsx` and `file-tabs.tsx` to pass media props instead of owning media-specific branches.
+- Keep session-specific layout and i18n strings in the consumer where they are not generic.
+
+#### Acceptance
+
+- Image/audio/svg/binary handling no longer duplicates core detection and load state logic.
+- Session review and file tabs still render the same media states and placeholders.
+- Text/diff comment and selection behavior is unchanged when media is not shown.
+
+### Phase 6: Align SSR and preloaded diffs
+
+- Create `packages/ui/src/components/file-ssr.tsx` with the same unified prop shape plus `preloadedDiff`.
+- Reuse shared diff normalization, theme sync, and commented-line marking helpers.
+- Convert `packages/ui/src/components/diff-ssr.tsx` into a thin adapter that forwards to the unified SSR entry in diff mode.
+- Migrate enterprise share page imports to `@opencode-ai/ui/file-ssr` when convenient, but keep `diff-ssr` export working.
+
+#### Acceptance
+
+- Preloaded diff hydration still works in `packages/enterprise/src/routes/share/[shareID].tsx`.
+- SSR diff and client diff now share normalization and comment marking helpers.
+- No duplicate `fixSelection` implementation remains.
+
+### Phase 7: Clean up and document
+
+- Remove dead internal helpers left behind in `code.tsx` and `diff.tsx`.
+- Add a short migration doc for downstream consumers that want to switch from `Code`/`Diff` to unified `File`.
+- Mark `Code`/`Diff` contexts and components as compatibility APIs in comments or docs.
+
+#### Acceptance
+
+- No stale duplicate helpers remain in legacy wrappers.
+- Unified path is the default recommendation for new UI work.
+
+---
+
+## Preserve compatibility
+
+Keep old APIs working while moving internals under them.
+
+### Context migration strategy
+
+- Introduce `FileComponentProvider` without deleting `CodeComponentProvider` or `DiffComponentProvider`.
+- Implement `useCodeComponent` and `useDiffComponent` as adapters around the unified context where possible.
+- If full adapter reuse is messy at first, keep old contexts and providers as thin wrappers that internally provide mapped unified props.
+
+### Consumer migration targets
+
+- `packages/app/src/pages/session/file-tabs.tsx` should move from `useCodeComponent` to `useFileComponent`.
+- `packages/ui/src/components/session-review.tsx`, `session-turn.tsx`, and `message-part.tsx` should move from `useDiffComponent` to `useFileComponent`.
+- `packages/app/src/app.tsx` and `packages/enterprise/src/routes/share/[shareID].tsx` should eventually provide only the unified provider.
+- Keep legacy hooks available until all call sites are migrated and reviewed.
+
+### Compatibility checkpoints
+
+- `@opencode-ai/ui/code`, `@opencode-ai/ui/diff`, and `@opencode-ai/ui/diff-ssr` imports must keep working during migration.
+- Existing prop names on `Code` and `Diff` wrappers should remain stable to avoid broad app changes in one PR.
+
+---
+
+## Unify search
+
+Port the current find feature into a shared engine and attach it to both modes.
+
+### Shared engine plan
+
+- Move keyboard host registry and active-target logic out of `code.tsx` into `packages/ui/src/pierre/file-find.ts`.
+- Move the find bar UI into `packages/ui/src/components/file-search.tsx`.
+- Keep DOM-based scanning and highlight/overlay rendering shared, since both text and diff render into the same shadow-root patterns.
+
+### Diff-specific handling
+
+- Search should scan both unified and split diff columns through the same selectors used in the current code find feature.
+- Match navigation should scroll the active range into view without interfering with line selection state.
+- Search refresh should run after `onRendered`, diff style changes, annotation rerenders, and query changes.
+
+### Scope guard
+
+- Preserve the current DOM-scan behavior first, even if virtualized search is limited to mounted rows.
+- If full-document virtualized search is required, treat it as a follow-up with a text-index layer rather than blocking the core refactor.
+
+---
+
+## Consolidate media
+
+Move media rendering logic into shared UI so text, diff, and media routing live behind one entry.
+
+### Ownership plan
+
+- Put media detection and normalization helpers in `packages/ui/src/pierre/media.ts`.
+- Put shared rendering UI in `packages/ui/src/components/file-media.tsx`.
+- Keep layout-specific wrappers in `session-review.tsx` and `file-tabs.tsx`, but remove duplicated media branching and load-state code from them.
+
+### Proposed media props
+
+- `media.mode`: `"auto"` or `"off"` for default behavior.
+- `media.path`: file path for extension checks and labels.
+- `media.current`: loaded file content for plain-file views.
+- `media.before` and `media.after`: diff-side values for image/audio previews.
+- `media.readFile`: optional lazy loader for session review expansion.
+- `media.renderBinaryPlaceholder`: optional consumer override for binary states.
+- `media.renderLoading` and `media.renderError`: optional consumer overrides when generic text is not enough.
+
+### Parity targets
+
+- Keep current image and audio support from session review.
+- Keep current SVG and binary handling from file tabs.
+- Defer video or PDF support unless explicitly requested.
+
+---
+
+## Align SSR
+
+Make SSR diff hydration a mode of the unified viewer instead of a parallel implementation.
+
+### SSR plan
+
+- Add `packages/ui/src/components/file-ssr.tsx` as the unified SSR entry with a diff-only path in phase one.
+- Reuse shared diff helpers for `fixSelection`, theme sync, and commented-line marking.
+- Keep the private `fileContainer` hydration workaround isolated in the SSR module so client code stays clean.
+
+### Integration plan
+
+- Keep `packages/ui/src/components/diff-ssr.tsx` as a forwarding adapter for compatibility.
+- Update enterprise share route to the unified SSR import after client and context migrations are stable.
+- Align prop names with the client `File` component so `SessionReview` can swap client/SSR providers without branching logic.
+
+### Defer item
+
+- Plain-file SSR hydration is not needed for this refactor and can stay out of scope.
+
+---
+
+## Verify behavior
+
+Use typechecks and targeted UI checks after each phase, and avoid repo-root runs.
+
+### Typecheck plan
+
+- Run `bun run typecheck` from `packages/ui` after phases 1-7 changes there.
+- Run `bun run typecheck` from `packages/app` after migrating file tabs or app provider wiring.
+- Run `bun run typecheck` from `packages/enterprise` after SSR/provider changes on the share route.
+
+### Targeted UI checks
+
+- Text mode:
+  - small file render
+  - virtualized large file render
+  - drag selection and line-number selection
+  - comment annotations and commented-line marks
+  - find shortcuts and match navigation
+- Diff mode:
+  - unified and split styles
+  - large diff fallback options
+  - diff selection normalization across sides
+  - comments and commented-line marks
+  - new find UX parity
+- Media:
+  - image, audio, SVG, and binary states in file tabs
+  - image and audio diff previews in session review
+  - lazy load and error placeholders
+- SSR:
+  - enterprise share page preloaded diffs hydrate correctly
+  - theme switching still updates hydrated diffs
+
+### Regression focus
+
+- Watch scroll restore behavior in `packages/app/src/pages/session/file-tabs.tsx`.
+- Watch multi-instance find shortcut routing in screens with many viewers.
+- Watch cleanup paths for listeners and virtualizers to avoid leaks.
+
+---
+
+## Manage risk
+
+Keep wrappers and adapters in place until the unified path is proven.
+
+### Key risks
+
+- Selection regressions are the highest risk because text and diff have similar but not identical line semantics.
+- SSR hydration can break subtly if client and SSR prop shapes drift.
+- Shared find host state can misroute shortcuts when many viewers are mounted.
+- Media consolidation can accidentally change placeholder timing or load behavior.
+
+### Rollback strategy
+
+- Land each phase in separate PRs or clearly separated commits on `dev`.
+- If a phase regresses behavior, revert only that phase and keep earlier extractions.
+- Keep `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` wrappers intact until final verification, so a rollback only changes internals.
+- If diff search is unstable, disable it behind the unified component while keeping the rest of the refactor.
+
+---
+
+## Order implementation
+
+Follow this sequence to keep reviews small and reduce merge risk.
+
+1. Finalize prop shape and file names for the unified component and context.
+2. Extract shared diff normalization, theme sync, and render-ready helpers with no public API changes.
+3. Extract shared selection controller and migrate `code.tsx` and `diff.tsx` to it.
+4. Add the unified client `File` component and convert `code.tsx`/`diff.tsx` into wrappers.
+5. Add `FileComponentProvider` and migrate provider wiring in `app.tsx` and enterprise share route.
+6. Migrate consumer hooks (`file-tabs`, `session-review`, `message-part`, `session-turn`) to the unified context.
+7. Extract and share find engine/UI, then enable search in diff mode.
+8. Extract media helpers/UI and migrate `session-review.tsx` and `file-tabs.tsx`.
+9. Add unified `file-ssr.tsx`, convert `diff-ssr.tsx` to a wrapper, and migrate enterprise imports.
+10. Remove dead duplication and write a short migration note for future consumers.
+
+---
+
+## Decide open items
+
+Resolve these before coding to avoid rework mid-refactor.
+
+### API decisions
+
+- Should the unified component require `mode`, or should it infer mode from props for convenience.
+- Should the public export be named `File` only, or also ship a temporary alias like `UnifiedFile` for migration clarity.
+- Should `preloadedDiff` live on the main `File` props or only on `file-ssr.tsx`.
+
+### Search decisions
+
+- Is DOM-only search acceptable for virtualized content in the first pass.
+- Should find state reset on every rerender, or preserve query and index across diff style toggles.
+
+### Media decisions
+
+- Which placeholders and strings should stay consumer-owned versus shared in UI.
+- Whether SVG should be treated as media-only, text-only, or a mixed mode with both preview and source.
+- Whether video support should be included now or explicitly deferred.
+
+### Migration decisions
+
+- How long `CodeComponentProvider` and `DiffComponentProvider` should remain supported.
+- Whether to migrate all consumers in one PR after wrappers land, or in follow-up PRs by surface area.
+- Whether `diff-ssr` should remain as a permanent alias for compatibility.

+ 234 - 0
specs/session-review-cross-diff-search-plan.md

@@ -0,0 +1,234 @@
+# Session Review Cross-Diff Search Plan
+
+One search input for all diffs in the review pane
+
+---
+
+## Goal
+
+Add a single search UI to `SessionReview` that searches across all diff files in the accordion and supports next/previous navigation across files.
+
+Navigation should auto-open the target accordion item and reveal the active match inside the existing unified `File` diff viewer.
+
+---
+
+## Non-goals
+
+- Do not change diff rendering visuals, line comments, or file selection behavior.
+- Do not add regex, fuzzy search, or replace.
+- Do not change `@pierre/diffs` internals.
+
+---
+
+## Current behavior
+
+- `SessionReview` renders one `File` diff viewer per accordion item, but only mounts the viewer when that item is expanded.
+- Large diffs may be blocked behind the `MAX_DIFF_CHANGED_LINES` gate until the user clicks "render anyway".
+- `File` owns a local search engine (`createFileFind`) with:
+  - query state
+  - hit counting
+  - current match index
+  - highlighting (CSS Highlight API or overlay fallback)
+  - `Cmd/Ctrl+F` and `Cmd/Ctrl+G` keyboard handling
+- `FileSearchBar` is currently rendered per viewer.
+- There is no parent-level search state in `SessionReview`.
+
+---
+
+## UX requirements
+
+- Add one search bar in the `SessionReview` header (input, total count, prev, next, close).
+- Show a global count like `3/17` across all searchable diffs.
+- `Cmd/Ctrl+F` inside the session review pane opens the session-level search.
+- `Cmd/Ctrl+G`, `Shift+Cmd/Ctrl+G`, `Enter`, and `Shift+Enter` navigate globally.
+- Navigating to a match in a collapsed file auto-expands that file.
+- The active match scrolls into view and is highlighted in the target viewer.
+- Media/binary diffs are excluded from search.
+- Empty query clears highlights and resets to `0/0`.
+
+---
+
+## Architecture proposal
+
+Use a hybrid model:
+
+- A **session-level match index** for global searching/counting/navigation across all diffs.
+- The existing **per-viewer search engine** for local highlighting and scrolling in the active file.
+
+This avoids mounting every accordion item just to search while reusing the existing DOM highlight behavior.
+
+### High-level pieces
+
+- `SessionReview` owns the global query, hit list, and active hit index.
+- `File` exposes a small controlled search handle (register, set query, clear, reveal hit).
+- `SessionReview` keeps a map of mounted file viewers and their search handles.
+- `SessionReview` resolves next/prev hits, expands files as needed, then tells the target viewer to reveal the hit.
+
+---
+
+## Data model and interfaces
+
+```ts
+type SessionSearchHit = {
+  file: string
+  side: "additions" | "deletions"
+  line: number
+  col: number
+  len: number
+}
+
+type SessionSearchState = {
+  query: string
+  hits: SessionSearchHit[]
+  active: number
+}
+```
+
+```ts
+type FileSearchReveal = {
+  side: "additions" | "deletions"
+  line: number
+  col: number
+  len: number
+}
+
+type FileSearchHandle = {
+  setQuery: (value: string) => void
+  clear: () => void
+  reveal: (hit: FileSearchReveal) => boolean
+  refresh: () => void
+}
+```
+
+```ts
+type FileSearchControl = {
+  shortcuts?: "global" | "disabled"
+  showBar?: boolean
+  register: (handle: FileSearchHandle | null) => void
+}
+```
+
+---
+
+## Integration steps
+
+### Phase 1: Expose controlled search on `File`
+
+- Extend `createFileFind` and `File` to support a controlled search handle.
+- Keep existing per-viewer search behavior as the default path.
+- Add a way to disable per-viewer global shortcuts when hosted inside `SessionReview`.
+
+#### Acceptance
+
+- `File` still supports local search unchanged by default.
+- `File` can optionally register a search handle and accept controlled reveal calls.
+
+### Phase 2: Add session-level search state in `SessionReview`
+
+- Add a single search UI in the `SessionReview` header (can reuse `FileSearchBar` visuals or extract shared presentational pieces).
+- Build a global hit list from `props.diffs` string content.
+- Index hits by file/side/line/column/length.
+
+#### Acceptance
+
+- Header search appears once for the pane.
+- Global hit count updates as query changes.
+- Media/binary diffs are excluded.
+
+### Phase 3: Wire global navigation to viewers
+
+- Register a `FileSearchHandle` per mounted diff viewer.
+- On next/prev, resolve the active global hit and:
+  1. expand the target file if needed
+  2. wait for the viewer to mount/render
+  3. call `handle.setQuery(query)` and `handle.reveal(hit)`
+
+#### Acceptance
+
+- Next/prev moves across files.
+- Collapsed targets auto-open.
+- Active match is highlighted in the target diff.
+
+### Phase 4: Handle large-diff gating
+
+- Lift `render anyway` state from local accordion item state into a file-keyed map in `SessionReview`.
+- If navigation targets a gated file, force-render it before reveal.
+
+#### Acceptance
+
+- Global search can navigate into a large diff without manual user expansion/render.
+
+### Phase 5: Keyboard and race-condition polish
+
+- Route `Cmd/Ctrl+F`, `Cmd/Ctrl+G`, `Shift+Cmd/Ctrl+G` to session search when focus is in the review pane.
+- Add token/cancel guards so fast navigation does not reveal stale targets after async mounts.
+
+#### Acceptance
+
+- Keyboard shortcuts consistently target session-level search.
+- No stale reveal jumps during rapid navigation.
+
+---
+
+## Edge cases
+
+- Empty query: clear all viewer highlights, reset count/index.
+- No results: keep the search bar open, disable prev/next.
+- Added/deleted files: index only the available side.
+- Collapsed files: queue reveal until `onRendered` fires.
+- Large diffs: auto-force render before reveal.
+- Split diff mode: handle duplicate text on both sides without losing side info.
+- Do not clear line comment draft or selected lines when navigating search results.
+
+---
+
+## Testing plan
+
+### Unit tests
+
+- Session hit-index builder:
+  - line/column mapping
+  - additions/deletions side tagging
+  - wrap-around next/prev behavior
+- `File` controlled search handle:
+  - `setQuery`
+  - `clear`
+  - `reveal` by side/line/column in unified and split diff
+
+### Component / integration tests
+
+- Search across multiple diffs and navigate across collapsed accordion items.
+- Global counter updates correctly (`current/total`).
+- Split and unified diff styles both navigate correctly.
+- Large diff target auto-renders on navigation.
+- Existing line comment draft remains intact while searching.
+
+### Manual verification
+
+- `Cmd/Ctrl+F` opens session-level search in the review pane.
+- `Cmd/Ctrl+G` / `Shift+Cmd/Ctrl+G` navigate globally.
+- Highlighting and scroll behavior stay stable with many open diffs.
+
+---
+
+## Risks and rollback
+
+### Key risks
+
+- Global index and DOM highlights can drift if line/column mapping does not match viewer DOM content exactly.
+- Keyboard shortcut conflicts between session-level search and per-viewer search.
+- Performance impact when indexing many large diffs in one session.
+
+### Rollback plan
+
+- Gate session-level search behind a `SessionReview` prop/flag during rollout.
+- If unstable, disable the session-level path and keep existing per-viewer search unchanged.
+
+---
+
+## Open questions
+
+- Should search match file paths as well as content, or content only?
+- In split mode, should the same text on both sides count as two matches?
+- Should auto-navigation into gated large diffs silently render them, or show a prompt first?
+- Should the session-level search bar reuse `FileSearchBar` directly or split out a shared non-portal variant?