summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-21 05:27:52 -0600
committerAdam <[email protected]>2026-01-22 22:12:12 -0600
commit0ce0cacb282c47943348a2af21ea00e721bcb9d9 (patch)
treee1c17ec3dc03ce1fd86f348059a6401e700eb60d
parent640d1f1ecc7a2b46fb2bafed760c7348c70579a8 (diff)
downloadopencode-0ce0cacb282c47943348a2af21ea00e721bcb9d9.tar.gz
opencode-0ce0cacb282c47943348a2af21ea00e721bcb9d9.zip
wip(app): line selection
-rw-r--r--packages/app/src/components/prompt-input.tsx137
-rw-r--r--packages/app/src/context/prompt.tsx9
-rw-r--r--packages/app/src/pages/session.tsx34
-rw-r--r--packages/ui/src/components/diff-ssr.tsx21
-rw-r--r--packages/ui/src/components/diff.tsx285
-rw-r--r--packages/ui/src/components/session-review.tsx197
-rw-r--r--packages/ui/src/pierre/index.ts3
7 files changed, 628 insertions, 58 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 0d6a7641a..5e936737a 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/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>
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index 993d7e7a8..a76d9d5f1 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/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 {
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index ad6d360dc..1e0d7a89e 100644
--- a/packages/app/src/pages/session.tsx
+++ b/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)
diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx
index 56a12c100..da99ba3b7 100644
--- a/packages/ui/src/components/diff-ssr.tsx
+++ b/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) {
diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx
index 46b6709b6..20dd5c440 100644
--- a/packages/ui/src/components/diff.tsx
+++ b/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} />
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index c47d11d08..814281723 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/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(),
diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts
index 38bf6c854..0d9092c21 100644
--- a/packages/ui/src/pierre/index.ts
+++ b/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"]