From bbeaf8ff3edfa6c566288b50306bdca463af3d15 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:24:06 +1000 Subject: [PATCH 1/4] feat(web): show messages below in the scroll-to-end button --- apps/web/src/components/ChatView.tsx | 17 ++++- .../components/chat/MessagesTimeline.logic.ts | 37 ++++++++++ .../src/components/chat/MessagesTimeline.tsx | 32 +++++++-- .../src/components/chat/messagesBelow.test.ts | 70 +++++++++++++++++++ docs/user/composer.md | 7 ++ 5 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/chat/messagesBelow.test.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 38f773cf037e..48d3112f8698 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1655,6 +1655,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]; @@ -5814,7 +5819,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 @@ -9507,6 +9512,7 @@ export default function ChatView(props: ChatViewProps) { contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} + onMessagesBelowChange={setMessagesBelow} onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} @@ -9530,7 +9536,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 983e49dfaa87..f843c5d3aaec 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -170,6 +170,43 @@ 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, +): number { + if (state?.scroll === undefined || state.scrollLength === undefined) return 0; + const visibleBottom = state.scroll + state.scrollLength - Math.max(0, composerInset); + // 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); + const bottom = + height !== undefined ? top + height : (state.positionAtIndex?.(rowIndex + 1) ?? top); + 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 307a893342f3..879cc7ff31a7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -167,6 +167,7 @@ import { import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget"; import { computeStableMessagesTimelineRows, + countTimelineMessagesBelow, deriveMessagesTimelineRowsWithState, type MessagesTimelineRowsProjection, liveWorkEntryLabel, @@ -428,6 +429,7 @@ interface MessagesTimelineProps { */ liveFollowEnabled: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; + onMessagesBelowChange?: (count: number) => void; /** * Whether the real rows extend past the viewport above the composer. * Reported after scrolls, row size changes, and viewport resizes. @@ -490,6 +492,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ contentInsetEndAdjustment, liveFollowEnabled, onIsAtEndChange, + onMessagesBelowChange, onContentOverflowChange, onToolOutputCollapsedAtEnd, onManualNavigation, @@ -749,6 +752,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ queuedMessages, ]); const rows = useStableRows(rawRows, listIdentityKey); + const messageRowIndices = useMemo( + () => rows.flatMap((row, index) => (row.kind === "message" ? [index] : [])), + [rows], + ); + const reportMessagesBelow = useCallback(() => { + onMessagesBelowChange?.( + countTimelineMessagesBelow( + messageRowIndices, + listRef.current?.getState?.(), + contentInsetEndAdjustment, + ), + ); + }, [contentInsetEndAdjustment, listRef, messageRowIndices, onMessagesBelowChange]); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); const [timelineViewportElement, setTimelineViewportElement] = useState( null, @@ -813,12 +829,13 @@ 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]); 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 @@ -828,7 +845,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?.(); 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..47a27a3ef608 --- /dev/null +++ b/apps/web/src/components/chat/messagesBelow.test.ts @@ -0,0 +1,70 @@ +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("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("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 f3be7a5f342d..3e89cb849af2 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -12,6 +12,13 @@ becomes an attachment when inserting it would exceed the message limit. On a hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere to keep a large paste editable in the composer instead. +## 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 From 6d5b510d7bcdd32690ea84350e7e0ab5ba1bcac9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:56:33 +1000 Subject: [PATCH 2/4] fix(web): count below the visible composer and list header --- apps/web/src/components/ChatView.tsx | 1 + .../components/chat/MessagesTimeline.logic.ts | 3 ++- .../web/src/components/chat/MessagesTimeline.tsx | 16 ++++++++++++++-- .../src/components/chat/messagesBelow.test.ts | 8 ++++++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 48d3112f8698..5662c0a5bf85 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9513,6 +9513,7 @@ export default function ChatView(props: ChatViewProps) { liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} onMessagesBelowChange={setMessagesBelow} + visibleBottomInset={composerOverlayHeight} onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f843c5d3aaec..5229c64645dd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -182,9 +182,10 @@ export function countTimelineMessagesBelow( } | 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); + 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; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 879cc7ff31a7..5c46d8108268 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -430,6 +430,7 @@ 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. @@ -493,6 +494,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ liveFollowEnabled, onIsAtEndChange, onMessagesBelowChange, + visibleBottomInset = contentInsetEndAdjustment, onContentOverflowChange, onToolOutputCollapsedAtEnd, onManualNavigation, @@ -756,15 +758,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ () => rows.flatMap((row, index) => (row.kind === "message" ? [index] : [])), [rows], ); + const timelineHeaderSizeRef = useRef(0); const reportMessagesBelow = useCallback(() => { onMessagesBelowChange?.( countTimelineMessagesBelow( messageRowIndices, listRef.current?.getState?.(), - contentInsetEndAdjustment, + visibleBottomInset, + timelineHeaderSizeRef.current, ), ); - }, [contentInsetEndAdjustment, listRef, messageRowIndices, onMessagesBelowChange]); + }, [visibleBottomInset, listRef, messageRowIndices, onMessagesBelowChange]); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); const [timelineViewportElement, setTimelineViewportElement] = useState( null, @@ -836,6 +840,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ reportMessagesBelow(); }); }, [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 @@ -1085,6 +1096,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 index 47a27a3ef608..27762fc6e45a 100644 --- a/apps/web/src/components/chat/messagesBelow.test.ts +++ b/apps/web/src/components/chat/messagesBelow.test.ts @@ -33,6 +33,14 @@ describe("countTimelineMessagesBelow", () => { 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); From 7b09b2972d920681d1074155c6e2e54fd735be3f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:26:34 +1000 Subject: [PATCH 3/4] fix(web): ignore non-finite timeline row measurements --- apps/web/src/components/ChatView.tsx | 2 +- .../components/chat/MessagesTimeline.logic.ts | 9 +++++-- .../src/components/chat/messagesBelow.test.ts | 26 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5662c0a5bf85..6160218a5b46 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9513,7 +9513,7 @@ export default function ChatView(props: ChatViewProps) { liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} onMessagesBelowChange={setMessagesBelow} - visibleBottomInset={composerOverlayHeight} + visibleBottomInset={composerTimelineInset} onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 5229c64645dd..b37a1be2aad7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -197,8 +197,13 @@ export function countTimelineMessagesBelow( // 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); - const bottom = - height !== undefined ? top + height : (state.positionAtIndex?.(rowIndex + 1) ?? top); + 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 { diff --git a/apps/web/src/components/chat/messagesBelow.test.ts b/apps/web/src/components/chat/messagesBelow.test.ts index 27762fc6e45a..a6752062c1f0 100644 --- a/apps/web/src/components/chat/messagesBelow.test.ts +++ b/apps/web/src/components/chat/messagesBelow.test.ts @@ -69,6 +69,32 @@ describe("countTimelineMessagesBelow", () => { ).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); From 0579f5c6bd25f2267dc1aef7ac125c4036aee776 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 16 Sep 2026 12:39:10 +1000 Subject: [PATCH 4/4] fix(web): count queued messages below the fold Queued follow-ups render as message rows at the end of the timeline, so leaving them out under-counts what remains below the viewport. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/components/chat/MessagesTimeline.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5c46d8108268..94c78d909ee3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -755,7 +755,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ]); const rows = useStableRows(rawRows, listIdentityKey); const messageRowIndices = useMemo( - () => rows.flatMap((row, index) => (row.kind === "message" ? [index] : [])), + () => + rows.flatMap((row, index) => + row.kind === "message" || row.kind === "queued-message" ? [index] : [], + ), [rows], ); const timelineHeaderSizeRef = useRef(0);