diff options
| author | Adam <[email protected]> | 2026-02-26 18:23:04 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-26 18:23:04 -0600 |
| commit | fc52e4b2d3a41efde772e6de8fb2e01f27821701 (patch) | |
| tree | cf23af294a00a10e55f230232585344c111f0bb9 /packages/app/src/pages | |
| parent | 9a6bfeb782766099d4ce3a98bb9e7b4e79f8bfe6 (diff) | |
| download | opencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.tar.gz opencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.zip | |
feat(app): better diff/code comments (#14621)
Co-authored-by: adamelmore <[email protected]>
Co-authored-by: David Hill <[email protected]>
Diffstat (limited to 'packages/app/src/pages')
| -rw-r--r-- | packages/app/src/pages/session.tsx | 60 | ||||
| -rw-r--r-- | packages/app/src/pages/session/file-tabs.tsx | 461 | ||||
| -rw-r--r-- | packages/app/src/pages/session/message-timeline.tsx | 120 | ||||
| -rw-r--r-- | packages/app/src/pages/session/review-tab.tsx | 99 |
4 files changed, 409 insertions, 331 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 75bd988f8..0d2718efb 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -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} diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 4b30915d8..e92eee670 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -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 getRoot = () => { - const el = wrap - if (!el) return - - const host = el.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) 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 syncSelected = (range: SelectedLineRange | null) => { + const p = path() + if (!p) return + file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null) } - 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 activeSelection = () => note.selected ?? selectedLines() + + 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 + + setTimeout(() => { + if (!document.activeElement || !current.contains(document.activeElement)) { + setNote("commenting", null) + } + }, 0) + }, + }) - 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) - } + createEffect(() => { + if (typeof window === "undefined") return - 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 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 - const range = note.commenting - if (!range) { - setNote("draftTop", undefined) - return + event.preventDefault() + event.stopPropagation() + find?.focus() } - const marker = findMarker(root, range) - if (marker) { - setNote("draftTop", markerTop(el, marker)) - return - } - - setNote("draftTop", large ? estimateTop(range) : undefined) - } - - const scheduleComments = () => { - requestAnimationFrame(updateComments) - } - - createEffect(() => { - commentLayout() - scheduleComments() + 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> diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index b84109035..8215f31ba 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -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> diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index fd2f3b2bd..7f90ff5ac 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -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 + + const maxY = Math.max(0, el.scrollHeight - el.clientHeight) + const maxX = Math.max(0, el.scrollWidth - el.clientWidth) - if (el.scrollTop !== s.y) el.scrollTop = s.y - if (el.scrollLeft !== s.x) el.scrollLeft = s.x + 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 handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - pending = { - x: event.currentTarget.scrollLeft, - y: event.currentTarget.scrollTop, - } - if (frame !== undefined) return + const queueRestore = () => { + if (userInteracted || restoreFrame !== undefined) return + restoreFrame = requestAnimationFrame(doRestore) + } - frame = requestAnimationFrame(() => { - frame = undefined + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + if (!layout.ready() || !userInteracted) return - const next = pending - pending = undefined - if (!next) return + const el = event.currentTarget + if (el.clientHeight === 0 || el.clientWidth === 0) 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} |
