1import type { LegendListRef } from "@legendapp/list/react";
2import type { RunId } from "@t3tools/contracts";
3import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
4import type { TimelineEntry } from "../../session-logic";
5import type { MessagesTimelineRow } from "./MessagesTimeline.logic";
6import type { AssistantCitationRequest, AssistantCitationTarget } from "./AssistantCitationSource";
7import { toastManager } from "../ui/toast";
8
9export interface CitationHistoryPage {
10 readonly loading: boolean;
11 readonly cursor?: string | null;
12 readonly onLoadEarlier: () => void;
13}
14
15/** Fetch, unfold, and mount the source before its measured quote owns scrolling. */
16export function useAssistantCitationTarget({
17 request,
18 entries,
19 rows,
20 listRef,
21 viewport,
22 historyLoading,
23 loadEarlier,
24 onExpandTurn,
25 onManualNavigation,
26}: {
27 request: AssistantCitationRequest | null;
28 entries: ReadonlyArray<TimelineEntry>;
29 rows: ReadonlyArray<MessagesTimelineRow>;
30 listRef: RefObject<LegendListRef | null>;
31 viewport: HTMLElement | null;
32 historyLoading: boolean;
33 loadEarlier: CitationHistoryPage | null;
34 onExpandTurn: (runId: RunId) => void;
35 onManualNavigation: () => void;
36}) {
37 const [ready, setReady] = useState<AssistantCitationTarget | null>(null);
38 const [finishedKey, setFinishedKey] = useState<string | null>(null);
39 const [listLoaded, setListLoaded] = useState(false);
40 const onListLoad = useCallback(() => setListLoaded(true), []);
41 const navigationRef = useRef<{
42 target: AssistantCitationTarget;
43 requestedPages: Set<string>;
44 done: boolean;
45 } | null>(null);
46
47 useEffect(() => {
48 if (navigationRef.current && navigationRef.current.target.key !== request?.key) {
49 navigationRef.current.target.activationRef.current.dismissed = true;
50 navigationRef.current.target.activationRef.current.cancelScroll?.();
51 }
52 if (!request) {
53 navigationRef.current = null;
54 setReady(null);
55 setFinishedKey(null);
56 return;
57 }
58 if (navigationRef.current?.target.key !== request.key) {
59 const target: AssistantCitationTarget = {
60 ...request,
61 activationRef: { current: { scrolled: false, dismissed: false } },
62 onComplete: () => {
63 if (navigationRef.current?.target !== target) return;
64 navigationRef.current.done = true;
65 // ChatView's thread-open effect can run after our initial opt-out.
66 onManualNavigation();
67 setFinishedKey(target.key);
68 },
69 };
70 navigationRef.current = {
71 target,
72 requestedPages: new Set(),
73 done: false,
74 };
75 setReady(null);
76 onManualNavigation();
77 }
78 if (!viewport || historyLoading) return;
79 const navigation = navigationRef.current;
80 if (navigation.done || navigation.target.activationRef.current.dismissed) return;
81 const fail = (title: string, description: string) => {
82 navigation.done = true;
83 setFinishedKey(navigation.target.key);
84 toastManager.add({ type: "warning", title, description });
85 };
86 const source = entries.find(
87 (entry) =>
88 entry.kind === "message" && entry.message.id === navigation.target.citation.messageId,
89 );
90 if (!source) {
91 if (loadEarlier) {
92 if (loadEarlier.loading) return;
93 const cursor = loadEarlier.cursor ?? entries[0]?.id ?? "first";
94 if (navigation.requestedPages.has(cursor) || navigation.requestedPages.size >= 20) {
95 fail(
96 "Could not load the cited response",
97 "Load earlier turns, then click the citation to try again. Your saved quote is unchanged.",
98 );
99 return;
100 }
101 navigation.requestedPages.add(cursor);
102 loadEarlier.onLoadEarlier();
103 return;
104 }
105 fail(
106 "The cited response is unavailable",
107 "It may have been removed. The selected text is still saved in your citation.",
108 );
109 return;
110 }
111 if (source.kind !== "message" || source.message.role !== "assistant") {
112 fail(
113 "The citation does not refer to an assistant response",
114 "The selected text is still saved in your citation.",
115 );
116 return;
117 }
118 const index = rows.findIndex(
119 (row) => row.kind === "message" && row.message.id === navigation.target.citation.messageId,
120 );
121 if (index < 0) {
122 if (source.message.runId) onExpandTurn(source.message.runId);
123 return;
124 }
125 if (listLoaded && listRef.current) setReady(navigation.target);
126 }, [
127 entries,
128 historyLoading,
129 listLoaded,
130 listRef,
131 loadEarlier,
132 onExpandTurn,
133 onManualNavigation,
134 request,
135 rows,
136 viewport,
137 ]);
138
139 useEffect(() => {
140 if (!request) return;
141 const dismiss = (onlyPending: boolean) => {
142 const navigation = navigationRef.current;
143 if (!navigation || navigation.target.key !== request.key) return;
144 const activation = navigation.target.activationRef.current;
145 if (activation.dismissed || (onlyPending && (activation.scrolled || navigation.done))) return;
146 activation.dismissed = true;
147 activation.cancelScroll?.();
148 if (!activation.scrolled && !navigation.done) onManualNavigation();
149 navigation.done = true;
150 setReady(null);
151 setFinishedKey(request.key);
152 };
153 const onKeyDown = (event: KeyboardEvent) => {
154 if (event.key === "Escape") {
155 dismiss(false);
156 } else if (
157 viewport?.contains(event.target as Node) &&
158 ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)
159 ) {
160 dismiss(true);
161 }
162 };
163 const onWheel = (event: WheelEvent) => {
164 if (event.deltaY !== 0) dismiss(true);
165 };
166 const onNavigation = () => dismiss(true);
167 document.addEventListener("keydown", onKeyDown);
168 document.addEventListener("pointerdown", onNavigation, true);
169 viewport?.addEventListener("wheel", onWheel, { passive: true });
170 viewport?.addEventListener("touchmove", onNavigation, { passive: true });
171 return () => {
172 document.removeEventListener("keydown", onKeyDown);
173 document.removeEventListener("pointerdown", onNavigation, true);
174 viewport?.removeEventListener("wheel", onWheel);
175 viewport?.removeEventListener("touchmove", onNavigation);
176 };
177 }, [onManualNavigation, request, viewport]);
178
179 const target = ready?.key === request?.key ? ready : null;
180 const positioning = request !== null && finishedKey !== request.key;
181 const sourceRow =
182 target && positioning
183 ? rows.find((row) => row.kind === "message" && row.message.id === target.citation.messageId)
184 : undefined;
185 return {
186 target,
187 positioning,
188 onListLoad,
189 alwaysRender: sourceRow ? { keys: [sourceRow.id] } : undefined,
190 };
191}