Skip to content

Commit d7bbe01

Browse files
Apply PR #28422: fix(app): stabilize virtual session timeline interactions
2 parents 64fae43 + 63b8376 commit d7bbe01

13 files changed

Lines changed: 1134 additions & 152 deletions

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
141141
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
142142
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
143+
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
143144
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch"
144145
}
145146
}
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
import { expect, test, type Locator, type Page, type Route } from "@playwright/test"
2+
3+
const directory = "C:/OpenCode/TimelineStateRegression"
4+
const projectID = "proj_timeline_state_regression"
5+
const sessionID = "ses_timeline_state_regression"
6+
const userMessageID = "msg_user_regression"
7+
const assistantMessageID = "msg_assistant_regression"
8+
const editPartID = "prt_0001_edit"
9+
const textPartID = "prt_9999_text"
10+
const title = "Timeline collapse state regression"
11+
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
12+
13+
type EventPayload = {
14+
directory: string
15+
payload: Record<string, unknown>
16+
}
17+
18+
declare global {
19+
interface Window {
20+
__timelineDiffProbe: {
21+
reset: () => void
22+
shadowRoots: () => number
23+
}
24+
}
25+
}
26+
27+
const userMessage = {
28+
info: {
29+
id: userMessageID,
30+
sessionID,
31+
role: "user",
32+
time: { created: 1700000000000 },
33+
summary: { diffs: [] },
34+
agent: "build",
35+
model,
36+
},
37+
parts: [
38+
{
39+
id: "prt_user_text",
40+
sessionID,
41+
messageID: userMessageID,
42+
type: "text",
43+
text: "Please edit the file.",
44+
},
45+
],
46+
}
47+
48+
const editPart = {
49+
id: editPartID,
50+
sessionID,
51+
messageID: assistantMessageID,
52+
type: "tool",
53+
callID: "call_edit_regression",
54+
tool: "edit",
55+
state: {
56+
status: "completed",
57+
input: { filePath: "src/regression.ts" },
58+
output: "Edited src/regression.ts",
59+
title: "src/regression.ts",
60+
metadata: {
61+
filediff: {
62+
file: "src/regression.ts",
63+
additions: 1,
64+
deletions: 1,
65+
before: "export const value = 'before'\n",
66+
after: "export const value = 'after'\n",
67+
},
68+
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
69+
},
70+
time: { start: 1700000001000, end: 1700000002000 },
71+
},
72+
}
73+
74+
const streamedTextPart = {
75+
id: textPartID,
76+
sessionID,
77+
messageID: assistantMessageID,
78+
type: "text",
79+
text: "Streaming added a later assistant text part.",
80+
}
81+
82+
const assistantMessage = {
83+
info: {
84+
id: assistantMessageID,
85+
sessionID,
86+
role: "assistant",
87+
time: { created: 1700000001000 },
88+
parentID: userMessageID,
89+
modelID: model.modelID,
90+
providerID: model.providerID,
91+
mode: "build",
92+
agent: "build",
93+
path: { cwd: directory, root: directory },
94+
cost: 0.01,
95+
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
96+
variant: "max",
97+
},
98+
parts: [editPart],
99+
}
100+
101+
test.describe("regression: session timeline local row state", () => {
102+
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
103+
const events: EventPayload[] = []
104+
await mockServer(page, events)
105+
await configurePage(page)
106+
107+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
108+
await expect(page.getByRole("heading", { name: title })).toBeVisible()
109+
110+
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
111+
await expect(wrapper).toBeVisible()
112+
await expectExpanded(wrapper, true)
113+
114+
await wrapper.evaluate((element) => {
115+
;(element as HTMLElement).dataset.regressionMarker = "before-stream"
116+
})
117+
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
118+
await expectExpanded(wrapper, false)
119+
120+
events.push({
121+
directory,
122+
payload: {
123+
type: "message.part.updated",
124+
properties: { part: streamedTextPart },
125+
},
126+
})
127+
128+
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
129+
130+
expect(await readToolState(page)).toEqual({
131+
expanded: false,
132+
row: "AssistantPart",
133+
streamedTextVisible: true,
134+
})
135+
})
136+
137+
test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
138+
const events: EventPayload[] = []
139+
await installDiffProbe(page)
140+
await mockServer(page, events)
141+
await configurePage(page)
142+
143+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
144+
await expect(page.getByRole("heading", { name: title })).toBeVisible()
145+
146+
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
147+
await expect(wrapper).toBeVisible()
148+
await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible()
149+
await markDiffProbe(page)
150+
151+
events.push({
152+
directory,
153+
payload: {
154+
type: "message.part.updated",
155+
properties: { part: streamedTextPart },
156+
},
157+
})
158+
159+
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
160+
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
161+
162+
await markDiffProbe(page)
163+
events.push({
164+
directory,
165+
payload: {
166+
type: "message.part.updated",
167+
properties: { part: editPartWithAdditions(2) },
168+
},
169+
})
170+
171+
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible({ timeout: 10_000 })
172+
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
173+
})
174+
})
175+
176+
async function configurePage(page: Page) {
177+
await page.addInitScript(() => {
178+
localStorage.setItem(
179+
"settings.v3",
180+
JSON.stringify({
181+
general: {
182+
editToolPartsExpanded: true,
183+
shellToolPartsExpanded: true,
184+
showReasoningSummaries: true,
185+
showSessionProgressBar: true,
186+
},
187+
}),
188+
)
189+
})
190+
}
191+
192+
async function expectExpanded(locator: Locator, expected: boolean) {
193+
await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
194+
}
195+
196+
async function readToolState(page: Page) {
197+
return page.locator(`[data-timeline-part-id="${editPartID}"]`).first().evaluate((element, textPartID) => ({
198+
expanded: (() => {
199+
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
200+
const aria = trigger?.getAttribute("aria-expanded")
201+
if (aria === "true") return true
202+
if (aria === "false") return false
203+
204+
const root = element.querySelector('[data-component="collapsible"]')
205+
if (root?.hasAttribute("data-expanded")) return true
206+
if (root?.hasAttribute("data-closed")) return false
207+
208+
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
209+
return !!content && content.getBoundingClientRect().height > 0
210+
})(),
211+
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
212+
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
213+
}), textPartID)
214+
}
215+
216+
async function installDiffProbe(page: Page) {
217+
await page.addInitScript(() => {
218+
let shadowRootCount = 0
219+
const attachShadow = Element.prototype.attachShadow
220+
Element.prototype.attachShadow = function (init) {
221+
shadowRootCount += 1
222+
return attachShadow.call(this, init)
223+
}
224+
window.__timelineDiffProbe = {
225+
reset: () => {
226+
shadowRootCount = 0
227+
},
228+
shadowRoots: () => shadowRootCount,
229+
}
230+
})
231+
}
232+
233+
async function markDiffProbe(page: Page) {
234+
await page.locator(`[data-timeline-part-id="${editPartID}"]`).first().evaluate((element) => {
235+
const tool = element as HTMLElement
236+
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
237+
if (!file) throw new Error("missing edit diff file")
238+
239+
tool.dataset.timelineProbe = "before"
240+
file.dataset.timelineProbe = "before"
241+
window.__timelineDiffProbe.reset()
242+
})
243+
}
244+
245+
async function readDiffProbe(page: Page) {
246+
return page.locator(`[data-timeline-part-id="${editPartID}"]`).first().evaluate((element) => {
247+
const tool = element as HTMLElement
248+
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
249+
return {
250+
fileMarker: file?.dataset.timelineProbe,
251+
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
252+
toolMarker: tool.dataset.timelineProbe,
253+
}
254+
})
255+
}
256+
257+
function editPartWithAdditions(additions: number) {
258+
return {
259+
...editPart,
260+
state: {
261+
...editPart.state,
262+
metadata: {
263+
...editPart.state.metadata,
264+
filediff: {
265+
...editPart.state.metadata.filediff,
266+
additions,
267+
},
268+
},
269+
},
270+
}
271+
}
272+
273+
function readExpanded(element: Element) {
274+
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
275+
const aria = trigger?.getAttribute("aria-expanded")
276+
if (aria === "true") return true
277+
if (aria === "false") return false
278+
279+
const root = element.querySelector('[data-component="collapsible"]')
280+
if (root?.hasAttribute("data-expanded")) return true
281+
if (root?.hasAttribute("data-closed")) return false
282+
283+
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
284+
return !!content && content.getBoundingClientRect().height > 0
285+
}
286+
287+
async function mockServer(page: Page, events: EventPayload[]) {
288+
await page.route("**/*", async (route) => {
289+
const url = new URL(route.request().url())
290+
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
291+
if (url.port !== targetPort) return route.fallback()
292+
293+
const path = url.pathname
294+
if (path === "/global/event") return sse(route, events.splice(0))
295+
if (path === "/global/config" || path === "/config" || path === "/provider/auth" || path === "/mcp" || path === "/session/status") return json(route, {})
296+
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(path)) return json(route, [])
297+
if (path === "/provider") return json(route, provider())
298+
if (path === "/path") return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
299+
if (path === "/project") return json(route, [project()])
300+
if (path === "/project/current") return json(route, project())
301+
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
302+
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
303+
if (path === "/session") return json(route, [session()])
304+
if (path === `/session/${sessionID}`) return json(route, session())
305+
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
306+
if (path === `/session/${sessionID}/message`) return json(route, [userMessage, assistantMessage])
307+
return json(route, {})
308+
})
309+
}
310+
311+
function project() {
312+
return { id: projectID, worktree: directory, vcs: "git", name: "timeline-state-regression", time: { created: 1700000000000, updated: 1700000000000 }, sandboxes: [] }
313+
}
314+
315+
function session() {
316+
return { id: sessionID, slug: "timeline-state-regression", projectID, directory, title, version: "dev", time: { created: 1700000000000, updated: 1700000000000 } }
317+
}
318+
319+
function provider() {
320+
return {
321+
all: [
322+
{
323+
id: "opencode",
324+
name: "OpenCode",
325+
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
326+
},
327+
],
328+
connected: ["opencode"],
329+
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
330+
}
331+
}
332+
333+
function json(route: Route, body: unknown, headers?: Record<string, string>) {
334+
return route.fulfill({
335+
status: 200,
336+
contentType: "application/json",
337+
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
338+
body: JSON.stringify(body ?? null),
339+
})
340+
}
341+
342+
function sse(route: Route, events: EventPayload[]) {
343+
return route.fulfill({
344+
status: 200,
345+
contentType: "text/event-stream",
346+
headers: { "access-control-allow-origin": "*" },
347+
body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
348+
})
349+
}
350+
351+
function base64Encode(value: string) {
352+
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
353+
}

0 commit comments

Comments
 (0)