fix(chat): anchored-scroll behavior parity with the reference model

Six reported defects, one root theme — our port diverged from the
reference semantics:

- a user gesture no longer collapses the reserved anchored end space
  (that snap-to-bottom was the 'anchoring vanishes on a micro-scroll');
  it only disarms the follow/anchor machinery, and anchor remeasures
  after the user takes over can no longer re-position the viewport
- the anchor arms synchronously BEFORE the send reaches the store, and
  the claim compares against the arm-time baseline — arming a frame
  after the optimistic row committed is why anchoring 'rarely worked'
  and sending from mid-history did nothing
- gestures are selective (wheel up, touch/pointer away from the end,
  PageUp/Home/ArrowUp), so scrolling toward the end or clicking a row
  at the live edge no longer kills follow
- 'at end' is a tight 40px band against the full content length instead
  of the list's half-viewport isNearEnd, so the scroll-to-bottom pill
  appears when the viewport actually leaves the end
- reaching the end re-arms follow without knocking an anchored turn out
  of its mode, and the overlay scrollbar suppression follows scroll
  ownership rather than the anchor's mere existence
- the status/working row moved out of the list footer to a fixed spot
  above the composer: inside the list it walked down with every
  streamed line, and its unmount was part of the end-of-stream jerk
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 01:35:45 +03:00
parent 42a8eafafc
commit 955b2cc812
5 changed files with 120 additions and 42 deletions
@@ -184,12 +184,14 @@ describe('getAnchoredTurnMetrics', () => {
});
describe('resolveTimelineIsAtEnd', () => {
test('prefers the near-end threshold over the exact content bottom', () => {
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false);
test('uses a tight distance band against the full content length', () => {
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
});
test('falls back to the exact end when near-end is unavailable', () => {
test('falls back to the list flags when distances are unavailable', () => {
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
});
@@ -108,15 +108,35 @@ export const getAnchoredTurnMetrics = ({
};
};
// "At the end" for follow purposes is the NEAR-end threshold, not the exact
// content bottom: the timeline's footer (status row plus bottom spacer) sits
// below the last row, so requiring the exact bottom would drop out of follow —
// and pop the scroll-to-bottom pill — while the user is still looking at the
// live edge. `isAtEnd` is only the fallback for states that predate the
// near-end signal.
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
// follow while the user had genuinely scrolled away, yanking them back on the
// next stream chunk. Distance is measured against the full content length —
// reserved anchored end space included — so a parked anchored turn counts as
// the live edge.
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
export const resolveTimelineIsAtEnd = (
state: { readonly isNearEnd?: boolean; readonly isAtEnd?: boolean } | undefined,
): boolean | undefined => state?.isNearEnd ?? state?.isAtEnd;
state: {
readonly contentLength?: number;
readonly scroll?: number;
readonly scrollLength?: number;
readonly isNearEnd?: boolean;
readonly isAtEnd?: boolean;
} | undefined,
): boolean | undefined => {
if (!state) return undefined;
const { contentLength, scroll, scrollLength } = state;
if (
typeof contentLength === 'number'
&& typeof scroll === 'number'
&& typeof scrollLength === 'number'
&& Number.isFinite(contentLength)
) {
return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
}
return state.isNearEnd ?? state.isAtEnd;
};
export interface ChatListAnchoredEndSpace {
readonly anchorIndex: number;