session-timeline-collapse-state.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. import { expect, test, type Locator, type Page } from "@playwright/test"
  2. import { mockOpenCodeServer } from "../utils/mock-server"
  3. import { expectAppVisible, expectSessionTitle } from "../utils/waits"
  4. const directory = "C:/OpenCode/TimelineStateRegression"
  5. const projectID = "proj_timeline_state_regression"
  6. const sessionID = "ses_timeline_state_regression"
  7. const userMessageID = "msg_user_regression"
  8. const assistantMessageID = "msg_assistant_regression"
  9. const editPartID = "prt_0001_edit"
  10. const textPartID = "prt_9999_text"
  11. const title = "Timeline collapse state regression"
  12. const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
  13. type EventPayload = {
  14. directory: string
  15. payload: Record<string, unknown>
  16. }
  17. declare global {
  18. interface Window {
  19. __timelineDiffProbe: {
  20. reset: () => void
  21. shadowRoots: () => number
  22. }
  23. }
  24. }
  25. const userMessage = {
  26. info: {
  27. id: userMessageID,
  28. sessionID,
  29. role: "user",
  30. time: { created: 1700000000000 },
  31. summary: { diffs: [] },
  32. agent: "build",
  33. model,
  34. },
  35. parts: [
  36. {
  37. id: "prt_user_text",
  38. sessionID,
  39. messageID: userMessageID,
  40. type: "text",
  41. text: "Please edit the file.",
  42. },
  43. ],
  44. }
  45. const editPart = {
  46. id: editPartID,
  47. sessionID,
  48. messageID: assistantMessageID,
  49. type: "tool",
  50. callID: "call_edit_regression",
  51. tool: "edit",
  52. state: {
  53. status: "completed",
  54. input: { filePath: "src/regression.ts" },
  55. output: "Edited src/regression.ts",
  56. title: "src/regression.ts",
  57. metadata: {
  58. filediff: {
  59. file: "src/regression.ts",
  60. additions: 1,
  61. deletions: 1,
  62. before: "export const value = 'before'\n",
  63. after: "export const value = 'after'\n",
  64. },
  65. diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
  66. },
  67. time: { start: 1700000001000, end: 1700000002000 },
  68. },
  69. }
  70. const streamedTextPart = {
  71. id: textPartID,
  72. sessionID,
  73. messageID: assistantMessageID,
  74. type: "text",
  75. text: "Streaming added a later assistant text part.",
  76. }
  77. const assistantMessage = {
  78. info: {
  79. id: assistantMessageID,
  80. sessionID,
  81. role: "assistant",
  82. time: { created: 1700000001000 },
  83. parentID: userMessageID,
  84. modelID: model.modelID,
  85. providerID: model.providerID,
  86. mode: "build",
  87. agent: "build",
  88. path: { cwd: directory, root: directory },
  89. cost: 0.01,
  90. tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
  91. variant: "max",
  92. },
  93. parts: [editPart],
  94. }
  95. test.describe("regression: session timeline local row state", () => {
  96. test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
  97. const events: EventPayload[] = []
  98. await mockServer(page, events)
  99. await configurePage(page)
  100. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  101. await expectSessionTitle(page, title)
  102. const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
  103. await expectAppVisible(wrapper)
  104. await expectExpanded(wrapper, true)
  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. events.push({
  111. directory,
  112. payload: {
  113. type: "message.part.updated",
  114. properties: { part: streamedTextPart },
  115. },
  116. })
  117. await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
  118. expect(await readToolState(page)).toEqual({
  119. expanded: false,
  120. row: "AssistantPart",
  121. streamedTextVisible: true,
  122. })
  123. })
  124. test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
  125. const events: EventPayload[] = []
  126. await installDiffProbe(page)
  127. await mockServer(page, events)
  128. await configurePage(page)
  129. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  130. await expectSessionTitle(page, title)
  131. const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
  132. await expectAppVisible(wrapper)
  133. const file = wrapper.locator('[data-component="file"][data-mode="diff"]').first()
  134. await expectAppVisible(file)
  135. await markDiffProbe(page)
  136. events.push({
  137. directory,
  138. payload: {
  139. type: "message.part.updated",
  140. properties: { part: streamedTextPart },
  141. },
  142. })
  143. await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
  144. const siblingProbe = await readDiffProbe(page)
  145. expect(siblingProbe).toEqual({
  146. fileMarker: "before",
  147. frameMarker: "before",
  148. rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
  149. rowMarker: "before",
  150. shadowRoots: 0,
  151. toolMarker: "before",
  152. })
  153. await markDiffProbe(page)
  154. events.push({
  155. directory,
  156. payload: {
  157. type: "message.part.updated",
  158. properties: { part: editPartWithAdditions(2) },
  159. },
  160. })
  161. await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
  162. { timeout: 10_000 },
  163. )
  164. expect(await readDiffProbe(page)).toEqual({
  165. fileMarker: "before",
  166. frameMarker: "before",
  167. rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
  168. rowMarker: "before",
  169. shadowRoots: 0,
  170. toolMarker: "before",
  171. })
  172. })
  173. test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
  174. const events: EventPayload[] = []
  175. const lines = Array.from({ length: 1_000 }, (_, index) => `export const value${index} = ${index}\n`).join("")
  176. const after = [100, 300, 500, 700, 900].reduce(
  177. (result, index) =>
  178. result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`),
  179. lines,
  180. )
  181. const part = {
  182. ...editPart,
  183. state: {
  184. ...editPart.state,
  185. metadata: {
  186. ...editPart.state.metadata,
  187. filediff: {
  188. file: "src/regression.ts",
  189. additions: 1,
  190. deletions: 1,
  191. before: lines,
  192. after,
  193. },
  194. },
  195. },
  196. }
  197. await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }])
  198. await configurePage(page)
  199. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  200. await expectSessionTitle(page, title)
  201. const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
  202. const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first()
  203. const diff = wrapper.locator('[data-component="edit-content"]').first()
  204. await expectAppVisible(diff)
  205. await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500)
  206. const samples = await wrapper.evaluate(async (element) => {
  207. const root = element.closest<HTMLElement>(".scroll-view__viewport")!
  208. element.scrollIntoView({ block: "start" })
  209. const result = []
  210. for (const offset of [0, 120, 240, 360, 480]) {
  211. root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0))
  212. await new Promise(requestAnimationFrame)
  213. const trigger = element.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')!
  214. const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
  215. result.push({
  216. offset,
  217. trigger: trigger.getBoundingClientRect().y,
  218. diff: diff.getBoundingClientRect().y,
  219. bottom: element.getBoundingClientRect().bottom,
  220. })
  221. }
  222. return result
  223. })
  224. expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff)
  225. expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true)
  226. expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true)
  227. })
  228. })
  229. async function configurePage(page: Page) {
  230. await page.addInitScript(() => {
  231. localStorage.setItem(
  232. "settings.v3",
  233. JSON.stringify({
  234. general: {
  235. editToolPartsExpanded: true,
  236. shellToolPartsExpanded: true,
  237. showReasoningSummaries: true,
  238. },
  239. }),
  240. )
  241. })
  242. }
  243. async function expectExpanded(locator: Locator, expected: boolean) {
  244. await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
  245. }
  246. async function readToolState(page: Page) {
  247. return page
  248. .locator(`[data-timeline-part-id="${editPartID}"]`)
  249. .first()
  250. .evaluate(
  251. (element, textPartID) => ({
  252. expanded: (() => {
  253. const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
  254. const aria = trigger?.getAttribute("aria-expanded")
  255. if (aria === "true") return true
  256. if (aria === "false") return false
  257. const root = element.querySelector('[data-component="collapsible"]')
  258. if (root?.hasAttribute("data-expanded")) return true
  259. if (root?.hasAttribute("data-closed")) return false
  260. const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
  261. return !!content && content.getBoundingClientRect().height > 0
  262. })(),
  263. row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
  264. streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
  265. }),
  266. textPartID,
  267. )
  268. }
  269. async function installDiffProbe(page: Page) {
  270. await page.addInitScript(() => {
  271. let shadowRootCount = 0
  272. const attachShadow = Element.prototype.attachShadow
  273. Element.prototype.attachShadow = function (init) {
  274. shadowRootCount += 1
  275. return attachShadow.call(this, init)
  276. }
  277. window.__timelineDiffProbe = {
  278. reset: () => {
  279. shadowRootCount = 0
  280. },
  281. shadowRoots: () => shadowRootCount,
  282. }
  283. })
  284. }
  285. async function markDiffProbe(page: Page) {
  286. await page
  287. .locator(`[data-timeline-part-id="${editPartID}"]`)
  288. .first()
  289. .evaluate((element) => {
  290. const tool = element as HTMLElement
  291. const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
  292. const row = tool.closest<HTMLElement>("[data-timeline-key]")
  293. const frame = tool.closest<HTMLElement>("[data-timeline-row]")
  294. if (!file) throw new Error("missing edit diff file")
  295. if (!row) throw new Error("missing virtual timeline row")
  296. if (!frame) throw new Error("missing timeline row frame")
  297. tool.dataset.timelineProbe = "before"
  298. file.dataset.timelineProbe = "before"
  299. row.dataset.timelineProbe = "before"
  300. frame.dataset.timelineProbe = "before"
  301. window.__timelineDiffProbe.reset()
  302. })
  303. }
  304. async function readDiffProbe(page: Page) {
  305. return page
  306. .locator(`[data-timeline-part-id="${editPartID}"]`)
  307. .first()
  308. .evaluate((element) => {
  309. const tool = element as HTMLElement
  310. const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
  311. const row = tool.closest<HTMLElement>("[data-timeline-key]")
  312. const frame = tool.closest<HTMLElement>("[data-timeline-row]")
  313. return {
  314. fileMarker: file?.dataset.timelineProbe,
  315. shadowRoots: window.__timelineDiffProbe.shadowRoots(),
  316. toolMarker: tool.dataset.timelineProbe,
  317. rowMarker: row?.dataset.timelineProbe,
  318. rowKey: row?.dataset.timelineKey,
  319. frameMarker: frame?.dataset.timelineProbe,
  320. }
  321. })
  322. }
  323. function editPartWithAdditions(additions: number) {
  324. return {
  325. ...editPart,
  326. state: {
  327. ...editPart.state,
  328. metadata: {
  329. ...editPart.state.metadata,
  330. filediff: {
  331. ...editPart.state.metadata.filediff,
  332. additions,
  333. },
  334. },
  335. },
  336. }
  337. }
  338. function readExpanded(element: Element) {
  339. const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
  340. const aria = trigger?.getAttribute("aria-expanded")
  341. if (aria === "true") return true
  342. if (aria === "false") return false
  343. const root = element.querySelector('[data-component="collapsible"]')
  344. if (root?.hasAttribute("data-expanded")) return true
  345. if (root?.hasAttribute("data-closed")) return false
  346. const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
  347. return !!content && content.getBoundingClientRect().height > 0
  348. }
  349. async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) {
  350. await mockOpenCodeServer(page, {
  351. directory,
  352. project: project(),
  353. provider: provider(),
  354. sessions: [session()],
  355. pageMessages: () => ({ items: messages }),
  356. events: () => events.splice(0, 1),
  357. eventRetry: 16,
  358. })
  359. }
  360. function project() {
  361. return {
  362. id: projectID,
  363. worktree: directory,
  364. vcs: "git",
  365. name: "timeline-state-regression",
  366. time: { created: 1700000000000, updated: 1700000000000 },
  367. sandboxes: [],
  368. }
  369. }
  370. function session() {
  371. return {
  372. id: sessionID,
  373. slug: "timeline-state-regression",
  374. projectID,
  375. directory,
  376. title,
  377. version: "dev",
  378. time: { created: 1700000000000, updated: 1700000000000 },
  379. }
  380. }
  381. function provider() {
  382. return {
  383. all: [
  384. {
  385. id: "opencode",
  386. name: "OpenCode",
  387. models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
  388. },
  389. ],
  390. connected: ["opencode"],
  391. default: { providerID: "opencode", modelID: "claude-opus-4-6" },
  392. }
  393. }
  394. function base64Encode(value: string) {
  395. return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
  396. }