authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-08 07:48:23+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 01:48:23-03:00
log50a76cee7c56ac22568e549dc855248967bfb9ab
tree2238f6c7b5a82d6d907ebae84f9767421713a750
parent6ba15c027a6d411c7f75cc0ca59b601a295c3633
signature Signed by PGP key B5690EEEBB952194

fix(web): keep scroll-to-end button close to composer (#10543)


4 files changed, 88 insertions(+), 24 deletions(-)

apps/web/src/components/ChatView.tsx+44-23
......@@ -315,7 +315,7 @@ import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
315315import { MessagesTimeline } from "./chat/MessagesTimeline";
316316import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";
317317import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
318import { resolveComposerTimelineInset } from "./composerFooterLayout";
318import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout";
319319import { ChatHeader } from "./chat/ChatHeader";
320320import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
321321import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
......@@ -5163,27 +5163,48 @@ export default function ChatView(props: ChatViewProps) {
51635163 ? activePlan.steps
51645164 : null;
51655165
5166 const publishComposerOverlayHeight = useCallback((height: number) => {
5167 const nextHeight = Math.ceil(height);
5168 if (nextHeight <= 0) return;
5169 const previousHeight = composerOverlayHeightRef.current;
5170 if (previousHeight !== nextHeight) {
5171 composerOverlayHeightRef.current = nextHeight;
5172 setComposerOverlayHeight(nextHeight);
5173 }
5174 const nextInset = resolveComposerTimelineInset({
5175 currentInset: composerTimelineInsetRef.current,
5176 overlayHeight: nextHeight,
5177 isResting: composerRestingRef.current,
5178 });
5179 if (composerTimelineInsetRef.current !== nextInset) {
5180 composerTimelineInsetRef.current = nextInset;
5181 setComposerTimelineInset(nextInset);
5182 }
5183 setScrollToEndClearance((currentClearance) =>
5184 currentClearance === nextHeight ? currentClearance : nextHeight,
5185 );
5186 }, []);
5166 const publishComposerOverlayHeight = useCallback(
5167 (height: number) => {
5168 const nextHeight = Math.ceil(height);
5169 if (nextHeight <= 0) return;
5170 const previousHeight = composerOverlayHeightRef.current;
5171 if (previousHeight !== nextHeight) {
5172 composerOverlayHeightRef.current = nextHeight;
5173 setComposerOverlayHeight(nextHeight);
5174 }
5175 const nextInset = resolveComposerTimelineInset({
5176 currentInset: composerTimelineInsetRef.current,
5177 overlayHeight: nextHeight,
5178 isResting: composerRestingRef.current,
5179 });
5180 if (composerTimelineInsetRef.current !== nextInset) {
5181 composerTimelineInsetRef.current = nextInset;
5182 setComposerTimelineInset(nextInset);
5183 }
5184 const mainSurface = composerOverlayElement?.querySelector<HTMLElement>(
5185 '[data-chat-composer-main-surface="true"]',
5186 );
5187 const button = composerOverlayElement?.parentElement?.querySelector<HTMLElement>(
5188 'button[aria-label="Scroll to end"]',
5189 );
5190 const clearance =
5191 composerOverlayElement && mainSurface && button
5192 ? resolveScrollToEndClearance({
5193 overlayHeight: nextHeight,
5194 mainSurfaceTop: mainSurface.getBoundingClientRect().top,
5195 button: button.getBoundingClientRect(),
5196 attachments: Array.from(
5197 composerOverlayElement.querySelectorAll<HTMLElement>(
5198 '[data-composer-banner-surface="attached"]',
5199 ),
5200 (element) => element.getBoundingClientRect(),
5201 ),
5202 })
5203 : nextHeight;
5204 setScrollToEndClearance(clearance);
5205 },
5206 [composerOverlayElement],
5207 );
51875208 // The composer reports its resting flag from a layout effect, which runs
51885209 // before this component's own layout effects and before any resize
51895210 // observation, so every measurement below sees the flag for its layout.
......@@ -5216,7 +5237,7 @@ export default function ChatView(props: ChatViewProps) {
52165237 return () => {
52175238 resizeObserver.disconnect();
52185239 };
5219 }, [composerOverlayElement, publishComposerOverlayHeight]);
5240 }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]);
52205241 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);
52215242 const activeThreadReferenceCopyTarget = useMemo(
52225243 () =>
apps/web/src/components/chat/ComposerBannerStack.tsx+2-1
......@@ -120,6 +120,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
120120 >
121121 <div className={cn("relative flex flex-col-reverse", hasStack && stackExpanded && "z-50")}>
122122 <div
123 key={frontItem.id}
123124 className={cn(
124125 "relative z-10 transition-[translate,opacity] duration-220 ease-in",
125126 exitingItemId === frontItem.id
......@@ -147,7 +148,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
147148 {hasStack ? (
148149 <div
149150 ref={noticesRef}
150 className={cn("relative z-20", stackExpanded && "min-h-3")}
151 className="relative z-20 min-h-3"
151152 onPointerEnter={(event) => {
152153 if (event.pointerType === "touch") return;
153154 if (document.activeElement === peekRef.current) {
apps/web/src/components/composerFooterLayout.test.ts+25
......@@ -7,6 +7,7 @@ import {
77 COMPOSER_RESTING_EXPANSION_MIN_PX,
88 getRestingComposerImagePreviewCounts,
99 resolveComposerTimelineInset,
10 resolveScrollToEndClearance,
1011 resolveRestingComposerControlsLayout,
1112 resolveRestingComposerControlsNaturalWidth,
1213 shouldAnimateComposerRestingTransition,
......@@ -424,3 +425,27 @@ describe("resolveRestingComposerControlsLayout hysteresis", () => {
424425 ).toEqual({ hiddenCount: 2, visible: true });
425426 });
426427});
428
429describe("resolveScrollToEndClearance", () => {
430 it("removes the side tab gap in both composer states while clearing overlapping attachments", () => {
431 for (const overlayHeight of [120, 214]) {
432 const layout = {
433 overlayHeight,
434 mainSurfaceTop: 534,
435 button: { left: 340, right: 460 },
436 attachments: [{ top: 500, left: 600, right: 700 }],
437 };
438 expect(resolveScrollToEndClearance(layout)).toBe(overlayHeight - 34);
439 expect(resolveScrollToEndClearance({ ...layout, attachments: [] })).toBe(overlayHeight);
440 expect(
441 resolveScrollToEndClearance({
442 ...layout,
443 attachments: [...layout.attachments, { top: 500, left: 100, right: 700 }],
444 }),
445 ).toBe(overlayHeight);
446 expect(resolveScrollToEndClearance({ ...layout, button: { left: 590, right: 710 } })).toBe(
447 overlayHeight,
448 );
449 }
450 });
451});
apps/web/src/components/composerFooterLayout.ts+17
......@@ -191,3 +191,20 @@ export function resolveRestingComposerControlsLayout(
191191 : minimumWidth <= hostWidth;
192192 return { hiddenCount, visible };
193193}
194
195export function resolveScrollToEndClearance(input: {
196 overlayHeight: number;
197 mainSurfaceTop: number;
198 button: { left: number; right: number };
199 attachments: ReadonlyArray<{ top: number; left: number; right: number }>;
200}): number {
201 let contentTop = input.mainSurfaceTop;
202 let top = contentTop;
203 for (const attachment of input.attachments) {
204 contentTop = Math.min(contentTop, attachment.top);
205 if (attachment.left < input.button.right && attachment.right > input.button.left) {
206 top = Math.min(top, attachment.top);
207 }
208 }
209 return Math.ceil(input.overlayHeight - (top - contentTop));
210}