summaryrefslogtreecommitdiffhomepage
path: root/packages/app
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
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')
-rw-r--r--packages/app/e2e/files/file-tree.spec.ts6
-rw-r--r--packages/app/e2e/files/file-viewer.spec.ts60
-rw-r--r--packages/app/src/app.tsx10
-rw-r--r--packages/app/src/components/prompt-input.tsx100
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.test.ts9
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.ts22
-rw-r--r--packages/app/src/components/prompt-input/history.test.ts52
-rw-r--r--packages/app/src/components/prompt-input/history.ts125
-rw-r--r--packages/app/src/components/session/session-context-tab.tsx5
-rw-r--r--packages/app/src/context/comments.test.ts33
-rw-r--r--packages/app/src/context/comments.tsx55
-rw-r--r--packages/app/src/context/file/view-cache.ts2
-rw-r--r--packages/app/src/context/layout-scroll.test.ts20
-rw-r--r--packages/app/src/context/layout-scroll.ts12
-rw-r--r--packages/app/src/context/prompt.tsx28
-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
-rw-r--r--packages/app/src/utils/comment-note.ts88
20 files changed, 974 insertions, 393 deletions
diff --git a/packages/app/e2e/files/file-tree.spec.ts b/packages/app/e2e/files/file-tree.spec.ts
index 321d96af5..44efb7f00 100644
--- a/packages/app/e2e/files/file-tree.spec.ts
+++ b/packages/app/e2e/files/file-tree.spec.ts
@@ -43,7 +43,7 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
- const code = page.locator('[data-component="code"]').first()
- await expect(code).toBeVisible()
- await expect(code).toContainText("export default function FileTree")
+ const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
+ await expect(viewer).toBeVisible()
+ await expect(viewer).toContainText("export default function FileTree")
})
diff --git a/packages/app/e2e/files/file-viewer.spec.ts b/packages/app/e2e/files/file-viewer.spec.ts
index b968acc13..bee67c7d1 100644
--- a/packages/app/e2e/files/file-viewer.spec.ts
+++ b/packages/app/e2e/files/file-viewer.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
+import { modKey } from "../utils"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession()
@@ -43,7 +44,60 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
await expect(tab).toBeVisible()
await tab.click()
- const code = page.locator('[data-component="code"]').first()
- await expect(code).toBeVisible()
- await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
+ const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
+ await expect(viewer).toBeVisible()
+ await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
+})
+
+test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ await page.locator(promptSelector).click()
+ await page.keyboard.type("/open")
+
+ const command = page.locator('[data-slash-id="file.open"]').first()
+ await expect(command).toBeVisible()
+ await page.keyboard.press("Enter")
+
+ const dialog = page
+ .getByRole("dialog")
+ .filter({ has: page.getByPlaceholder(/search files/i) })
+ .first()
+ await expect(dialog).toBeVisible()
+
+ const input = dialog.getByRole("textbox").first()
+ await input.fill("package.json")
+
+ const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
+ let index = -1
+ await expect
+ .poll(
+ async () => {
+ const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
+ index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
+ return index >= 0
+ },
+ { timeout: 30_000 },
+ )
+ .toBe(true)
+
+ const item = items.nth(index)
+ await expect(item).toBeVisible()
+ await item.click()
+
+ await expect(dialog).toHaveCount(0)
+
+ const tab = page.getByRole("tab", { name: "package.json" })
+ await expect(tab).toBeVisible()
+ await tab.click()
+
+ const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
+ await expect(viewer).toBeVisible()
+
+ await page.locator(promptSelector).click()
+ await page.keyboard.press(`${modKey}+f`)
+
+ const findInput = page.getByPlaceholder("Find")
+ await expect(findInput).toBeVisible()
+ await expect(findInput).toBeFocused()
})
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 1be9f38d7..4a25e8d94 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -1,11 +1,9 @@
import "@/index.css"
-import { Code } from "@opencode-ai/ui/code"
+import { File } from "@opencode-ai/ui/file"
import { I18nProvider } from "@opencode-ai/ui/context"
-import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
-import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
+import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
-import { Diff } from "@opencode-ai/ui/diff"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta"
@@ -122,9 +120,7 @@ export function AppBaseProviders(props: ParentProps) {
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProviderWithNativeParser>
- <DiffComponentProvider component={Diff}>
- <CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
- </DiffComponentProvider>
+ <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProviderWithNativeParser>
</DialogProvider>
</ErrorBoundary>
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 9174133ac..85aa16384 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -3,7 +3,7 @@ import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo
import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
-import { useFile } from "@/context/file"
+import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import {
ContentPart,
DEFAULT_PROMPT,
@@ -43,6 +43,9 @@ import {
canNavigateHistoryAtCursor,
navigatePromptHistory,
prependHistoryEntry,
+ type PromptHistoryComment,
+ type PromptHistoryEntry,
+ type PromptHistoryStoredEntry,
promptLength,
} from "./prompt-input/history"
import { createPromptSubmit } from "./prompt-input/submit"
@@ -170,12 +173,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const focus = { file: item.path, id: item.commentID }
comments.setActive(focus)
+ const queueCommentFocus = (attempts = 6) => {
+ const schedule = (left: number) => {
+ requestAnimationFrame(() => {
+ comments.setFocus({ ...focus })
+ if (left <= 0) return
+ requestAnimationFrame(() => {
+ const current = comments.focus()
+ if (!current) return
+ if (current.file !== focus.file || current.id !== focus.id) return
+ schedule(left - 1)
+ })
+ })
+ }
+
+ schedule(attempts)
+ }
+
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("changes")
tabs().setActive("review")
- requestAnimationFrame(() => comments.setFocus(focus))
+ queueCommentFocus()
return
}
@@ -183,8 +203,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
tabs().open(tab)
- files.load(item.path)
- requestAnimationFrame(() => comments.setFocus(focus))
+ tabs().setActive(tab)
+ Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
}
const recent = createMemo(() => {
@@ -219,7 +239,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [store, setStore] = createStore<{
popover: "at" | "slash" | null
historyIndex: number
- savedPrompt: Prompt | null
+ savedPrompt: PromptHistoryEntry | null
placeholder: number
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
@@ -227,7 +247,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}>({
popover: null,
historyIndex: -1,
- savedPrompt: null,
+ savedPrompt: null as PromptHistoryEntry | null,
placeholder: Math.floor(Math.random() * EXAMPLES.length),
draggingType: null,
mode: "normal",
@@ -256,7 +276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{
- entries: Prompt[]
+ entries: PromptHistoryStoredEntry[]
}>({
entries: [],
}),
@@ -264,7 +284,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [shellHistory, setShellHistory] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<{
- entries: Prompt[]
+ entries: PromptHistoryStoredEntry[]
}>({
entries: [],
}),
@@ -282,9 +302,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
)
- const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
+ const historyComments = () => {
+ const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
+ return prompt.context.items().flatMap((item) => {
+ if (item.type !== "file") return []
+ const comment = item.comment?.trim()
+ if (!comment) return []
+
+ const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
+ const nextSelection =
+ selection ??
+ (item.selection
+ ? ({
+ start: item.selection.startLine,
+ end: item.selection.endLine,
+ } satisfies SelectedLineRange)
+ : undefined)
+ if (!nextSelection) return []
+
+ return [
+ {
+ id: item.commentID ?? item.key,
+ path: item.path,
+ selection: { ...nextSelection },
+ comment,
+ time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
+ origin: item.commentOrigin,
+ preview: item.preview,
+ } satisfies PromptHistoryComment,
+ ]
+ })
+ }
+
+ const applyHistoryComments = (items: PromptHistoryComment[]) => {
+ comments.replace(
+ items.map((item) => ({
+ id: item.id,
+ file: item.path,
+ selection: { ...item.selection },
+ comment: item.comment,
+ time: item.time,
+ })),
+ )
+ prompt.context.replaceComments(
+ items.map((item) => ({
+ type: "file" as const,
+ path: item.path,
+ selection: selectionFromLines(item.selection),
+ comment: item.comment,
+ commentID: item.id,
+ commentOrigin: item.origin,
+ preview: item.preview,
+ })),
+ )
+ }
+
+ const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => {
+ const p = entry.prompt
const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true)
+ applyHistoryComments(entry.comments)
prompt.set(p, length)
requestAnimationFrame(() => {
editorRef.focus()
@@ -846,7 +923,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
- const next = prependHistoryEntry(currentHistory.entries, prompt)
+ const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
if (next === currentHistory.entries) return
setCurrentHistory("entries", next)
}
@@ -857,12 +934,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
entries: store.mode === "shell" ? shellHistory.entries : history.entries,
historyIndex: store.historyIndex,
currentPrompt: prompt.current(),
+ currentComments: historyComments(),
savedPrompt: store.savedPrompt,
})
if (!result.handled) return false
setStore("historyIndex", result.historyIndex)
setStore("savedPrompt", result.savedPrompt)
- applyHistoryPrompt(result.prompt, result.cursor)
+ applyHistoryPrompt(result.entry, result.cursor)
return true
}
diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts
index 72bdecc01..4c2e2d8be 100644
--- a/packages/app/src/components/prompt-input/build-request-parts.test.ts
+++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts
@@ -35,6 +35,15 @@ describe("buildRequestParts", () => {
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
).toBe(true)
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
+ expect(
+ result.requestParts.some(
+ (part) =>
+ part.type === "text" &&
+ part.synthetic &&
+ part.metadata?.opencodeComment &&
+ (part.metadata.opencodeComment as { comment?: string }).comment === "check this",
+ ),
+ ).toBe(true)
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts
index 0cc54dc2b..4146fb484 100644
--- a/packages/app/src/components/prompt-input/build-request-parts.ts
+++ b/packages/app/src/components/prompt-input/build-request-parts.ts
@@ -4,6 +4,7 @@ import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
+import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
@@ -41,18 +42,6 @@ const fileQuery = (selection: FileSelection | undefined) =>
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
-const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
- const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
- const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
- const range =
- start === undefined || end === undefined
- ? "this file"
- : start === end
- ? `line ${start}`
- : `lines ${start} through ${end}`
- return `The user made the following comment regarding ${range} of ${path}: ${comment}`
-}
-
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
if (part.type === "text") {
return {
@@ -153,8 +142,15 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
{
id: Identifier.ascending("part"),
type: "text",
- text: commentNote(item.path, item.selection, comment),
+ text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
synthetic: true,
+ metadata: createCommentMetadata({
+ path: item.path,
+ selection: item.selection,
+ comment,
+ preview: item.preview,
+ origin: item.commentOrigin,
+ }),
} satisfies PromptRequestPart,
filePart,
]
diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts
index b7a4f896b..37b5ce196 100644
--- a/packages/app/src/components/prompt-input/history.test.ts
+++ b/packages/app/src/components/prompt-input/history.test.ts
@@ -3,25 +3,42 @@ import type { Prompt } from "@/context/prompt"
import {
canNavigateHistoryAtCursor,
clonePromptParts,
+ normalizePromptHistoryEntry,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
+ type PromptHistoryComment,
} from "./history"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
+const comment = (id: string, value = "note"): PromptHistoryComment => ({
+ id,
+ path: "src/a.ts",
+ selection: { start: 2, end: 4 },
+ comment: value,
+ time: 1,
+ origin: "review",
+ preview: "const a = 1",
+})
describe("prompt-input history", () => {
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
const first = prependHistoryEntry([], DEFAULT_PROMPT)
expect(first).toEqual([])
+ const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")])
+ expect(commentsOnly).toHaveLength(1)
+
const withOne = prependHistoryEntry([], text("hello"))
expect(withOne).toHaveLength(1)
const deduped = prependHistoryEntry(withOne, text("hello"))
expect(deduped).toBe(withOne)
+
+ const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")])
+ expect(dedupedComments).toBe(commentsOnly)
})
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
@@ -31,24 +48,57 @@ describe("prompt-input history", () => {
entries,
historyIndex: -1,
currentPrompt: text("draft"),
+ currentComments: [comment("draft")],
savedPrompt: null,
})
expect(up.handled).toBe(true)
if (!up.handled) throw new Error("expected handled")
expect(up.historyIndex).toBe(0)
expect(up.cursor).toBe("start")
+ expect(up.entry.comments).toEqual([])
const down = navigatePromptHistory({
direction: "down",
entries,
historyIndex: up.historyIndex,
currentPrompt: text("ignored"),
+ currentComments: [],
savedPrompt: up.savedPrompt,
})
expect(down.handled).toBe(true)
if (!down.handled) throw new Error("expected handled")
expect(down.historyIndex).toBe(-1)
- expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
+ expect(down.entry.prompt[0]?.type === "text" ? down.entry.prompt[0].content : "").toBe("draft")
+ expect(down.entry.comments).toEqual([comment("draft")])
+ })
+
+ test("navigatePromptHistory keeps entry comments when moving through history", () => {
+ const entries = [
+ {
+ prompt: text("with comment"),
+ comments: [comment("c1")],
+ },
+ ]
+
+ const up = navigatePromptHistory({
+ direction: "up",
+ entries,
+ historyIndex: -1,
+ currentPrompt: text("draft"),
+ currentComments: [],
+ savedPrompt: null,
+ })
+
+ expect(up.handled).toBe(true)
+ if (!up.handled) throw new Error("expected handled")
+ expect(up.entry.prompt[0]?.type === "text" ? up.entry.prompt[0].content : "").toBe("with comment")
+ expect(up.entry.comments).toEqual([comment("c1")])
+ })
+
+ test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
+ const entry = normalizePromptHistoryEntry(text("legacy"))
+ expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
+ expect(entry.comments).toEqual([])
})
test("helpers clone prompt and count text content length", () => {
diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts
index c279a3ed5..de6265321 100644
--- a/packages/app/src/components/prompt-input/history.ts
+++ b/packages/app/src/components/prompt-input/history.ts
@@ -1,9 +1,27 @@
import type { Prompt } from "@/context/prompt"
+import type { SelectedLineRange } from "@/context/file"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100
+export type PromptHistoryComment = {
+ id: string
+ path: string
+ selection: SelectedLineRange
+ comment: string
+ time: number
+ origin?: "review" | "file"
+ preview?: string
+}
+
+export type PromptHistoryEntry = {
+ prompt: Prompt
+ comments: PromptHistoryComment[]
+}
+
+export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
+
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
const position = Math.max(0, Math.min(cursor, text.length))
const atStart = position === 0
@@ -25,29 +43,82 @@ export function clonePromptParts(prompt: Prompt): Prompt {
})
}
+function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
+ return {
+ start: selection.start,
+ end: selection.end,
+ ...(selection.side ? { side: selection.side } : {}),
+ ...(selection.endSide ? { endSide: selection.endSide } : {}),
+ }
+}
+
+export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
+ return comments.map((comment) => ({
+ ...comment,
+ selection: cloneSelection(comment.selection),
+ }))
+}
+
+export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
+ if (Array.isArray(entry)) {
+ return {
+ prompt: clonePromptParts(entry),
+ comments: [],
+ }
+ }
+ return {
+ prompt: clonePromptParts(entry.prompt),
+ comments: clonePromptHistoryComments(entry.comments),
+ }
+}
+
export function promptLength(prompt: Prompt) {
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
}
-export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
+export function prependHistoryEntry(
+ entries: PromptHistoryStoredEntry[],
+ prompt: Prompt,
+ comments: PromptHistoryComment[] = [],
+ max = MAX_HISTORY,
+) {
const text = prompt
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim()
const hasImages = prompt.some((part) => part.type === "image")
- if (!text && !hasImages) return entries
+ const hasComments = comments.some((comment) => !!comment.comment.trim())
+ if (!text && !hasImages && !hasComments) return entries
- const entry = clonePromptParts(prompt)
+ const entry = {
+ prompt: clonePromptParts(prompt),
+ comments: clonePromptHistoryComments(comments),
+ } satisfies PromptHistoryEntry
const last = entries[0]
if (last && isPromptEqual(last, entry)) return entries
return [entry, ...entries].slice(0, max)
}
-function isPromptEqual(promptA: Prompt, promptB: Prompt) {
- if (promptA.length !== promptB.length) return false
- for (let i = 0; i < promptA.length; i++) {
- const partA = promptA[i]
- const partB = promptB[i]
+function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
+ return (
+ commentA.path === commentB.path &&
+ commentA.comment === commentB.comment &&
+ commentA.origin === commentB.origin &&
+ commentA.preview === commentB.preview &&
+ commentA.selection.start === commentB.selection.start &&
+ commentA.selection.end === commentB.selection.end &&
+ commentA.selection.side === commentB.selection.side &&
+ commentA.selection.endSide === commentB.selection.endSide
+ )
+}
+
+function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
+ const entryA = normalizePromptHistoryEntry(promptA)
+ const entryB = normalizePromptHistoryEntry(promptB)
+ if (entryA.prompt.length !== entryB.prompt.length) return false
+ for (let i = 0; i < entryA.prompt.length; i++) {
+ const partA = entryA.prompt[i]
+ const partB = entryB.prompt[i]
if (partA.type !== partB.type) return false
if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
if (partA.type === "file") {
@@ -67,28 +138,35 @@ function isPromptEqual(promptA: Prompt, promptB: Prompt) {
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
}
+ if (entryA.comments.length !== entryB.comments.length) return false
+ for (let i = 0; i < entryA.comments.length; i++) {
+ const commentA = entryA.comments[i]
+ const commentB = entryB.comments[i]
+ if (!commentA || !commentB || !isCommentEqual(commentA, commentB)) return false
+ }
return true
}
type HistoryNavInput = {
direction: "up" | "down"
- entries: Prompt[]
+ entries: PromptHistoryStoredEntry[]
historyIndex: number
currentPrompt: Prompt
- savedPrompt: Prompt | null
+ currentComments: PromptHistoryComment[]
+ savedPrompt: PromptHistoryEntry | null
}
type HistoryNavResult =
| {
handled: false
historyIndex: number
- savedPrompt: Prompt | null
+ savedPrompt: PromptHistoryEntry | null
}
| {
handled: true
historyIndex: number
- savedPrompt: Prompt | null
- prompt: Prompt
+ savedPrompt: PromptHistoryEntry | null
+ entry: PromptHistoryEntry
cursor: "start" | "end"
}
@@ -103,22 +181,27 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
}
if (input.historyIndex === -1) {
+ const entry = normalizePromptHistoryEntry(input.entries[0])
return {
handled: true,
historyIndex: 0,
- savedPrompt: clonePromptParts(input.currentPrompt),
- prompt: input.entries[0],
+ savedPrompt: {
+ prompt: clonePromptParts(input.currentPrompt),
+ comments: clonePromptHistoryComments(input.currentComments),
+ },
+ entry,
cursor: "start",
}
}
if (input.historyIndex < input.entries.length - 1) {
const next = input.historyIndex + 1
+ const entry = normalizePromptHistoryEntry(input.entries[next])
return {
handled: true,
historyIndex: next,
savedPrompt: input.savedPrompt,
- prompt: input.entries[next],
+ entry,
cursor: "start",
}
}
@@ -132,11 +215,12 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
if (input.historyIndex > 0) {
const next = input.historyIndex - 1
+ const entry = normalizePromptHistoryEntry(input.entries[next])
return {
handled: true,
historyIndex: next,
savedPrompt: input.savedPrompt,
- prompt: input.entries[next],
+ entry,
cursor: "end",
}
}
@@ -147,7 +231,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
handled: true,
historyIndex: -1,
savedPrompt: null,
- prompt: input.savedPrompt,
+ entry: input.savedPrompt,
cursor: "end",
}
}
@@ -156,7 +240,10 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
handled: true,
historyIndex: -1,
savedPrompt: null,
- prompt: DEFAULT_PROMPT,
+ entry: {
+ prompt: DEFAULT_PROMPT,
+ comments: [],
+ },
cursor: "end",
}
}
diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx
index 1ea97c395..582aa3391 100644
--- a/packages/app/src/components/session/session-context-tab.tsx
+++ b/packages/app/src/components/session/session-context-tab.tsx
@@ -9,7 +9,7 @@ import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
-import { Code } from "@opencode-ai/ui/code"
+import { File } from "@opencode-ai/ui/file"
import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
@@ -47,7 +47,8 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
})
return (
- <Code
+ <File
+ mode="text"
file={file()}
overflow="wrap"
class="select-text"
diff --git a/packages/app/src/context/comments.test.ts b/packages/app/src/context/comments.test.ts
index bee5c7871..82fa170f2 100644
--- a/packages/app/src/context/comments.test.ts
+++ b/packages/app/src/context/comments.test.ts
@@ -150,4 +150,37 @@ describe("comments session indexing", () => {
dispose()
})
})
+
+ test("update changes only the targeted comment body", () => {
+ createRoot((dispose) => {
+ const comments = createCommentSessionForTest({
+ "a.ts": [line("a.ts", "a1", 10), line("a.ts", "a2", 20)],
+ })
+
+ comments.update("a.ts", "a2", "edited")
+
+ expect(comments.list("a.ts").map((item) => item.comment)).toEqual(["a1", "edited"])
+
+ dispose()
+ })
+ })
+
+ test("replace swaps comment state and clears focus state", () => {
+ createRoot((dispose) => {
+ const comments = createCommentSessionForTest({
+ "a.ts": [line("a.ts", "a1", 10)],
+ })
+
+ comments.setFocus({ file: "a.ts", id: "a1" })
+ comments.setActive({ file: "a.ts", id: "a1" })
+ comments.replace([line("b.ts", "b1", 30)])
+
+ expect(comments.list("a.ts")).toEqual([])
+ expect(comments.list("b.ts").map((item) => item.id)).toEqual(["b1"])
+ expect(comments.focus()).toBeNull()
+ expect(comments.active()).toBeNull()
+
+ dispose()
+ })
+ })
})
diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx
index ecf63e45b..a97010c0a 100644
--- a/packages/app/src/context/comments.tsx
+++ b/packages/app/src/context/comments.tsx
@@ -44,6 +44,37 @@ function aggregate(comments: Record<string, LineComment[]>) {
.sort((a, b) => a.time - b.time)
}
+function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
+ const next: SelectedLineRange = {
+ start: selection.start,
+ end: selection.end,
+ }
+
+ if (selection.side) next.side = selection.side
+ if (selection.endSide) next.endSide = selection.endSide
+ return next
+}
+
+function cloneComment(comment: LineComment): LineComment {
+ return {
+ ...comment,
+ selection: cloneSelection(comment.selection),
+ }
+}
+
+function group(comments: LineComment[]) {
+ return comments.reduce<Record<string, LineComment[]>>((acc, comment) => {
+ const list = acc[comment.file]
+ const next = cloneComment(comment)
+ if (list) {
+ list.push(next)
+ return acc
+ }
+ acc[comment.file] = [next]
+ return acc
+ }, {})
+}
+
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({
focus: null as CommentFocus | null,
@@ -70,6 +101,7 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
id: uuid(),
time: Date.now(),
...input,
+ selection: cloneSelection(input.selection),
}
batch(() => {
@@ -87,6 +119,23 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
})
}
+ const update = (file: string, id: string, comment: string) => {
+ setStore("comments", file, (items) =>
+ (items ?? []).map((item) => {
+ if (item.id !== id) return item
+ return { ...item, comment }
+ }),
+ )
+ }
+
+ const replace = (comments: LineComment[]) => {
+ batch(() => {
+ setStore("comments", reconcile(group(comments)))
+ setFocus(null)
+ setActive(null)
+ })
+ }
+
const clear = () => {
batch(() => {
setStore("comments", reconcile({}))
@@ -100,6 +149,8 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
all,
add,
remove,
+ update,
+ replace,
clear,
focus: () => state.focus,
setFocus,
@@ -132,6 +183,8 @@ function createCommentSession(dir: string, id: string | undefined) {
all: session.all,
add: session.add,
remove: session.remove,
+ update: session.update,
+ replace: session.replace,
clear: session.clear,
focus: session.focus,
setFocus: session.setFocus,
@@ -176,6 +229,8 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
all: () => session().all(),
add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
remove: (file: string, id: string) => session().remove(file, id),
+ update: (file: string, id: string, comment: string) => session().update(file, id, comment),
+ replace: (comments: LineComment[]) => session().replace(comments),
clear: () => session().clear(),
focus: () => session().focus(),
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts
index 6e8ddf62d..4c060174a 100644
--- a/packages/app/src/context/file/view-cache.ts
+++ b/packages/app/src/context/file/view-cache.ts
@@ -9,7 +9,7 @@ const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
- if (range.start <= range.end) return range
+ if (range.start <= range.end) return { ...range }
const startSide = range.side
const endSide = range.endSide ?? startSide
diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts
index 2a13e4020..483be150f 100644
--- a/packages/app/src/context/layout-scroll.test.ts
+++ b/packages/app/src/context/layout-scroll.test.ts
@@ -41,4 +41,24 @@ describe("createScrollPersistence", () => {
vi.useRealTimers()
}
})
+
+ test("reseeds empty cache after persisted snapshot loads", () => {
+ const snapshot = {
+ session: {},
+ } as Record<string, Record<string, { x: number; y: number }>>
+
+ const scroll = createScrollPersistence({
+ getSnapshot: (sessionKey) => snapshot[sessionKey],
+ onFlush: () => {},
+ })
+
+ expect(scroll.scroll("session", "review")).toBeUndefined()
+
+ snapshot.session = {
+ review: { x: 12, y: 34 },
+ }
+
+ expect(scroll.scroll("session", "review")).toEqual({ x: 12, y: 34 })
+ scroll.dispose()
+ })
})
diff --git a/packages/app/src/context/layout-scroll.ts b/packages/app/src/context/layout-scroll.ts
index 30b0f6904..ef66eccd9 100644
--- a/packages/app/src/context/layout-scroll.ts
+++ b/packages/app/src/context/layout-scroll.ts
@@ -33,8 +33,16 @@ export function createScrollPersistence(opts: Options) {
}
function seed(sessionKey: string) {
- if (cache[sessionKey]) return
- setCache(sessionKey, clone(opts.getSnapshot(sessionKey)))
+ const next = clone(opts.getSnapshot(sessionKey))
+ const current = cache[sessionKey]
+ if (!current) {
+ setCache(sessionKey, next)
+ return
+ }
+
+ if (Object.keys(current).length > 0) return
+ if (Object.keys(next).length === 0) return
+ setCache(sessionKey, next)
}
function scroll(sessionKey: string, tab: string) {
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index 064892105..fb8226559 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/packages/app/src/context/prompt.tsx
@@ -116,6 +116,10 @@ function contextItemKey(item: ContextItem) {
return `${key}:c=${digest.slice(0, 8)}`
}
+function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
+ return item.type === "file" && !!item.comment?.trim()
+}
+
function createPromptActions(
setStore: SetStoreFunction<{
prompt: Prompt
@@ -189,6 +193,26 @@ function createPromptSession(dir: string, id: string | undefined) {
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
+ removeComment(path: string, commentID: string) {
+ setStore("context", "items", (items) =>
+ items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
+ )
+ },
+ updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
+ setStore("context", "items", (items) =>
+ items.map((item) => {
+ if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
+ const value = { ...item, ...next }
+ return { ...value, key: contextItemKey(value) }
+ }),
+ )
+ },
+ replaceComments(items: FileContextItem[]) {
+ setStore("context", "items", (current) => [
+ ...current.filter((item) => !isCommentItem(item)),
+ ...items.map((item) => ({ ...item, key: contextItemKey(item) })),
+ ])
+ },
},
set: actions.set,
reset: actions.reset,
@@ -251,6 +275,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
items: () => session().context.items(),
add: (item: ContextItem) => session().context.add(item),
remove: (key: string) => session().context.remove(key),
+ removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
+ updateComment: (path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) =>
+ session().context.updateComment(path, commentID, next),
+ replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
},
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
reset: () => session().reset(),
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}
diff --git a/packages/app/src/utils/comment-note.ts b/packages/app/src/utils/comment-note.ts
new file mode 100644
index 000000000..99e87fc81
--- /dev/null
+++ b/packages/app/src/utils/comment-note.ts
@@ -0,0 +1,88 @@
+import type { FileSelection } from "@/context/file"
+
+export type PromptComment = {
+ path: string
+ selection?: FileSelection
+ comment: string
+ preview?: string
+ origin?: "review" | "file"
+}
+
+function selection(selection: unknown) {
+ if (!selection || typeof selection !== "object") return undefined
+ const startLine = Number((selection as FileSelection).startLine)
+ const startChar = Number((selection as FileSelection).startChar)
+ const endLine = Number((selection as FileSelection).endLine)
+ const endChar = Number((selection as FileSelection).endChar)
+ if (![startLine, startChar, endLine, endChar].every(Number.isFinite)) return undefined
+ return {
+ startLine,
+ startChar,
+ endLine,
+ endChar,
+ } satisfies FileSelection
+}
+
+export function createCommentMetadata(input: PromptComment) {
+ return {
+ opencodeComment: {
+ path: input.path,
+ selection: input.selection,
+ comment: input.comment,
+ preview: input.preview,
+ origin: input.origin,
+ },
+ }
+}
+
+export function readCommentMetadata(value: unknown) {
+ if (!value || typeof value !== "object") return
+ const meta = (value as { opencodeComment?: unknown }).opencodeComment
+ if (!meta || typeof meta !== "object") return
+ const path = (meta as { path?: unknown }).path
+ const comment = (meta as { comment?: unknown }).comment
+ if (typeof path !== "string" || typeof comment !== "string") return
+ const preview = (meta as { preview?: unknown }).preview
+ const origin = (meta as { origin?: unknown }).origin
+ return {
+ path,
+ selection: selection((meta as { selection?: unknown }).selection),
+ comment,
+ preview: typeof preview === "string" ? preview : undefined,
+ origin: origin === "review" || origin === "file" ? origin : undefined,
+ } satisfies PromptComment
+}
+
+export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) {
+ const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined
+ const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined
+ const range =
+ start === undefined || end === undefined
+ ? "this file"
+ : start === end
+ ? `line ${start}`
+ : `lines ${start} through ${end}`
+ return `The user made the following comment regarding ${range} of ${input.path}: ${input.comment}`
+}
+
+export function parseCommentNote(text: string) {
+ const match = text.match(
+ /^The user made the following comment regarding (this file|line (\d+)|lines (\d+) through (\d+)) of (.+?): ([\s\S]+)$/,
+ )
+ if (!match) return
+ const start = match[2] ? Number(match[2]) : match[3] ? Number(match[3]) : undefined
+ const end = match[2] ? Number(match[2]) : match[4] ? Number(match[4]) : undefined
+ return {
+ path: match[5],
+ selection:
+ start !== undefined && end !== undefined
+ ? {
+ startLine: start,
+ startChar: 0,
+ endLine: end,
+ endChar: 0,
+ }
+ : undefined,
+ comment: match[6],
+ } satisfies PromptComment
+}