/** * Pure decision logic for the native transcript auto-scroll toggle. Kept * side-effect free for direct unit testing — the renderer owns all DOM calls * and just consults these functions. */ /** * Distinguishes a genuine prepend (older messages loaded above the current * view, e.g. via lazy-loading) from a plain append (a new message arriving * at the end) when the rendered item count grows. `useScrollPositionOnPrepend` * only needs to compensate scrollTop for the former — compensating an append * too would drag a scrolled-away user's view down by the height of every new * message, fighting the auto-scroll toggle's "stay put while disabled" * contract (and, independently of that toggle, jittering anyone who has * scrolled up to read older messages while new ones arrive). */ export function isPrependUpdate(params: { prevItemCount: number; nextItemCount: number; prevFirstKey: string | null; nextFirstKey: string | null; }): boolean { if (params.nextItemCount <= params.prevItemCount) return false; if (params.prevFirstKey === null) return false; if (params.nextFirstKey === null) return false; return params.nextFirstKey !== params.prevFirstKey; } // Matches the settle tolerance used elsewhere for "has this scrolled past // view" checks (see the last-prompt scroll-nav feature) — sub-pixel jitter // from layout rounding shouldn't count as "progressed". const SETTLE_TOLERANCE_PX = 2; /** * Whether the transcript's content bottom currently sits meaningfully below * the visible viewport bottom, i.e. there is unseen content past the current * scroll position. */ export function hasTranscriptProgressedPastView(params: { scrollTop: number; scrollHeight: number; clientHeight: number; }): boolean { return params.scrollHeight - params.scrollTop - params.clientHeight > SETTLE_TOLERANCE_PX; } /** * Whether the transcript actually appended new content since a baseline * captured at disable time — i.e. a genuinely new message row, or the same * trailing message streaming in more content. Derived purely from message * identity and `updated_at` (never scrollTop/scrollHeight), so it can't be * fooled by: * - the user scrolling further away themselves while disabled (no message * data changed, so this stays false), or * - a prepend (older messages loaded above): count grows but the LAST * message's id and `updated_at` are untouched, so this stays false. * * `updated_at` is the backend's documented "authoritative per-message change * signal; advances on every content/metadata update" (see Message in * lib/types/http.ts) — it catches in-place streaming growth of the last row * even when no new row was appended. */ export function hasTranscriptAppendedSinceBaseline(params: { baselineCount: number; currentCount: number; baselineLastId: string | null; currentLastId: string | null; baselineLastUpdatedAt: string | undefined; currentLastUpdatedAt: string | undefined; }): boolean { if (params.currentLastId === null) return false; const newRowAppended = params.currentCount > params.baselineCount && params.currentLastId !== params.baselineLastId; const sameRowChanged = params.currentLastId === params.baselineLastId && params.currentLastUpdatedAt !== undefined && params.currentLastUpdatedAt !== params.baselineLastUpdatedAt; return newRowAppended || sameRowChanged; } /** * Whether re-enabling auto-scroll (the user flips the toggle back on) should * immediately catch the view up to the bottom. Only fires on the disabled -> * enabled transition, and only when the transcript genuinely appended new * content while disabled (see `hasTranscriptAppendedSinceBaseline`) AND that * content isn't already in view. Resuming auto-follow with no new content — * whether the user simply never scrolled back down, or scrolled further away * themselves while disabled — never forces a scroll here. */ export function shouldCatchUpOnAutoScrollEnable(params: { wasEnabled: boolean; nowEnabled: boolean; appendedSinceDisable: boolean; isAtBottom: boolean; }): boolean { if (params.wasEnabled || !params.nowEnabled) return false; return params.appendedSinceDisable && !params.isAtBottom; } /** * Resolves the scrollTop the native list should apply right after mount * (session opened, or the panel remounted via a dockview layout rebuild). * * - A pending dockview layout-rebuild restore always wins; that separate * mechanism owns the position for maximize/un-maximize and runs * independently of this feature. * - When auto-scroll is disabled, restore whatever offset was captured the * last time this session's transcript was visible — falling back to the * bottom only if nothing was ever captured (first-time disable, or a * session that has never been scrolled). * - When enabled, the pre-existing behavior (scroll to bottom) applies. * * Returns `null` when the caller should skip applying any scrollTop. */ export function resolveNativeInitialScrollTop(params: { enabled: boolean; hasPendingLayoutRestore: boolean; savedScrollTop: number | undefined; scrollHeight: number; }): number | null { if (params.hasPendingLayoutRestore) return null; if (!params.enabled) return params.savedScrollTop ?? params.scrollHeight; return params.scrollHeight; } /** * Coalesces frequent `schedule()` calls into at most one `run` execution per * animation frame — e.g. persisting scroll position on every native `scroll` * event is far more expensive (sync storage write + store update) than the * events actually need. `flush` cancels any pending frame and runs `run` * once immediately, guaranteeing a final call on cleanup even mid-frame. */ export function createFrameCoalescer(run: () => void): { schedule: () => void; flush: () => void; } { let frameId: number | null = null; return { schedule: () => { if (frameId !== null) return; frameId = requestAnimationFrame(() => { frameId = null; run(); }); }, flush: () => { if (frameId !== null) { cancelAnimationFrame(frameId); frameId = null; } run(); }, }; }