fix(app): correct turn visibility and simplify previews (#57682)

This commit is contained in:
usrnk1
2026-10-21 10:09:13 +02:00
committed by GitHub
Unverified
parent b58bd26132
commit 5a3e2fdeeb
3 changed files with 174 additions and 62 deletions
@@ -155,33 +155,6 @@ test.describe("timeline history", () => {
await expect(page.locator("#message-msg_00001_turn_a_user")).toBeInViewport()
await expect.poll(activeIndexes).toEqual([0, 1])
// The 48px header and floating composer delimit which prompts are actually visible.
await page.setViewportSize({ width: 820, height: 500 })
await turns.nth(0).click()
const firstPrompt = page.locator('#message-msg_00000_turn_a_user [data-slot="user-message-text"]')
const secondPrompt = page.locator('#message-msg_00001_turn_a_user [data-slot="user-message-text"]')
const visibleArea = page.locator('[data-slot="session-timeline-visible"]')
await expect
.poll(async () => {
const prompt = await firstPrompt.boundingBox()
const area = await visibleArea.boundingBox()
return !!prompt && !!area && prompt.y >= area.y + 48 && prompt.y + prompt.height <= area.y + area.height
})
.toBe(true)
await expect
.poll(async () => {
const prompt = await secondPrompt.boundingBox()
const area = await visibleArea.boundingBox()
return !!prompt && !!area && prompt.y >= area.y + area.height
})
.toBe(true)
await expect.poll(activeIndexes).toEqual([0])
await page.setViewportSize({ width: 1400, height: 900 })
await turns.nth(0).click()
await expect.poll(activeIndexes).toEqual([0, 1])
// A reply-only turn is not highlighted while a different prompt remains visible.
const replyID = renderedPartID("prt_00000_turn_text")
await timelineScroller(page).evaluate((scroller, id) => {
@@ -268,6 +241,145 @@ test.describe("timeline history", () => {
await expect(page.locator(`#message-${turnID(5)}`)).not.toBeInViewport()
})
test("keeps navigator highlights inside the reading viewport and previews readable without fades", async ({
page,
}) => {
const prompt =
"Let's create a follow up here, and add a setting to the experimental tab that enables or disables it. It should be on by default. Do it in a separate branch based on the latest origin/beta."
const response =
"The setting is ready on a separate branch. It defaults to enabled and remembers your preference across reloads. Disabling it removes the navigator and stops its background requests. The existing navigation shortcuts remain available."
await setupTimeline(page, {
messages: Array.from({ length: 6 }, (_, index) => [
userMessage([userText(index === 2 ? prompt : `Prompt ${index}`)], { id: `msg_polish_${index}_user` }),
assistantMessage(
[
textPart(
`prt_polish_${index}`,
index === 2
? response
: [0, 5].includes(index)
? Array.from({ length: 30 }, () => `Reply ${index}. More context for scrolling.`).join("\n\n")
: `Reply ${index}.`,
),
],
{
id: `msg_polish_${index}_reply`,
parentID: `msg_polish_${index}_user`,
},
),
]).flat(),
settings: { showTurnNavigator: true },
reducedMotion: true,
viewport: { width: 820, height: 650 },
})
const navigator = page.locator('[data-component="session-turn-navigator"]')
const turns = navigator.locator("[data-turn]")
const preview = navigator.locator('[data-slot="session-turn-navigator-preview"]')
const scroller = timelineScroller(page)
await expect(turns).toHaveCount(6)
// Leave only the prompt's metadata below the sticky header, then move it behind the composer.
for (const edge of ["header", "composer"] as const) {
await turns.nth(2).click()
await expect
.poll(() =>
scroller.evaluate((root) => {
const prompt = root.querySelector<HTMLElement>('#message-msg_polish_2_user [data-slot="user-message-text"]')
if (!prompt) return false
const header = parseFloat(getComputedStyle(root).getPropertyValue("--sticky-accordion-top")) || 0
const offset = prompt.getBoundingClientRect().top - root.getBoundingClientRect().top - header
return offset >= 15 && offset <= 18
}),
)
.toBe(true)
await scroller.hover()
const delta = await scroller.evaluate((root, edge) => {
const prompt = root.querySelector<HTMLElement>('#message-msg_polish_2_user [data-slot="user-message-text"]')
if (!prompt) throw new Error("Expected the prompt bubble")
const view = root.getBoundingClientRect()
const box = prompt.getBoundingClientRect()
const header = parseFloat(getComputedStyle(root).getPropertyValue("--sticky-accordion-top")) || 0
const inset = parseFloat(getComputedStyle(root).getPropertyValue("--session-timeline-inset")) || 0
return edge === "header" ? box.bottom - view.top - header + 8 : box.top - view.bottom + inset - 8
}, edge)
await page.mouse.wheel(0, delta)
await expect
.poll(() =>
scroller.evaluate((root, edge) => {
const prompt = root.querySelector<HTMLElement>('#message-msg_polish_2_user [data-slot="user-message-text"]')
if (!prompt) return true
const box = prompt.getBoundingClientRect()
const view = root.getBoundingClientRect()
const header = parseFloat(getComputedStyle(root).getPropertyValue("--sticky-accordion-top")) || 0
const inset = parseFloat(getComputedStyle(root).getPropertyValue("--session-timeline-inset")) || 0
return edge === "header" ? box.bottom <= view.top + header : box.top >= view.bottom - inset
}, edge),
)
.toBe(true)
await expect
.poll(() =>
scroller.evaluate((root) => {
const view = root.getBoundingClientRect()
const header = parseFloat(getComputedStyle(root).getPropertyValue("--sticky-accordion-top")) || 0
const inset = parseFloat(getComputedStyle(root).getPropertyValue("--session-timeline-inset")) || 0
const visible = [...root.querySelectorAll<HTMLElement>('[data-timeline-row="UserMessage"]')]
.filter((row) =>
[...row.querySelectorAll<HTMLElement>('[data-slot="user-message-text"]')].some((bubble) => {
const box = bubble.getBoundingClientRect()
return box.height > 0 && box.bottom > view.top + header + 0.5 && box.top < view.bottom - inset - 0.5
}),
)
.map((row) => row.dataset.messageId)
const highlighted = [
...document.querySelectorAll<HTMLElement>(
'[data-component="session-turn-navigator"] [data-state="active"]',
),
].map((tick) => tick.dataset.turn)
return {
extra: highlighted.filter((id) => !visible.includes(id)),
missing: visible.filter((id) => !highlighted.includes(id)),
}
}),
)
.toEqual({ extra: [], missing: [] })
await expect
.poll(() =>
navigator
.locator('[data-state="active"]')
.evaluateAll((ticks) => ticks.map((tick) => tick.getAttribute("data-turn"))),
)
.not.toContain("msg_polish_2_user")
}
await turns.nth(2).hover()
await expect(preview).toContainText(prompt)
await expect(preview).toContainText(response)
await expect(preview).toHaveCSS("opacity", "1")
const texts = preview.locator("p")
await expect(texts.nth(0)).toHaveCSS("mask-image", "none")
await expect(texts.nth(1)).toHaveCSS("mask-image", "none")
await expect
.poll(() =>
texts.evaluateAll((elements) =>
elements.every((element) => {
const end = document.createRange()
end.selectNodeContents(element)
return [...end.getClientRects()].every(
(rect) => rect.bottom <= element.getBoundingClientRect().bottom + 0.5,
)
}),
),
)
.toBe(true)
})
test("mounts every part once and in order while paging to the start", async ({ page }) => {
test.setTimeout(240_000)
const errors = trackPageErrors(page)
@@ -7,8 +7,8 @@ import { useServerSDK } from "@/runtime/server/client"
const turnPageLimit = 200
// Previews fade out after a few lines; keep only enough text to fill them.
const previewLength = 400
// Bound preview work without cutting short the text that fits in the card.
const previewLength = 800
// Keep index loading clear of session entry, tab switch rendering and the timeline's own history requests.
const railDelay = 1000
@@ -29,11 +29,11 @@ const waveRadius = 48
const tickWidths = { default: 7, active: 12, peak: 22 }
// The prompt's first line sits level with its tick: card padding, bubble padding, then half a 16px line.
const previewAnchor = 20
// The prompt's first line sits level with its tick: 12px card padding, then half a 20px line.
const previewAnchor = 22
// The preview at its tallest, with both texts clamped. Clamping to it keeps the card still while a reply loads.
const previewMaxHeight = 142
const previewMaxHeight = 212
type TurnNavigatorProps = {
sessionID: string
@@ -361,7 +361,7 @@ function TurnRail(props: TurnNavigatorProps & { index?: Map<string, string>; roo
ticks.set(id, { element, scale })
onCleanup(() => ticks.delete(id))
}}
class="block h-0.5 shrink-0 origin-left bg-(--tick-color) rtl:origin-right"
class="pointer-events-none block h-0.5 shrink-0 origin-left bg-(--tick-color) rtl:origin-right"
style={{
width: `${tickWidths.peak}px`,
opacity: hovered(id) ? 1 : "var(--tick-opacity)",
@@ -377,25 +377,23 @@ function TurnRail(props: TurnNavigatorProps & { index?: Map<string, string>; roo
<div
ref={card}
data-slot="session-turn-navigator-preview"
class="pointer-events-none invisible absolute start-12 top-0 w-[320px] max-w-[calc(100vw-5rem)] rounded-lg bg-v2-background-bg-base p-2 opacity-0"
class="pointer-events-none invisible absolute start-12 top-0 w-[384px] max-w-[calc(100cqw-4rem)] rounded-lg bg-v2-background-bg-base p-3 opacity-0"
style={{ "box-shadow": "var(--v2-elevation-raised)" }}
>
<Show when={preview()}>
{(item) => (
<>
<div class="rounded-md bg-v2-background-bg-layer-02 px-2 py-1">
<p
dir="auto"
class="max-h-12 overflow-hidden text-[13px] font-medium leading-text-compact text-v2-text-text-base [mask-image:linear-gradient(to_bottom,#000_32px,transparent_48px)]"
>
{item().prompt}
</p>
</div>
<p
dir="auto"
class="line-clamp-4 break-words text-[13px] font-medium leading-text-base text-v2-text-text-base"
>
{item().prompt}
</p>
<Show when={item().response}>
{(text) => (
<p
dir="auto"
class="mt-1.5 max-h-16 overflow-hidden px-2 text-[13px] leading-text-compact text-v2-text-text-muted [mask-image:linear-gradient(to_bottom,#000_48px,transparent_64px)]"
class="mt-2 line-clamp-5 break-words text-[13px] leading-text-base text-v2-text-text-muted"
>
{text()}
</p>
@@ -535,49 +535,51 @@ export function createTimelineVirtualizer(input: Input) {
const updateVisibleTurns = () => {
if (!active()) return
const root = listRoot()
if (!root?.isConnected) return
const bounds = root.getBoundingClientRect()
const header = input.showHeader() ? viewport.headerHeight : 0
const top = viewport.top + header
const bottom = viewport.top + viewport.height
const top = bounds.top + header
const bottom = bounds.top + root.clientHeight - bottomInset()
const list = rows()
const visible = virtualizer.getVirtualItems().flatMap((item) => {
if (item.end <= top || item.start >= bottom) return []
const row = list[item.index]
if (!row || Predicate.isTagged(row, "TurnGap") || Predicate.isTagged(row, "TurnDivider")) return []
const element = virtualizer.elementsCache.get(item.key)
return [{ item, row }]
if (!element?.isConnected) return []
const box = element.getBoundingClientRect()
if (box.bottom <= top || box.top >= bottom) return []
return [{ element, row }]
})
const root = listRoot()
let rootTop: number | undefined
const prompts = visible.flatMap((entry) => {
if (!Predicate.isTagged(entry.row, "UserMessage")) return []
if (entry.item.start >= top && entry.item.end <= bottom) return [entry.row.userMessageID]
if (!root?.isConnected) return []
const element = virtualizer.elementsCache.get(entry.item.key)
// Bubble bounds exclude the invisible copy controls beneath a prompt.
const bubbles = element?.querySelectorAll<HTMLElement>(
// Virtual measurements may still describe the previous layout after wrapping or resizing. Highlight the
// rendered bubbles, not estimated row bounds or the invisible copy controls beneath a prompt.
const bubbles = entry.element.querySelectorAll<HTMLElement>(
'[data-slot="user-message-text"], [data-slot="user-message-attachments"], [data-slot="user-message-comments"]',
)
if (!bubbles) return []
rootTop ??= root.getBoundingClientRect().top
return [...bubbles].some((bubble) => {
const box = bubble.getBoundingClientRect()
return box.height > 0 && box.bottom > rootTop! + header && box.top < rootTop! + viewport.height
return box.height > 0 && box.bottom > top && box.top < bottom
})
? [entry.row.userMessageID]
: []
})
const ids = new Set(prompts.length ? prompts : visible[0] ? [visible[0].row.userMessageID] : [])
// A clipped prompt can leave only its invisible copy controls in view. The reply-only fallback must come from
// conversation content, not from that prompt's remaining row box.
const preceding = visible.find((entry) => !Predicate.isTagged(entry.row, "UserMessage"))?.row.userMessageID
const ids = new Set(prompts.length ? prompts : preceding ? [preceding] : [])
if (
viewport.visibleUserMessageIDs.size === ids.size &&