import { expect, test, type Locator, type Page } from "@playwright/test" import { timelinePresets } from "@opencode/session-ui/timeline/detail" import { createTwoFilesPatch } from "diff" import { assistantMessage, historyMessages, partUpdated, renderedPartID, session, sessionID, setupTimeline, shell, textPart, toolPart, userMessage, userText, validateTimelineMessages, waitForVisualSettle, } from "../utils/timeline" import { analyzeVisualObservations, defineVisualRegions, startVisualProbe, stopVisualProbe, visualPlan, } from "../utils/visual-stability" const expanded = { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true } const contextTools = [ { id: "ctx_0100_read", tool: "read", input: { path: "src/recent-a.ts", offset: 0, limit: 120 } }, { id: "ctx_0101_glob", tool: "glob", input: { path: "C:/OpenCode/TimelineStability", pattern: "**/*.ts" } }, { id: "ctx_0102_grep", tool: "grep", input: { path: "C:/OpenCode/TimelineStability", pattern: "Explored", include: "*.ts" }, }, { id: "ctx_0103_list", tool: "list", input: { path: "src" } }, ] const contextSelector = `[data-timeline-part-ids="${contextTools.map((tool) => tool.id).join(",")}"]` const output = (tool: string) => `Completed ${tool}.\n${"detail line\n".repeat(8)}` function contextTurn(status: "running" | "completed") { return [ userMessage(), assistantMessage([ ...contextTools.map((tool) => status === "running" ? toolPart(tool.id, tool.tool, "running", tool.input) : toolPart(tool.id, tool.tool, "completed", tool.input, { output: output(tool.tool) }), ), textPart("prt_after_context", "This assistant text is immediately after the explored context group."), ]), ] } test("remeasures a recent explored context group before the next paint", async ({ page }) => { await setupTimeline(page, { seedHistory: true, settings: expanded, messages: contextTurn("completed") }) const following = `[data-timeline-part-id="${renderedPartID("prt_after_context")}"]` await expect(page.locator(contextSelector)).toBeVisible() await expect(page.locator(following)).toBeVisible() await waitForVisualSettle(page, [contextSelector, following]) // Samples every painted frame after the click until the open group's row holds its measured content, so a // stale row height shows up as overlap before the virtualizer catches up. const samples = await page.evaluate( ({ contextSelector, following }) => new Promise<{ frame: number; overlap: number; expanded: string | null; measured: boolean }[]>((resolve) => { const context = document.querySelector(contextSelector) const text = document.querySelector(following) const scroller = context?.closest(".scroll-view__viewport") const trigger = context?.querySelector('[data-slot="collapsible-trigger"]') const contextRow = context?.closest('[data-timeline-row="AssistantPart"]') const virtualRow = context?.closest("[data-timeline-key]") const textRow = text?.closest('[data-timeline-row="AssistantPart"]') if (!scroller || !trigger || !contextRow || !virtualRow || !textRow) throw new Error("missing regression nodes") scroller.scrollTop = scroller.scrollHeight const samples: { frame: number; overlap: number; expanded: string | null; measured: boolean }[] = [] const capture = (frame: number) => { const content = context!.querySelector('[data-slot="collapsible-content"]') const allocated = virtualRow.getBoundingClientRect().height const inner = virtualRow.firstElementChild?.getBoundingClientRect().height ?? Infinity samples.push({ frame, overlap: Math.max( 0, Math.round((contextRow.getBoundingClientRect().bottom - textRow.getBoundingClientRect().top) * 10) / 10, ), expanded: trigger.getAttribute("aria-expanded"), measured: !!content && Math.abs(content.getBoundingClientRect().height - content.scrollHeight) <= 1 && Math.abs(allocated - inner) <= 1, }) } capture(-1) trigger.click() capture(0) const tick = (frame: number) => setTimeout(() => { capture(frame) const last = samples.at(-1)! if (last.expanded === "true" && last.measured) return resolve(samples) requestAnimationFrame(() => tick(frame + 1)) }, 0) requestAnimationFrame(() => tick(1)) }), { contextSelector, following }, ) expect(samples[0]?.overlap).toBe(0) expect(samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)).toEqual([]) expect(samples.at(-1)).toMatchObject({ expanded: "true", measured: true }) }) test("keeps a grouped tool summary stable as its calls complete", async ({ page }) => { const timeline = await setupTimeline(page, { seedHistory: true, settings: expanded, messages: contextTurn("running"), cpuRate: 4, }) const context = page.locator(contextSelector) const label = "Used 4 Read, Glob, Grep, List" const trigger = context.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]') await expect(trigger).toHaveAccessibleName(label) // Open the group so each call shows whether it is still running. await trigger.click() const status = (tool: string) => context.locator( `[data-slot="context-tool-group-item"] [data-component="text-shimmer"][aria-label="${tool[0]!.toUpperCase()}${tool.slice(1)}"]`, ) for (const tool of contextTools) await expect(status(tool.tool)).toHaveAttribute("data-active", "true") const following = `[data-timeline-part-id="${renderedPartID("prt_after_context")}"]` await waitForVisualSettle(page, [contextSelector, following]) const regions = defineVisualRegions({ status: { selector: `${contextSelector} [data-component="context-tool-group-trigger"]` }, context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' }, following: { selector: following, closest: '[data-timeline-row="AssistantPart"]' }, }) await startVisualProbe(page, regions) // Each completion lands in its own settled frame so the probe sees every intermediate state. for (const tool of contextTools) { await timeline.send( partUpdated(toolPart(tool.id, tool.tool, "completed", tool.input, { output: output(tool.tool) })), ) await expect(status(tool.tool)).toHaveAttribute("data-active", "false") await waitForVisualSettle(page, [contextSelector, following]) } for (const tool of contextTools) await expect(status(tool.tool)).toHaveAttribute("data-active", "false") await expect(trigger).toHaveAccessibleName(label) const trace = await stopVisualProbe(page) const labels = trace.samples .map((sample) => sample.regions.status?.label) .filter((value): value is string => !!value) .filter((value, index, all) => value !== all[index - 1]) const issues = analyzeVisualObservations( trace.samples, visualPlan(regions, [ { type: "required", regions: ["context", "following"] }, { type: "opacity", regions: "all" }, { type: "continuity", regions: "all" }, { type: "motion", regions: "all" }, { type: "label-stability", regions: "all" }, { type: "flow", regions: ["context", "following"] }, ]), ) expect(labels).toEqual([label]) expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([]) }) test("keeps a file diff anchored while it expands and collapses", async ({ page }) => { const id = "prt_file_projection_anchored_patch" const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("") const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n") await setupTimeline(page, { settings: { timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } }, }, messages: [ userMessage(), assistantMessage([ textPart("prt_file_projection_preceding", "Preceding context ".repeat(240)), toolPart( id, "patch", "completed", { patchText: "Update src/anchored.ts" }, { metadata: { files: [ { file: "src/anchored.ts", status: "modified", patch: createTwoFilesPatch("a/src/anchored.ts", "b/src/anchored.ts", before, after), additions: 80, deletions: 80, }, ], }, }, ), ]), ], viewport: { width: 1200, height: 600 }, }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) const wrapper = page.locator(`[data-timeline-part-id="${id}"]`) const diff = wrapper.locator('[data-component="apply-patch-file-diff"]') const row = page.locator("[data-timeline-key]", { has: wrapper }) const trigger = wrapper.getByRole("button") const bottom = () => scroller.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop)) const measured = () => row.evaluate((element) => { const content = element.querySelector("[data-index]") return content ? Math.abs(element.getBoundingClientRect().height - content.getBoundingClientRect().height) : Number.POSITIVE_INFINITY }) await expect(trigger).toHaveAttribute("aria-expanded", "false") await expect.poll(measured).toBeLessThanOrEqual(1) // Pinned to the tail, toggling the diff keeps the view at the bottom. for (const visible of [true, false]) { await trigger.click() await expect(diff).toHaveCount(visible ? 1 : 0) await expect.poll(bottom).toBeLessThanOrEqual(1) } // Scrolled away from the tail, the header keeps its viewport position. await expect .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight)) .toBeGreaterThan(1) await scroller.evaluate((element) => { element.scrollTop = element.scrollHeight - element.clientHeight - 0.25 }) await expect(trigger).toBeInViewport() await expect.poll(bottom).toBeLessThanOrEqual(0.5) const bottomScrollTop = await scroller.evaluate((element) => element.scrollTop) await scroller.hover() await page.mouse.wheel(0, -20) await expect .poll(() => scroller.evaluate((element, bottom) => bottom - element.scrollTop, bottomScrollTop)) .toBeGreaterThan(0) const y = await trigger.evaluate((element) => element.getBoundingClientRect().y) const collapsedHeight = await row.evaluate((element) => element.getBoundingClientRect().height) await trigger.click() await expect(diff).toBeVisible() await expect .poll(() => row.evaluate((element, collapsed) => { const content = element.querySelector("[data-index]") const allocated = element.getBoundingClientRect().height return { grew: allocated > collapsed + 1, measured: content ? Math.abs(allocated - content.getBoundingClientRect().height) <= 1 : false, } }, collapsedHeight), ) .toEqual({ grew: true, measured: true }) await expect .poll(() => trigger.evaluate((element, initialY) => Math.abs(element.getBoundingClientRect().y - initialY), y)) .toBeLessThanOrEqual(5) const scrollTop = await scroller.evaluate((element) => element.scrollTop) await scroller.hover() await page.mouse.wheel(0, 200) await expect .poll(() => scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)) .toBeGreaterThan(50) expect(await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)).toBeLessThan(400) const expandedY = await trigger.evaluate((element) => element.getBoundingClientRect().y) await trigger.click() await expect(diff).toHaveCount(0) await expect.poll(bottom).toBeLessThanOrEqual(1) await expect.poll(() => trigger.evaluate((element) => element.getBoundingClientRect().y)).toBeGreaterThan(expandedY) await trigger.click() await expect(diff).toBeVisible() await expect.poll(measured).toBeLessThanOrEqual(1) await expect.poll(bottom).toBeLessThanOrEqual(1) }) for (const outline of [ { name: "shell outline at 1.25x", kind: "shell", deviceScaleFactor: 1.25 }, { name: "shell outline at 1.5x", kind: "shell", deviceScaleFactor: 1.5 }, { name: "patch card", kind: "patch", deviceScaleFactor: undefined }, ] as const) { test(`keeps the ${outline.name} inside a fractionally short virtual row`, async ({ page }) => { const partID = outline.kind === "shell" ? "prt_shell_outline" : "prt_patch_outline" const secondUserID = "msg_outline_second_user" const tool = outline.kind === "shell" ? shell(partID, "completed", "shell output") : toolPart( partID, "patch", "completed", { patchText: "Update src/outline.ts" }, { metadata: { files: [ { file: "src/outline.ts", status: "modified", patch: "diff --git a/src/outline.ts b/src/outline.ts\n--- a/src/outline.ts\n+++ b/src/outline.ts\n@@ -1 +1 @@\n-const outline = false\n+const outline = true\n", additions: 1, deletions: 1, }, ], }, }, ) // A second turn adds the fixed turn gap, which must not get the paint-rounding margin. await setupTimeline(page, { messages: [ userMessage(undefined, { summary: { diffs: [ { file: "src/summary.ts", additions: 1, deletions: 1, status: "modified", patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2", }, ], }, }), assistantMessage([tool]), userMessage(undefined, { id: secondUserID, created: 1700000010000 }), assistantMessage([], { id: "msg_outline_second_assistant", parentID: secondUserID, created: 1700000011000 }), ], settings: outline.kind === "shell" ? { shellToolPartsExpanded: true } : { timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } } }, reducedMotion: true, deviceScaleFactor: outline.deviceScaleFactor, }) await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) => elements.map((element) => ({ tag: element.querySelector("[data-timeline-row]")?.dataset.timelineRow, clipMargin: getComputedStyle(element).overflowClipMargin, })), ) expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true) expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }]) const part = page.locator(`[data-timeline-part-id="${partID}"]`) const row = page.locator("[data-timeline-key]", { has: part }) if (outline.kind === "patch") { const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]') await expect(card.getByRole("button")).toHaveAttribute("aria-expanded", "false") const geometry = await row.evaluate((element) => { const card = element.querySelector('[data-component="accordion"][data-scope="apply-patch"]') if (!card) throw new Error("Patch card is unavailable") const cardRect = card.getBoundingClientRect() element.style.height = `${cardRect.bottom - element.getBoundingClientRect().top - 0.49}px` const clipMargin = getComputedStyle(element).overflowClipMargin const bottom = element.getBoundingClientRect().bottom return { overflow: card.getBoundingClientRect().bottom - bottom, paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin), cardWidth: cardRect.width, cardHeight: cardRect.height, } }) expect(geometry.overflow).toBeCloseTo(0.49, 1) expect(geometry.paintOverflow).toBeLessThanOrEqual(0) const edges = await captureCardEdges(page, card) expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2) expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2) expect(edges.luminance.top).toBeLessThan(245) expect(edges.luminance.bottom).toBeLessThan(245) expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10) return } const bash = part.locator('[data-component="bash-output"]') await expect(bash).toBeVisible() await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"] [data-component="bash-output"]`]) const geometry = await row.evaluate((element) => { const output = element.querySelector('[data-component="bash-output"]') if (!output) throw new Error("Shell output is unavailable") const outputRect = output.getBoundingClientRect() // Match a rounded-down measurement at a fractional device-pixel phase. element.style.height = `${outputRect.bottom - element.getBoundingClientRect().top - 0.49}px` element.style.transform = "translateY(0.25px)" output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)") output.style.setProperty("background", "rgb(0, 0, 0)", "important") const style = getComputedStyle(output) return { outputWidth: outputRect.width, outputHeight: outputRect.height, borderColor: style.borderTopColor, boxShadow: style.boxShadow, } }) await expect .poll(() => row.evaluate( (element) => element.querySelector('[data-component="bash-output"]')!.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom, ), ) .toBeCloseTo(0.49, 1) expect(await page.evaluate(() => devicePixelRatio)).toBe(outline.deviceScaleFactor) const edges = await captureCardEdges(page, bash) expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2) expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2) expect(geometry.borderColor).toBe("rgb(255, 0, 255)") expect(geometry.boxShadow).toBe("none") expect(edges.magenta.top).toBeGreaterThan(0.75) expect(edges.magenta.bottom).toBeGreaterThan(0.75) expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2) }) } for (const failed of [false, true]) { test(`keeps a submitted prompt in place until Working shows${failed ? " after an error" : ""}`, async ({ page }) => { await setupTimeline(page, { sessionMessages: [ ...validateTimelineMessages([ ...historyMessages(18), userMessage(), ...(failed ? [{ ...assistantMessage([]), error: { type: "provider.error", message: "Previous request failed" } }] : []), ]), // The previous execution ended at an idle marker, so the submitted prompt starts a turn instead of // waiting as a steer behind that one. { id: "msg_1002_timeline_idle", type: "idle", outcome: failed ? "failed" : "succeeded", time: { created: 1700000003000 }, }, ], }) const working = page.locator('[data-component="session-working"]') await expect(working).toHaveCount(0) const release = Promise.withResolvers() await page.route(`**/api/session/${sessionID}/prompt`, async (route) => { if (route.request().method() !== "POST") return route.fallback() await release.promise return route.fallback() }) const text = "Observe optimistic prompt spacing." const editor = page.locator('[data-component="composer"]').getByRole("textbox") await expect(editor).toBeEditable() await editor.fill(text) await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled() const bottom = () => page.locator("[data-timeline-virtual-content]").evaluate((element) => { const root = element.parentElement! return root.scrollHeight - root.clientHeight - root.scrollTop }) await expect.poll(bottom).toBe(0) // Records the prompt row in every frame from submission until Working has shown. const observation = await page.evaluateHandle((text) => { const frames: { prompt: number | undefined; working: boolean }[] = [] let frame = 0 const sample = () => { const prompt = [...document.querySelectorAll('[data-timeline-row="UserMessage"]')].find((row) => row.textContent?.includes(text), ) frames.push({ prompt: prompt?.getBoundingClientRect().y, working: !!document.querySelector('[data-component="session-working"]'), }) frame = requestAnimationFrame(sample) } frame = requestAnimationFrame(sample) return { stop: () => { cancelAnimationFrame(frame) return frames }, } }, text) const requested = page.waitForRequest( (request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`, ) try { await editor.press("Enter") expect((await requested).postDataJSON()).toMatchObject({ text }) await expect(page.locator('[data-timeline-row="UserMessage"]').filter({ hasText: text })).toBeInViewport() await expect(working).toHaveRole("status") await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working") await expect(working).toBeInViewport() await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect.poll(bottom).toBe(0) const frames = await observation.evaluate((value) => value.stop()) expect(frames.some((frame) => frame.working && frame.prompt === undefined)).toBe(false) const positions = frames.flatMap((frame) => (frame.prompt === undefined ? [] : [frame.prompt])) expect(positions.length).toBeGreaterThan(0) expect(new Set(positions).size).toBe(1) } finally { release.resolve() await observation.dispose() } }) } async function captureCardEdges(page: Page, card: Locator) { const box = await card.boundingBox() if (!box) throw new Error("Tool card bounds are unavailable") const viewport = page.viewportSize() if (!viewport) throw new Error("Viewport bounds are unavailable") const screenshot = await page.screenshot() return page.evaluate( async ({ source, box, viewport }) => { const image = new Image() image.src = source await image.decode() const canvas = document.createElement("canvas") canvas.width = image.naturalWidth canvas.height = image.naturalHeight const context = canvas.getContext("2d") if (!context) throw new Error("2D canvas is unavailable") context.drawImage(image, 0, 0) const scale = { x: image.naturalWidth / viewport.width, y: image.naturalHeight / viewport.height } const rows = (candidates: number[]) => { const left = Math.floor((box.x + 8) * scale.x) const width = Math.floor((box.width - 16) * scale.x) return candidates.map((row) => { const pixels = context.getImageData(left, row, width, 1).data const indexes = Array.from({ length: width }, (_, index) => index * 4) return { luminance: indexes .map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3) .reduce((sum, value) => sum + value, 0) / width, magenta: indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200) .length / width, } }) } const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data const columns = new Uint32Array(image.naturalWidth) for (let index = 0; index < pixels.length; index += 4) { if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1 } const top = box.y * scale.y const bottom = (box.y + box.height) * scale.y const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)]) const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1]) return { box, luminance: { top: Math.min(...topRows.map((row) => row.luminance)), bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance, }, magenta: { top: Math.max(...topRows.map((row) => row.magenta)), bottom: Math.max(...bottomRows.map((row) => row.magenta)), vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length, }, } }, { source: `data:image/png;base64,${screenshot.toString("base64")}`, viewport, box }, ) } test("scrolls the timeline behind the composer region, fading at the card, and keeps its end above the region", async ({ page, }) => { await setupTimeline(page, { sessionMessages: [ ...validateTimelineMessages(historyMessages(18)), { id: "msg_9000_user", type: "user", text: "Split the work.", time: { created: 1 } }, { id: "msg_9001_assistant", type: "assistant", agent: "build", model: { id: "model", providerID: "provider" }, content: [ { type: "tool", id: "call_running_subagent", name: "subagent", state: { status: "running", input: { description: "Trace frame budget", agent: "explore" }, metadata: { sessionID: "ses_running_subagent" }, }, time: { created: 2 }, }, { type: "text", id: "prt_timeline_end", text: "The last answer ends above the composer region." }, ], time: { created: 2 }, }, ], sessions: [ session(), session({ id: "ses_running_subagent", parentID: sessionID, title: "Trace frame budget", agent: "explore" }), ], sessionStatus: { [sessionID]: { type: "busy" }, ses_running_subagent: { type: "busy" } }, }) const dock = page.locator('[data-component="session-composer-dock"]') const editor = page.locator('[data-component="composer"]').getByRole("textbox") await expect(page.getByRole("button", { name: "1 subagent", exact: true })).toBeVisible() await expect(page.locator('[data-component="session-background-hint-row"]')).toBeVisible() await expect(page.getByText("The last answer ends above the composer region.", { exact: true })).toBeVisible() const geometry = () => page.evaluate(() => { const viewport = document.querySelector('[data-slot="session-timeline-scroll"] > *')! const region = document.querySelector('[data-component="session-composer-dock"]')! const dock = region.getBoundingClientRect() const card = region.querySelector('[data-component="composer"]')!.getBoundingClientRect() const pill = region.querySelector('[data-slot="session-activity-chip"]')!.getBoundingClientRect() const hint = viewport.querySelector('[data-component="session-background-hint-row"]')!.getBoundingClientRect() const parts = [...viewport.querySelectorAll("[data-timeline-part-id]")] const last = parts.find((element) => element.textContent?.includes("The last answer ends"))! return { // The scroll area runs on behind the composer region to the panel's bottom edge. behind: Math.round(viewport.getBoundingClientRect().bottom - dock.bottom), inset: Math.round(viewport.querySelector("[data-timeline-inset]")!.offsetHeight - dock.height), // Content fades out where the composer card starts. fade: Math.round( dock.bottom - card.top - parseFloat(getComputedStyle(viewport).getPropertyValue("--session-timeline-inset")), ), // The pill floats above the region out of flow, so it never changes the inset the timeline scrolls above. floats: pill.bottom <= dock.top, // It floats in the timeline's reserved tail, below the background hint and the last answer. reserved: hint.bottom <= pill.top && last.getBoundingClientRect().bottom <= hint.top, atEnd: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop <= 1, } }) const expected = { behind: 0, inset: 0, fade: 0, floats: true, reserved: true, atEnd: true } await expect.poll(geometry).toEqual(expected) const before = (await dock.boundingBox())!.height await editor.click() await page.keyboard.type("A draft") for (const line of ["that grows", "the composer", "by several lines"]) { await page.keyboard.press("Shift+Enter") await page.keyboard.type(line) } await expect.poll(async () => (await dock.boundingBox())!.height).toBeGreaterThan(before + 40) await expect.poll(geometry).toEqual(expected) }) test("groups consecutive sent prompts under the last one's meta line, with each bubble's actions beside it", async ({ page, }) => { const prompt = (id: string, text: string, created: number) => userMessage([userText(text, { id: `prt_${id}_text` })], { id, created }) await setupTimeline(page, { messages: [ prompt("msg_0001_run", "Group these prompts.", 1700000000000), prompt("msg_0002_run", "Like the iOS app does.", 1700000001000), ], }) const row = (id: string) => page.locator(`[data-timeline-virtual-content] [data-timeline-row="UserMessage"][data-message-id="${id}"]`) const first = row("msg_0001_run") const last = row("msg_0002_run") const bubble = (row: Locator) => row.locator('[data-slot="user-message-text"]') await expect(bubble(last)).toHaveText("Like the iOS app does.") // Bubbles of a run sit 4px apart, as in the iOS app, and only the last carries the meta line. await expect .poll(async () => { const above = (await bubble(first).boundingBox())! const below = (await bubble(last).boundingBox())! return Math.round(below.y - above.y - above.height) }) .toBe(4) await expect(first.locator('[data-slot="user-message-copy-wrapper"]')).toBeHidden() await expect(last.locator('[data-slot="user-message-copy-wrapper"]')).toBeVisible() // The first message keeps its own actions, shown beside its bubble on hover. await bubble(first).hover() await expect(first.locator('[data-slot="user-message-run-actions"]')).toHaveCSS("opacity", "1") await expect(first.getByRole("button", { name: "More actions" })).toHaveCount(1) }) test("leaves a long prompt unfolded while Fold long messages is off", async ({ page }) => { const text = Array.from({ length: 20 }, (_, index) => `Long line ${index + 1}`).join("\n") await setupTimeline(page, { messages: [ userMessage([userText(text, { id: "prt_msg_0001_nofold_text" })], { id: "msg_0001_nofold", created: 1700000000000, }), ], viewport: { width: 1200, height: 700 }, }) const row = page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_0001_nofold"]') await expect(row).toContainText("Long line 20") await expect(row.locator('[data-slot="user-message-fold"]')).toHaveCount(0) await expect(row.getByRole("button", { name: "Read more" })).toHaveCount(0) }) test("folds a prompt past 12 lines to its first 8 and keeps it open after scrolling out of view", async ({ page }) => { const lines = (name: string, count: number) => Array.from({ length: count }, (_, index) => `${name} line ${index + 1}`).join("\n") const prompt = (id: string, text: string, created: number) => userMessage([userText(text, { id: `prt_${id}_text` })], { id, created }) const reply = (id: string, parentID: string, created: number) => assistantMessage( [{ id: `prt_${id}_text`, type: "text", text: `Reply ${id}. ${"Later work fills the timeline. ".repeat(12)}` }], { id, parentID, created }, ) await setupTimeline(page, { messages: [ prompt("msg_0001_fold_edge", lines("Edge", 12), 1700000000000), reply("msg_0002_fold_edge", "msg_0001_fold_edge", 1700000001000), prompt("msg_0003_fold_long", lines("Long", 13), 1700000002000), reply("msg_0004_fold_long", "msg_0003_fold_long", 1700000003000), ...Array.from({ length: 16 }, (_, index) => { const created = 1700000010000 + index * 2_000 const id = `msg_${String(100 + index * 2).padStart(4, "0")}_fold_later` return [prompt(id, `Later prompt ${index}`, created), reply(`${id}_reply`, id, created + 1_000)] }).flat(), ], settings: { foldUserMessages: true }, viewport: { width: 1200, height: 700 }, }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) const row = (id: string) => page.locator(`[data-timeline-virtual-content] [data-timeline-row="UserMessage"][data-message-id="${id}"]`) const edge = row("msg_0001_fold_edge") const long = row("msg_0003_fold_long") const bubble = long.locator('[data-slot="user-message-text"]') const fold = long.locator('[data-slot="user-message-fold"]') const lastLater = row("msg_0130_fold_later") await expect(lastLater).toBeVisible() // A wheel, not a scroll write, so the timeline stops following its end. await scroller.hover() await page.mouse.wheel(0, -100_000) await expect(long).toBeVisible() // 12 lines show in full; 13 fold to 8, and all 13 stay in the text for copy and select-all. await expect(edge.locator('[data-slot="user-message-fold"]')).toContainText("Edge line 12") await expect(edge.getByRole("button", { name: "Read more" })).toHaveCount(0) const toggle = long.getByRole("button", { name: /^(Read more|Show less)$/ }) await expect(toggle).toHaveAccessibleName("Read more") await expect(toggle).toHaveAttribute("aria-expanded", "false") const lineHeight = await fold.evaluate((element) => parseFloat(getComputedStyle(element).lineHeight)) await expect.poll(async () => Math.round((await fold.boundingBox())!.height / lineHeight)).toBe(8) expect( await fold.evaluate((element) => { const selection = getSelection()! selection.selectAllChildren(element) const text = selection.toString() selection.removeAllRanges() return text }), ).toContain("Long line 13") // A cached tab's timeline is detached while hidden; the message stays folded rather than measuring 0 lines. expect( await bubble.evaluate(async (element) => { const parent = element.parentElement! const next = element.nextSibling element.remove() await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) const folded = element.querySelector('[data-slot="user-message-fold"]')!.hasAttribute("data-folded") parent.insertBefore(element, next) return folded }), ).toBe(true) await expect(toggle).toHaveAttribute("aria-expanded", "false") // Opening grows the bubble downward, and the virtual row takes its new height. await bubble.evaluate((element) => element.scrollIntoView({ block: "center" })) const top = (await bubble.boundingBox())!.y await toggle.click() await expect(toggle).toHaveAttribute("aria-expanded", "true") await expect(toggle).toHaveAccessibleName("Show less") await expect.poll(async () => Math.round((await fold.boundingBox())!.height / lineHeight)).toBe(13) expect(Math.abs((await bubble.boundingBox())!.y - top)).toBeLessThanOrEqual(1) await expect .poll(() => long.evaluate((element) => { const allocated = element.closest("[data-timeline-key]")!.getBoundingClientRect().height const content = element.closest("[data-index]")!.getBoundingClientRect().height return Math.abs(allocated - content) }), ) .toBeLessThanOrEqual(1) // The row unmounts at the end of the timeline and comes back open. await page.mouse.wheel(0, 100_000) await expect(lastLater).toBeVisible() await expect(long).toHaveCount(0) await page.mouse.wheel(0, -100_000) await expect(long.getByRole("button", { name: "Show less" })).toHaveAttribute("aria-expanded", "true") await expect.poll(async () => Math.round((await fold.boundingBox())!.height / lineHeight)).toBe(13) // Closing a message whose top is on screen shrinks it in place, without scrolling the timeline. await scroller.evaluate( (element, target) => { element.scrollTop += target!.y - element.getBoundingClientRect().top - 100 }, await bubble.boundingBox(), ) const closing = { top: (await bubble.boundingBox())!.y, scroll: await scroller.evaluate((element) => element.scrollTop), } expect(closing.scroll).toBeGreaterThan(150) await toggle.click() await expect(toggle).toHaveAttribute("aria-expanded", "false") expect(Math.abs((await bubble.boundingBox())!.y - closing.top)).toBeLessThanOrEqual(1) expect(await scroller.evaluate((element) => element.scrollTop)).toBe(closing.scroll) // Find skips the Read more button, and a match hidden in the fold opens the message. const highlights = () => page.evaluate(() => ["timeline-search-hit", "timeline-search-hit-active"].reduce( (count, name) => count + (CSS.highlights.get(name)?.size ?? 0), 0, ), ) await page.keyboard.press("ControlOrMeta+f") const search = page.locator('[data-component="timeline-search-bar"] input') await expect(search).toBeFocused() await search.fill("more") await expect.poll(highlights).toBe(0) await search.fill("Long line 13") await expect(toggle).toHaveAttribute("aria-expanded", "true") await expect.poll(highlights).toBe(1) })