| author | |
| committer | |
| log | 9e1bc36a0843699db54ee28abbdac70584ae8f33 |
| tree | 8a24568bc0591d8ae3d44a76eba091727aa7fe4c |
| parent | 710f6dc417ebf303eede3df3605a6938482d83ab |
| signature | Signed by PGP key B5690EEEBB952194 |
Scrolling a long thread to the end with the composer at rest landed flush
against the short composer. The expansion that followed then covered the
last rows, because the timeline reserves only the live overlay height and
does not move for footer growth.
The timeline now keeps the expanded composer's height clear while the
composer rests, so expanding it again changes nothing above the composer.
The composer reports its resting flag from a layout effect and publishes a
fresh overlay height whenever that flag changes, so the reservation is
always computed from a height that belongs to the same layout.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>5 files changed, 133 insertions(+), 24 deletions(-)
apps/web/src/components/ChatView.tsx+38-5| ... | ... | @@ -298,6 +298,7 @@ import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| 298 | 298 | import { MessagesTimeline } from "./chat/MessagesTimeline"; |
| 299 | 299 | import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; |
| 300 | 300 | import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; |
| 301 | import { resolveComposerTimelineInset } from "./composerFooterLayout"; | |
| 301 | 302 | import { ChatHeader } from "./chat/ChatHeader"; |
| 302 | 303 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; |
| 303 | 304 | import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| ... | ... | @@ -1628,6 +1629,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 1628 | 1629 | const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null); |
| 1629 | 1630 | const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); |
| 1630 | 1631 | const composerOverlayHeightRef = useRef(0); |
| 1632 | // Space the timeline keeps clear above its end. Tracks the overlay while the | |
| 1633 | // composer is expanded and holds that height while it rests, so the resting | |
| 1634 | // composer never exposes rows that its expansion will cover. | |
| 1635 | const [composerTimelineInset, setComposerTimelineInset] = useState(0); | |
| 1636 | const composerTimelineInsetRef = useRef(0); | |
| 1637 | const composerRestingRef = useRef(false); | |
| 1631 | 1638 | const [scrollToEndClearance, setScrollToEndClearance] = useState(0); |
| 1632 | 1639 | const isAtEndRef = useRef(true); |
| 1633 | 1640 | const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []); |
| ... | ... | @@ -4459,11 +4466,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 4459 | 4466 | return getAnchoredTurnMetrics({ |
| 4460 | 4467 | state, |
| 4461 | 4468 | anchorIndex, |
| 4462 | composerOverlayHeight, | |
| 4469 | composerOverlayHeight: composerTimelineInset, | |
| 4463 | 4470 | anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, |
| 4464 | 4471 | }); |
| 4465 | 4472 | }, |
| 4466 | [composerOverlayHeight], | |
| 4473 | [composerTimelineInset], | |
| 4467 | 4474 | ); |
| 4468 | 4475 | const timelineRealContentOverflowsViewport = useCallback( |
| 4469 | 4476 | (list?: LegendListRef | null) => { |
| ... | ... | @@ -4488,11 +4495,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 4488 | 4495 | const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); |
| 4489 | 4496 | const visibleScrollLength = Math.max( |
| 4490 | 4497 | 0, |
| 4491 | (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET, | |
| 4498 | (state.scrollLength ?? 0) - composerTimelineInset - CHAT_TIMELINE_ANCHOR_OFFSET, | |
| 4492 | 4499 | ); |
| 4493 | 4500 | return realContentBottom > visibleScrollLength; |
| 4494 | 4501 | }, |
| 4495 | [composerOverlayHeight], | |
| 4502 | [composerTimelineInset], | |
| 4496 | 4503 | ); |
| 4497 | 4504 | const pageScrollControllerRef = useRef<ReturnType<typeof createPageScrollController> | null>( |
| 4498 | 4505 | null, |
| ... | ... | @@ -4964,10 +4971,35 @@ function ChatViewContent(props: ChatViewProps) { |
| 4964 | 4971 | composerOverlayHeightRef.current = nextHeight; |
| 4965 | 4972 | setComposerOverlayHeight(nextHeight); |
| 4966 | 4973 | } |
| 4974 | const nextInset = resolveComposerTimelineInset({ | |
| 4975 | currentInset: composerTimelineInsetRef.current, | |
| 4976 | overlayHeight: nextHeight, | |
| 4977 | isResting: composerRestingRef.current, | |
| 4978 | }); | |
| 4979 | if (composerTimelineInsetRef.current !== nextInset) { | |
| 4980 | composerTimelineInsetRef.current = nextInset; | |
| 4981 | setComposerTimelineInset(nextInset); | |
| 4982 | } | |
| 4967 | 4983 | setScrollToEndClearance((currentClearance) => |
| 4968 | 4984 | currentClearance === nextHeight ? currentClearance : nextHeight, |
| 4969 | 4985 | ); |
| 4970 | 4986 | }, []); |
| 4987 | // The composer reports its resting flag from a layout effect, which runs | |
| 4988 | // before this component's own layout effects and before any resize | |
| 4989 | // observation, so every measurement below sees the flag for its layout. | |
| 4990 | // Only the flag is stored here: the stored height still belongs to the | |
| 4991 | // previous layout, and the composer publishes the new layout's height | |
| 4992 | // itself once it has measured it. | |
| 4993 | const onComposerRestingChange = useCallback((resting: boolean) => { | |
| 4994 | composerRestingRef.current = resting; | |
| 4995 | }, []); | |
| 4996 | // A held reservation belongs to the previous thread's draft. Rebuild it from | |
| 4997 | // this thread's overlay so a tall draft elsewhere does not pad this one. | |
| 4998 | useLayoutEffect(() => { | |
| 4999 | if (!composerOverlayElement) return; | |
| 5000 | composerTimelineInsetRef.current = 0; | |
| 5001 | publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); | |
| 5002 | }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeight]); | |
| 4971 | 5003 | |
| 4972 | 5004 | useLayoutEffect(() => { |
| 4973 | 5005 | if (!composerOverlayElement) return; |
| ... | ... | @@ -7775,7 +7807,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7775 | 7807 | } |
| 7776 | 7808 | anchorMessageId={timelineAnchorMessageId} |
| 7777 | 7809 | onAnchorReady={onTimelineAnchorReady} |
| 7778 | contentInsetEndAdjustment={composerOverlayHeight} | |
| 7810 | contentInsetEndAdjustment={composerTimelineInset} | |
| 7779 | 7811 | liveFollowEnabled={timelineLiveFollowEnabled} |
| 7780 | 7812 | onIsAtEndChange={onIsAtEndChange} |
| 7781 | 7813 | onManualNavigation={cancelTimelineLiveFollowForUserNavigation} |
| ... | ... | @@ -7923,6 +7955,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7923 | 7955 | getTimelineScrollableNode={getTimelineScrollableNode} |
| 7924 | 7956 | isTimelineAtLogicalEnd={isTimelineAtLogicalEnd} |
| 7925 | 7957 | onComposerOverlayHeightChange={publishComposerOverlayHeight} |
| 7958 | onRestingChange={onComposerRestingChange} | |
| 7926 | 7959 | promptRef={promptRef} |
| 7927 | 7960 | composerImagesRef={composerImagesRef} |
| 7928 | 7961 | composerFilesRef={composerFilesRef} |
apps/web/src/components/chat/ChatComposer.tsx+40-17| ... | ... | @@ -251,12 +251,14 @@ const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4; |
| 251 | 251 | |
| 252 | 252 | function useComposerRestingTransition( |
| 253 | 253 | isCollapsed: boolean, |
| 254 | isResting: boolean, | |
| 254 | 255 | restingControlsRef: React.RefObject<HTMLDivElement | null>, |
| 255 | 256 | onOverlayHeightChange: (height: number) => void, |
| 256 | 257 | ) { |
| 257 | 258 | const elementRef = useRef<HTMLDivElement>(null); |
| 258 | 259 | const isCollapsedRef = useRef(isCollapsed); |
| 259 | 260 | const previousCollapsedRef = useRef(isCollapsed); |
| 261 | const previousRestingRef = useRef(isResting); | |
| 260 | 262 | const previousHeightRef = useRef<number | null>(null); |
| 261 | 263 | const previousContentOffsetsRef = useRef<{ |
| 262 | 264 | promptFromTop: number | null; |
| ... | ... | @@ -355,6 +357,15 @@ function useComposerRestingTransition( |
| 355 | 357 | |
| 356 | 358 | const nextRect = element.getBoundingClientRect(); |
| 357 | 359 | const nextHeight = nextRect.height; |
| 360 | // The chat view resize-observes the overlay to place the timeline | |
| 361 | // inset, the scroll-to-end pill, and the mini player. Publishing the | |
| 362 | // destination height here turns that feedback into one update instead | |
| 363 | // of a ChatView re-render on every animation frame. | |
| 364 | const overlay = element.closest<HTMLElement>('[data-chat-composer-overlay="true"]'); | |
| 365 | const overlayHeight = overlay?.getBoundingClientRect().height ?? null; | |
| 366 | if (overlayHeight !== null) { | |
| 367 | onOverlayHeightChange(overlayHeight); | |
| 368 | } | |
| 358 | 369 | const nextPromptRect = prompt?.getBoundingClientRect() ?? null; |
| 359 | 370 | const nextPromptTop = nextPromptRect?.top ?? null; |
| 360 | 371 | const nextActionTop = action?.getBoundingClientRect().top ?? null; |
| ... | ... | @@ -385,18 +396,13 @@ function useComposerRestingTransition( |
| 385 | 396 | element.style.overflow = "clip"; |
| 386 | 397 | surface.style.height = "100%"; |
| 387 | 398 | |
| 388 | // The chat view resize-observes the overlay to place the timeline | |
| 389 | // inset, the scroll-to-end pill, and the mini player. Pinning the | |
| 390 | // overlay at the destination height turns that feedback into one | |
| 391 | // update instead of a ChatView re-render on every animation frame; | |
| 392 | // bottom alignment keeps the animating surface glued to the overlay's | |
| 393 | // stable bottom edge. The pin lasts only for the tween so later | |
| 394 | // attachment, thread, font, and viewport changes remain natural. | |
| 395 | const overlay = element.closest<HTMLElement>('[data-chat-composer-overlay="true"]'); | |
| 396 | let pinnedOverlayHeight: number | null = null; | |
| 397 | if (overlay) { | |
| 398 | pinnedOverlayHeight = overlay.getBoundingClientRect().height; | |
| 399 | overlay.style.height = `${String(pinnedOverlayHeight)}px`; | |
| 399 | // Pinning the overlay at the destination height keeps the resize | |
| 400 | // observer quiet for the tween; bottom alignment keeps the animating | |
| 401 | // surface glued to the overlay's stable bottom edge. The pin lasts | |
| 402 | // only for the tween so later attachment, thread, font, and viewport | |
| 403 | // changes remain natural. | |
| 404 | if (overlay && overlayHeight !== null) { | |
| 405 | overlay.style.height = `${String(overlayHeight)}px`; | |
| 400 | 406 | overlay.style.display = "flex"; |
| 401 | 407 | overlay.style.flexDirection = "column"; |
| 402 | 408 | overlay.style.justifyContent = "flex-end"; |
| ... | ... | @@ -430,11 +436,6 @@ function useComposerRestingTransition( |
| 430 | 436 | ); |
| 431 | 437 | animationRef.current = animation; |
| 432 | 438 | animationTargetHeightRef.current = nextHeight; |
| 433 | // Publish the destination overlay geometry in the same layout pass; | |
| 434 | // ResizeObserver remains the fallback for non-transition changes. | |
| 435 | if (pinnedOverlayHeight !== null) { | |
| 436 | onOverlayHeightChange(pinnedOverlayHeight); | |
| 437 | } | |
| 438 | 439 | |
| 439 | 440 | const animatedRect = element.getBoundingClientRect(); |
| 440 | 441 | const previousPromptTop = |
| ... | ... | @@ -612,6 +613,18 @@ function useComposerRestingTransition( |
| 612 | 613 | }; |
| 613 | 614 | }, [isCollapsed, transitionToCurrentGeometry]); |
| 614 | 615 | |
| 616 | // The resting flag can change while the collapsed layout stays the same, | |
| 617 | // for example when an unfocused thread crosses the phone breakpoint. The | |
| 618 | // chat view pairs overlay heights with that flag, so republish the natural | |
| 619 | // height for the new flag. A transition in flight publishes its own. | |
| 620 | useLayoutEffect(() => { | |
| 621 | if (previousRestingRef.current === isResting) return; | |
| 622 | previousRestingRef.current = isResting; | |
| 623 | if (animationRef.current) return; | |
| 624 | const overlay = elementRef.current?.closest<HTMLElement>('[data-chat-composer-overlay="true"]'); | |
| 625 | if (overlay) onOverlayHeightChange(overlay.getBoundingClientRect().height); | |
| 626 | }, [isResting, onOverlayHeightChange]); | |
| 627 | ||
| 615 | 628 | useLayoutEffect(() => { |
| 616 | 629 | const element = elementRef.current; |
| 617 | 630 | if (!element || typeof ResizeObserver === "undefined") return; |
| ... | ... | @@ -1232,6 +1245,11 @@ export interface ChatComposerProps { |
| 1232 | 1245 | getTimelineScrollableNode: () => HTMLElement | null; |
| 1233 | 1246 | isTimelineAtLogicalEnd: () => boolean; |
| 1234 | 1247 | onComposerOverlayHeightChange: (height: number) => void; |
| 1248 | /** | |
| 1249 | * Whether the desktop resting layout is active. Reported from a layout | |
| 1250 | * effect, so it is current before the chat view measures the overlay. | |
| 1251 | */ | |
| 1252 | onRestingChange: (resting: boolean) => void; | |
| 1235 | 1253 | |
| 1236 | 1254 | // Refs the parent needs kept in sync |
| 1237 | 1255 | promptRef: React.RefObject<string>; |
| ... | ... | @@ -1336,6 +1354,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1336 | 1354 | getTimelineScrollableNode, |
| 1337 | 1355 | isTimelineAtLogicalEnd, |
| 1338 | 1356 | onComposerOverlayHeightChange, |
| 1357 | onRestingChange, | |
| 1339 | 1358 | promptRef, |
| 1340 | 1359 | composerRef, |
| 1341 | 1360 | composerImagesRef, |
| ... | ... | @@ -3584,6 +3603,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3584 | 3603 | useLayoutEffect(() => { |
| 3585 | 3604 | onRestingControlsVisibilityChange(composerControlsVisibleInStrip); |
| 3586 | 3605 | }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]); |
| 3606 | useLayoutEffect(() => { | |
| 3607 | onRestingChange(isComposerResting); | |
| 3608 | }, [isComposerResting, onRestingChange]); | |
| 3587 | 3609 | const restingImagePreviewCounts = getRestingComposerImagePreviewCounts( |
| 3588 | 3610 | standaloneComposerImages.length, |
| 3589 | 3611 | ); |
| ... | ... | @@ -3639,6 +3661,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3639 | 3661 | ) : null; |
| 3640 | 3662 | const composerMainSurfaceRef = useComposerRestingTransition( |
| 3641 | 3663 | composerControlsInStrip, |
| 3664 | isComposerResting, | |
| 3642 | 3665 | restingComposerControlsRef, |
| 3643 | 3666 | onComposerOverlayHeightChange, |
| 3644 | 3667 | ); |
apps/web/src/components/composerFooterLayout.test.ts+22| ... | ... | @@ -4,7 +4,9 @@ import { resolveContextStripLabelsCompact } from "./BranchToolbar.logic"; |
| 4 | 4 | import { |
| 5 | 5 | COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, |
| 6 | 6 | COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, |
| 7 | COMPOSER_RESTING_EXPANSION_MIN_PX, | |
| 7 | 8 | getRestingComposerImagePreviewCounts, |
| 9 | resolveComposerTimelineInset, | |
| 8 | 10 | resolveRestingComposerControlsLayout, |
| 9 | 11 | resolveRestingComposerControlsNaturalWidth, |
| 10 | 12 | shouldAnimateComposerRestingTransition, |
| ... | ... | @@ -74,6 +76,26 @@ describe("shouldUseCompactComposerPrimaryActions", () => { |
| 74 | 76 | }); |
| 75 | 77 | }); |
| 76 | 78 | |
| 79 | describe("resolveComposerTimelineInset", () => { | |
| 80 | it("follows the expanded overlay height", () => { | |
| 81 | expect( | |
| 82 | resolveComposerTimelineInset({ currentInset: 160, overlayHeight: 140, isResting: false }), | |
| 83 | ).toBe(140); | |
| 84 | }); | |
| 85 | ||
| 86 | it("keeps a larger expanded reservation while resting", () => { | |
| 87 | expect( | |
| 88 | resolveComposerTimelineInset({ currentInset: 200, overlayHeight: 60, isResting: true }), | |
| 89 | ).toBe(200); | |
| 90 | }); | |
| 91 | ||
| 92 | it("reserves the empty expansion when no larger height is known", () => { | |
| 93 | expect( | |
| 94 | resolveComposerTimelineInset({ currentInset: 0, overlayHeight: 60, isResting: true }), | |
| 95 | ).toBe(60 + COMPOSER_RESTING_EXPANSION_MIN_PX); | |
| 96 | }); | |
| 97 | }); | |
| 98 | ||
| 77 | 99 | describe("shouldUseRestingComposerLayout", () => { |
| 78 | 100 | const resting = { |
| 79 | 101 | isExistingThread: true, |
apps/web/src/components/composerFooterLayout.ts+30| ... | ... | @@ -48,6 +48,36 @@ export function shouldUseRestingComposerLayout(input: { |
| 48 | 48 | return input.isExistingThread && !input.isMobileViewport && collapsed && !input.hasExpandedChrome; |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | /** | |
| 52 | * How much taller the empty expanded composer is than its resting row on | |
| 53 | * desktop widths, from the layout classes in ChatComposer: the body loses | |
| 54 | * 8px of top padding, the prompt clamps from min-h-17.5 (70px) to 32px, and | |
| 55 | * the 48px footer leaves flow. | |
| 56 | */ | |
| 57 | export const COMPOSER_RESTING_EXPANSION_MIN_PX = 94; | |
| 58 | ||
| 59 | /** | |
| 60 | * The space the timeline reserves at its end for the composer overlay. | |
| 61 | * | |
| 62 | * The overlay is measured live, but a resting composer is much shorter than | |
| 63 | * an expanded one. Reserving only the resting height lets a scroll to the end | |
| 64 | * land flush against the short composer, and the expansion that follows then | |
| 65 | * covers the last rows because the timeline never moves for footer growth. | |
| 66 | * While resting, the reservation keeps the last expanded height, or at least | |
| 67 | * the resting height plus the empty expansion, so expanding again changes | |
| 68 | * nothing above the composer. An expanded measurement is authoritative and | |
| 69 | * may shrink it. | |
| 70 | */ | |
| 71 | export function resolveComposerTimelineInset(input: { | |
| 72 | currentInset: number; | |
| 73 | overlayHeight: number; | |
| 74 | isResting: boolean; | |
| 75 | }): number { | |
| 76 | return input.isResting | |
| 77 | ? Math.max(input.currentInset, input.overlayHeight + COMPOSER_RESTING_EXPANSION_MIN_PX) | |
| 78 | : input.overlayHeight; | |
| 79 | } | |
| 80 | ||
| 51 | 81 | export function shouldAnimateComposerRestingTransition(input: { |
| 52 | 82 | hasCompletedInitialLayout: boolean; |
| 53 | 83 | stateChanged: boolean; |
docs/user/composer.md+3-2| ... | ... | @@ -55,8 +55,9 @@ On web and desktop, an existing thread settles its composer into a single-line r |
| 55 | 55 | the composer loses focus. At wider sizes, scrolling the conversation also rests a focused composer, |
| 56 | 56 | except when scrolling toward the end while already there. When the thread-context strip has room, |
| 57 | 57 | the model and mode controls stay available beside the thread context; otherwise they return when the |
| 58 | composer is focused. Focus the composer or start typing to expand it again. New-thread layouts keep | |
| 59 | the full composer. **Settings → General → Collapse composer** chooses which triggers rest it: | |
| 58 | composer is focused. Focus the composer or start typing to expand it again. The conversation keeps | |
| 59 | the expanded composer's space clear above its last message while the composer rests, so expanding it | |
| 60 | again never covers what you scrolled to. New-thread layouts keep the full composer. **Settings → General → Collapse composer** chooses which triggers rest it: | |
| 60 | 61 | **On unfocus**, **On scroll**, both, or neither. With neither selected the composer stays expanded. |
| 61 | 62 | |
| 62 | 63 | At phone-sized web or desktop window widths, existing threads animate between their compact and |