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";...@@ -315,7 +315,7 @@ import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
315import { MessagesTimeline } from "./chat/MessagesTimeline";315import { MessagesTimeline } from "./chat/MessagesTimeline";
316import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";316import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";
317import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";317import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
318import { resolveComposerTimelineInset } from "./composerFooterLayout";318import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout";
319import { ChatHeader } from "./chat/ChatHeader";319import { ChatHeader } from "./chat/ChatHeader";
320import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";320import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
321import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview";321import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
...@@ -5163,27 +5163,48 @@ export default function ChatView(props: ChatViewProps) {...@@ -5163,27 +5163,48 @@ export default function ChatView(props: ChatViewProps) {
5163 ? activePlan.steps5163 ? activePlan.steps
5164 : null;5164 : null;
51655165
5166 const publishComposerOverlayHeight = useCallback((height: number) => {5166 const publishComposerOverlayHeight = useCallback(
5167 const nextHeight = Math.ceil(height);5167 (height: number) => {
5168 if (nextHeight <= 0) return;5168 const nextHeight = Math.ceil(height);
5169 const previousHeight = composerOverlayHeightRef.current;5169 if (nextHeight <= 0) return;
5170 if (previousHeight !== nextHeight) {5170 const previousHeight = composerOverlayHeightRef.current;
5171 composerOverlayHeightRef.current = nextHeight;5171 if (previousHeight !== nextHeight) {
5172 setComposerOverlayHeight(nextHeight);5172 composerOverlayHeightRef.current = nextHeight;
5173 }5173 setComposerOverlayHeight(nextHeight);
5174 const nextInset = resolveComposerTimelineInset({5174 }
5175 currentInset: composerTimelineInsetRef.current,5175 const nextInset = resolveComposerTimelineInset({
5176 overlayHeight: nextHeight,5176 currentInset: composerTimelineInsetRef.current,
5177 isResting: composerRestingRef.current,5177 overlayHeight: nextHeight,
5178 });5178 isResting: composerRestingRef.current,
5179 if (composerTimelineInsetRef.current !== nextInset) {5179 });
5180 composerTimelineInsetRef.current = nextInset;5180 if (composerTimelineInsetRef.current !== nextInset) {
5181 setComposerTimelineInset(nextInset);5181 composerTimelineInsetRef.current = nextInset;
5182 }5182 setComposerTimelineInset(nextInset);
5183 setScrollToEndClearance((currentClearance) =>5183 }
5184 currentClearance === nextHeight ? currentClearance : nextHeight,5184 const mainSurface = composerOverlayElement?.querySelector<HTMLElement>(
5185 );5185 '[data-chat-composer-main-surface="true"]',
5186 }, []);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 );
5187 // The composer reports its resting flag from a layout effect, which runs5208 // The composer reports its resting flag from a layout effect, which runs
5188 // before this component's own layout effects and before any resize5209 // before this component's own layout effects and before any resize
5189 // observation, so every measurement below sees the flag for its layout.5210 // observation, so every measurement below sees the flag for its layout.
...@@ -5216,7 +5237,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5216,7 +5237,7 @@ export default function ChatView(props: ChatViewProps) {
5216 return () => {5237 return () => {
5217 resizeObserver.disconnect();5238 resizeObserver.disconnect();
5218 };5239 };
5219 }, [composerOverlayElement, publishComposerOverlayHeight]);5240 }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]);
5220 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);5241 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);
5221 const activeThreadReferenceCopyTarget = useMemo(5242 const activeThreadReferenceCopyTarget = useMemo(
5222 () =>5243 () =>
apps/web/src/components/chat/ComposerBannerStack.tsx+2-1
...@@ -120,6 +120,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -120,6 +120,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
120 >120 >
121 <div className={cn("relative flex flex-col-reverse", hasStack && stackExpanded && "z-50")}>121 <div className={cn("relative flex flex-col-reverse", hasStack && stackExpanded && "z-50")}>
122 <div122 <div
123 key={frontItem.id}
123 className={cn(124 className={cn(
124 "relative z-10 transition-[translate,opacity] duration-220 ease-in",125 "relative z-10 transition-[translate,opacity] duration-220 ease-in",
125 exitingItemId === frontItem.id126 exitingItemId === frontItem.id
...@@ -147,7 +148,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -147,7 +148,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
147 {hasStack ? (148 {hasStack ? (
148 <div149 <div
149 ref={noticesRef}150 ref={noticesRef}
150 className={cn("relative z-20", stackExpanded && "min-h-3")}151 className="relative z-20 min-h-3"
151 onPointerEnter={(event) => {152 onPointerEnter={(event) => {
152 if (event.pointerType === "touch") return;153 if (event.pointerType === "touch") return;
153 if (document.activeElement === peekRef.current) {154 if (document.activeElement === peekRef.current) {
apps/web/src/components/composerFooterLayout.test.ts+25
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 COMPOSER_RESTING_EXPANSION_MIN_PX,7 COMPOSER_RESTING_EXPANSION_MIN_PX,
8 getRestingComposerImagePreviewCounts,8 getRestingComposerImagePreviewCounts,
9 resolveComposerTimelineInset,9 resolveComposerTimelineInset,
10 resolveScrollToEndClearance,
10 resolveRestingComposerControlsLayout,11 resolveRestingComposerControlsLayout,
11 resolveRestingComposerControlsNaturalWidth,12 resolveRestingComposerControlsNaturalWidth,
12 shouldAnimateComposerRestingTransition,13 shouldAnimateComposerRestingTransition,
...@@ -424,3 +425,27 @@ describe("resolveRestingComposerControlsLayout hysteresis", () => {...@@ -424,3 +425,27 @@ describe("resolveRestingComposerControlsLayout hysteresis", () => {
424 ).toEqual({ hiddenCount: 2, visible: true });425 ).toEqual({ hiddenCount: 2, visible: true });
425 });426 });
426});427});
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(...@@ -191,3 +191,20 @@ export function resolveRestingComposerControlsLayout(
191 : minimumWidth <= hostWidth;191 : minimumWidth <= hostWidth;
192 return { hiddenCount, visible };192 return { hiddenCount, visible };
193}193}
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}