Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions packages/app/e2e/regression/review-line-comment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"

const directory = "C:/OpenCode/ReviewLineCommentRegression"
const sessionID = "ses_review_line_comment_regression"
const title = "Review line comment regression"

test.beforeEach(async ({ page }) => {
await openReview(page)
})

test("opens the comment editor when code is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const line = review.getByText("export const value = 'after'", { exact: true })
await expectAppVisible(line)
await line.click()

await expect(review.getByRole("textbox")).toBeVisible()
})

test("opens the comment editor when a line number is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
await lineNumber.click()

await expect(review.getByRole("textbox")).toBeVisible()
})

test("opens the comment editor for a line number range", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const start = review.locator('[data-column-number="1"]').last()
const end = review.locator('[data-column-number="3"]').last()
await expectAppVisible(start)
await expectAppVisible(end)

const from = await start.boundingBox()
const to = await end.boundingBox()
if (!from || !to) throw new Error("Missing line number bounds")
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
await page.mouse.down()
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
await page.mouse.up()

await expect(review.getByRole("textbox")).toBeVisible()
})

test("shows a comment button when a line number is hovered", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)

const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => {
await page.mouse.move(0, 0)
await lineNumber.hover()
await expect(comment).toBeVisible({ timeout: 500 })
}).toPass()
await comment.click()
await expect(review.getByRole("textbox")).toBeVisible()
})

test("stages a submitted line comment in the prompt context", async ({ page }) => {
const requests: string[] = []
page.on("request", (request) => {
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
})

const review = page.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click()
await review.getByRole("textbox").fill("Use the existing value instead")
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()

await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
const context = page.getByText("Use the existing value instead", { exact: true }).last()
await expect(context).toBeVisible()
await expect(context.locator("..")).toContainText("review.ts:2")
expect(requests).toEqual([])
})

async function openReview(page: Page) {
await page.setViewportSize({ width: 700, height: 900 })
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_review_line_comment_regression",
worktree: directory,
vcs: "git",
name: "review-line-comment-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "review-line-comment-regression",
projectID: "proj_review_line_comment_regression",
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [
{
file: "src/review.ts",
additions: 1,
deletions: 1,
status: "modified",
patch:
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
},
],
pageMessages: () => ({
items: [
{
info: {
id: "msg_review_line_comment_regression",
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model: { providerID: "opencode", modelID: "test" },
},
parts: [
{
id: "prt_review_line_comment_regression",
sessionID,
messageID: "msg_review_line_comment_regression",
type: "text",
text: "Review this change.",
},
],
},
],
}),
})

await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
await page.getByRole("tab", { name: "Changes" }).click()
expect(await (await diffResponse).json()).toHaveLength(1)

const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
await review
.getByRole("heading", { name: /review\.ts/ })
.getByRole("button")
.first()
.click()
}
2 changes: 2 additions & 0 deletions packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface MockServerConfig {
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
vcsDiff?: unknown[]
messageDelay?: number
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
events?: () => unknown[]
Expand Down Expand Up @@ -52,6 +53,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
Expand Down
5 changes: 2 additions & 3 deletions packages/app/src/pages/session/file-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -405,19 +405,18 @@ export function FileTabContent(props: { tab: string }) {
cacheKey: cacheKey(),
}}
enableLineSelection
enableHoverUtility
enableGutterUtility
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
scrollSync.queueRestore()
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderHoverUtility={commentsUi.renderHoverUtility}
renderGutterUtility={commentsUi.renderGutterUtility}
onLineSelected={(range: SelectedLineRange | null) => {
commentsUi.onLineSelected(range)
}}
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
commentsUi.onLineSelectionEnd(range)
}}
Expand Down
25 changes: 5 additions & 20 deletions packages/ui/src/components/line-comment-annotations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,12 +288,6 @@ export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
setSelected(next)
}

const finishSelection = (range: SelectedLineRange) => {
closeComment()
setSelected(range)
cancelDraft()
}

return {
draft,
setDraft,
Expand All @@ -310,7 +304,6 @@ export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
openEditor,
hoverComment,
cancelDraft,
finishSelection,
select: setSelected,
reset,
}
Expand All @@ -322,21 +315,19 @@ export function createLineCommentController<T extends LineCommentShape>(
note: ReturnType<typeof createLineCommentState<string>>
annotations: Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
renderGutterUtility: ReturnType<typeof createLineCommentGutterRenderer>
onLineSelected: (range: SelectedLineRange | null) => void
onLineSelectionEnd: (range: SelectedLineRange | null) => void
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
}
export function createLineCommentController<T extends LineCommentShape>(
props: LineCommentControllerProps<T>,
): {
note: ReturnType<typeof createLineCommentState<string>>
annotations: Accessor<LineCommentAnnotation<T>[]>
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
renderGutterUtility: ReturnType<typeof createLineCommentGutterRenderer>
onLineSelected: (range: SelectedLineRange | null) => void
onLineSelectionEnd: (range: SelectedLineRange | null) => void
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
}
export function createLineCommentController<T extends LineCommentShape>(
props: LineCommentControllerProps<T> | LineCommentControllerWithSideProps<T>,
Expand Down Expand Up @@ -426,7 +417,7 @@ export function createLineCommentController<T extends LineCommentShape>(
}),
})

const renderHoverUtility = createLineCommentHoverRenderer({
const renderGutterUtility = createLineCommentGutterRenderer({
label: props.label,
getSelectedRange: () => {
if (note.opened()) return null
Expand All @@ -452,22 +443,16 @@ export function createLineCommentController<T extends LineCommentShape>(
return
}

note.finishSelection(range)
}

const onLineNumberSelectionEnd = (range: SelectedLineRange | null) => {
if (!range) return
note.openDraft(range)
}

return {
note,
annotations,
renderAnnotation,
renderHoverUtility,
renderGutterUtility,
onLineSelected,
onLineSelectionEnd,
onLineNumberSelectionEnd,
}
}

Expand Down Expand Up @@ -569,7 +554,7 @@ export function createManagedLineCommentAnnotationRenderer<T>(props: {
}
}

export function createLineCommentHoverRenderer(props: {
export function createLineCommentGutterRenderer(props: {
label: string
getSelectedRange: Accessor<SelectedLineRange | null>
onOpenDraft: (range: SelectedLineRange) => void
Expand Down
7 changes: 4 additions & 3 deletions packages/ui/src/components/session-review.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -620,13 +620,14 @@ export const SessionReview = (props: SessionReviewProps) => {
props.onDiffRendered?.()
}}
enableLineSelection={props.onLineComment != null}
enableHoverUtility={props.onLineComment != null}
enableGutterUtility={props.onLineComment != null}
onLineSelected={handleLineSelected}
onLineSelectionEnd={handleLineSelectionEnd}
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
renderGutterUtility={
props.onLineComment ? commentsUi.renderGutterUtility : undefined
}
selectedLines={selectedLines()}
commentedLines={commentedLines()}
media={{
Expand Down
Loading