Skip to content

Commit fc52e4b

Browse files
feat(app): better diff/code comments (#14621)
Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com> Co-authored-by: David Hill <iamdavidhill@gmail.com>
1 parent 9a6bfeb commit fc52e4b

70 files changed

Lines changed: 6450 additions & 3147 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/app/e2e/files/file-tree.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
4343
await tab.click()
4444
await expect(tab).toHaveAttribute("aria-selected", "true")
4545

46-
const code = page.locator('[data-component="code"]').first()
47-
await expect(code).toBeVisible()
48-
await expect(code).toContainText("export default function FileTree")
46+
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
47+
await expect(viewer).toBeVisible()
48+
await expect(viewer).toContainText("export default function FileTree")
4949
})

packages/app/e2e/files/file-viewer.spec.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { test, expect } from "../fixtures"
22
import { promptSelector } from "../selectors"
3+
import { modKey } from "../utils"
34

45
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
56
await gotoSession()
@@ -43,7 +44,60 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
4344
await expect(tab).toBeVisible()
4445
await tab.click()
4546

46-
const code = page.locator('[data-component="code"]').first()
47-
await expect(code).toBeVisible()
48-
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
47+
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
48+
await expect(viewer).toBeVisible()
49+
await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
50+
})
51+
52+
test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
53+
await gotoSession()
54+
55+
await page.locator(promptSelector).click()
56+
await page.keyboard.type("/open")
57+
58+
const command = page.locator('[data-slash-id="file.open"]').first()
59+
await expect(command).toBeVisible()
60+
await page.keyboard.press("Enter")
61+
62+
const dialog = page
63+
.getByRole("dialog")
64+
.filter({ has: page.getByPlaceholder(/search files/i) })
65+
.first()
66+
await expect(dialog).toBeVisible()
67+
68+
const input = dialog.getByRole("textbox").first()
69+
await input.fill("package.json")
70+
71+
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
72+
let index = -1
73+
await expect
74+
.poll(
75+
async () => {
76+
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
77+
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
78+
return index >= 0
79+
},
80+
{ timeout: 30_000 },
81+
)
82+
.toBe(true)
83+
84+
const item = items.nth(index)
85+
await expect(item).toBeVisible()
86+
await item.click()
87+
88+
await expect(dialog).toHaveCount(0)
89+
90+
const tab = page.getByRole("tab", { name: "package.json" })
91+
await expect(tab).toBeVisible()
92+
await tab.click()
93+
94+
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
95+
await expect(viewer).toBeVisible()
96+
97+
await page.locator(promptSelector).click()
98+
await page.keyboard.press(`${modKey}+f`)
99+
100+
const findInput = page.getByPlaceholder("Find")
101+
await expect(findInput).toBeVisible()
102+
await expect(findInput).toBeFocused()
49103
})

packages/app/src/app.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import "@/index.css"
2-
import { Code } from "@opencode-ai/ui/code"
2+
import { File } from "@opencode-ai/ui/file"
33
import { I18nProvider } from "@opencode-ai/ui/context"
4-
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
54
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
6-
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
5+
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
76
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
8-
import { Diff } from "@opencode-ai/ui/diff"
97
import { Font } from "@opencode-ai/ui/font"
108
import { ThemeProvider } from "@opencode-ai/ui/theme"
119
import { MetaProvider } from "@solidjs/meta"
@@ -122,9 +120,7 @@ export function AppBaseProviders(props: ParentProps) {
122120
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
123121
<DialogProvider>
124122
<MarkedProviderWithNativeParser>
125-
<DiffComponentProvider component={Diff}>
126-
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
127-
</DiffComponentProvider>
123+
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
128124
</MarkedProviderWithNativeParser>
129125
</DialogProvider>
130126
</ErrorBoundary>

packages/app/src/components/prompt-input.tsx

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo
33
import { createStore } from "solid-js/store"
44
import { createFocusSignal } from "@solid-primitives/active-element"
55
import { useLocal } from "@/context/local"
6-
import { useFile } from "@/context/file"
6+
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
77
import {
88
ContentPart,
99
DEFAULT_PROMPT,
@@ -43,6 +43,9 @@ import {
4343
canNavigateHistoryAtCursor,
4444
navigatePromptHistory,
4545
prependHistoryEntry,
46+
type PromptHistoryComment,
47+
type PromptHistoryEntry,
48+
type PromptHistoryStoredEntry,
4649
promptLength,
4750
} from "./prompt-input/history"
4851
import { createPromptSubmit } from "./prompt-input/submit"
@@ -170,21 +173,38 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
170173
const focus = { file: item.path, id: item.commentID }
171174
comments.setActive(focus)
172175

176+
const queueCommentFocus = (attempts = 6) => {
177+
const schedule = (left: number) => {
178+
requestAnimationFrame(() => {
179+
comments.setFocus({ ...focus })
180+
if (left <= 0) return
181+
requestAnimationFrame(() => {
182+
const current = comments.focus()
183+
if (!current) return
184+
if (current.file !== focus.file || current.id !== focus.id) return
185+
schedule(left - 1)
186+
})
187+
})
188+
}
189+
190+
schedule(attempts)
191+
}
192+
173193
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
174194
if (wantsReview) {
175195
if (!view().reviewPanel.opened()) view().reviewPanel.open()
176196
layout.fileTree.setTab("changes")
177197
tabs().setActive("review")
178-
requestAnimationFrame(() => comments.setFocus(focus))
198+
queueCommentFocus()
179199
return
180200
}
181201

182202
if (!view().reviewPanel.opened()) view().reviewPanel.open()
183203
layout.fileTree.setTab("all")
184204
const tab = files.tab(item.path)
185205
tabs().open(tab)
186-
files.load(item.path)
187-
requestAnimationFrame(() => comments.setFocus(focus))
206+
tabs().setActive(tab)
207+
Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
188208
}
189209

190210
const recent = createMemo(() => {
@@ -219,15 +239,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
219239
const [store, setStore] = createStore<{
220240
popover: "at" | "slash" | null
221241
historyIndex: number
222-
savedPrompt: Prompt | null
242+
savedPrompt: PromptHistoryEntry | null
223243
placeholder: number
224244
draggingType: "image" | "@mention" | null
225245
mode: "normal" | "shell"
226246
applyingHistory: boolean
227247
}>({
228248
popover: null,
229249
historyIndex: -1,
230-
savedPrompt: null,
250+
savedPrompt: null as PromptHistoryEntry | null,
231251
placeholder: Math.floor(Math.random() * EXAMPLES.length),
232252
draggingType: null,
233253
mode: "normal",
@@ -256,15 +276,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
256276
const [history, setHistory] = persisted(
257277
Persist.global("prompt-history", ["prompt-history.v1"]),
258278
createStore<{
259-
entries: Prompt[]
279+
entries: PromptHistoryStoredEntry[]
260280
}>({
261281
entries: [],
262282
}),
263283
)
264284
const [shellHistory, setShellHistory] = persisted(
265285
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
266286
createStore<{
267-
entries: Prompt[]
287+
entries: PromptHistoryStoredEntry[]
268288
}>({
269289
entries: [],
270290
}),
@@ -282,9 +302,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
282302
}),
283303
)
284304

285-
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
305+
const historyComments = () => {
306+
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
307+
return prompt.context.items().flatMap((item) => {
308+
if (item.type !== "file") return []
309+
const comment = item.comment?.trim()
310+
if (!comment) return []
311+
312+
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
313+
const nextSelection =
314+
selection ??
315+
(item.selection
316+
? ({
317+
start: item.selection.startLine,
318+
end: item.selection.endLine,
319+
} satisfies SelectedLineRange)
320+
: undefined)
321+
if (!nextSelection) return []
322+
323+
return [
324+
{
325+
id: item.commentID ?? item.key,
326+
path: item.path,
327+
selection: { ...nextSelection },
328+
comment,
329+
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
330+
origin: item.commentOrigin,
331+
preview: item.preview,
332+
} satisfies PromptHistoryComment,
333+
]
334+
})
335+
}
336+
337+
const applyHistoryComments = (items: PromptHistoryComment[]) => {
338+
comments.replace(
339+
items.map((item) => ({
340+
id: item.id,
341+
file: item.path,
342+
selection: { ...item.selection },
343+
comment: item.comment,
344+
time: item.time,
345+
})),
346+
)
347+
prompt.context.replaceComments(
348+
items.map((item) => ({
349+
type: "file" as const,
350+
path: item.path,
351+
selection: selectionFromLines(item.selection),
352+
comment: item.comment,
353+
commentID: item.id,
354+
commentOrigin: item.origin,
355+
preview: item.preview,
356+
})),
357+
)
358+
}
359+
360+
const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => {
361+
const p = entry.prompt
286362
const length = position === "start" ? 0 : promptLength(p)
287363
setStore("applyingHistory", true)
364+
applyHistoryComments(entry.comments)
288365
prompt.set(p, length)
289366
requestAnimationFrame(() => {
290367
editorRef.focus()
@@ -846,7 +923,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
846923
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
847924
const currentHistory = mode === "shell" ? shellHistory : history
848925
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
849-
const next = prependHistoryEntry(currentHistory.entries, prompt)
926+
const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
850927
if (next === currentHistory.entries) return
851928
setCurrentHistory("entries", next)
852929
}
@@ -857,12 +934,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
857934
entries: store.mode === "shell" ? shellHistory.entries : history.entries,
858935
historyIndex: store.historyIndex,
859936
currentPrompt: prompt.current(),
937+
currentComments: historyComments(),
860938
savedPrompt: store.savedPrompt,
861939
})
862940
if (!result.handled) return false
863941
setStore("historyIndex", result.historyIndex)
864942
setStore("savedPrompt", result.savedPrompt)
865-
applyHistoryPrompt(result.prompt, result.cursor)
943+
applyHistoryPrompt(result.entry, result.cursor)
866944
return true
867945
}
868946

packages/app/src/components/prompt-input/build-request-parts.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ describe("buildRequestParts", () => {
3535
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
3636
).toBe(true)
3737
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
38+
expect(
39+
result.requestParts.some(
40+
(part) =>
41+
part.type === "text" &&
42+
part.synthetic &&
43+
part.metadata?.opencodeComment &&
44+
(part.metadata.opencodeComment as { comment?: string }).comment === "check this",
45+
),
46+
).toBe(true)
3847

3948
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
4049
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)

packages/app/src/components/prompt-input/build-request-parts.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { FileSelection } from "@/context/file"
44
import { encodeFilePath } from "@/context/file/path"
55
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
66
import { Identifier } from "@/utils/id"
7+
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
78

89
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
910

@@ -41,18 +42,6 @@ const fileQuery = (selection: FileSelection | undefined) =>
4142
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
4243
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
4344

44-
const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
45-
const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
46-
const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
47-
const range =
48-
start === undefined || end === undefined
49-
? "this file"
50-
: start === end
51-
? `line ${start}`
52-
: `lines ${start} through ${end}`
53-
return `The user made the following comment regarding ${range} of ${path}: ${comment}`
54-
}
55-
5645
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
5746
if (part.type === "text") {
5847
return {
@@ -153,8 +142,15 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
153142
{
154143
id: Identifier.ascending("part"),
155144
type: "text",
156-
text: commentNote(item.path, item.selection, comment),
145+
text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
157146
synthetic: true,
147+
metadata: createCommentMetadata({
148+
path: item.path,
149+
selection: item.selection,
150+
comment,
151+
preview: item.preview,
152+
origin: item.commentOrigin,
153+
}),
158154
} satisfies PromptRequestPart,
159155
filePart,
160156
]

0 commit comments

Comments
 (0)