summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-26 18:23:04 -0600
committerGitHub <[email protected]>2026-02-26 18:23:04 -0600
commitfc52e4b2d3a41efde772e6de8fb2e01f27821701 (patch)
treecf23af294a00a10e55f230232585344c111f0bb9 /packages/app/src/pages
parent9a6bfeb782766099d4ce3a98bb9e7b4e79f8bfe6 (diff)
downloadopencode-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.tsx60
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx461
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx120
-rw-r--r--packages/app/src/pages/session/review-tab.tsx99
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}