authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 19:36:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 19:36:10-07:00
log9e1bc36a0843699db54ee28abbdac70584ae8f33
tree8a24568bc0591d8ae3d44a76eba091727aa7fe4c
parent710f6dc417ebf303eede3df3605a6938482d83ab
signature Signed by PGP key B5690EEEBB952194

fix(web): keep the last message visible when the resting composer expands (#9553)

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";
298298import { MessagesTimeline } from "./chat/MessagesTimeline";
299299import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";
300300import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
301import { resolveComposerTimelineInset } from "./composerFooterLayout";
301302import { ChatHeader } from "./chat/ChatHeader";
302303import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
303304import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
......@@ -1628,6 +1629,12 @@ function ChatViewContent(props: ChatViewProps) {
16281629 const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null);
16291630 const [composerOverlayHeight, setComposerOverlayHeight] = useState(0);
16301631 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);
16311638 const [scrollToEndClearance, setScrollToEndClearance] = useState(0);
16321639 const isAtEndRef = useRef(true);
16331640 const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []);
......@@ -4459,11 +4466,11 @@ function ChatViewContent(props: ChatViewProps) {
44594466 return getAnchoredTurnMetrics({
44604467 state,
44614468 anchorIndex,
4462 composerOverlayHeight,
4469 composerOverlayHeight: composerTimelineInset,
44634470 anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
44644471 });
44654472 },
4466 [composerOverlayHeight],
4473 [composerTimelineInset],
44674474 );
44684475 const timelineRealContentOverflowsViewport = useCallback(
44694476 (list?: LegendListRef | null) => {
......@@ -4488,11 +4495,11 @@ function ChatViewContent(props: ChatViewProps) {
44884495 const realContentBottom = lastRowTop + Math.max(1, lastRowHeight);
44894496 const visibleScrollLength = Math.max(
44904497 0,
4491 (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET,
4498 (state.scrollLength ?? 0) - composerTimelineInset - CHAT_TIMELINE_ANCHOR_OFFSET,
44924499 );
44934500 return realContentBottom > visibleScrollLength;
44944501 },
4495 [composerOverlayHeight],
4502 [composerTimelineInset],
44964503 );
44974504 const pageScrollControllerRef = useRef<ReturnType<typeof createPageScrollController> | null>(
44984505 null,
......@@ -4964,10 +4971,35 @@ function ChatViewContent(props: ChatViewProps) {
49644971 composerOverlayHeightRef.current = nextHeight;
49654972 setComposerOverlayHeight(nextHeight);
49664973 }
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 }
49674983 setScrollToEndClearance((currentClearance) =>
49684984 currentClearance === nextHeight ? currentClearance : nextHeight,
49694985 );
49704986 }, []);
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]);
49715003
49725004 useLayoutEffect(() => {
49735005 if (!composerOverlayElement) return;
......@@ -7775,7 +7807,7 @@ function ChatViewContent(props: ChatViewProps) {
77757807 }
77767808 anchorMessageId={timelineAnchorMessageId}
77777809 onAnchorReady={onTimelineAnchorReady}
7778 contentInsetEndAdjustment={composerOverlayHeight}
7810 contentInsetEndAdjustment={composerTimelineInset}
77797811 liveFollowEnabled={timelineLiveFollowEnabled}
77807812 onIsAtEndChange={onIsAtEndChange}
77817813 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
......@@ -7923,6 +7955,7 @@ function ChatViewContent(props: ChatViewProps) {
79237955 getTimelineScrollableNode={getTimelineScrollableNode}
79247956 isTimelineAtLogicalEnd={isTimelineAtLogicalEnd}
79257957 onComposerOverlayHeightChange={publishComposerOverlayHeight}
7958 onRestingChange={onComposerRestingChange}
79267959 promptRef={promptRef}
79277960 composerImagesRef={composerImagesRef}
79287961 composerFilesRef={composerFilesRef}
apps/web/src/components/chat/ChatComposer.tsx+40-17
......@@ -251,12 +251,14 @@ const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4;
251251
252252function useComposerRestingTransition(
253253 isCollapsed: boolean,
254 isResting: boolean,
254255 restingControlsRef: React.RefObject<HTMLDivElement | null>,
255256 onOverlayHeightChange: (height: number) => void,
256257) {
257258 const elementRef = useRef<HTMLDivElement>(null);
258259 const isCollapsedRef = useRef(isCollapsed);
259260 const previousCollapsedRef = useRef(isCollapsed);
261 const previousRestingRef = useRef(isResting);
260262 const previousHeightRef = useRef<number | null>(null);
261263 const previousContentOffsetsRef = useRef<{
262264 promptFromTop: number | null;
......@@ -355,6 +357,15 @@ function useComposerRestingTransition(
355357
356358 const nextRect = element.getBoundingClientRect();
357359 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 }
358369 const nextPromptRect = prompt?.getBoundingClientRect() ?? null;
359370 const nextPromptTop = nextPromptRect?.top ?? null;
360371 const nextActionTop = action?.getBoundingClientRect().top ?? null;
......@@ -385,18 +396,13 @@ function useComposerRestingTransition(
385396 element.style.overflow = "clip";
386397 surface.style.height = "100%";
387398
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`;
400406 overlay.style.display = "flex";
401407 overlay.style.flexDirection = "column";
402408 overlay.style.justifyContent = "flex-end";
......@@ -430,11 +436,6 @@ function useComposerRestingTransition(
430436 );
431437 animationRef.current = animation;
432438 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 }
438439
439440 const animatedRect = element.getBoundingClientRect();
440441 const previousPromptTop =
......@@ -612,6 +613,18 @@ function useComposerRestingTransition(
612613 };
613614 }, [isCollapsed, transitionToCurrentGeometry]);
614615
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
615628 useLayoutEffect(() => {
616629 const element = elementRef.current;
617630 if (!element || typeof ResizeObserver === "undefined") return;
......@@ -1232,6 +1245,11 @@ export interface ChatComposerProps {
12321245 getTimelineScrollableNode: () => HTMLElement | null;
12331246 isTimelineAtLogicalEnd: () => boolean;
12341247 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;
12351253
12361254 // Refs the parent needs kept in sync
12371255 promptRef: React.RefObject<string>;
......@@ -1336,6 +1354,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13361354 getTimelineScrollableNode,
13371355 isTimelineAtLogicalEnd,
13381356 onComposerOverlayHeightChange,
1357 onRestingChange,
13391358 promptRef,
13401359 composerRef,
13411360 composerImagesRef,
......@@ -3584,6 +3603,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
35843603 useLayoutEffect(() => {
35853604 onRestingControlsVisibilityChange(composerControlsVisibleInStrip);
35863605 }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]);
3606 useLayoutEffect(() => {
3607 onRestingChange(isComposerResting);
3608 }, [isComposerResting, onRestingChange]);
35873609 const restingImagePreviewCounts = getRestingComposerImagePreviewCounts(
35883610 standaloneComposerImages.length,
35893611 );
......@@ -3639,6 +3661,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
36393661 ) : null;
36403662 const composerMainSurfaceRef = useComposerRestingTransition(
36413663 composerControlsInStrip,
3664 isComposerResting,
36423665 restingComposerControlsRef,
36433666 onComposerOverlayHeightChange,
36443667 );
apps/web/src/components/composerFooterLayout.test.ts+22
......@@ -4,7 +4,9 @@ import { resolveContextStripLabelsCompact } from "./BranchToolbar.logic";
44import {
55 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
66 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
7 COMPOSER_RESTING_EXPANSION_MIN_PX,
78 getRestingComposerImagePreviewCounts,
9 resolveComposerTimelineInset,
810 resolveRestingComposerControlsLayout,
911 resolveRestingComposerControlsNaturalWidth,
1012 shouldAnimateComposerRestingTransition,
......@@ -74,6 +76,26 @@ describe("shouldUseCompactComposerPrimaryActions", () => {
7476 });
7577});
7678
79describe("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
7799describe("shouldUseRestingComposerLayout", () => {
78100 const resting = {
79101 isExistingThread: true,
apps/web/src/components/composerFooterLayout.ts+30
......@@ -48,6 +48,36 @@ export function shouldUseRestingComposerLayout(input: {
4848 return input.isExistingThread && !input.isMobileViewport && collapsed && !input.hasExpandedChrome;
4949}
5050
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 */
57export 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 */
71export 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
5181export function shouldAnimateComposerRestingTransition(input: {
5282 hasCompletedInitialLayout: boolean;
5383 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
5555the composer loses focus. At wider sizes, scrolling the conversation also rests a focused composer,
5656except when scrolling toward the end while already there. When the thread-context strip has room,
5757the model and mode controls stay available beside the thread context; otherwise they return when the
58composer is focused. Focus the composer or start typing to expand it again. New-thread layouts keep
59the full composer. **Settings → General → Collapse composer** chooses which triggers rest it:
58composer is focused. Focus the composer or start typing to expand it again. The conversation keeps
59the expanded composer's space clear above its last message while the composer rests, so expanding it
60again never covers what you scrolled to. New-thread layouts keep the full composer. **Settings → General → Collapse composer** chooses which triggers rest it:
6061**On unfocus**, **On scroll**, both, or neither. With neither selected the composer stays expanded.
6162
6263At phone-sized web or desktop window widths, existing threads animate between their compact and