Skip to content

Commit febb19b

Browse files
Apply PR #28422: fix(app): stabilize virtual session timeline interactions
2 parents a33cfac + bf6ff7d commit febb19b

9 files changed

Lines changed: 719 additions & 55 deletions

File tree

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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
139139
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
140140
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
141-
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
141+
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
142+
"virtua@0.49.1": "patches/virtua@0.49.1.patch"
142143
}
143144
}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
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+
const userMessage = {
19+
info: {
20+
id: userMessageID,
21+
sessionID,
22+
role: "user",
23+
time: { created: 1700000000000 },
24+
summary: { diffs: [] },
25+
agent: "build",
26+
model,
27+
},
28+
parts: [
29+
{
30+
id: "prt_user_text",
31+
sessionID,
32+
messageID: userMessageID,
33+
type: "text",
34+
text: "Please edit the file.",
35+
},
36+
],
37+
}
38+
39+
const editPart = {
40+
id: editPartID,
41+
sessionID,
42+
messageID: assistantMessageID,
43+
type: "tool",
44+
callID: "call_edit_regression",
45+
tool: "edit",
46+
state: {
47+
status: "completed",
48+
input: { filePath: "src/regression.ts" },
49+
output: "Edited src/regression.ts",
50+
title: "src/regression.ts",
51+
metadata: {
52+
filediff: {
53+
file: "src/regression.ts",
54+
additions: 1,
55+
deletions: 1,
56+
before: "export const value = 'before'\n",
57+
after: "export const value = 'after'\n",
58+
},
59+
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
60+
},
61+
time: { start: 1700000001000, end: 1700000002000 },
62+
},
63+
}
64+
65+
const streamedTextPart = {
66+
id: textPartID,
67+
sessionID,
68+
messageID: assistantMessageID,
69+
type: "text",
70+
text: "Streaming added a later assistant text part.",
71+
}
72+
73+
const assistantMessage = {
74+
info: {
75+
id: assistantMessageID,
76+
sessionID,
77+
role: "assistant",
78+
time: { created: 1700000001000 },
79+
parentID: userMessageID,
80+
modelID: model.modelID,
81+
providerID: model.providerID,
82+
mode: "build",
83+
agent: "build",
84+
path: { cwd: directory, root: directory },
85+
cost: 0.01,
86+
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
87+
variant: "max",
88+
},
89+
parts: [editPart],
90+
}
91+
92+
test.describe("regression: session timeline local row state", () => {
93+
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
94+
const events: EventPayload[] = []
95+
await mockServer(page, events)
96+
await configurePage(page)
97+
98+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
99+
await expect(page.getByRole("heading", { name: title })).toBeVisible()
100+
101+
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
102+
await expect(wrapper).toBeVisible()
103+
await expectExpanded(wrapper, true)
104+
105+
await wrapper.evaluate((element) => {
106+
;(element as HTMLElement).dataset.regressionMarker = "before-stream"
107+
})
108+
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
109+
await expectExpanded(wrapper, false)
110+
111+
events.push({
112+
directory,
113+
payload: {
114+
type: "message.part.updated",
115+
properties: { part: streamedTextPart },
116+
},
117+
})
118+
119+
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
120+
121+
expect(await readToolState(page)).toEqual({
122+
expanded: false,
123+
row: "AssistantPart",
124+
streamedTextVisible: true,
125+
})
126+
})
127+
})
128+
129+
async function configurePage(page: Page) {
130+
await page.addInitScript(() => {
131+
localStorage.setItem(
132+
"settings.v3",
133+
JSON.stringify({
134+
general: {
135+
editToolPartsExpanded: true,
136+
shellToolPartsExpanded: true,
137+
showReasoningSummaries: true,
138+
showSessionProgressBar: true,
139+
},
140+
}),
141+
)
142+
})
143+
}
144+
145+
async function expectExpanded(locator: Locator, expected: boolean) {
146+
await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
147+
}
148+
149+
async function readToolState(page: Page) {
150+
return page.locator(`[data-timeline-part-id="${editPartID}"]`).first().evaluate((element, textPartID) => ({
151+
expanded: (() => {
152+
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
153+
const aria = trigger?.getAttribute("aria-expanded")
154+
if (aria === "true") return true
155+
if (aria === "false") return false
156+
157+
const root = element.querySelector('[data-component="collapsible"]')
158+
if (root?.hasAttribute("data-expanded")) return true
159+
if (root?.hasAttribute("data-closed")) return false
160+
161+
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
162+
return !!content && content.getBoundingClientRect().height > 0
163+
})(),
164+
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
165+
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
166+
}), textPartID)
167+
}
168+
169+
function readExpanded(element: Element) {
170+
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
171+
const aria = trigger?.getAttribute("aria-expanded")
172+
if (aria === "true") return true
173+
if (aria === "false") return false
174+
175+
const root = element.querySelector('[data-component="collapsible"]')
176+
if (root?.hasAttribute("data-expanded")) return true
177+
if (root?.hasAttribute("data-closed")) return false
178+
179+
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
180+
return !!content && content.getBoundingClientRect().height > 0
181+
}
182+
183+
async function mockServer(page: Page, events: EventPayload[]) {
184+
await page.route("**/*", async (route) => {
185+
const url = new URL(route.request().url())
186+
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
187+
if (url.port !== targetPort) return route.fallback()
188+
189+
const path = url.pathname
190+
if (path === "/global/event") return sse(route, events.splice(0))
191+
if (path === "/global/config" || path === "/config" || path === "/provider/auth" || path === "/mcp" || path === "/session/status") return json(route, {})
192+
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(path)) return json(route, [])
193+
if (path === "/provider") return json(route, provider())
194+
if (path === "/path") return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
195+
if (path === "/project") return json(route, [project()])
196+
if (path === "/project/current") return json(route, project())
197+
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
198+
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
199+
if (path === "/session") return json(route, [session()])
200+
if (path === `/session/${sessionID}`) return json(route, session())
201+
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
202+
if (path === `/session/${sessionID}/message`) return json(route, [userMessage, assistantMessage])
203+
return json(route, {})
204+
})
205+
}
206+
207+
function project() {
208+
return { id: projectID, worktree: directory, vcs: "git", name: "timeline-state-regression", time: { created: 1700000000000, updated: 1700000000000 }, sandboxes: [] }
209+
}
210+
211+
function session() {
212+
return { id: sessionID, slug: "timeline-state-regression", projectID, directory, title, version: "dev", time: { created: 1700000000000, updated: 1700000000000 } }
213+
}
214+
215+
function provider() {
216+
return {
217+
all: [
218+
{
219+
id: "opencode",
220+
name: "OpenCode",
221+
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
222+
},
223+
],
224+
connected: ["opencode"],
225+
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
226+
}
227+
}
228+
229+
function json(route: Route, body: unknown, headers?: Record<string, string>) {
230+
return route.fulfill({
231+
status: 200,
232+
contentType: "application/json",
233+
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
234+
body: JSON.stringify(body ?? null),
235+
})
236+
}
237+
238+
function sse(route: Route, events: EventPayload[]) {
239+
return route.fulfill({
240+
status: 200,
241+
contentType: "text/event-stream",
242+
headers: { "access-control-allow-origin": "*" },
243+
body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
244+
})
245+
}
246+
247+
function base64Encode(value: string) {
248+
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
249+
}

0 commit comments

Comments
 (0)