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<LegendListRef | null>; children: ReactNode;}) { const rootRef = useRef<HTMLDivElement>(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 ( <div ref={rootRef} data-assistant-citation-source={messageId} data-assistant-citation-environment={threadRef?.environmentId} data-assistant-citation-thread={threadRef?.threadId} > {children} </div> );}
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<LegendListRef | null>;
children: ReactNode;
}) {
const rootRef = useRef<HTMLDivElement>(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 (
<div
ref={rootRef}
data-assistant-citation-source={messageId}
data-assistant-citation-environment={threadRef?.environmentId}
data-assistant-citation-thread={threadRef?.threadId}
>
{children}
</div>
);
}