94 lines
3.8 KiB
JavaScript
94 lines
3.8 KiB
JavaScript
// stream-follow.js — pure "auto-follow the streaming tail" controller.
|
|
//
|
|
// The chat stream auto-scrolls to the bottom as reasoning / assistant-text /
|
|
// tool-call deltas arrive (renderer.js scrollToBottom(), historically called
|
|
// unconditionally per chunk at the assistant/chunk site). That unconditional
|
|
// scroll had a bug: a reader who scrolled UP to re-read an earlier step got
|
|
// yanked back to the bottom on the very next delta, making it impossible to
|
|
// read while the model streams.
|
|
//
|
|
// This module isolates the "should we follow?" decision as pure logic so it
|
|
// is unit-testable without a DOM. renderer.js owns the wiring: it feeds the
|
|
// scroll region's metrics in on every scroll event (onScroll) and asks
|
|
// onContent() whether to hard-scroll when new streamed content lands. When
|
|
// the reader has detached, onContent stops following and signals that the
|
|
// "↓ 回到底部" chip should appear.
|
|
//
|
|
// Dual export: CommonJS for node:test, window.__dshStreamFollow for the
|
|
// renderer (same shape as the other small pure renderer modules —
|
|
// chat-refresh-throttle.js, html-escape.js).
|
|
|
|
'use strict'
|
|
|
|
;(function () {
|
|
const DEFAULT_THRESHOLD_PX = 40
|
|
|
|
// Distance in px from the bottom of the scroll region. 0 = pinned to the
|
|
// very bottom; grows as the reader scrolls up. Clamped at 0 so sub-pixel
|
|
// rounding (fractional scrollHeight/clientHeight under zoom) can't report a
|
|
// tiny negative and read as "not at bottom".
|
|
function distanceFromBottom (metrics) {
|
|
if (!metrics) return 0
|
|
const st = Number(metrics.scrollTop) || 0
|
|
const sh = Number(metrics.scrollHeight) || 0
|
|
const ch = Number(metrics.clientHeight) || 0
|
|
return Math.max(0, sh - st - ch)
|
|
}
|
|
|
|
function isNearBottom (metrics, threshold) {
|
|
const t = Number.isFinite(threshold) ? threshold : DEFAULT_THRESHOLD_PX
|
|
return distanceFromBottom(metrics) <= t
|
|
}
|
|
|
|
// Stateful controller, but pure w.r.t. the DOM: callers pass metrics in and
|
|
// act on the returned intent. Two bits of state:
|
|
// pinned — is the view following the bottom right now?
|
|
// detachedWithNew — has new streamed content landed since the reader
|
|
// scrolled away? (drives the "back to bottom" chip)
|
|
function createFollowController (opts) {
|
|
const options = opts || {}
|
|
const threshold = Number.isFinite(options.threshold) ? options.threshold : DEFAULT_THRESHOLD_PX
|
|
let pinned = options.startPinned === false ? false : true
|
|
let detachedWithNew = false
|
|
|
|
// Call on every scroll event. Recomputes `pinned` from the metrics and
|
|
// clears the "new while detached" flag once the reader is back at bottom.
|
|
function onScroll (metrics) {
|
|
const near = isNearBottom(metrics, threshold)
|
|
pinned = near
|
|
if (near) detachedWithNew = false
|
|
return { pinned, showChip: detachedWithNew && !near }
|
|
}
|
|
|
|
// Call when new streamed content is appended. Returns whether the caller
|
|
// should hard-scroll to the bottom (only when pinned) and whether the
|
|
// "back to bottom" chip should be visible.
|
|
function onContent () {
|
|
if (pinned) return { follow: true, showChip: false }
|
|
detachedWithNew = true
|
|
return { follow: false, showChip: true }
|
|
}
|
|
|
|
// Force re-pin: chip click, or a deliberate jump (the reader sent a
|
|
// message). Caller hard-scrolls after calling this.
|
|
function repin () {
|
|
pinned = true
|
|
detachedWithNew = false
|
|
return { pinned, showChip: false }
|
|
}
|
|
|
|
return {
|
|
onScroll,
|
|
onContent,
|
|
repin,
|
|
isPinned: () => pinned,
|
|
hasDetachedContent: () => detachedWithNew,
|
|
threshold,
|
|
}
|
|
}
|
|
|
|
const api = { distanceFromBottom, isNearBottom, createFollowController, DEFAULT_THRESHOLD_PX }
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api
|
|
if (typeof window !== 'undefined') window.__dshStreamFollow = api
|
|
})()
|