Adam 1 месяц назад
Родитель
Сommit
0ce0cacb28

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

@@ -164,6 +164,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     return files.pathFromTab(tab)
   })
 
+  const selectionPreview = (path: string, selection?: FileSelection, preview?: string) => {
+    if (preview) return preview
+    if (!selection) return undefined
+    const content = files.get(path)?.content?.content
+    if (!content) return undefined
+    const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
+    const end = Math.max(selection.startLine, selection.endLine)
+    const lines = content.split("\n").slice(start - 1, end)
+    if (lines.length === 0) return undefined
+    return lines.slice(0, 2).join("\n")
+  }
+
   const activeFileSelection = createMemo(() => {
     const path = activeFile()
     if (!path) return
@@ -171,6 +183,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (!range) return
     return selectionFromLines(range)
   })
+  const activeSelectionPreview = createMemo(() => {
+    const path = activeFile()
+    if (!path) return
+    return selectionPreview(path, activeFileSelection())
+  })
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const status = createMemo(
     () =>
@@ -1485,40 +1502,49 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           </div>
         </Show>
         <Show when={prompt.context.items().length > 0 || !!activeFile()}>
-          <div class="flex flex-wrap items-center gap-1.5 px-3 pt-3">
+          <div class="flex flex-nowrap items-start gap-1.5 px-3 pt-3 overflow-x-auto no-scrollbar">
             <Show when={prompt.context.activeTab() ? activeFile() : undefined}>
               {(path) => (
-                <div class="flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base max-w-full">
-                  <FileIcon node={{ path: path(), type: "file" }} class="shrink-0 size-3.5" />
-                  <div class="flex items-center text-11-regular min-w-0">
-                    <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(path())}</span>
-                    <span class="text-text-strong whitespace-nowrap">{getFilename(path())}</span>
-                    <Show when={activeFileSelection()}>
-                      {(sel) => (
-                        <span class="text-text-weak whitespace-nowrap ml-1">
-                          {sel().startLine === sel().endLine
-                            ? `:${sel().startLine}`
-                            : `:${sel().startLine}-${sel().endLine}`}
-                        </span>
-                      )}
-                    </Show>
-                    <span class="text-text-weak whitespace-nowrap ml-1">{language.t("prompt.context.active")}</span>
+                <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 class="flex items-center gap-1.5">
+                    <FileIcon node={{ path: path(), type: "file" }} class="shrink-0 size-3.5" />
+                    <div class="flex items-center text-11-regular min-w-0">
+                      <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(path())}</span>
+                      <span class="text-text-strong whitespace-nowrap">{getFilename(path())}</span>
+                      <Show when={activeFileSelection()}>
+                        {(sel) => (
+                          <span class="text-text-weak whitespace-nowrap ml-1">
+                            {sel().startLine === sel().endLine
+                              ? `:${sel().startLine}`
+                              : `:${sel().startLine}-${sel().endLine}`}
+                          </span>
+                        )}
+                      </Show>
+                      <span class="text-text-weak whitespace-nowrap ml-1">{language.t("prompt.context.active")}</span>
+                    </div>
+                    <IconButton
+                      type="button"
+                      icon="close"
+                      variant="ghost"
+                      class="h-5 w-5"
+                      onClick={() => prompt.context.removeActive()}
+                      aria-label={language.t("prompt.context.removeActiveFile")}
+                    />
                   </div>
-                  <IconButton
-                    type="button"
-                    icon="close"
-                    variant="ghost"
-                    class="h-5 w-5"
-                    onClick={() => prompt.context.removeActive()}
-                    aria-label={language.t("prompt.context.removeActiveFile")}
-                  />
+                  <Show when={activeSelectionPreview()}>
+                    {(preview) => (
+                      <pre class="text-10-regular text-text-weak font-mono whitespace-pre-wrap leading-4">
+                        {preview()}
+                      </pre>
+                    )}
+                  </Show>
                 </div>
               )}
             </Show>
             <Show when={!prompt.context.activeTab() && !!activeFile()}>
               <button
                 type="button"
-                class="flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base text-11-regular text-text-weak hover:bg-surface-raised-base-hover"
+                class="shrink-0 flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base text-11-regular text-text-weak hover:bg-surface-raised-base-hover"
                 onClick={() => prompt.context.addActive()}
               >
                 <Icon name="plus-small" size="small" />
@@ -1526,32 +1552,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               </button>
             </Show>
             <For each={prompt.context.items()}>
-              {(item) => (
-                <div class="flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base max-w-full">
-                  <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
-                  <div class="flex items-center text-11-regular min-w-0">
-                    <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span>
-                    <span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
-                    <Show when={item.selection}>
-                      {(sel) => (
-                        <span class="text-text-weak whitespace-nowrap ml-1">
-                          {sel().startLine === sel().endLine
-                            ? `:${sel().startLine}`
-                            : `:${sel().startLine}-${sel().endLine}`}
-                        </span>
+              {(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 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">
+                        <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span>
+                        <span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
+                        <Show when={item.selection}>
+                          {(sel) => (
+                            <span class="text-text-weak whitespace-nowrap ml-1">
+                              {sel().startLine === sel().endLine
+                                ? `:${sel().startLine}`
+                                : `:${sel().startLine}-${sel().endLine}`}
+                            </span>
+                          )}
+                        </Show>
+                      </div>
+                      <IconButton
+                        type="button"
+                        icon="close"
+                        variant="ghost"
+                        class="h-5 w-5"
+                        onClick={() => prompt.context.remove(item.key)}
+                        aria-label={language.t("prompt.context.removeFile")}
+                      />
+                    </div>
+                    <Show when={item.comment}>
+                      {(comment) => <div class="text-11-regular text-text-strong">{comment()}</div>}
+                    </Show>
+                    <Show when={preview()}>
+                      {(content) => (
+                        <pre class="text-10-regular text-text-weak font-mono whitespace-pre-wrap leading-4">
+                          {content()}
+                        </pre>
                       )}
                     </Show>
                   </div>
-                  <IconButton
-                    type="button"
-                    icon="close"
-                    variant="ghost"
-                    class="h-5 w-5"
-                    onClick={() => prompt.context.remove(item.key)}
-                    aria-label={language.t("prompt.context.removeFile")}
-                  />
-                </div>
-              )}
+                )
+              }}
             </For>
           </div>
         </Show>

+ 8 - 1
packages/app/src/context/prompt.tsx

@@ -4,6 +4,7 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
 import { useParams } from "@solidjs/router"
 import type { FileSelection } from "@/context/file"
 import { Persist, persisted } from "@/utils/persist"
+import { checksum } from "@opencode-ai/util/encode"
 
 interface PartBase {
   content: string
@@ -41,6 +42,8 @@ export type FileContextItem = {
   type: "file"
   path: string
   selection?: FileSelection
+  comment?: string
+  preview?: string
 }
 
 export type ContextItem = FileContextItem
@@ -135,7 +138,11 @@ function createPromptSession(dir: string, id: string | undefined) {
     if (item.type !== "file") return item.type
     const start = item.selection?.startLine
     const end = item.selection?.endLine
-    return `${item.type}:${item.path}:${start}:${end}`
+    const key = `${item.type}:${item.path}:${start}:${end}`
+    const comment = item.comment?.trim()
+    if (!comment) return key
+    const digest = checksum(comment) ?? comment
+    return `${key}:c=${digest.slice(0, 8)}`
   }
 
   return {

+ 33 - 1
packages/app/src/pages/session.tsx

@@ -81,6 +81,7 @@ interface SessionReviewTabProps {
   diffStyle: DiffStyle
   onDiffStyleChange?: (style: DiffStyle) => void
   onViewFile?: (file: string) => void
+  onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
   classes?: {
     root?: string
     header?: string
@@ -166,6 +167,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
       onDiffStyleChange={props.onDiffStyleChange}
       onViewFile={props.onViewFile}
       readFile={readFile}
+      onLineComment={props.onLineComment}
     />
   )
 }
@@ -488,8 +490,36 @@ export default function Page() {
     setStore("expanded", id, status().type !== "idle")
   })
 
+  const selectionPreview = (path: string, selection: FileSelection) => {
+    const content = file.get(path)?.content?.content
+    if (!content) return undefined
+    const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
+    const end = Math.max(selection.startLine, selection.endLine)
+    const lines = content.split("\n").slice(start - 1, end)
+    if (lines.length === 0) return undefined
+    return lines.slice(0, 2).join("\n")
+  }
+
   const addSelectionToContext = (path: string, selection: FileSelection) => {
-    prompt.context.add({ type: "file", path, selection })
+    const preview = selectionPreview(path, selection)
+    prompt.context.add({ type: "file", path, selection, preview })
+  }
+
+  const addCommentToContext = (input: {
+    file: string
+    selection: SelectedLineRange
+    comment: string
+    preview?: string
+  }) => {
+    const selection = selectionFromLines(input.selection)
+    const preview = input.preview ?? selectionPreview(input.file, selection)
+    prompt.context.add({
+      type: "file",
+      path: input.file,
+      selection,
+      comment: input.comment,
+      preview,
+    })
   }
 
   command.register(() => [
@@ -1402,6 +1432,7 @@ export default function Page() {
                                 diffs={diffs}
                                 view={view}
                                 diffStyle="unified"
+                                onLineComment={addCommentToContext}
                                 onViewFile={(path) => {
                                   const value = file.tab(path)
                                   tabs().open(value)
@@ -1717,6 +1748,7 @@ export default function Page() {
                                 view={view}
                                 diffStyle={layout.review.diffStyle()}
                                 onDiffStyleChange={layout.review.setDiffStyle}
+                                onLineComment={addCommentToContext}
                                 onViewFile={(path) => {
                                   const value = file.tab(path)
                                   tabs().open(value)

+ 19 - 2
packages/ui/src/components/diff-ssr.tsx

@@ -1,6 +1,6 @@
 import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs"
 import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
-import { onCleanup, onMount, Show, splitProps } from "solid-js"
+import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
 import { Dynamic, isServer } from "solid-js/web"
 import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
 import { useWorkerPool } from "../context/worker-pool"
@@ -12,7 +12,14 @@ export type SSRDiffProps<T = {}> = DiffProps<T> & {
 export function Diff<T>(props: SSRDiffProps<T>) {
   let container!: HTMLDivElement
   let fileDiffRef!: HTMLElement
-  const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"])
+  const [local, others] = splitProps(props, [
+    "before",
+    "after",
+    "class",
+    "classList",
+    "annotations",
+    "selectedLines",
+  ])
   const workerPool = useWorkerPool(props.diffStyle)
 
   let fileDiffInstance: FileDiff<T> | undefined
@@ -38,6 +45,16 @@ export function Diff<T>(props: SSRDiffProps<T>) {
       containerWrapper: container,
     })
 
+    fileDiffInstance.setSelectedLines(local.selectedLines ?? null)
+
+    createEffect(() => {
+      fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
+    })
+
+    createEffect(() => {
+      fileDiffInstance?.setSelectedLines(local.selectedLines ?? null)
+    })
+
     // Hydrate annotation slots with interactive SolidJS components
     // if (props.annotations.length > 0 && props.renderAnnotation != null) {
     //   for (const annotation of props.annotations) {

+ 281 - 4
packages/ui/src/components/diff.tsx

@@ -1,16 +1,70 @@
 import { checksum } from "@opencode-ai/util/encode"
-import { FileDiff } from "@pierre/diffs"
+import { FileDiff, type SelectedLineRange } from "@pierre/diffs"
 import { createMediaQuery } from "@solid-primitives/media"
-import { createEffect, createMemo, onCleanup, splitProps } from "solid-js"
+import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
 import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
 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 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 renderToken = 0
-
-  const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations", "onRendered"])
+  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",
+    "onRendered",
+  ])
 
   const mobile = createMediaQuery("(max-width: 640px)")
 
@@ -27,6 +81,7 @@ export function Diff<T>(props: DiffProps<T>) {
   })
 
   let instance: FileDiff<T> | undefined
+  const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
 
   const getRoot = () => {
     const host = container.querySelector("diffs-container")
@@ -117,6 +172,186 @@ export function Diff<T>(props: DiffProps<T>) {
     observer.observe(container, { childList: true, subtree: true })
   }
 
+  const setSelectedLines = (range: SelectedLineRange | null) => {
+    const active = current()
+    if (!active) return
+    lastSelection = range
+    active.setSelectedLines(range)
+  }
+
+  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 && 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 = 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 = getWorkerPool(props.diffStyle)
@@ -126,6 +361,7 @@ export function Diff<T>(props: DiffProps<T>) {
 
     instance?.cleanUp()
     instance = new FileDiff<T>(opts, workerPool)
+    setCurrent(instance)
 
     container.innerHTML = ""
     instance.render({
@@ -146,9 +382,50 @@ export function Diff<T>(props: DiffProps<T>) {
     notifyRendered()
   })
 
+  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)
   })
 
   return <div data-component="diff" style={styleVariables} ref={container} />

+ 196 - 1
packages/ui/src/components/session-review.tsx

@@ -10,10 +10,11 @@ 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, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
+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 { Dynamic } from "solid-js/web"
 
 export type SessionReviewDiffStyle = "unified" | "split"
@@ -23,6 +24,7 @@ export interface SessionReviewProps {
   diffStyle?: SessionReviewDiffStyle
   onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
   onDiffRendered?: () => void
+  onLineComment?: (comment: SessionReviewLineComment) => void
   open?: string[]
   onOpenChange?: (open: string[]) => void
   scrollRef?: (el: HTMLDivElement) => void
@@ -98,6 +100,25 @@ function dataUrlFromValue(value: unknown): string | undefined {
   return `data:${mime};base64,${content}`
 }
 
+type SessionReviewSelection = {
+  file: string
+  range: SelectedLineRange
+}
+
+type SessionReviewLineComment = {
+  file: string
+  selection: SelectedLineRange
+  comment: string
+  preview?: string
+}
+
+type CommentAnnotationMeta = {
+  file: string
+  selection: SelectedLineRange
+  label: string
+  preview?: string
+}
+
 export const SessionReview = (props: SessionReviewProps) => {
   const i18n = useI18n()
   const diffComponent = useDiffComponent()
@@ -105,6 +126,8 @@ export const SessionReview = (props: SessionReviewProps) => {
   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 open = () => props.open ?? store.open
   const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
@@ -120,6 +143,113 @@ 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 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) => {
+    const side = selectionSide(range)
+    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")
+  }
+
+  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)
+    }
+
+    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)
+    })
+
+    actions.appendChild(cancel)
+    actions.appendChild(submit)
+    footer.appendChild(label)
+    footer.appendChild(actions)
+    card.appendChild(textarea)
+    card.appendChild(footer)
+    wrapper.appendChild(card)
+
+    requestAnimationFrame(() => textarea.focus())
+
+    return wrapper
+  }
+
   return (
     <div
       data-component="session-review"
@@ -185,6 +315,35 @@ export const SessionReview = (props: SessionReviewProps) => {
               const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
               const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
 
+              const selectedLines = createMemo(() => {
+                const current = selection()
+                if (!current || current.file !== diff.file) return null
+                return current.range
+              })
+
+              const commentingLines = 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),
+                    },
+                  },
+                ]
+              })
+
               createEffect(() => {
                 if (!open().includes(diff.file)) return
                 if (!isImage()) return
@@ -245,6 +404,36 @@ export const SessionReview = (props: SessionReviewProps) => {
                 }
               }
 
+              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) => {
+                if (!props.onLineComment) return
+
+                if (!range) {
+                  setCommenting(null)
+                  return
+                }
+
+                setSelection({ file: diff.file, range })
+                setCommenting({ file: diff.file, range })
+              }
+
               return (
                 <Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
                   <StickyAccordionHeader>
@@ -348,6 +537,12 @@ export const SessionReview = (props: SessionReviewProps) => {
                           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(),

+ 2 - 1
packages/ui/src/pierre/index.ts

@@ -1,10 +1,11 @@
-import { DiffLineAnnotation, FileContents, FileDiffOptions } from "@pierre/diffs"
+import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
 import { ComponentProps } from "solid-js"
 
 export type DiffProps<T = {}> = FileDiffOptions<T> & {
   before: FileContents
   after: FileContents
   annotations?: DiffLineAnnotation<T>[]
+  selectedLines?: SelectedLineRange | null
   onRendered?: () => void
   class?: string
   classList?: ComponentProps<"div">["classList"]