1import type { LegendListRef } from "@legendapp/list/react";
2import type { AssistantCitation, MessageId, ScopedThreadRef } from "@t3tools/contracts";
3import { useEffect, useRef, type ReactNode, type RefObject } from "react";
4import {
5 resolveAssistantCitationRange,
6 type AssistantCitationSourceAnchor,
7} from "~/lib/assistantTextSelection";
8import { toastManager } from "../ui/toast";
9import { observeResize } from "~/lib/observeResize";
10
11const CITATION_PULSE_DURATION_MS = 650;
12// The second pulse settles into a held highlight so late glances still find the quote.
13const CITATION_HIGHLIGHT_HOLD_MS = 1575;
14const CITATION_HIGHLIGHT_FADE_MS = 450;
15const CITATION_HIGHLIGHT_TOTAL_MS =
16 1.5 * CITATION_PULSE_DURATION_MS + CITATION_HIGHLIGHT_HOLD_MS + CITATION_HIGHLIGHT_FADE_MS;
17const CITATION_HIGHLIGHT_OPACITY = "--assistant-citation-highlight-opacity";
18// Matches the comment-editing highlight strength in index.css.
19const CITATION_HIGHLIGHT_PEAK = 0.45;
20const COMMENT_HIGHLIGHT_NAME = "t3-assistant-citation-comment";
21
22/** Keep source text marked while its comment editor is open, without changing native selection. */
23export function observeAssistantCitationCommentSource({
24 anchor,
25 citation,
26 onUnavailable,
27}: {
28 anchor: AssistantCitationSourceAnchor;
29 citation: AssistantCitation;
30 onUnavailable: () => void;
31}): () => void {
32 const { source, range, viewport } = anchor;
33 const registry = typeof CSS !== "undefined" ? CSS.highlights : undefined;
34 let highlight: Highlight | null = null;
35 let stopped = false;
36 const observer = new MutationObserver((records) => {
37 if (stopped) return;
38 const sourceChanged = records.some((record) => source.contains(record.target));
39 validate(sourceChanged);
40 });
41 const dispose = () => {
42 if (stopped) return;
43 stopped = true;
44 observer.disconnect();
45 if (!highlight) return;
46 highlight.delete(range);
47 if (highlight.size === 0 && registry?.get(COMMENT_HIGHLIGHT_NAME) === highlight) {
48 registry.delete(COMMENT_HIGHLIGHT_NAME);
49 }
50 };
51 const unavailable = () => {
52 if (stopped) return;
53 dispose();
54 onUnavailable();
55 };
56 const validate = (sourceChanged: boolean): boolean => {
57 if (!source.isConnected || !viewport.contains(source)) {
58 unavailable();
59 return false;
60 }
61 if (
62 sourceChanged ||
63 range.collapsed ||
64 !source.contains(range.startContainer) ||
65 !source.contains(range.endContainer)
66 ) {
67 const repaired = resolveAssistantCitationRange(source, citation);
68 if (!repaired) {
69 unavailable();
70 return false;
71 }
72 range.setStart(repaired.startContainer, repaired.startOffset);
73 range.setEnd(repaired.endContainer, repaired.endOffset);
74 }
75 return true;
76 };
77
78 if (!validate(false)) return dispose;
79 if (typeof Highlight !== "undefined" && registry) {
80 const existing = registry.get(COMMENT_HIGHLIGHT_NAME);
81 highlight = existing ?? new Highlight();
82 highlight.add(range);
83 if (!existing) registry.set(COMMENT_HIGHLIGHT_NAME, highlight);
84 }
85 observer.observe(viewport, { childList: true, subtree: true, characterData: true });
86 return dispose;
87}
88
89export interface AssistantCitationRequest {
90 citation: AssistantCitation;
91 key: string;
92}
93
94/** Navigation owns the ref so virtual row remounts cannot replay an activation. */
95export interface AssistantCitationTarget extends AssistantCitationRequest {
96 activationRef: RefObject<{
97 scrolled: boolean;
98 dismissed: boolean;
99 cancelScroll?: () => void;
100 pulse?: { startedAt: number; reducedMotion: boolean };
101 }>;
102 onComplete: () => void;
103}
104
105/** Observe only the active source, including virtual containers moved above its root. */
106export function observeAssistantCitationSource({
107 root,
108 itemKey,
109 request,
110 list,
111}: {
112 root: HTMLElement;
113 itemKey: string;
114 request: AssistantCitationTarget;
115 list: LegendListRef;
116}) {
117 const activation = request.activationRef.current;
118 if (activation.dismissed) return;
119 const scrollNode = list.getScrollableNode();
120 if (!(scrollNode instanceof HTMLElement)) return;
121 const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
122 let highlighted: Highlight | null = null;
123 let ownedRange: Range | null = null;
124 let selected: { range: Range; snapshot: Range } | null = null;
125 let frame: number | null = null;
126 let scrolling = false;
127 let stopped = false;
128 let pulseAnimation: Animation | null = null;
129 let canReplaceSelection = !activation.scrolled;
130 const ownsSelection = (selection: Selection | null) =>
131 selected !== null &&
132 selection?.rangeCount === 1 &&
133 selection.getRangeAt(0) === selected.range &&
134 selected.range.startContainer === selected.snapshot.startContainer &&
135 selected.range.startOffset === selected.snapshot.startOffset &&
136 selected.range.endContainer === selected.snapshot.endContainer &&
137 selected.range.endOffset === selected.snapshot.endOffset;
138 const clear = () => {
139 if (highlighted && CSS.highlights.get("t3-assistant-citation") === highlighted) {
140 CSS.highlights.delete("t3-assistant-citation");
141 }
142 highlighted = null;
143 ownedRange = null;
144 const selection = selected ? root.ownerDocument.getSelection() : null;
145 if (selected && ownsSelection(selection)) selection?.removeRange(selected.range);
146 selected = null;
147 delete root.dataset.citationHighlighted;
148 };
149 const finishHighlight = () => {
150 if (stopped) return;
151 activation.dismissed = true;
152 dispose();
153 };
154 const highlight = () => {
155 frame = null;
156 if (stopped || activation.dismissed || !root.isConnected || !scrollNode.contains(root)) {
157 clear();
158 return;
159 }
160 if (
161 activation.pulse &&
162 performance.now() - activation.pulse.startedAt >= CITATION_HIGHLIGHT_TOTAL_MS
163 ) {
164 finishHighlight();
165 return;
166 }
167 const state = list.getState();
168 const index = state.indexByKey(itemKey);
169 // alwaysRender mounts this row before navigation. Estimates are not measurements.
170 if (index === undefined || !(state.sizeAtIndex(index) > 0)) return;
171 const range = resolveAssistantCitationRange(root, request.citation);
172 const rect = (range ?? root).getBoundingClientRect();
173 if (rect.height <= 0 || scrollNode.clientHeight <= 0) {
174 clear();
175 return;
176 }
177 if (!activation.scrolled) {
178 if (scrolling) return;
179 const scrollRect = scrollNode.getBoundingClientRect();
180 const offset = Math.max(
181 0,
182 Math.min(
183 scrollNode.scrollHeight - scrollNode.clientHeight,
184 state.scroll + rect.top - scrollRect.top - Math.min(120, scrollNode.clientHeight / 3),
185 ),
186 );
187 if (Math.abs(offset - state.scroll) > 1) {
188 scrolling = true;
189 void list.scrollToOffset({ offset, animated: !reducedMotion }).then(
190 () => {
191 scrolling = false;
192 if (!stopped && !activation.dismissed) schedule();
193 },
194 () => {
195 scrolling = false;
196 if (stopped || activation.dismissed) return;
197 activation.dismissed = true;
198 clear();
199 request.onComplete();
200 toastManager.add({
201 type: "warning",
202 title: "Could not open the cited response",
203 description: "Click the citation to try again.",
204 });
205 },
206 );
207 return;
208 }
209 // A prepend or row measurement can still change geometry during the scroll promise.
210 if (rect.bottom <= scrollRect.top || rect.top >= scrollRect.bottom) return;
211 activation.scrolled = true;
212 request.onComplete();
213 if (!range) {
214 toastManager.add({
215 type: "warning",
216 title: "The quoted text has changed",
217 description: "Showing the source response. The saved quote is unchanged.",
218 });
219 }
220 }
221 if (!range) {
222 finishHighlight();
223 return;
224 }
225 if (typeof Highlight !== "undefined" && typeof CSS !== "undefined" && CSS.highlights) {
226 highlighted = new Highlight(range);
227 CSS.highlights.set("t3-assistant-citation", highlighted);
228 ownedRange = range;
229 root.dataset.citationHighlighted = "true";
230 } else {
231 const selection = root.ownerDocument.getSelection();
232 // Only a fresh activation may replace a selection we do not own.
233 if (selection && (canReplaceSelection || ownsSelection(selection))) {
234 selection.removeAllRanges();
235 selection.addRange(range);
236 selected = { range: selection.getRangeAt(0), snapshot: range.cloneRange() };
237 canReplaceSelection = false;
238 ownedRange = range;
239 root.dataset.citationHighlighted = "true";
240 } else {
241 finishHighlight();
242 return;
243 }
244 }
245 if (!pulseAnimation) {
246 // Preserve the original deadline through virtual remounts and range repairs.
247 const pulse = (activation.pulse ??= {
248 startedAt: performance.now(),
249 reducedMotion,
250 });
251 const at = (milliseconds: number) => milliseconds / CITATION_HIGHLIGHT_TOTAL_MS;
252 const holdEnd = at(CITATION_HIGHLIGHT_TOTAL_MS - CITATION_HIGHLIGHT_FADE_MS);
253 pulseAnimation = root.animate(
254 pulse.reducedMotion
255 ? [
256 { offset: 0, [CITATION_HIGHLIGHT_OPACITY]: CITATION_HIGHLIGHT_PEAK },
257 { offset: holdEnd, [CITATION_HIGHLIGHT_OPACITY]: CITATION_HIGHLIGHT_PEAK },
258 { offset: 1, [CITATION_HIGHLIGHT_OPACITY]: 0 },
259 ]
260 : [
261 { offset: 0, [CITATION_HIGHLIGHT_OPACITY]: 0 },
262 {
263 offset: at(CITATION_PULSE_DURATION_MS * 0.5),
264 [CITATION_HIGHLIGHT_OPACITY]: CITATION_HIGHLIGHT_PEAK,
265 },
266 { offset: at(CITATION_PULSE_DURATION_MS), [CITATION_HIGHLIGHT_OPACITY]: 0 },
267 {
268 offset: at(CITATION_PULSE_DURATION_MS * 1.5),
269 [CITATION_HIGHLIGHT_OPACITY]: CITATION_HIGHLIGHT_PEAK,
270 },
271 { offset: holdEnd, [CITATION_HIGHLIGHT_OPACITY]: CITATION_HIGHLIGHT_PEAK },
272 { offset: 1, [CITATION_HIGHLIGHT_OPACITY]: 0 },
273 ],
274 { duration: CITATION_HIGHLIGHT_TOTAL_MS, easing: "ease-in-out" },
275 );
276 pulseAnimation.id = "t3-assistant-citation-pulse";
277 pulseAnimation.currentTime = Math.max(0, performance.now() - pulse.startedAt);
278 void pulseAnimation.finished.then(finishHighlight, () => {});
279 }
280 };
281 const schedule = () => {
282 if (frame !== null) cancelAnimationFrame(frame);
283 frame = requestAnimationFrame(highlight);
284 };
285 const observer = new MutationObserver((records) => {
286 const rangeMoved =
287 ownedRange !== null &&
288 (ownedRange.collapsed ||
289 !root.contains(ownedRange.startContainer) ||
290 !root.contains(ownedRange.endContainer));
291 if (rangeMoved) {
292 canReplaceSelection ||= selected !== null && ownsSelection(root.ownerDocument.getSelection());
293 clear();
294 }
295 if (
296 rangeMoved ||
297 records.some(
298 (record) =>
299 root.contains(record.target) ||
300 [...record.addedNodes, ...record.removedNodes].some((node) => node.contains(root)),
301 )
302 ) {
303 schedule();
304 }
305 });
306 observer.observe(scrollNode, { childList: true, characterData: true, subtree: true });
307 const stopObservingResize = observeResize([root, scrollNode], schedule);
308 const state = list.getState();
309 const unsubscribe = [
310 state.listenToPosition(itemKey, schedule),
311 state.listen("totalSize", schedule),
312 state.listen("headerSize", schedule),
313 ];
314 const cancelScroll = () => {
315 if (scrolling) {
316 scrolling = false;
317 // Supersede a queued list scroll before the user's gesture or minimap action runs.
318 void list.scrollToOffset({
319 // Legend may defer this call too. Read the user's position when it executes.
320 get offset() {
321 return scrollNode.scrollTop;
322 },
323 animated: false,
324 });
325 }
326 };
327 activation.cancelScroll = cancelScroll;
328 schedule();
329 const dispose = () => {
330 if (stopped) return;
331 stopped = true;
332 cancelScroll();
333 pulseAnimation?.cancel();
334 if (frame !== null) cancelAnimationFrame(frame);
335 observer.disconnect();
336 stopObservingResize();
337 for (const stop of unsubscribe) stop();
338 if (activation.cancelScroll === cancelScroll) delete activation.cancelScroll;
339 clear();
340 };
341 return dispose;
342}
343
344export function AssistantCitationSource({
345 messageId,
346 threadRef,
347 itemKey,
348 request,
349 listRef,
350 children,
351}: {
352 messageId: MessageId;
353 threadRef?: ScopedThreadRef;
354 itemKey: string;
355 request: AssistantCitationTarget | null;
356 listRef: RefObject<LegendListRef | null>;
357 children: ReactNode;
358}) {
359 const rootRef = useRef<HTMLDivElement>(null);
360 useEffect(() => {
361 const root = rootRef.current;
362 const list = listRef.current;
363 if (!root || !list || !request || request.citation.messageId !== messageId) return;
364 return observeAssistantCitationSource({ root, itemKey, request, list });
365 }, [itemKey, listRef, messageId, request]);
366
367 return (
368 <div
369 ref={rootRef}
370 data-assistant-citation-source={messageId}
371 data-assistant-citation-environment={threadRef?.environmentId}
372 data-assistant-citation-thread={threadRef?.threadId}
373 >
374 {children}
375 </div>
376 );
377}