Skip to content

Commit f60fd31

Browse files
authored
Merge branch 'dev' into for-cotaya
2 parents eaf25e9 + a379c79 commit f60fd31

43 files changed

Lines changed: 754 additions & 96 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { expect, test, type Page } from "@playwright/test"
2+
import { base64Encode } from "@opencode-ai/core/util/encode"
3+
import { mockOpenCodeServer } from "../utils/mock-server"
4+
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
5+
6+
const directory = "C:/OpenCode/ReviewLineCommentRegression"
7+
const sessionID = "ses_review_line_comment_regression"
8+
const title = "Review line comment regression"
9+
10+
test.beforeEach(async ({ page }) => {
11+
await openReview(page)
12+
})
13+
14+
test("opens the comment editor when code is clicked", async ({ page }) => {
15+
const review = page.locator('[data-component="session-review"]')
16+
const line = review.getByText("export const value = 'after'", { exact: true })
17+
await expectAppVisible(line)
18+
await line.click()
19+
20+
await expect(review.getByRole("textbox")).toBeVisible()
21+
})
22+
23+
test("opens the comment editor when a line number is clicked", async ({ page }) => {
24+
const review = page.locator('[data-component="session-review"]')
25+
const lineNumber = review.locator('[data-column-number="1"]').last()
26+
await expectAppVisible(lineNumber)
27+
await lineNumber.click()
28+
29+
await expect(review.getByRole("textbox")).toBeVisible()
30+
})
31+
32+
test("opens the comment editor for a line number range", async ({ page }) => {
33+
const review = page.locator('[data-component="session-review"]')
34+
const start = review.locator('[data-column-number="1"]').last()
35+
const end = review.locator('[data-column-number="3"]').last()
36+
await expectAppVisible(start)
37+
await expectAppVisible(end)
38+
39+
const from = await start.boundingBox()
40+
const to = await end.boundingBox()
41+
if (!from || !to) throw new Error("Missing line number bounds")
42+
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
43+
await page.mouse.down()
44+
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
45+
await page.mouse.up()
46+
47+
await expect(review.getByRole("textbox")).toBeVisible()
48+
})
49+
50+
test("shows a comment button when a line number is hovered", async ({ page }) => {
51+
const review = page.locator('[data-component="session-review"]')
52+
const lineNumber = review.locator('[data-column-number="1"]').last()
53+
await expectAppVisible(lineNumber)
54+
55+
const comment = review.getByRole("button", { name: "Comment", exact: true })
56+
await expect(async () => {
57+
await page.mouse.move(0, 0)
58+
await lineNumber.hover()
59+
await expect(comment).toBeVisible({ timeout: 500 })
60+
}).toPass()
61+
await comment.click()
62+
await expect(review.getByRole("textbox")).toBeVisible()
63+
})
64+
65+
test("stages a submitted line comment in the prompt context", async ({ page }) => {
66+
const requests: string[] = []
67+
page.on("request", (request) => {
68+
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
69+
})
70+
71+
const review = page.locator('[data-component="session-review"]')
72+
await review.getByText("export const value = 'after'", { exact: true }).click()
73+
await review.getByRole("textbox").fill("Use the existing value instead")
74+
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
75+
76+
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
77+
const context = page.getByText("Use the existing value instead", { exact: true }).last()
78+
await expect(context).toBeVisible()
79+
await expect(context.locator("..")).toContainText("review.ts:2")
80+
expect(requests).toEqual([])
81+
})
82+
83+
async function openReview(page: Page) {
84+
await page.setViewportSize({ width: 700, height: 900 })
85+
await mockOpenCodeServer(page, {
86+
directory,
87+
project: {
88+
id: "proj_review_line_comment_regression",
89+
worktree: directory,
90+
vcs: "git",
91+
name: "review-line-comment-regression",
92+
time: { created: 1700000000000, updated: 1700000000000 },
93+
sandboxes: [],
94+
},
95+
provider: { all: [], connected: [], default: {} },
96+
sessions: [
97+
{
98+
id: sessionID,
99+
slug: "review-line-comment-regression",
100+
projectID: "proj_review_line_comment_regression",
101+
directory,
102+
title,
103+
version: "dev",
104+
time: { created: 1700000000000, updated: 1700000000000 },
105+
},
106+
],
107+
vcsDiff: [
108+
{
109+
file: "src/review.ts",
110+
additions: 1,
111+
deletions: 1,
112+
status: "modified",
113+
patch:
114+
"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",
115+
},
116+
],
117+
pageMessages: () => ({
118+
items: [
119+
{
120+
info: {
121+
id: "msg_review_line_comment_regression",
122+
sessionID,
123+
role: "user",
124+
time: { created: 1700000000000 },
125+
summary: { diffs: [] },
126+
agent: "build",
127+
model: { providerID: "opencode", modelID: "test" },
128+
},
129+
parts: [
130+
{
131+
id: "prt_review_line_comment_regression",
132+
sessionID,
133+
messageID: "msg_review_line_comment_regression",
134+
type: "text",
135+
text: "Review this change.",
136+
},
137+
],
138+
},
139+
],
140+
}),
141+
})
142+
143+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
144+
await expectSessionTitle(page, title)
145+
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
146+
await page.getByRole("tab", { name: "Changes" }).click()
147+
expect(await (await diffResponse).json()).toHaveLength(1)
148+
149+
const review = page.locator('[data-component="session-review"]')
150+
await expectAppVisible(review)
151+
await review
152+
.getByRole("heading", { name: /review\.ts/ })
153+
.getByRole("button")
154+
.first()
155+
.click()
156+
}

packages/app/e2e/utils/mock-server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface MockServerConfig {
1818
project: unknown
1919
sessions: ({ id: string } & Record<string, unknown>)[]
2020
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
21+
vcsDiff?: unknown[]
2122
messageDelay?: number
2223
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
2324
events?: () => unknown[]
@@ -52,6 +53,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
5253
const path = url.pathname
5354
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
5455
if (path === "/global/health") return json(route, { healthy: true })
56+
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
5557
if (emptyObject.has(path)) return json(route, {})
5658
if (emptyList.has(path)) return json(route, [])
5759
if (path in staticRoutes) return json(route, staticRoutes[path])

packages/app/src/components/settings-v2/servers.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
21
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
32
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
43
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
@@ -14,7 +13,7 @@ import { ServerConnection, serverName } from "@/context/server"
1413
import { useServerManagementController } from "../dialog-select-server"
1514
import { DialogServerV2 } from "./dialog-server-v2"
1615
import { SettingsListV2 } from "./parts/list"
17-
import { isWslServer, useFilteredWslServers, WslAddServerButton, WslServerSettings } from "@/wsl/settings"
16+
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
1817
import "./settings-v2.css"
1918

2019
export const SettingsServersV2: Component = () => {
@@ -55,10 +54,7 @@ export const SettingsServersV2: Component = () => {
5554
>
5655
<div class="settings-v2-tab-header-row">
5756
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
58-
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
59-
{language.t("dialog.server.add.button")}
60-
</ButtonV2>
61-
<WslAddServerButton />
57+
<AddServerMenu onAddServer={openAdd} />
6258
</div>
6359
<Show when={showSearch()}>
6460
<div class="settings-v2-tab-search">

packages/app/src/i18n/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,8 @@ export const dict = {
589589
"home.title": "Home",
590590
"home.projects": "Projects",
591591
"home.project.add": "Add project",
592+
"home.server.collapse": "Collapse server projects",
593+
"home.server.expand": "Expand server projects",
592594
"home.sessions.search.placeholder": "Search sessions",
593595
"home.sessions.search.sessions": "Sessions",
594596
"home.sessions.search.noResults": "No sessions found for {{query}}",

packages/app/src/pages/home.tsx

Lines changed: 81 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { useCommand } from "@/context/command"
4646
import { ServerRowMenu } from "@/components/server/server-row-menu"
4747
import { ServerHealthIndicator } from "@/components/server/server-row"
4848
import { type ServerHealth } from "@/utils/server-health"
49+
import { Persist, persisted } from "@/utils/persist"
4950

5051
const HOME_SESSION_LIMIT = 64
5152
const HOME_ROW_LAYOUT =
@@ -329,7 +330,7 @@ export function NewHome() {
329330

330331
return (
331332
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
332-
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
333+
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
333334
<HomeProjectColumn
334335
projects={projects()}
335336
selected={state.selection}
@@ -355,7 +356,7 @@ export function NewHome() {
355356
/>
356357

357358
<section
358-
class="min-h-0 min-w-0 flex-1 flex flex-col pt-12"
359+
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12"
359360
aria-label={language.t("sidebar.project.recentSessions")}
360361
>
361362
<HomeSessionSearch
@@ -419,6 +420,12 @@ export function NewHome() {
419420
</div>
420421
</ScrollView>
421422
</section>
423+
<HomeUtilityNav
424+
class="flex lg:hidden"
425+
openSettings={openSettings}
426+
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
427+
language={language}
428+
/>
422429
</div>
423430
</div>
424431
)
@@ -442,8 +449,15 @@ function HomeProjectColumn(props: {
442449
const global = useGlobal()
443450
const dialog = useDialog()
444451
const controller = useServerManagementController({ navigateOnAdd: false })
452+
const [state, setState] = persisted(
453+
Persist.global("home.servers", ["home.servers.v1"]),
454+
createStore({ collapsed: {} as Record<string, boolean> }),
455+
)
445456
return (
446-
<aside class="flex min-w-0 flex-col lg:pt-[52px] mt-14 gap-4" aria-label={props.language.t("home.projects")}>
457+
<aside
458+
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
459+
aria-label={props.language.t("home.projects")}
460+
>
447461
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
448462
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
449463
<Show when={global.servers.list().length === 1}>
@@ -467,20 +481,23 @@ function HomeProjectColumn(props: {
467481
const key = ServerConnection.key(item)
468482
const healthy = () => !!global.servers.health[key]?.healthy
469483
const serverCtx = global.createServerCtx(item)
484+
const collapsed = () => !!state.collapsed[key]
470485
return (
471486
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
472487
<HomeServerRow
473488
server={item}
474489
selected={props.selected.server === key && !props.selected.directory}
475490
healthy={healthy()}
491+
collapsed={collapsed()}
476492
health={global.servers.health[key]}
477493
controller={controller}
478494
focusServer={props.focusServer}
479495
chooseProject={props.chooseProject}
480496
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
497+
toggleCollapsed={() => setState("collapsed", key, !state.collapsed[key])}
481498
language={props.language}
482499
/>
483-
<Show when={healthy()}>
500+
<Show when={healthy() && !collapsed()}>
484501
<div class="mx-3 h-px bg-v2-border-border-base" />
485502
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
486503
</Show>
@@ -489,37 +506,55 @@ function HomeProjectColumn(props: {
489506
}}
490507
</For>
491508
</Show>
492-
<div class="mt-4 flex min-w-0 flex-col gap-1">
493-
<button
494-
type="button"
495-
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
496-
onClick={props.openSettings}
497-
>
498-
<IconV2 name="settings-gear" size="small" />
499-
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
500-
</button>
501-
<button
502-
type="button"
503-
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
504-
onClick={props.openHelp}
505-
>
506-
<IconV2 name="help" size="small" />
507-
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
508-
</button>
509-
</div>
509+
<HomeUtilityNav
510+
class="mt-4 hidden lg:flex"
511+
openSettings={props.openSettings}
512+
openHelp={props.openHelp}
513+
language={props.language}
514+
/>
510515
</aside>
511516
)
512517
}
513518

519+
function HomeUtilityNav(props: {
520+
class?: string
521+
openSettings: () => void
522+
openHelp: () => void
523+
language: ReturnType<typeof useLanguage>
524+
}) {
525+
return (
526+
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1`}>
527+
<button
528+
type="button"
529+
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
530+
onClick={props.openSettings}
531+
>
532+
<IconV2 name="settings-gear" size="small" />
533+
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
534+
</button>
535+
<button
536+
type="button"
537+
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
538+
onClick={props.openHelp}
539+
>
540+
<IconV2 name="help" size="small" />
541+
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
542+
</button>
543+
</div>
544+
)
545+
}
546+
514547
function HomeServerRow(props: {
515548
server: ServerConnection.Any
516549
selected: boolean
517550
healthy: boolean
551+
collapsed: boolean
518552
health: ServerHealth | undefined
519553
controller: ReturnType<typeof useServerManagementController>
520554
focusServer: (server: ServerConnection.Any) => void
521555
chooseProject: (server: ServerConnection.Any) => void
522556
openEdit: (server: ServerConnection.Http) => void
557+
toggleCollapsed: () => void
523558
language: ReturnType<typeof useLanguage>
524559
}) {
525560
const [state, setState] = createStore({ menuOpen: false })
@@ -532,7 +567,30 @@ function HomeServerRow(props: {
532567
disabled={!props.healthy}
533568
onClick={() => props.focusServer(props.server)}
534569
>
535-
<div class="flex size-4 shrink-0 items-center justify-center">
570+
<Show when={props.healthy}>
571+
<span
572+
data-action="home-server-collapse"
573+
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
574+
aria-label={
575+
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
576+
}
577+
aria-expanded={!props.collapsed}
578+
onClick={(event) => {
579+
event.preventDefault()
580+
event.stopPropagation()
581+
props.toggleCollapsed()
582+
}}
583+
onPointerDown={(event) => event.preventDefault()}
584+
>
585+
<IconV2
586+
name="chevron-down"
587+
size="small"
588+
class="transition-transform duration-150 ease-in-out"
589+
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
590+
/>
591+
</span>
592+
</Show>
593+
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
536594
<ServerHealthIndicator health={props.health} />
537595
</div>
538596
<span class="flex min-w-0 items-center gap-1">

0 commit comments

Comments
 (0)