diff --git a/packages/app/e2e/regression/timeline-history.spec.ts b/packages/app/e2e/regression/timeline-history.spec.ts index 0437334253e..a15c9323fb2 100644 --- a/packages/app/e2e/regression/timeline-history.spec.ts +++ b/packages/app/e2e/regression/timeline-history.spec.ts @@ -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('#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('#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('#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('[data-timeline-row="UserMessage"]')] + .filter((row) => + [...row.querySelectorAll('[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( + '[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) diff --git a/packages/app/src/session/timeline/turn-navigator.tsx b/packages/app/src/session/timeline/turn-navigator.tsx index db376724568..420cc186545 100644 --- a/packages/app/src/session/timeline/turn-navigator.tsx +++ b/packages/app/src/session/timeline/turn-navigator.tsx @@ -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; 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; roo