apps/web/src/components/chat/AssistantCitationSource.tsx

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>
  );
}