History

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

Theo Browne committed 2 months ago

Commit c1d27e51 parent 485782b9 files · +161 −31Browse files

Unified · Split

9 files
  1. apps/web/src/components/ChatView.tsx +11 −26
  2. apps/web/src/components/chat/ChatComposer.tsx +4 −0
  3. apps/web/src/components/chat/MessagesTimeline.test.tsx +1 −0
  4. apps/web/src/components/chat/MessagesTimeline.tsx +58 −3
  5. apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +36 −1
  6. apps/web/src/components/chat/timelineScrollAnchoring.ts +25 −0
  7. apps/web/src/components/chat/useComposerFocusState.test.tsx +1 −0
  8. apps/web/src/components/composerFooterLayout.test.ts +12 −0
  9. apps/web/src/components/composerFooterLayout.ts +13 −1
apps/web/src/components/ChatView.tsx+11−26
@@ -111,6 +111,7 @@ import { type LegendListRef } from "@legendapp/list/react"; · 110 unmodified lines
111111 import {
112112 CHAT_TIMELINE_ANCHOR_OFFSET,
113113 getAnchoredTurnMetrics,
114+ timelineContentOverflowsViewport,
114115 type TimelineScrollMode,
115116 } from "./chat/timelineScrollAnchoring";
116117 import {
@@ -1647,6 +1648,9 @@ export default function ChatView(props: ChatViewProps) { · 1530 unmodified lines
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) { · 2849 unmodified lines
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) { · 3323 unmodified lines
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) { · 140 unmodified lines
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−0
@@ -1251,6 +1251,8 @@ export interface ChatComposerProps { · 1250 unmodified lines
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) · 104 unmodified lines
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) · 2332 unmodified lines
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−0
@@ -279,6 +279,7 @@ describe("MessagesTimeline", () => { · 278 unmodified lines
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 { · 112 unmodified lines
113113 } from "./ExpandedImagePreview";
114114 import { ProposedPlanCard } from "./ProposedPlanCard";
115115 import { ChangedFilesCard } from "./ChangedFilesTree";
116−import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring";
116+import {
117+ CHAT_TIMELINE_ANCHOR_OFFSET,
118+ timelineContentOverflowsViewport,
119+} from "./timelineScrollAnchoring";
117120 import { MessageCopyButton } from "./MessageCopyButton";
118121 import { PierreEntryIcon } from "./PierreEntryIcon";
119122 import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";
@@ -335,6 +338,11 @@ interface MessagesTimelineProps { · 215 unmodified lines
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({ · 41 unmodified lines
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({ · 214 unmodified lines
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({ · 16 unmodified lines
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({ · 12 unmodified lines
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({ · 5 unmodified lines
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({ · 122 unmodified lines
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 @@
11 import { describe, expect, it } from "vite-plus/test";
2−import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring";
2+import {
3+ getAnchoredTurnMetrics,
4+ getRowBottom,
5+ timelineContentOverflowsViewport,
6+} from "./timelineScrollAnchoring";
37
48 function buildState({
59 positions,
@@ -21,6 +25,37 @@ function buildState({ · 15 unmodified lines
2125 };
2226 }
2327
28+describe("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+
2459 describe("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−0
@@ -37,6 +37,31 @@ export function getRowBottom(state: TimelineListMeasurementState, index: number) · 36 unmodified lines
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+ */
46+export 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+
4065 export function getAnchoredTurnMetrics({
4166 state,
4267 anchorIndex,
apps/web/src/components/chat/useComposerFocusState.test.tsx+1−0
@@ -20,6 +20,7 @@ function ComposerProbe({ isMobileViewport = false }: { isMobileViewport?: boolea · 19 unmodified lines
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−0
@@ -104,6 +104,7 @@ describe("shouldUseRestingComposerLayout", () => { · 103 unmodified lines
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", () => { · 21 unmodified lines
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: { · 29 unmodified lines
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: { · 8 unmodified lines
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 /**