authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-05 05:26:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 05:26:40-07:00
logc1d27e593b278e7d997c05efdbcc043bae653e83
tree55fab45c9738d65b1e802d6c5c18ae499b05f6dd
parent485782b2aab7d7aee57bacba841e11ac24d9cc63
signature Signed by PGP key B5690EEEBB952194

fix(web): keep the composer expanded until the thread can scroll (#9965)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

9 files changed, 161 insertions(+), 31 deletions(-)

apps/web/src/components/ChatView.tsx+11-26
......@@ -111,6 +111,7 @@ import { type LegendListRef } from "@legendapp/list/react";
111111import {
112112 CHAT_TIMELINE_ANCHOR_OFFSET,
113113 getAnchoredTurnMetrics,
114 timelineContentOverflowsViewport,
114115 type TimelineScrollMode,
115116} from "./chat/timelineScrollAnchoring";
116117import {
......@@ -1647,6 +1648,9 @@ export default function ChatView(props: ChatViewProps) {
16471648 const [scrollToEndClearance, setScrollToEndClearance] = useState(0);
16481649 const isAtEndRef = useRef(true);
16491650 const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []);
1651 // Whether the timeline's rows extend past the viewport above the composer.
1652 // The composer only rests when there is reading space to give back.
1653 const [timelineOverflows, setTimelineOverflows] = useState(false);
16501654 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
16511655 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
16521656 const sendInFlightRef = useRef(false);
......@@ -4502,32 +4506,11 @@ export default function ChatView(props: ChatViewProps) {
45024506 [composerTimelineInset],
45034507 );
45044508 const timelineRealContentOverflowsViewport = useCallback(
4505 (list?: LegendListRef | null) => {
4506 const resolvedList = list ?? legendListRef.current;
4507 const state = resolvedList?.getState();
4508 if (!resolvedList || !state || state.data.length === 0) {
4509 return false;
4510 }
4511
4512 const lastRowIndex = state.data.length - 1;
4513 const lastRowTop = state.positionAtIndex(lastRowIndex);
4514 const lastRowHeight = state.sizeAtIndex(lastRowIndex);
4515 if (
4516 typeof lastRowTop !== "number" ||
4517 typeof lastRowHeight !== "number" ||
4518 !Number.isFinite(lastRowTop) ||
4519 !Number.isFinite(lastRowHeight)
4520 ) {
4521 return false;
4522 }
4523
4524 const realContentBottom = lastRowTop + Math.max(1, lastRowHeight);
4525 const visibleScrollLength = Math.max(
4526 0,
4527 (state.scrollLength ?? 0) - composerTimelineInset - CHAT_TIMELINE_ANCHOR_OFFSET,
4528 );
4529 return realContentBottom > visibleScrollLength;
4530 },
4509 (list?: LegendListRef | null) =>
4510 timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), {
4511 composerInset: composerTimelineInset,
4512 anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
4513 }),
45314514 [composerTimelineInset],
45324515 );
45334516 const pageScrollControllerRef = useRef<ReturnType<typeof createPageScrollController> | null>(
......@@ -7857,6 +7840,7 @@ export default function ChatView(props: ChatViewProps) {
78577840 contentInsetEndAdjustment={composerTimelineInset}
78587841 liveFollowEnabled={timelineLiveFollowEnabled}
78597842 onIsAtEndChange={onIsAtEndChange}
7843 onContentOverflowChange={setTimelineOverflows}
78607844 onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}
78617845 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
78627846 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
......@@ -8003,6 +7987,7 @@ export default function ChatView(props: ChatViewProps) {
80037987 onRestingControlsVisibilityChange={setRestingComposerControlsVisible}
80047988 getTimelineScrollableNode={getTimelineScrollableNode}
80057989 isTimelineAtLogicalEnd={isTimelineAtLogicalEnd}
7990 timelineOverflows={timelineOverflows}
80067991 onComposerOverlayHeightChange={publishComposerOverlayHeight}
80077992 onRestingChange={onComposerRestingChange}
80087993 promptRef={promptRef}
apps/web/src/components/chat/ChatComposer.tsx+4
......@@ -1251,6 +1251,8 @@ export interface ChatComposerProps {
12511251 onRestingControlsVisibilityChange: (visible: boolean) => void;
12521252 getTimelineScrollableNode: () => HTMLElement | null;
12531253 isTimelineAtLogicalEnd: () => boolean;
1254 /** Whether the timeline has more content than fits above the composer. */
1255 timelineOverflows: boolean;
12541256 onComposerOverlayHeightChange: (height: number) => void;
12551257 /**
12561258 * Whether the desktop resting layout is active. Reported from a layout
......@@ -1361,6 +1363,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13611363 onRestingControlsVisibilityChange,
13621364 getTimelineScrollableNode,
13631365 isTimelineAtLogicalEnd,
1366 timelineOverflows,
13641367 onComposerOverlayHeightChange,
13651368 onRestingChange,
13661369 promptRef,
......@@ -3699,6 +3702,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
36993702 isScrollCollapsed: isComposerScrollCollapsed,
37003703 hasExpandedChrome: composerHasExpandedChrome,
37013704 collapseOnBlur: settings.composerCollapseOnBlur,
3705 timelineOverflows,
37023706 });
37033707 // The relocated controls live in the context strip whenever the composer is
37043708 // collapsed for any reason, the desktop resting layout or the phone
apps/web/src/components/chat/MessagesTimeline.test.tsx+1
......@@ -279,6 +279,7 @@ describe("MessagesTimeline", () => {
279279 isScrollCollapsed: composer.isComposerScrollCollapsed,
280280 hasExpandedChrome: false,
281281 collapseOnBlur: true,
282 timelineOverflows: true,
282283 });
283284 });
284285 return (
apps/web/src/components/chat/MessagesTimeline.tsx+58-3
......@@ -113,7 +113,10 @@ import {
113113} from "./ExpandedImagePreview";
114114import { ProposedPlanCard } from "./ProposedPlanCard";
115115import { ChangedFilesCard } from "./ChangedFilesTree";
116import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring";
116import {
117 CHAT_TIMELINE_ANCHOR_OFFSET,
118 timelineContentOverflowsViewport,
119} from "./timelineScrollAnchoring";
117120import { MessageCopyButton } from "./MessageCopyButton";
118121import { PierreEntryIcon } from "./PierreEntryIcon";
119122import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";
......@@ -335,6 +338,11 @@ interface MessagesTimelineProps {
335338 */
336339 liveFollowEnabled: boolean;
337340 onIsAtEndChange: (isAtEnd: boolean) => void;
341 /**
342 * Whether the real rows extend past the viewport above the composer.
343 * Reported after scrolls, row size changes, and viewport resizes.
344 */
345 onContentOverflowChange?: (overflows: boolean) => void;
338346 onToolOutputCollapsedAtEnd?: () => void;
339347 onManualNavigation: () => void;
340348 hideEmptyPlaceholder?: boolean;
......@@ -382,6 +390,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
382390 contentInsetEndAdjustment,
383391 liveFollowEnabled,
384392 onIsAtEndChange,
393 onContentOverflowChange,
385394 onToolOutputCollapsedAtEnd,
386395 onManualNavigation,
387396 hideEmptyPlaceholder = false,
......@@ -602,12 +611,49 @@ export const MessagesTimeline = memo(function MessagesTimeline({
602611 [anchoredEndSpace, contentInsetEndAdjustment],
603612 );
604613
614 const measureContentOverflow = useCallback(
615 () =>
616 timelineContentOverflowsViewport(listRef.current?.getState?.(), {
617 composerInset: contentInsetEndAdjustment,
618 anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
619 }),
620 [contentInsetEndAdjustment, listRef],
621 );
622 // LegendList lays rows out from layout effects, so a read on the next frame
623 // sees the settled positions. One frame is shared across bursts of size
624 // changes.
625 const contentOverflowFrameRef = useRef<number | null>(null);
626 const cancelContentOverflowFrame = useCallback(() => {
627 if (contentOverflowFrameRef.current !== null) {
628 cancelAnimationFrame(contentOverflowFrameRef.current);
629 contentOverflowFrameRef.current = null;
630 }
631 }, []);
632 const reportContentOverflow = useCallback(() => {
633 if (!onContentOverflowChange || contentOverflowFrameRef.current !== null) return;
634 contentOverflowFrameRef.current = requestAnimationFrame(() => {
635 contentOverflowFrameRef.current = null;
636 onContentOverflowChange(measureContentOverflow());
637 });
638 }, [measureContentOverflow, onContentOverflowChange]);
639 useEffect(() => cancelContentOverflowFrame, [cancelContentOverflowFrame]);
640 // The list's own layout effects have already run here, so estimated row
641 // positions are in place. Reporting before the first paint lets a thread
642 // open in its final composer layout instead of correcting it a frame later.
643 // A frame scheduled with the previous inset would overwrite this read, so
644 // it is dropped first.
645 useLayoutEffect(() => {
646 cancelContentOverflowFrame();
647 onContentOverflowChange?.(measureContentOverflow());
648 }, [cancelContentOverflowFrame, measureContentOverflow, onContentOverflowChange, rows.length]);
649
605650 const handleScroll = useCallback(() => {
606651 const state = listRef.current?.getState?.();
607652 const isAtEnd = resolveTimelineIsAtEnd(state);
608653 if (isAtEnd !== undefined && !citationPositioning) {
609654 onIsAtEndChange(isAtEnd);
610655 }
656 reportContentOverflow();
611657 if (!state || minimapItems.length === 0) {
612658 return;
613659 }
......@@ -630,7 +676,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
630676
631677 strip.dataset.inView = inView ? "true" : "false";
632678 }
633 }, [citationPositioning, listRef, minimapItems, minimapStripMap, onIsAtEndChange]);
679 }, [
680 citationPositioning,
681 listRef,
682 minimapItems,
683 minimapStripMap,
684 onIsAtEndChange,
685 reportContentOverflow,
686 ]);
634687
635688 useEffect(() => {
636689 const frame = requestAnimationFrame(handleScroll);
......@@ -649,6 +702,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
649702 current === nextHasPersistentGutter ? current : nextHasPersistentGutter,
650703 );
651704 setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth));
705 reportContentOverflow();
652706 };
653707
654708 const frame = requestAnimationFrame(measure);
......@@ -660,7 +714,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
660714 cancelAnimationFrame(frame);
661715 observer.disconnect();
662716 };
663 }, [timelineViewportElement, rows.length]);
717 }, [timelineViewportElement, rows.length, reportContentOverflow]);
664718
665719 const sharedState = useMemo<TimelineRowSharedState>(
666720 () => ({
......@@ -789,6 +843,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
789843 }
790844 maintainScrollAtEndThreshold={1}
791845 onScroll={handleScroll}
846 onItemSizeChanged={reportContentOverflow}
792847 className={cn(
793848 "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
794849 topFadeEnabled && "topbar-scroll-fade",
apps/web/src/components/chat/timelineScrollAnchoring.test.tsx+36-1
......@@ -1,5 +1,9 @@
11import { describe, expect, it } from "vite-plus/test";
2import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring";
2import {
3 getAnchoredTurnMetrics,
4 getRowBottom,
5 timelineContentOverflowsViewport,
6} from "./timelineScrollAnchoring";
37
48function buildState({
59 positions,
......@@ -21,6 +25,37 @@ function buildState({
2125 };
2226}
2327
28describe("timelineContentOverflowsViewport", () => {
29 const inset = { composerInset: 100, anchorOffset: 24 };
30
31 it("reports overflow from the last row, not the inset spacer", () => {
32 const fits = buildState({ positions: [0, 200], sizes: [200, 300], scrollLength: 700 });
33 expect(timelineContentOverflowsViewport(fits, inset)).toBe(false);
34
35 const overflows = buildState({ positions: [0, 200], sizes: [200, 400], scrollLength: 700 });
36 expect(timelineContentOverflowsViewport(overflows, inset)).toBe(true);
37 });
38
39 it("treats an empty or unmeasured list as fitting", () => {
40 expect(timelineContentOverflowsViewport(undefined, inset)).toBe(false);
41 expect(
42 timelineContentOverflowsViewport(
43 buildState({ positions: [0, 200], sizes: [200, 400], scrollLength: 0 }),
44 inset,
45 ),
46 ).toBe(false);
47 expect(timelineContentOverflowsViewport(buildState({ positions: [], sizes: [] }), inset)).toBe(
48 false,
49 );
50 expect(
51 timelineContentOverflowsViewport(
52 buildState({ positions: [0, 200], sizes: [200, Number.NaN] }),
53 inset,
54 ),
55 ).toBe(false);
56 });
57});
58
2459describe("timeline scroll anchoring", () => {
2560 it("measures row bottoms from LegendList row position and size", () => {
2661 const state = buildState({
apps/web/src/components/chat/timelineScrollAnchoring.ts+25
......@@ -37,6 +37,31 @@ export function getRowBottom(state: TimelineListMeasurementState, index: number)
3737 return top + Math.max(1, height);
3838}
3939
40/**
41 * Whether the timeline's real rows extend past the viewport left above the
42 * composer. The list's own content length includes the composer inset
43 * spacer, so this measures from the last row instead. Unknown row geometry
44 * or an unmeasured viewport counts as fitting.
45 */
46export function timelineContentOverflowsViewport(
47 state: TimelineListMeasurementState | undefined,
48 input: { readonly composerInset: number; readonly anchorOffset: number },
49): boolean {
50 if (!state || !state.data || state.data.length === 0) {
51 return false;
52 }
53 const scrollLength = state.scrollLength;
54 if (typeof scrollLength !== "number" || !Number.isFinite(scrollLength) || scrollLength <= 0) {
55 return false;
56 }
57 const lastBottom = getRowBottom(state, state.data.length - 1);
58 if (lastBottom === null) {
59 return false;
60 }
61 const visibleScrollLength = Math.max(0, scrollLength - input.composerInset - input.anchorOffset);
62 return lastBottom > visibleScrollLength;
63}
64
4065export function getAnchoredTurnMetrics({
4166 state,
4267 anchorIndex,
apps/web/src/components/chat/useComposerFocusState.test.tsx+1
......@@ -20,6 +20,7 @@ function ComposerProbe({ isMobileViewport = false }: { isMobileViewport?: boolea
2020 isScrollCollapsed: state.isComposerScrollCollapsed,
2121 hasExpandedChrome: false,
2222 collapseOnBlur: true,
23 timelineOverflows: true,
2324 });
2425 });
2526 return null;
apps/web/src/components/composerFooterLayout.test.ts+12
......@@ -104,6 +104,7 @@ describe("shouldUseRestingComposerLayout", () => {
104104 isScrollCollapsed: false,
105105 hasExpandedChrome: false,
106106 collapseOnBlur: true,
107 timelineOverflows: true,
107108 };
108109
109110 it("uses the resting layout for an unfocused desktop composer", () => {
......@@ -131,6 +132,17 @@ describe("shouldUseRestingComposerLayout", () => {
131132 ).toBe(true);
132133 });
133134
135 it("keeps the composer expanded while the timeline fits above it", () => {
136 expect(shouldUseRestingComposerLayout({ ...resting, timelineOverflows: false })).toBe(false);
137 expect(
138 shouldUseRestingComposerLayout({
139 ...resting,
140 isScrollCollapsed: true,
141 timelineOverflows: false,
142 }),
143 ).toBe(false);
144 });
145
134146 it("keeps new-thread composers expanded", () => {
135147 expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false);
136148 });
apps/web/src/components/composerFooterLayout.ts+13-1
......@@ -30,6 +30,8 @@ export function shouldUseRestingComposerLayout(input: {
3030 isScrollCollapsed: boolean;
3131 hasExpandedChrome: boolean;
3232 collapseOnBlur: boolean;
33 /** Whether the timeline has more content than fits above the composer. */
34 timelineOverflows: boolean;
3335}): boolean {
3436 // Passive draft content is deliberately absent here. Resting only clamps
3537 // the prompt row and overlays its actions; non-image attachment and context
......@@ -44,8 +46,18 @@ export function shouldUseRestingComposerLayout(input: {
4446 // the user asked for it with the gesture, and it lifts on the next
4547 // composer interaction. With blur collapse off, losing focus alone never
4648 // rests the composer.
49 //
50 // Resting exists to give reading space back to the timeline. A thread that
51 // fits above the composer has nothing to reclaim, so it stays expanded and
52 // never shows the collapsed row that a fresh thread would otherwise open on.
4753 const collapsed = input.isScrollCollapsed || (input.collapseOnBlur && !input.isFocused);
48 return input.isExistingThread && !input.isMobileViewport && collapsed && !input.hasExpandedChrome;
54 return (
55 input.isExistingThread &&
56 !input.isMobileViewport &&
57 input.timelineOverflows &&
58 collapsed &&
59 !input.hasExpandedChrome
60 );
4961}
5062
5163/**