Skip to content

Commit 3cf7180

Browse files
authored
fix(app): stabilize session timeline layout continuity (#34533)
1 parent f266e82 commit 3cf7180

57 files changed

Lines changed: 6159 additions & 142 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)