import type { LegendListRef } from "@legendapp/list/react"; import type { AssistantCitation, MessageId, ScopedThreadRef } from "@t3tools/contracts"; import { useEffect, useRef, type ReactNode, type RefObject } from "react"; import { resolveAssistantCitationRange, type AssistantCitationSourceAnchor, } from "~/lib/assistantTextSelection"; import { toastManager } from "../ui/toast"; const CITATION_PULSE_DURATION_MS = 650; // The second pulse settles into a held highlight so late glances still find the quote. const CITATION_HIGHLIGHT_HOLD_MS = 1575; const CITATION_HIGHLIGHT_FADE_MS = 450; const CITATION_HIGHLIGHT_TOTAL_MS = 1.5 * CITATION_PULSE_DURATION_MS + CITATION_HIGHLIGHT_HOLD_MS + CITATION_HIGHLIGHT_FADE_MS; const CITATION_HIGHLIGHT_OPACITY = "--assistant-citation-highlight-opacity"; // Matches the comment-editing highlight strength in index.css. const CITATION_HIGHLIGHT_PEAK = 0.45; const COMMENT_HIGHLIGHT_NAME = "t3-assistant-citation-comment"; /** Keep source text marked while its comment editor is open, without changing native selection. */ export function observeAssistantCitationCommentSource({ anchor, citation, onUnavailable, }: { anchor: AssistantCitationSourceAnchor; citation: AssistantCitation; onUnavailable: () => void; }): () => void { const { source, range, viewport } = anchor; const registry = typeof CSS !== "undefined" ? CSS.highlights : undefined; let highlight: Highlight | null = null; let stopped = false; const observer = new MutationObserver((records) => { if (stopped) return; const sourceChanged = records.some((record) => source.contains(record.target)); validate(sourceChanged); }); const dispose = () => { if (stopped) return; stopped = true; observer.disconnect(); if (!highlight) return; highlight.delete(range); if (highlight.size === 0 && registry?.get(COMMENT_HIGHLIGHT_NAME) === highlight) { registry.delete(COMMENT_HIGHLIGHT_NAME); } }; const unavailable = () => { if (stopped) return; dispose(); onUnavailable(); }; const validate = (sourceChanged: boolean): boolean => { if (!source.isConnected || !viewport.contains(source)) { unavailable(); return false; } if ( sourceChanged || range.collapsed || !source.contains(range.startContainer) || !source.contains(range.endContainer) ) { const repaired = resolveAssistantCitationRange(source, citation); if (!repaired) { unavailable(); return false; } range.setStart(repaired.startContainer, repaired.startOffset); range.setEnd(repaired.endContainer, repaired.endOffset); } return true; }; if (!validate(false)) return dispose; if (typeof Highlight !== "undefined" && registry) { const existing = registry.get(COMMENT_HIGHLIGHT_NAME); highlight = existing ?? new Highlight(); highlight.add(range); if (!existing) registry.set(COMMENT_HIGHLIGHT_NAME, highlight); } observer.observe(viewport, { childList: true, subtree: true, characterData: true }); return dispose; } export interface AssistantCitationRequest { citation: AssistantCitation; key: string; } /** Navigation owns the ref so virtual row remounts cannot replay an activation. */ export interface AssistantCitationTarget extends AssistantCitationRequest { activationRef: RefObject<{ scrolled: boolean; dismissed: boolean; cancelScroll?: () => void; pulse?: { startedAt: number; reducedMotion: boolean }; }>; onComplete: () => void; } /** Observe only the active source, including virtual containers moved above its root. */ export function observeAssistantCitationSource({ root, itemKey, request, list, }: { root: HTMLElement; itemKey: string; request: AssistantCitationTarget; list: LegendListRef; }) { const activation = request.activationRef.current; if (activation.dismissed) return; const scrollNode = list.getScrollableNode(); if (!(scrollNode instanceof HTMLElement)) return; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; let highlighted: Highlight | null = null; let ownedRange: Range | null = null; let selected: { range: Range; snapshot: Range } | null = null; let frame: number | null = null; let scrolling = false; let stopped = false; let pulseAnimation: Animation | null = null; let canReplaceSelection = !activation.scrolled; const ownsSelection = (selection: Selection | null) => selected !== null && selection?.rangeCount === 1 && selection.getRangeAt(0) === selected.range && selected.range.startContainer === selected.snapshot.startContainer && selected.range.startOffset === selected.snapshot.startOffset && selected.range.endContainer === selected.snapshot.endContainer && selected.range.endOffset === selected.snapshot.endOffset; const clear = () => { if (highlighted && CSS.highlights.get("t3-assistant-citation") === highlighted) { CSS.highlights.delete("t3-assistant-citation"); } highlighted = null; ownedRange = null; const selection = selected ? root.ownerDocument.getSelection() : null; if (selected && ownsSelection(selection)) selection?.removeRange(selected.range); selected = null; delete root.dataset.citationHighlighted; }; const finishHighlight = () => { if (stopped) return; activation.dismissed = true; dispose(); }; const highlight = () => { frame = null; if (stopped || activation.dismissed || !root.isConnected || !scrollNode.contains(root)) { clear(); return; } if ( activation.pulse && performance.now() - activation.pulse.startedAt >= CITATION_HIGHLIGHT_TOTAL_MS ) { finishHighlight(); return; } const state = list.getState(); const index = state.indexByKey(itemKey); // alwaysRender mounts this row before navigation. Estimates are not measurements. if (index === undefined || !(state.sizeAtIndex(index) > 0)) return; const range = resolveAssistantCitationRange(root, request.citation); const rect = (range ?? root).getBoundingClientRect(); if (rect.height <= 0 || scrollNode.clientHeight <= 0) { clear(); return; } if (!activation.scrolled) { if (scrolling) return; const scrollRect = scrollNode.getBoundingClientRect(); const offset = Math.max( 0, Math.min( scrollNode.scrollHeight - scrollNode.clientHeight, state.scroll + rect.top - scrollRect.top - Math.min(120, scrollNode.clientHeight / 3), ), ); if (Math.abs(offset - state.scroll) > 1) { scrolling = true; void list.scrollToOffset({ offset, animated: !reducedMotion }).then( () => { scrolling = false; if (!stopped && !activation.dismissed) schedule(); }, () => { scrolling = false; if (stopped || activation.dismissed) return; activation.dismissed = true; clear(); request.onComplete(); toastManager.add({ type: "warning", title: "Could not open the cited response", description: "Click the citation to try again.", }); }, ); return; } // A prepend or row measurement can still change geometry during the scroll promise. if (rect.bottom <= scrollRect.top || rect.top >= scrollRect.bottom) return; activation.scrolled = true; request.onComplete(); if (!range) { toastManager.add({ type: "warning", title: "The quoted text has changed", description: "Showing the source response. The saved quote is unchanged.", }); } } if (!range) { finishHighlight(); return; } if (typeof Highlight !== "undefined" && typeof CSS !== "undefined" && CSS.highlights) { highlighted = new Highlight(range); CSS.highlights.set("t3-assistant-citation", highlighted); ownedRange = range; root.dataset.citationHighlighted = "true"; } else { const selection = root.ownerDocument.getSelection(); // Only a fresh activation may replace a selection we do not own. if (selection && (canReplaceSelection || ownsSelection(selection))) { selection.removeAllRanges(); selection.addRange(range); selected = { range: selection.getRangeAt(0), snapshot: range.cloneRange() }; canReplaceSelection = false; ownedRange = range; root.dataset.citationHighlighted = "true"; } else { finishHighlight(); return; } } if (!pulseAnimation) { // Preserve the original deadline through virtual remounts and range repairs. const pulse = (activation.pulse ??= { startedAt: performance.now(), reducedMotion, }); const at = (milliseconds: number) => milliseconds / CITATION_HIGHLIGHT_TOTAL_MS; const holdEnd = at(CITATION_HIGHLIGHT_TOTAL_MS - CITATION_HIGHLIGHT_FADE_MS); pulseAnimation = root.animate( pulse.reducedMotion ? [ { offset: 0, [CITATION_HIGHLIGHT_OPACITY]: CITATION_HIGHLIGHT_PEAK }, { offset: holdEnd, [CITATION_HIGHLIGHT_OPACITY]: CITATION_HIGHLIGHT_PEAK }, { offset: 1, [CITATION_HIGHLIGHT_OPACITY]: 0 }, ] : [ { offset: 0, [CITATION_HIGHLIGHT_OPACITY]: 0 }, { offset: at(CITATION_PULSE_DURATION_MS * 0.5), [CITATION_HIGHLIGHT_OPACITY]: CITATION_HIGHLIGHT_PEAK, }, { offset: at(CITATION_PULSE_DURATION_MS), [CITATION_HIGHLIGHT_OPACITY]: 0 }, { offset: at(CITATION_PULSE_DURATION_MS * 1.5), [CITATION_HIGHLIGHT_OPACITY]: CITATION_HIGHLIGHT_PEAK, }, { offset: holdEnd, [CITATION_HIGHLIGHT_OPACITY]: CITATION_HIGHLIGHT_PEAK }, { offset: 1, [CITATION_HIGHLIGHT_OPACITY]: 0 }, ], { duration: CITATION_HIGHLIGHT_TOTAL_MS, easing: "ease-in-out" }, ); pulseAnimation.id = "t3-assistant-citation-pulse"; pulseAnimation.currentTime = Math.max(0, performance.now() - pulse.startedAt); void pulseAnimation.finished.then(finishHighlight, () => {}); } }; const schedule = () => { if (frame !== null) cancelAnimationFrame(frame); frame = requestAnimationFrame(highlight); }; const observer = new MutationObserver((records) => { const rangeMoved = ownedRange !== null && (ownedRange.collapsed || !root.contains(ownedRange.startContainer) || !root.contains(ownedRange.endContainer)); if (rangeMoved) { canReplaceSelection ||= selected !== null && ownsSelection(root.ownerDocument.getSelection()); clear(); } if ( rangeMoved || records.some( (record) => root.contains(record.target) || [...record.addedNodes, ...record.removedNodes].some((node) => node.contains(root)), ) ) { schedule(); } }); observer.observe(scrollNode, { childList: true, characterData: true, subtree: true }); const resizeObserver = new ResizeObserver(schedule); resizeObserver.observe(root); resizeObserver.observe(scrollNode); const state = list.getState(); const unsubscribe = [ state.listenToPosition(itemKey, schedule), state.listen("totalSize", schedule), state.listen("headerSize", schedule), ]; const cancelScroll = () => { if (scrolling) { scrolling = false; // Supersede a queued list scroll before the user's gesture or minimap action runs. void list.scrollToOffset({ // Legend may defer this call too. Read the user's position when it executes. get offset() { return scrollNode.scrollTop; }, animated: false, }); } }; activation.cancelScroll = cancelScroll; schedule(); const dispose = () => { if (stopped) return; stopped = true; cancelScroll(); pulseAnimation?.cancel(); if (frame !== null) cancelAnimationFrame(frame); observer.disconnect(); resizeObserver.disconnect(); for (const stop of unsubscribe) stop(); if (activation.cancelScroll === cancelScroll) delete activation.cancelScroll; clear(); }; return dispose; } export function AssistantCitationSource({ messageId, threadRef, itemKey, request, listRef, children, }: { messageId: MessageId; threadRef?: ScopedThreadRef; itemKey: string; request: AssistantCitationTarget | null; listRef: RefObject; children: ReactNode; }) { const rootRef = useRef(null); useEffect(() => { const root = rootRef.current; const list = listRef.current; if (!root || !list || !request || request.citation.messageId !== messageId) return; return observeAssistantCitationSource({ root, itemKey, request, list }); }, [itemKey, listRef, messageId, request]); return (
{children}
); }