| author | |
| committer | |
| log | c1d27e593b278e7d997c05efdbcc043bae653e83 |
| tree | 55fab45c9738d65b1e802d6c5c18ae499b05f6dd |
| parent | 485782b2aab7d7aee57bacba841e11ac24d9cc63 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 111 | import { | 111 | import { |
| 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"; |
| 116 | import { | 117 | import { |
| ... | @@ -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 layout | 1258 | * 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 is | 3707 | // The relocated controls live in the context strip whenever the composer is |
| 3704 | // collapsed for any reason, the desktop resting layout or the phone | 3708 | // 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"; |
| 114 | import { ProposedPlanCard } from "./ProposedPlanCard"; | 114 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 115 | import { ChangedFilesCard } from "./ChangedFilesTree"; | 115 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 116 | import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring"; | 116 | import { |
| 117 | CHAT_TIMELINE_ANCHOR_OFFSET, | ||
| 118 | timelineContentOverflowsViewport, | ||
| 119 | } from "./timelineScrollAnchoring"; | ||
| 117 | import { MessageCopyButton } from "./MessageCopyButton"; | 120 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 118 | import { PierreEntryIcon } from "./PierreEntryIcon"; | 121 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 119 | import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; | 122 | import { 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 | ); |
| 604 | 613 | ||
| 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({ |
| 630 | 676 | ||
| 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 | ]); | ||
| 634 | 687 | ||
| 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 | }; |
| 653 | 707 | ||
| 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]); |
| 664 | 718 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | 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"; | ||
| 3 | 7 | ||
| 4 | function buildState({ | 8 | function buildState({ |
| 5 | positions, | 9 | positions, |
| ... | @@ -21,6 +25,37 @@ function buildState({ | ... | @@ -21,6 +25,37 @@ function buildState({ |
| 21 | }; | 25 | }; |
| 22 | } | 26 | } |
| 23 | 27 | ||
| 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 | |||
| 24 | describe("timeline scroll anchoring", () => { | 59 | describe("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 | } |
| 39 | 39 | ||
| 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 | |||
| 40 | export function getAnchoredTurnMetrics({ | 65 | export 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 | }; |
| 108 | 109 | ||
| 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 | }); |
| 133 | 134 | ||
| 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 clamps | 36 | // Passive draft content is deliberately absent here. Resting only clamps |
| 35 | // the prompt row and overlays its actions; non-image attachment and context | 37 | // 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 next | 46 | // the user asked for it with the gesture, and it lifts on the next |
| 45 | // composer interaction. With blur collapse off, losing focus alone never | 47 | // 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 | } |
| 50 | 62 | ||
| 51 | /** | 63 | /** |