1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
|
// Pure smart-scroll reducer — "stick the transcript to the bottom while it grows,
// unless the user has scrolled up". Zero DOM, zero Svelte: it takes scroll
// GEOMETRY snapshots in and returns the next state plus an optional scroll
// COMMAND for the shell to execute. The injected shell (the Svelte action) reads
// the geometry off a real element and runs the commands.
/** A snapshot of a scroll container's vertical geometry (in CSS pixels). */
export interface ScrollGeometry {
/** Current scroll offset from the top. */
readonly scrollTop: number;
/** Total scrollable content height. */
readonly scrollHeight: number;
/** Visible viewport height. */
readonly clientHeight: number;
}
/** Distance (px) from the bottom within which we still consider the view "at bottom". */
export const NEAR_BOTTOM_THRESHOLD = 64;
/** True when the viewport is within `threshold` px of the content's bottom edge. */
export function isNearBottom(
geom: ScrollGeometry,
threshold: number = NEAR_BOTTOM_THRESHOLD,
): boolean {
return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold;
}
/** A scroll the shell should perform on the real element. */
export interface ScrollCommand {
readonly kind: "scroll-to-bottom";
/** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */
readonly animate: boolean;
}
export interface SmartScrollState {
/**
* Whether the view is currently following the bottom. While `stuck`, new
* content keeps the view pinned to the bottom; once the user scrolls up it
* goes false and stays false until they return to the bottom (or resume).
*/
readonly stuck: boolean;
}
/** A reducer step's result: the next state, an optional command, and whether to show the button. */
export interface SmartScrollResult {
readonly state: SmartScrollState;
readonly command: ScrollCommand | null;
/** Show the "scroll to bottom" affordance exactly when not stuck. */
readonly showButton: boolean;
}
/** Initial state — start stuck so the first content snaps to the bottom. */
export function createSmartScrollState(): SmartScrollState {
return { stuck: true };
}
function result(state: SmartScrollState, command: ScrollCommand | null): SmartScrollResult {
return { state, command, showButton: !state.stuck };
}
/**
* The user scrolled (or the viewport resized). Re-derive `stuck` purely from
* geometry: near the bottom ⇒ stuck (follow), otherwise unstuck. Never emits a
* command — reacting to the user's own scroll with a scroll would fight them.
*/
export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartScrollResult {
return result({ stuck: isNearBottom(geom) }, null);
}
/**
* Content changed (a streamed delta, a new message, history loaded). If we're
* stuck, emit a non-animated scroll to keep up; otherwise leave the user where
* they are. State is unchanged — content growth alone never flips `stuck`.
*/
export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry): SmartScrollResult {
return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null);
}
/**
* The user asked to return to the bottom (clicked the button). Force-stick and
* emit an animated scroll.
*/
export function onResume(_state: SmartScrollState): SmartScrollResult {
return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true });
}
/**
* The transcript context changed entirely (e.g. a conversation/tab switch).
* Reset to stuck and snap (non-animated) to the bottom of the new content.
*/
export function onReset(): SmartScrollResult {
return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false });
}
|