diff options
| author | Adam <[email protected]> | 2026-02-26 18:23:04 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-26 18:23:04 -0600 |
| commit | fc52e4b2d3a41efde772e6de8fb2e01f27821701 (patch) | |
| tree | cf23af294a00a10e55f230232585344c111f0bb9 /packages/app | |
| parent | 9a6bfeb782766099d4ce3a98bb9e7b4e79f8bfe6 (diff) | |
| download | opencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.tar.gz opencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.zip | |
feat(app): better diff/code comments (#14621)
Co-authored-by: adamelmore <[email protected]>
Co-authored-by: David Hill <[email protected]>
Diffstat (limited to 'packages/app')
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 +} |
