فهرست منبع

wip(app): line selection

Adam 1 ماه پیش
والد
کامیت
cb481d9ac8

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

@@ -19,6 +19,7 @@ import { SettingsProvider } from "@/context/settings"
 import { TerminalProvider } from "@/context/terminal"
 import { PromptProvider } from "@/context/prompt"
 import { FileProvider } from "@/context/file"
+import { CommentsProvider } from "@/context/comments"
 import { NotificationProvider } from "@/context/notification"
 import { DialogProvider } from "@opencode-ai/ui/context/dialog"
 import { CommandProvider } from "@/context/command"
@@ -128,13 +129,15 @@ export function AppInterface(props: { defaultUrl?: string }) {
                   component={(p) => (
                     <Show when={p.params.id ?? "new"}>
                       <TerminalProvider>
-                        <FileProvider>
-                          <PromptProvider>
-                            <Suspense fallback={<Loading />}>
-                              <Session />
-                            </Suspense>
-                          </PromptProvider>
-                        </FileProvider>
+                         <FileProvider>
+                           <PromptProvider>
+                             <CommentsProvider>
+                             <Suspense fallback={<Loading />}>
+                               <Session />
+                             </Suspense>
+                             </CommentsProvider>
+                           </PromptProvider>
+                         </FileProvider>
                       </TerminalProvider>
                     </Show>
                   )}

+ 19 - 2
packages/app/src/components/prompt-input.tsx

@@ -30,6 +30,7 @@ import { useLayout } from "@/context/layout"
 import { useSDK } from "@/context/sdk"
 import { useNavigate, useParams } from "@solidjs/router"
 import { useSync } from "@/context/sync"
+import { useComments } from "@/context/comments"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -115,6 +116,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const files = useFile()
   const prompt = usePrompt()
   const layout = useLayout()
+  const comments = useComments()
   const params = useParams()
   const dialog = useDialog()
   const providers = useProviders()
@@ -158,6 +160,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey()))
+  const view = createMemo(() => layout.view(sessionKey()))
   const activeFile = createMemo(() => {
     const tab = tabs().active()
     if (!tab) return
@@ -1555,7 +1558,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               {(item) => {
                 const preview = createMemo(() => selectionPreview(item.path, item.selection, item.preview))
                 return (
-                  <div class="shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]">
+                  <div
+                    classList={{
+                      "shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]": true,
+                      "cursor-pointer hover:bg-surface-raised-base-hover": !!item.commentID,
+                    }}
+                    onClick={() => {
+                      if (!item.commentID) return
+                      comments.setFocus({ file: item.path, id: item.commentID })
+                      view().reviewPanel.open()
+                      tabs().open("review")
+                    }}
+                  >
                     <div class="flex items-center gap-1.5">
                       <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
                       <div class="flex items-center text-11-regular min-w-0">
@@ -1576,7 +1590,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                         icon="close"
                         variant="ghost"
                         class="h-5 w-5"
-                        onClick={() => prompt.context.remove(item.key)}
+                        onClick={(e) => {
+                          e.stopPropagation()
+                          prompt.context.remove(item.key)
+                        }}
                         aria-label={language.t("prompt.context.removeFile")}
                       />
                     </div>

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

@@ -0,0 +1,140 @@
+import { batch, createMemo, createRoot, createSignal, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { useParams } from "@solidjs/router"
+import { Persist, persisted } from "@/utils/persist"
+import type { SelectedLineRange } from "@/context/file"
+
+export type LineComment = {
+  id: string
+  file: string
+  selection: SelectedLineRange
+  comment: string
+  time: number
+}
+
+type CommentFocus = { file: string; id: string }
+
+const WORKSPACE_KEY = "__workspace__"
+const MAX_COMMENT_SESSIONS = 20
+
+type CommentSession = ReturnType<typeof createCommentSession>
+
+type CommentCacheEntry = {
+  value: CommentSession
+  dispose: VoidFunction
+}
+
+function createCommentSession(dir: string, id: string | undefined) {
+  const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
+
+  const [store, setStore, _, ready] = persisted(
+    Persist.scoped(dir, id, "comments", [legacy]),
+    createStore<{
+      comments: Record<string, LineComment[]>
+    }>({
+      comments: {},
+    }),
+  )
+
+  const [focus, setFocus] = createSignal<CommentFocus | null>(null)
+
+  const list = (file: string) => store.comments[file] ?? []
+
+  const add = (input: Omit<LineComment, "id" | "time">) => {
+    const next: LineComment = {
+      id: crypto.randomUUID(),
+      time: Date.now(),
+      ...input,
+    }
+
+    batch(() => {
+      setStore("comments", input.file, (items) => [...(items ?? []), next])
+      setFocus({ file: input.file, id: next.id })
+    })
+
+    return next
+  }
+
+  const remove = (file: string, id: string) => {
+    setStore("comments", file, (items) => (items ?? []).filter((x) => x.id !== id))
+    setFocus((current) => (current?.id === id ? null : current))
+  }
+
+  const all = createMemo(() => {
+    const files = Object.keys(store.comments)
+    const items = files.flatMap((file) => store.comments[file] ?? [])
+    return items.slice().sort((a, b) => a.time - b.time)
+  })
+
+  return {
+    ready,
+    list,
+    all,
+    add,
+    remove,
+    focus: createMemo(() => focus()),
+    setFocus,
+    clearFocus: () => setFocus(null),
+  }
+}
+
+export const { use: useComments, provider: CommentsProvider } = createSimpleContext({
+  name: "Comments",
+  gate: false,
+  init: () => {
+    const params = useParams()
+    const cache = new Map<string, CommentCacheEntry>()
+
+    const disposeAll = () => {
+      for (const entry of cache.values()) {
+        entry.dispose()
+      }
+      cache.clear()
+    }
+
+    onCleanup(disposeAll)
+
+    const prune = () => {
+      while (cache.size > MAX_COMMENT_SESSIONS) {
+        const first = cache.keys().next().value
+        if (!first) return
+        const entry = cache.get(first)
+        entry?.dispose()
+        cache.delete(first)
+      }
+    }
+
+    const load = (dir: string, id: string | undefined) => {
+      const key = `${dir}:${id ?? WORKSPACE_KEY}`
+      const existing = cache.get(key)
+      if (existing) {
+        cache.delete(key)
+        cache.set(key, existing)
+        return existing.value
+      }
+
+      const entry = createRoot((dispose) => ({
+        value: createCommentSession(dir, id),
+        dispose,
+      }))
+
+      cache.set(key, entry)
+      prune()
+      return entry.value
+    }
+
+    const session = createMemo(() => load(params.dir!, params.id))
+
+    return {
+      ready: () => session().ready(),
+      list: (file: string) => session().list(file),
+      all: () => session().all(),
+      add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
+      remove: (file: string, id: string) => session().remove(file, id),
+      focus: () => session().focus(),
+      setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
+      clearFocus: () => session().clearFocus(),
+    }
+  },
+})

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

@@ -43,6 +43,7 @@ export type FileContextItem = {
   path: string
   selection?: FileSelection
   comment?: string
+  commentID?: string
   preview?: string
 }
 
@@ -139,6 +140,11 @@ function createPromptSession(dir: string, id: string | undefined) {
     const start = item.selection?.startLine
     const end = item.selection?.endLine
     const key = `${item.type}:${item.path}:${start}:${end}`
+
+    if (item.commentID) {
+      return `${key}:c=${item.commentID}`
+    }
+
     const comment = item.comment?.trim()
     if (!comment) return key
     const digest = checksum(comment) ?? comment

+ 20 - 0
packages/app/src/pages/session.tsx

@@ -51,6 +51,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
 import type { FileDiff } from "@opencode-ai/sdk/v2/client"
 import { useSDK } from "@/context/sdk"
 import { usePrompt } from "@/context/prompt"
+import { useComments, type LineComment } from "@/context/comments"
 import { extractPromptFromParts } from "@/utils/prompt"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 import { usePermission } from "@/context/permission"
@@ -82,6 +83,9 @@ interface SessionReviewTabProps {
   onDiffStyleChange?: (style: DiffStyle) => void
   onViewFile?: (file: string) => void
   onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
+  comments?: LineComment[]
+  focusedComment?: { file: string; id: string } | null
+  onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
   classes?: {
     root?: string
     header?: string
@@ -168,6 +172,9 @@ function SessionReviewTab(props: SessionReviewTabProps) {
       onViewFile={props.onViewFile}
       readFile={readFile}
       onLineComment={props.onLineComment}
+      comments={props.comments}
+      focusedComment={props.focusedComment}
+      onFocusedCommentChange={props.onFocusedCommentChange}
     />
   )
 }
@@ -187,6 +194,7 @@ export default function Page() {
   const navigate = useNavigate()
   const sdk = useSDK()
   const prompt = usePrompt()
+  const comments = useComments()
   const permission = usePermission()
   const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -513,11 +521,17 @@ export default function Page() {
   }) => {
     const selection = selectionFromLines(input.selection)
     const preview = input.preview ?? selectionPreview(input.file, selection)
+    const saved = comments.add({
+      file: input.file,
+      selection: input.selection,
+      comment: input.comment,
+    })
     prompt.context.add({
       type: "file",
       path: input.file,
       selection,
       comment: input.comment,
+      commentID: saved.id,
       preview,
     })
   }
@@ -1433,6 +1447,9 @@ export default function Page() {
                                 view={view}
                                 diffStyle="unified"
                                 onLineComment={addCommentToContext}
+                                comments={comments.all()}
+                                focusedComment={comments.focus()}
+                                onFocusedCommentChange={comments.setFocus}
                                 onViewFile={(path) => {
                                   const value = file.tab(path)
                                   tabs().open(value)
@@ -1749,6 +1766,9 @@ export default function Page() {
                                 diffStyle={layout.review.diffStyle()}
                                 onDiffStyleChange={layout.review.setDiffStyle}
                                 onLineComment={addCommentToContext}
+                                comments={comments.all()}
+                                focusedComment={comments.focus()}
+                                onFocusedCommentChange={comments.setFocus}
                                 onViewFile={(path) => {
                                   const value = file.tab(path)
                                   tabs().open(value)

+ 44 - 1
packages/ui/src/components/diff-ssr.tsx

@@ -1,4 +1,4 @@
-import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs"
+import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange } 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"
@@ -19,12 +19,50 @@ export function Diff<T>(props: SSRDiffProps<T>) {
     "classList",
     "annotations",
     "selectedLines",
+    "commentedLines",
   ])
   const workerPool = useWorkerPool(props.diffStyle)
 
   let fileDiffInstance: FileDiff<T> | undefined
   const cleanupFunctions: Array<() => void> = []
 
+  const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
+
+  const findSide = (element: HTMLElement): "additions" | "deletions" => {
+    const code = element.closest("[data-code]")
+    if (!(code instanceof HTMLElement)) return "additions"
+    if (code.hasAttribute("data-deletions")) return "deletions"
+    return "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")
+    }
+
+    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 expectedSide =
+          line === end ? (range.endSide ?? range.side) : line === start ? range.side : (range.side ?? range.endSide)
+
+        const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`))
+        for (const node of nodes) {
+          if (!(node instanceof HTMLElement)) continue
+          if (expectedSide && findSide(node) !== expectedSide) continue
+          node.setAttribute("data-comment-selected", "")
+        }
+      }
+    }
+  }
+
   onMount(() => {
     if (isServer || !props.preloadedDiff) return
     fileDiffInstance = new FileDiff<T>(
@@ -55,6 +93,11 @@ export function Diff<T>(props: SSRDiffProps<T>) {
       fileDiffInstance?.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) {

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

@@ -63,6 +63,7 @@ export function Diff<T>(props: DiffProps<T>) {
     "classList",
     "annotations",
     "selectedLines",
+    "commentedLines",
     "onRendered",
   ])
 
@@ -82,6 +83,7 @@ export function Diff<T>(props: DiffProps<T>) {
 
   let instance: FileDiff<T> | undefined
   const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
+  const [rendered, setRendered] = createSignal(0)
 
   const getRoot = () => {
     const host = container.querySelector("diffs-container")
@@ -172,6 +174,39 @@ export function Diff<T>(props: DiffProps<T>) {
     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")
+    }
+
+    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 expectedSide =
+          line === end ? (range.endSide ?? range.side) : line === start ? range.side : (range.side ?? range.endSide)
+
+        const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`))
+        for (const node of nodes) {
+          if (!(node instanceof HTMLElement)) continue
+
+          if (expectedSide) {
+            const side = findSide(node)
+            if (side && side !== expectedSide) continue
+          }
+
+          node.setAttribute("data-comment-selected", "")
+        }
+      }
+    }
+  }
+
   const setSelectedLines = (range: SelectedLineRange | null) => {
     const active = current()
     if (!active) return
@@ -379,9 +414,16 @@ export function Diff<T>(props: DiffProps<T>) {
       containerWrapper: container,
     })
 
+    setRendered((value) => value + 1)
     notifyRendered()
   })
 
+  createEffect(() => {
+    rendered()
+    const ranges = local.commentedLines ?? []
+    requestAnimationFrame(() => applyCommentedLines(ranges))
+  })
+
   createEffect(() => {
     const selected = local.selectedLines ?? null
     setSelectedLines(selected)

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

@@ -195,4 +195,103 @@
     font-size: var(--font-size-small);
     color: var(--text-weak);
   }
+
+  [data-slot="session-review-diff-wrapper"] {
+    position: relative;
+  }
+
+  [data-slot="session-review-comment-anchor"] {
+    position: absolute;
+    right: 12px;
+    z-index: 30;
+  }
+
+  [data-slot="session-review-comment-button"] {
+    width: 20px;
+    height: 20px;
+    border-radius: 6px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: var(--surface-base);
+    border: 1px solid color-mix(in oklch, var(--icon-info-active) 60%, transparent);
+    color: var(--icon-info-active);
+    box-shadow: var(--shadow-xs-border);
+    cursor: pointer;
+
+    &:hover {
+      background: var(--surface-raised-base-hover);
+      border-color: var(--icon-info-active);
+    }
+
+    &:focus {
+      outline: none;
+    }
+
+    &:focus-visible {
+      box-shadow: var(--shadow-xs-border-focus);
+    }
+  }
+
+  [data-slot="session-review-comment-hover"] {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    max-width: 320px;
+  }
+
+  [data-slot="session-review-comment-hover-label"],
+  [data-slot="session-review-comment-popover-label"] {
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    font-weight: var(--font-weight-medium);
+    color: var(--text-strong);
+  }
+
+  [data-slot="session-review-comment-hover-text"],
+  [data-slot="session-review-comment-popover-text"] {
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    font-weight: var(--font-weight-regular);
+    color: var(--text-base);
+    white-space: pre-wrap;
+  }
+
+  [data-slot="session-review-comment-preview"] {
+    margin: 0;
+    padding: 8px;
+    border-radius: var(--radius-sm);
+    background: var(--surface-base);
+    border: 1px solid color-mix(in oklch, var(--border-base) 55%, transparent);
+    color: var(--text-base);
+    font-family: var(--font-family-mono);
+    font-size: var(--font-size-small);
+    line-height: 1.4;
+    white-space: pre-wrap;
+  }
+
+  [data-slot="session-review-comment-textarea"] {
+    width: 320px;
+    max-width: calc(100vw - 48px);
+    resize: vertical;
+    padding: 8px;
+    border-radius: var(--radius-sm);
+    background: var(--surface-base);
+    border: 1px solid color-mix(in oklch, var(--border-base) 55%, transparent);
+    color: var(--text-strong);
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    line-height: 1.4;
+
+    &:focus {
+      outline: none;
+      box-shadow: var(--shadow-xs-border-focus);
+    }
+  }
+
+  [data-slot="session-review-comment-actions"] {
+    display: flex;
+    justify-content: flex-end;
+    gap: 8px;
+  }
 }

+ 298 - 151
packages/ui/src/components/session-review.tsx

@@ -1,30 +1,49 @@
 import { Accordion } from "./accordion"
 import { Button } from "./button"
+import { HoverCard } from "./hover-card"
+import { Popover } from "./popover"
 import { RadioGroup } from "./radio-group"
 import { DiffChanges } from "./diff-changes"
 import { FileIcon } from "./file-icon"
 import { Icon } from "./icon"
 import { StickyAccordionHeader } from "./sticky-accordion-header"
-import { useCodeComponent } from "../context/code"
 import { useDiffComponent } from "../context/diff"
 import { useI18n } from "../context/i18n"
-import { checksum } from "@opencode-ai/util/encode"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { createEffect, createMemo, createSignal, For, Match, 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 DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
+import { type SelectedLineRange } from "@pierre/diffs"
 import { Dynamic } from "solid-js/web"
 
 export type SessionReviewDiffStyle = "unified" | "split"
 
+export type SessionReviewComment = {
+  id: string
+  file: string
+  selection: SelectedLineRange
+  comment: string
+}
+
+export type SessionReviewLineComment = {
+  file: string
+  selection: SelectedLineRange
+  comment: string
+  preview?: string
+}
+
+export type SessionReviewFocus = { file: string; id: string }
+
 export interface SessionReviewProps {
   split?: boolean
   diffStyle?: SessionReviewDiffStyle
   onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
   onDiffRendered?: () => void
   onLineComment?: (comment: SessionReviewLineComment) => void
+  comments?: SessionReviewComment[]
+  focusedComment?: SessionReviewFocus | null
+  onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
   open?: string[]
   onOpenChange?: (open: string[]) => void
   scrollRef?: (el: HTMLDivElement) => void
@@ -105,29 +124,43 @@ type SessionReviewSelection = {
   range: SelectedLineRange
 }
 
-type SessionReviewLineComment = {
-  file: string
-  selection: SelectedLineRange
-  comment: string
-  preview?: string
+function findSide(element: HTMLElement): "additions" | "deletions" {
+  const code = element.closest("[data-code]")
+  if (!(code instanceof HTMLElement)) return "additions"
+  if (code.hasAttribute("data-deletions")) return "deletions"
+  return "additions"
 }
 
-type CommentAnnotationMeta = {
-  file: string
-  selection: SelectedLineRange
-  label: string
-  preview?: string
+function findMarker(root: ShadowRoot, range: SelectedLineRange) {
+  const line = Math.max(range.start, range.end)
+  const side = range.endSide ?? range.side
+  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]
+}
+
+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) => {
   const i18n = useI18n()
   const diffComponent = useDiffComponent()
-  const codeComponent = useCodeComponent()
+  const anchors = new Map<string, HTMLElement>()
   const [store, setStore] = createStore({
     open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
   })
+
   const [selection, setSelection] = createSignal<SessionReviewSelection | null>(null)
   const [commenting, setCommenting] = createSignal<SessionReviewSelection | null>(null)
+  const [opened, setOpened] = createSignal<SessionReviewFocus | null>(null)
 
   const open = () => props.open ?? store.open
   const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
@@ -150,9 +183,6 @@ export const SessionReview = (props: SessionReviewProps) => {
     return `lines ${start}-${end}`
   }
 
-  const isRangeEqual = (a: SelectedLineRange, b: SelectedLineRange) =>
-    a.start === b.start && a.end === b.end && a.side === b.side && a.endSide === b.endSide
-
   const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
 
   const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
@@ -167,88 +197,26 @@ export const SessionReview = (props: SessionReviewProps) => {
     return lines.slice(0, 2).join("\n")
   }
 
-  const renderAnnotation = (annotation: DiffLineAnnotation<CommentAnnotationMeta>) => {
-    if (!props.onLineComment) return undefined
-    const meta = annotation.metadata
-    if (!meta) return undefined
-
-    const wrapper = document.createElement("div")
-    wrapper.className = "relative"
-
-    const card = document.createElement("div")
-    card.className =
-      "min-w-[240px] max-w-[320px] flex flex-col gap-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha p-2 shadow-md"
-
-    const textarea = document.createElement("textarea")
-    textarea.rows = 3
-    textarea.placeholder = "Add a comment"
-    textarea.className =
-      "w-full resize-none rounded-md border border-border-base bg-surface-base px-2 py-1 text-12-regular text-text-strong placeholder:text-text-subtle"
-
-    const footer = document.createElement("div")
-    footer.className = "flex items-center justify-between gap-2 text-11-regular text-text-weak"
-
-    const label = document.createElement("span")
-    label.textContent = `Commenting on ${meta.label}`
-
-    const actions = document.createElement("div")
-    actions.className = "flex items-center gap-2"
-
-    const cancel = document.createElement("button")
-    cancel.type = "button"
-    cancel.textContent = "Cancel"
-    cancel.className = "text-11-regular text-text-weak hover:text-text-strong"
-
-    const submit = document.createElement("button")
-    submit.type = "button"
-    submit.textContent = "Comment"
-    submit.className =
-      "rounded-md border border-border-base bg-surface-base px-2 py-1 text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
-
-    const updateState = () => {
-      const active = textarea.value.trim().length > 0
-      submit.disabled = !active
-      submit.classList.toggle("opacity-50", !active)
-      submit.classList.toggle("cursor-not-allowed", !active)
-    }
+  createEffect(() => {
+    const focus = props.focusedComment
+    if (!focus) return
 
-    updateState()
-    textarea.addEventListener("input", updateState)
-    textarea.addEventListener("keydown", (event) => {
-      if (event.key !== "Enter") return
-      if (event.shiftKey) return
-      event.preventDefault()
-      submit.click()
-    })
-    cancel.addEventListener("click", () => {
-      setSelection(null)
-      setCommenting(null)
-    })
-    submit.addEventListener("click", () => {
-      const value = textarea.value.trim()
-      if (!value) return
-      props.onLineComment?.({
-        file: meta.file,
-        selection: meta.selection,
-        comment: value,
-        preview: meta.preview,
-      })
-      setSelection(null)
-      setCommenting(null)
-    })
+    setOpened(focus)
 
-    actions.appendChild(cancel)
-    actions.appendChild(submit)
-    footer.appendChild(label)
-    footer.appendChild(actions)
-    card.appendChild(textarea)
-    card.appendChild(footer)
-    wrapper.appendChild(card)
+    const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
+    if (comment) setSelection({ file: comment.file, range: comment.selection })
 
-    requestAnimationFrame(() => textarea.focus())
+    const current = open()
+    if (!current.includes(focus.file)) {
+      handleChange([...current, focus.file])
+    }
 
-    return wrapper
-  }
+    requestAnimationFrame(() => {
+      anchors.get(focus.file)?.scrollIntoView({ block: "center" })
+    })
+
+    requestAnimationFrame(() => props.onFocusedCommentChange?.(null))
+  })
 
   return (
     <div
@@ -298,6 +266,12 @@ export const SessionReview = (props: SessionReviewProps) => {
         <Accordion multiple value={open()} onChange={handleChange}>
           <For each={props.diffs}>
             {(diff) => {
+              let wrapper: HTMLDivElement | undefined
+              let textarea: HTMLTextAreaElement | undefined
+
+              const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
+              const commentedLines = createMemo(() => comments().map((c) => c.selection))
+
               const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
               const afterText = () => (typeof diff.after === "string" ? diff.after : "")
 
@@ -321,27 +295,70 @@ export const SessionReview = (props: SessionReviewProps) => {
                 return current.range
               })
 
-              const commentingLines = createMemo(() => {
+              const draftRange = createMemo(() => {
                 const current = commenting()
                 if (!current || current.file !== diff.file) return null
                 return current.range
               })
 
-              const annotations = createMemo<DiffLineAnnotation<CommentAnnotationMeta>[]>(() => {
-                const range = commentingLines()
-                if (!range) return []
-                return [
-                  {
-                    lineNumber: Math.max(range.start, range.end),
-                    side: selectionSide(range),
-                    metadata: {
-                      file: diff.file,
-                      selection: range,
-                      label: selectionLabel(range),
-                      preview: selectionPreview(diff, 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(() => {
+                comments()
+                scheduleAnchors()
+              })
+
+              createEffect(() => {
+                const range = draftRange()
+                if (!range) return
+                setDraft("")
+                scheduleAnchors()
+                requestAnimationFrame(() => textarea?.focus())
               })
 
               createEffect(() => {
@@ -395,31 +412,15 @@ export const SessionReview = (props: SessionReviewProps) => {
                   })
               })
 
-              const fileForCode = () => {
-                const contents = afterText() || beforeText()
-                return {
-                  name: diff.file,
-                  contents,
-                  cacheKey: checksum(contents),
-                }
-              }
-
               const handleLineSelected = (range: SelectedLineRange | null) => {
                 if (!props.onLineComment) return
 
                 if (!range) {
                   setSelection(null)
-                  setCommenting(null)
                   return
                 }
 
                 setSelection({ file: diff.file, range })
-
-                const current = commenting()
-                if (!current) return
-                if (current.file !== diff.file) return
-                if (isRangeEqual(current.range, range)) return
-                setCommenting(null)
               }
 
               const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
@@ -434,6 +435,17 @@ export const SessionReview = (props: SessionReviewProps) => {
                 setCommenting({ file: diff.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
+              }
+
               return (
                 <Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
                   <StickyAccordionHeader>
@@ -526,32 +538,167 @@ export const SessionReview = (props: SessionReviewProps) => {
                           </Show>
                         </div>
                       </Match>
-                      <Match when={isAdded() || isDeleted()}>
-                        <div data-slot="session-review-file-container">
-                          <Dynamic component={codeComponent} file={fileForCode()} overflow="scroll" />
-                        </div>
-                      </Match>
                       <Match when={true}>
-                        <Dynamic
-                          component={diffComponent}
-                          preloadedDiff={diff.preloaded}
-                          diffStyle={diffStyle()}
-                          onRendered={props.onDiffRendered}
-                          enableLineSelection={props.onLineComment != null}
-                          onLineSelected={handleLineSelected}
-                          onLineSelectionEnd={handleLineSelectionEnd}
-                          selectedLines={selectedLines()}
-                          annotations={annotations()}
-                          renderAnnotation={renderAnnotation}
-                          before={{
-                            name: diff.file!,
-                            contents: beforeText(),
+                        <div
+                          data-slot="session-review-diff-wrapper"
+                          ref={(el) => {
+                            wrapper = el
+                            anchors.set(diff.file, el)
+                            scheduleAnchors()
                           }}
-                          after={{
-                            name: diff.file!,
-                            contents: afterText(),
-                          }}
-                        />
+                        >
+                          <Dynamic
+                            component={diffComponent}
+                            preloadedDiff={diff.preloaded}
+                            diffStyle={diffStyle()}
+                            onRendered={() => {
+                              props.onDiffRendered?.()
+                              scheduleAnchors()
+                            }}
+                            enableLineSelection={props.onLineComment != null}
+                            onLineSelected={handleLineSelected}
+                            onLineSelectionEnd={handleLineSelectionEnd}
+                            selectedLines={selectedLines()}
+                            commentedLines={commentedLines()}
+                            before={{
+                              name: diff.file!,
+                              contents: beforeText(),
+                            }}
+                            after={{
+                              name: diff.file!,
+                              contents: afterText(),
+                            }}
+                          />
+
+                          <For each={comments()}>
+                            {(comment) => (
+                              <div
+                                data-slot="session-review-comment-anchor"
+                                style={{
+                                  top: `${positions()[comment.id] ?? 0}px`,
+                                  opacity: positions()[comment.id] === undefined ? 0 : 1,
+                                  "pointer-events": positions()[comment.id] === undefined ? "none" : "auto",
+                                }}
+                              >
+                                <Popover
+                                  open={isCommentOpen(comment)}
+                                  onOpenChange={(open) => {
+                                    if (open) {
+                                      openComment(comment)
+                                      return
+                                    }
+                                    if (!isCommentOpen(comment)) return
+                                    setOpened(null)
+                                  }}
+                                  trigger={
+                                    <HoverCard
+                                      trigger={
+                                        <button
+                                          type="button"
+                                          data-slot="session-review-comment-button"
+                                          onMouseEnter={() =>
+                                            setSelection({ file: comment.file, range: comment.selection })
+                                          }
+                                        >
+                                          <Icon name="speech-bubble" size="small" />
+                                        </button>
+                                      }
+                                    >
+                                      <div data-slot="session-review-comment-hover">
+                                        <div data-slot="session-review-comment-hover-label">
+                                          {getFilename(comment.file)}:{selectionLabel(comment.selection)}
+                                        </div>
+                                        <div data-slot="session-review-comment-hover-text">{comment.comment}</div>
+                                      </div>
+                                    </HoverCard>
+                                  }
+                                >
+                                  <div data-slot="session-review-comment-popover">
+                                    <div data-slot="session-review-comment-popover-label">
+                                      {getFilename(comment.file)}:{selectionLabel(comment.selection)}
+                                    </div>
+                                    <div data-slot="session-review-comment-popover-text">{comment.comment}</div>
+                                    <Show when={selectionPreview(diff, comment.selection)}>
+                                      {(preview) => <pre data-slot="session-review-comment-preview">{preview()}</pre>}
+                                    </Show>
+                                  </div>
+                                </Popover>
+                              </div>
+                            )}
+                          </For>
+
+                          <Show when={draftRange()}>
+                            {(range) => (
+                              <Show when={draftTop() !== undefined}>
+                                <div data-slot="session-review-comment-anchor" style={{ top: `${draftTop() ?? 0}px` }}>
+                                  <Popover
+                                    open={true}
+                                    onOpenChange={(open) => {
+                                      if (open) return
+                                      setCommenting(null)
+                                    }}
+                                    trigger={
+                                      <button type="button" data-slot="session-review-comment-button">
+                                        <Icon name="speech-bubble" size="small" />
+                                      </button>
+                                    }
+                                  >
+                                    <div data-slot="session-review-comment-popover">
+                                      <div data-slot="session-review-comment-popover-label">
+                                        Commenting on {getFilename(diff.file)}:{selectionLabel(range())}
+                                      </div>
+                                      <textarea
+                                        ref={textarea}
+                                        data-slot="session-review-comment-textarea"
+                                        rows={3}
+                                        placeholder="Add a comment"
+                                        value={draft()}
+                                        onInput={(e) => setDraft(e.currentTarget.value)}
+                                        onKeyDown={(e) => {
+                                          if (e.key !== "Enter") return
+                                          if (e.shiftKey) return
+                                          e.preventDefault()
+                                          const value = draft().trim()
+                                          if (!value) return
+                                          props.onLineComment?.({
+                                            file: diff.file,
+                                            selection: range(),
+                                            comment: value,
+                                            preview: selectionPreview(diff, range()),
+                                          })
+                                          setCommenting(null)
+                                        }}
+                                      />
+                                      <div data-slot="session-review-comment-actions">
+                                        <Button size="small" variant="ghost" onClick={() => setCommenting(null)}>
+                                          Cancel
+                                        </Button>
+                                        <Button
+                                          size="small"
+                                          variant="secondary"
+                                          disabled={draft().trim().length === 0}
+                                          onClick={() => {
+                                            const value = draft().trim()
+                                            if (!value) return
+                                            props.onLineComment?.({
+                                              file: diff.file,
+                                              selection: range(),
+                                              comment: value,
+                                              preview: selectionPreview(diff, range()),
+                                            })
+                                            setCommenting(null)
+                                          }}
+                                        >
+                                          Comment
+                                        </Button>
+                                      </div>
+                                    </div>
+                                  </Popover>
+                                </div>
+                              </Show>
+                            )}
+                          </Show>
+                        </div>
                       </Match>
                     </Switch>
                   </Accordion.Content>

+ 10 - 0
packages/ui/src/pierre/index.ts

@@ -6,6 +6,7 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
   after: FileContents
   annotations?: DiffLineAnnotation<T>[]
   selectedLines?: SelectedLineRange | null
+  commentedLines?: SelectedLineRange[]
   onRendered?: () => void
   class?: string
   classList?: ComponentProps<"div">["classList"]
@@ -42,6 +43,15 @@ const unsafeCSS = `
   background-color: var(--diffs-bg-selection-text);
 }
 
+[data-diffs] [data-comment-selected] {
+  background-color: var(--diffs-bg-selection);
+}
+
+[data-diffs] [data-comment-selected] [data-column-number] {
+  background-color: var(--diffs-bg-selection-number);
+  color: var(--diffs-selection-number-fg);
+}
+
 [data-diffs-header],
 [data-diffs] {
   [data-separator-wrapper] {