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

Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com>
Co-authored-by: David Hill <iamdavidhill@gmail.com>
This commit is contained in:
Adam
2026-02-26 18:23:04 -06:00
committed by GitHub
parent 9a6bfeb782
commit fc52e4b2d3
70 changed files with 6454 additions and 3151 deletions

View File

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

View File

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

View File

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

View File

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