| 1 | import type { LegendListRef } from "@legendapp/list/react"; |
| 2 | import type { AssistantCitation, MessageId, ScopedThreadRef } from "@t3tools/contracts"; |
| 3 | import { useEffect, useRef, type ReactNode, type RefObject } from "react"; |
| 4 | import { |
| 5 | resolveAssistantCitationRange, |
| 6 | type AssistantCitationSourceAnchor, |
| 7 | } from "~/lib/assistantTextSelection"; |
| 8 | import { toastManager } from "../ui/toast"; |
| 9 | import { observeResize } from "~/lib/observeResize"; |
| 10 | |
| 11 | const CITATION_PULSE_DURATION_MS = 650; |
| 12 | // The second pulse settles into a held highlight so late glances still find the quote. |
| 13 | const CITATION_HIGHLIGHT_HOLD_MS = 1575; |
| 14 | const CITATION_HIGHLIGHT_FADE_MS = 450; |
| 15 | const CITATION_HIGHLIGHT_TOTAL_MS = |
| 16 | 1.5 * CITATION_PULSE_DURATION_MS + CITATION_HIGHLIGHT_HOLD_MS + CITATION_HIGHLIGHT_FADE_MS; |
| 17 | const CITATION_HIGHLIGHT_OPACITY = "--assistant-citation-highlight-opacity"; |
| 18 | // Matches the comment-editing highlight strength in index.css. |
| 19 | const CITATION_HIGHLIGHT_PEAK = 0.45; |
| 20 | const COMMENT_HIGHLIGHT_NAME = "t3-assistant-citation-comment"; |
| 21 | |
| 22 | /** Keep source text marked while its comment editor is open, without changing native selection. */ |
| 23 | export 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 | |
| 89 | export interface AssistantCitationRequest { |
| 90 | citation: AssistantCitation; |
| 91 | key: string; |
| 92 | } |
| 93 | |
| 94 | /** Navigation owns the ref so virtual row remounts cannot replay an activation. */ |
| 95 | export 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. */ |
| 106 | export 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 | |
| 344 | export 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 | } |