summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-21 06:17:55 -0600
committerAdam <[email protected]>2026-01-22 22:12:12 -0600
commitcb481d9ac861813d4ff091ed33bcac9e882da1a1 (patch)
treec08be4b96815b74ac6dc1e3bab6359cd5dbb27b3
parent0ce0cacb282c47943348a2af21ea00e721bcb9d9 (diff)
downloadopencode-cb481d9ac861813d4ff091ed33bcac9e882da1a1.tar.gz
opencode-cb481d9ac861813d4ff091ed33bcac9e882da1a1.zip
wip(app): line selection
-rw-r--r--packages/app/src/app.tsx17
-rw-r--r--packages/app/src/components/prompt-input.tsx21
-rw-r--r--packages/app/src/context/comments.tsx140
-rw-r--r--packages/app/src/context/prompt.tsx6
-rw-r--r--packages/app/src/pages/session.tsx20
-rw-r--r--packages/ui/src/components/diff-ssr.tsx45
-rw-r--r--packages/ui/src/components/diff.tsx42
-rw-r--r--packages/ui/src/components/session-review.css99
-rw-r--r--packages/ui/src/components/session-review.tsx449
-rw-r--r--packages/ui/src/pierre/index.ts10
10 files changed, 688 insertions, 161 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 56d6ec406..4fee0852f 100644
--- a/packages/app/src/app.tsx
+++ b/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>
)}
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 5e936737a..b2c8cccca 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/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>
diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx
new file mode 100644
index 000000000..12ee977e9
--- /dev/null
+++ b/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(),
+ }
+ },
+})
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index a76d9d5f1..40baa0ef5 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/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
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 1e0d7a89e..b2d9747c7 100644
--- a/packages/app/src/pages/session.tsx
+++ b/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)
diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx
index da99ba3b7..ac98a6d24 100644
--- a/packages/ui/src/components/diff-ssr.tsx
+++ b/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) {
diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx
index 20dd5c440..825a7e076 100644
--- a/packages/ui/src/components/diff.tsx
+++ b/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,10 +414,17 @@ 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)
})
diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css
index a53289b9a..775d3d444 100644
--- a/packages/ui/src/components/session-review.css
+++ b/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;
+ }
}
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index 814281723..7afebdced 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/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>
diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts
index 0d9092c21..16bc08f86 100644
--- a/packages/ui/src/pierre/index.ts
+++ b/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] {