import type { AssistantCitation } from "@t3tools/contracts"; import { assistantCitationLabel, serializeAssistantCitation, } from "@t3tools/shared/assistantCitations"; import { Link, useNavigate } from "@tanstack/react-router"; import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react"; import { useEffect, useEffectEvent, useRef, useState, type MouseEvent as ReactMouseEvent, } from "react"; import { findAssistantCitationSourceAnchor, type AssistantCitationSourceAnchor, } from "~/lib/assistantTextSelection"; import { assistantCitationHash, assistantCitationNavigation, } from "../../lib/assistantCitationNavigation"; import { cn } from "~/lib/utils"; import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip"; import { ContextChipPopover } from "../contextChipParts"; import { Button } from "../ui/button"; import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; import { composerFloatingLayerProps } from "./composerEventScope"; import { observeResize } from "~/lib/observeResize"; import { textDirection } from "~/textDirection"; export function AssistantCitationChip({ citation, composer = false, commentEditor, }: { citation: AssistantCitation; composer?: boolean; commentEditor?: { open: boolean; sourceAnchor?: AssistantCitationSourceAnchor | undefined; onOpenChange: (open: boolean) => void; onCancel?: () => void; onSave: (comment: string) => boolean; onSaveAndSend?: (comment: string) => boolean; onDraftChange?: (comment: string) => void; onRestoreFocus?: () => void; }; }) { const navigate = useNavigate(); const commentInputRef = useRef(null); const commentPopupRef = useRef(null); const draftCommentRef = useRef(null); const [unavailableSourceAnchor, setUnavailableSourceAnchor] = useState(null); const commentOpen = commentEditor?.open ?? false; const sourceAnchor = commentEditor?.sourceAnchor; const activeSourceAnchor = sourceAnchor === unavailableSourceAnchor ? undefined : sourceAnchor; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- drops the uncommitted comment draft held in a ref once the parent closes the comment editor useEffect(() => { if (!commentOpen) draftCommentRef.current = null; }, [commentOpen]); const settleDraftOnClose = (reason: string): boolean => { const dismissal = resolveAssistantCitationCommentDismissal({ reason, draft: draftCommentRef.current, savedComment: citation.comment, }); if (dismissal.kind === "commit") return commentEditor?.onSave(dismissal.comment) ?? true; return dismissal.kind !== "keep-open"; }; const onSourceUnavailable = useEffectEvent(() => { if (!sourceAnchor) return; if (settleDraftOnClose("none")) { commentEditor?.onOpenChange(false); } else { setUnavailableSourceAnchor(sourceAnchor); } }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes the cited DOM source while the comment editor is open and reacts when it disappears useEffect(() => { if (!commentOpen || sourceAnchor === unavailableSourceAnchor) return; const anchor = sourceAnchor ?? findAssistantCitationSourceAnchor(document, citation); if (!anchor) return; return observeAssistantCitationCommentSource({ anchor, citation, onUnavailable: onSourceUnavailable, }); }, [citation, commentOpen, sourceAnchor, unavailableSourceAnchor]); const popupAnchor = activeSourceAnchor ? { contextElement: activeSourceAnchor.source, getBoundingClientRect: () => { const rects = activeSourceAnchor.range.getClientRects(); const rect = rects.item(rects.length - 1) ?? activeSourceAnchor.range.getBoundingClientRect(); // Code blocks and tables scroll sideways inside the message, so the cited // text can end in their hidden overflow. Base UI only shifts the popup while // it still touches the anchor, so clamp the anchor to the message's width. const bounds = activeSourceAnchor.source.getBoundingClientRect(); const left = Math.min(Math.max(rect.left, bounds.left), bounds.right); const right = Math.max(Math.min(rect.right, bounds.right), left); return new DOMRect(left, rect.top, right - left, rect.height); }, } : undefined; const label = assistantCitationLabel(citation); const sourceLinkProps = { to: "/$environmentId/$threadId" as const, params: { environmentId: citation.environmentId, threadId: citation.threadId }, hash: assistantCitationHash(citation), "data-markdown-copy": serializeAssistantCitation(citation), resetScroll: false, onClick: (event: ReactMouseEvent) => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { return; } event.preventDefault(); void navigate(assistantCitationNavigation(citation)); }, }; const composerSourceLink = (