Skip to content

Commit 3a7117b

Browse files
authored
feat(app): persist review state per session (anomalyco#35488)
1 parent cb9357c commit 3a7117b

3 files changed

Lines changed: 222 additions & 23 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { base64Encode } from "@opencode-ai/core/util/encode"
2+
import { expect, test, type Page } from "@playwright/test"
3+
import { mockOpenCodeServer } from "../utils/mock-server"
4+
import { expectSessionTitle } from "../utils/waits"
5+
6+
const directory = "C:/OpenCode/ReviewStatePersistence"
7+
const projectID = "proj_review_state_persistence"
8+
const sessionA = "ses_review_state_a"
9+
const sessionB = "ses_review_state_b"
10+
const titleA = "Alpha review state"
11+
const titleB = "Beta review state"
12+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
13+
14+
test.use({ viewport: { width: 1440, height: 900 } })
15+
16+
test("restores review mode and selected file per session", async ({ page }) => {
17+
await setup(page)
18+
await page.goto(sessionHref(sessionA))
19+
await expectSessionTitle(page, titleA)
20+
await page.getByRole("button", { name: "Toggle review" }).click()
21+
22+
await selectMode(page, "Git changes", "Branch changes")
23+
await selectFile(page, "beta.ts")
24+
25+
await switchSession(page, titleB)
26+
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
27+
await selectFile(page, "gamma.ts")
28+
29+
await switchSession(page, titleA)
30+
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
31+
await expectSelectedFile(page, "beta.ts")
32+
await selectMode(page, "Branch changes", "Git changes")
33+
await expectSelectedFile(page, "alpha.ts")
34+
await selectMode(page, "Git changes", "Branch changes")
35+
await expectSelectedFile(page, "beta.ts")
36+
37+
await page.reload()
38+
await expectSessionTitle(page, titleA)
39+
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
40+
await expectSelectedFile(page, "beta.ts")
41+
42+
await switchSession(page, titleB)
43+
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
44+
await expectSelectedFile(page, "gamma.ts")
45+
})
46+
47+
async function selectMode(page: Page, current: string, next: string) {
48+
await page.getByRole("button", { name: current }).click()
49+
await page.getByRole("option", { name: next }).click()
50+
}
51+
52+
async function selectFile(page: Page, file: string) {
53+
await page.getByRole("button", { name: file }).click()
54+
await expectSelectedFile(page, file)
55+
}
56+
57+
async function expectSelectedFile(page: Page, file: string) {
58+
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
59+
}
60+
61+
async function switchSession(page: Page, title: string) {
62+
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
63+
await expectSessionTitle(page, title)
64+
}
65+
66+
async function setup(page: Page) {
67+
await mockOpenCodeServer(page, {
68+
directory,
69+
project: {
70+
id: projectID,
71+
worktree: directory,
72+
vcs: "git",
73+
name: "review-state-persistence",
74+
time: { created: 1700000000000, updated: 1700000000000 },
75+
sandboxes: [],
76+
},
77+
provider: {
78+
all: [
79+
{
80+
id: "opencode",
81+
name: "OpenCode",
82+
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
83+
},
84+
],
85+
connected: ["opencode"],
86+
default: { providerID: "opencode", modelID: "test" },
87+
},
88+
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
89+
pageMessages: () => ({ items: [] }),
90+
})
91+
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
92+
route.fulfill({
93+
status: 200,
94+
contentType: "application/json",
95+
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
96+
}),
97+
)
98+
await page.route("**/vcs/diff**", (route) =>
99+
route.fulfill({
100+
status: 200,
101+
contentType: "application/json",
102+
body: JSON.stringify(
103+
new URL(route.request().url()).searchParams.get("mode") === "branch"
104+
? [diff("src/alpha.ts"), diff("src/beta.ts")]
105+
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
106+
),
107+
}),
108+
)
109+
await page.addInitScript(
110+
({ directory, server, sessions }) => {
111+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
112+
localStorage.setItem(
113+
"opencode.global.dat:server",
114+
JSON.stringify({
115+
projects: { local: [{ worktree: directory, expanded: true }] },
116+
lastProject: { local: directory },
117+
}),
118+
)
119+
localStorage.setItem(
120+
"opencode.window.browser.dat:tabs",
121+
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
122+
)
123+
},
124+
{ directory, server, sessions: [sessionA, sessionB] },
125+
)
126+
}
127+
128+
function session(id: string, title: string, created: number) {
129+
return {
130+
id,
131+
slug: id,
132+
projectID,
133+
directory,
134+
title,
135+
version: "dev",
136+
time: { created, updated: created },
137+
}
138+
}
139+
140+
function diff(file: string) {
141+
return {
142+
file,
143+
additions: 1,
144+
deletions: 1,
145+
status: "modified",
146+
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
147+
}
148+
}
149+
150+
function sessionHref(sessionID: string) {
151+
return `/server/${base64Encode(server)}/session/${sessionID}`
152+
}

packages/app/src/context/layout.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
5959
type SessionView = {
6060
scroll: Record<string, SessionScroll>
6161
reviewOpen?: string[]
62+
reviewMode?: ReviewChangeMode
63+
reviewFile?: string
6264
pendingMessage?: string
6365
pendingMessageAt?: number
6466
todoCollapsed?: boolean
@@ -75,6 +77,7 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
7577
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
7678

7779
export type ReviewDiffStyle = "unified" | "split"
80+
export type ReviewChangeMode = "git" | "branch" | "turn"
7881
export type ReviewPanelSource = "context-button" | "other"
7982

8083
export type LayoutRoute =
@@ -786,6 +789,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
786789
view(sessionKey: string | Accessor<string>) {
787790
const key = createSessionKeyReader(sessionKey, ensureKey)
788791
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
792+
const reviewMode = createMemo(() => {
793+
const mode = s().reviewMode
794+
if (mode === "git" || mode === "branch" || mode === "turn") return mode
795+
})
796+
const reviewFile = createMemo(() => {
797+
const file = s().reviewFile
798+
if (typeof file === "string") return file
799+
})
789800
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
790801
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
791802
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
@@ -869,6 +880,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
869880
},
870881
},
871882
review: {
883+
mode: reviewMode,
884+
setMode(mode: ReviewChangeMode) {
885+
const session = key()
886+
const current = store.sessionView[session]
887+
if (!current) {
888+
setStore("sessionView", session, { scroll: {}, reviewMode: mode })
889+
prune(session)
890+
return
891+
}
892+
if (current.reviewMode === mode) return
893+
setStore("sessionView", session, "reviewMode", mode)
894+
prune(session)
895+
},
896+
file: reviewFile,
897+
setFile(file: string) {
898+
const session = key()
899+
const current = store.sessionView[session]
900+
if (!current) {
901+
setStore("sessionView", session, { scroll: {}, reviewFile: file })
902+
prune(session)
903+
return
904+
}
905+
if (current.reviewFile === file) return
906+
setStore("sessionView", session, "reviewFile", file)
907+
prune(session)
908+
},
872909
open: createMemo(() => s().reviewOpen ?? []),
873910
setOpen(open: string[]) {
874911
const session = key()

0 commit comments

Comments
 (0)