SaylorTwift HF Staff commited on
Commit
3e05655
·
verified ·
1 Parent(s): 0dbc9de

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. packages/app/e2e/AGENTS.md +16 -0
  2. packages/app/e2e/performance/timeline-stability/README.md +61 -0
  3. packages/app/e2e/performance/timeline-stability/adverse.spec.ts +250 -0
  4. packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts +192 -0
  5. packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts +113 -0
  6. packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts +121 -0
  7. packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts +113 -0
  8. packages/app/e2e/performance/timeline-stability/fixture.test.ts +68 -0
  9. packages/app/e2e/performance/timeline-stability/fixture.ts +568 -0
  10. packages/app/e2e/performance/timeline-stability/interaction.spec.ts +242 -0
  11. packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts +157 -0
  12. packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts +75 -0
  13. packages/app/e2e/performance/timeline-stability/playwright.config.ts +17 -0
  14. packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts +353 -0
  15. packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts +255 -0
  16. packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts +106 -0
  17. packages/app/e2e/performance/timeline-stability/tools.spec.ts +198 -0
  18. packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts +272 -0
  19. packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts +87 -0
  20. packages/app/e2e/performance/timeline/first-navigation-metrics.ts +32 -0
  21. packages/app/e2e/performance/timeline/first-navigation-probe.ts +86 -0
  22. packages/app/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts +114 -0
  23. packages/app/e2e/performance/timeline/navigation-milestones.ts +128 -0
  24. packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts +312 -0
  25. packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts +172 -0
  26. packages/app/e2e/performance/timeline/session-tab-flash.spec.ts +67 -0
  27. packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts +251 -0
  28. packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts +144 -0
  29. packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts +59 -0
  30. packages/app/e2e/performance/timeline/session-tab-switch-probe.ts +226 -0
  31. packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts +504 -0
  32. packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts +306 -0
  33. packages/app/e2e/performance/timeline/session-timeline-profile.ts +40 -0
  34. packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts +547 -0
  35. packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts +369 -0
  36. packages/app/e2e/performance/timeline/timeline-test-helpers.ts +134 -0
  37. packages/app/e2e/performance/unit/chrome-trace-write.test.ts +15 -0
  38. packages/app/e2e/performance/unit/first-navigation-metrics.test.ts +32 -0
  39. packages/app/e2e/performance/unit/mock-server.test.ts +46 -0
  40. packages/app/e2e/performance/unit/navigation-milestones.test.ts +34 -0
  41. packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts +42 -0
  42. packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts +86 -0
  43. packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts +55 -0
  44. packages/app/e2e/performance/unit/timeline-test-helpers.test.ts +10 -0
  45. packages/app/e2e/performance/unit/visual-stability.test.ts +392 -0
  46. packages/app/e2e/reproduction/timeline-suspense/index.html +34 -0
  47. packages/app/e2e/reproduction/timeline-suspense/main.tsx +315 -0
  48. packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts +34 -0
  49. packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts +179 -0
  50. packages/app/e2e/reproduction/timeline-suspense/vite.config.ts +7 -0
packages/app/e2e/AGENTS.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## Required Reading
2
+
3
+ - Before writing, changing, or reviewing E2E tests, ALWAYS read and follow Playwright's official [Best Practices](https://playwright.dev/docs/best-practices), [Auto-waiting](https://playwright.dev/docs/actionability), and [Assertions](https://playwright.dev/docs/test-assertions) guides.
4
+ - Use the official [Locators](https://playwright.dev/docs/locators), [Network](https://playwright.dev/docs/network), and [Test Isolation](https://playwright.dev/docs/browser-contexts) guides when those concerns apply.
5
+
6
+ ## Test Hygiene
7
+
8
+ - Test user-visible behavior with isolated, deterministic data and scoped, unique locators.
9
+ - Prefer role, label, text, and explicit test-contract locators. Do not use `.first()` or `.last()` merely to silence strictness errors.
10
+ - Use locator actions, Playwright auto-waiting, and web-first assertions for observable readiness and outcomes.
11
+ - NEVER use `waitForTimeout`, `setTimeout`, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead.
12
+ - Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires.
13
+ - Register event and network waits before the action that triggers them.
14
+ - Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome.
15
+ - Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes.
16
+ - Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass.
packages/app/e2e/performance/timeline-stability/README.md ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Timeline Layout Continuity
2
+
3
+ Run from `packages/app`:
4
+
5
+ ```sh
6
+ bun run test:stability
7
+ ```
8
+
9
+ The suite runs a production build in one Chromium worker. Selected scenarios use deterministic 4x CPU stress after application readiness. This is a stress profile, not emulation of a specific device.
10
+
11
+ ## What It Proves
12
+
13
+ The continuity probe samples DOM-derived layout and visibility state across browser render opportunities. Tests declare explicit contracts such as:
14
+
15
+ - Preserve a visible semantic anchor while the user is away from the bottom.
16
+ - Preserve end anchoring while active content grows or new content appears.
17
+ - Keep adjacent visible rows ordered without material overlap.
18
+ - Keep user-selected disclosure state through updates and virtualization.
19
+ - Avoid a sampled blank interval while one visible surface replaces another.
20
+ - Preserve logical row and control identity where local state or focus depends on it.
21
+ - Keep keyboard, wheel, and nested-scroll ownership consistent during remeasurement.
22
+
23
+ The suite exercises real browser reducer, projection, component, virtualizer, layout, focus, and interaction code. The backend and event producer are controlled fixtures.
24
+
25
+ ## What It Does Not Prove
26
+
27
+ The pass/fail oracle does not inspect every compositor-presented pixel. A sample taken after `requestAnimationFrame` is a DOM/layout observation, not proof that every sampled state was displayed or that every displayed frame was sampled.
28
+
29
+ The suite does not provide complete coverage for:
30
+
31
+ - Compositor-only or raster-only glitches.
32
+ - Color, contrast, canvas, WebGL, masks, irregular clips, or arbitrary occlusion.
33
+ - Physical display refresh rates, native OS scaling, or a named low-end device.
34
+ - TCP packetization, proxy buffering, or the complete real server/provider pipeline.
35
+
36
+ Playwright video, trace, screenshots, and observation JSON are diagnostic evidence. They are not pixel baselines and do not participate in normal pass/fail decisions.
37
+
38
+ For optional before/violation/after screenshots, set `OPENCODE_STABILITY_CAPTURE=1`. Capture is opt-in because compositor readback can perturb timing.
39
+
40
+ ## Test Layers
41
+
42
+ - **Projection:** admitted rows, grouping, labels, and final visible states.
43
+ - **Local state:** disclosure state, identity, duplicate delivery, and virtualization restoration.
44
+ - **Interaction:** wheel, keyboard, nested scrolling, actionability, and focus behavior.
45
+ - **Layout continuity:** anchoring, adjacency, responsive reflow, and visible surface handoffs.
46
+ - **Reducer hardening:** validly shaped but intentionally reordered, duplicated, removed, or replaced events.
47
+ - **Oracle contract:** pure analyzer and browser sampler calibration tests.
48
+
49
+ Production-lifecycle fixtures should model states emitted by the current producer. Impossible or reordered sequences belong in reducer-hardening tests and must not be described as normal provider behavior.
50
+
51
+ ## Diagnostics
52
+
53
+ Failures retain:
54
+
55
+ - `video.webm`
56
+ - `trace.zip`
57
+ - failure screenshot
58
+ - sampled DOM/layout trace JSON
59
+ - event markers and summarized violations
60
+
61
+ The analyzer records both unclipped layout bounds and ancestor-clipped visible intersections. Scrollbar and raw `scrollTop` changes alone do not fail continuity checks; user-visible semantic anchor movement does.
packages/app/e2e/performance/timeline-stability/adverse.spec.ts ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ defineVisualRegions,
4
+ reportVisualStability,
5
+ startVisualProbe,
6
+ stopVisualProbe,
7
+ visualPlan,
8
+ } from "../../utils/visual-stability"
9
+ import {
10
+ assistantMessage,
11
+ partUpdated,
12
+ setupTimeline,
13
+ shell,
14
+ textPart,
15
+ toolPart,
16
+ userMessage,
17
+ waitForVisualSettle,
18
+ type TimelineMessage,
19
+ } from "./fixture"
20
+
21
+ test.describe("timeline adverse visual stability", () => {
22
+ test("does not pull a scrolled-away user while an active shell grows", async ({ page }, testInfo) => {
23
+ const activeShellID = "prt_adverse_01_shell"
24
+ const messages = [
25
+ ...history(24),
26
+ userMessage(),
27
+ assistantMessage([shell(activeShellID, "running")], { completed: false }),
28
+ ]
29
+ const timeline = await setupTimeline(page, {
30
+ messages,
31
+ settings: { shellToolPartsExpanded: true },
32
+ cpuRate: 4,
33
+ eventRetry: 30,
34
+ })
35
+ const scroller = page.locator(".scroll-view__viewport", {
36
+ has: page.locator('[data-timeline-row="AssistantPart"]'),
37
+ })
38
+ await scroller.evaluate((element) => {
39
+ element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -450 }))
40
+ element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 450)
41
+ })
42
+ await page.waitForTimeout(150)
43
+ await expect
44
+ .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
45
+ .toBeGreaterThan(100)
46
+ const anchor = await scroller.evaluate((element) => {
47
+ const view = element.getBoundingClientRect()
48
+ return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
49
+ const rect = row.getBoundingClientRect()
50
+ return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
51
+ })?.dataset.timelineKey
52
+ })
53
+ expect(anchor).toBeTruthy()
54
+ await waitForVisualSettle(page, [`[data-timeline-key="${anchor}"]`])
55
+
56
+ const regions = defineVisualRegions({
57
+ anchor: { selector: `[data-timeline-key="${anchor}"]` },
58
+ })
59
+ await startVisualProbe(page, regions)
60
+ await timeline.send(partUpdated(shell(activeShellID, "running", lines(1))), 180)
61
+ await timeline.send(partUpdated(shell(activeShellID, "running", lines(10))), 90)
62
+ await timeline.send(partUpdated(shell(activeShellID, "running", lines(50))), 350)
63
+ await timeline.send(partUpdated(shell(activeShellID, "completed", lines(50))), 500)
64
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
65
+ await reportVisualStability(
66
+ testInfo,
67
+ "scrolled-away-shell",
68
+ trace,
69
+ visualPlan(regions, [
70
+ { type: "required", regions: ["anchor"] },
71
+ { type: "unique", regions: ["anchor"] },
72
+ { type: "stable", regions: ["anchor"] },
73
+ { type: "fixed", regions: ["anchor"] },
74
+ { type: "opacity", regions: "all" },
75
+ { type: "continuity", regions: "all" },
76
+ { type: "motion", regions: "all", maxPositionReversals: 0 },
77
+ { type: "label-stability", regions: "all" },
78
+ ]),
79
+ )
80
+ })
81
+
82
+ test("preserves an explicit shell state across virtualization", async ({ page }) => {
83
+ const targetID = "prt_virtual_shell"
84
+ const messages = [
85
+ userMessage(undefined, { id: "msg_0000_virtual_user", created: 1700000000000 }),
86
+ assistantMessage([shell(targetID, "completed", lines(20))], {
87
+ id: "msg_0001_virtual_assistant",
88
+ parentID: "msg_0000_virtual_user",
89
+ created: 1700000001000,
90
+ }),
91
+ ...history(35, 10),
92
+ ]
93
+ await setupTimeline(page, { messages, settings: { shellToolPartsExpanded: false } })
94
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
95
+ await scroller.evaluate((element) => {
96
+ element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1_000 }))
97
+ element.scrollTop = 0
98
+ })
99
+ await page.waitForTimeout(300)
100
+ const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`)
101
+ await expect(trigger).toBeVisible()
102
+ await trigger.click()
103
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
104
+
105
+ await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
106
+ await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0)
107
+ await scroller.evaluate((element) => (element.scrollTop = 0))
108
+ await expect(trigger).toBeVisible()
109
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
110
+ })
111
+
112
+ test("keeps narrow viewport rows ordered during long shell growth", async ({ page }, testInfo) => {
113
+ const shellID = "prt_narrow_01_shell"
114
+ const followingID = "prt_narrow_02_following"
115
+ const timeline = await setupTimeline(page, {
116
+ messages: [
117
+ userMessage(),
118
+ assistantMessage(
119
+ [shell(shellID, "running"), textPart(followingID, "A narrow following row that wraps across lines.")],
120
+ {
121
+ completed: false,
122
+ },
123
+ ),
124
+ ],
125
+ settings: { shellToolPartsExpanded: true },
126
+ viewport: { width: 430, height: 800 },
127
+ cpuRate: 4,
128
+ })
129
+ await waitForVisualSettle(page, [
130
+ `[data-timeline-part-id="${shellID}"]`,
131
+ `[data-timeline-part-id="${followingID}"]`,
132
+ ])
133
+ const regions = defineVisualRegions({
134
+ shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
135
+ following: {
136
+ selector: `[data-timeline-part-id="${followingID}"]`,
137
+ closest: '[data-timeline-row="AssistantPart"]',
138
+ },
139
+ })
140
+ await startVisualProbe(page, regions)
141
+ await timeline.send(partUpdated(shell(shellID, "running", wideLines(10))), 100)
142
+ await timeline.send(partUpdated(shell(shellID, "running", wideLines(50))), 300)
143
+ await timeline.send(partUpdated(shell(shellID, "completed", wideLines(50))), 500)
144
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
145
+ await reportVisualStability(
146
+ testInfo,
147
+ "narrow-shell",
148
+ trace,
149
+ visualPlan(
150
+ regions,
151
+ [
152
+ { type: "required", regions: ["shell", "following"] },
153
+ { type: "unique", regions: ["shell", "following"] },
154
+ { type: "stable", regions: ["shell", "following"] },
155
+ { type: "opacity", regions: "all" },
156
+ { type: "continuity", regions: "all" },
157
+ { type: "motion", regions: "all", maxPositionReversals: 0 },
158
+ { type: "label-stability", regions: "all" },
159
+ { type: "preserve-bottom-anchor" },
160
+ { type: "flow", regions: ["shell", "following"] },
161
+ ],
162
+ { perMarker: true },
163
+ ),
164
+ )
165
+ })
166
+
167
+ test("keeps visible rows ordered while resizing desktop to narrow and back", async ({ page }, testInfo) => {
168
+ const shellID = "prt_resize_01_shell"
169
+ const contextIDs = ["prt_resize_02_read", "prt_resize_03_glob"]
170
+ const followingID = "prt_resize_04_following"
171
+ await setupTimeline(page, {
172
+ messages: [
173
+ userMessage(),
174
+ assistantMessage([
175
+ shell(shellID, "completed", wideLines(15)),
176
+ toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }),
177
+ toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
178
+ textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
179
+ ]),
180
+ ],
181
+ settings: { shellToolPartsExpanded: true },
182
+ cpuRate: 4,
183
+ seedHistory: true,
184
+ })
185
+ const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
186
+ const regions = defineVisualRegions({
187
+ shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
188
+ context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
189
+ following: {
190
+ selector: `[data-timeline-part-id="${followingID}"]`,
191
+ closest: '[data-timeline-row="AssistantPart"]',
192
+ },
193
+ })
194
+ await startVisualProbe(page, regions)
195
+ await page.setViewportSize({ width: 430, height: 800 })
196
+ await page.waitForTimeout(500)
197
+ await page.setViewportSize({ width: 900, height: 800 })
198
+ await page.waitForTimeout(500)
199
+ await page.setViewportSize({ width: 1400, height: 900 })
200
+ await page.waitForTimeout(500)
201
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
202
+ await reportVisualStability(
203
+ testInfo,
204
+ "responsive-resize",
205
+ trace,
206
+ visualPlan(regions, [
207
+ { type: "required", regions: ["shell", "context", "following"] },
208
+ { type: "unique", regions: ["shell", "context", "following"] },
209
+ { type: "stable", regions: ["shell", "context", "following"] },
210
+ { type: "opacity", regions: "all" },
211
+ { type: "continuity", regions: "all" },
212
+ { type: "motion", regions: "all", maxPositionReversals: 4, maxReversals: 4 },
213
+ { type: "label-stability", regions: "all" },
214
+ { type: "flow", regions: ["shell", "context", "following"] },
215
+ ]),
216
+ )
217
+ })
218
+ })
219
+
220
+ function history(count: number, offset = 0): TimelineMessage[] {
221
+ return Array.from({ length: count }, (_, index) => {
222
+ const value = index + offset
223
+ const prefix = `msg_0${String(value).padStart(3, "0")}_history`
224
+ const userID = `${prefix}_a_user`
225
+ return [
226
+ userMessage(undefined, { id: userID, created: 1699990000000 + value * 10_000 }),
227
+ assistantMessage(
228
+ [
229
+ textPart(
230
+ `prt_history_${String(value).padStart(3, "0")}`,
231
+ `Historical response ${value}. ${"Stable history content. ".repeat(8)}`,
232
+ ),
233
+ ],
234
+ {
235
+ id: `${prefix}_b_assistant`,
236
+ parentID: userID,
237
+ created: 1699990001000 + value * 10_000,
238
+ },
239
+ ),
240
+ ]
241
+ }).flat()
242
+ }
243
+
244
+ function lines(count: number) {
245
+ return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
246
+ }
247
+
248
+ function wideLines(count: number) {
249
+ return Array.from({ length: count }, (_, index) => `line ${index + 1} ${"wide-output-".repeat(20)}`).join("\n")
250
+ }
packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ defineVisualRegions,
4
+ reportVisualStability,
5
+ startVisualProbe,
6
+ stopVisualProbe,
7
+ visualPlan,
8
+ } from "../../utils/visual-stability"
9
+ import {
10
+ assistantID,
11
+ assistantMessage,
12
+ event,
13
+ partUpdated,
14
+ setupTimeline,
15
+ textPart,
16
+ toolPart,
17
+ userMessage,
18
+ waitForVisualSettle,
19
+ } from "./fixture"
20
+
21
+ const inputs = {
22
+ read: { filePath: "src/a.ts", offset: 0, limit: 120 },
23
+ glob: { path: ".", pattern: "**/*.ts" },
24
+ grep: { path: ".", pattern: "stable", include: "*.ts" },
25
+ list: { path: "src" },
26
+ }
27
+
28
+ test("appends context operations while the group is expanded", async ({ page }, testInfo) => {
29
+ const firstID = "prt_append_01_read"
30
+ const followingID = "prt_append_99_following"
31
+ const timeline = await setupTimeline(page, {
32
+ messages: [
33
+ userMessage(),
34
+ assistantMessage([toolPart(firstID, "read", "running", inputs.read), textPart(followingID, "Following append")], {
35
+ completed: false,
36
+ }),
37
+ ],
38
+ cpuRate: 4,
39
+ })
40
+ const initialGroup = `[data-timeline-part-ids="${firstID}"]`
41
+ await page.locator(`${initialGroup} [data-slot="collapsible-trigger"]`).click()
42
+ await waitForVisualSettle(page, [initialGroup, `[data-timeline-part-id="${followingID}"]`])
43
+ const regions = defineVisualRegions({
44
+ context: {
45
+ selector: '[data-timeline-part-ids^="prt_append_01_read"]',
46
+ closest: '[data-timeline-row="AssistantPart"]',
47
+ },
48
+ following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
49
+ })
50
+ await startVisualProbe(page, regions)
51
+ await timeline.send(partUpdated(toolPart("prt_append_02_glob", "glob", "running", inputs.glob)), 180)
52
+ await timeline.send(partUpdated(toolPart("prt_append_03_grep", "grep", "completed", inputs.grep)), 240)
53
+ await timeline.send(partUpdated(toolPart("prt_append_04_list", "list", "completed", inputs.list)), 500)
54
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
55
+ await reportVisualStability(
56
+ testInfo,
57
+ "context-append",
58
+ trace,
59
+ visualPlan(
60
+ regions,
61
+ [
62
+ { type: "required", regions: ["context", "following"] },
63
+ { type: "unique", regions: ["context", "following"] },
64
+ { type: "stable", regions: ["context", "following"] },
65
+ { type: "opacity", regions: "all" },
66
+ { type: "continuity", regions: "all" },
67
+ { type: "motion", regions: ["following"], maxPositionReversals: 0 },
68
+ { type: "label-stability", regions: "all" },
69
+ { type: "preserve-bottom-anchor" },
70
+ { type: "flow", regions: ["context", "following"] },
71
+ ],
72
+ { perMarker: true },
73
+ ),
74
+ )
75
+ await expect(
76
+ page.locator(
77
+ '[data-timeline-part-ids="prt_append_01_read,prt_append_02_glob,prt_append_03_grep,prt_append_04_list"]',
78
+ ),
79
+ ).toBeVisible()
80
+ await expect(
81
+ page.locator('[data-timeline-part-ids^="prt_append_01_read"] [data-slot="collapsible-trigger"]'),
82
+ ).toHaveAttribute("aria-expanded", "true")
83
+ })
84
+
85
+ test("splits and merges context groups when a middle text part changes", async ({ page }, testInfo) => {
86
+ const textID = "prt_split_02_text"
87
+ const followingID = "prt_split_99_following"
88
+ const timeline = await setupTimeline(page, {
89
+ messages: [
90
+ userMessage(),
91
+ assistantMessage([
92
+ toolPart("prt_split_01_read", "read", "completed", inputs.read),
93
+ textPart(textID, "Boundary"),
94
+ toolPart("prt_split_03_glob", "glob", "completed", inputs.glob),
95
+ textPart(followingID, "Following split groups"),
96
+ ]),
97
+ ],
98
+ cpuRate: 4,
99
+ })
100
+ const regions = defineVisualRegions({
101
+ following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
102
+ })
103
+ await startVisualProbe(page, regions)
104
+ await timeline.send(
105
+ event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: textID }),
106
+ 500,
107
+ )
108
+ await expect(page.locator('[data-timeline-part-ids="prt_split_01_read,prt_split_03_glob"]')).toBeVisible()
109
+ await timeline.send(partUpdated(textPart(textID, "Boundary restored")), 500)
110
+ await expect(page.locator('[data-timeline-part-ids="prt_split_01_read"]')).toBeVisible()
111
+ await expect(page.locator('[data-timeline-part-ids="prt_split_03_glob"]')).toBeVisible()
112
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
113
+ await reportVisualStability(
114
+ testInfo,
115
+ "context-split-merge",
116
+ trace,
117
+ visualPlan(
118
+ regions,
119
+ [
120
+ { type: "required", regions: ["following"] },
121
+ { type: "unique", regions: ["following"] },
122
+ { type: "stable", regions: ["following"] },
123
+ { type: "opacity", regions: "all" },
124
+ { type: "continuity", regions: "all" },
125
+ { type: "motion", regions: "all", maxPositionReversals: 1 },
126
+ { type: "label-stability", regions: "all" },
127
+ ],
128
+ { perMarker: true },
129
+ ),
130
+ )
131
+ })
132
+
133
+ test("removing the first context member replaces the group once without overlapping following content", async ({
134
+ page,
135
+ }, testInfo) => {
136
+ const ids = ["prt_key_01_read", "prt_key_02_glob", "prt_key_03_grep"]
137
+ const followingID = "prt_key_99_following"
138
+ const timeline = await setupTimeline(page, {
139
+ messages: [
140
+ userMessage(),
141
+ assistantMessage([
142
+ toolPart(ids[0]!, "read", "completed", inputs.read),
143
+ toolPart(ids[1]!, "glob", "completed", inputs.glob),
144
+ toolPart(ids[2]!, "grep", "completed", inputs.grep),
145
+ textPart(followingID, "Following replaced group"),
146
+ ]),
147
+ ],
148
+ cpuRate: 4,
149
+ })
150
+ const original = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
151
+ const originalRowKey = await original.evaluate((element) =>
152
+ element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key"),
153
+ )
154
+ await original.locator('[data-slot="collapsible-trigger"]').click()
155
+ await expect(original.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
156
+ const regions = defineVisualRegions({
157
+ context: {
158
+ selector: '[data-timeline-part-ids*="prt_key_02_glob"]',
159
+ closest: '[data-timeline-row="AssistantPart"]',
160
+ },
161
+ following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
162
+ })
163
+ await startVisualProbe(page, regions)
164
+ await timeline.send(
165
+ event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: ids[0] }),
166
+ 500,
167
+ )
168
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
169
+ await reportVisualStability(
170
+ testInfo,
171
+ "context-first-remove",
172
+ trace,
173
+ visualPlan(regions, [
174
+ { type: "required", regions: ["context", "following"] },
175
+ { type: "unique", regions: ["context", "following"] },
176
+ { type: "opacity", regions: "all" },
177
+ { type: "continuity", regions: "all" },
178
+ { type: "motion", regions: "all", maxPositionReversals: 0 },
179
+ { type: "label-stability", regions: "all" },
180
+ { type: "flow", regions: ["context", "following"] },
181
+ ]),
182
+ )
183
+ await expect(page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)).toBeVisible()
184
+ expect(
185
+ await page
186
+ .locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)
187
+ .evaluate((element) => element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key")),
188
+ ).toBe(originalRowKey)
189
+ await expect(
190
+ page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"] [data-slot="collapsible-trigger"]`),
191
+ ).toHaveAttribute("aria-expanded", "true")
192
+ })
packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test } from "@playwright/test"
2
+ import {
3
+ defineVisualRegions,
4
+ reportVisualStability,
5
+ startVisualProbe,
6
+ stopVisualProbe,
7
+ visualPlan,
8
+ } from "../../utils/visual-stability"
9
+ import {
10
+ assistantMessage,
11
+ partUpdated,
12
+ setupTimeline,
13
+ shell,
14
+ textPart,
15
+ userMessage,
16
+ waitForVisualSettle,
17
+ } from "./fixture"
18
+
19
+ // Fractional scaling exercises different browser rounding than the baseline.
20
+ for (const deviceScaleFactor of [1, 1.25]) {
21
+ test(`keeps shell growth ordered at device scale ${deviceScaleFactor}`, async ({ page }, testInfo) => {
22
+ const shellID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_01_shell`
23
+ const followingID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_02_following`
24
+ const timeline = await setupTimeline(page, {
25
+ messages: [
26
+ userMessage(),
27
+ assistantMessage([shell(shellID, "running"), textPart(followingID, "Following scaled shell")], {
28
+ completed: false,
29
+ }),
30
+ ],
31
+ settings: { shellToolPartsExpanded: true },
32
+ cpuRate: 4,
33
+ deviceScaleFactor,
34
+ seedHistory: true,
35
+ })
36
+ await waitForVisualSettle(page, [
37
+ `[data-timeline-part-id="${shellID}"]`,
38
+ `[data-timeline-part-id="${followingID}"]`,
39
+ ])
40
+ const regions = defineVisualRegions({
41
+ shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
42
+ following: {
43
+ selector: `[data-timeline-part-id="${followingID}"]`,
44
+ closest: '[data-timeline-row="AssistantPart"]',
45
+ },
46
+ })
47
+ await startVisualProbe(page, regions)
48
+ await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 180)
49
+ await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 500)
50
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
51
+ await reportVisualStability(testInfo, `dpr-${deviceScaleFactor}`, trace, shellPlan(regions))
52
+ })
53
+ }
54
+
55
+ for (const reducedMotion of [true]) {
56
+ test(`keeps shell and status transitions ordered with reduced motion ${reducedMotion}`, async ({
57
+ page,
58
+ }, testInfo) => {
59
+ const shellID = `prt_motion_${reducedMotion}_01_shell`
60
+ const followingID = `prt_motion_${reducedMotion}_02_following`
61
+ const timeline = await setupTimeline(page, {
62
+ messages: [
63
+ userMessage(),
64
+ assistantMessage([shell(shellID, "running"), textPart(followingID, "Following motion profile")], {
65
+ completed: false,
66
+ }),
67
+ ],
68
+ settings: { shellToolPartsExpanded: true },
69
+ reducedMotion,
70
+ cpuRate: 4,
71
+ seedHistory: true,
72
+ })
73
+ await waitForVisualSettle(page, [
74
+ `[data-timeline-part-id="${shellID}"]`,
75
+ `[data-timeline-part-id="${followingID}"]`,
76
+ ])
77
+ const regions = defineVisualRegions({
78
+ shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
79
+ following: {
80
+ selector: `[data-timeline-part-id="${followingID}"]`,
81
+ closest: '[data-timeline-row="AssistantPart"]',
82
+ },
83
+ })
84
+ await startVisualProbe(page, regions)
85
+ await timeline.send(partUpdated(shell(shellID, "completed", lines(10))), 500)
86
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
87
+ await reportVisualStability(testInfo, `reduced-motion-${reducedMotion}`, trace, shellPlan(regions))
88
+ })
89
+ }
90
+
91
+ function lines(count: number) {
92
+ return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
93
+ }
94
+
95
+ function shellPlan<Regions extends ReturnType<typeof defineVisualRegions>>(
96
+ regions: Regions & Record<"shell" | "following", { selector: string }>,
97
+ ) {
98
+ return visualPlan(
99
+ regions,
100
+ [
101
+ { type: "required", regions: ["shell", "following"] },
102
+ { type: "unique", regions: ["shell", "following"] },
103
+ { type: "stable", regions: ["shell", "following"] },
104
+ { type: "opacity", regions: "all" },
105
+ { type: "continuity", regions: "all" },
106
+ { type: "motion", regions: "all", maxPositionReversals: 0 },
107
+ { type: "label-stability", regions: "all" },
108
+ { type: "preserve-bottom-anchor" },
109
+ { type: "flow", regions: ["shell", "following"] },
110
+ ],
111
+ { perMarker: true },
112
+ )
113
+ }
packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test } from "@playwright/test"
2
+ import {
3
+ defineVisualRegions,
4
+ reportVisualStability,
5
+ startVisualProbe,
6
+ stopVisualProbe,
7
+ visualPlan,
8
+ } from "../../utils/visual-stability"
9
+ import {
10
+ assistantMessage,
11
+ partUpdated,
12
+ setupTimeline,
13
+ textPart,
14
+ toolPart,
15
+ userMessage,
16
+ waitForVisualSettle,
17
+ } from "./fixture"
18
+
19
+ const profiles = [
20
+ { name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
21
+ {
22
+ name: "multi patch",
23
+ tool: "apply_patch",
24
+ input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
25
+ },
26
+ ] as const
27
+
28
+ for (const profile of profiles) {
29
+ test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => {
30
+ const partID = `prt_file_matrix_${profiles.indexOf(profile)}`
31
+ const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}`
32
+ const timeline = await setupTimeline(page, {
33
+ messages: [
34
+ userMessage(),
35
+ assistantMessage(
36
+ [
37
+ toolPart(partID, profile.tool, "pending", profile.input),
38
+ textPart(followingID, `Following ${profile.name}`),
39
+ ],
40
+ { completed: false },
41
+ ),
42
+ ],
43
+ settings: { editToolPartsExpanded: true },
44
+ cpuRate: 4,
45
+ })
46
+ await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`])
47
+ const regions = defineVisualRegions({
48
+ tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
49
+ following: {
50
+ selector: `[data-timeline-part-id="${followingID}"]`,
51
+ closest: '[data-timeline-row="AssistantPart"]',
52
+ },
53
+ })
54
+ await startVisualProbe(page, regions)
55
+ await timeline.send(partUpdated(toolPart(partID, profile.tool, "running", profile.input)), 180)
56
+ await timeline.send(partUpdated(completedPart(partID, profile)), 900)
57
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
58
+ await reportVisualStability(
59
+ testInfo,
60
+ `file-${profile.name}`,
61
+ trace,
62
+ visualPlan(
63
+ regions,
64
+ [
65
+ { type: "required", regions: ["tool", "following"] },
66
+ { type: "unique", regions: ["tool", "following"] },
67
+ { type: "stable", regions: ["tool", "following"] },
68
+ { type: "opacity", regions: "all" },
69
+ { type: "continuity", regions: "all" },
70
+ { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
71
+ { type: "label-stability", regions: "all" },
72
+ { type: "preserve-bottom-anchor" },
73
+ { type: "flow", regions: ["tool", "following"] },
74
+ ],
75
+ { perMarker: true },
76
+ ),
77
+ )
78
+ })
79
+ }
80
+
81
+ function completedPart(partID: string, profile: (typeof profiles)[number]) {
82
+ if (profile.tool === "edit") {
83
+ return toolPart(partID, profile.tool, "completed", profile.input, {
84
+ metadata: {
85
+ filediff: {
86
+ file: "src/edit.ts",
87
+ additions: 50,
88
+ deletions: 50,
89
+ before: source(50, false),
90
+ after: source(50, true),
91
+ },
92
+ },
93
+ })
94
+ }
95
+ const files = [
96
+ patchFile("src/a.ts", "update"),
97
+ patchFile("src/b.ts", "add"),
98
+ patchFile("src/old.ts", "delete"),
99
+ { ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
100
+ ]
101
+ return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } })
102
+ }
103
+
104
+ function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
105
+ return {
106
+ filePath,
107
+ relativePath: filePath,
108
+ type,
109
+ additions: type === "delete" ? 0 : 20,
110
+ deletions: type === "add" ? 0 : 20,
111
+ before: type === "add" ? undefined : source(20, false),
112
+ after: type === "delete" ? undefined : source(20, true),
113
+ }
114
+ }
115
+
116
+ function source(count: number, changed: boolean) {
117
+ return Array.from(
118
+ { length: count },
119
+ (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
120
+ ).join("")
121
+ }
packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ defineVisualRegions,
4
+ reportVisualStability,
5
+ startVisualProbe,
6
+ stopVisualProbe,
7
+ visualPlan,
8
+ } from "../../utils/visual-stability"
9
+ import {
10
+ assistantMessage,
11
+ partUpdated,
12
+ setupTimeline,
13
+ textPart,
14
+ toolPart,
15
+ userMessage,
16
+ waitForVisualSettle,
17
+ } from "./fixture"
18
+
19
+ test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => {
20
+ const patchID = "prt_incremental_01_patch"
21
+ const followingID = "prt_incremental_02_following"
22
+ const first = patchFile("src/a.ts", "update")
23
+ const timeline = await setupTimeline(page, {
24
+ messages: [
25
+ userMessage(),
26
+ assistantMessage(
27
+ [
28
+ toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
29
+ textPart(followingID, "Following incremental patch"),
30
+ ],
31
+ { completed: false },
32
+ ),
33
+ ],
34
+ settings: { editToolPartsExpanded: true },
35
+ cpuRate: 4,
36
+ seedHistory: true,
37
+ })
38
+ const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first()
39
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
40
+ await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`])
41
+ const regions = defineVisualRegions({
42
+ patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
43
+ following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
44
+ })
45
+ await startVisualProbe(page, regions)
46
+ const second = patchFile("src/b.ts", "add")
47
+ const third = patchFile("src/old.ts", "delete")
48
+ await timeline.send(
49
+ partUpdated(
50
+ toolPart(
51
+ patchID,
52
+ "apply_patch",
53
+ "running",
54
+ { files: [first.filePath, second.filePath] },
55
+ { metadata: { files: [first, second] } },
56
+ ),
57
+ ),
58
+ 240,
59
+ )
60
+ await timeline.send(
61
+ partUpdated(
62
+ toolPart(
63
+ patchID,
64
+ "apply_patch",
65
+ "completed",
66
+ { files: [first.filePath, second.filePath, third.filePath] },
67
+ { metadata: { files: [first, second, third] } },
68
+ ),
69
+ ),
70
+ 800,
71
+ )
72
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
73
+ await reportVisualStability(
74
+ testInfo,
75
+ "incremental-patch",
76
+ trace,
77
+ visualPlan(
78
+ regions,
79
+ [
80
+ { type: "required", regions: ["patch", "following"] },
81
+ { type: "unique", regions: ["patch", "following"] },
82
+ { type: "stable", regions: ["patch", "following"] },
83
+ { type: "opacity", regions: "all" },
84
+ { type: "continuity", regions: "all" },
85
+ { type: "motion", regions: ["following"], maxPositionReversals: 0 },
86
+ { type: "label-stability", regions: "all" },
87
+ { type: "preserve-bottom-anchor" },
88
+ { type: "flow", regions: ["patch", "following"] },
89
+ ],
90
+ { perMarker: true },
91
+ ),
92
+ )
93
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
94
+ await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible()
95
+ })
96
+
97
+ function patchFile(filePath: string, type: "add" | "update" | "delete") {
98
+ return {
99
+ filePath,
100
+ relativePath: filePath,
101
+ type,
102
+ additions: type === "delete" ? 0 : 4,
103
+ deletions: type === "add" ? 0 : 3,
104
+ before: type === "add" ? undefined : source(false),
105
+ after: type === "delete" ? undefined : source(true),
106
+ }
107
+ }
108
+
109
+ function source(changed: boolean) {
110
+ return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
111
+ "",
112
+ )
113
+ }
packages/app/e2e/performance/timeline-stability/fixture.test.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ assistantMessage,
4
+ event,
5
+ toolPart,
6
+ userMessage,
7
+ validateTimelineEvent,
8
+ validateTimelineMessages,
9
+ type PartSeed,
10
+ } from "./fixture"
11
+
12
+ describe("timeline fixture validation", () => {
13
+ test("accepts a valid timeline", () => {
14
+ expect(validateTimelineMessages([userMessage(), assistantMessage()])).toHaveLength(2)
15
+ })
16
+
17
+ test("rejects malformed SDK values at runtime", () => {
18
+ expect(() =>
19
+ assistantMessage([], {
20
+ error: { name: "APIError", data: { message: "failed" } } as never,
21
+ }),
22
+ ).toThrow()
23
+ expect(() =>
24
+ validateTimelineEvent({
25
+ directory: "C:/OpenCode/TimelineStability",
26
+ payload: {
27
+ id: "evt_invalid_status",
28
+ type: "session.status",
29
+ properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
30
+ },
31
+ }),
32
+ ).toThrow()
33
+ })
34
+
35
+ test("rejects duplicate IDs and orphan assistants", () => {
36
+ expect(() => validateTimelineMessages([userMessage(), userMessage()])).toThrow(/duplicate message ID/)
37
+ expect(() =>
38
+ validateTimelineMessages([userMessage(), assistantMessage([], { parentID: "msg_missing_parent" })]),
39
+ ).toThrow(/parent user/)
40
+ })
41
+
42
+ test("assigns deterministic event IDs", () => {
43
+ const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } })
44
+ const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } })
45
+ expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
46
+ expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
47
+ })
48
+ })
49
+
50
+ if (false) {
51
+ const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user">
52
+ userMessage([userSeed])
53
+
54
+ // @ts-expect-error Tool completion fields are not valid while pending.
55
+ toolPart("prt_invalid_pending", "bash", "pending", {}, { output: "impossible" })
56
+ // @ts-expect-error Tool completion fields are not valid while running.
57
+ toolPart("prt_invalid_running", "bash", "running", {}, { output: "impossible" })
58
+ // @ts-expect-error Tool error fields are not valid after completion.
59
+ toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" })
60
+
61
+ assistantMessage([
62
+ // @ts-expect-error Agent references belong to user messages, not assistant messages.
63
+ { id: "prt_invalid_owner", type: "agent", name: "explore", source: { value: "@explore", start: 0, end: 8 } },
64
+ ])
65
+
66
+ // @ts-expect-error Retry status events require message and next.
67
+ event("session.status", { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } })
68
+ }
packages/app/e2e/performance/timeline-stability/fixture.ts ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import { Event } from "@opencode-ai/schema/event"
3
+ import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
4
+ import { SessionV1 } from "@opencode-ai/schema/session-v1"
5
+ import type {
6
+ AssistantMessage,
7
+ GlobalEvent,
8
+ Message,
9
+ Part,
10
+ Session,
11
+ SessionStatus,
12
+ ToolPart,
13
+ ToolState,
14
+ UserMessage,
15
+ } from "@opencode-ai/sdk/v2/client"
16
+ import { expect, type Page } from "@playwright/test"
17
+ import { Schema } from "effect"
18
+ import { mockOpenCodeServer } from "../../utils/mock-server"
19
+ import { installSseTransport } from "../../utils/sse-transport"
20
+ import { expectSessionTitle } from "../../utils/waits"
21
+
22
+ export const directory = "C:/OpenCode/TimelineStability"
23
+ export const projectID = "proj_timeline_stability"
24
+ export const sessionID = "ses_timeline_stability"
25
+ export const userID = "msg_1000_timeline_user"
26
+ export const assistantID = "msg_1001_timeline_assistant"
27
+ export const title = "Timeline visual stability"
28
+ export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
29
+
30
+ type TimelinePayload = Extract<
31
+ GlobalEvent["payload"],
32
+ {
33
+ type:
34
+ | "message.updated"
35
+ | "message.removed"
36
+ | "message.part.updated"
37
+ | "message.part.removed"
38
+ | "message.part.delta"
39
+ | "session.status"
40
+ }
41
+ >
42
+
43
+ type DeepReadonly<Value> = Value extends readonly unknown[]
44
+ ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
45
+ : Value extends object
46
+ ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
47
+ : Value
48
+
49
+ export type TimelineEvent = DeepReadonly<Omit<GlobalEvent, "payload"> & { payload: TimelinePayload }>
50
+ export type EventPayload = TimelineEvent
51
+ export type ToolStatus = ToolState["status"]
52
+ export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] }
53
+
54
+ type UserPart = Extract<Part, { type: "text" | "file" | "agent" | "subtask" }>
55
+ type AssistantPart = Exclude<Part, { type: "agent" | "subtask" }>
56
+ type OwnedPart<Owner extends Message["role"]> = Owner extends "user" ? UserPart : AssistantPart
57
+ export type PartSeed<Owner extends Message["role"]> =
58
+ OwnedPart<Owner> extends infer Candidate
59
+ ? Candidate extends Part
60
+ ? Omit<Candidate, "sessionID" | "messageID">
61
+ : never
62
+ : never
63
+
64
+ type ToolOptions<State extends ToolStatus> = State extends "pending"
65
+ ? { output?: never; title?: never; metadata?: never; error?: never }
66
+ : State extends "running"
67
+ ? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
68
+ : State extends "error"
69
+ ? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
70
+ : { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
71
+
72
+ const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
73
+ const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
74
+ const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
75
+ const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
76
+ const timelineEventSchema = Schema.Union([
77
+ eventSchema("message.updated", SessionV1.Event.MessageUpdated.data),
78
+ eventSchema("message.removed", SessionV1.Event.MessageRemoved.data),
79
+ eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data),
80
+ eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data),
81
+ eventSchema("message.part.delta", SessionV1.Event.PartDelta.data),
82
+ eventSchema("session.status", SessionStatusEvent.Status.data),
83
+ ])
84
+ const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema)
85
+ let eventSequence = 0
86
+
87
+ export async function setupTimeline(
88
+ page: Page,
89
+ input: {
90
+ messages?: TimelineMessage[]
91
+ settings?: Record<string, boolean>
92
+ sessions?: Session[]
93
+ cpuRate?: number
94
+ viewport?: { width: number; height: number }
95
+ eventRetry?: number
96
+ reducedMotion?: boolean
97
+ locale?: string
98
+ deviceScaleFactor?: number
99
+ seedHistory?: boolean
100
+ protocol?: "v1" | "v2"
101
+ } = {},
102
+ ) {
103
+ const sessions = input.sessions ?? [session()]
104
+ const messages = validateTimelineMessages([
105
+ ...(input.seedHistory ? historyMessages(18) : []),
106
+ ...(input.messages ?? [userMessage(), assistantMessage()]),
107
+ ])
108
+ const active = messages.findLast((message) => message.info.role === "assistant")
109
+ const initialStatus = decodeStatus(
110
+ active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
111
+ decodeOptions,
112
+ )
113
+ const transport = await installSseTransport<EventPayload>(page, {
114
+ server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
115
+ retry: input.eventRetry ?? 20,
116
+ })
117
+ await mockOpenCodeServer(page, {
118
+ protocol: input.protocol,
119
+ directory,
120
+ project: project(),
121
+ provider: provider(),
122
+ sessions,
123
+ sessionStatus: { [sessionID]: initialStatus },
124
+ pageMessages: () => ({
125
+ items: messages,
126
+ }),
127
+ })
128
+ await page.addInitScript((settings) => {
129
+ localStorage.setItem(
130
+ "settings.v3",
131
+ JSON.stringify({
132
+ general: {
133
+ editToolPartsExpanded: false,
134
+ shellToolPartsExpanded: false,
135
+ showReasoningSummaries: false,
136
+ showSessionProgressBar: true,
137
+ ...settings,
138
+ },
139
+ }),
140
+ )
141
+ if (settings.newLayoutDesigns === false) {
142
+ localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
143
+ }
144
+ }, input.settings ?? {})
145
+ if (input.locale) {
146
+ await page.addInitScript((locale) => {
147
+ localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
148
+ }, input.locale)
149
+ }
150
+ if (input.reducedMotion) await page.emulateMedia({ reducedMotion: "reduce" })
151
+ await page.setViewportSize(input.viewport ?? { width: 1400, height: 900 })
152
+ if (input.deviceScaleFactor) {
153
+ const devtools = await page.context().newCDPSession(page)
154
+ const viewport = input.viewport ?? { width: 1400, height: 900 }
155
+ await devtools.send("Emulation.setDeviceMetricsOverride", {
156
+ width: viewport.width,
157
+ height: viewport.height,
158
+ deviceScaleFactor: input.deviceScaleFactor,
159
+ mobile: false,
160
+ })
161
+ }
162
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
163
+ await transport.waitForConnection()
164
+ await expectSessionTitle(page, title)
165
+ if (input.cpuRate && input.cpuRate > 1) {
166
+ const devtools = await page.context().newCDPSession(page)
167
+ await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate })
168
+ }
169
+
170
+ return {
171
+ transport,
172
+ async send(event: TimelineEvent, delay = 0) {
173
+ const valid = validateTimelineEvent(event)
174
+ await transport.send(valid, { marker: describeEvent(valid) })
175
+ if (delay) await page.waitForTimeout(delay)
176
+ },
177
+ async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
178
+ for (const item of sequence) {
179
+ const valid = validateTimelineEvent(item.event)
180
+ await transport.send(valid, { marker: describeEvent(valid) })
181
+ await page.waitForTimeout(item.delay)
182
+ }
183
+ },
184
+ async settle(frames = 3) {
185
+ await page.evaluate(
186
+ (frames) =>
187
+ new Promise<void>((resolve) => {
188
+ let remaining = frames
189
+ const tick = () => {
190
+ remaining--
191
+ if (remaining <= 0) return resolve()
192
+ requestAnimationFrame(tick)
193
+ }
194
+ requestAnimationFrame(tick)
195
+ }),
196
+ frames,
197
+ )
198
+ },
199
+ async waitForPart(partID: string) {
200
+ const part = page.locator(`[data-timeline-part-id="${partID}"]`)
201
+ await expect(part).toHaveCount(1)
202
+ await expect(part).toBeVisible()
203
+ },
204
+ }
205
+ }
206
+
207
+ function describeEvent(event: EventPayload) {
208
+ if (event.payload.type === "message.part.updated") {
209
+ const part = event.payload.properties.part
210
+ return [
211
+ event.payload.type,
212
+ part.id,
213
+ part.type === "tool" ? part.tool : part.type,
214
+ part.type === "tool" ? part.state.status : undefined,
215
+ ]
216
+ .filter(Boolean)
217
+ .join(":")
218
+ }
219
+ if (event.payload.type === "session.status") {
220
+ const status = event.payload.properties.status
221
+ return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
222
+ .filter((value) => value !== undefined)
223
+ .join(":")
224
+ }
225
+ return event.payload.type
226
+ }
227
+
228
+ export function event<const Type extends TimelinePayload["type"]>(
229
+ type: Type,
230
+ properties: Extract<TimelinePayload, { type: Type }>["properties"],
231
+ ): TimelineEvent
232
+ export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
233
+ return validateTimelineEvent({
234
+ directory,
235
+ payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
236
+ })
237
+ }
238
+
239
+ export function validateTimelineEvent(input: unknown): TimelineEvent {
240
+ return decodeEvent(input, decodeOptions)
241
+ }
242
+
243
+ export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
244
+ input.forEach((message) => decodeMessage(message, decodeOptions))
245
+ const messages = [...input]
246
+ const messageIDs = new Set<string>()
247
+ const partIDs = new Set<string>()
248
+ const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
249
+
250
+ messages.forEach((message) => {
251
+ if (messageIDs.has(message.info.id))
252
+ throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
253
+ messageIDs.add(message.info.id)
254
+ if (message.info.role === "assistant" && !users.has(message.info.parentID))
255
+ throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
256
+ message.parts.forEach((part) => {
257
+ if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
258
+ throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
259
+ if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
260
+ throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
261
+ if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
262
+ throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
263
+ if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
264
+ partIDs.add(part.id)
265
+ })
266
+ })
267
+ return messages
268
+ }
269
+
270
+ export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) {
271
+ await page.waitForFunction(
272
+ ({ selectors, stableFrames }) => {
273
+ const elements = selectors.map((selector) => document.querySelector<HTMLElement>(selector))
274
+ if (elements.some((element) => !element)) return false
275
+ return new Promise<boolean>((resolve) => {
276
+ let stable = 0
277
+ let previous = ""
278
+ const sample = () => {
279
+ const signature = JSON.stringify(
280
+ elements.map((element) => {
281
+ const rect = element!.getBoundingClientRect()
282
+ return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)]
283
+ }),
284
+ )
285
+ stable = signature === previous ? stable + 1 : 0
286
+ previous = signature
287
+ const ordered = elements
288
+ .slice(1)
289
+ .every(
290
+ (element, index) =>
291
+ elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5,
292
+ )
293
+ if (stable >= stableFrames && ordered) return resolve(true)
294
+ requestAnimationFrame(sample)
295
+ }
296
+ requestAnimationFrame(sample)
297
+ })
298
+ },
299
+ { selectors, stableFrames },
300
+ )
301
+ }
302
+
303
+ export function historyMessages(count: number): TimelineMessage[] {
304
+ return Array.from({ length: count }, (_, index) => {
305
+ const value = String(index).padStart(4, "0")
306
+ const historyUserID = `msg_0${value}_history_a_user`
307
+ return [
308
+ userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }),
309
+ assistantMessage(
310
+ [
311
+ {
312
+ id: `prt_0${value}_history_text`,
313
+ type: "text",
314
+ text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`,
315
+ },
316
+ ],
317
+ {
318
+ id: `msg_0${value}_history_b_assistant`,
319
+ parentID: historyUserID,
320
+ created: 1690000001000 + index * 10_000,
321
+ },
322
+ ),
323
+ ]
324
+ }).flat()
325
+ }
326
+
327
+ export function partUpdated(part: Part | PartSeed<"assistant">) {
328
+ const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
329
+ decodePart(owned, decodeOptions)
330
+ return event("message.part.updated", {
331
+ sessionID,
332
+ part: owned,
333
+ time: 1700000002000,
334
+ })
335
+ }
336
+
337
+ export function partDelta(partID: string, delta: string, messageID = assistantID) {
338
+ return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
339
+ }
340
+
341
+ export function messageUpdated(info: Message) {
342
+ return event("message.updated", { sessionID, info })
343
+ }
344
+
345
+ export function status(type: SessionStatus["type"], attempt = 1) {
346
+ return event("session.status", {
347
+ sessionID,
348
+ status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
349
+ })
350
+ }
351
+
352
+ export function userMessage(
353
+ parts?: PartSeed<"user">[],
354
+ input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
355
+ ): Extract<TimelineMessage, { info: { role: "user" } }> {
356
+ const id = input.id ?? userID
357
+ const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
358
+ const message = {
359
+ info: {
360
+ id,
361
+ sessionID,
362
+ role: "user",
363
+ time: { created: input.created ?? 1700000000000 },
364
+ summary: input.summary ?? { diffs: [] },
365
+ agent: "build",
366
+ model,
367
+ },
368
+ parts: seeds.map((part) => ({
369
+ ...part,
370
+ sessionID,
371
+ messageID: id,
372
+ })),
373
+ } satisfies Extract<TimelineMessage, { info: { role: "user" } }>
374
+ decodeMessage(message, decodeOptions)
375
+ return message
376
+ }
377
+
378
+ export function assistantMessage(
379
+ parts: PartSeed<"assistant">[] = [],
380
+ input: {
381
+ id?: string
382
+ parentID?: string
383
+ completed?: boolean
384
+ error?: AssistantMessage["error"]
385
+ created?: number
386
+ } = {},
387
+ ): Extract<TimelineMessage, { info: { role: "assistant" } }> {
388
+ const id = input.id ?? assistantID
389
+ const message = {
390
+ info: {
391
+ id,
392
+ sessionID,
393
+ role: "assistant",
394
+ time: {
395
+ created: input.created ?? 1700000001000,
396
+ ...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
397
+ },
398
+ parentID: input.parentID ?? userID,
399
+ modelID: model.modelID,
400
+ providerID: model.providerID,
401
+ mode: "build",
402
+ agent: "build",
403
+ path: { cwd: directory, root: directory },
404
+ cost: 0.01,
405
+ tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
406
+ variant: "max",
407
+ ...(input.error ? { error: input.error } : {}),
408
+ },
409
+ parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
410
+ } satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
411
+ decodeMessage(message, decodeOptions)
412
+ return message
413
+ }
414
+
415
+ export function userText(
416
+ text: string,
417
+ input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
418
+ ): Extract<PartSeed<"user">, { type: "text" }> {
419
+ return { id: "prt_user_text", type: "text", text, ...input }
420
+ }
421
+
422
+ export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
423
+ return { id, type: "text", text }
424
+ }
425
+
426
+ export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
427
+ return { id, type: "reasoning", text, time: { start: 1700000001000 } }
428
+ }
429
+
430
+ export function toolPart(
431
+ id: string,
432
+ tool: string,
433
+ state: "pending",
434
+ input: Record<string, unknown>,
435
+ options?: ToolOptions<"pending">,
436
+ ): Omit<ToolPart, "sessionID" | "messageID">
437
+ export function toolPart(
438
+ id: string,
439
+ tool: string,
440
+ state: "running",
441
+ input: Record<string, unknown>,
442
+ options?: ToolOptions<"running">,
443
+ ): Omit<ToolPart, "sessionID" | "messageID">
444
+ export function toolPart(
445
+ id: string,
446
+ tool: string,
447
+ state: "completed",
448
+ input: Record<string, unknown>,
449
+ options?: ToolOptions<"completed">,
450
+ ): Omit<ToolPart, "sessionID" | "messageID">
451
+ export function toolPart(
452
+ id: string,
453
+ tool: string,
454
+ state: "error",
455
+ input: Record<string, unknown>,
456
+ options?: ToolOptions<"error">,
457
+ ): Omit<ToolPart, "sessionID" | "messageID">
458
+ export function toolPart(
459
+ id: string,
460
+ tool: string,
461
+ state: ToolStatus,
462
+ input: Record<string, unknown>,
463
+ options: ToolOptions<ToolStatus> = {},
464
+ ): Omit<ToolPart, "sessionID" | "messageID"> {
465
+ const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
466
+ if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
467
+ if (state === "running")
468
+ return {
469
+ ...base,
470
+ state: {
471
+ status: state,
472
+ input,
473
+ title: options.title,
474
+ metadata: options.metadata ?? {},
475
+ time: { start: 1700000001000 },
476
+ },
477
+ }
478
+ if (state === "error")
479
+ return {
480
+ ...base,
481
+ state: {
482
+ status: state,
483
+ input,
484
+ error: options.error ?? "Tool failed",
485
+ metadata: options.metadata ?? {},
486
+ time: { start: 1700000001000, end: 1700000002000 },
487
+ },
488
+ }
489
+ return {
490
+ ...base,
491
+ state: {
492
+ status: state,
493
+ input,
494
+ output: options.output ?? "Completed",
495
+ title: options.title ?? tool,
496
+ metadata: options.metadata ?? {},
497
+ time: { start: 1700000001000, end: 1700000002000 },
498
+ },
499
+ }
500
+ }
501
+
502
+ export function shell(
503
+ id: string,
504
+ state: ToolStatus,
505
+ output = "",
506
+ command = `echo ${id}`,
507
+ ): Omit<ToolPart, "sessionID" | "messageID"> {
508
+ if (state === "pending") return toolPart(id, "bash", state, { command })
509
+ if (state === "running")
510
+ return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
511
+ if (state === "error")
512
+ return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
513
+ return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
514
+ }
515
+
516
+ export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
517
+ return { ...info, time: { ...info.time, completed: 1700000003000 } }
518
+ }
519
+
520
+ export function project() {
521
+ return {
522
+ id: projectID,
523
+ worktree: directory,
524
+ vcs: "git",
525
+ name: "timeline-stability",
526
+ time: { created: 1700000000000, updated: 1700000000000 },
527
+ sandboxes: [],
528
+ }
529
+ }
530
+
531
+ export function session(input: Partial<Session> = {}): Session {
532
+ return {
533
+ id: sessionID,
534
+ slug: "timeline-stability",
535
+ projectID,
536
+ directory,
537
+ title,
538
+ version: "dev",
539
+ time: { created: 1700000000000, updated: 1700000000000 },
540
+ ...input,
541
+ }
542
+ }
543
+
544
+ function eventSchema<
545
+ const Type extends TimelinePayload["type"],
546
+ const Properties extends Schema.Codec<unknown, unknown>,
547
+ >(type: Type, properties: Properties) {
548
+ return Schema.Struct({
549
+ directory: Schema.String,
550
+ project: Schema.optional(Schema.String),
551
+ workspace: Schema.optional(Schema.String),
552
+ payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
553
+ })
554
+ }
555
+
556
+ function provider() {
557
+ return {
558
+ all: [
559
+ {
560
+ id: "opencode",
561
+ name: "OpenCode",
562
+ models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
563
+ },
564
+ ],
565
+ connected: ["opencode"],
566
+ default: { providerID: "opencode", modelID: "claude-opus-4-6" },
567
+ }
568
+ }
packages/app/e2e/performance/timeline-stability/interaction.spec.ts ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ defineVisualRegions,
4
+ reportVisualStability,
5
+ startVisualProbe,
6
+ stopVisualProbe,
7
+ visualPlan,
8
+ } from "../../utils/visual-stability"
9
+ import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture"
10
+
11
+ test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
12
+ const shellID = "prt_interaction_01_shell"
13
+ const followingID = "prt_interaction_02_following"
14
+ await setupTimeline(page, {
15
+ messages: [
16
+ userMessage(),
17
+ assistantMessage([shell(shellID, "completed", lines(50)), textPart(followingID, "Following shell expansion")]),
18
+ ],
19
+ settings: { shellToolPartsExpanded: false },
20
+ cpuRate: 4,
21
+ seedHistory: true,
22
+ })
23
+ const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
24
+ await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
25
+ const regions = defineVisualRegions({
26
+ shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
27
+ following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
28
+ })
29
+ const plan = visualPlan(regions, [
30
+ { type: "required", regions: ["shell", "following"] },
31
+ { type: "unique", regions: ["shell", "following"] },
32
+ { type: "stable", regions: ["shell", "following"] },
33
+ { type: "opacity", regions: "all" },
34
+ { type: "continuity", regions: "all" },
35
+ { type: "motion", regions: "all", maxPositionReversals: 0 },
36
+ { type: "label-stability", regions: "all" },
37
+ { type: "preserve-bottom-anchor" },
38
+ { type: "flow", regions: ["shell", "following"] },
39
+ ])
40
+ await startVisualProbe(page, regions)
41
+ await trigger.click()
42
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
43
+ await page.waitForTimeout(500)
44
+ const expanded = await stopVisualProbe<keyof typeof regions>(page)
45
+ await reportVisualStability(testInfo, "shell-expand", expanded, plan)
46
+
47
+ await startVisualProbe(page, regions)
48
+ await trigger.click()
49
+ await expect(trigger).toHaveAttribute("aria-expanded", "false")
50
+ await page.waitForTimeout(500)
51
+ const collapsed = await stopVisualProbe<keyof typeof regions>(page)
52
+ await reportVisualStability(testInfo, "shell-collapse", collapsed, plan)
53
+ })
54
+
55
+ test("expands and collapses a completed context group without overlap", async ({ page }, testInfo) => {
56
+ const ids = [
57
+ "prt_interaction_01_read",
58
+ "prt_interaction_02_glob",
59
+ "prt_interaction_03_grep",
60
+ "prt_interaction_04_list",
61
+ ]
62
+ const group = `[data-timeline-part-ids="${ids.join(",")}"]`
63
+ const followingID = "prt_interaction_context_following"
64
+ await setupTimeline(page, {
65
+ messages: [
66
+ userMessage(),
67
+ assistantMessage([
68
+ toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
69
+ toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
70
+ toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }),
71
+ toolPart(ids[3]!, "list", "completed", { path: "src" }),
72
+ textPart(followingID, "Following context expansion"),
73
+ ]),
74
+ ],
75
+ cpuRate: 4,
76
+ seedHistory: true,
77
+ })
78
+ const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`)
79
+ await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`])
80
+ for (const [name, expanded] of [
81
+ ["context-expand", true],
82
+ ["context-collapse", false],
83
+ ["context-reexpand", true],
84
+ ] as const) {
85
+ const regions = defineVisualRegions({
86
+ context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
87
+ following: {
88
+ selector: `[data-timeline-part-id="${followingID}"]`,
89
+ closest: '[data-timeline-row="AssistantPart"]',
90
+ },
91
+ })
92
+ await startVisualProbe(page, regions)
93
+ await trigger.click()
94
+ await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
95
+ await page.waitForTimeout(500)
96
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
97
+ await reportVisualStability(
98
+ testInfo,
99
+ name,
100
+ trace,
101
+ visualPlan(regions, [
102
+ { type: "required", regions: ["context", "following"] },
103
+ { type: "unique", regions: ["context", "following"] },
104
+ { type: "stable", regions: ["context", "following"] },
105
+ { type: "opacity", regions: "all" },
106
+ { type: "continuity", regions: "all" },
107
+ { type: "motion", regions: "all", maxPositionReversals: 0 },
108
+ { type: "label-stability", regions: "all" },
109
+ { type: "preserve-bottom-anchor" },
110
+ { type: "flow", regions: ["context", "following"] },
111
+ ]),
112
+ )
113
+ }
114
+ })
115
+
116
+ test("expands and collapses an edit diff without moving twice", async ({ page }, testInfo) => {
117
+ const editID = "prt_interaction_edit"
118
+ const followingID = "prt_interaction_edit_following"
119
+ await setupTimeline(page, {
120
+ messages: [
121
+ userMessage(),
122
+ assistantMessage([
123
+ toolPart(
124
+ editID,
125
+ "edit",
126
+ "completed",
127
+ { filePath: "src/edit.ts" },
128
+ {
129
+ metadata: {
130
+ filediff: {
131
+ file: "src/edit.ts",
132
+ additions: 40,
133
+ deletions: 40,
134
+ before: source(40, false),
135
+ after: source(40, true),
136
+ },
137
+ },
138
+ },
139
+ ),
140
+ textPart(followingID, "Following edit expansion"),
141
+ ]),
142
+ ],
143
+ settings: { editToolPartsExpanded: false },
144
+ cpuRate: 4,
145
+ seedHistory: true,
146
+ })
147
+ const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
148
+ await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`])
149
+ const regions = defineVisualRegions({
150
+ edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
151
+ following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
152
+ })
153
+ await startVisualProbe(page, regions)
154
+ await trigger.click()
155
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
156
+ await page.waitForTimeout(900)
157
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
158
+ await reportVisualStability(
159
+ testInfo,
160
+ "edit-expand",
161
+ trace,
162
+ visualPlan(regions, [
163
+ { type: "required", regions: ["edit", "following"] },
164
+ { type: "unique", regions: ["edit", "following"] },
165
+ { type: "stable", regions: ["edit", "following"] },
166
+ { type: "opacity", regions: "all" },
167
+ { type: "continuity", regions: "all" },
168
+ { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
169
+ { type: "label-stability", regions: "all" },
170
+ { type: "preserve-bottom-anchor" },
171
+ { type: "flow", regions: ["edit", "following"] },
172
+ ]),
173
+ )
174
+ })
175
+
176
+ test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => {
177
+ const firstUser = userMessage(undefined, {
178
+ summary: {
179
+ diffs: Array.from({ length: 12 }, (_, index) => ({
180
+ file: `src/diff-${index}.ts`,
181
+ additions: 1,
182
+ deletions: 1,
183
+ patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
184
+ })),
185
+ },
186
+ })
187
+ const nextUserID = "msg_2000_diff_interaction_user"
188
+ await setupTimeline(page, {
189
+ messages: [
190
+ firstUser,
191
+ assistantMessage(),
192
+ userMessage(undefined, { id: nextUserID, created: 1700000010000 }),
193
+ assistantMessage([], {
194
+ id: "msg_2001_diff_interaction_assistant",
195
+ parentID: nextUserID,
196
+ created: 1700000011000,
197
+ }),
198
+ ],
199
+ cpuRate: 4,
200
+ })
201
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
202
+ await scroller.evaluate((element) => (element.scrollTop = 0))
203
+ const diff = page.locator('[data-timeline-row="DiffSummary"]')
204
+ const following = page.locator(`[data-message-id="${nextUserID}"]`).first()
205
+ await expect(diff).toBeVisible()
206
+ const regions = defineVisualRegions({
207
+ diff: { selector: '[data-timeline-row="DiffSummary"]' },
208
+ following: { selector: `[data-message-id="${nextUserID}"]` },
209
+ })
210
+ await startVisualProbe(page, regions)
211
+ await page.getByText(/show all/i).click()
212
+ await page.waitForTimeout(500)
213
+ await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click()
214
+ await page.waitForTimeout(900)
215
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
216
+ await reportVisualStability(
217
+ testInfo,
218
+ "diff-summary-expand",
219
+ trace,
220
+ visualPlan(regions, [
221
+ { type: "required", regions: ["diff", "following"] },
222
+ { type: "unique", regions: ["diff", "following"] },
223
+ { type: "stable", regions: ["diff", "following"] },
224
+ { type: "opacity", regions: "all" },
225
+ { type: "continuity", regions: "all" },
226
+ { type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
227
+ { type: "label-stability", regions: "all" },
228
+ { type: "flow", regions: ["diff", "following"] },
229
+ ]),
230
+ )
231
+ })
232
+
233
+ function lines(count: number) {
234
+ return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
235
+ }
236
+
237
+ function source(count: number, changed: boolean) {
238
+ return Array.from(
239
+ { length: count },
240
+ (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
241
+ ).join("")
242
+ }
packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ defineVisualRegions,
4
+ mapVisualRegions,
5
+ reportVisualStability,
6
+ startVisualProbe,
7
+ stopVisualProbe,
8
+ visualPlan,
9
+ } from "../../utils/visual-stability"
10
+ import {
11
+ assistantMessage,
12
+ completedAssistantInfo,
13
+ messageUpdated,
14
+ partDelta,
15
+ partUpdated,
16
+ reasoningPart,
17
+ setupTimeline,
18
+ shell,
19
+ status,
20
+ textPart,
21
+ userMessage,
22
+ waitForVisualSettle,
23
+ } from "./fixture"
24
+
25
+ test.describe("timeline visual lifecycle stability", () => {
26
+ test("streams empty, short, and long parallel shells to staggered completion", async ({ page }, testInfo) => {
27
+ test.setTimeout(180_000)
28
+ const ids = ["prt_parallel_01_empty", "prt_parallel_02_short", "prt_parallel_03_long"] as const
29
+ const initial = ids.map((id) => shell(id, "running"))
30
+ const followingID = "prt_parallel_04_following"
31
+ const assistant = assistantMessage([...initial, textPart(followingID, "Following all parallel shells.")], {
32
+ completed: false,
33
+ })
34
+ const timeline = await setupTimeline(page, {
35
+ messages: [userMessage(), assistant],
36
+ settings: { shellToolPartsExpanded: true, showReasoningSummaries: true },
37
+ cpuRate: 4,
38
+ eventRetry: 24,
39
+ seedHistory: true,
40
+ })
41
+ await timeline.send(status("busy"), 150)
42
+ for (const id of ids) await timeline.waitForPart(id)
43
+ const scroller = page.locator(".scroll-view__viewport", {
44
+ has: page.locator('[data-timeline-row="AssistantPart"]'),
45
+ })
46
+ await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
47
+ const regions = defineVisualRegions({
48
+ prt_shell_empty: shellRegion(ids[0]),
49
+ prt_shell_short: shellRegion(ids[1]),
50
+ prt_shell_long: shellRegion(ids[2]),
51
+ following: shellRegion(followingID),
52
+ })
53
+ await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`])
54
+ await startVisualProbe(page, regions)
55
+ await timeline.sendAll([
56
+ { event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 },
57
+ { event: partUpdated(shell(ids[2]!, "running", lines(10))), delay: 70 },
58
+ { event: partUpdated(shell(ids[1]!, "running", lines(2))), delay: 110 },
59
+ { event: partUpdated(shell(ids[2]!, "running", lines(25))), delay: 80 },
60
+ { event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 },
61
+ { event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 },
62
+ { event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 },
63
+ { event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 },
64
+ { event: status("idle"), delay: 700 },
65
+ ])
66
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
67
+ await reportVisualStability(
68
+ testInfo,
69
+ "parallel-shells",
70
+ trace,
71
+ visualPlan(
72
+ regions,
73
+ [
74
+ { type: "required", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
75
+ { type: "unique", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long"] },
76
+ { type: "stable", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
77
+ { type: "opacity", regions: "all" },
78
+ { type: "continuity", regions: "all" },
79
+ { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 4 },
80
+ { type: "label-stability", regions: "all" },
81
+ { type: "preserve-bottom-anchor" },
82
+ { type: "flow", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
83
+ ],
84
+ { perMarker: true },
85
+ ),
86
+ )
87
+ await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50")
88
+
89
+ const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`)
90
+ await short.locator('[data-slot="collapsible-trigger"]').click()
91
+ await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
92
+ await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250)
93
+ await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
94
+ })
95
+
96
+ test("replaces thinking with streamed reasoning and text without a blank visible turn", async ({
97
+ page,
98
+ }, testInfo) => {
99
+ const reasoningID = "prt_reasoning_visible"
100
+ const textID = "prt_streamed_text"
101
+ const assistant = assistantMessage([], { completed: false })
102
+ const timeline = await setupTimeline(page, {
103
+ messages: [userMessage(), assistant],
104
+ settings: { showReasoningSummaries: true },
105
+ cpuRate: 4,
106
+ })
107
+ await timeline.send(status("busy"), 120)
108
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
109
+
110
+ const regions = defineVisualRegions({
111
+ thinking: { selector: '[data-timeline-row="Thinking"]' },
112
+ reasoning: {
113
+ selector: `[data-timeline-part-id="${reasoningID}"]`,
114
+ closest: '[data-timeline-row="AssistantPart"]',
115
+ },
116
+ text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
117
+ })
118
+ await startVisualProbe(page, regions)
119
+ await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100)
120
+ await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
121
+ await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160)
122
+ await timeline.waitForPart(reasoningID)
123
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
124
+ await timeline.send(partUpdated(textPart(textID, "Starting")), 100)
125
+ await timeline.send(partDelta(textID, " **stable"), 90)
126
+ await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130)
127
+ await timeline.send(partDelta(textID, "](https://example.com)."), 220)
128
+ await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120)
129
+ await timeline.send(status("idle"), 500)
130
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
131
+ await reportVisualStability(
132
+ testInfo,
133
+ "reasoning-text-handoff",
134
+ trace,
135
+ visualPlan(regions, [
136
+ { type: "required", regions: ["reasoning", "text"] },
137
+ { type: "continuous-any", regions: ["thinking", "reasoning", "text"] },
138
+ { type: "unique", regions: ["reasoning", "text"] },
139
+ { type: "stable", regions: ["reasoning", "text"] },
140
+ { type: "opacity", regions: "all" },
141
+ { type: "continuity", regions: "all" },
142
+ { type: "motion", regions: "all", maxReversals: 4 },
143
+ { type: "label-stability", regions: "all" },
144
+ { type: "flow", regions: ["reasoning", "text"] },
145
+ ]),
146
+ )
147
+ await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output")
148
+ })
149
+ })
150
+
151
+ function lines(count: number) {
152
+ return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
153
+ }
154
+
155
+ function shellRegion(id: string) {
156
+ return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
157
+ }
packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ analyzeVisualObservations,
4
+ defineVisualRegions,
5
+ startVisualProbe,
6
+ stopVisualProbe,
7
+ visualPlan,
8
+ } from "../../utils/visual-stability"
9
+ import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture"
10
+
11
+ test("detects blanking caused by ancestor opacity", async ({ page }) => {
12
+ const partID = "prt_oracle_ancestor_opacity"
13
+ await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
14
+ const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
15
+ const regions = defineVisualRegions({
16
+ content: { selector: `[data-timeline-part-id="${partID}"]` },
17
+ })
18
+ await startVisualProbe(page, regions)
19
+ await row.evaluate((element) => {
20
+ element.parentElement!.style.opacity = "0"
21
+ })
22
+ await page.waitForTimeout(50)
23
+ await row.evaluate((element) => {
24
+ element.parentElement!.style.opacity = "1"
25
+ })
26
+ await page.waitForTimeout(50)
27
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
28
+ const issues = analyzeVisualObservations(
29
+ trace.samples,
30
+ visualPlan(regions, [
31
+ { type: "opacity", regions: "all" },
32
+ { type: "continuity", regions: "all" },
33
+ { type: "motion", regions: "all" },
34
+ { type: "label-stability", regions: "all" },
35
+ ]),
36
+ )
37
+
38
+ expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
39
+ })
40
+
41
+ test("detects root opacity when probing descendant opacity", async ({ page }) => {
42
+ const partID = "prt_oracle_descendant_opacity"
43
+ await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
44
+ const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
45
+ await row.evaluate((element) => {
46
+ element.innerHTML = '<span data-probe-opacity="true">Visible content</span>'
47
+ })
48
+ const regions = defineVisualRegions({
49
+ content: {
50
+ selector: `[data-timeline-part-id="${partID}"]`,
51
+ opacitySelectors: ['[data-probe-opacity="true"]'],
52
+ },
53
+ })
54
+ await startVisualProbe(page, regions)
55
+ await row.evaluate((element) => {
56
+ ;(element as HTMLElement).style.opacity = "0"
57
+ })
58
+ await page.waitForTimeout(50)
59
+ await row.evaluate((element) => {
60
+ ;(element as HTMLElement).style.opacity = "1"
61
+ })
62
+ await page.waitForTimeout(50)
63
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
64
+ const issues = analyzeVisualObservations(
65
+ trace.samples,
66
+ visualPlan(regions, [
67
+ { type: "opacity", regions: "all" },
68
+ { type: "continuity", regions: "all" },
69
+ { type: "motion", regions: "all" },
70
+ { type: "label-stability", regions: "all" },
71
+ ]),
72
+ )
73
+
74
+ expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
75
+ })
packages/app/e2e/performance/timeline-stability/playwright.config.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import config from "../playwright.config"
2
+
3
+ export default {
4
+ ...config,
5
+ testDir: ".",
6
+ testMatch: "**/*.spec.ts",
7
+ outputDir: "../../test-results/timeline-stability",
8
+ reporter: [["html", { outputFolder: "../../playwright-report/timeline-stability", open: "never" }], ["line"]],
9
+ retries: 0,
10
+ workers: 1,
11
+ use: {
12
+ ...config.use,
13
+ trace: "retain-on-failure",
14
+ screenshot: "only-on-failure",
15
+ video: "retain-on-failure",
16
+ },
17
+ }
packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ defineVisualRegions,
4
+ reportVisualStability,
5
+ startVisualProbe,
6
+ stopVisualProbe,
7
+ visualPlan,
8
+ } from "../../utils/visual-stability"
9
+ import {
10
+ assistantMessage,
11
+ partUpdated,
12
+ setupTimeline,
13
+ shell,
14
+ textPart,
15
+ userMessage,
16
+ type TimelineMessage,
17
+ } from "./fixture"
18
+
19
+ test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => {
20
+ const shellID = "prt_wheel_01_shell"
21
+ const followingID = "prt_wheel_02_following"
22
+ const timeline = await setupTimeline(page, {
23
+ messages: [
24
+ ...history(12),
25
+ userMessage(),
26
+ assistantMessage([shell(shellID, "running"), textPart(followingID, "Following wheel interaction")], {
27
+ completed: false,
28
+ }),
29
+ ],
30
+ settings: { shellToolPartsExpanded: true },
31
+ cpuRate: 4,
32
+ reducedMotion: true,
33
+ seedHistory: true,
34
+ })
35
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
36
+ const regions = defineVisualRegions({
37
+ shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
38
+ following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
39
+ })
40
+ await startVisualProbe(page, regions)
41
+ await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80)
42
+ await scroller.evaluate((element) =>
43
+ element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -180 })),
44
+ )
45
+ await scroller.evaluate((element) => (element.scrollTop -= 180))
46
+ await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 250)
47
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
48
+ await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
49
+ })
50
+
51
+ test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => {
52
+ await setupTimeline(page, {
53
+ messages: history(80),
54
+ viewport: { width: 1400, height: 700 },
55
+ reducedMotion: true,
56
+ })
57
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
58
+ const text = page.getByText("History 79.", { exact: false })
59
+ await expect(text).toBeVisible()
60
+ await scroller.evaluate((element) => {
61
+ element.dataset.selectionLength = "0"
62
+ document.addEventListener("selectionchange", () => {
63
+ element.dataset.selectionLength = String(
64
+ Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0),
65
+ )
66
+ })
67
+ })
68
+ const textBox = await text.boundingBox()
69
+ const scrollBox = await scroller.boundingBox()
70
+ expect(textBox).not.toBeNull()
71
+ expect(scrollBox).not.toBeNull()
72
+ if (!textBox || !scrollBox) return
73
+
74
+ await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
75
+ await page.mouse.down()
76
+ await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
77
+
78
+ await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0)
79
+ await expect
80
+ .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
81
+ .toBeGreaterThan(500)
82
+ await page.mouse.up()
83
+ })
84
+
85
+ test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
86
+ const shellID = "prt_keyboard_01_shell"
87
+ const followingID = "prt_keyboard_02_following"
88
+ const timeline = await setupTimeline(page, {
89
+ messages: [
90
+ ...history(12),
91
+ userMessage(),
92
+ assistantMessage([shell(shellID, "running"), textPart(followingID, "Following keyboard interaction")], {
93
+ completed: false,
94
+ }),
95
+ ],
96
+ settings: { shellToolPartsExpanded: true },
97
+ cpuRate: 4,
98
+ })
99
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
100
+ await scroller.focus()
101
+ for (let index = 0; index < 3; index++) {
102
+ await scroller.press("PageUp")
103
+ await page.waitForTimeout(250)
104
+ }
105
+ await expect
106
+ .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop), {
107
+ timeout: 20_000,
108
+ })
109
+ .toBeGreaterThan(80)
110
+ await page.waitForFunction(() => {
111
+ const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
112
+ element.querySelector("[data-timeline-row]"),
113
+ )
114
+ if (!root) return false
115
+ return new Promise<boolean>((resolve) => {
116
+ const top = root.scrollTop
117
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
118
+ })
119
+ })
120
+ const anchor = await scroller.evaluate((element) => {
121
+ const view = element.getBoundingClientRect()
122
+ return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
123
+ const rect = row.getBoundingClientRect()
124
+ return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
125
+ })?.dataset.timelineKey
126
+ })
127
+ expect(anchor).toBeTruthy()
128
+ const regions = defineVisualRegions({
129
+ anchor: { selector: `[data-timeline-key="${anchor}"]` },
130
+ })
131
+ await startVisualProbe(page, regions)
132
+ await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 400)
133
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
134
+ await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
135
+ })
136
+
137
+ test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
138
+ const shellID = "prt_descendant_keyboard_01_shell"
139
+ const timeline = await setupTimeline(page, {
140
+ messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
141
+ settings: { shellToolPartsExpanded: false },
142
+ cpuRate: 4,
143
+ reducedMotion: true,
144
+ })
145
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
146
+ const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
147
+ const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
148
+ await row.evaluate((element) => element.setAttribute("tabindex", "0"))
149
+ await row.focus()
150
+ for (let index = 0; index < 3; index++) {
151
+ await row.press("PageUp")
152
+ await page.waitForTimeout(250)
153
+ }
154
+ await expect
155
+ .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
156
+ .toBeGreaterThan(5)
157
+ const anchor = await scroller.evaluate((element) => {
158
+ const view = element.getBoundingClientRect()
159
+ return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
160
+ const rect = row.getBoundingClientRect()
161
+ return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
162
+ })?.dataset.timelineKey
163
+ })
164
+ expect(anchor).toBeTruthy()
165
+ const regions = defineVisualRegions({
166
+ anchor: { selector: `[data-timeline-key="${anchor}"]` },
167
+ })
168
+ await startVisualProbe(page, regions)
169
+ await trigger.click()
170
+ await page.waitForTimeout(300)
171
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
172
+ await reportVisualStability(testInfo, "descendant-keyboard-resize", trace, anchorPlan(regions))
173
+ })
174
+
175
+ test("does not claim keyboard scrolling owned by a nested scrollable", async ({ page }) => {
176
+ const shellID = "prt_nested_keyboard_shell"
177
+ await setupTimeline(page, {
178
+ messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(50))])],
179
+ settings: { shellToolPartsExpanded: true },
180
+ cpuRate: 4,
181
+ reducedMotion: true,
182
+ seedHistory: true,
183
+ })
184
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
185
+ const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`)
186
+ await nested.evaluate((element) => (element.scrollTop = element.scrollHeight))
187
+ await nested.focus()
188
+ await page.waitForFunction(() => {
189
+ const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
190
+ element.querySelector("[data-timeline-row]"),
191
+ )
192
+ if (!root) return false
193
+ return new Promise<boolean>((resolve) => {
194
+ const top = root.scrollTop
195
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
196
+ })
197
+ })
198
+ const before = await scroller.evaluate((element) => element.scrollTop)
199
+ const nestedBefore = await nested.evaluate((element) => element.scrollTop)
200
+ await nested.press("PageUp")
201
+ await page.waitForTimeout(300)
202
+ expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
203
+ expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
204
+
205
+ await nested.evaluate((element) => (element.scrollTop = 0))
206
+ await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight)))
207
+ const boundaryBefore = await scroller.evaluate((element) => element.scrollTop)
208
+ expect(boundaryBefore).toBeGreaterThan(0)
209
+ await nested.press("PageUp")
210
+ await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore)
211
+
212
+ const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
213
+ await nonOverflowing.evaluate((element) => {
214
+ element.setAttribute("data-scrollable", "")
215
+ element.setAttribute("tabindex", "0")
216
+ })
217
+ await nonOverflowing.focus()
218
+ const nonOverflowBefore = await scroller.evaluate((element) => element.scrollTop)
219
+ await nonOverflowing.press("PageUp")
220
+ await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(nonOverflowBefore)
221
+ })
222
+
223
+ test("jump to latest lands on stable final rows after offscreen growth", async ({ page }, testInfo) => {
224
+ const shellID = "prt_jump_01_shell"
225
+ const followingID = "prt_jump_02_following"
226
+ const timeline = await setupTimeline(page, {
227
+ messages: [
228
+ ...history(20),
229
+ userMessage(),
230
+ assistantMessage([shell(shellID, "running"), textPart(followingID, "Latest visible row")], { completed: false }),
231
+ ],
232
+ settings: { shellToolPartsExpanded: true },
233
+ cpuRate: 4,
234
+ })
235
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
236
+ await scroller.evaluate(
237
+ (element) => (element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 600)),
238
+ )
239
+ await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300)
240
+ const regions = defineVisualRegions({
241
+ shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
242
+ following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
243
+ })
244
+ await startVisualProbe(page, regions)
245
+ await page.getByRole("button", { name: /Jump to latest/i }).click()
246
+ await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible()
247
+ await page.waitForTimeout(600)
248
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
249
+ await reportVisualStability(
250
+ testInfo,
251
+ "jump-latest",
252
+ trace,
253
+ visualPlan(regions, [
254
+ { type: "required", regions: ["shell", "following"] },
255
+ { type: "unique", regions: ["shell", "following"] },
256
+ { type: "opacity", regions: "all" },
257
+ { type: "continuity", regions: "all" },
258
+ { type: "motion", regions: "all", maxPositionReversals: 1 },
259
+ { type: "label-stability", regions: "all" },
260
+ { type: "acquire-bottom-anchor" },
261
+ { type: "flow", regions: ["shell", "following"] },
262
+ ]),
263
+ )
264
+ })
265
+
266
+ test("handles a single row taller than the viewport", async ({ page }, testInfo) => {
267
+ const shellID = "prt_tall_01_shell"
268
+ const followingID = "prt_tall_02_following"
269
+ const timeline = await setupTimeline(page, {
270
+ messages: [
271
+ userMessage(),
272
+ assistantMessage([shell(shellID, "running"), textPart(followingID, "After tall row")], { completed: false }),
273
+ ],
274
+ settings: { shellToolPartsExpanded: true },
275
+ viewport: { width: 900, height: 360 },
276
+ cpuRate: 4,
277
+ seedHistory: true,
278
+ })
279
+ const regions = defineVisualRegions({
280
+ shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
281
+ following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
282
+ })
283
+ await startVisualProbe(page, regions)
284
+ await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700)
285
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
286
+ await reportVisualStability(
287
+ testInfo,
288
+ "taller-than-viewport",
289
+ trace,
290
+ visualPlan(regions, [
291
+ { type: "required", regions: ["shell", "following"] },
292
+ { type: "unique", regions: ["shell", "following"] },
293
+ { type: "stable", regions: ["shell", "following"] },
294
+ { type: "opacity", regions: "all" },
295
+ { type: "continuity", regions: "all" },
296
+ { type: "motion", regions: "all", maxPositionReversals: 0 },
297
+ { type: "label-stability", regions: "all" },
298
+ { type: "preserve-bottom-anchor" },
299
+ { type: "flow", regions: ["shell", "following"] },
300
+ ]),
301
+ )
302
+ })
303
+
304
+ function history(count: number): TimelineMessage[] {
305
+ return Array.from({ length: count }, (_, index) => {
306
+ const prefix = `msg_${String(index).padStart(4, "0")}_scroll`
307
+ const userID = `${prefix}_a_user`
308
+ return [
309
+ userMessage(undefined, { id: userID, created: 1690000000000 + index * 10_000 }),
310
+ assistantMessage(
311
+ [textPart(`prt_${String(index).padStart(4, "0")}_scroll`, `History ${index}. ${"content ".repeat(30)}`)],
312
+ {
313
+ id: `${prefix}_b_assistant`,
314
+ parentID: userID,
315
+ created: 1690000001000 + index * 10_000,
316
+ },
317
+ ),
318
+ ]
319
+ }).flat()
320
+ }
321
+
322
+ function lines(count: number) {
323
+ return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
324
+ }
325
+
326
+ function rowPairPlan(
327
+ regions: Record<"shell" | "following", { selector: string; closest?: string }>,
328
+ maxPositionReversals: number,
329
+ ) {
330
+ return visualPlan(regions, [
331
+ { type: "required", regions: ["shell", "following"] },
332
+ { type: "unique", regions: ["shell", "following"] },
333
+ { type: "stable", regions: ["shell", "following"] },
334
+ { type: "opacity", regions: "all" },
335
+ { type: "continuity", regions: "all" },
336
+ { type: "motion", regions: "all", maxPositionReversals },
337
+ { type: "label-stability", regions: "all" },
338
+ { type: "flow", regions: ["shell", "following"] },
339
+ ])
340
+ }
341
+
342
+ function anchorPlan(regions: Record<"anchor", { selector: string; closest?: string }>) {
343
+ return visualPlan(regions, [
344
+ { type: "required", regions: ["anchor"] },
345
+ { type: "unique", regions: ["anchor"] },
346
+ { type: "stable", regions: ["anchor"] },
347
+ { type: "fixed", regions: ["anchor"] },
348
+ { type: "opacity", regions: "all" },
349
+ { type: "continuity", regions: "all" },
350
+ { type: "motion", regions: "all", maxPositionReversals: 0 },
351
+ { type: "label-stability", regions: "all" },
352
+ ])
353
+ }
packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test } from "@playwright/test"
2
+ import {
3
+ defineVisualRegions,
4
+ reportVisualStability,
5
+ startVisualProbe,
6
+ stopVisualProbe,
7
+ visualPlan,
8
+ } from "../../utils/visual-stability"
9
+ import {
10
+ assistantMessage,
11
+ partUpdated,
12
+ setupTimeline,
13
+ shell,
14
+ textPart,
15
+ userMessage,
16
+ waitForVisualSettle,
17
+ } from "./fixture"
18
+
19
+ const profiles = [
20
+ {
21
+ name: "empty running to completed",
22
+ updates: [{ state: "completed" as const, output: "", delay: 350 }],
23
+ },
24
+ {
25
+ name: "50 lines arriving incrementally",
26
+ updates: [
27
+ { state: "running" as const, output: lines(1), delay: 100 },
28
+ { state: "running" as const, output: lines(10), delay: 160 },
29
+ { state: "running" as const, output: lines(25), delay: 90 },
30
+ { state: "running" as const, output: lines(50), delay: 220 },
31
+ { state: "completed" as const, output: lines(50), delay: 500 },
32
+ ],
33
+ },
34
+ {
35
+ name: "wide ANSI and CRLF output",
36
+ updates: [
37
+ {
38
+ state: "running" as const,
39
+ output: Array.from({ length: 20 }, (_, index) => `\u001b[32mline ${index}\u001b[0m ${"wide-".repeat(30)}`).join(
40
+ "\r\n",
41
+ ),
42
+ delay: 240,
43
+ },
44
+ {
45
+ state: "completed" as const,
46
+ output: Array.from({ length: 20 }, (_, index) => `line ${index} ${"wide-".repeat(30)}`).join("\n"),
47
+ delay: 500,
48
+ },
49
+ ],
50
+ },
51
+ ] as const
52
+
53
+ for (const profile of profiles) {
54
+ test(`keeps rows stable for shell ${profile.name}`, async ({ page }, testInfo) => {
55
+ const shellID = `prt_matrix_${profiles.indexOf(profile)}_01_shell`
56
+ const followingID = `prt_matrix_${profiles.indexOf(profile)}_02_following`
57
+ const timeline = await setupTimeline(page, {
58
+ messages: [
59
+ userMessage(),
60
+ assistantMessage([shell(shellID, "running"), textPart(followingID, "Following shell row")], {
61
+ completed: false,
62
+ }),
63
+ ],
64
+ settings: { shellToolPartsExpanded: true },
65
+ cpuRate: 4,
66
+ seedHistory: true,
67
+ })
68
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
69
+ await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
70
+ await waitForVisualSettle(page, [
71
+ `[data-timeline-part-id="${shellID}"]`,
72
+ `[data-timeline-part-id="${followingID}"]`,
73
+ ])
74
+ const regions = defineVisualRegions({
75
+ shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
76
+ following: {
77
+ selector: `[data-timeline-part-id="${followingID}"]`,
78
+ closest: '[data-timeline-row="AssistantPart"]',
79
+ },
80
+ })
81
+ await startVisualProbe(page, regions)
82
+ for (const update of profile.updates) {
83
+ await timeline.send(partUpdated(shell(shellID, update.state, update.output)), update.delay)
84
+ }
85
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
86
+ await reportVisualStability(
87
+ testInfo,
88
+ `shell-${profiles.indexOf(profile)}`,
89
+ trace,
90
+ visualPlan(
91
+ regions,
92
+ [
93
+ { type: "required", regions: ["shell", "following"] },
94
+ { type: "unique", regions: ["shell", "following"] },
95
+ { type: "stable", regions: ["shell", "following"] },
96
+ { type: "opacity", regions: "all" },
97
+ { type: "continuity", regions: "all" },
98
+ { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
99
+ { type: "label-stability", regions: "all" },
100
+ { type: "preserve-bottom-anchor" },
101
+ { type: "flow", regions: ["shell", "following"] },
102
+ ],
103
+ { perMarker: true },
104
+ ),
105
+ )
106
+ })
107
+ }
108
+
109
+ test("keeps following row stable when a collapsed shell receives 50 lines", async ({ page }, testInfo) => {
110
+ const shellID = "prt_matrix_collapsed_01_shell"
111
+ const followingID = "prt_matrix_collapsed_02_following"
112
+ const timeline = await setupTimeline(page, {
113
+ messages: [
114
+ userMessage(),
115
+ assistantMessage([shell(shellID, "running"), textPart(followingID, "Following collapsed shell")], {
116
+ completed: false,
117
+ }),
118
+ ],
119
+ settings: { shellToolPartsExpanded: false },
120
+ cpuRate: 4,
121
+ seedHistory: true,
122
+ })
123
+ await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
124
+ const regions = defineVisualRegions({
125
+ shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
126
+ following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
127
+ })
128
+ await startVisualProbe(page, regions)
129
+ await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240)
130
+ await timeline.send(partUpdated(shell(shellID, "completed", lines(50))), 500)
131
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
132
+ await reportVisualStability(
133
+ testInfo,
134
+ "collapsed-shell",
135
+ trace,
136
+ visualPlan(
137
+ regions,
138
+ [
139
+ { type: "required", regions: ["shell", "following"] },
140
+ { type: "unique", regions: ["shell", "following"] },
141
+ { type: "stable", regions: ["shell", "following"] },
142
+ { type: "opacity", regions: "all" },
143
+ { type: "continuity", regions: "all" },
144
+ { type: "motion", regions: ["following"], maxPositionReversals: 0 },
145
+ { type: "label-stability", regions: "all" },
146
+ { type: "flow", regions: ["shell", "following"] },
147
+ ],
148
+ { perMarker: true },
149
+ ),
150
+ )
151
+ })
152
+
153
+ test("keeps rows stable when a running shell becomes an error", async ({ page }, testInfo) => {
154
+ const shellID = "prt_matrix_error_01_shell"
155
+ const followingID = "prt_matrix_error_02_following"
156
+ const timeline = await setupTimeline(page, {
157
+ messages: [
158
+ userMessage(),
159
+ assistantMessage([shell(shellID, "running", lines(10)), textPart(followingID, "Following failed shell")], {
160
+ completed: false,
161
+ }),
162
+ ],
163
+ settings: { shellToolPartsExpanded: true },
164
+ cpuRate: 4,
165
+ seedHistory: true,
166
+ })
167
+ await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
168
+ const regions = defineVisualRegions({
169
+ shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
170
+ following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
171
+ })
172
+ await startVisualProbe(page, regions)
173
+ await timeline.send(
174
+ partUpdated({
175
+ ...shell(shellID, "error"),
176
+ state: {
177
+ status: "error",
178
+ input: { command: `echo ${shellID}` },
179
+ error: "Command failed after output",
180
+ metadata: {},
181
+ time: { start: 1700000001000, end: 1700000002000 },
182
+ },
183
+ }),
184
+ 500,
185
+ )
186
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
187
+ await reportVisualStability(
188
+ testInfo,
189
+ "shell-error",
190
+ trace,
191
+ visualPlan(
192
+ regions,
193
+ [
194
+ { type: "required", regions: ["shell", "following"] },
195
+ { type: "unique", regions: ["shell", "following"] },
196
+ { type: "stable", regions: ["shell", "following"] },
197
+ { type: "opacity", regions: "all" },
198
+ { type: "continuity", regions: "all" },
199
+ { type: "motion", regions: ["following"], maxPositionReversals: 0 },
200
+ { type: "label-stability", regions: "all" },
201
+ { type: "preserve-bottom-anchor" },
202
+ { type: "flow", regions: ["shell", "following"] },
203
+ ],
204
+ { perMarker: true },
205
+ ),
206
+ )
207
+ })
208
+
209
+ test("keeps rows stable when later text arrives before shell output", async ({ page }, testInfo) => {
210
+ const shellID = "prt_late_text_01_shell"
211
+ const followingID = "prt_late_text_02_following"
212
+ const timeline = await setupTimeline(page, {
213
+ messages: [userMessage(), assistantMessage([shell(shellID, "running")], { completed: false })],
214
+ settings: { shellToolPartsExpanded: true },
215
+ cpuRate: 4,
216
+ seedHistory: true,
217
+ })
218
+ await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`])
219
+ const regions = defineVisualRegions({
220
+ shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
221
+ following: {
222
+ selector: `[data-timeline-part-id="${followingID}"]`,
223
+ closest: '[data-timeline-row="AssistantPart"]',
224
+ },
225
+ })
226
+ await startVisualProbe(page, regions)
227
+ await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240)
228
+ await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300)
229
+ await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600)
230
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
231
+ await reportVisualStability(
232
+ testInfo,
233
+ "late-text-before-shell-output",
234
+ trace,
235
+ visualPlan(
236
+ regions,
237
+ [
238
+ { type: "required", regions: ["shell", "following"] },
239
+ { type: "unique", regions: ["shell", "following"] },
240
+ { type: "stable", regions: ["shell"] },
241
+ { type: "opacity", regions: "all" },
242
+ { type: "continuity", regions: "all" },
243
+ { type: "motion", regions: "all", maxPositionReversals: 0 },
244
+ { type: "label-stability", regions: "all" },
245
+ { type: "preserve-bottom-anchor" },
246
+ { type: "flow", regions: ["shell", "following"] },
247
+ ],
248
+ { perMarker: true },
249
+ ),
250
+ )
251
+ })
252
+
253
+ function lines(count: number) {
254
+ return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
255
+ }
packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ defineVisualRegions,
4
+ reportVisualStability,
5
+ startVisualProbe,
6
+ stopVisualProbe,
7
+ visualPlan,
8
+ } from "../../utils/visual-stability"
9
+ import {
10
+ assistantMessage,
11
+ partUpdated,
12
+ session,
13
+ sessionID,
14
+ setupTimeline,
15
+ textPart,
16
+ toolPart,
17
+ userMessage,
18
+ } from "./fixture"
19
+
20
+ test("adds a task child-session link without replacing the task row", async ({ page }, testInfo) => {
21
+ const taskID = "prt_task_link"
22
+ const childID = "ses_task_child"
23
+ const input = { description: "Inspect child", subagent_type: "explore" }
24
+ const timeline = await setupTimeline(page, {
25
+ messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })],
26
+ sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })],
27
+ cpuRate: 4,
28
+ })
29
+ const regions = defineVisualRegions({
30
+ task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` },
31
+ })
32
+ await startVisualProbe(page, regions)
33
+ await timeline.send(
34
+ partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })),
35
+ 500,
36
+ )
37
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
38
+ await reportVisualStability(
39
+ testInfo,
40
+ "task-link",
41
+ trace,
42
+ visualPlan(regions, [
43
+ { type: "required", regions: ["task"] },
44
+ { type: "unique", regions: ["task"] },
45
+ { type: "stable", regions: ["task"] },
46
+ { type: "opacity", regions: "all" },
47
+ { type: "continuity", regions: "all" },
48
+ { type: "motion", regions: "all", maxPositionReversals: 0 },
49
+ { type: "label-stability", regions: "all" },
50
+ ]),
51
+ )
52
+ await expect(
53
+ page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
54
+ ).toBeVisible()
55
+ })
56
+
57
+ test("changes generic tool arguments without replacing the row", async ({ page }, testInfo) => {
58
+ const toolID = "prt_generic_mutation"
59
+ const followingID = "prt_generic_mutation_following"
60
+ const timeline = await setupTimeline(page, {
61
+ messages: [
62
+ userMessage(),
63
+ assistantMessage(
64
+ [
65
+ toolPart(toolID, "mcp_probe", "running", { target: "one", count: 1 }),
66
+ textPart(followingID, "Following generic tool"),
67
+ ],
68
+ { completed: false },
69
+ ),
70
+ ],
71
+ cpuRate: 4,
72
+ })
73
+ const regions = defineVisualRegions({
74
+ tool: { selector: `[data-timeline-part-id="${toolID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
75
+ following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
76
+ })
77
+ await startVisualProbe(page, regions)
78
+ await timeline.send(
79
+ partUpdated(toolPart(toolID, "mcp_probe", "running", { target: "two", count: 2, mode: "deep" })),
80
+ 200,
81
+ )
82
+ await timeline.send(
83
+ partUpdated(toolPart(toolID, "mcp_probe", "completed", { target: "two", count: 2, mode: "deep" })),
84
+ 400,
85
+ )
86
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
87
+ await reportVisualStability(
88
+ testInfo,
89
+ "generic-mutation",
90
+ trace,
91
+ visualPlan(
92
+ regions,
93
+ [
94
+ { type: "required", regions: ["tool", "following"] },
95
+ { type: "unique", regions: ["tool", "following"] },
96
+ { type: "stable", regions: ["tool", "following"] },
97
+ { type: "opacity", regions: "all" },
98
+ { type: "continuity", regions: "all" },
99
+ { type: "motion", regions: "all", maxPositionReversals: 0 },
100
+ { type: "label-stability", regions: "all" },
101
+ { type: "flow", regions: ["tool", "following"] },
102
+ ],
103
+ { perMarker: true },
104
+ ),
105
+ )
106
+ })
packages/app/e2e/performance/timeline-stability/tools.spec.ts ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ defineVisualRegions,
4
+ reportVisualStability,
5
+ startVisualProbe,
6
+ stopVisualProbe,
7
+ visualPlan,
8
+ } from "../../utils/visual-stability"
9
+ import {
10
+ assistantMessage,
11
+ directory,
12
+ partUpdated,
13
+ session,
14
+ sessionID,
15
+ setupTimeline,
16
+ status,
17
+ textPart,
18
+ toolPart,
19
+ userMessage,
20
+ } from "./fixture"
21
+
22
+ test.describe("timeline tool state stability", () => {
23
+ test("moves lightweight tools through pending, running, and completed without replacing rows", async ({
24
+ page,
25
+ }, testInfo) => {
26
+ const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const
27
+ const inputs = {
28
+ webfetch: { url: "https://example.com/docs" },
29
+ websearch: { query: "timeline stability" },
30
+ task: { description: "Inspect timeline", subagent_type: "explore" },
31
+ skill: { name: "stability" },
32
+ custom: { target: "timeline", depth: 2 },
33
+ }
34
+ const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
35
+ const questionID = "prt_state_question"
36
+ const todoID = "prt_state_todo"
37
+ const initial = [
38
+ ...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
39
+ toolPart(questionID, "question", "pending", questionInput()),
40
+ toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
41
+ textPart("prt_state_following", "Following lightweight tools"),
42
+ ]
43
+ const childID = "ses_timeline_child"
44
+ const timeline = await setupTimeline(page, {
45
+ messages: [userMessage(), assistantMessage(initial, { completed: false })],
46
+ sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect timeline" })],
47
+ cpuRate: 4,
48
+ })
49
+ await timeline.send(status("busy"), 120)
50
+ for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
51
+ await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
52
+ await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
53
+
54
+ const regionIDs = [
55
+ "prt_state_webfetch",
56
+ "prt_state_websearch",
57
+ "prt_state_task",
58
+ "prt_state_skill",
59
+ "prt_state_custom",
60
+ ] as const
61
+ const regions = defineVisualRegions({
62
+ prt_state_webfetch: toolRegion(regionIDs[0]),
63
+ prt_state_websearch: toolRegion(regionIDs[1]),
64
+ prt_state_task: toolRegion(regionIDs[2]),
65
+ prt_state_skill: toolRegion(regionIDs[3]),
66
+ prt_state_custom: toolRegion(regionIDs[4]),
67
+ })
68
+ await startVisualProbe(page, regions)
69
+ for (const [index, id] of ids.entries()) {
70
+ await timeline.send(
71
+ partUpdated(toolPart(`prt_state_${id}`, names[id], "running", inputs[id])),
72
+ [80, 240, 100, 360, 140][index],
73
+ )
74
+ }
75
+ for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) {
76
+ const key = id as (typeof ids)[number]
77
+ const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {}
78
+ const output = key === "websearch" ? "Result https://example.com/result" : "Completed"
79
+ await timeline.send(
80
+ partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })),
81
+ [110, 70, 280, 130, 420][index],
82
+ )
83
+ }
84
+ await timeline.send(
85
+ partUpdated(
86
+ toolPart(questionID, "question", "completed", questionInput(), { metadata: { answers: [["Keep it stable"]] } }),
87
+ ),
88
+ 350,
89
+ )
90
+ await timeline.waitForPart(questionID)
91
+ await timeline.send(status("idle"), 500)
92
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
93
+ await reportVisualStability(
94
+ testInfo,
95
+ "lightweight-tools",
96
+ trace,
97
+ visualPlan(regions, [
98
+ { type: "required", regions: regionIDs },
99
+ { type: "unique", regions: regionIDs },
100
+ { type: "stable", regions: regionIDs },
101
+ { type: "opacity", regions: "all" },
102
+ { type: "continuity", regions: "all" },
103
+ { type: "motion", regions: "all", maxReversals: 4 },
104
+ { type: "label-stability", regions: "all" },
105
+ ]),
106
+ )
107
+ await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
108
+ await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
109
+ await expect(
110
+ page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
111
+ ).toBeVisible()
112
+ await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
113
+ })
114
+
115
+ test("keeps an expanded mixed context group stable through staggered completion and error", async ({
116
+ page,
117
+ }, testInfo) => {
118
+ const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"]
119
+ const tools = ["read", "glob", "grep", "list"]
120
+ const inputs = [
121
+ { filePath: "src/a.ts", offset: 0, limit: 120 },
122
+ { path: directory, pattern: "**/*.ts" },
123
+ { path: directory, pattern: "stability", include: "*.ts" },
124
+ { path: "src" },
125
+ ]
126
+ const context = ids.map((id, index) => toolPart(id, tools[index]!, "pending", inputs[index]!))
127
+ const timeline = await setupTimeline(page, {
128
+ messages: [
129
+ userMessage(),
130
+ assistantMessage([...context, textPart("prt_ctx_following", "Following context")], { completed: false }),
131
+ ],
132
+ cpuRate: 4,
133
+ })
134
+ await timeline.send(status("busy"), 100)
135
+ const groupSelector = `[data-timeline-part-ids="${ids.join(",")}"]`
136
+ const group = page.locator(groupSelector)
137
+ await expect(group).toBeVisible()
138
+ await group.locator('[data-slot="collapsible-trigger"]').click()
139
+ await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
140
+
141
+ const regions = defineVisualRegions({
142
+ status: {
143
+ selector: `${groupSelector} [data-component="tool-status-title"]`,
144
+ opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
145
+ },
146
+ context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' },
147
+ following: {
148
+ selector: '[data-timeline-part-id="prt_ctx_following"]',
149
+ closest: '[data-timeline-row="AssistantPart"]',
150
+ },
151
+ })
152
+ await startVisualProbe(page, regions)
153
+ for (const [index, delay] of [90, 260, 70, 380].entries()) {
154
+ await timeline.send(partUpdated(toolPart(ids[index]!, tools[index]!, "running", inputs[index]!)), delay)
155
+ }
156
+ await timeline.send(partUpdated(toolPart(ids[1]!, tools[1]!, "completed", inputs[1]!)), 130)
157
+ await timeline.send(partUpdated(toolPart(ids[3]!, tools[3]!, "completed", inputs[3]!)), 210)
158
+ await timeline.send(
159
+ partUpdated(toolPart(ids[0]!, tools[0]!, "error", inputs[0]!, { error: "Read interrupted" })),
160
+ 110,
161
+ )
162
+ await timeline.send(partUpdated(toolPart(ids[2]!, tools[2]!, "completed", inputs[2]!)), 250)
163
+ await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
164
+ await timeline.send(status("idle"), 700)
165
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
166
+ await reportVisualStability(
167
+ testInfo,
168
+ "mixed-context",
169
+ trace,
170
+ visualPlan(regions, [
171
+ { type: "required", regions: ["context", "following"] },
172
+ { type: "unique", regions: ["context"] },
173
+ { type: "stable", regions: ["context"] },
174
+ { type: "opacity", regions: "all" },
175
+ { type: "continuity", regions: "all" },
176
+ { type: "motion", regions: "all", maxReversals: 4 },
177
+ { type: "label-stability", regions: "all" },
178
+ { type: "flow", regions: ["context", "following"] },
179
+ ]),
180
+ )
181
+ await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
182
+ await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
183
+ await group.locator('[data-slot="collapsible-trigger"]').click()
184
+ await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
185
+ await timeline.send(partUpdated(textPart("prt_ctx_late_sibling", "Later sibling content")), 200)
186
+ await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
187
+ await group.locator('[data-slot="collapsible-trigger"]').click()
188
+ await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
189
+ })
190
+ })
191
+
192
+ function questionInput() {
193
+ return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
194
+ }
195
+
196
+ function toolRegion(id: string) {
197
+ return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
198
+ }
packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ defineVisualRegions,
4
+ reportVisualStability,
5
+ startVisualProbe,
6
+ stopVisualProbe,
7
+ visualPlan,
8
+ } from "../../utils/visual-stability"
9
+ import {
10
+ assistantID,
11
+ assistantMessage,
12
+ completedAssistantInfo,
13
+ event,
14
+ messageUpdated,
15
+ partDelta,
16
+ partUpdated,
17
+ setupTimeline,
18
+ shell,
19
+ status,
20
+ textPart,
21
+ toolPart,
22
+ userMessage,
23
+ } from "./fixture"
24
+
25
+ test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => {
26
+ const firstID = "prt_mutation_01_first"
27
+ const middleID = "prt_mutation_02_middle"
28
+ const lastID = "prt_mutation_03_last"
29
+ const timeline = await setupTimeline(page, {
30
+ messages: [
31
+ userMessage(),
32
+ assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], {
33
+ completed: false,
34
+ }),
35
+ ],
36
+ cpuRate: 4,
37
+ })
38
+ const regions = defineVisualRegions({
39
+ first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
40
+ last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
41
+ })
42
+ await startVisualProbe(page, regions)
43
+ await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350)
44
+ await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible()
45
+ await timeline.send(
46
+ event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }),
47
+ 500,
48
+ )
49
+ await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0)
50
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
51
+ await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1))
52
+ })
53
+
54
+ test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => {
55
+ const textID = "prt_text_reconcile"
56
+ const followingID = "prt_text_reconcile_following"
57
+ const assistant = assistantMessage([textPart(textID, "Starting"), textPart(followingID, "Following text row")], {
58
+ completed: false,
59
+ })
60
+ const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
61
+ const regions = defineVisualRegions({
62
+ text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
63
+ following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
64
+ })
65
+ await startVisualProbe(page, regions)
66
+ await timeline.send(partDelta(textID, " streamed content"), 100)
67
+ await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180)
68
+ await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200)
69
+ await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500)
70
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
71
+ await reportVisualStability(
72
+ testInfo,
73
+ "text-reconcile",
74
+ trace,
75
+ visualPlan(regions, [
76
+ { type: "required", regions: ["text", "following"] },
77
+ { type: "unique", regions: ["text", "following"] },
78
+ { type: "stable", regions: ["text", "following"] },
79
+ { type: "opacity", regions: "all" },
80
+ { type: "continuity", regions: "all" },
81
+ { type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
82
+ { type: "label-stability", regions: "all" },
83
+ { type: "preserve-bottom-anchor" },
84
+ { type: "flow", regions: ["text", "following"] },
85
+ ]),
86
+ )
87
+ })
88
+
89
+ test("inserts a completed question between stable rows", async ({ page }, testInfo) => {
90
+ const firstID = "prt_question_01_first"
91
+ const questionID = "prt_question_02_hidden"
92
+ const lastID = "prt_question_03_last"
93
+ const input = { questions: [{ header: "Choice", question: "Keep stable?", options: [] }] }
94
+ const timeline = await setupTimeline(page, {
95
+ messages: [
96
+ userMessage(),
97
+ assistantMessage(
98
+ [
99
+ textPart(firstID, "Before question"),
100
+ toolPart(questionID, "question", "running", input),
101
+ textPart(lastID, "After question"),
102
+ ],
103
+ { completed: false },
104
+ ),
105
+ ],
106
+ cpuRate: 4,
107
+ })
108
+ await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
109
+ const regions = defineVisualRegions({
110
+ first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
111
+ last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
112
+ })
113
+ await startVisualProbe(page, regions)
114
+ await timeline.send(
115
+ partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })),
116
+ 600,
117
+ )
118
+ await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible()
119
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
120
+ await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0))
121
+ })
122
+
123
+ test("replaces thinking with an assistant error without a blank turn", async ({ page }, testInfo) => {
124
+ const assistant = assistantMessage([], { completed: false })
125
+ const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
126
+ await timeline.send(status("busy"), 150)
127
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
128
+ const regions = defineVisualRegions({
129
+ thinking: { selector: '[data-timeline-row="Thinking"]' },
130
+ error: { selector: '[data-timeline-row="Error"]' },
131
+ })
132
+ await startVisualProbe(page, regions)
133
+ await timeline.send(
134
+ messageUpdated({
135
+ ...assistant.info,
136
+ error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } },
137
+ }),
138
+ 500,
139
+ )
140
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
141
+ await expect(page.locator('[data-timeline-row="Error"]')).toContainText("Provider failed visibly")
142
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
143
+ await reportVisualStability(
144
+ testInfo,
145
+ "thinking-error",
146
+ trace,
147
+ visualPlan(regions, [
148
+ { type: "required", regions: ["thinking", "error"] },
149
+ { type: "continuous-any", regions: ["thinking", "error"] },
150
+ { type: "unique", regions: ["thinking", "error"] },
151
+ { type: "opacity", regions: "all" },
152
+ { type: "continuity", regions: "all" },
153
+ { type: "motion", regions: "all" },
154
+ { type: "label-stability", regions: "all" },
155
+ ]),
156
+ )
157
+ })
158
+
159
+ test("updates retry attempts and long provider messages without remounting the retry row", async ({
160
+ page,
161
+ }, testInfo) => {
162
+ const timeline = await setupTimeline(page, {
163
+ messages: [userMessage(), assistantMessage([], { completed: false })],
164
+ cpuRate: 4,
165
+ })
166
+ await timeline.send(status("retry", 1), 120)
167
+ await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
168
+ const regions = defineVisualRegions({
169
+ retry: { selector: '[data-timeline-row="Retry"]' },
170
+ })
171
+ await startVisualProbe(page, regions)
172
+ await timeline.send(
173
+ event("session.status", {
174
+ sessionID: "ses_timeline_stability",
175
+ status: {
176
+ type: "retry",
177
+ attempt: 2,
178
+ message: "A very long provider retry message ".repeat(8),
179
+ next: Date.now() + 10_000,
180
+ },
181
+ }),
182
+ 300,
183
+ )
184
+ await timeline.send(status("retry", 3), 300)
185
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
186
+ await reportVisualStability(
187
+ testInfo,
188
+ "retry-evolution",
189
+ trace,
190
+ visualPlan(
191
+ regions,
192
+ [
193
+ { type: "required", regions: ["retry"] },
194
+ { type: "unique", regions: ["retry"] },
195
+ { type: "stable", regions: ["retry"] },
196
+ { type: "opacity", regions: "all" },
197
+ { type: "continuity", regions: "all" },
198
+ { type: "motion", regions: "all", maxPositionReversals: 0 },
199
+ { type: "label-stability", regions: "all" },
200
+ ],
201
+ { perMarker: true },
202
+ ),
203
+ )
204
+ })
205
+
206
+ test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({
207
+ page,
208
+ }, testInfo) => {
209
+ const removeUserID = "msg_0500_remove_user"
210
+ const removeAssistantID = "msg_0501_remove_assistant"
211
+ const anchorUserID = "msg_2000_anchor_user"
212
+ const timeline = await setupTimeline(page, {
213
+ messages: [
214
+ userMessage(undefined, { id: removeUserID, created: 1690000000000 }),
215
+ assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], {
216
+ id: removeAssistantID,
217
+ parentID: removeUserID,
218
+ created: 1690000001000,
219
+ }),
220
+ userMessage(undefined, { id: anchorUserID, created: 1700000000000 }),
221
+ assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], {
222
+ id: "msg_2001_anchor_assistant",
223
+ parentID: anchorUserID,
224
+ created: 1700000001000,
225
+ }),
226
+ ],
227
+ cpuRate: 4,
228
+ })
229
+ const regions = defineVisualRegions({
230
+ anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` },
231
+ })
232
+ await startVisualProbe(page, regions)
233
+ await timeline.send(
234
+ event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }),
235
+ 200,
236
+ )
237
+ await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500)
238
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
239
+ await reportVisualStability(
240
+ testInfo,
241
+ "historical-turn-remove",
242
+ trace,
243
+ visualPlan(
244
+ regions,
245
+ [
246
+ { type: "required", regions: ["anchor"] },
247
+ { type: "unique", regions: ["anchor"] },
248
+ { type: "stable", regions: ["anchor"] },
249
+ { type: "opacity", regions: "all" },
250
+ { type: "continuity", regions: "all" },
251
+ { type: "motion", regions: "all", maxPositionReversals: 0 },
252
+ { type: "label-stability", regions: "all" },
253
+ ],
254
+ { perMarker: true },
255
+ ),
256
+ )
257
+ })
258
+
259
+ function stablePairPlan(
260
+ regions: Record<"first" | "last", { selector: string; closest?: string }>,
261
+ maxPositionReversals: number,
262
+ ) {
263
+ return visualPlan(regions, [
264
+ { type: "required", regions: ["first", "last"] },
265
+ { type: "unique", regions: ["first", "last"] },
266
+ { type: "stable", regions: ["first", "last"] },
267
+ { type: "opacity", regions: "all" },
268
+ { type: "continuity", regions: "all" },
269
+ { type: "motion", regions: "all", maxPositionReversals },
270
+ { type: "label-stability", regions: "all" },
271
+ ])
272
+ }
packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expectSessionTitle } from "../../utils/waits"
2
+ import { benchmark, expect } from "../benchmark"
3
+ import { measureFirstNavigation } from "./first-navigation-probe"
4
+ import { fixture } from "./session-timeline-stress.fixture"
5
+ import {
6
+ installStressSessionTabs,
7
+ installTimelineSettings,
8
+ mockStressTimeline,
9
+ stressDraftHref,
10
+ stressSessionHref,
11
+ } from "./timeline-test-helpers"
12
+ import { waitForStableTimeline } from "./session-tab-switch-probe"
13
+
14
+ const contentSelector = '[data-message-id], [data-component="prompt-input"]'
15
+ const draftID = "draft_first_navigation"
16
+
17
+ benchmark.describe("performance: first navigation paint", () => {
18
+ benchmark("opens an unvisited session tab without a blank frame", async ({ page, report }) => {
19
+ await setup(page)
20
+ const href = stressSessionHref(fixture.targetID)
21
+ const result = await measureFirstNavigation(page, {
22
+ href,
23
+ destinationPath: href,
24
+ sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
25
+ destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
26
+ contentSelector,
27
+ navigate: async () => {
28
+ await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
29
+ await expectSessionTitle(page, fixture.expected.targetTitle)
30
+ },
31
+ })
32
+ report(result)
33
+ expect(result.summary.blankSamples).toBe(0)
34
+ expect(result.summary.unknownSamples).toBe(0)
35
+ })
36
+
37
+ benchmark("opens the new session page before its lazy module is used", async ({ page, report }) => {
38
+ await setup(page, draftID)
39
+ const href = stressDraftHref(draftID)
40
+ const result = await measureFirstNavigation(page, {
41
+ href,
42
+ destinationPath: href,
43
+ sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
44
+ destinationSelector: '[data-component="prompt-input"]',
45
+ contentSelector,
46
+ navigate: async () => {
47
+ await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
48
+ await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
49
+ },
50
+ })
51
+ report(result)
52
+ expect(result.summary.blankSamples).toBe(0)
53
+ expect(result.summary.unknownSamples).toBe(0)
54
+ })
55
+
56
+ benchmark("opens a child session without a blank frame", async ({ page, report }) => {
57
+ await setup(page)
58
+ const href = stressSessionHref(fixture.childID)
59
+ const result = await measureFirstNavigation(page, {
60
+ href,
61
+ destinationPath: href,
62
+ sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
63
+ destinationSelector: messageSelector(fixture.expected.childMessageIDs.at(-1)!),
64
+ contentSelector,
65
+ navigate: async () => {
66
+ await page.locator(`a[href="${href}"]`, { has: page.locator('[data-component="task-tool-card"]') }).click()
67
+ await expectSessionTitle(page, fixture.expected.childTitle)
68
+ },
69
+ })
70
+ report(result)
71
+ expect(result.summary.blankSamples).toBe(0)
72
+ expect(result.summary.unknownSamples).toBe(0)
73
+ })
74
+ })
75
+
76
+ async function setup(page: Parameters<typeof mockStressTimeline>[0], draft?: string) {
77
+ await mockStressTimeline(page)
78
+ await installTimelineSettings(page)
79
+ await installStressSessionTabs(page, draft ? { draftID: draft } : undefined)
80
+ await page.goto(stressSessionHref(fixture.sourceID))
81
+ await expectSessionTitle(page, fixture.expected.sourceTitle)
82
+ await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
83
+ }
84
+
85
+ function messageSelector(id: string) {
86
+ return `[data-message-id="${id}"]`
87
+ }
packages/app/e2e/performance/timeline/first-navigation-metrics.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type FirstNavigationSample = {
2
+ observedAtMs: number
3
+ source: boolean
4
+ destination: boolean
5
+ content: boolean
6
+ pathname?: string
7
+ center?: string
8
+ }
9
+
10
+ function category(sample: FirstNavigationSample) {
11
+ if (sample.destination && !sample.source) return "destination"
12
+ if (sample.source && !sample.destination) return "source"
13
+ if (!sample.content) return "blank"
14
+ return "unknown"
15
+ }
16
+
17
+ export function summarizeFirstNavigation(samples: FirstNavigationSample[]) {
18
+ const categories = samples.map(category)
19
+ const stable = categories.findIndex(
20
+ (value, index) =>
21
+ value === "destination" && categories[index + 1] === "destination" && categories[index + 2] === "destination",
22
+ )
23
+ return {
24
+ samples: samples.length,
25
+ firstDestinationObservedMs: samples[categories.indexOf("destination")]?.observedAtMs ?? null,
26
+ stableDestinationObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
27
+ sourceSamples: categories.filter((value) => value === "source").length,
28
+ blankSamples: categories.filter((value) => value === "blank").length,
29
+ unknownSamples: categories.filter((value) => value === "unknown").length,
30
+ destinationSamples: categories.filter((value) => value === "destination").length,
31
+ }
32
+ }
packages/app/e2e/performance/timeline/first-navigation-probe.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page } from "@playwright/test"
2
+ import { summarizeFirstNavigation, type FirstNavigationSample } from "./first-navigation-metrics"
3
+
4
+ type FirstNavigationProbe = {
5
+ samples: FirstNavigationSample[]
6
+ stop: () => void
7
+ }
8
+
9
+ export async function measureFirstNavigation(
10
+ page: Page,
11
+ input: {
12
+ href: string
13
+ destinationPath: string
14
+ sourceSelector: string
15
+ destinationSelector: string
16
+ contentSelector: string
17
+ navigate: () => Promise<void>
18
+ },
19
+ ) {
20
+ await page.evaluate(
21
+ ({ href, destinationPath, sourceSelector, destinationSelector, contentSelector }) => {
22
+ const samples: FirstNavigationSample[] = []
23
+ let started: number | undefined
24
+ let running = true
25
+ const visible = (selector: string) =>
26
+ [...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
27
+ const rect = element.getBoundingClientRect()
28
+ const style = getComputedStyle(element)
29
+ return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
30
+ })
31
+ const sample = () => {
32
+ if (!running || started === undefined) return
33
+ requestAnimationFrame(() => {
34
+ setTimeout(() => {
35
+ if (!running || started === undefined) return
36
+ samples.push({
37
+ observedAtMs: performance.now() - started,
38
+ source: visible(sourceSelector),
39
+ destination: `${location.pathname}${location.search}` === destinationPath && visible(destinationSelector),
40
+ content: visible(contentSelector),
41
+ pathname: `${location.pathname}${location.search}`,
42
+ center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
43
+ })
44
+ sample()
45
+ }, 0)
46
+ })
47
+ }
48
+ document.addEventListener(
49
+ "click",
50
+ (event) => {
51
+ const link = event.target instanceof Element ? event.target.closest("a") : undefined
52
+ if (link?.getAttribute("href") !== href) return
53
+ started = performance.now()
54
+ sample()
55
+ },
56
+ { capture: true, once: true },
57
+ )
58
+ ;(window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe = {
59
+ samples,
60
+ stop: () => {
61
+ running = false
62
+ },
63
+ }
64
+ },
65
+ {
66
+ href: input.href,
67
+ destinationPath: input.destinationPath,
68
+ sourceSelector: input.sourceSelector,
69
+ destinationSelector: input.destinationSelector,
70
+ contentSelector: input.contentSelector,
71
+ },
72
+ )
73
+ await input.navigate()
74
+ await page.waitForFunction(() => {
75
+ const samples = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe
76
+ ?.samples
77
+ if (!samples) return false
78
+ return samples.length >= 3 && samples.slice(-3).every((sample) => sample.destination && !sample.source)
79
+ })
80
+ const samples = await page.evaluate(() => {
81
+ const probe = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe!
82
+ probe.stop()
83
+ return probe.samples
84
+ })
85
+ return { summary: summarizeFirstNavigation(samples), samples }
86
+ }
packages/app/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { benchmark, expect } from "../benchmark"
2
+ import { expectSessionTitle } from "../../utils/waits"
3
+ import { measureNavigationMilestones } from "./navigation-milestones"
4
+ import { fixture } from "./session-timeline-stress.fixture"
5
+ import {
6
+ installStressSessionTabs,
7
+ installTimelineSettings,
8
+ mockStressTimeline,
9
+ stressSessionHref,
10
+ } from "./timeline-test-helpers"
11
+ import { waitForStableTimeline } from "./session-tab-switch-probe"
12
+
13
+ const homeRow = '[data-component="home-session-row"]'
14
+ const homeShell = '[data-component="home-session-search"]'
15
+
16
+ benchmark.describe("performance: home and tab navigation", () => {
17
+ benchmark("opens a home session and paints its titlebar tab", async ({ page, report }) => {
18
+ await setup(page, [])
19
+ await page.goto("/")
20
+ const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
21
+ await expect(row).toBeVisible()
22
+ const href = stressSessionHref(fixture.targetID)
23
+ const result = await measureNavigationMilestones(page, {
24
+ triggerSelector: homeRow,
25
+ milestones: {
26
+ content: { selector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!) },
27
+ tab: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]` },
28
+ },
29
+ navigate: async () => {
30
+ await row.click()
31
+ await expectSessionTitle(page, fixture.expected.targetTitle)
32
+ },
33
+ })
34
+ report(result)
35
+ await expect(page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)).toContainText(
36
+ fixture.expected.targetTitle,
37
+ )
38
+ })
39
+
40
+ benchmark("stages the review body after cold session content", async ({ page, report }) => {
41
+ await setup(page, [])
42
+ await page.goto("/")
43
+ const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
44
+ await expect(row).toBeVisible()
45
+ const result = await page.evaluate(
46
+ ({ rowSelector, title, contentSelector }) =>
47
+ new Promise<{ contentBeforeReview: boolean; samples: number }>((resolve) => {
48
+ let samples = 0
49
+ const sample = () => {
50
+ samples++
51
+ const content = !!document.querySelector(contentSelector)
52
+ const review = !!document.querySelector('[data-component="session-review"]')
53
+ if (content && !review) {
54
+ resolve({ contentBeforeReview: true, samples })
55
+ return
56
+ }
57
+ if (content && review) {
58
+ resolve({ contentBeforeReview: false, samples })
59
+ return
60
+ }
61
+ requestAnimationFrame(sample)
62
+ }
63
+ const target = [...document.querySelectorAll<HTMLElement>(rowSelector)].find((item) =>
64
+ item.textContent?.includes(title),
65
+ )
66
+ if (!target) throw new Error(`Home session row not found: ${title}`)
67
+ target.click()
68
+ requestAnimationFrame(sample)
69
+ }),
70
+ {
71
+ rowSelector: homeRow,
72
+ title: fixture.expected.targetTitle,
73
+ contentSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
74
+ },
75
+ )
76
+ report(result)
77
+ expect(result.contentBeforeReview).toBe(true)
78
+ await expect(page.locator('[data-component="session-review"]')).toBeVisible()
79
+ })
80
+
81
+ benchmark("closes the only session tab and paints home", async ({ page, report }) => {
82
+ await setup(page, [fixture.sourceID])
83
+ const href = stressSessionHref(fixture.sourceID)
84
+ await page.goto(href)
85
+ await expectSessionTitle(page, fixture.expected.sourceTitle)
86
+ await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
87
+ const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
88
+ const close = tab.locator("..").locator('[data-component="icon-button-v2"]')
89
+ await expect(close).toBeVisible()
90
+ const result = await measureNavigationMilestones(page, {
91
+ triggerSelector: '[data-slot="titlebar-tabs"] [data-component="icon-button-v2"]',
92
+ milestones: {
93
+ home: { selector: homeShell },
94
+ row: { selector: homeRow },
95
+ tabRemoved: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]`, visible: false },
96
+ },
97
+ navigate: async () => {
98
+ await close.click()
99
+ await expect(page).toHaveURL("/")
100
+ },
101
+ })
102
+ report(result)
103
+ })
104
+ })
105
+
106
+ async function setup(page: Parameters<typeof mockStressTimeline>[0], sessionIDs: string[]) {
107
+ await mockStressTimeline(page)
108
+ await installTimelineSettings(page)
109
+ await installStressSessionTabs(page, { sessionIDs })
110
+ }
111
+
112
+ function messageSelector(id: string) {
113
+ return `[data-message-id="${id}"]`
114
+ }
packages/app/e2e/performance/timeline/navigation-milestones.ts ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page } from "@playwright/test"
2
+
3
+ export type NavigationMilestoneSample = {
4
+ observedAtMs: number
5
+ milestones: Record<string, boolean>
6
+ }
7
+
8
+ export function summarizeNavigationMilestones(samples: NavigationMilestoneSample[]) {
9
+ const names = Object.keys(samples[0]?.milestones ?? {})
10
+ const summarize = (matches: (sample: NavigationMilestoneSample) => boolean) => {
11
+ const first = samples.find(matches)
12
+ const stable = samples.findIndex(
13
+ (sample, index) =>
14
+ index + 2 < samples.length && matches(sample) && matches(samples[index + 1]!) && matches(samples[index + 2]!),
15
+ )
16
+ return {
17
+ firstObservedMs: first?.observedAtMs ?? null,
18
+ stableObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
19
+ }
20
+ }
21
+ return {
22
+ samples: samples.length,
23
+ milestones: Object.fromEntries(
24
+ names.map((name) => [name, summarize((sample) => sample.milestones[name] === true)]),
25
+ ),
26
+ all: summarize((sample) => names.every((name) => sample.milestones[name] === true)),
27
+ }
28
+ }
29
+
30
+ type NavigationMilestoneProbe = {
31
+ samples: NavigationMilestoneSample[]
32
+ stop: () => void
33
+ }
34
+
35
+ export async function measureNavigationMilestones(
36
+ page: Page,
37
+ input: {
38
+ triggerSelector: string
39
+ milestones: Record<string, { selector: string; visible?: boolean }>
40
+ navigate: () => Promise<void>
41
+ },
42
+ ) {
43
+ await page.evaluate(
44
+ ({ triggerSelector, milestones }) => {
45
+ const samples: NavigationMilestoneSample[] = []
46
+ const streaks = new Map<string, number>()
47
+ const marked = new Set<string>()
48
+ let started: number | undefined
49
+ let running = true
50
+ const visible = (selector: string) =>
51
+ [...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
52
+ const rect = element.getBoundingClientRect()
53
+ const style = getComputedStyle(element)
54
+ return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
55
+ })
56
+ const sample = () => {
57
+ if (!running || started === undefined) return
58
+ requestAnimationFrame(() => {
59
+ setTimeout(() => {
60
+ if (!running || started === undefined) return
61
+ const current = Object.fromEntries(
62
+ Object.entries(milestones).map(([name, milestone]) => [
63
+ name,
64
+ milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
65
+ ]),
66
+ )
67
+ samples.push({
68
+ observedAtMs: performance.now() - started,
69
+ milestones: current,
70
+ })
71
+ Object.entries(current).forEach(([name, value]) => {
72
+ if (!value) {
73
+ streaks.set(name, 0)
74
+ return
75
+ }
76
+ if (!marked.has(`${name}.first`)) {
77
+ performance.mark(`opencode.navigation.${name}.first`)
78
+ marked.add(`${name}.first`)
79
+ }
80
+ const streak = (streaks.get(name) ?? 0) + 1
81
+ streaks.set(name, streak)
82
+ if (streak === 3) performance.mark(`opencode.navigation.${name}.stable`)
83
+ })
84
+ const all = Object.values(current).every(Boolean)
85
+ const allStreak = all ? (streaks.get("all") ?? 0) + 1 : 0
86
+ streaks.set("all", allStreak)
87
+ if (all && !marked.has("all.first")) {
88
+ performance.mark("opencode.navigation.all.first")
89
+ marked.add("all.first")
90
+ }
91
+ if (allStreak === 3) performance.mark("opencode.navigation.all.stable")
92
+ sample()
93
+ }, 0)
94
+ })
95
+ }
96
+ document.addEventListener(
97
+ "click",
98
+ (event) => {
99
+ if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
100
+ started = performance.now()
101
+ performance.mark("opencode.navigation.click")
102
+ sample()
103
+ },
104
+ { capture: true, once: true },
105
+ )
106
+ ;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
107
+ samples,
108
+ stop: () => {
109
+ running = false
110
+ },
111
+ }
112
+ },
113
+ { triggerSelector: input.triggerSelector, milestones: input.milestones },
114
+ )
115
+ await input.navigate()
116
+ await page.waitForFunction(() => {
117
+ const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
118
+ ?.samples
119
+ if (!samples || samples.length < 3) return false
120
+ return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
121
+ })
122
+ const samples = await page.evaluate(() => {
123
+ const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
124
+ probe.stop()
125
+ return probe.samples
126
+ })
127
+ return { summary: summarizeNavigationMilestones(samples), samples }
128
+ }
packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page } from "@playwright/test"
2
+ import { benchmark, expect } from "../benchmark"
3
+ import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
4
+
5
+ const changedLinesPerFile = 100
6
+ const linesPerSide = changedLinesPerFile / 2
7
+ const fileCounts = [1, 10, 100, 1_000, 10_000]
8
+ const filesPerDirectory = 100
9
+ const readyFrames = 3
10
+ const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000)
11
+
12
+ type ReviewPaneScalingSample = {
13
+ observedAtMs: number
14
+ logicalRows: number
15
+ treeRows: number
16
+ fileRows: number
17
+ diffLines: number
18
+ header: string
19
+ ready: boolean
20
+ }
21
+
22
+ type ReviewPaneScalingProbe = {
23
+ startedAt?: number
24
+ firstTreeRowMs?: number
25
+ logicalTreeReadyMs?: number
26
+ firstDiffRenderMs?: number
27
+ stableReadyMs?: number
28
+ samples: ReviewPaneScalingSample[]
29
+ frameTimesMs: number[]
30
+ longTasks: { startTime: number; duration: number }[]
31
+ stop: () => void
32
+ }
33
+
34
+ benchmark.describe("performance: review pane scaling", () => {
35
+ for (const fileCount of fileCounts) {
36
+ const changedLines = fileCount * changedLinesPerFile
37
+
38
+ benchmark(
39
+ `${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`,
40
+ async ({ page, report }) => {
41
+ benchmark.setTimeout(1_200_000)
42
+ await page.emulateMedia({ reducedMotion: "reduce" })
43
+
44
+ const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY)
45
+ if (Number.isNaN(patchByteLimit) || patchByteLimit < 0)
46
+ throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`)
47
+ const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit))
48
+ await setupTimelineBenchmark(page, {
49
+ historyTurns: 0,
50
+ eventBatch: 1,
51
+ newLayoutDesigns: true,
52
+ })
53
+ await page.route("**/vcs/diff**", (route) =>
54
+ route.fulfill({
55
+ status: 200,
56
+ contentType: "application/json",
57
+ headers: { "access-control-allow-origin": "*" },
58
+ body: responseBody,
59
+ }),
60
+ )
61
+
62
+ const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory)
63
+ const metrics = await measureReviewPaneLoad(page, {
64
+ expectedFile: reviewFile(0),
65
+ expectedRows,
66
+ })
67
+ const search = await measureBroadReviewSearch(page, fileCount)
68
+
69
+ expect(metrics.logicalRows).toBe(expectedRows)
70
+ expect(metrics.fileRows).toBeGreaterThan(0)
71
+ expect(metrics.treeRows).toBeGreaterThan(0)
72
+ expect(metrics.diffLines).toBeGreaterThan(0)
73
+ expect(search.logicalRows).toBe(fileCount)
74
+ expect(search.renderedRows).toBeGreaterThan(0)
75
+ report(
76
+ { ...metrics, search },
77
+ {
78
+ fileCount,
79
+ changedLinesPerFile,
80
+ changedLines,
81
+ additions: changedLines / 2,
82
+ deletions: changedLines / 2,
83
+ patchLines: changedLines,
84
+ patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null,
85
+ payloadBytes: new TextEncoder().encode(responseBody).byteLength,
86
+ expectedRows,
87
+ },
88
+ )
89
+ },
90
+ )
91
+ }
92
+ })
93
+
94
+ async function measureBroadReviewSearch(page: Page, expectedRows: number) {
95
+ const filter = page.getByRole("searchbox", { name: "Filter files" })
96
+ await filter.evaluate((element) => {
97
+ element.addEventListener(
98
+ "input",
99
+ () => {
100
+ ;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now()
101
+ },
102
+ { once: true, capture: true },
103
+ )
104
+ })
105
+ await filter.fill("file-")
106
+
107
+ return page.evaluate((expectedRows) => {
108
+ const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt!
109
+ return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => {
110
+ let previous = -1
111
+ let streak = 0
112
+ const sample = () => {
113
+ const tree = document.querySelector<HTMLElement>('#review-panel [data-component="file-tree-v2"]')
114
+ const rows = [...document.querySelectorAll<HTMLElement>('#review-panel [data-slot="file-tree-v2-row"]')]
115
+ const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
116
+ const ready =
117
+ logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-"))
118
+ streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0
119
+ previous = rows.length
120
+ if (streak >= 3) {
121
+ resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length })
122
+ return
123
+ }
124
+ requestAnimationFrame(sample)
125
+ }
126
+ requestAnimationFrame(sample)
127
+ })
128
+ }, expectedRows)
129
+ }
130
+
131
+ function createScalingDiffs(fileCount: number, patchByteLimit: number) {
132
+ const changes = Array.from({ length: linesPerSide }, (_, index) => {
133
+ const line = String(index).padStart(3, "0")
134
+ return `-export const value_${line} = "before"\n+export const value_${line} = "after"`
135
+ }).join("\n")
136
+ let patchBytes = 0
137
+ let capped = false
138
+
139
+ return Array.from({ length: fileCount }, (_, index) => {
140
+ const file = reviewFile(index)
141
+ const fullPatch = [
142
+ `diff --git a/${file} b/${file}`,
143
+ `--- a/${file}`,
144
+ `+++ b/${file}`,
145
+ `@@ -1,${linesPerSide} +1,${linesPerSide} @@`,
146
+ changes,
147
+ ].join("\n")
148
+ if (index === 0 && fullPatch.length > patchByteLimit)
149
+ throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`)
150
+ const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file)
151
+ if (patch === fullPatch) patchBytes += fullPatch.length
152
+ else capped = true
153
+ return {
154
+ file,
155
+ patch,
156
+ additions: linesPerSide,
157
+ deletions: linesPerSide,
158
+ status: "modified" as const,
159
+ }
160
+ })
161
+ }
162
+
163
+ function emptyReviewPatch(file: string) {
164
+ return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n")
165
+ }
166
+
167
+ function reviewFile(index: number) {
168
+ return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts`
169
+ }
170
+
171
+ async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) {
172
+ const toggle = page.getByRole("button", { name: "Toggle review" })
173
+ await expect(toggle).toBeVisible()
174
+ await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", ""))
175
+ await installReviewPaneScalingProbe(page, input)
176
+ await toggle.click()
177
+ await page.waitForFunction(
178
+ () =>
179
+ (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe
180
+ ?.stableReadyMs !== undefined,
181
+ undefined,
182
+ { timeout: completionTimeoutMs },
183
+ )
184
+
185
+ return page.evaluate(() => {
186
+ const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe!
187
+ probe.stop()
188
+ const startedAt = probe.startedAt!
189
+ const final = probe.samples.at(-1)!
190
+ const resources = performance
191
+ .getEntriesByType("resource")
192
+ .filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[]
193
+ const resource = resources.at(-1)
194
+ const longTasks = probe.longTasks.filter(
195
+ (entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!,
196
+ )
197
+ const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0))
198
+
199
+ return {
200
+ firstTreeRowMs: probe.firstTreeRowMs ?? null,
201
+ logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null,
202
+ firstDiffRenderMs: probe.firstDiffRenderMs ?? null,
203
+ stableReadyMs: probe.stableReadyMs ?? null,
204
+ responseStartMs: resource ? resource.responseStart - startedAt : null,
205
+ responseEndMs: resource ? resource.responseEnd - startedAt : null,
206
+ responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null,
207
+ treeRows: final.treeRows,
208
+ logicalRows: final.logicalRows,
209
+ fileRows: final.fileRows,
210
+ diffLines: final.diffLines,
211
+ samples: probe.samples.length,
212
+ maxFrameGapMs: Math.max(0, ...frameGaps),
213
+ longTaskCount: longTasks.length,
214
+ longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
215
+ maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)),
216
+ }
217
+ })
218
+ }
219
+
220
+ async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) {
221
+ await page.evaluate(
222
+ ({ expectedFile, expectedRows, stableFrames }) => {
223
+ let running = true
224
+ let readyStreak = 0
225
+ const basename = expectedFile.split("/").at(-1)!
226
+ const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask")
227
+ ? new PerformanceObserver((list) => {
228
+ probe.longTasks.push(
229
+ ...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })),
230
+ )
231
+ })
232
+ : undefined
233
+ const probe: ReviewPaneScalingProbe = {
234
+ samples: [],
235
+ frameTimesMs: [],
236
+ longTasks: [],
237
+ stop: () => {
238
+ running = false
239
+ longTaskObserver?.disconnect()
240
+ },
241
+ }
242
+
243
+ const sample = (time: number) => {
244
+ if (!running || probe.startedAt === undefined) return
245
+ const panel = document.querySelector<HTMLElement>("#review-panel")
246
+ const tree = panel?.querySelector<HTMLElement>('[data-component="file-tree-v2"]')
247
+ const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? []
248
+ const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? []
249
+ const header =
250
+ panel?.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? ""
251
+ const viewers = panel
252
+ ? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')]
253
+ : []
254
+ const diffLines = viewers.reduce(
255
+ (sum, viewer) =>
256
+ sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0),
257
+ 0,
258
+ )
259
+ const observedAtMs = time - probe.startedAt
260
+ const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
261
+ const ready =
262
+ logicalRows === expectedRows &&
263
+ fileRows.length > 0 &&
264
+ header.includes(basename) &&
265
+ viewers.length === 1 &&
266
+ diffLines > 0
267
+ const previous = probe.samples.at(-1)
268
+ const stable =
269
+ ready &&
270
+ previous?.ready === true &&
271
+ previous.logicalRows === logicalRows &&
272
+ previous.treeRows === rows.length &&
273
+ previous.fileRows === fileRows.length &&
274
+ previous.diffLines === diffLines &&
275
+ previous.header === header
276
+
277
+ probe.frameTimesMs.push(observedAtMs)
278
+ probe.samples.push({
279
+ observedAtMs,
280
+ logicalRows,
281
+ treeRows: rows.length,
282
+ fileRows: fileRows.length,
283
+ diffLines,
284
+ header,
285
+ ready,
286
+ })
287
+ if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs
288
+ if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows)
289
+ probe.logicalTreeReadyMs = observedAtMs
290
+ if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs
291
+ readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1
292
+ if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs
293
+ if (probe.stableReadyMs === undefined) requestAnimationFrame(sample)
294
+ }
295
+
296
+ longTaskObserver?.observe({ type: "longtask", buffered: true })
297
+ document.addEventListener(
298
+ "click",
299
+ (event) => {
300
+ const toggle = event.target instanceof Element ? event.target.closest("button") : undefined
301
+ if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return
302
+ probe.startedAt = performance.now()
303
+ performance.mark("opencode.review-pane-scaling.click")
304
+ requestAnimationFrame(sample)
305
+ },
306
+ { capture: true, once: true },
307
+ )
308
+ ;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe
309
+ },
310
+ { ...input, stableFrames: readyFrames },
311
+ )
312
+ }
packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page } from "@playwright/test"
2
+ import { expectSessionTitle } from "../../utils/waits"
3
+ import { mockOpenCodeServer } from "../../utils/mock-server"
4
+ import { benchmark, expect, withBenchmarkPage } from "../benchmark"
5
+ import { fixture } from "./session-timeline-stress.fixture"
6
+ import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
7
+ import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
8
+
9
+ type ParentHydrationBenchmarkMode = "natural" | "candidate"
10
+
11
+ const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
12
+ if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
13
+ const userID = "msg_parent_hydration_user"
14
+ const user = {
15
+ ...fixture.messages[fixture.targetID][0]!,
16
+ info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
17
+ parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
18
+ ...part,
19
+ id: `prt_parent_hydration_user_${index}`,
20
+ messageID: userID,
21
+ })),
22
+ }
23
+ const assistantSeed = fixture.messages[fixture.targetID][3]!
24
+ const assistants = Array.from({ length: 14 }, (_, index) => {
25
+ const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
26
+ return {
27
+ ...assistantSeed,
28
+ info: {
29
+ ...assistantSeed.info,
30
+ id: messageID,
31
+ parentID: userID,
32
+ time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
33
+ },
34
+ parts: assistantSeed.parts.map((part, partIndex) => ({
35
+ ...part,
36
+ id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
37
+ messageID,
38
+ })),
39
+ }
40
+ })
41
+ const messages = [user, ...assistants]
42
+ const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
43
+ const lastID = userID
44
+ const lastAssistant = assistants.at(-1)!
45
+ const lastPart = lastAssistant.parts.at(-1)!
46
+ const lastPartID =
47
+ lastPart.type === "tool"
48
+ ? lastPart.id
49
+ : `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
50
+
51
+ benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
52
+ benchmark.setTimeout(180_000)
53
+ const results = [] as Awaited<ReturnType<typeof trial>>[]
54
+ for (let run = 0; run < 5; run++) {
55
+ results.push(
56
+ await withBenchmarkPage(
57
+ browser,
58
+ `session-parent-hydration-${mode}-${run}`,
59
+ (page) => trial(page, mode),
60
+ testInfo,
61
+ ),
62
+ )
63
+ }
64
+ const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
65
+ report(
66
+ {
67
+ results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
68
+ summary: {
69
+ firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
70
+ blankSamples: results.map((result) => result.metrics.blankSamples),
71
+ requestCounts: {
72
+ list: results.map((result) => result.requestCounts.list),
73
+ parent: results.map((result) => result.requestCounts.parent),
74
+ },
75
+ historyGateCount: results.map((result) => result.historyGateCount),
76
+ },
77
+ },
78
+ { mode },
79
+ )
80
+ })
81
+
82
+ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
83
+ const requests: { type: "list" | "parent"; before?: string }[] = []
84
+ const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
85
+ let historyGates = 0
86
+ await mockOpenCodeServer(page, {
87
+ sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
88
+ provider: fixture.provider,
89
+ directory: fixture.directory,
90
+ project: fixture.project,
91
+ messageDelay: 50,
92
+ onMessages: (request) => {
93
+ if (request.sessionID === fixture.targetID && request.phase === "start")
94
+ requests.push({ type: "list", before: request.before })
95
+ },
96
+ beforeMessagesResponse: (request) => {
97
+ if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
98
+ historyGates++
99
+ return history!.promise
100
+ },
101
+ onMessage: (request) => {
102
+ if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
103
+ },
104
+ message: (sessionID, messageID) => {
105
+ if (sessionID !== fixture.targetID || messageID !== userID) return
106
+ return user
107
+ },
108
+ pageMessages: (sessionID, limit, before) => {
109
+ const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
110
+ const end = before ? items.findIndex((message) => message.info.id === before) : items.length
111
+ const start = Math.max(0, end - limit)
112
+ return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
113
+ },
114
+ })
115
+ await page.route(`**/session/${fixture.targetID}`, (route) => {
116
+ const current = new URL(route.request().url()).pathname.startsWith("/api/")
117
+ return route.fulfill({
118
+ status: 200,
119
+ contentType: "application/json",
120
+ body: JSON.stringify(
121
+ current
122
+ ? {
123
+ data: {
124
+ ...target,
125
+ cost: 0,
126
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
127
+ location: { directory: target.directory },
128
+ },
129
+ }
130
+ : target,
131
+ ),
132
+ })
133
+ })
134
+ await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
135
+ await page.goto(stressSessionHref(fixture.sourceID))
136
+ await expectSessionTitle(page, fixture.expected.sourceTitle)
137
+ await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
138
+
139
+ const href = stressSessionHref(fixture.targetID)
140
+ await page.evaluate(
141
+ ({ href, title }) => {
142
+ const link = document.createElement("a")
143
+ link.id = "parent-hydration-target"
144
+ link.href = href
145
+ link.textContent = title
146
+ document.body.append(link)
147
+ },
148
+ { href, title: target.title },
149
+ )
150
+ const metrics = await measureSessionSwitch(page, {
151
+ destinationIDs: messages.map((message) => message.info.id),
152
+ sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
153
+ lastID,
154
+ requiredPartID: lastPartID,
155
+ requireBottomAnchor: false,
156
+ href,
157
+ switch: async () => {
158
+ await page.locator("#parent-hydration-target").click()
159
+ await expectSessionTitle(page, target.title)
160
+ },
161
+ }).finally(() => history?.resolve())
162
+ expect(metrics.firstCorrectObservedMs).not.toBeNull()
163
+ const requestCounts = {
164
+ list: requests.filter((request) => request.type === "list").length,
165
+ parent: requests.filter((request) => request.type === "parent").length,
166
+ }
167
+ if (mode === "candidate") {
168
+ expect(requestCounts.parent).toBe(0)
169
+ expect(historyGates).toBe(0)
170
+ }
171
+ return { metrics, requestCounts, historyGateCount: historyGates }
172
+ }
packages/app/e2e/performance/timeline/session-tab-flash.spec.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { benchmark, expect } from "../benchmark"
2
+ import { expectSessionTitle } from "../../utils/waits"
3
+ import { fixture } from "./session-timeline-stress.fixture"
4
+ import {
5
+ collectCachedRepaintTrace,
6
+ compressCachedRepaintTrace,
7
+ installCachedRepaintProbe,
8
+ waitForCachedRepaintWindow,
9
+ } from "./session-tab-repaint-probe"
10
+ import { waitForStableTimeline } from "./session-tab-switch-probe"
11
+ import {
12
+ installStressSessionTabs,
13
+ installTimelineSettings,
14
+ mockStressTimeline,
15
+ stressSessionHref,
16
+ } from "./timeline-test-helpers"
17
+
18
+ benchmark("samples cached session repaint after the click", async ({ page, report }) => {
19
+ benchmark.setTimeout(120_000)
20
+ await mockStressTimeline(page)
21
+ await installStressSessionTabs(page)
22
+ await installTimelineSettings(page)
23
+ await page.goto(stressSessionHref(fixture.targetID))
24
+ await expectSessionTitle(page, fixture.expected.targetTitle)
25
+ await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
26
+ await page
27
+ .locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`)
28
+ .first()
29
+ .click()
30
+ await expectSessionTitle(page, fixture.expected.sourceTitle)
31
+ await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
32
+
33
+ await installCachedRepaintProbe(page, {
34
+ targetHref: stressSessionHref(fixture.targetID),
35
+ destination: fixture.messages[fixture.targetID].map((message) => message.info.id),
36
+ source: fixture.messages[fixture.sourceID].map((message) => message.info.id),
37
+ last: fixture.expected.targetMessageIDs.at(-1)!,
38
+ windowMs: 1_000,
39
+ })
40
+
41
+ await page
42
+ .locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`)
43
+ .first()
44
+ .click()
45
+ await Promise.all([expectSessionTitle(page, fixture.expected.targetTitle), waitForCachedRepaintWindow(page, 1_000)])
46
+ const result = await collectCachedRepaintTrace(page)
47
+ report(compressCachedRepaintTrace(result))
48
+ expect(result.samples.length).toBeGreaterThan(0)
49
+ })
50
+
51
+ benchmark("prefetches every open session tab", async ({ page, report }) => {
52
+ const prefetched = new Set<string>()
53
+ await mockStressTimeline(page, {
54
+ onMessages: (input) => {
55
+ if (!input.before && input.phase === "start") prefetched.add(input.sessionID)
56
+ },
57
+ })
58
+ await installStressSessionTabs(page, {
59
+ sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID],
60
+ })
61
+ await installTimelineSettings(page)
62
+ await page.goto(stressSessionHref(fixture.sourceID))
63
+ await expectSessionTitle(page, fixture.expected.sourceTitle)
64
+
65
+ await expect.poll(() => prefetched.has(fixture.childID)).toBe(true)
66
+ report({ prefetched: [...prefetched] })
67
+ })
packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page } from "@playwright/test"
2
+
3
+ type CachedRepaintTrace = {
4
+ timeOriginEpochMs: number
5
+ startedAtPerformanceMs: number
6
+ samples: {
7
+ observedAtMs: number
8
+ root: number | undefined
9
+ scrollTop: number
10
+ scrollHeight: number
11
+ bottomErrorPx: number | undefined
12
+ last: boolean
13
+ rows: { key: string | undefined; node: number; top: number; bottom: number }[]
14
+ mounted: number
15
+ center: string | undefined
16
+ destination: string[]
17
+ source: string[]
18
+ }[]
19
+ mutations: { observedAtMs: number; changed: { type: string; node: number }[] }[]
20
+ shifts: { occurredAtMs: number; value: number }[]
21
+ windowMs: number
22
+ running: boolean
23
+ stop: () => void
24
+ }
25
+
26
+ export async function installCachedRepaintProbe(
27
+ page: Page,
28
+ input: { targetHref: string; destination: string[]; source: string[]; last: string; windowMs: number },
29
+ ) {
30
+ await page.evaluate(({ targetHref, destination, source, last, windowMs }) => {
31
+ const destinationIDs = new Set(destination)
32
+ const sourceIDs = new Set(source)
33
+ const nodeIDs = new WeakMap<Node, number>()
34
+ let nextNodeID = 1
35
+ const id = (node: Node) => {
36
+ const current = nodeIDs.get(node)
37
+ if (current) return current
38
+ nodeIDs.set(node, nextNodeID)
39
+ return nextNodeID++
40
+ }
41
+ const state: CachedRepaintTrace = {
42
+ timeOriginEpochMs: performance.timeOrigin,
43
+ startedAtPerformanceMs: 0,
44
+ samples: [],
45
+ mutations: [],
46
+ shifts: [],
47
+ windowMs,
48
+ running: false,
49
+ stop: () => {},
50
+ }
51
+ const recordShifts = (entries: PerformanceEntry[]) => {
52
+ if (!state.running) return
53
+ state.shifts.push(
54
+ ...entries
55
+ .map((entry) => {
56
+ if (
57
+ entry.startTime < state.startedAtPerformanceMs ||
58
+ entry.startTime > state.startedAtPerformanceMs + state.windowMs
59
+ )
60
+ return
61
+ return {
62
+ occurredAtMs: entry.startTime - state.startedAtPerformanceMs,
63
+ value: (entry as PerformanceEntry & { value: number }).value,
64
+ }
65
+ })
66
+ .filter((entry): entry is { occurredAtMs: number; value: number } => entry !== undefined),
67
+ )
68
+ }
69
+ const shiftObserver = new PerformanceObserver((entries) => recordShifts(entries.getEntries()))
70
+ shiftObserver.observe({ type: "layout-shift" })
71
+ const recordMutations = (entries: MutationRecord[]) => {
72
+ if (!state.running) return
73
+ const observedAtMs = performance.now() - state.startedAtPerformanceMs
74
+ if (observedAtMs > state.windowMs) return
75
+ const changed = entries.flatMap((entry) => [
76
+ ...[...entry.addedNodes].map((node) => ({ type: "add", node: id(node) })),
77
+ ...[...entry.removedNodes].map((node) => ({ type: "remove", node: id(node) })),
78
+ ])
79
+ if (changed.length) state.mutations.push({ observedAtMs, changed })
80
+ }
81
+ const mutationObserver = new MutationObserver(recordMutations)
82
+ mutationObserver.observe(document.documentElement, { childList: true, subtree: true })
83
+ state.stop = () => {
84
+ recordShifts(shiftObserver.takeRecords())
85
+ recordMutations(mutationObserver.takeRecords())
86
+ state.running = false
87
+ shiftObserver.disconnect()
88
+ mutationObserver.disconnect()
89
+ }
90
+ const sample = () => {
91
+ if (!state.running) return
92
+ setTimeout(() => {
93
+ if (!state.running) return
94
+ const observedAtMs = performance.now() - state.startedAtPerformanceMs
95
+ if (observedAtMs > state.windowMs) return
96
+ const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
97
+ element.querySelector("[data-timeline-row]"),
98
+ )
99
+ if (root) {
100
+ const view = root.getBoundingClientRect()
101
+ const rows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
102
+ .map((element) => ({
103
+ key: element.dataset.timelineKey,
104
+ node: id(element),
105
+ rect: element.getBoundingClientRect(),
106
+ }))
107
+ .filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
108
+ .map((item) => ({
109
+ key: item.key,
110
+ node: item.node,
111
+ top: item.rect.top - view.top,
112
+ bottom: item.rect.bottom - view.top,
113
+ }))
114
+ const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
115
+ .filter((element) => {
116
+ const rect = element.getBoundingClientRect()
117
+ return rect.bottom > view.top && rect.top < view.bottom
118
+ })
119
+ .map((element) => element.dataset.messageId!)
120
+ const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
121
+ state.samples.push({
122
+ observedAtMs,
123
+ root: id(root),
124
+ scrollTop: root.scrollTop,
125
+ scrollHeight: root.scrollHeight,
126
+ bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
127
+ last: messages.includes(last),
128
+ rows,
129
+ mounted: root.querySelectorAll("[data-timeline-key]").length,
130
+ center: document
131
+ .elementFromPoint(view.left + view.width / 2, view.top + view.height / 2)
132
+ ?.textContent?.slice(0, 80),
133
+ destination: messages.filter((messageID) => destinationIDs.has(messageID)),
134
+ source: messages.filter((messageID) => sourceIDs.has(messageID)),
135
+ })
136
+ } else {
137
+ state.samples.push({
138
+ observedAtMs,
139
+ root: undefined,
140
+ scrollTop: 0,
141
+ scrollHeight: 0,
142
+ bottomErrorPx: undefined,
143
+ last: false,
144
+ rows: [],
145
+ mounted: 0,
146
+ center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
147
+ destination: [],
148
+ source: [],
149
+ })
150
+ }
151
+ requestAnimationFrame(sample)
152
+ }, 0)
153
+ }
154
+ document.addEventListener(
155
+ "click",
156
+ (event) => {
157
+ const link = event.target instanceof Element ? event.target.closest("a") : undefined
158
+ if (link?.getAttribute("href") !== targetHref) return
159
+ state.startedAtPerformanceMs = performance.now()
160
+ state.running = true
161
+ requestAnimationFrame(sample)
162
+ },
163
+ { capture: true, once: true },
164
+ )
165
+ ;(window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash = state
166
+ }, input)
167
+ }
168
+
169
+ export function layoutShiftSample(entry: Pick<PerformanceEntry, "startTime"> & { value: number }, started: number) {
170
+ if (entry.startTime < started) return
171
+ return { occurredAtMs: entry.startTime - started, value: entry.value }
172
+ }
173
+
174
+ export async function waitForCachedRepaintWindow(page: Page, durationMs: number) {
175
+ await page.waitForFunction((durationMs) => {
176
+ const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash
177
+ return !!state?.running && performance.now() - state.startedAtPerformanceMs >= durationMs
178
+ }, durationMs)
179
+ }
180
+
181
+ export async function collectCachedRepaintTrace(page: Page) {
182
+ return page.evaluate(() => {
183
+ const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash!
184
+ state.stop()
185
+ return state
186
+ })
187
+ }
188
+
189
+ export function summarizeCachedRepaintTrace(trace: CachedRepaintTrace) {
190
+ const roots = trace.samples.map((sample) => sample.root)
191
+ const bottomErrors = trace.samples.flatMap((sample) =>
192
+ sample.bottomErrorPx === undefined ? [] : [Math.abs(sample.bottomErrorPx)],
193
+ )
194
+ const category = (sample: CachedRepaintTrace["samples"][number]) => {
195
+ if (sample.source.length) return "source"
196
+ if (sample.root === undefined || sample.rows.length === 0) return "blank"
197
+ if (!sample.destination.length) return "unknown"
198
+ if (sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) return "correct"
199
+ return "wrongDestination"
200
+ }
201
+ return {
202
+ samples: trace.samples.length,
203
+ durationMs: trace.samples.at(-1)?.observedAtMs ?? 0,
204
+ firstSampleObservedMs: trace.samples[0]?.observedAtMs,
205
+ firstSampleCorrect: trace.samples[0] ? category(trace.samples[0]) === "correct" : false,
206
+ blankSamples: trace.samples.filter((sample) => category(sample) === "blank").length,
207
+ sourceSamples: trace.samples.filter((sample) => category(sample) === "source").length,
208
+ wrongDestinationSamples: trace.samples.filter((sample) => category(sample) === "wrongDestination").length,
209
+ unknownSamples: trace.samples.filter((sample) => category(sample) === "unknown").length,
210
+ rootChanges: roots.slice(1).filter((root, index) => root !== roots[index]).length,
211
+ mountedMin: trace.samples.length ? Math.min(...trace.samples.map((sample) => sample.mounted)) : 0,
212
+ mountedMax: Math.max(...trace.samples.map((sample) => sample.mounted)),
213
+ maxBottomErrorPx: Math.max(0, ...bottomErrors),
214
+ mutationBatches: trace.mutations.length,
215
+ addedNodes: trace.mutations.reduce(
216
+ (sum, batch) => sum + batch.changed.filter((change) => change.type === "add").length,
217
+ 0,
218
+ ),
219
+ removedNodes: trace.mutations.reduce(
220
+ (sum, batch) => sum + batch.changed.filter((change) => change.type === "remove").length,
221
+ 0,
222
+ ),
223
+ layoutShiftValueSum: trace.shifts.reduce((sum, shift) => sum + shift.value, 0),
224
+ maxLayoutShiftValue: Math.max(0, ...trace.shifts.map((shift) => shift.value)),
225
+ }
226
+ }
227
+
228
+ export function compressCachedRepaintTrace(trace: CachedRepaintTrace) {
229
+ const samples: {
230
+ observedAtMs: number[]
231
+ state: Omit<CachedRepaintTrace["samples"][number], "observedAtMs">
232
+ }[] = []
233
+ for (const sample of trace.samples) {
234
+ const { observedAtMs, ...state } = sample
235
+ const previous = samples.at(-1)
236
+ if (previous && JSON.stringify(previous.state) === JSON.stringify(state)) {
237
+ previous.observedAtMs.push(observedAtMs)
238
+ continue
239
+ }
240
+ samples.push({ observedAtMs: [observedAtMs], state })
241
+ }
242
+ return {
243
+ timeOriginEpochMs: trace.timeOriginEpochMs,
244
+ startedAtPerformanceMs: trace.startedAtPerformanceMs,
245
+ windowMs: trace.windowMs,
246
+ summary: summarizeCachedRepaintTrace(trace),
247
+ samples,
248
+ mutations: trace.mutations,
249
+ shifts: trace.shifts,
250
+ }
251
+ }
packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page } from "@playwright/test"
2
+ import { expectSessionTitle } from "../../utils/waits"
3
+ import { benchmark, expect, withBenchmarkPage } from "../benchmark"
4
+ import { fixture } from "./session-timeline-stress.fixture"
5
+ import {
6
+ createReviewDiffs,
7
+ installStressSessionTabs,
8
+ installTimelineSettings,
9
+ mockStressTimeline,
10
+ stressSessionHref,
11
+ } from "./timeline-test-helpers"
12
+ import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
13
+
14
+ type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
15
+
16
+ benchmark("benchmarks cold and hot session tab switching", async ({ browser, report }, testInfo) => {
17
+ benchmark.setTimeout(180_000)
18
+ const results = { cold: [] as Result[], hot: [] as Result[] }
19
+ for (const mode of ["cold", "hot"] as const) {
20
+ for (let run = 0; run < 5; run++) {
21
+ results[mode].push(
22
+ await withBenchmarkPage(browser, `session-tab-switch-${mode}-${run}`, (page) => trial(page, mode), testInfo),
23
+ )
24
+ }
25
+ }
26
+ report({ results, summary: summarize(results) })
27
+ })
28
+
29
+ benchmark(
30
+ "benchmarks v2 session tab switching with and without the review pane",
31
+ async ({ browser, report }, testInfo) => {
32
+ benchmark.setTimeout(360_000)
33
+ const runs = Number(process.env.SESSION_TAB_SWITCH_RUNS ?? 5)
34
+ const results = {
35
+ closed: { cold: [] as Result[], hot: [] as Result[] },
36
+ open: { cold: [] as Result[], hot: [] as Result[] },
37
+ }
38
+ for (const reviewPane of ["closed", "open"] as const) {
39
+ for (const mode of ["cold", "hot"] as const) {
40
+ for (let run = 0; run < runs; run++) {
41
+ results[reviewPane][mode].push(
42
+ await withBenchmarkPage(
43
+ browser,
44
+ `session-tab-switch-v2-${reviewPane}-${mode}-${run}`,
45
+ (page) => trial(page, mode, { newLayoutDesigns: true, reviewPane }),
46
+ testInfo,
47
+ ),
48
+ )
49
+ }
50
+ }
51
+ }
52
+ report({ results, summary: summarizeReviewPane(results) }, { runs, reviewDiffs: createReviewDiffs().length })
53
+ },
54
+ )
55
+
56
+ async function trial(
57
+ page: Page,
58
+ mode: "cold" | "hot",
59
+ options?: { newLayoutDesigns?: boolean; reviewPane?: "closed" | "open" },
60
+ ) {
61
+ const reviewDiffs = options?.newLayoutDesigns ? createReviewDiffs() : undefined
62
+ await mockStressTimeline(page, { vcsDiff: reviewDiffs })
63
+ if (options?.newLayoutDesigns) await installTimelineSettings(page)
64
+ await installStressSessionTabs(page)
65
+ if (mode === "hot") {
66
+ await page.goto(stressSessionHref(fixture.targetID))
67
+ await expectSessionTitle(page, fixture.expected.targetTitle)
68
+ await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
69
+ await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
70
+ } else {
71
+ await page.goto(stressSessionHref(fixture.sourceID))
72
+ await expectSessionTitle(page, fixture.expected.sourceTitle)
73
+ }
74
+ await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
75
+ if (options?.reviewPane === "open") {
76
+ await openReviewPane(page)
77
+ await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
78
+ }
79
+
80
+ const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
81
+ const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
82
+ const lastID = fixture.expected.targetMessageIDs.at(-1)!
83
+ const href = stressSessionHref(fixture.targetID)
84
+ const result = await measureSessionSwitch(page, {
85
+ destinationIDs,
86
+ sourceIDs,
87
+ lastID,
88
+ href,
89
+ switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
90
+ })
91
+ return result
92
+ }
93
+
94
+ function summarize(results: Record<"cold" | "hot", Result[]>) {
95
+ const stats = (values: (number | null)[]) => {
96
+ const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b)
97
+ return {
98
+ min: sorted[0] ?? null,
99
+ median: sorted[Math.floor(sorted.length / 2)] ?? null,
100
+ max: sorted.at(-1) ?? null,
101
+ missing: values.length - sorted.length,
102
+ }
103
+ }
104
+ return Object.fromEntries(
105
+ Object.entries(results).map(([mode, values]) => [
106
+ mode,
107
+ {
108
+ firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)),
109
+ firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)),
110
+ stableObservedMs: stats(values.map((value) => value.stableObservedMs)),
111
+ },
112
+ ]),
113
+ )
114
+ }
115
+
116
+ function summarizeReviewPane(results: Record<"closed" | "open", Record<"cold" | "hot", Result[]>>) {
117
+ return Object.fromEntries(
118
+ Object.entries(results).map(([reviewPane, values]) => [
119
+ reviewPane,
120
+ summarize(values as Record<"cold" | "hot", Result[]>),
121
+ ]),
122
+ )
123
+ }
124
+
125
+ async function switchSession(page: Page, sessionID: string, title: string) {
126
+ const href = stressSessionHref(sessionID)
127
+ const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
128
+ await expect(tab).toBeVisible()
129
+ await tab.click()
130
+ await expectSessionTitle(page, title)
131
+ }
132
+
133
+ async function openReviewPane(page: Page) {
134
+ await page.getByRole("button", { name: "Toggle review" }).click()
135
+ const panel = page.locator("#review-panel")
136
+ await expect(panel).toBeVisible()
137
+ // Text-based readiness works across review implementations; the legacy list mounts
138
+ // diff viewers lazily while V2 mounts the active preview eagerly.
139
+ await page.waitForFunction(() => {
140
+ const panel = document.querySelector<HTMLElement>("#review-panel")
141
+ const text = panel?.textContent ?? ""
142
+ return text.includes("generated-000.ts") && text.includes("+3")
143
+ })
144
+ }
packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type SessionSwitchSample = {
2
+ observedAtMs: number
3
+ destination: string[]
4
+ source: string[]
5
+ hasVisibleRows: boolean
6
+ last: boolean
7
+ requiredPartVisible?: boolean
8
+ bottomAnchorRequired?: boolean
9
+ bottomErrorPx?: number
10
+ review?: {
11
+ fileHost: boolean
12
+ fileHostReplaced: boolean
13
+ header: string
14
+ replacedLevels: string[]
15
+ }
16
+ }
17
+
18
+ export function classifySessionSwitch(samples: SessionSwitchSample[]) {
19
+ const firstDestination = samples.findIndex((sample) => sample.destination.length > 0)
20
+ const firstCorrect = samples.findIndex(isCorrectDestination)
21
+ const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3)))
22
+ return {
23
+ firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null,
24
+ firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null,
25
+ stableObservedMs: samples[stable + 2]?.observedAtMs ?? null,
26
+ wrongDestinationSamples: samples
27
+ .slice(firstDestination)
28
+ .filter((sample) => sample.destination.length > 0 && !sample.last).length,
29
+ blankSamples: samples.filter((sample) => !sample.hasVisibleRows).length,
30
+ unknownSamples: samples.filter(
31
+ (sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0,
32
+ ).length,
33
+ sourceSamples: samples.filter((sample) => sample.source.length > 0).length,
34
+ reviewFileHostMissingSamples: samples.filter((sample) => sample.review && !sample.review.fileHost).length,
35
+ reviewFileHostReplacedSamples: samples.filter((sample) => sample.review?.fileHostReplaced).length,
36
+ reviewHeaders: [...new Set(samples.flatMap((sample) => (sample.review ? [sample.review.header] : [])))],
37
+ reviewReplacedLevels: [...new Set(samples.flatMap((sample) => sample.review?.replacedLevels ?? []))],
38
+ }
39
+ }
40
+
41
+ export function isCorrectDestination(sample: SessionSwitchSample) {
42
+ return (
43
+ sample.destination.length > 0 &&
44
+ sample.source.length === 0 &&
45
+ sample.last &&
46
+ sample.requiredPartVisible !== false &&
47
+ (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
48
+ )
49
+ }
50
+
51
+ export function isStableSessionSwitch(samples: SessionSwitchSample[]) {
52
+ return samples.length === 3 && samples.every(isCorrectDestination)
53
+ }
54
+
55
+ export function isStableDestination(samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[]) {
56
+ return (
57
+ samples.length === 3 && samples.every((sample) => sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
58
+ )
59
+ }
packages/app/e2e/performance/timeline/session-tab-switch-probe.ts ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, type Page } from "@playwright/test"
2
+ import { classifySessionSwitch, isStableDestination, type SessionSwitchSample } from "./session-tab-switch-metrics"
3
+
4
+ type SessionSwitchProbe = {
5
+ samples: SessionSwitchSample[]
6
+ stop: () => void
7
+ }
8
+
9
+ async function installSessionSwitchProbe(
10
+ page: Page,
11
+ input: {
12
+ destinationIDs: string[]
13
+ sourceIDs: string[]
14
+ lastID: string
15
+ requiredPartID?: string
16
+ requireBottomAnchor?: boolean
17
+ href: string
18
+ },
19
+ ) {
20
+ await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
21
+ const destination = new Set(destinationIDs)
22
+ const source = new Set(sourceIDs)
23
+ const samples: SessionSwitchSample[] = []
24
+ let started: number | undefined
25
+ let running = true
26
+ const reviewLevels: Record<string, string> = {
27
+ panel: "#review-panel",
28
+ tabs: '#review-panel [data-component="tabs"]',
29
+ body: '#review-panel [data-slot="session-review-v2-body"]',
30
+ review: '#review-panel [data-component="session-review-v2"]',
31
+ preview: '#review-panel [data-slot="session-review-v2-preview"]',
32
+ scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
33
+ file: '#review-panel [data-component="file"][data-mode="diff"]',
34
+ }
35
+ const initialReviewNodes: Record<string, Element | null> = {}
36
+ const sample = () => {
37
+ if (!running || started === undefined) return
38
+ setTimeout(() => {
39
+ if (!running || started === undefined) return
40
+ const observedAtMs = performance.now() - started
41
+ const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
42
+ const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
43
+ const initialReviewFile = initialReviewNodes.file
44
+ const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
45
+ const initial = initialReviewNodes[name]
46
+ if (!initial) return []
47
+ const current = document.querySelector(selector)
48
+ return current && current !== initial ? [name] : []
49
+ })
50
+ const review = reviewPanel
51
+ ? {
52
+ fileHost: !!reviewFile,
53
+ fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
54
+ header:
55
+ reviewPanel
56
+ .querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
57
+ ?.textContent?.trim() ?? "",
58
+ replacedLevels,
59
+ }
60
+ : undefined
61
+ const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
62
+ element.querySelector("[data-timeline-row]"),
63
+ )
64
+ if (root) {
65
+ const view = root.getBoundingClientRect()
66
+ const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
67
+ .filter((element) => {
68
+ const rect = element.getBoundingClientRect()
69
+ return rect.bottom > view.top && rect.top < view.bottom
70
+ })
71
+ .map((element) => element.dataset.messageId!)
72
+ const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
73
+ const rect = element.getBoundingClientRect()
74
+ return rect.bottom > view.top && rect.top < view.bottom
75
+ })
76
+ const requiredPartVisible = requiredPartID
77
+ ? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
78
+ if (element.dataset.timelinePartId !== requiredPartID) return false
79
+ const rect = element.getBoundingClientRect()
80
+ return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
81
+ })
82
+ : undefined
83
+ const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
84
+ samples.push({
85
+ observedAtMs,
86
+ destination: visible.filter((id) => destination.has(id)),
87
+ source: visible.filter((id) => source.has(id)),
88
+ hasVisibleRows,
89
+ last: visible.includes(lastID),
90
+ requiredPartVisible,
91
+ bottomAnchorRequired: requireBottomAnchor !== false,
92
+ bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
93
+ review,
94
+ })
95
+ } else {
96
+ samples.push({
97
+ observedAtMs,
98
+ destination: [],
99
+ source: [],
100
+ hasVisibleRows: false,
101
+ last: false,
102
+ requiredPartVisible: requiredPartID ? false : undefined,
103
+ bottomAnchorRequired: requireBottomAnchor !== false,
104
+ review,
105
+ })
106
+ }
107
+ requestAnimationFrame(sample)
108
+ }, 0)
109
+ }
110
+ document.addEventListener(
111
+ "click",
112
+ (event) => {
113
+ const link = event.target instanceof Element ? event.target.closest("a") : undefined
114
+ if (link?.getAttribute("href") !== href) return
115
+ started = performance.now()
116
+ for (const [name, selector] of Object.entries(reviewLevels)) {
117
+ initialReviewNodes[name] = document.querySelector(selector)
118
+ }
119
+ requestAnimationFrame(sample)
120
+ },
121
+ { capture: true, once: true },
122
+ )
123
+ ;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
124
+ samples,
125
+ stop: () => {
126
+ running = false
127
+ },
128
+ }
129
+ }, input)
130
+ }
131
+
132
+ async function waitForStableSessionSwitch(page: Page) {
133
+ await page.waitForFunction(() => {
134
+ const samples = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.samples
135
+ if (!samples) return false
136
+ return samples.some((_, index) => {
137
+ const stable = samples.slice(index, index + 3)
138
+ return (
139
+ stable.length === 3 &&
140
+ stable.every(
141
+ (sample) =>
142
+ sample.destination.length > 0 &&
143
+ sample.source.length === 0 &&
144
+ sample.last &&
145
+ sample.requiredPartVisible !== false &&
146
+ (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1),
147
+ )
148
+ )
149
+ })
150
+ })
151
+ }
152
+
153
+ async function collectSessionSwitchResult(page: Page) {
154
+ const samples = await page.evaluate(() => {
155
+ const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
156
+ probe.stop()
157
+ return probe.samples
158
+ })
159
+ return classifySessionSwitch(samples)
160
+ }
161
+
162
+ export async function measureSessionSwitch(
163
+ page: Page,
164
+ input: {
165
+ destinationIDs: string[]
166
+ sourceIDs: string[]
167
+ lastID: string
168
+ requiredPartID?: string
169
+ requireBottomAnchor?: boolean
170
+ href: string
171
+ switch: () => Promise<void>
172
+ },
173
+ ) {
174
+ const { switch: run, ...probe } = input
175
+ await installSessionSwitchProbe(page, probe)
176
+ try {
177
+ await run()
178
+ await waitForStableSessionSwitch(page)
179
+ return await collectSessionSwitchResult(page)
180
+ } finally {
181
+ await page.evaluate(() => {
182
+ ;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
183
+ })
184
+ }
185
+ }
186
+
187
+ export async function waitForStableTimeline(page: Page, lastID: string) {
188
+ const samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[] = []
189
+ await expect
190
+ .poll(
191
+ async () => {
192
+ samples.push(
193
+ await page.evaluate(
194
+ (lastID) =>
195
+ new Promise<Pick<SessionSwitchSample, "last" | "bottomErrorPx">>((resolve) => {
196
+ requestAnimationFrame(() =>
197
+ setTimeout(() => {
198
+ const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
199
+ element.querySelector("[data-timeline-row]"),
200
+ )
201
+ if (!root) {
202
+ resolve({ last: false })
203
+ return
204
+ }
205
+ const view = root.getBoundingClientRect()
206
+ const last = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].some((element) => {
207
+ if (element.dataset.messageId !== lastID) return false
208
+ const rect = element.getBoundingClientRect()
209
+ return rect.bottom > view.top && rect.top < view.bottom
210
+ })
211
+ const spacer = root
212
+ .querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
213
+ ?.getBoundingClientRect()
214
+ resolve({ last, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined })
215
+ }, 0),
216
+ )
217
+ }),
218
+ lastID,
219
+ ),
220
+ )
221
+ return isStableDestination(samples.slice(-3))
222
+ },
223
+ { timeout: 30_000, intervals: [0] },
224
+ )
225
+ .toBe(true)
226
+ }
packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import type { Page } from "@playwright/test"
3
+ import { mockOpenCodeServer } from "../../utils/mock-server"
4
+ import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
5
+ import { expect } from "../benchmark"
6
+
7
+ const directory = "C:/OpenCode/TimelineStateRegression"
8
+ const projectID = "proj_timeline_state_regression"
9
+ const sessionID = "ses_timeline_state_regression"
10
+ const userMessageID = "msg_user_regression"
11
+ const assistantMessageID = "msg_assistant_regression"
12
+ const editPartID = "prt_0001_edit"
13
+ export const textPartID = "prt_9999_text"
14
+ const title = "Timeline collapse state regression"
15
+ const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
16
+
17
+ type EventPayload = {
18
+ directory: string
19
+ payload: Record<string, unknown>
20
+ }
21
+
22
+ const userMessage = {
23
+ info: {
24
+ id: userMessageID,
25
+ sessionID,
26
+ role: "user",
27
+ time: { created: 1700000000000 },
28
+ summary: { diffs: [] },
29
+ agent: "build",
30
+ model,
31
+ },
32
+ parts: [
33
+ {
34
+ id: "prt_user_text",
35
+ sessionID,
36
+ messageID: userMessageID,
37
+ type: "text",
38
+ text: "Please edit the file.",
39
+ },
40
+ ],
41
+ }
42
+
43
+ const editPart = {
44
+ id: editPartID,
45
+ sessionID,
46
+ messageID: assistantMessageID,
47
+ type: "tool",
48
+ callID: "call_edit_regression",
49
+ tool: "edit",
50
+ state: {
51
+ status: "completed",
52
+ input: { filePath: "src/regression.ts" },
53
+ output: "Edited src/regression.ts",
54
+ title: "src/regression.ts",
55
+ metadata: {
56
+ filediff: {
57
+ file: "src/regression.ts",
58
+ additions: 1,
59
+ deletions: 1,
60
+ before: "export const value = 'before'\n",
61
+ after: "export const value = 'after'\n",
62
+ },
63
+ diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
64
+ },
65
+ time: { start: 1700000001000, end: 1700000002000 },
66
+ },
67
+ }
68
+
69
+ const streamedTextPart = {
70
+ id: textPartID,
71
+ sessionID,
72
+ messageID: assistantMessageID,
73
+ type: "text",
74
+ text: "Streaming added a later assistant text part.",
75
+ }
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
+
96
+ export async function setupTimelineBenchmark(
97
+ page: Page,
98
+ options: {
99
+ historyTurns: number
100
+ eventBatch: number
101
+ newLayoutDesigns?: boolean
102
+ vcsDiff?: unknown[]
103
+ turnDiffs?: unknown[]
104
+ },
105
+ ) {
106
+ const events: EventPayload[] = []
107
+ let eventBatch = options.eventBatch
108
+ const currentUserMessage = options.turnDiffs
109
+ ? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } }
110
+ : userMessage
111
+ await mockOpenCodeServer(page, {
112
+ directory,
113
+ project: project(),
114
+ provider: provider(),
115
+ sessions: [session()],
116
+ vcsDiff: options.vcsDiff,
117
+ pageMessages: () => ({
118
+ items: [
119
+ ...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
120
+ currentUserMessage,
121
+ assistantMessage,
122
+ ],
123
+ }),
124
+ events: () => events.splice(0, eventBatch),
125
+ eventRetry: 16,
126
+ })
127
+ await page.addInitScript(
128
+ (input) => {
129
+ localStorage.setItem(
130
+ "settings.v3",
131
+ JSON.stringify({
132
+ general: {
133
+ newLayoutDesigns: input.newLayoutDesigns,
134
+ editToolPartsExpanded: true,
135
+ shellToolPartsExpanded: true,
136
+ showReasoningSummaries: true,
137
+ },
138
+ }),
139
+ )
140
+ },
141
+ { newLayoutDesigns: options.newLayoutDesigns ?? false },
142
+ )
143
+ await page.setViewportSize({ width: 1366, height: 768 })
144
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
145
+ const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
146
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
147
+ await expectSessionTitle(page, title)
148
+ await expectAppVisible(scroller)
149
+ return {
150
+ scroller,
151
+ text,
152
+ transport: {
153
+ enqueue(payload: EventPayload | EventPayload[]) {
154
+ events.push(...(Array.isArray(payload) ? payload : [payload]))
155
+ },
156
+ pendingCount() {
157
+ return events.length
158
+ },
159
+ releaseAll() {
160
+ eventBatch = events.length
161
+ },
162
+ },
163
+ async scrollToBottom() {
164
+ await scroller.evaluate((element) => {
165
+ element.scrollTop = element.scrollHeight
166
+ })
167
+ },
168
+ async waitForStableGeometry() {
169
+ await expect
170
+ .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
171
+ .toBeLessThanOrEqual(1)
172
+ await page.waitForFunction((partID) => {
173
+ const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
174
+ element.querySelector(`[data-timeline-part-id="${partID}"]`),
175
+ )
176
+ if (!root) return false
177
+ return new Promise<boolean>((resolve) => {
178
+ const height = root.scrollHeight
179
+ requestAnimationFrame(() =>
180
+ requestAnimationFrame(() =>
181
+ resolve(root.scrollHeight === height && root.scrollHeight - root.clientHeight - root.scrollTop <= 1),
182
+ ),
183
+ )
184
+ })
185
+ }, textPartID)
186
+ },
187
+ }
188
+ }
189
+
190
+ export function buildInitialStreamEvent(deltaCount: number): EventPayload {
191
+ return {
192
+ directory,
193
+ payload: {
194
+ type: "message.part.updated",
195
+ properties: {
196
+ part: {
197
+ ...streamedTextPart,
198
+ text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
199
+ },
200
+ },
201
+ },
202
+ }
203
+ }
204
+
205
+ export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
206
+ return Array.from({ length: deltaCount }, (_, index) => ({
207
+ directory,
208
+ payload: {
209
+ type: "message.part.delta",
210
+ properties: {
211
+ messageID: assistantMessageID,
212
+ partID: textPartID,
213
+ field: "text",
214
+ delta: streamChunk(index + 1, deltaCount + 1),
215
+ },
216
+ },
217
+ }))
218
+ }
219
+
220
+ function performanceTurn(index: number) {
221
+ const suffix = String(index).padStart(4, "0")
222
+ const userID = `msg_0000_${suffix}_a_user`
223
+ const assistantID = `msg_0000_${suffix}_b_assistant`
224
+ const before = historicalSource(index, false)
225
+ const after = historicalSource(index, true)
226
+ const parts = [
227
+ ...(index % 5 === 0
228
+ ? [
229
+ {
230
+ id: `prt_0000_${suffix}_reasoning`,
231
+ sessionID,
232
+ messageID: assistantID,
233
+ type: "reasoning",
234
+ text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
235
+ time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 },
236
+ },
237
+ ]
238
+ : []),
239
+ {
240
+ id: `prt_0000_${suffix}_assistant`,
241
+ sessionID,
242
+ messageID: assistantID,
243
+ type: "text",
244
+ text: historicalMarkdown(index),
245
+ },
246
+ ...(index % 8 === 0
247
+ ? [
248
+ {
249
+ id: `prt_0000_${suffix}_edit`,
250
+ sessionID,
251
+ messageID: assistantID,
252
+ type: "tool",
253
+ callID: `call_0000_${suffix}_edit`,
254
+ tool: "edit",
255
+ state: {
256
+ status: "completed",
257
+ input: { filePath: `src/history-${index}.ts` },
258
+ output: `Edited src/history-${index}.ts`,
259
+ title: `src/history-${index}.ts`,
260
+ metadata: {
261
+ filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
262
+ },
263
+ time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
264
+ },
265
+ },
266
+ ]
267
+ : []),
268
+ ...(index % 12 === 0
269
+ ? [
270
+ {
271
+ id: `prt_0000_${suffix}_write`,
272
+ sessionID,
273
+ messageID: assistantID,
274
+ type: "tool",
275
+ callID: `call_0000_${suffix}_write`,
276
+ tool: "write",
277
+ state: {
278
+ status: "completed",
279
+ input: { filePath: `src/generated-${index}.tsx`, content: after },
280
+ output: `Wrote src/generated-${index}.tsx`,
281
+ title: `src/generated-${index}.tsx`,
282
+ metadata: {
283
+ filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
284
+ },
285
+ time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
286
+ },
287
+ },
288
+ ]
289
+ : []),
290
+ ...(index % 16 === 0
291
+ ? [
292
+ {
293
+ id: `prt_0000_${suffix}_patch`,
294
+ sessionID,
295
+ messageID: assistantID,
296
+ type: "tool",
297
+ callID: `call_0000_${suffix}_patch`,
298
+ tool: "apply_patch",
299
+ state: {
300
+ status: "completed",
301
+ input: { patchText: realisticPatch(index) },
302
+ output: "Success. Updated src/components/SessionCard.tsx",
303
+ title: "src/components/SessionCard.tsx",
304
+ metadata: {
305
+ files: [
306
+ {
307
+ filePath: "src/components/SessionCard.tsx",
308
+ relativePath: "src/components/SessionCard.tsx",
309
+ type: "update",
310
+ additions: 8,
311
+ deletions: 3,
312
+ patch: realisticPatch(index),
313
+ before,
314
+ after,
315
+ },
316
+ ],
317
+ },
318
+ time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
319
+ },
320
+ },
321
+ ]
322
+ : []),
323
+ ]
324
+ return [
325
+ {
326
+ info: {
327
+ id: userID,
328
+ sessionID,
329
+ role: "user",
330
+ time: { created: 1690000000000 + index * 2_000 },
331
+ summary: { diffs: [] },
332
+ agent: "build",
333
+ model,
334
+ },
335
+ parts: [
336
+ {
337
+ id: `prt_0000_${suffix}_user`,
338
+ sessionID,
339
+ messageID: userID,
340
+ type: "text",
341
+ text: `Historical prompt ${index}`,
342
+ },
343
+ ],
344
+ },
345
+ {
346
+ info: {
347
+ id: assistantID,
348
+ sessionID,
349
+ role: "assistant",
350
+ time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
351
+ parentID: userID,
352
+ modelID: model.modelID,
353
+ providerID: model.providerID,
354
+ mode: "build",
355
+ agent: "build",
356
+ path: { cwd: directory, root: directory },
357
+ cost: 0.01,
358
+ tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
359
+ variant: "max",
360
+ finish: "stop",
361
+ },
362
+ parts,
363
+ },
364
+ ]
365
+ }
366
+
367
+ function historicalMarkdown(index: number) {
368
+ const code = `import { For, Show, createSignal } from "solid-js"
369
+
370
+ type SessionRow = { id: string; title: string; active: boolean }
371
+
372
+ export function SessionList(props: { rows: SessionRow[] }) {
373
+ const [selected, setSelected] = createSignal<string>()
374
+ return (
375
+ <section aria-label="Sessions">
376
+ <For each={props.rows}>{(row) => (
377
+ <button classList={{ active: row.active }} onClick={() => setSelected(row.id)}>
378
+ <Show when={selected() === row.id} fallback={row.title}>{row.title.toUpperCase()}</Show>
379
+ </button>
380
+ )}</For>
381
+ </section>
382
+ )
383
+ }`
384
+ return `## Session renderer review ${index}
385
+
386
+ The active session keeps **semantic row identity** while reconciling measured content. See [Solid documentation](https://docs.solidjs.com/) and the inline \`measureElement(node)\` call.
387
+
388
+ | Concern | Current behavior | Verification |
389
+ | --- | --- | --- |
390
+ | streaming | appends Markdown blocks | painted frames |
391
+ | geometry | anchors visible rows | DOM coordinates |
392
+ | tools | preserves expanded state | keyed remount probe |
393
+
394
+ > Long sessions combine Markdown, syntax highlighting, tool output, and asynchronously rendered diffs.
395
+
396
+ ${index % 4 === 0 ? `\`\`\`tsx\n${code}\n\`\`\`\n\n\`\`\`bash\nbun typecheck\nbun test --preload ./happydom.ts ./src/pages/session\ngit diff --check\n\`\`\`` : "- preserve the viewport anchor\n- avoid replacing stable Markdown nodes\n- process provider deltas without blocking input"}`
397
+ }
398
+
399
+ function historicalSource(index: number, updated: boolean) {
400
+ const method = updated ? "toLocaleUpperCase(props.locale)" : "toUpperCase()"
401
+ const limit = updated ? 24 : 20
402
+ return `import { createMemo, For } from "solid-js"
403
+
404
+ type Message = {
405
+ id: string
406
+ role: "user" | "assistant"
407
+ text: string
408
+ tokens: { input: number; output: number }
409
+ }
410
+
411
+ export function MessageSummary(props: { messages: Message[]; locale: string }) {
412
+ const visible = createMemo(() => props.messages.filter((message) => message.text.trim()).slice(-${limit}))
413
+ const total = createMemo(() => visible().reduce((sum, message) => sum + message.tokens.output, 0))
414
+ return (
415
+ <article data-session-index="${index}">
416
+ <header>{total().toLocaleString(props.locale)} output tokens</header>
417
+ <For each={visible()}>{(message) => <p data-role={message.role}>{message.text.${method}}</p>}</For>
418
+ </article>
419
+ )
420
+ }
421
+ `
422
+ }
423
+
424
+ function realisticPatch(index: number) {
425
+ return `*** Begin Patch
426
+ *** Update File: src/components/SessionCard.tsx
427
+ @@
428
+ -const title = props.session.title.toUpperCase()
429
+ -const messages = props.messages.slice(-20)
430
+ +const title = props.session.title.toLocaleUpperCase(props.locale)
431
+ +const messages = props.messages.filter((message) => message.text.trim()).slice(-24)
432
+ +const outputTokens = messages.reduce((sum, message) => sum + message.tokens.output, 0)
433
+ @@
434
+ - <h2>{title}</h2>
435
+ + <h2 data-session-index="${index}">{title}</h2>
436
+ + <span>{outputTokens.toLocaleString(props.locale)} output tokens</span>
437
+ *** End Patch`
438
+ }
439
+
440
+ export function streamChunk(index: number, count: number) {
441
+ if (index === 0) return `\n\n## Implementation plan\n\nStreaming **bold analysis`
442
+ if (index === count - 1)
443
+ return `\n\`\`\`\n\n## Verification\n\n- **Typecheck:** passed\n- **Timeline geometry:** stable\n- **Streaming output:** benchmark-complete <!-- stream-${index} -->`
444
+
445
+ const section = Math.floor(index / 18) + 1
446
+ const fragments = [
447
+ ` continues across three`,
448
+ ` or four word`,
449
+ ` provider deltas and`,
450
+ ` closes in this fragment**. <!-- stream-${index} -->\n\n`,
451
+ `| Concern | State`,
452
+ ` | Verification |\n|`,
453
+ ` --- | ---`,
454
+ ` | --- |\n|`,
455
+ ` markdown | incremental |`,
456
+ ` painted frames | <!-- stream-${index} -->\n\n`,
457
+ `\`\`\`tsx\nconst row: SessionRow`,
458
+ ` = rows[index] ??`,
459
+ ` fallback\nconst title =`,
460
+ ` row.title.toLocaleUpperCase(locale)\n`,
461
+ `const selected = createMemo(()`,
462
+ ` => row.id ===`,
463
+ ` activeID()) // stream-${index}\n`,
464
+ `// stream-${index}\n\`\`\`\n\n### Iteration ${section}\n\nStreaming **bold analysis`,
465
+ ]
466
+ return fragments[(index - 1) % fragments.length]!
467
+ }
468
+
469
+ function project() {
470
+ return {
471
+ id: projectID,
472
+ worktree: directory,
473
+ vcs: "git",
474
+ name: "timeline-state-regression",
475
+ time: { created: 1700000000000, updated: 1700000000000 },
476
+ sandboxes: [],
477
+ }
478
+ }
479
+
480
+ function session() {
481
+ return {
482
+ id: sessionID,
483
+ slug: "timeline-state-regression",
484
+ projectID,
485
+ directory,
486
+ title,
487
+ version: "dev",
488
+ time: { created: 1700000000000, updated: 1700000000000 },
489
+ }
490
+ }
491
+
492
+ function provider() {
493
+ return {
494
+ all: [
495
+ {
496
+ id: "opencode",
497
+ name: "OpenCode",
498
+ models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
499
+ },
500
+ ],
501
+ connected: ["opencode"],
502
+ default: { providerID: "opencode", modelID: "claude-opus-4-6" },
503
+ }
504
+ }
packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page } from "@playwright/test"
2
+ import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
3
+ import {
4
+ buildInitialStreamEvent,
5
+ buildStreamDeltaEvents,
6
+ setupTimelineBenchmark,
7
+ textPartID,
8
+ } from "./session-timeline-benchmark.fixture"
9
+ import { startTimelineProfile } from "./session-timeline-profile"
10
+ import { createReviewDiffs } from "./timeline-test-helpers"
11
+ import {
12
+ collectTimelineStreamMetrics,
13
+ installTimelineStreamProbe,
14
+ startTimelineStreamProbe,
15
+ } from "./session-timeline-stream-probe"
16
+
17
+ type TimelineStreamOptions = {
18
+ newLayoutDesigns?: boolean
19
+ reviewDiffs?: boolean
20
+ reviewPane?: boolean
21
+ }
22
+
23
+ type ReviewPaneSample = {
24
+ observedAtMs: number
25
+ panelVisible: boolean
26
+ header: string
27
+ diffViewers: number
28
+ diffLines: number
29
+ codeBlocks: number
30
+ ready: boolean
31
+ }
32
+
33
+ type ReviewPaneProbe = {
34
+ samples: ReviewPaneSample[]
35
+ start: () => void
36
+ stop: () => void
37
+ }
38
+
39
+ const reviewReadyStreak = 3
40
+
41
+ benchmark.describe("performance: session timeline streaming", () => {
42
+ benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => {
43
+ benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
44
+ const result = await runTimelineStreamBenchmark(page, {})
45
+ report(result.metrics, result.context)
46
+ })
47
+
48
+ benchmark("streams assistant text in v2 with review pane closed", async ({ page, report }) => {
49
+ benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
50
+ const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true })
51
+ report(result.metrics, result.context)
52
+ })
53
+
54
+ benchmark("streams assistant text in v2 with review diffs and pane closed", async ({ page, report }) => {
55
+ benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
56
+ const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewDiffs: true })
57
+ report(result.metrics, result.context)
58
+ })
59
+
60
+ benchmark("streams assistant text in v2 with review pane open", async ({ page, report }) => {
61
+ benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
62
+ const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewPane: true })
63
+ report(result.metrics, result.context)
64
+ })
65
+ })
66
+
67
+ benchmark.describe("performance: review pane", () => {
68
+ benchmark("loads v2 review diffs and switches active files", async ({ page, report }) => {
69
+ benchmark.setTimeout(240_000)
70
+ const historyTurns = Number(process.env.REVIEW_PANE_HISTORY_TURNS ?? 72)
71
+ const diffs = createReviewDiffs()
72
+ const fixture = await setupTimelineBenchmark(page, {
73
+ historyTurns,
74
+ eventBatch: 1,
75
+ newLayoutDesigns: true,
76
+ vcsDiff: diffs,
77
+ })
78
+
79
+ fixture.transport.enqueue(buildInitialStreamEvent(1))
80
+ await expect(fixture.text).toBeVisible()
81
+ await expect(fixture.text).toContainText("Implementation plan")
82
+ await fixture.scrollToBottom()
83
+ await fixture.waitForStableGeometry()
84
+
85
+ const open = await measureReviewPaneLoad(page, diffs[0]!.file)
86
+ const switches = []
87
+ for (const diff of diffs.slice(1, 4)) switches.push(await measureReviewNextFile(page, diff.file))
88
+
89
+ report(
90
+ {
91
+ open,
92
+ switches,
93
+ },
94
+ {
95
+ historyTurns,
96
+ reviewDiffs: diffs.length,
97
+ },
98
+ )
99
+ })
100
+ })
101
+
102
+ async function runTimelineStreamBenchmark(page: Page, options: TimelineStreamOptions) {
103
+ const completionTimeoutMs = Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000)
104
+ const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
105
+ const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
106
+ const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
107
+ const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
108
+ const minimal = process.env.TIMELINE_MINIMAL === "1"
109
+ const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
110
+ const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
111
+ const diffs = options.reviewDiffs || options.reviewPane ? createReviewDiffs() : undefined
112
+ const fixture = await setupTimelineBenchmark(page, {
113
+ historyTurns,
114
+ eventBatch,
115
+ newLayoutDesigns: options.newLayoutDesigns,
116
+ // Turn diffs exercise timeline data cost; the pane-open scenario serves the same
117
+ // diffs through the default git mode so it works across review implementations.
118
+ turnDiffs: options.reviewDiffs ? diffs : undefined,
119
+ vcsDiff: options.reviewPane ? diffs : undefined,
120
+ })
121
+
122
+ fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
123
+ const contentStart = performance.now()
124
+ await expect(fixture.text).toBeVisible()
125
+ await expect(fixture.text).toContainText("Implementation plan")
126
+ const initialContentObservedMs = performance.now() - contentStart
127
+ await fixture.scrollToBottom()
128
+ await fixture.waitForStableGeometry()
129
+
130
+ const reviewPane = options.reviewPane && diffs ? await measureReviewPaneLoad(page, diffs[0]!.file) : undefined
131
+ if (reviewPane) await fixture.waitForStableGeometry()
132
+
133
+ const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
134
+ await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
135
+ const deltas = buildStreamDeltaEvents(deltaCount)
136
+ await startTimelineStreamProbe(page)
137
+ fixture.transport.enqueue(deltas)
138
+
139
+ await page.waitForFunction(
140
+ (finalIndex) =>
141
+ (
142
+ window as Window & {
143
+ __timelineStreamBenchmark?: { applied: { index: number }[] }
144
+ }
145
+ ).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
146
+ deltaCount,
147
+ { timeout: completionTimeoutMs },
148
+ )
149
+ await expect(fixture.text).toContainText("benchmark-complete")
150
+ await expect(fixture.text).toContainText("Streaming")
151
+ await fixture.waitForStableGeometry()
152
+ const metrics = await collectTimelineStreamMetrics(page, {
153
+ textPartID,
154
+ finalIndex: deltaCount,
155
+ navigations: benchmarkDiagnostics(page).navigations,
156
+ })
157
+ const delivered = deltas.length - fixture.transport.pendingCount()
158
+ await profile.stop()
159
+
160
+ const result = {
161
+ metrics: {
162
+ endToEndInitialContentObservedMs: initialContentObservedMs,
163
+ ...metrics,
164
+ deliveredDeltas: delivered,
165
+ pendingDeltas: fixture.transport.pendingCount(),
166
+ reviewPane: reviewPane ?? null,
167
+ },
168
+ context: {
169
+ cpuThrottle,
170
+ profileCPU,
171
+ profileVisual,
172
+ minimal,
173
+ queuedDeltas: deltas.length,
174
+ historyTurns,
175
+ eventBatch,
176
+ newLayoutDesigns: options.newLayoutDesigns === true,
177
+ reviewPane: options.reviewPane === true ? "open" : "closed",
178
+ reviewDiffs: diffs?.length ?? 0,
179
+ },
180
+ }
181
+
182
+ await profile.reset()
183
+ return result
184
+ }
185
+
186
+ async function measureReviewPaneLoad(page: Page, file: string) {
187
+ // Default git mode reads the mocked /vcs/diff data, so opening the pane is enough
188
+ // and the flow works across review pane implementations.
189
+ await installReviewPaneProbe(page, { file })
190
+ await startReviewPaneProbe(page)
191
+ await page.getByRole("button", { name: "Toggle review" }).click()
192
+ await expect(page.locator("#review-panel")).toBeVisible()
193
+ return collectReviewPaneProbe(page)
194
+ }
195
+
196
+ async function measureReviewNextFile(page: Page, file: string) {
197
+ await installReviewPaneProbe(page, { file })
198
+ await startReviewPaneProbe(page)
199
+ await page.getByRole("button", { name: "Next file" }).click()
200
+ return collectReviewPaneProbe(page)
201
+ }
202
+
203
+ async function installReviewPaneProbe(page: Page, input: { file: string }) {
204
+ await page.evaluate((input) => {
205
+ const samples: ReviewPaneSample[] = []
206
+ const basename = input.file.split(/[\\/]/).at(-1) ?? input.file
207
+ let started: number | undefined
208
+ let running = true
209
+
210
+ const paneState = () => {
211
+ const panel = document.querySelector<HTMLElement>("#review-panel")
212
+ const review = panel?.querySelector<HTMLElement>('[data-component="session-review-v2"]')
213
+ const rect = (review ?? panel)?.getBoundingClientRect()
214
+ const text = panel?.textContent ?? ""
215
+ const previewHeader = panel?.querySelector<HTMLElement>(
216
+ '[data-slot="session-review-v2-file-header"]',
217
+ )?.textContent
218
+ const header = previewHeader ?? text
219
+ const viewers = panel ? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')] : []
220
+ const codeBlocks = panel?.querySelectorAll("code").length ?? 0
221
+ const diffLines = viewers.reduce(
222
+ (sum, viewer) =>
223
+ sum +
224
+ (viewer.shadowRoot?.querySelectorAll("[data-line]").length ?? viewer.querySelectorAll("[data-line]").length),
225
+ 0,
226
+ )
227
+ const panelVisible =
228
+ !!panel && panel.getAttribute("aria-hidden") !== "true" && !!rect && rect.width > 0 && rect.height > 0
229
+ return {
230
+ panelVisible,
231
+ header: header.slice(0, 500),
232
+ diffViewers: viewers.length,
233
+ diffLines,
234
+ codeBlocks,
235
+ ready:
236
+ panelVisible &&
237
+ header.includes(basename) &&
238
+ (viewers.length > 0 || text.includes("+3") || diffLines > 0 || codeBlocks > 0),
239
+ }
240
+ }
241
+
242
+ const sample = () => {
243
+ if (!running || started === undefined) return
244
+ requestAnimationFrame(() => {
245
+ setTimeout(() => {
246
+ if (!running || started === undefined) return
247
+ samples.push({ observedAtMs: performance.now() - started, ...paneState() })
248
+ if (performance.now() - started < 10_000) sample()
249
+ }, 0)
250
+ })
251
+ }
252
+
253
+ ;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe = {
254
+ samples,
255
+ start: () => {
256
+ started = performance.now()
257
+ performance.mark("opencode.review-pane.click")
258
+ sample()
259
+ },
260
+ stop: () => {
261
+ running = false
262
+ },
263
+ }
264
+ }, input)
265
+ }
266
+
267
+ async function startReviewPaneProbe(page: Page) {
268
+ await page.evaluate(() => {
269
+ ;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe!.start()
270
+ })
271
+ }
272
+
273
+ async function collectReviewPaneProbe(page: Page) {
274
+ await page.waitForFunction((streak) => {
275
+ const samples = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe?.samples
276
+ if (!samples) return false
277
+ return samples.some((_, index) => {
278
+ const stable = samples.slice(index, index + streak)
279
+ return stable.length === streak && stable.every((sample) => sample.ready)
280
+ })
281
+ }, reviewReadyStreak)
282
+
283
+ const samples = await page.evaluate(() => {
284
+ const probe = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe!
285
+ probe.stop()
286
+ return probe.samples
287
+ })
288
+ return { summary: summarizeReviewPaneSamples(samples), samples }
289
+ }
290
+
291
+ function summarizeReviewPaneSamples(samples: ReviewPaneSample[]) {
292
+ const firstReady = samples.find((sample) => sample.ready)
293
+ const stableIndex = samples.findIndex((_, index) => {
294
+ const stable = samples.slice(index, index + reviewReadyStreak)
295
+ return stable.length === reviewReadyStreak && stable.every((sample) => sample.ready)
296
+ })
297
+ return {
298
+ samples: samples.length,
299
+ firstReadyObservedMs: firstReady?.observedAtMs ?? null,
300
+ stableReadyObservedMs: stableIndex === -1 ? null : samples[stableIndex + reviewReadyStreak - 1]!.observedAtMs,
301
+ notReadySamples: samples.filter((sample) => !sample.ready).length,
302
+ maxDiffViewers: Math.max(0, ...samples.map((sample) => sample.diffViewers)),
303
+ maxDiffLines: Math.max(0, ...samples.map((sample) => sample.diffLines)),
304
+ maxCodeBlocks: Math.max(0, ...samples.map((sample) => sample.codeBlocks)),
305
+ }
306
+ }
packages/app/e2e/performance/timeline/session-timeline-profile.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { CDPSession, Page } from "@playwright/test"
2
+
3
+ export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) {
4
+ const cdp = await page.context().newCDPSession(page)
5
+ if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: options.cpuThrottle })
6
+ if (options.profileCPU) {
7
+ await cdp.send("Profiler.enable")
8
+ await cdp.send("Profiler.setSamplingInterval", { interval: 100 })
9
+ await cdp.send("Profiler.start")
10
+ }
11
+ return {
12
+ async stop() {
13
+ if (!options.profileCPU) return
14
+ const result = await cdp.send("Profiler.stop")
15
+ const self = new Map<number, number>()
16
+ result.profile.samples?.forEach((id, index) => {
17
+ const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000
18
+ self.set(id, (self.get(id) ?? 0) + duration)
19
+ })
20
+ console.log(
21
+ "timeline cpu profile",
22
+ JSON.stringify(
23
+ result.profile.nodes
24
+ .map((node) => ({
25
+ function: node.callFrame.functionName || "(anonymous)",
26
+ url: node.callFrame.url,
27
+ line: node.callFrame.lineNumber + 1,
28
+ selfMs: self.get(node.id) ?? 0,
29
+ }))
30
+ .filter((node) => node.selfMs > 1)
31
+ .sort((a, b) => b.selfMs - a.selfMs)
32
+ .slice(0, 40),
33
+ ),
34
+ )
35
+ },
36
+ async reset() {
37
+ if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 })
38
+ },
39
+ }
40
+ }
packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts ADDED
@@ -0,0 +1,547 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page } from "@playwright/test"
2
+
3
+ const STREAM_MARKER_PATTERN = "stream-(\\d+)"
4
+ const STREAM_FRAGMENT_COUNT = 18
5
+
6
+ type TimelineProbeState = {
7
+ started: number
8
+ ended: number
9
+ profileVisual: boolean
10
+ minimal: boolean
11
+ frames: number[]
12
+ frameAt: number[]
13
+ applied: { at: number; index: number }[]
14
+ geometry: {
15
+ scrollTop: number
16
+ scrollHeight: number
17
+ clientHeight: number
18
+ distance: number
19
+ virtualHeight: number
20
+ headerHeight: number
21
+ }[]
22
+ blanks: number
23
+ longTasks: number[]
24
+ layoutShifts: number[]
25
+ visibleMounts: number
26
+ visibleUnmounts: number
27
+ visibleRows: Set<Element>
28
+ visibleSubtreeMounts: string[]
29
+ visibleSubtreeUnmounts: string[]
30
+ visibleSubtreeReplacements: number
31
+ visibleSubtreeDropouts: string[]
32
+ visibleSubtrees: Map<string, Element>
33
+ subtreeKeys: WeakMap<Element, string>
34
+ maxOverlap: number
35
+ maxGap: number
36
+ maxPartTopMovement: number
37
+ previousPartTop: number
38
+ slowFrames: {
39
+ duration: number
40
+ index: number
41
+ phase: "stream" | "boundary" | "complete" | "unknown"
42
+ tokenSpans: number
43
+ blocks: number
44
+ codeBlocks: number
45
+ height: number
46
+ distance: number
47
+ }[]
48
+ scroll: {
49
+ calls: number
50
+ callNoops: number
51
+ sameFrameCalls: number
52
+ assignments: number
53
+ assignmentNoops: number
54
+ lastCallFrame: number
55
+ frame: number
56
+ }
57
+ row: HTMLElement
58
+ markdown: HTMLElement
59
+ running: boolean
60
+ previous: number
61
+ cleanup: () => void
62
+ start: () => void
63
+ }
64
+
65
+ export async function installTimelineStreamProbe(
66
+ page: Page,
67
+ options: { textPartID: string; finalIndex: number; profileVisual: boolean; minimal: boolean },
68
+ ) {
69
+ await page.evaluate(
70
+ ({ textPartID, finalIndex, profileVisual, minimal, markerPattern, fragmentCount }) => {
71
+ const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
72
+ const row = part?.closest<HTMLElement>("[data-timeline-row]")
73
+ const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
74
+ const root = part?.closest<HTMLElement>(".scroll-view__viewport")
75
+ if (!part || !row || !markdown || !root) throw new Error("missing streaming benchmark nodes")
76
+ const viewport = root.getBoundingClientRect()
77
+ const state: TimelineProbeState = {
78
+ started: 0,
79
+ ended: Infinity,
80
+ profileVisual,
81
+ minimal,
82
+ frames: [],
83
+ frameAt: [],
84
+ applied: [],
85
+ geometry: [],
86
+ blanks: 0,
87
+ longTasks: [],
88
+ layoutShifts: [],
89
+ visibleMounts: 0,
90
+ visibleUnmounts: 0,
91
+ visibleRows: new Set(
92
+ [...root.querySelectorAll("[data-timeline-key]")].filter((element) => {
93
+ const rect = element.getBoundingClientRect()
94
+ return rect.bottom > viewport.top && rect.top < viewport.bottom
95
+ }),
96
+ ),
97
+ visibleSubtreeMounts: [],
98
+ visibleSubtreeUnmounts: [],
99
+ visibleSubtreeReplacements: 0,
100
+ visibleSubtreeDropouts: [],
101
+ visibleSubtrees: new Map<string, Element>(),
102
+ subtreeKeys: new WeakMap<Element, string>(),
103
+ maxOverlap: 0,
104
+ maxGap: 0,
105
+ maxPartTopMovement: 0,
106
+ previousPartTop: part.getBoundingClientRect().top,
107
+ slowFrames: [],
108
+ scroll: {
109
+ calls: 0,
110
+ callNoops: 0,
111
+ sameFrameCalls: 0,
112
+ assignments: 0,
113
+ assignmentNoops: 0,
114
+ lastCallFrame: -1,
115
+ frame: 0,
116
+ },
117
+ row,
118
+ markdown,
119
+ running: false,
120
+ previous: 0,
121
+ cleanup: () => {},
122
+ start: () => {},
123
+ }
124
+ ;(window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark = state
125
+ const scrollTo = Element.prototype.scrollTo
126
+ const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
127
+ if (profileVisual) {
128
+ Element.prototype.scrollTo = function (...args) {
129
+ state.scroll.calls += 1
130
+ const top = typeof args[0] === "object" ? args[0]?.top : args[1]
131
+ if (typeof top === "number") {
132
+ const target = Math.min(top, this.scrollHeight - this.clientHeight)
133
+ if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
134
+ }
135
+ if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
136
+ state.scroll.lastCallFrame = state.scroll.frame
137
+ return scrollTo.apply(this, args)
138
+ }
139
+ Object.defineProperty(Element.prototype, "scrollTop", {
140
+ configurable: true,
141
+ get: scrollTop.get,
142
+ set(value) {
143
+ state.scroll.assignments += 1
144
+ if (Math.abs(this.scrollTop - value) < 1) state.scroll.assignmentNoops += 1
145
+ scrollTop.set!.call(this, value)
146
+ },
147
+ })
148
+ }
149
+
150
+ const recordLongTasks = (entries: PerformanceEntry[]) => {
151
+ if (!state.running) return
152
+ state.longTasks.push(
153
+ ...entries
154
+ .filter((entry) => entry.startTime >= state.started && entry.startTime <= state.ended)
155
+ .map((entry) => entry.duration),
156
+ )
157
+ }
158
+ const longTaskObserver = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
159
+ longTaskObserver.observe({ type: "longtask" })
160
+ const recordLayoutShifts = (entries: PerformanceEntry[]) => {
161
+ if (!state.running) return
162
+ state.layoutShifts.push(
163
+ ...entries
164
+ .map((entry) => {
165
+ const shift = entry as LayoutShiftEntry
166
+ if (shift.startTime < state.started || shift.hadRecentInput) return
167
+ return shift.value
168
+ })
169
+ .filter((value): value is number => value !== undefined),
170
+ )
171
+ }
172
+ const layoutShiftObserver = profileVisual
173
+ ? new PerformanceObserver((list) => recordLayoutShifts(list.getEntries()))
174
+ : undefined
175
+ layoutShiftObserver?.observe({ type: "layout-shift", buffered: true })
176
+
177
+ const visible = (element: Element) => {
178
+ const rect = element.getBoundingClientRect()
179
+ const viewport = root.getBoundingClientRect()
180
+ const style = getComputedStyle(element)
181
+ return (
182
+ element.isConnected &&
183
+ rect.width > 0 &&
184
+ rect.height > 0 &&
185
+ rect.bottom > viewport.top &&
186
+ rect.top < viewport.bottom &&
187
+ style.display !== "none" &&
188
+ style.visibility !== "hidden" &&
189
+ Number(style.opacity) > 0
190
+ )
191
+ }
192
+ const critical = [
193
+ "[data-timeline-part-id]",
194
+ '[data-component="edit-content"]',
195
+ '[data-component="apply-patch-file-diff"]',
196
+ '[data-component="file"]',
197
+ '[data-component="markdown-code"]',
198
+ "[data-markdown-block]",
199
+ ].join(",")
200
+ const describe = (element: Element) => {
201
+ const cached = state.subtreeKeys.get(element)
202
+ if (!element.isConnected && cached) return cached
203
+ const part = element.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId ?? "unknown"
204
+ const block = element
205
+ .closest<HTMLElement>("[data-markdown-key]")
206
+ ?.dataset.markdownKey?.replace(/:(?:code|full|live)$/, "")
207
+ const component =
208
+ element.getAttribute("data-component") ?? element.getAttribute("data-markdown-block") ?? element.tagName
209
+ const key = `${part}:${block ?? "root"}:${component}`
210
+ state.subtreeKeys.set(element, key)
211
+ return key
212
+ }
213
+ const recordMutations = (records: MutationRecord[]) => {
214
+ if (!state.running) return
215
+ records.forEach((record) => {
216
+ record.addedNodes.forEach((node) => {
217
+ if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && visible(node)) {
218
+ state.visibleMounts += 1
219
+ state.visibleRows.add(node)
220
+ }
221
+ if (!(node instanceof Element)) return
222
+ const added = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
223
+ added.forEach((element) => {
224
+ if (visible(element)) state.visibleSubtreeMounts.push(describe(element))
225
+ })
226
+ })
227
+ record.removedNodes.forEach((node) => {
228
+ if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && state.visibleRows.delete(node))
229
+ state.visibleUnmounts += 1
230
+ if (!(node instanceof Element)) return
231
+ const removed = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
232
+ removed.forEach((element) => {
233
+ const key = describe(element)
234
+ if (state.visibleSubtrees.get(key) === element) state.visibleSubtreeUnmounts.push(key)
235
+ })
236
+ })
237
+ })
238
+ }
239
+ const mutationObserver = profileVisual ? new MutationObserver(recordMutations) : undefined
240
+ mutationObserver?.observe(root, { childList: true, subtree: true })
241
+ const currentPart = () => root.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
242
+ const observeProgress = (at: number) => {
243
+ if (!state.running) return
244
+ const content = currentPart()?.textContent ?? ""
245
+ const index = content.includes("benchmark-complete")
246
+ ? finalIndex
247
+ : Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
248
+ if (index >= 0 && index !== state.applied.at(-1)?.index) state.applied.push({ at, index })
249
+ }
250
+ const progressObserver = new MutationObserver(() => observeProgress(performance.now()))
251
+ progressObserver.observe(root, { characterData: true, childList: true, subtree: true })
252
+ state.cleanup = () => {
253
+ recordLongTasks(longTaskObserver.takeRecords())
254
+ recordLayoutShifts(layoutShiftObserver?.takeRecords() ?? [])
255
+ recordMutations(mutationObserver?.takeRecords() ?? [])
256
+ if (progressObserver.takeRecords().length) observeProgress(performance.now())
257
+ longTaskObserver.disconnect()
258
+ layoutShiftObserver?.disconnect()
259
+ mutationObserver?.disconnect()
260
+ progressObserver.disconnect()
261
+ if (!profileVisual) return
262
+ Element.prototype.scrollTo = scrollTo
263
+ Object.defineProperty(Element.prototype, "scrollTop", scrollTop)
264
+ }
265
+
266
+ const sample = (now: number) => {
267
+ if (!state.running) return
268
+ state.frameAt.push(now)
269
+ observeProgress(now)
270
+ if (minimal) {
271
+ state.frames.push(now - state.previous)
272
+ state.previous = now
273
+ requestAnimationFrame(sample)
274
+ return
275
+ }
276
+ setTimeout(() => {
277
+ if (!state.running) return
278
+ state.scroll.frame += 1
279
+ const duration = now - state.previous
280
+ state.frames.push(duration)
281
+ state.previous = now
282
+ const virtualRoot = root.querySelector<HTMLElement>("[data-timeline-virtual-content]")
283
+ const header = root.querySelector<HTMLElement>("[data-session-title]")
284
+ state.geometry.push({
285
+ scrollTop: root.scrollTop,
286
+ scrollHeight: root.scrollHeight,
287
+ clientHeight: root.clientHeight,
288
+ distance: root.scrollHeight - root.clientHeight - root.scrollTop,
289
+ virtualHeight: virtualRoot?.getBoundingClientRect().height ?? 0,
290
+ headerHeight: header?.getBoundingClientRect().height ?? 0,
291
+ })
292
+ const viewport = root.getBoundingClientRect()
293
+ if (profileVisual) {
294
+ const visibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
295
+ .map((element) => ({ element, rect: element.getBoundingClientRect() }))
296
+ .filter((item) => item.rect.bottom > viewport.top && item.rect.top < viewport.bottom)
297
+ .sort((a, b) => a.rect.top - b.rect.top)
298
+ state.visibleRows = new Set(visibleRows.map((item) => item.element))
299
+ const rows = visibleRows.map((item) => item.rect)
300
+ rows.slice(1).forEach((rect, index) => {
301
+ const previous = rows[index]!
302
+ state.maxOverlap = Math.max(state.maxOverlap, previous.bottom - rect.top)
303
+ state.maxGap = Math.max(state.maxGap, rect.top - previous.bottom)
304
+ })
305
+ const partTop = part.getBoundingClientRect().top
306
+ state.maxPartTopMovement = Math.max(state.maxPartTopMovement, Math.abs(partTop - state.previousPartTop))
307
+ state.previousPartTop = partTop
308
+ }
309
+ const visibleRow = [...root.querySelectorAll<HTMLElement>("[data-timeline-row]")].some((element) => {
310
+ const rect = element.getBoundingClientRect()
311
+ return rect.bottom > viewport.top && rect.top < viewport.bottom
312
+ })
313
+ if (!visibleRow) state.blanks += 1
314
+ if (profileVisual) {
315
+ const subtrees = new Map<string, { element: Element; rendered: boolean }>()
316
+ const visibleSubtrees = new Map<string, Element>()
317
+ root.querySelectorAll(critical).forEach((element) => {
318
+ const key = describe(element)
319
+ const rect = element.getBoundingClientRect()
320
+ const style = getComputedStyle(element)
321
+ const rendered =
322
+ element.isConnected &&
323
+ rect.width > 0 &&
324
+ rect.height > 0 &&
325
+ style.display !== "none" &&
326
+ style.visibility !== "hidden" &&
327
+ Number(style.opacity) > 0
328
+ subtrees.set(key, { element, rendered })
329
+ if (rendered && rect.bottom > viewport.top && rect.top < viewport.bottom) {
330
+ const previous = state.visibleSubtrees.get(key)
331
+ if (previous && previous !== element && key.startsWith(`${textPartID}:`))
332
+ state.visibleSubtreeReplacements += 1
333
+ visibleSubtrees.set(key, element)
334
+ }
335
+ })
336
+ state.visibleSubtrees.forEach((element, key) => {
337
+ const current = subtrees.get(key)
338
+ if (key.startsWith(`${textPartID}:`) && !current?.rendered) {
339
+ const markdown = part.querySelector<HTMLElement>('[data-component="markdown"]')
340
+ state.visibleSubtreeDropouts.push(
341
+ `${key}:projection=${markdown?.dataset.markdownProjectionLength}/${markdown?.dataset.markdownProjectionBlocks}:result=${markdown?.dataset.markdownResultLength}/${markdown?.dataset.markdownResultBlocks}:applied=${markdown?.dataset.markdownAppliedBlocks}:dom=${markdown?.children.length}`,
342
+ )
343
+ }
344
+ if (element.matches('[data-component="file"]')) {
345
+ const hadLines = element.hasAttribute("data-profiler-had-lines")
346
+ const hasLines = element.shadowRoot?.querySelector("[data-line]") != null
347
+ if (hasLines) element.setAttribute("data-profiler-had-lines", "")
348
+ if (hadLines && !hasLines) state.visibleSubtreeDropouts.push(`${key}:shadow-lines`)
349
+ }
350
+ })
351
+ state.visibleSubtrees = visibleSubtrees
352
+ }
353
+ if (profileVisual && duration > 33.34) {
354
+ const livePart = currentPart()
355
+ const content = livePart?.textContent ?? ""
356
+ const complete = content.includes("benchmark-complete")
357
+ const index = complete
358
+ ? finalIndex
359
+ : Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
360
+ state.slowFrames.push({
361
+ duration,
362
+ index,
363
+ phase: complete
364
+ ? "complete"
365
+ : index >= 0 && index % fragmentCount === 0
366
+ ? "boundary"
367
+ : index >= 0
368
+ ? "stream"
369
+ : "unknown",
370
+ tokenSpans: livePart?.querySelectorAll(".shiki span").length ?? 0,
371
+ blocks: livePart?.querySelectorAll("[data-markdown-block]").length ?? 0,
372
+ codeBlocks: livePart?.querySelectorAll('[data-component="markdown-code"]').length ?? 0,
373
+ height: livePart?.getBoundingClientRect().height ?? 0,
374
+ distance: root.scrollHeight - root.clientHeight - root.scrollTop,
375
+ })
376
+ }
377
+ requestAnimationFrame(sample)
378
+ }, 0)
379
+ }
380
+ state.start = () => {
381
+ state.started = performance.now()
382
+ state.previous = state.started
383
+ state.running = true
384
+ requestAnimationFrame(sample)
385
+ }
386
+ },
387
+ { ...options, markerPattern: STREAM_MARKER_PATTERN, fragmentCount: STREAM_FRAGMENT_COUNT },
388
+ )
389
+ }
390
+
391
+ export function startTimelineStreamProbe(page: Page) {
392
+ return page.evaluate(() => {
393
+ const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
394
+ if (!state) throw new Error("missing streaming benchmark state")
395
+ state.start()
396
+ })
397
+ }
398
+
399
+ type LayoutShiftEntry = PerformanceEntry & { value: number; hadRecentInput?: boolean }
400
+
401
+ export function layoutShiftValue(
402
+ entry: Pick<LayoutShiftEntry, "startTime" | "value" | "hadRecentInput">,
403
+ start: number,
404
+ ) {
405
+ if (entry.startTime < start || entry.hadRecentInput) return
406
+ return entry.value
407
+ }
408
+
409
+ export function removeVisibleRow<T>(visible: Set<T>, row: T) {
410
+ return visible.delete(row)
411
+ }
412
+
413
+ export function streamProgress(content: string) {
414
+ const index = Number(content.match(new RegExp(STREAM_MARKER_PATTERN, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
415
+ return {
416
+ index,
417
+ phase: content.includes("benchmark-complete")
418
+ ? ("complete" as const)
419
+ : index >= 0 && index % STREAM_FRAGMENT_COUNT === 0
420
+ ? ("boundary" as const)
421
+ : index >= 0
422
+ ? ("stream" as const)
423
+ : ("unknown" as const),
424
+ }
425
+ }
426
+
427
+ export async function collectTimelineStreamMetrics(
428
+ page: Page,
429
+ options: { textPartID: string; finalIndex: number; navigations: string[] },
430
+ ) {
431
+ return page.evaluate(({ textPartID, finalIndex, navigations }) => {
432
+ const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
433
+ if (!state) throw new Error(`missing streaming benchmark state after navigation: ${JSON.stringify(navigations)}`)
434
+ state.ended = performance.now()
435
+ state.cleanup()
436
+ state.running = false
437
+ const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
438
+ const row = part?.closest<HTMLElement>("[data-timeline-row]")
439
+ const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
440
+ const sorted = state.frames.slice().sort((a, b) => a - b)
441
+ const duration = state.frames.reduce((sum, value) => sum + value, 0)
442
+ const longestSlowStreak = state.frames.reduce(
443
+ (result, value) => {
444
+ const current = value > 33.34 ? result.current + 1 : 0
445
+ return { current, longest: Math.max(result.longest, current) }
446
+ },
447
+ { current: 0, longest: 0 },
448
+ ).longest
449
+ const busyStart = state.applied.at(0)?.at
450
+ const completion = state.applied.find((value) => value.index === finalIndex)
451
+ const busyEnd = completion?.at
452
+ const busyFrames =
453
+ busyStart === undefined || busyEnd === undefined
454
+ ? []
455
+ : state.frames.filter((_, index) => state.frameAt[index]! >= busyStart && state.frameAt[index]! <= busyEnd)
456
+ const busySorted = busyFrames.slice().sort((a, b) => a - b)
457
+ const busyDuration = busyFrames.reduce((sum, value) => sum + value, 0)
458
+ const completionObservedMs = (completion?.at ?? NaN) - state.started
459
+ const visual = state.profileVisual
460
+ ? {
461
+ layoutShiftValueSum: state.layoutShifts.reduce((sum, value) => sum + value, 0),
462
+ maxLayoutShiftValue: Math.max(0, ...state.layoutShifts),
463
+ visibleMounts: state.visibleMounts,
464
+ visibleUnmounts: state.visibleUnmounts,
465
+ visibleSubtreeMounts: state.visibleSubtreeMounts,
466
+ visibleSubtreeUnmounts: [...new Set(state.visibleSubtreeUnmounts)],
467
+ visibleSubtreeReplacements: state.visibleSubtreeReplacements,
468
+ visibleSubtreeDropouts: [...new Set(state.visibleSubtreeDropouts)],
469
+ maxOverlapPx: state.maxOverlap,
470
+ maxGapPx: state.maxGap,
471
+ maxPartTopMovementPx: state.maxPartTopMovement,
472
+ slowestRafGaps: state.slowFrames
473
+ .sort((a, b) => b.duration - a.duration)
474
+ .slice(0, 20)
475
+ .map((frame) => ({
476
+ durationMs: frame.duration,
477
+ index: frame.index,
478
+ phase: frame.phase,
479
+ tokenSpans: frame.tokenSpans,
480
+ blocks: frame.blocks,
481
+ codeBlocks: frame.codeBlocks,
482
+ heightPx: frame.height,
483
+ distancePx: frame.distance,
484
+ })),
485
+ slowRafGapPhases: Object.fromEntries(
486
+ ["stream", "boundary", "complete", "unknown"].map((phase) => {
487
+ const frames = state.slowFrames.filter((frame) => frame.phase === phase)
488
+ return [
489
+ phase,
490
+ {
491
+ count: frames.length,
492
+ totalMs: frames.reduce((sum, frame) => sum + frame.duration, 0),
493
+ maxMs: Math.max(0, ...frames.map((frame) => frame.duration)),
494
+ },
495
+ ]
496
+ }),
497
+ ),
498
+ scroll: state.scroll,
499
+ }
500
+ : null
501
+ const geometry = state.minimal
502
+ ? null
503
+ : {
504
+ maxDistancePx: Math.max(0, ...state.geometry.map((sample) => sample.distance)),
505
+ finalDistancePx: state.geometry.at(-1)?.distance ?? 0,
506
+ final: state.geometry.at(-1),
507
+ distanceTransitionsPx: state.geometry
508
+ .map((sample) => Math.round(sample.distance))
509
+ .filter((value, index, values) => index === 0 || value !== values[index - 1]),
510
+ bottomDriftTransitions: state.geometry.slice(1).filter((value, index) => {
511
+ const previous = state.geometry[index]?.distance ?? 0
512
+ return previous <= 1 && value.distance > 1
513
+ }).length,
514
+ blankSamples: state.blanks,
515
+ }
516
+ return {
517
+ capabilities: { visual: state.profileVisual, geometry: !state.minimal },
518
+ completionObservedMs,
519
+ deltasPerSecond: Number.isFinite(completionObservedMs) ? finalIndex / (completionObservedMs / 1_000) : null,
520
+ rafGapSamples: state.frames.length,
521
+ rafCallbackRate: duration ? (state.frames.length * 1000) / duration : 0,
522
+ observedProgressWindowRafCallbackRate: busyDuration ? (busyFrames.length * 1000) / busyDuration : null,
523
+ observedProgressWindowRafGapP95Ms: busySorted[Math.floor(busySorted.length * 0.95)] ?? null,
524
+ observedProgressWindowRafGaps: busyFrames.length,
525
+ maxObservedProgressIndex: Math.max(-1, ...state.applied.map((value) => value.index)),
526
+ observedProgressTransitions: state.applied.length,
527
+ rafGapP50Ms: sorted[Math.floor(sorted.length * 0.5)] ?? 0,
528
+ rafGapP95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0,
529
+ rafGapP99Ms: sorted[Math.floor(sorted.length * 0.99)] ?? 0,
530
+ maxRafGapMs: sorted.at(-1) ?? 0,
531
+ rafGapsOver33Ms: state.frames.filter((value) => value > 33.34).length,
532
+ rafGapsOver50Ms: state.frames.filter((value) => value > 50).length,
533
+ missedFrameBudgetEquivalents: state.frames.reduce(
534
+ (sum, value) => sum + Math.max(0, Math.round(value / 16.67) - 1),
535
+ 0,
536
+ ),
537
+ longestRafGapOver33MsStreak: longestSlowStreak,
538
+ longTaskCount: state.longTasks.length,
539
+ longTaskTimeMs: state.longTasks.reduce((sum, value) => sum + value, 0),
540
+ visual,
541
+ geometry,
542
+ rowReplaced: row !== state.row,
543
+ markdownReplaced: markdown !== state.markdown,
544
+ domTextCharacters: part?.textContent?.length ?? 0,
545
+ }
546
+ }, options)
547
+ }
packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const words = [
2
+ "alpha",
3
+ "bravo",
4
+ "charlie",
5
+ "delta",
6
+ "echo",
7
+ "foxtrot",
8
+ "golf",
9
+ "hotel",
10
+ "india",
11
+ "juliet",
12
+ "kilo",
13
+ "lima",
14
+ "metro",
15
+ "nova",
16
+ "orbit",
17
+ "pixel",
18
+ "quartz",
19
+ "river",
20
+ "signal",
21
+ "vector",
22
+ ]
23
+
24
+ const sourceID = "ses_smoke_source"
25
+ const targetID = "ses_smoke_target"
26
+ const childID = "ses_smoke_child"
27
+ const directory = "C:/OpenCode/SmokeProject"
28
+ const projectID = "proj_smoke_timeline"
29
+ const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
30
+
31
+ type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
32
+ type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
33
+ type Message = { info: MessageInfo; parts: MessagePart[] }
34
+
35
+ function lorem(seed: number, length: number) {
36
+ let out = ""
37
+ let i = seed
38
+ while (out.length < length) {
39
+ const word = words[i % words.length]
40
+ out += (out ? " " : "") + word
41
+ if (i % 17 === 0) out += ".\n\n"
42
+ i += 7
43
+ }
44
+ return out.slice(0, length)
45
+ }
46
+
47
+ function id(prefix: string, value: number) {
48
+ return `${prefix}_smoke_${String(value).padStart(4, "0")}`
49
+ }
50
+
51
+ function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
52
+ const messageID = id("msg_user", index)
53
+ return {
54
+ info: {
55
+ id: messageID,
56
+ sessionID,
57
+ role: "user",
58
+ time: { created: 1700000000000 + index * 10_000 },
59
+ summary: { diffs },
60
+ agent: "build",
61
+ model,
62
+ },
63
+ parts: [
64
+ {
65
+ id: id("prt_user_text", index),
66
+ sessionID,
67
+ messageID,
68
+ type: "text",
69
+ text: lorem(index, textLength),
70
+ },
71
+ ],
72
+ }
73
+ }
74
+
75
+ function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
76
+ const messageID = id("msg_assistant", index)
77
+ return {
78
+ info: {
79
+ id: messageID,
80
+ sessionID,
81
+ role: "assistant",
82
+ time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
83
+ parentID,
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
+ finish: "stop",
93
+ },
94
+ parts: parts.map((part) => ({
95
+ ...part,
96
+ sessionID,
97
+ messageID,
98
+ })),
99
+ }
100
+ }
101
+
102
+ function textPart(index: number, partIndex: number, length: number): MessagePart {
103
+ const prose = lorem(index * 13 + partIndex, length)
104
+ const text =
105
+ index % 12 === 0
106
+ ? `${prose}\n\n\`\`\`ts\n${code(index, 80)}\n\`\`\``
107
+ : index % 5 === 0
108
+ ? `${prose}\n\n\`\`\`ts\nexport const value = "${lorem(index, 220)}"\n\`\`\``
109
+ : index % 7 === 0
110
+ ? `${prose}\n\nThe wrapped inline value is \`${lorem(index, 180)}\`.`
111
+ : prose
112
+ return { id: id(`prt_text_${partIndex}`, index), type: "text", text }
113
+ }
114
+
115
+ function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
116
+ return {
117
+ id: id(`prt_reasoning_${partIndex}`, index),
118
+ type: "reasoning",
119
+ text: lorem(index * 19 + partIndex, length),
120
+ time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
121
+ }
122
+ }
123
+
124
+ function toolPart(
125
+ index: number,
126
+ partIndex: number,
127
+ tool: string,
128
+ input: Record<string, unknown>,
129
+ outputLength = 160,
130
+ metadataOverride?: Record<string, unknown>,
131
+ ): MessagePart {
132
+ const metadata =
133
+ metadataOverride ??
134
+ (tool === "apply_patch"
135
+ ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
136
+ : tool === "edit" || tool === "write"
137
+ ? {
138
+ filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
139
+ diff: patch(index, outputLength),
140
+ preview: patch(index + 1, 420),
141
+ }
142
+ : tool === "question"
143
+ ? { answers: [["Proceed"], ["Keep sample output"]] }
144
+ : {})
145
+ return {
146
+ id: id(`prt_tool_${tool}_${partIndex}`, index),
147
+ type: "tool",
148
+ callID: id("call", index * 10 + partIndex),
149
+ tool,
150
+ state: {
151
+ status: "completed",
152
+ input,
153
+ output: lorem(index * 23 + partIndex, outputLength),
154
+ title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
155
+ metadata,
156
+ time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
157
+ },
158
+ }
159
+ }
160
+
161
+ function patchFile(seed: number, type: "add" | "update" | "delete") {
162
+ return {
163
+ filePath: `src/generated/patch-${seed}.ts`,
164
+ relativePath: `src/generated/patch-${seed}.ts`,
165
+ type,
166
+ additions: (seed % 7) + 1,
167
+ deletions: type === "add" ? 0 : seed % 4,
168
+ patch: patch(seed, 520),
169
+ before: type === "add" ? undefined : code(seed, 18),
170
+ after: type === "delete" ? undefined : code(seed + 1, 24),
171
+ }
172
+ }
173
+
174
+ function fileDiff(file: string, seed: number) {
175
+ const lines = seed % 12 === 0 ? 300 : seed % 8 === 0 ? 2 : 38
176
+ const before = code(seed, lines, seed % 10 === 0 ? 280 : 32)
177
+ const after =
178
+ lines === 2
179
+ ? before.replace("value1", "updatedValue1")
180
+ : lines === 300
181
+ ? code(seed + 1, lines, seed % 10 === 0 ? 280 : 32)
182
+ : before.replace("value4", "updatedValue4").replace("value20", "updatedValue20")
183
+ return {
184
+ file,
185
+ additions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
186
+ deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
187
+ before,
188
+ after,
189
+ }
190
+ }
191
+
192
+ function patch(seed: number, length: number) {
193
+ return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
194
+ }
195
+
196
+ function code(seed: number, lines: number, width = 32) {
197
+ return Array.from(
198
+ { length: lines },
199
+ (_, index) => `export const value${index} = "${lorem(seed + index, width)}"`,
200
+ ).join("\n")
201
+ }
202
+
203
+ function turn(index: number): Message[] {
204
+ const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
205
+ const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
206
+ const parts = [
207
+ ...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
208
+ ...(index % 3 === 0
209
+ ? [
210
+ toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
211
+ toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
212
+ toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
213
+ toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
214
+ ]
215
+ : []),
216
+ textPart(index, 2, 160 + (index % 6) * 90),
217
+ ...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
218
+ ...(index % 6 === 0
219
+ ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
220
+ : []),
221
+ ...(index % 8 === 0
222
+ ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
223
+ : []),
224
+ ...(index % 7 === 0
225
+ ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
226
+ : []),
227
+ ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
228
+ ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
229
+ ...(index % 13 === 0
230
+ ? [
231
+ toolPart(
232
+ index,
233
+ 11,
234
+ "question",
235
+ { questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
236
+ 120,
237
+ ),
238
+ ]
239
+ : []),
240
+ ...(index % 17 === 0
241
+ ? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
242
+ : []),
243
+ ]
244
+ return [user, assistantMessage(targetID, index, user.info.id, parts)]
245
+ }
246
+
247
+ const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
248
+ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
249
+ userMessage(sourceID, index + 1000, 120),
250
+ assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [
251
+ textPart(index + 1000, 0, 240),
252
+ ...(index === 11
253
+ ? [
254
+ toolPart(
255
+ index + 1000,
256
+ 1,
257
+ "task",
258
+ { description: "Inspect child navigation", subagent_type: "explore" },
259
+ 160,
260
+ { sessionId: childID },
261
+ ),
262
+ ]
263
+ : []),
264
+ ]),
265
+ ]).flat()
266
+ const childMessages = Array.from({ length: 4 }, (_, index) => [
267
+ userMessage(childID, index + 2000, 120),
268
+ assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
269
+ ]).flat()
270
+
271
+ function renderable(part: MessagePart) {
272
+ if (part.type === "tool" && part.tool === "todowrite") return false
273
+ if (part.type === "text") return !!part.text.trim()
274
+ if (part.type === "reasoning") return !!part.text.trim()
275
+ return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
276
+ }
277
+
278
+ function orderedParts(message: Message) {
279
+ return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
280
+ }
281
+
282
+ export const fixture = {
283
+ directory,
284
+ project: {
285
+ id: projectID,
286
+ worktree: directory,
287
+ vcs: "git",
288
+ name: "smoke-project",
289
+ time: { created: 1700000000000, updated: 1700000000000 },
290
+ sandboxes: [],
291
+ },
292
+ provider: {
293
+ all: [
294
+ {
295
+ id: "opencode",
296
+ name: "OpenCode",
297
+ models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
298
+ },
299
+ ],
300
+ connected: ["opencode"],
301
+ default: { providerID: "opencode", modelID: "claude-opus-4-6" },
302
+ },
303
+ sessions: [
304
+ {
305
+ id: sourceID,
306
+ slug: "source",
307
+ projectID,
308
+ directory,
309
+ title: "Uncommitted changes inquiry",
310
+ version: "dev",
311
+ time: { created: 1700000000000, updated: 1700000000000 },
312
+ },
313
+ {
314
+ id: targetID,
315
+ slug: "target",
316
+ projectID,
317
+ directory,
318
+ title: "Example Game: sample jump movement & sample physics analysis",
319
+ version: "dev",
320
+ time: { created: 1700000001000, updated: 1700000001000 },
321
+ },
322
+ {
323
+ id: childID,
324
+ parentID: sourceID,
325
+ slug: "child",
326
+ projectID,
327
+ directory,
328
+ title: "Inspect child navigation",
329
+ version: "dev",
330
+ time: { created: 1700000002000, updated: 1700000002000 },
331
+ },
332
+ ],
333
+ sourceID,
334
+ targetID,
335
+ childID,
336
+ messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
337
+ expected: {
338
+ sourceTitle: "Uncommitted changes inquiry",
339
+ targetTitle: "Example Game: sample jump movement & sample physics analysis",
340
+ childTitle: "Inspect child navigation",
341
+ sourceMessageIDs: sourceMessages
342
+ .filter((message) => message.info.role === "user")
343
+ .map((message) => message.info.id),
344
+ targetMessageIDs: targetMessages
345
+ .filter((message) => message.info.role === "user")
346
+ .map((message) => message.info.id),
347
+ childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
348
+ targetPartIDs: targetMessages.flatMap((message) =>
349
+ orderedParts(message)
350
+ .filter(renderable)
351
+ .map((part) => part.id),
352
+ ),
353
+ },
354
+ }
355
+
356
+ export function pageMessages(sessionID: string, limit: number, before?: string) {
357
+ const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
358
+ const end = before
359
+ ? Math.max(
360
+ 0,
361
+ messages.findIndex((message) => message.info.id === before),
362
+ )
363
+ : messages.length
364
+ const start = Math.max(0, end - limit)
365
+ return {
366
+ items: messages.slice(start, end),
367
+ cursor: start > 0 ? messages[start]!.info.id : undefined,
368
+ }
369
+ }
packages/app/e2e/performance/timeline/timeline-test-helpers.ts ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page } from "@playwright/test"
2
+ import { base64Encode } from "@opencode-ai/core/util/encode"
3
+ import { mockOpenCodeServer } from "../../utils/mock-server"
4
+ import { fixture, pageMessages } from "./session-timeline-stress.fixture"
5
+
6
+ export async function installTimelineSettings(page: Page) {
7
+ await page.addInitScript(() => {
8
+ localStorage.setItem(
9
+ "settings.v3",
10
+ JSON.stringify({
11
+ general: {
12
+ newLayoutDesigns: true,
13
+ editToolPartsExpanded: true,
14
+ shellToolPartsExpanded: true,
15
+ showReasoningSummaries: true,
16
+ },
17
+ }),
18
+ )
19
+ })
20
+ }
21
+
22
+ export function mockStressTimeline(
23
+ page: Page,
24
+ input?: {
25
+ onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
26
+ vcsDiff?: unknown[]
27
+ },
28
+ ) {
29
+ return mockOpenCodeServer(page, {
30
+ sessions: fixture.sessions,
31
+ provider: fixture.provider,
32
+ directory: fixture.directory,
33
+ project: fixture.project,
34
+ pageMessages,
35
+ onMessages: input?.onMessages,
36
+ vcsDiff: input?.vcsDiff,
37
+ })
38
+ }
39
+
40
+ export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) {
41
+ const server = stressServer()
42
+ await page.addInitScript(
43
+ ({ directory, sessionIDs, dirBase64, server, draftID }) => {
44
+ localStorage.setItem(
45
+ "opencode.global.dat:server",
46
+ JSON.stringify({
47
+ projects: { local: [{ worktree: directory, expanded: true }] },
48
+ lastProject: { local: directory },
49
+ }),
50
+ )
51
+ localStorage.setItem(
52
+ "opencode.window.browser.dat:tabs",
53
+ JSON.stringify([
54
+ ...sessionIDs.map((sessionId) => ({
55
+ type: "session",
56
+ server,
57
+ dirBase64,
58
+ sessionId,
59
+ })),
60
+ ...(draftID ? [{ type: "draft", draftID, server, directory }] : []),
61
+ ]),
62
+ )
63
+ },
64
+ {
65
+ directory: fixture.directory,
66
+ sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID],
67
+ dirBase64: base64Encode(fixture.directory),
68
+ server,
69
+ draftID: input?.draftID,
70
+ },
71
+ )
72
+ }
73
+
74
+ export function stressSessionHref(sessionID: string) {
75
+ return `/server/${base64Encode(stressServer())}/session/${sessionID}`
76
+ }
77
+
78
+ export function stressDraftHref(draftID: string) {
79
+ return `/new-session?draftId=${encodeURIComponent(draftID)}`
80
+ }
81
+
82
+ function stressServer() {
83
+ return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
84
+ }
85
+
86
+ export function createReviewDiffs() {
87
+ return Array.from({ length: Number(process.env.REVIEW_PANE_DIFF_COUNT ?? 72) }, (_, index) => {
88
+ const lines = index % 3 === 0 ? 300 : index % 3 === 1 ? 120 : 38
89
+ const file = `src/review/generated-${String(index).padStart(3, "0")}.ts`
90
+ const before = reviewSource(index, lines)
91
+ const after = before
92
+ .replace(`value_${index}_4`, `updated_${index}_4`)
93
+ .replace(
94
+ `value_${index}_${Math.max(8, Math.floor(lines / 2))}`,
95
+ `updated_${index}_${Math.max(8, Math.floor(lines / 2))}`,
96
+ )
97
+ .replace(`value_${index}_${lines - 4}`, `updated_${index}_${lines - 4}`)
98
+ return {
99
+ file,
100
+ patch: reviewPatch(file, before, after),
101
+ additions: 3,
102
+ deletions: 3,
103
+ status: "modified" as const,
104
+ }
105
+ })
106
+ }
107
+
108
+ function reviewSource(seed: number, lines: number) {
109
+ return Array.from(
110
+ { length: lines },
111
+ (_, index) => `export const value_${seed}_${index} = "${reviewWords(seed + index, index % 5 === 0 ? 180 : 42)}"`,
112
+ ).join("\n")
113
+ }
114
+
115
+ function reviewPatch(file: string, before: string, after: string) {
116
+ const beforeLines = before.split("\n")
117
+ const afterLines = after.split("\n")
118
+ return [
119
+ `diff --git a/${file} b/${file}`,
120
+ `--- a/${file}`,
121
+ `+++ b/${file}`,
122
+ `@@ -1,${beforeLines.length} +1,${afterLines.length} @@`,
123
+ ...beforeLines.flatMap((line, index) => {
124
+ const next = afterLines[index]!
125
+ if (line === next) return [` ${line}`]
126
+ return [`-${line}`, `+${next}`]
127
+ }),
128
+ ].join("\n")
129
+ }
130
+
131
+ function reviewWords(seed: number, length: number) {
132
+ const words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet"]
133
+ return Array.from({ length: Math.ceil(length / 7) }, (_, index) => words[(seed + index * 3) % words.length]).join(" ")
134
+ }
packages/app/e2e/performance/unit/chrome-trace-write.test.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "bun:test"
2
+ import { mkdtemp, rm } from "node:fs/promises"
3
+ import path from "node:path"
4
+ import os from "node:os"
5
+ import { prepareChromeTrace } from "../chrome-trace"
6
+
7
+ test("creates the configured trace directory", async () => {
8
+ const root = await mkdtemp(path.join(os.tmpdir(), "opencode-trace-"))
9
+ try {
10
+ const file = await prepareChromeTrace(path.join(root, "nested", "traces"), "session/tab", false, "test")
11
+ expect(file).toEndWith("-session-tab-458ed9e3-test.json")
12
+ } finally {
13
+ await rm(root, { recursive: true, force: true })
14
+ }
15
+ })
packages/app/e2e/performance/unit/first-navigation-metrics.test.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "bun:test"
2
+ import { summarizeFirstNavigation } from "../timeline/first-navigation-metrics"
3
+
4
+ test("reports blank frames before first destination and stable paint", () => {
5
+ expect(
6
+ summarizeFirstNavigation([
7
+ { observedAtMs: 16, source: true, destination: false, content: true },
8
+ { observedAtMs: 32, source: false, destination: false, content: false },
9
+ { observedAtMs: 48, source: false, destination: true, content: true },
10
+ { observedAtMs: 64, source: false, destination: true, content: true },
11
+ { observedAtMs: 80, source: false, destination: true, content: true },
12
+ ]),
13
+ ).toEqual({
14
+ samples: 5,
15
+ firstDestinationObservedMs: 48,
16
+ stableDestinationObservedMs: 80,
17
+ sourceSamples: 1,
18
+ blankSamples: 1,
19
+ unknownSamples: 0,
20
+ destinationSamples: 3,
21
+ })
22
+ })
23
+
24
+ test("does not report stability for interrupted destination frames", () => {
25
+ expect(
26
+ summarizeFirstNavigation([
27
+ { observedAtMs: 16, source: false, destination: true, content: true },
28
+ { observedAtMs: 32, source: false, destination: false, content: true },
29
+ { observedAtMs: 48, source: false, destination: true, content: true },
30
+ ]).stableDestinationObservedMs,
31
+ ).toBeNull()
32
+ })
packages/app/e2e/performance/unit/mock-server.test.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "bun:test"
2
+ import type { Page, Route } from "@playwright/test"
3
+ import { mockOpenCodeServer } from "../../utils/mock-server"
4
+
5
+ test("applies message latency after a list response gate is released", async () => {
6
+ const events: string[] = []
7
+ const gate = Promise.withResolvers<void>()
8
+ let handler: ((route: Route) => Promise<void>) | undefined
9
+ const page = {
10
+ route: (_url: string, callback: (route: Route) => Promise<void>) => {
11
+ handler = callback
12
+ return Promise.resolve()
13
+ },
14
+ } as unknown as Page
15
+ await mockOpenCodeServer(page, {
16
+ provider: {},
17
+ directory: "C:/OpenCode",
18
+ project: {},
19
+ sessions: [{ id: "session" }],
20
+ messageDelay: 25,
21
+ beforeMessagesResponse: () => {
22
+ events.push("before")
23
+ return gate.promise
24
+ },
25
+ onMessages: (request) => events.push(request.phase),
26
+ pageMessages: () => {
27
+ events.push("page")
28
+ return { items: [] }
29
+ },
30
+ })
31
+
32
+ const response = handler!({
33
+ request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
34
+ fulfill: () => {
35
+ events.push("fulfill")
36
+ return Promise.resolve()
37
+ },
38
+ } as unknown as Route)
39
+ expect(events).toEqual(["start", "before"])
40
+
41
+ const released = performance.now()
42
+ gate.resolve()
43
+ await response
44
+ expect(performance.now() - released).toBeGreaterThanOrEqual(20)
45
+ expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
46
+ })
packages/app/e2e/performance/unit/navigation-milestones.test.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "bun:test"
2
+ import { summarizeNavigationMilestones } from "../timeline/navigation-milestones"
3
+
4
+ test("reports first and stable paint for each navigation milestone", () => {
5
+ expect(
6
+ summarizeNavigationMilestones([
7
+ { observedAtMs: 16, milestones: { content: false, tab: false } },
8
+ { observedAtMs: 32, milestones: { content: true, tab: false } },
9
+ { observedAtMs: 48, milestones: { content: true, tab: true } },
10
+ { observedAtMs: 64, milestones: { content: true, tab: true } },
11
+ { observedAtMs: 80, milestones: { content: true, tab: true } },
12
+ ]),
13
+ ).toEqual({
14
+ samples: 5,
15
+ milestones: {
16
+ content: { firstObservedMs: 32, stableObservedMs: 64 },
17
+ tab: { firstObservedMs: 48, stableObservedMs: 80 },
18
+ },
19
+ all: { firstObservedMs: 48, stableObservedMs: 80 },
20
+ })
21
+ })
22
+
23
+ test("reports missing stability when a milestone appears in the final samples", () => {
24
+ expect(
25
+ summarizeNavigationMilestones([
26
+ { observedAtMs: 16, milestones: { content: false } },
27
+ { observedAtMs: 32, milestones: { content: true } },
28
+ ]),
29
+ ).toEqual({
30
+ samples: 2,
31
+ milestones: { content: { firstObservedMs: 32, stableObservedMs: null } },
32
+ all: { firstObservedMs: 32, stableObservedMs: null },
33
+ })
34
+ })
packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "bun:test"
2
+ import { compressCachedRepaintTrace, layoutShiftSample } from "../timeline/session-tab-repaint-probe"
3
+
4
+ test("compresses repeated repaint states without losing frame samples", () => {
5
+ const state = {
6
+ root: 1,
7
+ scrollTop: 10,
8
+ scrollHeight: 20,
9
+ bottomErrorPx: 0,
10
+ last: true,
11
+ rows: [{ key: "row", node: 2, top: 0, bottom: 10 }],
12
+ mounted: 1,
13
+ center: "content",
14
+ }
15
+ const trace = {
16
+ timeOriginEpochMs: 1_000,
17
+ startedAtPerformanceMs: 100,
18
+ samples: [
19
+ { observedAtMs: 16, ...state, destination: ["target"], source: [] },
20
+ { observedAtMs: 32, ...state, destination: ["target"], source: [] },
21
+ { observedAtMs: 48, ...state, scrollTop: 11, destination: ["target"], source: [] },
22
+ ],
23
+ mutations: [{ observedAtMs: 20, changed: [{ type: "add", node: 2 }] }],
24
+ shifts: [{ occurredAtMs: 24, value: 0.1 }],
25
+ windowMs: 1_000,
26
+ running: false,
27
+ stop() {},
28
+ }
29
+ const compressed = compressCachedRepaintTrace(trace)
30
+ const samples = compressed.samples.flatMap((group) =>
31
+ group.observedAtMs.map((observedAtMs) => ({ observedAtMs, ...group.state })),
32
+ )
33
+
34
+ expect(samples).toEqual(trace.samples)
35
+ expect(compressed.mutations).toEqual(trace.mutations)
36
+ expect(compressed.shifts).toEqual(trace.shifts)
37
+ })
38
+
39
+ test("records layout shifts at occurrence time within the probe window", () => {
40
+ expect(layoutShiftSample({ startTime: 99, value: 0.1 }, 100)).toBeUndefined()
41
+ expect(layoutShiftSample({ startTime: 124, value: 0.2 }, 100)).toEqual({ occurredAtMs: 24, value: 0.2 })
42
+ })
packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "bun:test"
2
+ import { classifySessionSwitch } from "../timeline/session-tab-switch-metrics"
3
+
4
+ test("counts source and blank samples before the destination is observed", () => {
5
+ const result = classifySessionSwitch([
6
+ { observedAtMs: 16, destination: [], source: ["source"], hasVisibleRows: true, last: false },
7
+ { observedAtMs: 32, destination: [], source: [], hasVisibleRows: false, last: false },
8
+ { observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
9
+ { observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
10
+ { observedAtMs: 80, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
11
+ ])
12
+
13
+ expect(result.blankSamples).toBe(1)
14
+ expect(result.sourceSamples).toBe(1)
15
+ expect(result.unknownSamples).toBe(0)
16
+ expect(result.firstDestinationObservedMs).toBe(48)
17
+ expect(result.stableObservedMs).toBe(80)
18
+ })
19
+
20
+ test("does not classify mixed source and destination content as correct", () => {
21
+ const result = classifySessionSwitch([
22
+ {
23
+ observedAtMs: 16,
24
+ destination: ["destination"],
25
+ source: ["source"],
26
+ hasVisibleRows: true,
27
+ last: true,
28
+ bottomErrorPx: 0,
29
+ },
30
+ { observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
31
+ { observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
32
+ { observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
33
+ ])
34
+
35
+ expect(result.firstCorrectObservedMs).toBe(32)
36
+ expect(result.stableObservedMs).toBe(64)
37
+ })
38
+
39
+ test("reports missing correctness without throwing", () => {
40
+ const result = classifySessionSwitch([
41
+ {
42
+ observedAtMs: 16,
43
+ destination: ["destination"],
44
+ source: ["source"],
45
+ hasVisibleRows: true,
46
+ last: true,
47
+ bottomErrorPx: 0,
48
+ },
49
+ ])
50
+
51
+ expect(result.firstDestinationObservedMs).toBe(16)
52
+ expect(result.firstCorrectObservedMs).toBeNull()
53
+ expect(result.stableObservedMs).toBeNull()
54
+ })
55
+
56
+ test("requires an explicitly tracked part to be visible", () => {
57
+ const result = classifySessionSwitch([
58
+ {
59
+ observedAtMs: 16,
60
+ destination: ["destination"],
61
+ source: [],
62
+ hasVisibleRows: true,
63
+ last: true,
64
+ requiredPartVisible: false,
65
+ bottomErrorPx: 0,
66
+ },
67
+ ])
68
+
69
+ expect(result.firstCorrectObservedMs).toBeNull()
70
+ })
71
+
72
+ test("can measure content correctness without requiring a bottom anchor", () => {
73
+ const result = classifySessionSwitch([
74
+ {
75
+ observedAtMs: 16,
76
+ destination: ["destination"],
77
+ source: [],
78
+ hasVisibleRows: true,
79
+ last: true,
80
+ requiredPartVisible: true,
81
+ bottomAnchorRequired: false,
82
+ },
83
+ ])
84
+
85
+ expect(result.firstCorrectObservedMs).toBe(16)
86
+ })
packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "bun:test"
2
+ import type { Page } from "@playwright/test"
3
+ import { measureSessionSwitch } from "../timeline/session-tab-switch-probe"
4
+
5
+ function testPage(waitFailure?: Error) {
6
+ const stops: unknown[] = []
7
+ const page = {
8
+ evaluate: async (_callback: unknown, input?: unknown) => {
9
+ if (input) return
10
+ stops.push(undefined)
11
+ },
12
+ waitForFunction: async () => {
13
+ if (waitFailure) throw waitFailure
14
+ },
15
+ } as unknown as Page
16
+ return { page, stops }
17
+ }
18
+
19
+ function input(run: () => Promise<void>) {
20
+ return {
21
+ destinationIDs: ["destination"],
22
+ sourceIDs: ["source"],
23
+ lastID: "destination",
24
+ href: "/session/destination",
25
+ switch: run,
26
+ }
27
+ }
28
+
29
+ test("stops sampling when the session switch fails", async () => {
30
+ const failure = new Error("switch failed")
31
+ const context = testPage()
32
+
33
+ await expect(
34
+ measureSessionSwitch(
35
+ context.page,
36
+ input(async () => Promise.reject(failure)),
37
+ ),
38
+ ).rejects.toBe(failure)
39
+
40
+ expect(context.stops).toHaveLength(1)
41
+ })
42
+
43
+ test("stops sampling when the stable wait fails", async () => {
44
+ const failure = new Error("stable wait failed")
45
+ const context = testPage(failure)
46
+
47
+ await expect(
48
+ measureSessionSwitch(
49
+ context.page,
50
+ input(async () => {}),
51
+ ),
52
+ ).rejects.toBe(failure)
53
+
54
+ expect(context.stops).toHaveLength(1)
55
+ })
packages/app/e2e/performance/unit/timeline-test-helpers.test.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "bun:test"
2
+ import { base64Encode } from "@opencode-ai/core/util/encode"
3
+ import { fixture } from "../timeline/session-timeline-stress.fixture"
4
+ import { stressSessionHref } from "../timeline/timeline-test-helpers"
5
+
6
+ test("builds stress session links for the benchmark server", () => {
7
+ expect(stressSessionHref(fixture.sourceID)).toBe(
8
+ `/server/${base64Encode("http://127.0.0.1:4096")}/session/${fixture.sourceID}`,
9
+ )
10
+ })
packages/app/e2e/performance/unit/visual-stability.test.ts ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "bun:test"
2
+ import {
3
+ analyzeVisualStability,
4
+ analyzeVisualStabilityByMarker,
5
+ type VisualStabilityTrace,
6
+ } from "../../utils/visual-stability"
7
+ import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer"
8
+ import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
9
+ import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions"
10
+
11
+ function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace {
12
+ return { markers: [], samples }
13
+ }
14
+
15
+ test("accepts continuous visible motion", () => {
16
+ expect(
17
+ analyzeVisualStability(
18
+ trace([
19
+ frame(0, region({ width: 80, bottom: 40 }), region({ top: 40, bottom: 60 })),
20
+ frame(16, region({ width: 75, bottom: 45 }), region({ top: 45, bottom: 65 })),
21
+ frame(32, region({ width: 70, bottom: 50 }), region({ top: 50, bottom: 70 })),
22
+ ]),
23
+ { flow: ["changing", "following"] },
24
+ ),
25
+ ).toEqual([])
26
+ })
27
+
28
+ test("reports repeated geometry reversals", () => {
29
+ const issues = analyzeVisualStability(
30
+ trace([
31
+ frame(0, region({ width: 80 })),
32
+ frame(16, region({ width: 60 })),
33
+ frame(32, region({ width: 78 })),
34
+ frame(48, region({ width: 62 })),
35
+ ]),
36
+ )
37
+
38
+ expect(issues.some((issue) => issue.includes("changing width reversed 2 times"))).toBe(true)
39
+ })
40
+
41
+ test("reports visible blanking, label reversal, and overlap", () => {
42
+ const issues = analyzeVisualStability(
43
+ trace([
44
+ frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
45
+ frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
46
+ frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
47
+ ]),
48
+ { flow: ["changing", "following"] },
49
+ )
50
+
51
+ expect(issues.some((issue) => issue.includes("opacity fell to 0.2"))).toBe(true)
52
+ expect(issues.some((issue) => issue.includes("label reverted"))).toBe(true)
53
+ expect(issues.some((issue) => issue.includes("overlapped following by 1px"))).toBe(true)
54
+ })
55
+
56
+ test("reports duplicate regions and unexpected remounts", () => {
57
+ const issues = analyzeVisualStability(
58
+ trace([frame(0, region({ node: 1 })), frame(16, region({ node: 2, count: 2 })), frame(32, region({ node: 2 }))]),
59
+ { stable: ["changing"], unique: ["changing"] },
60
+ )
61
+
62
+ expect(issues.some((issue) => issue.includes("changing appeared 2 times"))).toBe(true)
63
+ expect(issues.some((issue) => issue.includes("changing remounted"))).toBe(true)
64
+ })
65
+
66
+ test("reports bottom anchor loss but permits movement while scrolled away", () => {
67
+ const anchored = analyzeVisualStability(
68
+ trace([
69
+ { at: 0, regions: { changing: region() }, viewport: viewport(0) },
70
+ { at: 16, regions: { changing: region() }, viewport: viewport(24) },
71
+ ]),
72
+ { preserveBottomAnchor: true },
73
+ )
74
+ const away = analyzeVisualStability(
75
+ trace([
76
+ { at: 0, regions: { changing: region() }, viewport: viewport(80) },
77
+ { at: 16, regions: { changing: region() }, viewport: viewport(104) },
78
+ ]),
79
+ { preserveBottomAnchor: true },
80
+ )
81
+
82
+ expect(anchored.some((issue) => issue.includes("bottom anchor moved to 24px"))).toBe(true)
83
+ expect(away).toEqual([])
84
+ })
85
+
86
+ test("reports up down up movement while preserving a bottom anchor", () => {
87
+ const issues = analyzeVisualStability(
88
+ trace([
89
+ { at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
90
+ { at: 16, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
91
+ { at: 32, regions: { changing: region({ top: 196, bottom: 236 }) }, viewport: viewport(0) },
92
+ { at: 48, regions: { changing: region({ top: 176, bottom: 216 }) }, viewport: viewport(0) },
93
+ ]),
94
+ { preserveBottomAnchor: true, maxPositionReversals: 0 },
95
+ )
96
+
97
+ expect(issues.some((issue) => issue.includes("changing top reversed 2 times"))).toBe(true)
98
+ expect(issues.some((issue) => issue.includes("changing bottom reversed 2 times"))).toBe(true)
99
+ })
100
+
101
+ test("accepts monotonic upward movement while preserving a bottom anchor", () => {
102
+ expect(
103
+ analyzeVisualStability(
104
+ trace([
105
+ { at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
106
+ { at: 16, regions: { changing: region({ top: 190, bottom: 230 }) }, viewport: viewport(0) },
107
+ { at: 32, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
108
+ ]),
109
+ { preserveBottomAnchor: true, maxPositionReversals: 0 },
110
+ ),
111
+ ).toEqual([])
112
+ })
113
+
114
+ test("ignores overlap entirely outside the clipped timeline viewport", () => {
115
+ expect(
116
+ analyzeVisualStability(
117
+ trace([
118
+ {
119
+ at: 0,
120
+ regions: {
121
+ changing: region({ top: -200, bottom: -100 }),
122
+ following: region({ top: -150, bottom: -50 }),
123
+ },
124
+ viewport: viewport(0),
125
+ },
126
+ ]),
127
+ { flow: ["changing", "following"] },
128
+ ),
129
+ ).toEqual([])
130
+ })
131
+
132
+ test("reports visible anchor movement while allowing virtual scrollbar movement", () => {
133
+ const issues = analyzeVisualStability(
134
+ trace([
135
+ { at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(100), scrollTop: 40 } },
136
+ { at: 16, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(120), scrollTop: 60 } },
137
+ ]),
138
+ { fixed: ["anchor"] },
139
+ )
140
+
141
+ expect(issues).toEqual([])
142
+
143
+ const moved = analyzeVisualStability(
144
+ trace([
145
+ { at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: viewport(100) },
146
+ { at: 16, regions: { anchor: region({ top: 112, bottom: 132 }) }, viewport: viewport(100) },
147
+ ]),
148
+ { fixed: ["anchor"] },
149
+ )
150
+ expect(moved.some((issue) => issue.includes("anchor moved 12px in the viewport"))).toBe(true)
151
+ })
152
+
153
+ test("analyzes each marked event independently", () => {
154
+ const input: VisualStabilityTrace = {
155
+ markers: [
156
+ { at: 10, label: "grow" },
157
+ { at: 40, label: "shrink" },
158
+ ],
159
+ samples: [
160
+ frame(0, region({ top: 100 })),
161
+ frame(16, region({ top: 90 })),
162
+ frame(32, region({ top: 80 })),
163
+ frame(48, region({ top: 90 })),
164
+ frame(64, region({ top: 100 })),
165
+ ],
166
+ }
167
+
168
+ expect(analyzeVisualStability(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
169
+ expect(
170
+ analyzeVisualStabilityByMarker(input, {
171
+ maxPositionReversals: 0,
172
+ motion: ["changing"],
173
+ aggregateMotion: false,
174
+ }),
175
+ ).toEqual([])
176
+ })
177
+
178
+ test("reports cross-event motion reversals by default", () => {
179
+ const input: VisualStabilityTrace = {
180
+ markers: [
181
+ { at: 10, label: "up" },
182
+ { at: 40, label: "down" },
183
+ ],
184
+ samples: [
185
+ frame(0, region({ top: 100 })),
186
+ frame(16, region({ top: 90 })),
187
+ frame(32, region({ top: 80 })),
188
+ frame(48, region({ top: 90 })),
189
+ frame(64, region({ top: 100 })),
190
+ ],
191
+ }
192
+
193
+ expect(analyzeVisualStabilityByMarker(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
194
+ })
195
+
196
+ test("reports regions rendered in the wrong flow order", () => {
197
+ const issues = analyzeVisualStability(
198
+ trace([frame(0, region({ top: 100, bottom: 120 }), region({ top: 60, bottom: 80 }))]),
199
+ { flow: ["changing", "following"] },
200
+ )
201
+
202
+ expect(issues.some((issue) => issue.includes("changing rendered after following"))).toBe(true)
203
+ })
204
+
205
+ test("uses painted bounds instead of clipped layout overflow", () => {
206
+ expect(
207
+ analyzeVisualStability(
208
+ trace([
209
+ frame(
210
+ 0,
211
+ region({ top: 100, bottom: 140, height: 40, layoutTop: 100, layoutBottom: 300 }),
212
+ region({ top: 140, bottom: 180 }),
213
+ ),
214
+ ]),
215
+ { flow: ["changing", "following"] },
216
+ ),
217
+ ).toEqual([])
218
+ })
219
+
220
+ test("does not report disappearance when a present row moves outside the viewport", () => {
221
+ expect(
222
+ analyzeVisualStability(
223
+ trace([
224
+ frame(0, region({ visible: true })),
225
+ frame(16, region({ visible: false, inViewport: false, top: -100, bottom: -80 })),
226
+ frame(32, region({ visible: true })),
227
+ ]),
228
+ ),
229
+ ).toEqual([])
230
+ })
231
+
232
+ test("reports an in-viewport transparent frame between visible frames", () => {
233
+ const issues = analyzeVisualStability(
234
+ trace([
235
+ frame(0, region()),
236
+ frame(16, region({ visible: false, opacity: 0, inViewport: true })),
237
+ frame(32, region()),
238
+ ]),
239
+ )
240
+
241
+ expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
242
+ })
243
+
244
+ test("reports an in-viewport display-none frame between visible frames", () => {
245
+ const issues = analyzeVisualStability(
246
+ trace([
247
+ frame(0, region()),
248
+ frame(16, region({ visible: false, width: 0, height: 0, inViewport: true, cssHidden: true })),
249
+ frame(32, region()),
250
+ ]),
251
+ )
252
+
253
+ expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
254
+ })
255
+
256
+ test("can limit motion analysis to unaffected regions", () => {
257
+ expect(
258
+ analyzeVisualStability(
259
+ trace([
260
+ frame(0, region({ height: 20 }), region()),
261
+ frame(16, region({ height: 40 }), region()),
262
+ frame(32, region({ height: 30 }), region()),
263
+ ]),
264
+ { motion: ["following"] },
265
+ ),
266
+ ).toEqual([])
267
+ })
268
+
269
+ test("reports a blank frame across replacement surfaces", () => {
270
+ const issues = analyzeVisualStability(
271
+ {
272
+ markers: [],
273
+ samples: [
274
+ { at: 0, regions: { thinking: region(), error: region({ present: false, visible: false }) } },
275
+ {
276
+ at: 16,
277
+ regions: {
278
+ thinking: region({ present: false, visible: false }),
279
+ error: region({ present: false, visible: false }),
280
+ },
281
+ },
282
+ { at: 32, regions: { thinking: region({ present: false, visible: false }), error: region() } },
283
+ ],
284
+ },
285
+ { continuousAny: [["thinking", "error"]] },
286
+ )
287
+
288
+ expect(issues.some((issue) => issue.includes("thinking | error blanked"))).toBe(true)
289
+ })
290
+
291
+ test("reports failure to acquire the bottom anchor", () => {
292
+ const issues = analyzeVisualStability(
293
+ trace([
294
+ { at: 0, regions: { changing: region() }, viewport: viewport(600) },
295
+ { at: 16, regions: { changing: region() }, viewport: viewport(120) },
296
+ ]),
297
+ { acquireBottomAnchor: true },
298
+ )
299
+
300
+ expect(issues.some((issue) => issue.includes("did not acquire bottom anchor"))).toBe(true)
301
+ })
302
+
303
+ test("reports a required region that never renders", () => {
304
+ const issues = analyzeVisualStability(
305
+ trace([
306
+ {
307
+ at: 0,
308
+ regions: { changing: region({ present: false, visible: false }) },
309
+ },
310
+ ]),
311
+ { required: ["changing"] },
312
+ )
313
+
314
+ expect(issues).toContain("changing never rendered")
315
+ })
316
+
317
+ test("preserves typed region names while mapping definitions", () => {
318
+ const regions = defineVisualRegions({
319
+ changing: { selector: "[data-changing]" },
320
+ following: { selector: "[data-following]", closest: "[data-row]" },
321
+ })
322
+ const selectors = mapVisualRegions(regions, (region) => region.selector)
323
+
324
+ expect(selectors).toEqual({ changing: "[data-changing]", following: "[data-following]" })
325
+ const name: keyof typeof selectors = "changing"
326
+ expect(name).toBe("changing")
327
+ })
328
+
329
+ test("evaluates the typed invariant algebra over explicit observations", () => {
330
+ const regions = defineVisualRegions({
331
+ changing: { selector: "[data-changing]" },
332
+ following: { selector: "[data-following]" },
333
+ })
334
+ const invariants = [
335
+ { type: "required", regions: ["changing"] },
336
+ { type: "flow", regions: ["changing", "following"] },
337
+ ] satisfies VisualInvariant<keyof typeof regions>[]
338
+ // @ts-expect-error Plans reject names that are not in the region definition.
339
+ const invalid = { type: "required", regions: ["missing"] } satisfies VisualInvariant<keyof typeof regions>
340
+ const plan = visualPlan(regions, invariants, { perMarker: true })
341
+
342
+ expect(invalid.regions).toEqual(["missing"])
343
+ expect(plan.perMarker).toBe(true)
344
+ expect(analyzeVisualObservations([frame(0, region({ bottom: 50 }), region({ top: 49, bottom: 69 }))], plan)).toEqual([
345
+ "changing overlapped following by 1px at 0ms",
346
+ ])
347
+ })
348
+
349
+ test("legacy plan adapter preserves analyzer messages and order", () => {
350
+ const input = trace([
351
+ frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
352
+ frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
353
+ frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
354
+ ])
355
+ const options = { flow: ["changing", "following"], stable: ["changing"] }
356
+
357
+ expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual(
358
+ analyzeVisualStability(input, options),
359
+ )
360
+ })
361
+
362
+ function frame(
363
+ at: number,
364
+ changing: VisualStabilityTrace["samples"][number]["regions"][string],
365
+ following?: VisualStabilityTrace["samples"][number]["regions"][string],
366
+ ) {
367
+ return { at, regions: { changing, ...(following ? { following } : {}) } }
368
+ }
369
+
370
+ function region(input: Partial<VisualStabilityTrace["samples"][number]["regions"][string]> = {}) {
371
+ return {
372
+ present: true,
373
+ visible: true,
374
+ inViewport: true,
375
+ top: 0,
376
+ bottom: 20,
377
+ width: 100,
378
+ height: 20,
379
+ opacity: 1,
380
+ count: 1,
381
+ node: 1,
382
+ label: "",
383
+ text: "",
384
+ layoutTop: input.top ?? 0,
385
+ layoutBottom: input.bottom ?? 20,
386
+ ...input,
387
+ }
388
+ }
389
+
390
+ function viewport(distanceFromBottom: number) {
391
+ return { top: 0, bottom: 400, scrollTop: 100, scrollHeight: 500, clientHeight: 400, distanceFromBottom }
392
+ }
packages/app/e2e/reproduction/timeline-suspense/index.html ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Timeline Suspense Reproduction</title>
7
+ <style>
8
+ * {
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ html,
13
+ body,
14
+ #root {
15
+ margin: 0;
16
+ min-height: 100%;
17
+ }
18
+
19
+ body {
20
+ background: #171717;
21
+ color: #f5f5f5;
22
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
23
+ }
24
+
25
+ #root {
26
+ padding: 24px;
27
+ }
28
+ </style>
29
+ </head>
30
+ <body>
31
+ <main id="root"></main>
32
+ <script type="module" src="/main.tsx"></script>
33
+ </body>
34
+ </html>
packages/app/e2e/reproduction/timeline-suspense/main.tsx ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
2
+ import { render } from "solid-js/web"
3
+ import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
4
+ import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
5
+
6
+ const rowCount = 2_000
7
+ const rowHeight = 40
8
+ const parameters = new URLSearchParams(location.search)
9
+ const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline"
10
+ const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline"
11
+
12
+ type MutationEvent = {
13
+ kind: "removed" | "added"
14
+ callbackTime: number
15
+ callbackFrame: number
16
+ routeConnectedInCallback: boolean
17
+ nativeOffsetInCallback: number
18
+ }
19
+
20
+ type Snapshot = {
21
+ mode: {
22
+ resource: "baseline" | "guard"
23
+ reconnect: "baseline" | "candidate"
24
+ }
25
+ operation: {
26
+ sequence: number
27
+ phase: string
28
+ time: number
29
+ frame: number
30
+ }
31
+ resourceState: string
32
+ routeConnected: boolean
33
+ viewportConnected: boolean
34
+ viewportOwnedByRoute: boolean
35
+ sameRoute: boolean
36
+ sameViewport: boolean
37
+ sameSurface: boolean
38
+ sameMountedRows: boolean
39
+ nativeOffset: number
40
+ coreOffset: number
41
+ rangeStart: number
42
+ rangeEnd: number
43
+ indexes: number[]
44
+ domIndexes: number[]
45
+ logicalSurfaceHeight: number
46
+ renderedSurfaceHeight: number
47
+ viewportClientHeight: number
48
+ viewportScrollHeight: number
49
+ visibleRows: number
50
+ minimumRowTop: number
51
+ domScrollEvents: number
52
+ lastScrollTrusted: boolean
53
+ coreOffsetCallbackCalls: number
54
+ offsetCallbackSources: "observer"[]
55
+ rectObserverCallbacks: number
56
+ ignoredDetachedZeroRects: number
57
+ syntheticScrollDispatches: number
58
+ mutationEvents: MutationEvent[]
59
+ }
60
+
61
+ declare global {
62
+ interface Window {
63
+ timelineSuspense: {
64
+ prepare: () => Promise<Snapshot>
65
+ trigger: () => void
66
+ resolve: () => void
67
+ frames: (count?: number) => Promise<void>
68
+ snapshot: () => Snapshot
69
+ }
70
+ }
71
+ }
72
+
73
+ function App() {
74
+ const [refresh, setRefresh] = createSignal(false)
75
+ let resolveResource: (() => void) | undefined
76
+ const [resource] = createResource(
77
+ refresh,
78
+ (version) =>
79
+ new Promise<string>((resolve) => {
80
+ resolveResource = () => resolve(`settled-${version}`)
81
+ }),
82
+ { initialValue: "settled" },
83
+ )
84
+
85
+ function Route() {
86
+ let route: HTMLElement | undefined
87
+ let viewport: HTMLDivElement | undefined
88
+ let surface: HTMLDivElement | undefined
89
+ let initialRoute: HTMLElement | undefined
90
+ let initialViewport: HTMLDivElement | undefined
91
+ let initialSurface: HTMLDivElement | undefined
92
+ let initialRows: HTMLElement[] = []
93
+ let phase = "mounting"
94
+ let browserFrame = 0
95
+ let snapshotSequence = 0
96
+ let domScrollEvents = 0
97
+ let lastScrollTrusted = false
98
+ let coreOffsetCallbackCalls = 0
99
+ let rectObserverCallbacks = 0
100
+ let ignoredDetachedZeroRects = 0
101
+ const offsetCallbackSources: "observer"[] = []
102
+ const mutationEvents: MutationEvent[] = []
103
+ const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
104
+ count: rowCount,
105
+ getScrollElement: () => viewport ?? null,
106
+ estimateSize: () => rowHeight,
107
+ initialRect: { width: 900, height: 600 },
108
+ overscan: 2,
109
+ observeElementRect: (instance, callback) =>
110
+ observeElementRect(instance, (rect) => {
111
+ rectObserverCallbacks++
112
+ // A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect.
113
+ if (!instance.scrollElement?.isConnected && rect.height === 0) {
114
+ ignoredDetachedZeroRects++
115
+ return
116
+ }
117
+ callback(rect)
118
+ }),
119
+ observeElementOffset: (instance, callback) => {
120
+ const deliver = (offset: number, isScrolling: boolean) => {
121
+ coreOffsetCallbackCalls++
122
+ offsetCallbackSources.push("observer")
123
+ callback(offset, isScrolling)
124
+ }
125
+ if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver)
126
+ return observeElementOffset(instance, deliver)
127
+ },
128
+ })
129
+
130
+ const frames = async (count = 2) => {
131
+ for (let index = 0; index < count; index++) {
132
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
133
+ }
134
+ }
135
+ const mountedRows = () => [...(surface?.querySelectorAll<HTMLElement>("[data-row-index]") ?? [])]
136
+ const snapshot = (): Snapshot => {
137
+ const rows = mountedRows()
138
+ const view = viewport?.getBoundingClientRect()
139
+ const visibleRows =
140
+ viewport?.isConnected && view
141
+ ? rows.filter((row) => {
142
+ const rect = row.getBoundingClientRect()
143
+ return rect.bottom > view.top && rect.top < view.bottom
144
+ }).length
145
+ : 0
146
+ return {
147
+ mode: { resource: resourceMode, reconnect: reconnectMode },
148
+ operation: {
149
+ sequence: ++snapshotSequence,
150
+ phase,
151
+ time: performance.now(),
152
+ frame: browserFrame,
153
+ },
154
+ resourceState: resource.state,
155
+ routeConnected: route?.isConnected ?? false,
156
+ viewportConnected: viewport?.isConnected ?? false,
157
+ viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport),
158
+ sameRoute: route === initialRoute,
159
+ sameViewport: viewport === initialViewport,
160
+ sameSurface: surface === initialSurface,
161
+ sameMountedRows:
162
+ initialRows.length > 0 &&
163
+ initialRows.length === rows.length &&
164
+ initialRows.every((row, index) => row === rows[index]),
165
+ nativeOffset: viewport?.scrollTop ?? -1,
166
+ coreOffset: virtualizer.scrollOffset ?? -1,
167
+ rangeStart: virtualizer.range?.startIndex ?? -1,
168
+ rangeEnd: virtualizer.range?.endIndex ?? -1,
169
+ indexes: virtualizer.getVirtualItems().map((item) => item.index),
170
+ domIndexes: rows.map((row) => Number(row.dataset.rowIndex)),
171
+ logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"),
172
+ renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1,
173
+ viewportClientHeight: viewport?.clientHeight ?? -1,
174
+ viewportScrollHeight: viewport?.scrollHeight ?? -1,
175
+ visibleRows,
176
+ minimumRowTop:
177
+ rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1,
178
+ domScrollEvents,
179
+ lastScrollTrusted,
180
+ coreOffsetCallbackCalls,
181
+ offsetCallbackSources: [...offsetCallbackSources],
182
+ rectObserverCallbacks,
183
+ ignoredDetachedZeroRects,
184
+ syntheticScrollDispatches: 0,
185
+ mutationEvents: mutationEvents.map((event) => ({ ...event })),
186
+ }
187
+ }
188
+
189
+ onMount(() => {
190
+ if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount")
191
+ const routeRoot = route.parentElement
192
+ if (!routeRoot) throw new Error("Timeline route root did not mount")
193
+ initialRoute = route
194
+ initialViewport = viewport
195
+ initialSurface = surface
196
+ viewport.addEventListener("scroll", (event) => {
197
+ domScrollEvents++
198
+ lastScrollTrusted = event.isTrusted
199
+ })
200
+ const countFrames = () => {
201
+ browserFrame++
202
+ requestAnimationFrame(countFrames)
203
+ }
204
+ requestAnimationFrame(countFrames)
205
+ new MutationObserver((records) => {
206
+ const callbackTime = performance.now()
207
+ records.forEach((record) => {
208
+ ;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => {
209
+ if (node !== route) return
210
+ phase = "detached"
211
+ mutationEvents.push({
212
+ kind: "removed",
213
+ callbackTime,
214
+ callbackFrame: browserFrame,
215
+ routeConnectedInCallback: route.isConnected,
216
+ nativeOffsetInCallback: viewport.scrollTop,
217
+ })
218
+ })
219
+ ;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => {
220
+ if (node !== route) return
221
+ phase = "reinserted"
222
+ mutationEvents.push({
223
+ kind: "added",
224
+ callbackTime,
225
+ callbackFrame: browserFrame,
226
+ routeConnectedInCallback: route.isConnected,
227
+ nativeOffsetInCallback: viewport.scrollTop,
228
+ })
229
+ })
230
+ })
231
+ }).observe(routeRoot, { childList: true })
232
+ window.timelineSuspense = {
233
+ prepare: async () => {
234
+ phase = "preparing"
235
+ await frames(2)
236
+ viewport.scrollTop = viewport.scrollHeight
237
+ await frames(3)
238
+ await new Promise((resolve) => setTimeout(resolve, 200))
239
+ await frames(2)
240
+ initialRows = mountedRows()
241
+ phase = "prepared"
242
+ return snapshot()
243
+ },
244
+ trigger: () => {
245
+ phase = "triggering"
246
+ setRefresh(true)
247
+ },
248
+ resolve: () => {
249
+ if (!resolveResource) throw new Error("Resource is not pending")
250
+ phase = "resolving"
251
+ resolveResource()
252
+ },
253
+ frames,
254
+ snapshot,
255
+ }
256
+ })
257
+
258
+ return (
259
+ <section ref={route} data-route style={{ width: "900px", margin: "0 auto" }}>
260
+ <span aria-hidden="true" style={{ display: "none" }}>
261
+ {resourceMode === "guard" && resource.state === "refreshing" ? resource.latest : resource()}
262
+ </span>
263
+ <div
264
+ ref={viewport}
265
+ data-viewport
266
+ style={{
267
+ height: "600px",
268
+ overflow: "auto",
269
+ "overflow-anchor": "none",
270
+ position: "relative",
271
+ background: "#202020",
272
+ outline: "1px solid #3f3f46",
273
+ }}
274
+ >
275
+ <div
276
+ ref={surface}
277
+ data-surface
278
+ style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", "overflow-anchor": "none" }}
279
+ >
280
+ <For each={virtualizer.getVirtualItems()}>
281
+ {(item) => (
282
+ <div
283
+ data-row-index={item.index}
284
+ style={{
285
+ position: "absolute",
286
+ top: "0",
287
+ left: "0",
288
+ width: "100%",
289
+ height: `${item.size}px`,
290
+ transform: `translateY(${item.start}px)`,
291
+ padding: "10px 14px",
292
+ border: "0 solid #333",
293
+ "border-bottom-width": "1px",
294
+ }}
295
+ >
296
+ logical row {item.index}
297
+ </div>
298
+ )}
299
+ </For>
300
+ </div>
301
+ </div>
302
+ </section>
303
+ )
304
+ }
305
+
306
+ return (
307
+ <main>
308
+ <Suspense>
309
+ <Route />
310
+ </Suspense>
311
+ </main>
312
+ )
313
+ }
314
+
315
+ render(() => <App />, document.getElementById("root")!)
packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, devices } from "@playwright/test"
2
+
3
+ const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317)
4
+
5
+ export default defineConfig({
6
+ testDir: ".",
7
+ testMatch: "timeline-suspense.repro.ts",
8
+ outputDir: "../../test-results/timeline-suspense",
9
+ fullyParallel: false,
10
+ workers: 1,
11
+ retries: 0,
12
+ reporter: "line",
13
+ timeout: 30_000,
14
+ expect: {
15
+ timeout: 10_000,
16
+ },
17
+ webServer: {
18
+ command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
19
+ cwd: import.meta.dirname,
20
+ url: `http://127.0.0.1:${port}`,
21
+ reuseExistingServer: false,
22
+ },
23
+ use: {
24
+ baseURL: `http://127.0.0.1:${port}`,
25
+ trace: "retain-on-failure",
26
+ screenshot: "only-on-failure",
27
+ },
28
+ projects: [
29
+ {
30
+ name: "chromium",
31
+ use: { ...devices["Desktop Chrome"] },
32
+ },
33
+ ],
34
+ })
packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Page } from "@playwright/test"
2
+
3
+ test.beforeEach(async ({ page }) => {
4
+ page.on("pageerror", (error) => console.error(error))
5
+ await page.goto("/")
6
+ await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true)
7
+ })
8
+
9
+ test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => {
10
+ await page.goto("/?reconnect=candidate")
11
+ await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate")
12
+ const before = await prepare(page)
13
+ await triggerBaselineSuspension(page)
14
+ const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
15
+ expect(pending.nativeOffset).toBe(0)
16
+ expect(pending.coreOffset).toBe(before.coreOffset)
17
+ expect(pending.indexes).toEqual(before.indexes)
18
+ expect(pending.sameRoute).toBe(true)
19
+ expect(pending.sameViewport).toBe(true)
20
+ expect(pending.sameSurface).toBe(true)
21
+ expect(pending.sameMountedRows).toBe(true)
22
+
23
+ await resolveSuspension(page)
24
+ await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0)
25
+ await page.waitForTimeout(250)
26
+ await page.evaluate(() => window.timelineSuspense.frames(2))
27
+ const after = await page.evaluate(() => window.timelineSuspense.snapshot())
28
+ expect(after.sameRoute).toBe(true)
29
+ expect(after.sameViewport).toBe(true)
30
+ expect(after.sameSurface).toBe(true)
31
+ expect(after.nativeOffset).toBe(0)
32
+ expect(after.coreOffset).toBe(0)
33
+ expect(after.rangeStart).toBeLessThan(10)
34
+ expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0)
35
+ expect(after.domScrollEvents).toBe(before.domScrollEvents)
36
+ expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1)
37
+ expect(after.offsetCallbackSources.at(-1)).toBe("observer")
38
+ expect(after.syntheticScrollDispatches).toBe(0)
39
+ })
40
+
41
+ test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({
42
+ page,
43
+ }) => {
44
+ const before = await prepare(page)
45
+ const beforeRows = before.domIndexes
46
+ expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" })
47
+ expect(before.logicalSurfaceHeight).toBe(80_000)
48
+ expect(before.renderedSurfaceHeight).toBe(80_000)
49
+ expect(before.viewportClientHeight).toBe(600)
50
+ expect(before.viewportScrollHeight).toBe(80_000)
51
+ expect(before.rangeStart).toBeGreaterThan(1_900)
52
+ expect(before.nativeOffset).toBe(before.coreOffset)
53
+ expect(before.visibleRows).toBeGreaterThan(0)
54
+
55
+ await triggerBaselineSuspension(page)
56
+ const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
57
+ expect(pending.resourceState).toBe("refreshing")
58
+ expect(pending.routeConnected).toBe(false)
59
+ expect(pending.viewportConnected).toBe(false)
60
+ expect(pending.viewportOwnedByRoute).toBe(true)
61
+ expect(pending.nativeOffset).toBe(0)
62
+ expect(pending.coreOffset).toBe(before.coreOffset)
63
+ expect(pending.rangeStart).toBe(before.rangeStart)
64
+ expect(pending.rangeEnd).toBe(before.rangeEnd)
65
+ expect(pending.indexes).toEqual(before.indexes)
66
+ expect(pending.domIndexes).toEqual(beforeRows)
67
+ expect(pending.sameMountedRows).toBe(true)
68
+ expect(pending.domScrollEvents).toBe(before.domScrollEvents)
69
+ expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
70
+ expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects)
71
+ expect(pending.mutationEvents).toHaveLength(1)
72
+ expect(pending.mutationEvents[0]).toMatchObject({
73
+ kind: "removed",
74
+ routeConnectedInCallback: false,
75
+ nativeOffsetInCallback: 0,
76
+ })
77
+ expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time)
78
+ expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame)
79
+
80
+ const after = await resolveSuspension(page)
81
+ expect(after.resourceState).toBe("ready")
82
+ expect(after.routeConnected).toBe(true)
83
+ expect(after.viewportConnected).toBe(true)
84
+ expect(after.viewportOwnedByRoute).toBe(true)
85
+ expect(after.sameRoute).toBe(true)
86
+ expect(after.sameViewport).toBe(true)
87
+ expect(after.sameSurface).toBe(true)
88
+ expect(after.sameMountedRows).toBe(true)
89
+ expect(after.nativeOffset).toBe(0)
90
+ expect(after.coreOffset).toBe(before.coreOffset)
91
+ expect(after.rangeStart).toBe(before.rangeStart)
92
+ expect(after.rangeEnd).toBe(before.rangeEnd)
93
+ expect(after.indexes).toEqual(before.indexes)
94
+ expect(after.domIndexes).toEqual(beforeRows)
95
+ expect(after.domScrollEvents).toBe(before.domScrollEvents)
96
+ expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
97
+ expect(after.mutationEvents).toHaveLength(2)
98
+ expect(after.mutationEvents[1]).toMatchObject({
99
+ kind: "added",
100
+ routeConnectedInCallback: true,
101
+ nativeOffsetInCallback: 0,
102
+ })
103
+ expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time)
104
+ expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame)
105
+ expect(after.visibleRows).toBe(0)
106
+ expect(after.minimumRowTop).toBeGreaterThan(50_000)
107
+ expect(after.syntheticScrollDispatches).toBe(0)
108
+
109
+ await page.locator("[data-viewport]").hover()
110
+ await page.mouse.wheel(0, 80)
111
+ await expect
112
+ .poll(() =>
113
+ page.evaluate(() => {
114
+ const value = window.timelineSuspense.snapshot()
115
+ return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset
116
+ }),
117
+ )
118
+ .toBe(true)
119
+ await page.evaluate(() => window.timelineSuspense.frames(2))
120
+ const recovered = await page.evaluate(() => window.timelineSuspense.snapshot())
121
+ expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents)
122
+ expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls)
123
+ expect(recovered.offsetCallbackSources.at(-1)).toBe("observer")
124
+ expect(recovered.lastScrollTrusted).toBe(true)
125
+ expect(recovered.rangeStart).toBeLessThan(10)
126
+ expect(recovered.visibleRows).toBeGreaterThan(0)
127
+ })
128
+
129
+ test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => {
130
+ await page.goto("/?resource=guard")
131
+ await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard")
132
+ const before = await prepare(page)
133
+
134
+ await page.evaluate(() => window.timelineSuspense.trigger())
135
+ await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
136
+ await page.evaluate(() => window.timelineSuspense.frames(3))
137
+ const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
138
+ expect(pending.routeConnected).toBe(true)
139
+ expect(pending.mutationEvents).toEqual([])
140
+ expect(pending.nativeOffset).toBe(before.nativeOffset)
141
+ expect(pending.coreOffset).toBe(before.coreOffset)
142
+ expect(pending.visibleRows).toBeGreaterThan(0)
143
+
144
+ const after = await resolveSuspension(page)
145
+ expect(after.routeConnected).toBe(true)
146
+ expect(after.nativeOffset).toBe(before.nativeOffset)
147
+ expect(after.coreOffset).toBe(before.coreOffset)
148
+ expect(after.visibleRows).toBeGreaterThan(0)
149
+ })
150
+
151
+ async function prepare(page: Page) {
152
+ const before = await page.evaluate(() => window.timelineSuspense.prepare())
153
+ expect(before.routeConnected).toBe(true)
154
+ expect(before.viewportConnected).toBe(true)
155
+ expect(before.viewportOwnedByRoute).toBe(true)
156
+ expect(before.sameMountedRows).toBe(true)
157
+ expect(before.rangeStart).toBeGreaterThan(1_900)
158
+ expect(before.nativeOffset).toBe(before.coreOffset)
159
+ return before
160
+ }
161
+
162
+ async function triggerBaselineSuspension(page: Page) {
163
+ await page.evaluate(() => window.timelineSuspense.trigger())
164
+ await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
165
+ await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false)
166
+ await page.evaluate(() => window.timelineSuspense.frames(3))
167
+ }
168
+
169
+ async function resolveSuspension(page: Page) {
170
+ await page.evaluate(() => window.timelineSuspense.resolve())
171
+ await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready")
172
+ await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true)
173
+ await page.evaluate(() => window.timelineSuspense.frames(3))
174
+ return page.evaluate(() => window.timelineSuspense.snapshot())
175
+ }
176
+
177
+ function diagnostic(value: unknown) {
178
+ return JSON.stringify(value, null, 2)
179
+ }
packages/app/e2e/reproduction/timeline-suspense/vite.config.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite"
2
+ import solid from "vite-plugin-solid"
3
+
4
+ export default defineConfig({
5
+ root: import.meta.dirname,
6
+ plugins: [solid()],
7
+ })