From 9719d71f875c3347c41daf54209901353a52b443 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/3] 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 0fbef88c81e1..4aca0687183a 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,7 @@ export default function ChatView(props: ChatViewProps) { contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} + onMessagesBelowChange={setMessagesBelow} onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} @@ -8275,7 +8281,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..49746cebc04d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -164,6 +164,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 1d4abe39bf39..0dd8dca3fc33 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,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. @@ -396,6 +398,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ contentInsetEndAdjustment, liveFollowEnabled, onIsAtEndChange, + onMessagesBelowChange, onContentOverflowChange, onToolOutputCollapsedAtEnd, onManualNavigation, @@ -573,6 +576,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ supportsConversationRollback, ]); const rows = useStableRows(rawRows); + 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, @@ -637,12 +653,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 @@ -652,7 +669,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 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 From 464fb955b03536226fe5e7c082a9b5496244906b 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/3] 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 4aca0687183a..1f5bdbc34a40 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8266,6 +8266,7 @@ export default function ChatView(props: ChatViewProps) { liveFollowEnabled={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 49746cebc04d..3797d6a2f73d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -176,9 +176,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 0dd8dca3fc33..ec5f3b887984 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -346,6 +346,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. @@ -399,6 +400,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ liveFollowEnabled, onIsAtEndChange, onMessagesBelowChange, + visibleBottomInset = contentInsetEndAdjustment, onContentOverflowChange, onToolOutputCollapsedAtEnd, onManualNavigation, @@ -580,15 +582,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, @@ -660,6 +664,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 @@ -887,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 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 6e709c8295d51017c0120a99866ea5dd2f8ca131 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/3] fix(web): ignore non-finite timeline row measurements --- .../components/chat/MessagesTimeline.logic.ts | 9 +++++-- .../src/components/chat/messagesBelow.test.ts | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 3797d6a2f73d..8b0deefd89de 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -191,8 +191,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);