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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
|
// Injected shell for smart-scroll: binds a real scrollable element to the pure
// reducer (logic/smart-scroll). It owns the reactive `showButton` flag (a thin
// rune wrapper over the reducer state), runs the scroll COMMANDS the reducer
// emits against the element, and listens at the outermost edges (the element's
// `scroll`/`scrollend` events + a ResizeObserver on the content). No ambient
// state: the consumer instantiates ONE controller per scroll region and disposes
// it on unmount.
import {
createSmartScrollState,
onContentChange,
onReset,
onResume,
onScroll,
type ScrollCommand,
type ScrollGeometry,
type SmartScrollResult,
type SmartScrollState,
} from "../logic/smart-scroll";
export interface SmartScrollController {
/** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */
readonly showButton: boolean;
/**
* Non-reactive point-in-time query: is the view stuck to the bottom right now?
* For imperative callers (e.g. the chat-limit unload gate) that poll at event
* time rather than subscribing — reads the reducer state, not a rune.
*/
isAtBottom(): boolean;
/**
* Attach to the scroll container; returns a teardown to call on unmount.
* Pass the inner CONTENT element to also follow height changes that aren't a
* transcript update (async markdown/highlight, image loads, a collapse toggling,
* viewport reflow) via a ResizeObserver.
*/
attach(el: HTMLElement, content?: HTMLElement): () => void;
/**
* Notify that the transcript content changed (a streamed delta / new message).
* While stuck, keeps the view pinned to the bottom.
*/
contentChanged(): void;
/** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */
reset(): void;
/** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */
resume(): void;
}
function geometryOf(el: HTMLElement): ScrollGeometry {
return {
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
};
}
export function createSmartScrollController(): SmartScrollController {
let state: SmartScrollState = createSmartScrollState();
let showButton = $state(false);
let el: HTMLElement | null = null;
// True while WE drive a programmatic scroll, so the resulting `scroll` event
// doesn't get misread as the user scrolling up. Cleared on `scrollend`.
let selfScrolling = false;
function run(command: ScrollCommand | null): void {
if (!command || !el) return;
selfScrolling = true;
el.scrollTo({
top: el.scrollHeight,
behavior: command.animate ? "smooth" : "instant",
});
}
function apply(r: SmartScrollResult): void {
state = r.state;
showButton = r.showButton;
run(r.command);
}
function handleScroll(): void {
if (!el || selfScrolling) return;
apply(onScroll(state, geometryOf(el)));
}
function handleScrollEnd(): void {
selfScrolling = false;
}
return {
get showButton(): boolean {
return showButton;
},
isAtBottom(): boolean {
return state.stuck;
},
attach(node: HTMLElement, content?: HTMLElement): () => void {
el = node;
node.addEventListener("scroll", handleScroll, { passive: true });
node.addEventListener("scrollend", handleScrollEnd);
// A ResizeObserver keeps the view pinned through height changes that are
// NOT a transcript update — async markdown/syntax-highlight, image loads, a
// collapse toggling, font swaps, viewport reflow — which a content-count
// signal can't see. Observe the CONTENT (it grows) and the container (it
// changes on viewport resize). Routed through `onContentChange`, so it only
// scrolls while stuck and never fights the reader. The `selfScrolling` guard
// (and the fact that scrolling doesn't resize content) prevents any loop.
let ro: ResizeObserver | null = null;
if (typeof ResizeObserver !== "undefined") {
ro = new ResizeObserver(() => {
if (!el || selfScrolling) return;
apply(onContentChange(state, geometryOf(el)));
});
if (content) ro.observe(content);
ro.observe(node);
}
return () => {
node.removeEventListener("scroll", handleScroll);
node.removeEventListener("scrollend", handleScrollEnd);
ro?.disconnect();
if (el === node) el = null;
};
},
contentChanged(): void {
if (!el) return;
apply(onContentChange(state, geometryOf(el)));
},
reset(): void {
apply(onReset());
},
resume(): void {
apply(onResume(state));
},
};
}
|