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) {