From b1d453a10259cdad406547e2fd65bd83b06f4ff4 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:36:36 +0200 Subject: [PATCH 1/2] fix(web): keep scroll-to-end button close to composer --- apps/web/src/components/ChatView.tsx | 67 ++++++++++++------- .../components/composerFooterLayout.test.ts | 25 +++++++ .../src/components/composerFooterLayout.ts | 17 +++++ 3 files changed, 86 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d1906ff975c..e26ad9dcb10c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -313,7 +313,7 @@ import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; -import { resolveComposerTimelineInset } from "./composerFooterLayout"; +import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -5121,27 +5121,48 @@ export default function ChatView(props: ChatViewProps) { ? activePlan.steps : null; - const publishComposerOverlayHeight = useCallback((height: number) => { - const nextHeight = Math.ceil(height); - if (nextHeight <= 0) return; - const previousHeight = composerOverlayHeightRef.current; - if (previousHeight !== nextHeight) { - composerOverlayHeightRef.current = nextHeight; - setComposerOverlayHeight(nextHeight); - } - const nextInset = resolveComposerTimelineInset({ - currentInset: composerTimelineInsetRef.current, - overlayHeight: nextHeight, - isResting: composerRestingRef.current, - }); - if (composerTimelineInsetRef.current !== nextInset) { - composerTimelineInsetRef.current = nextInset; - setComposerTimelineInset(nextInset); - } - setScrollToEndClearance((currentClearance) => - currentClearance === nextHeight ? currentClearance : nextHeight, - ); - }, []); + const publishComposerOverlayHeight = useCallback( + (height: number) => { + const nextHeight = Math.ceil(height); + if (nextHeight <= 0) return; + const previousHeight = composerOverlayHeightRef.current; + if (previousHeight !== nextHeight) { + composerOverlayHeightRef.current = nextHeight; + setComposerOverlayHeight(nextHeight); + } + const nextInset = resolveComposerTimelineInset({ + currentInset: composerTimelineInsetRef.current, + overlayHeight: nextHeight, + isResting: composerRestingRef.current, + }); + if (composerTimelineInsetRef.current !== nextInset) { + composerTimelineInsetRef.current = nextInset; + setComposerTimelineInset(nextInset); + } + const mainSurface = composerOverlayElement?.querySelector( + '[data-chat-composer-main-surface="true"]', + ); + const button = composerOverlayElement?.parentElement?.querySelector( + 'button[aria-label="Scroll to end"]', + ); + const clearance = + composerOverlayElement && mainSurface && button + ? resolveScrollToEndClearance({ + overlayHeight: nextHeight, + mainSurfaceTop: mainSurface.getBoundingClientRect().top, + button: button.getBoundingClientRect(), + attachments: Array.from( + composerOverlayElement.querySelectorAll( + '[data-composer-banner-surface="attached"]', + ), + (element) => element.getBoundingClientRect(), + ), + }) + : nextHeight; + setScrollToEndClearance(clearance); + }, + [composerOverlayElement], + ); // The composer reports its resting flag from a layout effect, which runs // before this component's own layout effects and before any resize // observation, so every measurement below sees the flag for its layout. @@ -5174,7 +5195,7 @@ export default function ChatView(props: ChatViewProps) { return () => { resizeObserver.disconnect(); }; - }, [composerOverlayElement, publishComposerOverlayHeight]); + }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]); const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); const activeThreadReferenceCopyTarget = useMemo( () => diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 2a04e01538a6..5c0e4327d959 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -7,6 +7,7 @@ import { COMPOSER_RESTING_EXPANSION_MIN_PX, getRestingComposerImagePreviewCounts, resolveComposerTimelineInset, + resolveScrollToEndClearance, resolveRestingComposerControlsLayout, resolveRestingComposerControlsNaturalWidth, shouldAnimateComposerRestingTransition, @@ -424,3 +425,27 @@ describe("resolveRestingComposerControlsLayout hysteresis", () => { ).toEqual({ hiddenCount: 2, visible: true }); }); }); + +describe("resolveScrollToEndClearance", () => { + it("removes the side tab gap in both composer states while clearing overlapping attachments", () => { + for (const overlayHeight of [120, 214]) { + const layout = { + overlayHeight, + mainSurfaceTop: 534, + button: { left: 340, right: 460 }, + attachments: [{ top: 500, left: 600, right: 700 }], + }; + expect(resolveScrollToEndClearance(layout)).toBe(overlayHeight - 34); + expect(resolveScrollToEndClearance({ ...layout, attachments: [] })).toBe(overlayHeight); + expect( + resolveScrollToEndClearance({ + ...layout, + attachments: [...layout.attachments, { top: 500, left: 100, right: 700 }], + }), + ).toBe(overlayHeight); + expect(resolveScrollToEndClearance({ ...layout, button: { left: 590, right: 710 } })).toBe( + overlayHeight, + ); + } + }); +}); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 5eb46eb33d2c..42f466c2ae9d 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -191,3 +191,20 @@ export function resolveRestingComposerControlsLayout( : minimumWidth <= hostWidth; return { hiddenCount, visible }; } + +export function resolveScrollToEndClearance(input: { + overlayHeight: number; + mainSurfaceTop: number; + button: { left: number; right: number }; + attachments: ReadonlyArray<{ top: number; left: number; right: number }>; +}): number { + let contentTop = input.mainSurfaceTop; + let top = contentTop; + for (const attachment of input.attachments) { + contentTop = Math.min(contentTop, attachment.top); + if (attachment.left < input.button.right && attachment.right > input.button.left) { + top = Math.min(top, attachment.top); + } + } + return Math.ceil(input.overlayHeight - (top - contentTop)); +} From 17841518c29d37a7131bfd6a832fcbba9972878b Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:19:45 +0200 Subject: [PATCH 2/2] fix(web): reserve space for collapsed banner stacks --- apps/web/src/components/chat/ComposerBannerStack.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 5de40a0d90d5..c540613b009c 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 }: ComposerBannerStackPro >
{ if (event.pointerType === "touch") return; if (document.activeElement === peekRef.current) {