summaryrefslogtreecommitdiffhomepage
path: root/src/features/smart-scroll/logic/smart-scroll.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/smart-scroll/logic/smart-scroll.ts')
-rw-r--r--src/features/smart-scroll/logic/smart-scroll.ts93
1 files changed, 93 insertions, 0 deletions
diff --git a/src/features/smart-scroll/logic/smart-scroll.ts b/src/features/smart-scroll/logic/smart-scroll.ts
new file mode 100644
index 0000000..4a04812
--- /dev/null
+++ b/src/features/smart-scroll/logic/smart-scroll.ts
@@ -0,0 +1,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 });
+}