import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { OrchestrationThreadActivity } from "@t3tools/contracts"; import { liveWindowOldestActivityId, oldestActivityByChronology } from "./threadReducer.ts"; const EMPTY_ACTIVITIES: ReadonlyArray = []; /** * Pagination cursor for a thread's older activities. Sequenced rows page by * `beforeSequence`; legacy/unsequenced rows (the common case — `sequence` is * absent on most real rows) page by the `(createdAt, activityId)` keyset. */ export type OlderActivitiesCursor = | { readonly beforeSequence: number } | { readonly beforeCreatedAt: OrchestrationThreadActivity["createdAt"]; readonly beforeActivityId: OrchestrationThreadActivity["id"]; }; export interface OlderActivitiesPage { readonly activities: ReadonlyArray; readonly hasMore: boolean; } export interface UseOlderThreadActivitiesOptions { /** * Identity of the thread the live window belongs to (e.g. * `${environmentId}\0${threadId}`); null when no thread is selected. * Changing it resets the lazy-loaded pages. */ readonly threadKey: string | null; /** The server-windowed live activity set from the thread detail. */ readonly liveActivities: ReadonlyArray; /** The server's `hasMoreActivities` flag from the detail snapshot. */ readonly hasMoreLiveActivities: boolean; /** * Fetch the page immediately older than the cursor. Resolve `null` to skip * the page silently (a failure the caller already surfaced, or an * interrupted command) — `hasMore` is left true so the user can retry. * MUST be referentially stable (useCallback) for the load callback to be. */ readonly loadPage: (cursor: OlderActivitiesCursor) => Promise; } export interface UseOlderThreadActivitiesResult { /** Lazy-loaded older pages + the live window, oldest first. */ readonly mergedActivities: ReadonlyArray; /** Whether older history exists beyond everything loaded. */ readonly hasMoreOlder: boolean; readonly loadingOlder: boolean; /** Dispatch a load of the next older page (no-op while one is in flight). */ readonly loadOlder: () => void; } // ── Pure decision kernel (exported for unit tests) ────────────────────────── export interface LiveWindowShape { readonly key: string | null; /** Chronological-oldest activity id (an identity sentinel, not a lookup key). */ readonly oldest: string | null; readonly count: number; } /** * Whether the live window was RESHAPED rather than purely appended-to: a * different thread, a re-snapshot (reconnect) that changes the window's * chronological-oldest row, or a checkpoint revert that shrinks it. A pure * append (same thread, same oldest, count not smaller) is NOT a reshape. */ export function didLiveWindowReshape(previous: LiveWindowShape, next: LiveWindowShape): boolean { return ( next.key !== previous.key || next.oldest !== previous.oldest || next.count < previous.count ); } /** * The cursor for the page immediately older than `oldest`: sequenced rows page * by `beforeSequence`; unsequenced rows (the common case) by the * `(createdAt, activityId)` keyset. */ export function olderActivitiesCursorFor( oldest: OrchestrationThreadActivity, ): OlderActivitiesCursor { return oldest.sequence !== undefined ? { beforeSequence: oldest.sequence } : { beforeCreatedAt: oldest.createdAt, beforeActivityId: oldest.id }; } /** * The row the NEXT load should cursor from: the explicit cursor row already * paged past when one exists (so an all-overlap page keeps advancing), else * the chronologically-oldest loaded row — never index 0, which the reducer * can fill with a newer row (unsequenced rows sort to the end). */ export function nextOlderActivitiesCursorRow( pagedPast: OrchestrationThreadActivity | null, merged: ReadonlyArray, ): OrchestrationThreadActivity | null { return pagedPast ?? oldestActivityByChronology(merged); } /** * The page rows not already present in the loaded set (older pages + live * window) — boundary overlap and mid-flight appends must never produce * duplicate ids in the merged timeline. */ export function freshOlderActivities( page: OlderActivitiesPage, merged: ReadonlyArray, ): ReadonlyArray { const seen = new Set(merged.map((activity) => activity.id)); return page.activities.filter((activity) => !seen.has(activity.id)); } /** * The older-history lazy-load engine, shared by every client (web ChatView, * the mobile composer, the TUI ChatView). The thread-detail snapshot windows * activities to the most recent page; older pages are fetched on demand and * prepended. * * One implementation holds all the hardening the per-client copies kept * drifting on: * - reset on live-window RESHAPE, not just thread switch: a reconnect * re-snapshot changes the window's chronological-oldest row and a checkpoint * revert shrinks it, but a plain append does neither (the reducer re-sorts * unsequenced rows, so index 0 is not a stable boundary — the sentinel is * {@link liveWindowOldestActivityId}); * - a generation guard so a load resolving after a reset can't repopulate the * cleared state; * - a synchronous in-flight key so scroll-triggered duplicate dispatches * coalesce before the loading state commits; * - an explicit advancing cursor (the oldest row paged PAST), so an * all-overlap page keeps paging instead of dead-ending while the server * still reports more — the server cursor is strict, so it strictly * decreases and paging cannot loop; * - dedup against the LATEST merged set via a ref, so a live append or a * prior prepend settling mid-flight can't produce duplicate ids; * - `hasMore` stays true on a failed/skipped page (the history still exists; * scrolling back retries). */ export function useOlderThreadActivities( options: UseOlderThreadActivitiesOptions, ): UseOlderThreadActivitiesResult { const { threadKey, liveActivities, hasMoreLiveActivities, loadPage } = options; const [olderActivities, setOlderActivities] = useState< ReadonlyArray >([]); const [olderLoaded, setOlderLoaded] = useState(false); const [olderHasMore, setOlderHasMore] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); // Order-independent oldest boundary: `liveActivities[0]` shifts when the // reducer re-sorts unsequenced rows on the first live append, which would // otherwise make a plain append look like a window reshape. const liveOldestActivityId = useMemo( () => liveWindowOldestActivityId(liveActivities), [liveActivities], ); const liveActivityCount = liveActivities.length; // Bumps on every reset so a late in-flight load can't repopulate the // freshly-cleared state (the thread key alone doesn't change on a // same-thread window reshape). const generationRef = useRef(0); // The thread key of an in-flight load — coalesces the duplicate dispatches a // fast scroll fires before the loading state updates. const inFlightKeyRef = useRef(null); // The oldest row we've paged past; advances even when a page dedupes to // nothing. Reset on reshape. const cursorRef = useRef(null); const windowRef = useRef({ key: threadKey, oldest: liveOldestActivityId, count: liveActivityCount, }); // useLayoutEffect (not useEffect) so the cleared state commits before paint: // otherwise a thread switch renders one frame with the previous thread's // lazy-loaded pages still merged in, flashing stale rows. useLayoutEffect(() => { const previous = windowRef.current; windowRef.current = { key: threadKey, oldest: liveOldestActivityId, count: liveActivityCount, }; if (!didLiveWindowReshape(previous, windowRef.current)) { return; } generationRef.current += 1; inFlightKeyRef.current = null; cursorRef.current = null; setOlderActivities([]); setOlderLoaded(false); setOlderHasMore(false); setLoadingOlder(false); }, [threadKey, liveOldestActivityId, liveActivityCount]); const mergedActivities = useMemo( () => (olderActivities.length > 0 ? [...olderActivities, ...liveActivities] : liveActivities), [olderActivities, liveActivities], ); // Latest merged set, read inside the async load handler so dedup runs // against current state, not the snapshot captured at dispatch time. const mergedActivitiesRef = useRef(mergedActivities); useLayoutEffect(() => { mergedActivitiesRef.current = mergedActivities; }, [mergedActivities]); // Before any page is loaded the server flag is authoritative; afterwards // the latest page's `hasMore` is. const hasMoreOlder = olderLoaded ? olderHasMore : threadKey !== null && hasMoreLiveActivities; const loadOlder = useCallback(() => { if (threadKey === null || !hasMoreOlder) { return; } const oldest = nextOlderActivitiesCursorRow(cursorRef.current, mergedActivitiesRef.current); if (!oldest) { return; } if (inFlightKeyRef.current === threadKey) { return; // a load for this thread is already in flight } const cursor = olderActivitiesCursorFor(oldest); const generation = generationRef.current; inFlightKeyRef.current = threadKey; setLoadingOlder(true); void loadPage(cursor) .then((page) => { // The window/thread was reset while this was in flight — drop the page // so it can't repopulate state cleared by the reset. if (generationRef.current !== generation) { return; } if (page === null) { // Failed or interrupted (already surfaced by the caller). Keep // `hasMore` — the history still exists and retrying is valid. return; } // Advance the cursor even when every row dedupes away — the server // cursor is strict, so it strictly decreases and paging can't loop. const pageOldest = page.activities[0]; if (pageOldest) { cursorRef.current = pageOldest; } const fresh = freshOlderActivities(page, mergedActivitiesRef.current); if (fresh.length > 0) { setOlderActivities((previous) => [...fresh, ...previous]); } setOlderLoaded(true); setOlderHasMore(page.hasMore); }) .finally(() => { if (generationRef.current === generation) { inFlightKeyRef.current = null; setLoadingOlder(false); } }); }, [threadKey, hasMoreOlder, loadPage]); return { mergedActivities: threadKey === null ? EMPTY_ACTIVITIES : mergedActivities, hasMoreOlder, loadingOlder, loadOlder, }; }