diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0fbef88c81e1..1f5bdbc34a40 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1591,6 +1591,11 @@ export default function ChatView(props: ChatViewProps) { ); const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [messagesBelow, setMessagesBelow] = useState(0); + const scrollToEndLabel = + messagesBelow > 0 + ? `${messagesBelow} ${messagesBelow === 1 ? "message" : "messages"}` + : "Scroll to end"; const [expandedImage, setExpandedImage] = useState(null); useEffect(() => { const item = expandedImage?.images[expandedImage.index]; @@ -5314,7 +5319,7 @@ export default function ChatView(props: ChatViewProps) { '[data-chat-composer-main-surface="true"]', ); const button = composerOverlayElement?.parentElement?.querySelector( - 'button[aria-label="Scroll to end"]', + "button[data-scroll-to-end]", ); const clearance = composerOverlayElement && mainSurface && button @@ -8260,6 +8265,8 @@ export default function ChatView(props: ChatViewProps) { contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} + onMessagesBelowChange={setMessagesBelow} + visibleBottomInset={composerOverlayHeight} onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} @@ -8275,7 +8282,12 @@ export default function ChatView(props: ChatViewProps) { style={{ bottom: scrollToEndClearance + 4 }} > )} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index a87531673506..8b0deefd89de 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -164,6 +164,49 @@ export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boo return contentLength - scroll - scrollLength <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; } +/** Counts message rows with content below the unobscured viewport, including a partial row. */ +export function countTimelineMessagesBelow( + messageRowIndices: ReadonlyArray, + state: + | { + readonly scroll?: number; + readonly scrollLength?: number; + readonly positionAtIndex?: (index: number) => number | undefined; + readonly sizeAtIndex?: (index: number) => number | undefined; + } + | undefined, + composerInset: number, + headerSize = 0, +): number { + if (state?.scroll === undefined || state.scrollLength === undefined) return 0; + const visibleBottom = state.scroll + state.scrollLength - Math.max(0, composerInset) - headerSize; + // Cached row positions are ordered, so only log(n) lookups are needed per scroll. + let low = 0; + let high = messageRowIndices.length; + while (low < high) { + const middle = (low + high) >>> 1; + const rowIndex = messageRowIndices[middle]!; + const top = state.positionAtIndex?.(rowIndex); + if (top === undefined || !Number.isFinite(top)) return 0; + // Offscreen rows can have an estimated position without a measured size. + // Their top alone is sufficient when the whole row is below the viewport. + const height = state.sizeAtIndex?.(rowIndex); + let bottom = top; + if (height !== undefined && Number.isFinite(height)) { + bottom = top + height; + } else { + const nextTop = state.positionAtIndex?.(rowIndex + 1); + if (nextTop !== undefined && Number.isFinite(nextTop)) bottom = nextTop; + } + if (top > visibleBottom + 1 || bottom > visibleBottom + 1) { + high = middle; + } else { + low = middle + 1; + } + } + return messageRowIndices.length - low; +} + export function shouldPreserveAssistantLineBreaks(text: string): boolean { return /^★ Insight(?:\s|─)/mu.test(text); } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1d4abe39bf39..ec5f3b887984 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -134,6 +134,7 @@ import { import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget"; import { computeStableMessagesTimelineRows, + countTimelineMessagesBelow, deriveMessagesTimelineRowsWithState, type MessagesTimelineRowsProjection, liveWorkEntryLabel, @@ -344,6 +345,8 @@ interface MessagesTimelineProps { */ liveFollowEnabled: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; + onMessagesBelowChange?: (count: number) => void; + visibleBottomInset?: number; /** * Whether the real rows extend past the viewport above the composer. * Reported after scrolls, row size changes, and viewport resizes. @@ -396,6 +399,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ contentInsetEndAdjustment, liveFollowEnabled, onIsAtEndChange, + onMessagesBelowChange, + visibleBottomInset = contentInsetEndAdjustment, onContentOverflowChange, onToolOutputCollapsedAtEnd, onManualNavigation, @@ -573,6 +578,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ supportsConversationRollback, ]); const rows = useStableRows(rawRows); + const messageRowIndices = useMemo( + () => rows.flatMap((row, index) => (row.kind === "message" ? [index] : [])), + [rows], + ); + const timelineHeaderSizeRef = useRef(0); + const reportMessagesBelow = useCallback(() => { + onMessagesBelowChange?.( + countTimelineMessagesBelow( + messageRowIndices, + listRef.current?.getState?.(), + visibleBottomInset, + timelineHeaderSizeRef.current, + ), + ); + }, [visibleBottomInset, listRef, messageRowIndices, onMessagesBelowChange]); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); const [timelineViewportElement, setTimelineViewportElement] = useState( null, @@ -637,12 +657,20 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } }, []); const reportContentOverflow = useCallback(() => { - if (!onContentOverflowChange || contentOverflowFrameRef.current !== null) return; + if (contentOverflowFrameRef.current !== null) return; contentOverflowFrameRef.current = requestAnimationFrame(() => { contentOverflowFrameRef.current = null; - onContentOverflowChange(measureContentOverflow()); + onContentOverflowChange?.(measureContentOverflow()); + reportMessagesBelow(); }); - }, [measureContentOverflow, onContentOverflowChange]); + }, [measureContentOverflow, onContentOverflowChange, reportMessagesBelow]); + const handleMetricsChange = useCallback( + (metrics: { headerSize: number }) => { + timelineHeaderSizeRef.current = metrics.headerSize; + reportContentOverflow(); + }, + [reportContentOverflow], + ); useEffect(() => cancelContentOverflowFrame, [cancelContentOverflowFrame]); // The list's own layout effects have already run here, so estimated row // positions are in place. Reporting before the first paint lets a thread @@ -652,7 +680,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ useLayoutEffect(() => { cancelContentOverflowFrame(); onContentOverflowChange?.(measureContentOverflow()); - }, [cancelContentOverflowFrame, measureContentOverflow, onContentOverflowChange, rows.length]); + reportMessagesBelow(); + }, [ + cancelContentOverflowFrame, + measureContentOverflow, + onContentOverflowChange, + reportMessagesBelow, + rows.length, + ]); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); @@ -863,6 +898,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ maintainScrollAtEndThreshold={1} onScroll={handleScroll} onItemSizeChanged={reportContentOverflow} + onMetricsChange={handleMetricsChange} className={cn( "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", topFadeEnabled && "topbar-scroll-fade", diff --git a/apps/web/src/components/chat/messagesBelow.test.ts b/apps/web/src/components/chat/messagesBelow.test.ts new file mode 100644 index 000000000000..a6752062c1f0 --- /dev/null +++ b/apps/web/src/components/chat/messagesBelow.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { countTimelineMessagesBelow } from "./MessagesTimeline.logic"; + +describe("countTimelineMessagesBelow", () => { + const state = { + scroll: 0, + scrollLength: 400, + positionAtIndex: (index: number) => index * 100, + sizeAtIndex: () => 100, + }; + + it("counts only indexed messages, including one partly obscured by the composer", () => { + // Rows 1 and 3 are tool activity, not messages. + expect(countTimelineMessagesBelow([0, 2, 4, 5], state, 150)).toBe(3); + expect(countTimelineMessagesBelow([0, 2, 4, 5], { ...state, scroll: 50 }, 150)).toBe(2); + }); + + it("decreases while scrolling down and increases when scrolling back up", () => { + const indices = [0, 1, 2, 3, 4, 5]; + expect(countTimelineMessagesBelow(indices, state, 100)).toBe(3); + expect(countTimelineMessagesBelow(indices, { ...state, scroll: 200 }, 100)).toBe(1); + expect(countTimelineMessagesBelow(indices, { ...state, scroll: 300 }, 100)).toBe(0); + expect(countTimelineMessagesBelow(indices, state, 100)).toBe(3); + }); + + it("updates for appended messages, streaming growth, and viewport or composer resizing", () => { + expect(countTimelineMessagesBelow([0, 1, 2], state, 100)).toBe(0); + expect(countTimelineMessagesBelow([0, 1, 2, 3], state, 100)).toBe(1); + expect(countTimelineMessagesBelow([0, 1, 2], { ...state, sizeAtIndex: () => 150 }, 100)).toBe( + 1, + ); + expect(countTimelineMessagesBelow([0, 1, 2], { ...state, scrollLength: 250 }, 100)).toBe(2); + expect(countTimelineMessagesBelow([0, 1, 2], state, 250)).toBe(2); + }); + + it("accounts for the header and actual overlay rather than reserved footer space", () => { + const indices = [0, 1, 2, 3]; + expect(countTimelineMessagesBelow(indices, state, 110, 24)).toBe(2); + expect(countTimelineMessagesBelow(indices, { ...state, scroll: 35 }, 110, 24)).toBe(1); + // A collapsed composer leaves reserved scroll space, but no longer obscures that area. + expect(countTimelineMessagesBelow(indices, state, 204, 24)).toBe(3); + }); + + it("does not count blank end space or an empty timeline", () => { + expect(countTimelineMessagesBelow([0, 1], { ...state, scroll: 500 }, 100)).toBe(0); + expect(countTimelineMessagesBelow([], state, 100)).toBe(0); + }); + + it("waits for valid measurements and tolerates fractional pixel rounding", () => { + expect(countTimelineMessagesBelow([0], undefined, 100)).toBe(0); + expect(countTimelineMessagesBelow([0], {}, 100)).toBe(0); + expect(countTimelineMessagesBelow([0], { ...state, sizeAtIndex: () => undefined }, 100)).toBe( + 0, + ); + expect(countTimelineMessagesBelow([0], { ...state, positionAtIndex: () => NaN }, 100)).toBe(0); + expect(countTimelineMessagesBelow([0, 1, 2], { ...state, scroll: -0.5 }, 100)).toBe(0); + }); + + it("counts virtualized rows whose sizes have not been measured yet", () => { + expect( + countTimelineMessagesBelow( + [0, 1, 2, 3, 4, 5], + { + ...state, + sizeAtIndex: () => undefined, + }, + 150, + ), + ).toBe(4); + }); + + it.each([undefined, NaN, Infinity, -Infinity])( + "falls back to the next row for an invalid height: %s", + (height) => { + const unmeasured = { + ...state, + positionAtIndex: (index: number) => 200 + index * 100, + sizeAtIndex: () => height, + }; + expect(countTimelineMessagesBelow([0], unmeasured, 150)).toBe(1); + expect(countTimelineMessagesBelow([0], { ...unmeasured, scroll: 100 }, 150)).toBe(0); + }, + ); + + it.each([undefined, NaN, Infinity, -Infinity])( + "falls back to the row top when the next position is invalid: %s", + (nextPosition) => { + const unmeasured = { + ...state, + positionAtIndex: (index: number) => (index === 0 ? 300 : nextPosition), + sizeAtIndex: () => NaN, + }; + expect(countTimelineMessagesBelow([0], unmeasured, 150)).toBe(1); + expect(countTimelineMessagesBelow([0], { ...unmeasured, scroll: 100 }, 150)).toBe(0); + }, + ); + + it("uses logarithmic cached position reads for long histories", () => { + const indices = Array.from({ length: 10_000 }, (_, index) => index); + const positionAtIndex = vi.fn(state.positionAtIndex); + expect(countTimelineMessagesBelow(indices, { ...state, positionAtIndex }, 100)).toBe(9997); + expect(positionAtIndex.mock.calls.length).toBeLessThanOrEqual(14); + }); +}); diff --git a/docs/user/composer.md b/docs/user/composer.md index 4a8df5333664..08997a9565e3 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -6,6 +6,13 @@ include a skill when the task needs more context. Messages can contain up to 120,000 characters. Longer drafts stay in the composer so you can shorten them or split them into several messages. +## Return to the latest message + +On web and desktop, scrolling up shows a button above the composer with the number +of messages remaining below your view. A partially visible message counts until +its end is visible. Tool activity and messages hidden inside collapsed turns do +not count. Select the button to return to the end of the conversation. + ## Attach files Attach up to eight files per message. Images can be up to 10 MB; other files can