From 5004d0205e872767f7c35e70d8bb9f63de5fbe81 Mon Sep 17 00:00:00 2001 From: Derek Trimm <275381468+derektrimm@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:20:52 -0500 Subject: [PATCH 1/2] fix(web): expanding stacked notices no longer scrolls a working thread Hovering the composer's notice stack grows the overlay, and the timeline reserved that growth as footer space. The thread did not move at once, but the next row re-pinned it to the new end, jumping by the expanded height. The reservation now leaves out how far the stack grew past its collapsed height, so working threads behave like idle ones: the expanded notices cover the last rows until the pointer leaves. Fixes #19880 --- apps/web/src/components/ChatView.tsx | 14 +++++++++++ .../components/chat/ComposerBannerStack.tsx | 1 + .../components/composerFooterLayout.test.ts | 24 +++++++++++++++++++ .../src/components/composerFooterLayout.ts | 9 ++++--- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d5599f8c46a33..3e5d3aee5e193 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6679,11 +6679,25 @@ export default function ChatView(props: ChatViewProps) { modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop), ) : 0; + // Expanded notices grow the stack past the minimum height that holds its + // peek strip. Round once, after removing that growth, so the reservation + // matches the collapsed overlay exactly. + const noticeStack = composerOverlayElement?.querySelector( + '[data-composer-banner-stack="true"]', + ); + const expandedNoticesGrowth = noticeStack + ? Math.max( + 0, + noticeStack.getBoundingClientRect().height - + Number.parseFloat(getComputedStyle(noticeStack).minHeight), + ) + : 0; const nextInset = resolveComposerTimelineInset({ currentInset: composerTimelineInsetRef.current, overlayHeight: nextHeight, isResting, restingOnlyHeight, + expandedNoticesHeight: nextHeight - Math.ceil(height - expandedNoticesGrowth), }); if (composerTimelineInsetRef.current !== nextInset) { composerTimelineInsetRef.current = nextInset; diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index ccff4df8a03f5..ad92bcbff8eb6 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -120,6 +120,7 @@ export function ComposerBannerStack({ className, items, attachedAbove }: Compose {hasStack ? (
{ diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index f913c04391819..7658769636419 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -140,6 +140,30 @@ describe("resolveComposerTimelineInset", () => { resolveComposerTimelineInset({ currentInset: 0, overlayHeight: 60, isResting: true }), ).toBe(60 + COMPOSER_RESTING_EXPANSION_MIN_PX); }); + + it("keeps the reservation while notices are expanded from the stack", () => { + const collapsed = resolveComposerTimelineInset({ + currentInset: 0, + overlayHeight: 172, + isResting: false, + }); + expect( + resolveComposerTimelineInset({ + currentInset: collapsed, + overlayHeight: 532, + isResting: false, + expandedNoticesHeight: 360, + }), + ).toBe(collapsed); + expect( + resolveComposerTimelineInset({ + currentInset: collapsed, + overlayHeight: 470, + isResting: true, + expandedNoticesHeight: 360, + }), + ).toBe(collapsed); + }); }); describe("shouldUseRestingComposerLayout", () => { diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index e0dce39603e01..009db13a25859 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -52,23 +52,26 @@ export const COMPOSER_RESTING_EXPANSION_MIN_PX = 94; /** * Reserving only the resting height lets a scroll to the end land flush against the short * composer, and the expansion that follows then covers the last rows because the timeline never - * moves for footer growth. + * moves for footer growth. Notices expanded from the stack are left out: reserving them would + * scroll the thread by their height on the next row. */ export function resolveComposerTimelineInset(input: { currentInset: number; overlayHeight: number; isResting: boolean; restingOnlyHeight?: number; + expandedNoticesHeight?: number; }): number { + const overlayHeight = input.overlayHeight - (input.expandedNoticesHeight ?? 0); return input.isResting ? Math.max( input.currentInset, - input.overlayHeight + + overlayHeight + (input.currentInset === 0 ? COMPOSER_RESTING_EXPANSION_MIN_PX - (input.restingOnlyHeight ?? 0) : 0), ) - : input.overlayHeight; + : overlayHeight; } export function overlayComposerIsResting(input: { From 8379462558c308dd622dbd8749c5366f2c5d56ad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 08:50:47 +0000 Subject: [PATCH 2/2] fix(web): keep notice expansion out of timeline padding --- apps/web/src/components/ChatView.tsx | 9 ++---- .../components/composerFooterLayout.test.ts | 29 +++++++++++-------- .../src/components/composerFooterLayout.ts | 7 +++-- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c367ff66302d..21f42be456fd5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4678,13 +4678,10 @@ export default function ChatView(props: ChatViewProps) { modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop), ) : 0; - // Expanded notices grow the stack past the minimum height that holds its - // peek strip. Round once, after removing that growth, so the reservation - // matches the collapsed overlay exactly. const noticeStack = composerOverlayElement?.querySelector( '[data-composer-banner-stack="true"]', ); - const expandedNoticesGrowth = noticeStack + const expandedNoticesHeight = noticeStack ? Math.max( 0, noticeStack.getBoundingClientRect().height - @@ -4693,10 +4690,10 @@ export default function ChatView(props: ChatViewProps) { : 0; const nextInset = resolveComposerTimelineInset({ currentInset: composerTimelineInsetRef.current, - overlayHeight: nextHeight, + overlayHeight: height, isResting, restingOnlyHeight, - expandedNoticesHeight: nextHeight - Math.ceil(height - expandedNoticesGrowth), + expandedNoticesHeight, }); if (composerTimelineInsetRef.current !== nextInset) { composerTimelineInsetRef.current = nextInset; diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 7658769636419..ef021a7dc013f 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -141,26 +141,31 @@ describe("resolveComposerTimelineInset", () => { ).toBe(60 + COMPOSER_RESTING_EXPANSION_MIN_PX); }); - it("keeps the reservation while notices are expanded from the stack", () => { + it("keeps the timeline reservation stable through notice expansion and collapse", () => { const collapsed = resolveComposerTimelineInset({ currentInset: 0, overlayHeight: 172, isResting: false, }); + const expanded = resolveComposerTimelineInset({ + currentInset: collapsed, + overlayHeight: 532, + isResting: false, + expandedNoticesHeight: 360, + }); + expect(expanded).toBe(collapsed); + const resting = resolveComposerTimelineInset({ + currentInset: expanded, + overlayHeight: 470, + isResting: true, + expandedNoticesHeight: 360, + }); + expect(resting).toBe(collapsed); expect( resolveComposerTimelineInset({ - currentInset: collapsed, - overlayHeight: 532, - isResting: false, - expandedNoticesHeight: 360, - }), - ).toBe(collapsed); - expect( - resolveComposerTimelineInset({ - currentInset: collapsed, - overlayHeight: 470, + currentInset: resting, + overlayHeight: 110, isResting: true, - expandedNoticesHeight: 360, }), ).toBe(collapsed); }); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 009db13a25859..5ae971f19a344 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -52,8 +52,7 @@ export const COMPOSER_RESTING_EXPANSION_MIN_PX = 94; /** * Reserving only the resting height lets a scroll to the end land flush against the short * composer, and the expansion that follows then covers the last rows because the timeline never - * moves for footer growth. Notices expanded from the stack are left out: reserving them would - * scroll the thread by their height on the next row. + * moves for footer growth. */ export function resolveComposerTimelineInset(input: { currentInset: number; @@ -62,7 +61,9 @@ export function resolveComposerTimelineInset(input: { restingOnlyHeight?: number; expandedNoticesHeight?: number; }): number { - const overlayHeight = input.overlayHeight - (input.expandedNoticesHeight ?? 0); + // Expanded notices temporarily cover the timeline. Remove their growth before + // rounding so fractional measurements reserve exactly the collapsed height. + const overlayHeight = Math.ceil(input.overlayHeight - (input.expandedNoticesHeight ?? 0)); return input.isResting ? Math.max( input.currentInset,