Files
opencode/packages/app/e2e/regression/timeline-geometry.spec.ts

923 lines
38 KiB
TypeScript

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<HTMLElement>(contextSelector)
const text = document.querySelector<HTMLElement>(following)
const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
const virtualRow = context?.closest<HTMLElement>("[data-timeline-key]")
const textRow = text?.closest<HTMLElement>('[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<HTMLElement>('[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<keyof typeof regions>(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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<void>()
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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>("[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<HTMLElement>("[data-timeline-key]")!.getBoundingClientRect().height
const content = element.closest<HTMLElement>("[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)
})