191 lines · 6.6 KB · TypeScript
RawDownload
import type { LegendListRef } from "@legendapp/list/react";import type { TurnId } from "@t3tools/contracts";import { useCallback, useEffect, useRef, useState, type RefObject } from "react";import type { TimelineEntry } from "../../session-logic";import type { MessagesTimelineRow } from "./MessagesTimeline.logic";import type { AssistantCitationRequest, AssistantCitationTarget } from "./AssistantCitationSource";import { toastManager } from "../ui/toast"; export interface CitationHistoryPage {  readonly loading: boolean;  readonly cursor?: string | null;  readonly onLoadEarlier: () => void;} /** Fetch, unfold, and mount the source before its measured quote owns scrolling. */export function useAssistantCitationTarget({  request,  entries,  rows,  listRef,  viewport,  historyLoading,  loadEarlier,  onExpandTurn,  onManualNavigation,}: {  request: AssistantCitationRequest | null;  entries: ReadonlyArray<TimelineEntry>;  rows: ReadonlyArray<MessagesTimelineRow>;  listRef: RefObject<LegendListRef | null>;  viewport: HTMLElement | null;  historyLoading: boolean;  loadEarlier: CitationHistoryPage | null;  onExpandTurn: (turnId: TurnId) => void;  onManualNavigation: () => void;}) {  const [ready, setReady] = useState<AssistantCitationTarget | null>(null);  const [finishedKey, setFinishedKey] = useState<string | null>(null);  const [listLoaded, setListLoaded] = useState(false);  const onListLoad = useCallback(() => setListLoaded(true), []);  const navigationRef = useRef<{    target: AssistantCitationTarget;    requestedPages: Set<string>;    done: boolean;  } | null>(null);   useEffect(() => {    if (navigationRef.current && navigationRef.current.target.key !== request?.key) {      navigationRef.current.target.activationRef.current.dismissed = true;      navigationRef.current.target.activationRef.current.cancelScroll?.();    }    if (!request) {      navigationRef.current = null;      setReady(null);      setFinishedKey(null);      return;    }    if (navigationRef.current?.target.key !== request.key) {      const target: AssistantCitationTarget = {        ...request,        activationRef: { current: { scrolled: false, dismissed: false } },        onComplete: () => {          if (navigationRef.current?.target !== target) return;          navigationRef.current.done = true;          // ChatView's thread-open effect can run after our initial opt-out.          onManualNavigation();          setFinishedKey(target.key);        },      };      navigationRef.current = {        target,        requestedPages: new Set(),        done: false,      };      setReady(null);      onManualNavigation();    }    if (!viewport || historyLoading) return;    const navigation = navigationRef.current;    if (navigation.done || navigation.target.activationRef.current.dismissed) return;    const fail = (title: string, description: string) => {      navigation.done = true;      setFinishedKey(navigation.target.key);      toastManager.add({ type: "warning", title, description });    };    const source = entries.find(      (entry) =>        entry.kind === "message" && entry.message.id === navigation.target.citation.messageId,    );    if (!source) {      if (loadEarlier) {        if (loadEarlier.loading) return;        const cursor = loadEarlier.cursor ?? entries[0]?.id ?? "first";        if (navigation.requestedPages.has(cursor) || navigation.requestedPages.size >= 20) {          fail(            "Could not load the cited response",            "Load earlier turns, then click the citation to try again. Your saved quote is unchanged.",          );          return;        }        navigation.requestedPages.add(cursor);        loadEarlier.onLoadEarlier();        return;      }      fail(        "The cited response is unavailable",        "It may have been removed. The selected text is still saved in your citation.",      );      return;    }    if (source.kind !== "message" || source.message.role !== "assistant") {      fail(        "The citation does not refer to an assistant response",        "The selected text is still saved in your citation.",      );      return;    }    const index = rows.findIndex(      (row) => row.kind === "message" && row.message.id === navigation.target.citation.messageId,    );    if (index < 0) {      if (source.message.turnId) onExpandTurn(source.message.turnId);      return;    }    if (listLoaded && listRef.current) setReady(navigation.target);  }, [    entries,    historyLoading,    listLoaded,    listRef,    loadEarlier,    onExpandTurn,    onManualNavigation,    request,    rows,    viewport,  ]);   useEffect(() => {    if (!request) return;    const dismiss = (onlyPending: boolean) => {      const navigation = navigationRef.current;      if (!navigation || navigation.target.key !== request.key) return;      const activation = navigation.target.activationRef.current;      if (activation.dismissed || (onlyPending && (activation.scrolled || navigation.done))) return;      activation.dismissed = true;      activation.cancelScroll?.();      if (!activation.scrolled && !navigation.done) onManualNavigation();      navigation.done = true;      setReady(null);      setFinishedKey(request.key);    };    const onKeyDown = (event: KeyboardEvent) => {      if (event.key === "Escape") {        dismiss(false);      } else if (        viewport?.contains(event.target as Node) &&        ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)      ) {        dismiss(true);      }    };    const onWheel = (event: WheelEvent) => {      if (event.deltaY !== 0) dismiss(true);    };    const onNavigation = () => dismiss(true);    document.addEventListener("keydown", onKeyDown);    document.addEventListener("pointerdown", onNavigation, true);    viewport?.addEventListener("wheel", onWheel, { passive: true });    viewport?.addEventListener("touchmove", onNavigation, { passive: true });    return () => {      document.removeEventListener("keydown", onKeyDown);      document.removeEventListener("pointerdown", onNavigation, true);      viewport?.removeEventListener("wheel", onWheel);      viewport?.removeEventListener("touchmove", onNavigation);    };  }, [onManualNavigation, request, viewport]);   const target = ready?.key === request?.key ? ready : null;  const positioning = request !== null && finishedKey !== request.key;  const sourceRow =    target && positioning      ? rows.find((row) => row.kind === "message" && row.message.id === target.citation.messageId)      : undefined;  return {    target,    positioning,    onListLoad,    alwaysRender: sourceRow ? { keys: [sourceRow.id] } : undefined,  };}