From 15125f0d38d502d028fc05e3134b57d2b261dffc Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 15 May 2026 09:46:28 +0800 Subject: [PATCH 1/8] fix(app): align jump-to-bottom button to W1 preview (#601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the W1-locked spec from docs/design/preview/message-flow.html (L263-271, spec row L1066) for the floating jump button: - size 32 → 30 (locked default control height) - hover swap from border tint to 4% black overlay (light) / 4% white overlay (dark), per the preview's hover gradient stack layered over --surface-raised Position, background, border, shadow, and cursor were already aligned. The 2 px size delta was a long-standing dev deviation flagged by the W1 audit comment on #601. E2E regression spec (session-w1-jump.spec.ts) locks the geometry (getComputedStyle width/height === 30px), cursor: pointer, hover gradient presence, and click-back-to-bottom behaviour. --- .../app/e2e/session/session-w1-jump.spec.ts | 123 ++++++++++++++++++ .../src/pages/session/message-timeline.tsx | 2 +- 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/session/session-w1-jump.spec.ts diff --git a/packages/app/e2e/session/session-w1-jump.spec.ts b/packages/app/e2e/session/session-w1-jump.spec.ts new file mode 100644 index 000000000..65f08b13a --- /dev/null +++ b/packages/app/e2e/session/session-w1-jump.spec.ts @@ -0,0 +1,123 @@ +import type { Page } from "@playwright/test" +import { test, expect } from "../fixtures" +import { cleanupSession } from "../actions" +import { scrollViewportSelector, sessionTurnListSelector } from "../selectors" + +type Sdk = Parameters[0]["sdk"] + +const JUMP_BUTTON_SIZE_PX = 30 + +async function seedSessionTurns(input: { sdk: Sdk; sessionID: string; count: number }) { + for (let i = 0; i < input.count; i++) { + await input.sdk.session.promptAsync({ + sessionID: input.sessionID, + noReply: true, + parts: [ + { + type: "text", + text: `w1 jump seed ${i}\n${Array.from({ length: 16 }, (_, line) => `line ${line} ${"content ".repeat(8)}`).join("\n")}`, + }, + ], + }) + } +} + +async function scrollTimelineToTop(page: Page) { + return page.evaluate( + ({ scrollViewportSelector, turnListSelector }) => { + const list = document.querySelector(turnListSelector) + const viewport = list?.closest(scrollViewportSelector) + if (!(viewport instanceof HTMLElement)) return false + viewport.scrollTop = 0 + viewport.dispatchEvent(new Event("scroll", { bubbles: true })) + return true + }, + { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, + ) +} + +async function timelineDistanceFromBottom(page: Page) { + return page.evaluate( + ({ scrollViewportSelector, turnListSelector }) => { + const list = document.querySelector(turnListSelector) + const viewport = list?.closest(scrollViewportSelector) + if (!(viewport instanceof HTMLElement)) return null + return viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop + }, + { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, + ) as Promise +} + +test("session w1 jump-to-bottom button matches W1-locked geometry and click behaviour", async ({ + page, + project, +}) => { + await project.open() + const session = await project.sdk.session.create({ title: "w1 jump spec" }).then((r) => r.data) + if (!session?.id) throw new Error("Session create did not return an id") + + try { + await seedSessionTurns({ sdk: project.sdk, sessionID: session.id, count: 12 }) + await project.gotoSession(session.id) + await expect(page.locator(sessionTurnListSelector)).toBeVisible() + + // Wait for messages to render and the timeline to grow past the jump + // threshold (clientHeight + 400 per use-session-scroll-dock.ts). + await expect + .poll( + async () => { + const m = await page.evaluate( + ({ scrollViewportSelector, turnListSelector }) => { + const list = document.querySelector(turnListSelector) + const viewport = list?.closest(scrollViewportSelector) + if (!(viewport instanceof HTMLElement)) return null + return { scrollHeight: viewport.scrollHeight, clientHeight: viewport.clientHeight } + }, + { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, + ) + if (!m) return 0 + return m.scrollHeight - m.clientHeight + }, + { timeout: 15_000 }, + ) + .toBeGreaterThan(450) + + // The button is always present in the DOM (just transformed off-screen + // when the timeline is at bottom). Geometry assertions read CSS rather + // than boundingBox so they are not affected by the scale-95 hidden state. + const jumpButton = page.locator('button[aria-label="Jump to latest"]') + await expect(jumpButton).toBeAttached() + + // Geometry — preview L263-271 / L1066 locks 30 × 30. + const dims = await jumpButton.evaluate((el) => { + const cs = window.getComputedStyle(el) + return { width: cs.width, height: cs.height, cursor: cs.cursor } + }) + expect(dims.width).toBe(`${JUMP_BUTTON_SIZE_PX}px`) + expect(dims.height).toBe(`${JUMP_BUTTON_SIZE_PX}px`) + + // Cursor — preview L267 locks cursor: pointer. + expect(dims.cursor).toBe("pointer") + + // Hover background — preview L269 layers a 4% black overlay over + // --surface-raised. We force the :hover class via hover() and read + // computed background-image; gradient stack must appear (not `none`). + await jumpButton.hover({ force: true }) + const hoverBackgroundImage = await jumpButton.evaluate( + (el) => window.getComputedStyle(el).backgroundImage, + ) + expect(hoverBackgroundImage).toContain("linear-gradient") + await page.mouse.move(0, 0) + + // Click — should scroll the timeline back to the bottom. Use scrollTo + // first to put the timeline somewhere off the bottom, then click and + // expect the dock to pull it back. + await scrollTimelineToTop(page) + await jumpButton.click({ force: true }) + await expect + .poll(async () => (await timelineDistanceFromBottom(page)) ?? -1, { timeout: 5_000 }) + .toBeLessThanOrEqual(8) + } finally { + await cleanupSession({ sdk: project.sdk, sessionID: session.id }) + } +}) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 274b5698e..5b1c508cc 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -947,7 +947,7 @@ export function MessageTimeline(props: { >