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";...@@ -111,6 +111,7 @@ import { type LegendListRef } from "@legendapp/list/react";
111import {111import {
112 CHAT_TIMELINE_ANCHOR_OFFSET,112 CHAT_TIMELINE_ANCHOR_OFFSET,
113 getAnchoredTurnMetrics,113 getAnchoredTurnMetrics,
114 timelineContentOverflowsViewport,
114 type TimelineScrollMode,115 type TimelineScrollMode,
115} from "./chat/timelineScrollAnchoring";116} from "./chat/timelineScrollAnchoring";
116import {117import {
...@@ -1647,6 +1648,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -1647,6 +1648,9 @@ export default function ChatView(props: ChatViewProps) {
1647 const [scrollToEndClearance, setScrollToEndClearance] = useState(0);1648 const [scrollToEndClearance, setScrollToEndClearance] = useState(0);
1648 const isAtEndRef = useRef(true);1649 const isAtEndRef = useRef(true);
1649 const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []);1650 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);
1650 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});1654 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
1651 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});1655 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
1652 const sendInFlightRef = useRef(false);1656 const sendInFlightRef = useRef(false);
...@@ -4502,32 +4506,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -4502,32 +4506,11 @@ export default function ChatView(props: ChatViewProps) {
4502 [composerTimelineInset],4506 [composerTimelineInset],
4503 );4507 );
4504 const timelineRealContentOverflowsViewport = useCallback(4508 const timelineRealContentOverflowsViewport = useCallback(
4505 (list?: LegendListRef | null) => {4509 (list?: LegendListRef | null) =>
4506 const resolvedList = list ?? legendListRef.current;4510 timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), {
4507 const state = resolvedList?.getState();4511 composerInset: composerTimelineInset,
4508 if (!resolvedList || !state || state.data.length === 0) {4512 anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
4509 return false;4513 }),
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 },
4531 [composerTimelineInset],4514 [composerTimelineInset],
4532 );4515 );
4533 const pageScrollControllerRef = useRef<ReturnType<typeof createPageScrollController> | null>(4516 const pageScrollControllerRef = useRef<ReturnType<typeof createPageScrollController> | null>(
...@@ -7857,6 +7840,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7857,6 +7840,7 @@ export default function ChatView(props: ChatViewProps) {
7857 contentInsetEndAdjustment={composerTimelineInset}7840 contentInsetEndAdjustment={composerTimelineInset}
7858 liveFollowEnabled={timelineLiveFollowEnabled}7841 liveFollowEnabled={timelineLiveFollowEnabled}
7859 onIsAtEndChange={onIsAtEndChange}7842 onIsAtEndChange={onIsAtEndChange}
7843 onContentOverflowChange={setTimelineOverflows}
7860 onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}7844 onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}
7861 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}7845 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
7862 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}7846 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
...@@ -8003,6 +7987,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8003,6 +7987,7 @@ export default function ChatView(props: ChatViewProps) {
8003 onRestingControlsVisibilityChange={setRestingComposerControlsVisible}7987 onRestingControlsVisibilityChange={setRestingComposerControlsVisible}
8004 getTimelineScrollableNode={getTimelineScrollableNode}7988 getTimelineScrollableNode={getTimelineScrollableNode}
8005 isTimelineAtLogicalEnd={isTimelineAtLogicalEnd}7989 isTimelineAtLogicalEnd={isTimelineAtLogicalEnd}
7990 timelineOverflows={timelineOverflows}
8006 onComposerOverlayHeightChange={publishComposerOverlayHeight}7991 onComposerOverlayHeightChange={publishComposerOverlayHeight}
8007 onRestingChange={onComposerRestingChange}7992 onRestingChange={onComposerRestingChange}
8008 promptRef={promptRef}7993 promptRef={promptRef}
apps/web/src/components/chat/ChatComposer.tsx+4
...@@ -1251,6 +1251,8 @@ export interface ChatComposerProps {...@@ -1251,6 +1251,8 @@ export interface ChatComposerProps {
1251 onRestingControlsVisibilityChange: (visible: boolean) => void;1251 onRestingControlsVisibilityChange: (visible: boolean) => void;
1252 getTimelineScrollableNode: () => HTMLElement | null;1252 getTimelineScrollableNode: () => HTMLElement | null;
1253 isTimelineAtLogicalEnd: () => boolean;1253 isTimelineAtLogicalEnd: () => boolean;
1254 /** Whether the timeline has more content than fits above the composer. */
1255 timelineOverflows: boolean;
1254 onComposerOverlayHeightChange: (height: number) => void;1256 onComposerOverlayHeightChange: (height: number) => void;
1255 /**1257 /**
1256 * Whether the desktop resting layout is active. Reported from a layout1258 * Whether the desktop resting layout is active. Reported from a layout
...@@ -1361,6 +1363,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1361,6 +1363,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1361 onRestingControlsVisibilityChange,1363 onRestingControlsVisibilityChange,
1362 getTimelineScrollableNode,1364 getTimelineScrollableNode,
1363 isTimelineAtLogicalEnd,1365 isTimelineAtLogicalEnd,
1366 timelineOverflows,
1364 onComposerOverlayHeightChange,1367 onComposerOverlayHeightChange,
1365 onRestingChange,1368 onRestingChange,
1366 promptRef,1369 promptRef,
...@@ -3699,6 +3702,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3699,6 +3702,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3699 isScrollCollapsed: isComposerScrollCollapsed,3702 isScrollCollapsed: isComposerScrollCollapsed,
3700 hasExpandedChrome: composerHasExpandedChrome,3703 hasExpandedChrome: composerHasExpandedChrome,
3701 collapseOnBlur: settings.composerCollapseOnBlur,3704 collapseOnBlur: settings.composerCollapseOnBlur,
3705 timelineOverflows,
3702 });3706 });
3703 // The relocated controls live in the context strip whenever the composer is3707 // The relocated controls live in the context strip whenever the composer is
3704 // collapsed for any reason, the desktop resting layout or the phone3708 // 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", () => {...@@ -279,6 +279,7 @@ describe("MessagesTimeline", () => {
279 isScrollCollapsed: composer.isComposerScrollCollapsed,279 isScrollCollapsed: composer.isComposerScrollCollapsed,
280 hasExpandedChrome: false,280 hasExpandedChrome: false,
281 collapseOnBlur: true,281 collapseOnBlur: true,
282 timelineOverflows: true,
282 });283 });
283 });284 });
284 return (285 return (
apps/web/src/components/chat/MessagesTimeline.tsx+58-3
...@@ -113,7 +113,10 @@ import {...@@ -113,7 +113,10 @@ import {
113} from "./ExpandedImagePreview";113} from "./ExpandedImagePreview";
114import { ProposedPlanCard } from "./ProposedPlanCard";114import { ProposedPlanCard } from "./ProposedPlanCard";
115import { ChangedFilesCard } from "./ChangedFilesTree";115import { ChangedFilesCard } from "./ChangedFilesTree";
116import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring";116import {
117 CHAT_TIMELINE_ANCHOR_OFFSET,
118 timelineContentOverflowsViewport,
119} from "./timelineScrollAnchoring";
117import { MessageCopyButton } from "./MessageCopyButton";120import { MessageCopyButton } from "./MessageCopyButton";
118import { PierreEntryIcon } from "./PierreEntryIcon";121import { PierreEntryIcon } from "./PierreEntryIcon";
119import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";122import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";
...@@ -335,6 +338,11 @@ interface MessagesTimelineProps {...@@ -335,6 +338,11 @@ interface MessagesTimelineProps {
335 */338 */
336 liveFollowEnabled: boolean;339 liveFollowEnabled: boolean;
337 onIsAtEndChange: (isAtEnd: boolean) => void;340 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;
338 onToolOutputCollapsedAtEnd?: () => void;346 onToolOutputCollapsedAtEnd?: () => void;
339 onManualNavigation: () => void;347 onManualNavigation: () => void;
340 hideEmptyPlaceholder?: boolean;348 hideEmptyPlaceholder?: boolean;
...@@ -382,6 +390,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -382,6 +390,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
382 contentInsetEndAdjustment,390 contentInsetEndAdjustment,
383 liveFollowEnabled,391 liveFollowEnabled,
384 onIsAtEndChange,392 onIsAtEndChange,
393 onContentOverflowChange,
385 onToolOutputCollapsedAtEnd,394 onToolOutputCollapsedAtEnd,
386 onManualNavigation,395 onManualNavigation,
387 hideEmptyPlaceholder = false,396 hideEmptyPlaceholder = false,
...@@ -602,12 +611,49 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -602,12 +611,49 @@ export const MessagesTimeline = memo(function MessagesTimeline({
602 [anchoredEndSpace, contentInsetEndAdjustment],611 [anchoredEndSpace, contentInsetEndAdjustment],
603 );612 );
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
605 const handleScroll = useCallback(() => {650 const handleScroll = useCallback(() => {
606 const state = listRef.current?.getState?.();651 const state = listRef.current?.getState?.();
607 const isAtEnd = resolveTimelineIsAtEnd(state);652 const isAtEnd = resolveTimelineIsAtEnd(state);
608 if (isAtEnd !== undefined && !citationPositioning) {653 if (isAtEnd !== undefined && !citationPositioning) {
609 onIsAtEndChange(isAtEnd);654 onIsAtEndChange(isAtEnd);
610 }655 }
656 reportContentOverflow();
611 if (!state || minimapItems.length === 0) {657 if (!state || minimapItems.length === 0) {
612 return;658 return;
613 }659 }
...@@ -630,7 +676,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -630,7 +676,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
630676
631 strip.dataset.inView = inView ? "true" : "false";677 strip.dataset.inView = inView ? "true" : "false";
632 }678 }
633 }, [citationPositioning, listRef, minimapItems, minimapStripMap, onIsAtEndChange]);679 }, [
680 citationPositioning,
681 listRef,
682 minimapItems,
683 minimapStripMap,
684 onIsAtEndChange,
685 reportContentOverflow,
686 ]);
634687
635 useEffect(() => {688 useEffect(() => {
636 const frame = requestAnimationFrame(handleScroll);689 const frame = requestAnimationFrame(handleScroll);
...@@ -649,6 +702,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -649,6 +702,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
649 current === nextHasPersistentGutter ? current : nextHasPersistentGutter,702 current === nextHasPersistentGutter ? current : nextHasPersistentGutter,
650 );703 );
651 setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth));704 setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth));
705 reportContentOverflow();
652 };706 };
653707
654 const frame = requestAnimationFrame(measure);708 const frame = requestAnimationFrame(measure);
...@@ -660,7 +714,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -660,7 +714,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
660 cancelAnimationFrame(frame);714 cancelAnimationFrame(frame);
661 observer.disconnect();715 observer.disconnect();
662 };716 };
663 }, [timelineViewportElement, rows.length]);717 }, [timelineViewportElement, rows.length, reportContentOverflow]);
664718
665 const sharedState = useMemo<TimelineRowSharedState>(719 const sharedState = useMemo<TimelineRowSharedState>(
666 () => ({720 () => ({
...@@ -789,6 +843,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -789,6 +843,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
789 }843 }
790 maintainScrollAtEndThreshold={1}844 maintainScrollAtEndThreshold={1}
791 onScroll={handleScroll}845 onScroll={handleScroll}
846 onItemSizeChanged={reportContentOverflow}
792 className={cn(847 className={cn(
793 "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",848 "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
794 topFadeEnabled && "topbar-scroll-fade",849 topFadeEnabled && "topbar-scroll-fade",
apps/web/src/components/chat/timelineScrollAnchoring.test.tsx+36-1
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring";2import {
3 getAnchoredTurnMetrics,
4 getRowBottom,
5 timelineContentOverflowsViewport,
6} from "./timelineScrollAnchoring";
37
4function buildState({8function buildState({
5 positions,9 positions,
...@@ -21,6 +25,37 @@ function buildState({...@@ -21,6 +25,37 @@ function buildState({
21 };25 };
22}26}
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
24describe("timeline scroll anchoring", () => {59describe("timeline scroll anchoring", () => {
25 it("measures row bottoms from LegendList row position and size", () => {60 it("measures row bottoms from LegendList row position and size", () => {
26 const state = buildState({61 const state = buildState({
apps/web/src/components/chat/timelineScrollAnchoring.ts+25
...@@ -37,6 +37,31 @@ export function getRowBottom(state: TimelineListMeasurementState, index: number)...@@ -37,6 +37,31 @@ export function getRowBottom(state: TimelineListMeasurementState, index: number)
37 return top + Math.max(1, height);37 return top + Math.max(1, height);
38}38}
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
40export function getAnchoredTurnMetrics({65export function getAnchoredTurnMetrics({
41 state,66 state,
42 anchorIndex,67 anchorIndex,
apps/web/src/components/chat/useComposerFocusState.test.tsx+1
...@@ -20,6 +20,7 @@ function ComposerProbe({ isMobileViewport = false }: { isMobileViewport?: boolea...@@ -20,6 +20,7 @@ function ComposerProbe({ isMobileViewport = false }: { isMobileViewport?: boolea
20 isScrollCollapsed: state.isComposerScrollCollapsed,20 isScrollCollapsed: state.isComposerScrollCollapsed,
21 hasExpandedChrome: false,21 hasExpandedChrome: false,
22 collapseOnBlur: true,22 collapseOnBlur: true,
23 timelineOverflows: true,
23 });24 });
24 });25 });
25 return null;26 return null;
apps/web/src/components/composerFooterLayout.test.ts+12
...@@ -104,6 +104,7 @@ describe("shouldUseRestingComposerLayout", () => {...@@ -104,6 +104,7 @@ describe("shouldUseRestingComposerLayout", () => {
104 isScrollCollapsed: false,104 isScrollCollapsed: false,
105 hasExpandedChrome: false,105 hasExpandedChrome: false,
106 collapseOnBlur: true,106 collapseOnBlur: true,
107 timelineOverflows: true,
107 };108 };
108109
109 it("uses the resting layout for an unfocused desktop composer", () => {110 it("uses the resting layout for an unfocused desktop composer", () => {
...@@ -131,6 +132,17 @@ describe("shouldUseRestingComposerLayout", () => {...@@ -131,6 +132,17 @@ describe("shouldUseRestingComposerLayout", () => {
131 ).toBe(true);132 ).toBe(true);
132 });133 });
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
134 it("keeps new-thread composers expanded", () => {146 it("keeps new-thread composers expanded", () => {
135 expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false);147 expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false);
136 });148 });
apps/web/src/components/composerFooterLayout.ts+13-1
...@@ -30,6 +30,8 @@ export function shouldUseRestingComposerLayout(input: {...@@ -30,6 +30,8 @@ export function shouldUseRestingComposerLayout(input: {
30 isScrollCollapsed: boolean;30 isScrollCollapsed: boolean;
31 hasExpandedChrome: boolean;31 hasExpandedChrome: boolean;
32 collapseOnBlur: boolean;32 collapseOnBlur: boolean;
33 /** Whether the timeline has more content than fits above the composer. */
34 timelineOverflows: boolean;
33}): boolean {35}): boolean {
34 // Passive draft content is deliberately absent here. Resting only clamps36 // Passive draft content is deliberately absent here. Resting only clamps
35 // the prompt row and overlays its actions; non-image attachment and context37 // the prompt row and overlays its actions; non-image attachment and context
...@@ -44,8 +46,18 @@ export function shouldUseRestingComposerLayout(input: {...@@ -44,8 +46,18 @@ export function shouldUseRestingComposerLayout(input: {
44 // the user asked for it with the gesture, and it lifts on the next46 // the user asked for it with the gesture, and it lifts on the next
45 // composer interaction. With blur collapse off, losing focus alone never47 // composer interaction. With blur collapse off, losing focus alone never
46 // rests the composer.48 // 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.
47 const collapsed = input.isScrollCollapsed || (input.collapseOnBlur && !input.isFocused);53 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 );
49}61}
5062
51/**63/**