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
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user