From 3ca0739383061ab2c1a0280bc427af434c66a5c2 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:34:59 -0600 Subject: [PATCH] fix(app): keep the activity pill clear of Working on touch (#61794) --- .../e2e/regression/timeline-geometry.spec.ts | 49 ++++++++++++++++--- .../src/session/composer/composer-lane.css | 1 + .../app/src/session/timeline/virtualizer.tsx | 17 ++++--- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/packages/app/e2e/regression/timeline-geometry.spec.ts b/packages/app/e2e/regression/timeline-geometry.spec.ts index e1d41a7cf6c..a2a54cf3006 100644 --- a/packages/app/e2e/regression/timeline-geometry.spec.ts +++ b/packages/app/e2e/regression/timeline-geometry.spec.ts @@ -633,10 +633,28 @@ async function captureCardEdges(page: Page, card: Locator) { ) } -test("scrolls the timeline behind the composer region, fading at the card, and keeps its end above the region", async ({ - page, -}) => { +// Without hover the activity pill grows to its 44px touch size, and the tail it floats in grows with it. +for (const input of [ + { name: "a pointer", touch: false, viewport: { width: 1400, height: 900 }, pill: 32 }, + { name: "touch", touch: true, viewport: { width: 390, height: 844 }, pill: 44 }, +]) { + test.describe(`with ${input.name}`, () => { + test.use({ hasTouch: input.touch, isMobile: input.touch }) + + test("scrolls the timeline behind the composer region, fading at the card, and keeps its end above the region", async ({ + page, + }) => { + await scrollsBehindComposerRegion(page, input) + }) + }) +} + +async function scrollsBehindComposerRegion( + page: Page, + input: { viewport: { width: number; height: number }; pill: number }, +) { await setupTimeline(page, { + viewport: input.viewport, sessionMessages: [ ...validateTimelineMessages(historyMessages(18)), { id: "msg_9000_user", type: "user", text: "Split the work.", time: { created: 1 } }, @@ -681,7 +699,10 @@ test("scrolls the timeline behind the composer region, fading at the card, and k 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 row = viewport.querySelector('[data-component="session-background-hint-row"]')! + const hint = row.getBoundingClientRect() + // The live status lane that holds Working and the background hint. + const lane = row.parentElement!.getBoundingClientRect() const parts = [...viewport.querySelectorAll("[data-timeline-part-id]")] const last = parts.find((element) => element.textContent?.includes("The last answer ends"))! @@ -695,13 +716,25 @@ test("scrolls the timeline behind the composer region, fading at the card, and k ), // 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, + pill: Math.round(pill.height), + // It floats in the timeline's reserved tail, a clear gap below the live status lane and the last answer. + clearance: Math.round(pill.top - lane.bottom), + reserved: lane.top <= hint.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 } + const expected = { + behind: 0, + inset: 0, + fade: 0, + floats: true, + pill: input.pill, + clearance: 14, + reserved: true, + atEnd: true, + } + await expect.poll(geometry).toEqual(expected) const before = (await dock.boundingBox())!.height @@ -715,7 +748,7 @@ test("scrolls the timeline behind the composer region, fading at the card, and k 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, diff --git a/packages/app/src/session/composer/composer-lane.css b/packages/app/src/session/composer/composer-lane.css index 43e5c5c163a..64dfc1463c9 100644 --- a/packages/app/src/session/composer/composer-lane.css +++ b/packages/app/src/session/composer/composer-lane.css @@ -53,6 +53,7 @@ } } + /* The timeline's tail reserves this height (tailSize in timeline/virtualizer.tsx); change both together. */ @media (hover: none) { min-height: 44px; } diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index 1eaedf02ae3..8949cfc26a4 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -35,10 +35,6 @@ import { defersAdjustments, END_EPSILON, MIN_OVERFLOW, needsEndProbe, ZOOM_SLACK const fallbackItemSize = 60 -// Reserved below the last row: a 36px lane for the live status row and its background hint, a 14px gap, then a 32px -// activity pill and its 10px composer gap. Both come and go inside it, so neither moves the rows above. -const tailSize = 92 - const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])' const upwardKeys = new Set(["up", "page-up", "home"]) @@ -116,6 +112,11 @@ export function createTimelineVirtualizer(input: Input) { const active = () => input.active?.() !== false const isDesktop = createMediaQuery("(min-width: 768px)") const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16) + // Reserved below the last row: a 36px lane for the live status row and its background hint, a 14px gap, then the + // activity pill and its 10px composer gap. Without hover the pill grows from 32px to its 44px touch size + // (composer-lane.css), and the tail grows with it. Both come and go inside it, so neither moves the rows above. + const touch = createMediaQuery("(hover: none)") + const tailSize = () => (touch() ? 104 : 92) const ownerSessionKey = input.sessionKey() const entry = cache.get(ownerSessionKey) const cached = entry?.presentationKey === input.presentationKey?.() ? entry : undefined @@ -415,7 +416,9 @@ export function createTimelineVirtualizer(input: Input) { // Empty projections still need the bottom spacer for running status. return rows().length > 0 ? topOffset() : 0 }, - paddingEnd: tailSize, + get paddingEnd() { + return tailSize() + }, // Revealed prompts sit below the solid header and its 16px fade. get scrollPaddingStart() { return input.showHeader() ? headerHeight() + 16 : 0 @@ -1190,8 +1193,8 @@ export function createTimelineVirtualizer(input: Input) { data-timeline-row="bottom-spacer" class="absolute top-0 left-0 w-full" style={{ - height: `${tailSize}px`, - transform: `translateY(${virtualizer.getTotalSize() - tailSize - rendering.scrollAdjustment}px)`, + height: `${tailSize()}px`, + transform: `translateY(${virtualizer.getTotalSize() - tailSize() - rendering.scrollAdjustment}px)`, }} > {props.bottomSpacer}