270 lines · 10.8 KB · TypeScript
RawDownload
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { OrchestrationThreadActivity } from "@t3tools/contracts"; import { liveWindowOldestActivityId, oldestActivityByChronology } from "./threadReducer.ts"; const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = []; /** * 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<OrchestrationThreadActivity>;  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<OrchestrationThreadActivity>;  /** 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<OlderActivitiesPage | null>;} export interface UseOlderThreadActivitiesResult {  /** Lazy-loaded older pages + the live window, oldest first. */  readonly mergedActivities: ReadonlyArray<OrchestrationThreadActivity>;  /** 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>,): 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<OrchestrationThreadActivity>,): ReadonlyArray<OrchestrationThreadActivity> {  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<OrchestrationThreadActivity>  >([]);  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<string | null>(null);  // The oldest row we've paged past; advances even when a page dedupes to  // nothing. Reset on reshape.  const cursorRef = useRef<OrchestrationThreadActivity | null>(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,  };}