From 84f651d9c8b56fd97c5c92a24ccb237400534fab Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 29 Jul 2026 17:39:05 +0300 Subject: [PATCH] fix: keep the final chat turn active near the bottom Aligns the scroll spy with the chat pinned zone at the bottom Keeps the active rail state on the latest turn before its DOM is measurable Adds coverage for the bottom spacer behavior --- .../chat/hooks/useChatTimelineController.ts | 14 +++++++ .../chat/lib/scroll/scrollSpy.test.ts | 40 +++++++++++++++++++ .../components/chat/lib/scroll/scrollSpy.ts | 17 +++++--- 3 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/components/chat/lib/scroll/scrollSpy.test.ts diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index a47dd207..047a4717 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -313,6 +313,20 @@ export const useChatTimelineController = ({ setActiveTurnId(null); }, [sessionId]); + React.useLayoutEffect(() => { + if (!isPinned) { + return; + } + const latestTurnId = turnWindowModel.turnIds[turnWindowModel.turnIds.length - 1]; + if (!latestTurnId) { + return; + } + // A sent prompt updates the timeline model before its new DOM node is + // measurable by the scroll spy. While pinned, the latest turn is + // authoritative and keeps the rail in sync for that interval. + setActiveTurnId((current) => current === latestTurnId ? current : latestTurnId); + }, [isPinned, turnWindowModel.turnIds]); + const resolvePendingRenderWaiters = React.useCallback(() => { const resolvers = pendingRenderResolversRef.current; if (resolvers.length === 0) { diff --git a/packages/ui/src/components/chat/lib/scroll/scrollSpy.test.ts b/packages/ui/src/components/chat/lib/scroll/scrollSpy.test.ts new file mode 100644 index 00000000..864ab85b --- /dev/null +++ b/packages/ui/src/components/chat/lib/scroll/scrollSpy.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; + +import { createScrollSpy } from './scrollSpy'; + +describe('createScrollSpy', () => { + test('activates the final turn throughout the chat bottom spacer', () => { + const frames: FrameRequestCallback[] = []; + const activeTurnIds: string[] = []; + const spy = createScrollSpy({ + onActive: (turnId) => activeTurnIds.push(turnId), + raf: (callback) => { + frames.push(callback); + return frames.length; + }, + caf: () => {}, + }); + const container = { + scrollHeight: 1_000, + scrollTop: 870, + clientHeight: 100, + getBoundingClientRect: () => ({ top: 0 }), + } as HTMLDivElement; + const previousTurn = { + getBoundingClientRect: () => ({ top: -120 }), + } as HTMLElement; + const finalTurn = { + // Its top is still below the reading line at this scroll position. + getBoundingClientRect: () => ({ top: 120 }), + } as HTMLElement; + + spy.setContainer(container); + spy.register(previousTurn, 'previous'); + spy.register(finalTurn, 'final'); + while (frames.length > 0) { + frames.shift()?.(0); + } + + expect(activeTurnIds).toEqual(['final']); + }); +}); diff --git a/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts b/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts index a68b3930..f6562f00 100644 --- a/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts +++ b/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts @@ -16,10 +16,16 @@ type ScrollSpyInput = { // stable while scrolling inside a long turn (no visibility-ratio flip-flop). const READ_LINE_OFFSET_PX = 100; -// When the container is scrolled to (or almost to) the bottom, the last turn -// is what the user is reading even if it is too short for its top edge to -// ever cross the reading line — force-activate it in that case. -const BOTTOM_ANCHOR_EPSILON_PX = 8; +// The chat treats its bottom spacer as part of the pinned zone. Match that +// boundary here so the rail reaches the final prompt even when its top never +// crosses the reading line before the viewport enters that spacer. +const BOTTOM_ANCHOR_MIN_PX = 48; +const BOTTOM_ANCHOR_VIEWPORT_FACTOR = 0.1; + +const isInBottomAnchorZone = (container: HTMLDivElement): boolean => { + const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; + return distanceFromBottom <= Math.max(BOTTOM_ANCHOR_MIN_PX, container.clientHeight * BOTTOM_ANCHOR_VIEWPORT_FACTOR); +}; const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefined => { if (list.length === 0) { @@ -103,8 +109,7 @@ export const createScrollSpy = (input: ScrollSpyInput) => { refreshOffsets(); } - const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - const next = distanceFromBottom <= BOTTOM_ANCHOR_EPSILON_PX + const next = isInBottomAnchorZone(container) ? offsets[offsets.length - 1]?.id : pickOffsetTurnId(offsets, container.scrollTop + READ_LINE_OFFSET_PX); if (!next || next === active) {