| 1 | import type { AssistantCitation } from "@t3tools/contracts"; |
| 2 | import { |
| 3 | assistantCitationLabel, |
| 4 | serializeAssistantCitation, |
| 5 | } from "@t3tools/shared/assistantCitations"; |
| 6 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 7 | import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react"; |
| 8 | import { |
| 9 | useCallback, |
| 10 | useEffect, |
| 11 | useEffectEvent, |
| 12 | useRef, |
| 13 | useState, |
| 14 | type MouseEvent as ReactMouseEvent, |
| 15 | } from "react"; |
| 16 | import { |
| 17 | findAssistantCitationSourceAnchor, |
| 18 | type AssistantCitationSourceAnchor, |
| 19 | } from "~/lib/assistantTextSelection"; |
| 20 | import { |
| 21 | assistantCitationHash, |
| 22 | assistantCitationNavigation, |
| 23 | } from "../../lib/assistantCitationNavigation"; |
| 24 | import { cn } from "~/lib/utils"; |
| 25 | import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip"; |
| 26 | import { ContextChipPopover } from "../contextChipParts"; |
| 27 | import { Button } from "../ui/button"; |
| 28 | import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 29 | import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; |
| 30 | import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; |
| 31 | import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; |
| 32 | import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; |
| 33 | import { composerFloatingLayerProps } from "./composerEventScope"; |
| 34 | import { observeResize } from "~/lib/observeResize"; |
| 35 | |
| 36 | export function AssistantCitationChip({ |
| 37 | citation, |
| 38 | composer = false, |
| 39 | commentEditor, |
| 40 | }: { |
| 41 | citation: AssistantCitation; |
| 42 | composer?: boolean; |
| 43 | commentEditor?: { |
| 44 | open: boolean; |
| 45 | sourceAnchor?: AssistantCitationSourceAnchor | undefined; |
| 46 | onOpenChange: (open: boolean) => void; |
| 47 | onCancel?: () => void; |
| 48 | onSave: (comment: string) => boolean; |
| 49 | onSaveAndSend?: (comment: string) => boolean; |
| 50 | /** Reports each keystroke of the unsaved comment. */ |
| 51 | onDraftChange?: (comment: string) => void; |
| 52 | /** Returns focus to the host editor when the popover closes instead of to the pencil trigger. */ |
| 53 | onRestoreFocus?: () => void; |
| 54 | }; |
| 55 | }) { |
| 56 | const navigate = useNavigate(); |
| 57 | const commentInputRef = useRef<HTMLTextAreaElement>(null); |
| 58 | const commentPopupRef = useRef<HTMLDivElement>(null); |
| 59 | const draftCommentRef = useRef<string | null>(null); |
| 60 | const [unavailableSourceAnchor, setUnavailableSourceAnchor] = |
| 61 | useState<AssistantCitationSourceAnchor | null>(null); |
| 62 | const commentOpen = commentEditor?.open ?? false; |
| 63 | const sourceAnchor = commentEditor?.sourceAnchor; |
| 64 | const activeSourceAnchor = sourceAnchor === unavailableSourceAnchor ? undefined : sourceAnchor; |
| 65 | useEffect(() => { |
| 66 | if (!commentOpen) draftCommentRef.current = null; |
| 67 | }, [commentOpen]); |
| 68 | const settleDraftOnClose = (reason: string): boolean => { |
| 69 | const dismissal = resolveAssistantCitationCommentDismissal({ |
| 70 | reason, |
| 71 | draft: draftCommentRef.current, |
| 72 | savedComment: citation.comment, |
| 73 | }); |
| 74 | if (dismissal.kind === "commit") return commentEditor?.onSave(dismissal.comment) ?? true; |
| 75 | return dismissal.kind !== "keep-open"; |
| 76 | }; |
| 77 | const onSourceUnavailable = useEffectEvent(() => { |
| 78 | if (!sourceAnchor) return; |
| 79 | if (settleDraftOnClose("none")) { |
| 80 | commentEditor?.onOpenChange(false); |
| 81 | } else { |
| 82 | // Keep the draft mounted, positioned at the composer trigger instead of a detached range. |
| 83 | setUnavailableSourceAnchor(sourceAnchor); |
| 84 | } |
| 85 | }); |
| 86 | useEffect(() => { |
| 87 | if (!commentOpen || sourceAnchor === unavailableSourceAnchor) return; |
| 88 | const anchor = sourceAnchor ?? findAssistantCitationSourceAnchor(document, citation); |
| 89 | if (!anchor) return; |
| 90 | return observeAssistantCitationCommentSource({ |
| 91 | anchor, |
| 92 | citation, |
| 93 | onUnavailable: onSourceUnavailable, |
| 94 | }); |
| 95 | }, [citation, commentOpen, sourceAnchor, unavailableSourceAnchor]); |
| 96 | // A multi-line selection's bounding box spans the full message width; anchor |
| 97 | // the bubble to the selection's last line, where the pointer released. |
| 98 | const popupAnchor = activeSourceAnchor |
| 99 | ? { |
| 100 | contextElement: activeSourceAnchor.source, |
| 101 | getBoundingClientRect: () => { |
| 102 | const rects = activeSourceAnchor.range.getClientRects(); |
| 103 | return rects.item(rects.length - 1) ?? activeSourceAnchor.range.getBoundingClientRect(); |
| 104 | }, |
| 105 | } |
| 106 | : undefined; |
| 107 | const label = assistantCitationLabel(citation); |
| 108 | const sourceLinkProps = { |
| 109 | to: "/$environmentId/$threadId" as const, |
| 110 | params: { environmentId: citation.environmentId, threadId: citation.threadId }, |
| 111 | hash: assistantCitationHash(citation), |
| 112 | "data-markdown-copy": serializeAssistantCitation(citation), |
| 113 | resetScroll: false, |
| 114 | onClick: (event: ReactMouseEvent<HTMLAnchorElement>) => { |
| 115 | if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { |
| 116 | return; |
| 117 | } |
| 118 | event.preventDefault(); |
| 119 | void navigate(assistantCitationNavigation(citation)); |
| 120 | }, |
| 121 | }; |
| 122 | const composerSourceLink = ( |
| 123 | <Link |
| 124 | {...sourceLinkProps} |
| 125 | className="inline-flex h-full min-w-0 items-center gap-[0.33em] rounded-sm text-inherit no-underline focus-visible:outline-2 focus-visible:outline-foreground" |
| 126 | aria-label={`View cited assistant text: ${label}`} |
| 127 | > |
| 128 | <QuoteIcon aria-hidden="true" /> |
| 129 | <ContextChipLabel className="max-w-[16em]">{label}</ContextChipLabel> |
| 130 | </Link> |
| 131 | ); |
| 132 | if (!composer) { |
| 133 | return ( |
| 134 | <ContextChipPopover |
| 135 | kind="citation" |
| 136 | icon={<QuoteIcon />} |
| 137 | label={label} |
| 138 | accessibleLabel={`Quoted assistant text: ${label}`} |
| 139 | copyMarkdown={serializeAssistantCitation(citation)} |
| 140 | > |
| 141 | <div className="flex max-h-[calc(var(--available-height)_-_1rem_-_2px)] flex-col items-start gap-3 p-1 text-sm"> |
| 142 | <AssistantCitationQuote citation={citation} /> |
| 143 | <PopoverClose |
| 144 | render={<Button variant="outline" size="sm" render={<Link {...sourceLinkProps} />} />} |
| 145 | > |
| 146 | <ArrowUpRightIcon aria-hidden="true" /> |
| 147 | Go to source |
| 148 | </PopoverClose> |
| 149 | </div> |
| 150 | </ContextChipPopover> |
| 151 | ); |
| 152 | } |
| 153 | return ( |
| 154 | <ContextChip |
| 155 | kind="citation" |
| 156 | contentEditable={false} |
| 157 | data-assistant-citation-chip="true" |
| 158 | data-markdown-copy={serializeAssistantCitation(citation)} |
| 159 | > |
| 160 | {composerSourceLink} |
| 161 | {commentEditor ? ( |
| 162 | <Popover |
| 163 | open={commentEditor.open} |
| 164 | onOpenChange={(open, eventDetails) => { |
| 165 | if (!open && !settleDraftOnClose(eventDetails.reason)) { |
| 166 | eventDetails.cancel(); |
| 167 | return; |
| 168 | } |
| 169 | commentEditor.onOpenChange(open); |
| 170 | }} |
| 171 | > |
| 172 | <PopoverTrigger |
| 173 | aria-label={citation.comment ? "Edit citation comment" : "Add comment to citation"} |
| 174 | data-citation-comment-trigger="true" |
| 175 | render={<ContextChipAction />} |
| 176 | > |
| 177 | <PencilIcon aria-hidden="true" /> |
| 178 | </PopoverTrigger> |
| 179 | {commentEditor.open ? ( |
| 180 | <PopoverPopup |
| 181 | {...composerFloatingLayerProps} |
| 182 | side={activeSourceAnchor ? "bottom" : "top"} |
| 183 | align="end" |
| 184 | anchor={popupAnchor} |
| 185 | initialFocus={() => { |
| 186 | commentInputRef.current?.focus({ preventScroll: true }); |
| 187 | return false; |
| 188 | }} |
| 189 | finalFocus={ |
| 190 | commentEditor.onRestoreFocus |
| 191 | ? () => { |
| 192 | // Leave focus alone when the user closed the popover by moving to another control. |
| 193 | const activeElement = document.activeElement; |
| 194 | if ( |
| 195 | activeElement === document.body || |
| 196 | (activeElement !== null && commentPopupRef.current?.contains(activeElement)) |
| 197 | ) { |
| 198 | commentEditor.onRestoreFocus?.(); |
| 199 | } |
| 200 | return false; |
| 201 | } |
| 202 | : undefined |
| 203 | } |
| 204 | ref={commentPopupRef} |
| 205 | aria-label="Edit citation comment" |
| 206 | width="md" |
| 207 | padding="compact" |
| 208 | onPointerDown={(event) => event.stopPropagation()} |
| 209 | > |
| 210 | <AssistantCitationCommentEditor |
| 211 | key={serializeAssistantCitation(citation)} |
| 212 | citation={citation} |
| 213 | inputRef={commentInputRef} |
| 214 | onDraftChange={(comment) => { |
| 215 | draftCommentRef.current = comment; |
| 216 | commentEditor.onDraftChange?.(comment); |
| 217 | }} |
| 218 | onSubmit={(comment) => { |
| 219 | if (!commentEditor.onSave(comment)) return false; |
| 220 | commentEditor.onOpenChange(false); |
| 221 | return true; |
| 222 | }} |
| 223 | {...(commentEditor.onSaveAndSend |
| 224 | ? { |
| 225 | onSubmitAndSend: (comment: string) => { |
| 226 | if (!commentEditor.onSaveAndSend?.(comment)) return false; |
| 227 | commentEditor.onOpenChange(false); |
| 228 | return true; |
| 229 | }, |
| 230 | } |
| 231 | : {})} |
| 232 | onCancel={() => { |
| 233 | if (commentEditor.onCancel) { |
| 234 | commentEditor.onCancel(); |
| 235 | } else { |
| 236 | commentEditor.onOpenChange(false); |
| 237 | } |
| 238 | }} |
| 239 | /> |
| 240 | </PopoverPopup> |
| 241 | ) : null} |
| 242 | </Popover> |
| 243 | ) : null} |
| 244 | </ContextChip> |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) { |
| 249 | const [fade, setFade] = useState({ top: false, bottom: false }); |
| 250 | const updateFade = useCallback((element: HTMLElement) => { |
| 251 | const top = element.scrollTop > 1; |
| 252 | const bottom = element.scrollHeight - element.clientHeight - element.scrollTop > 1; |
| 253 | setFade((current) => |
| 254 | current.top === top && current.bottom === bottom ? current : { top, bottom }, |
| 255 | ); |
| 256 | }, []); |
| 257 | // Stable so a fade update during resize delivery does not resubscribe the element. |
| 258 | const observeFade = useCallback( |
| 259 | (element: HTMLDivElement | null) => |
| 260 | element ? observeResize(element, () => updateFade(element)) : undefined, |
| 261 | [updateFade], |
| 262 | ); |
| 263 | return ( |
| 264 | <div |
| 265 | ref={observeFade} |
| 266 | onScroll={(event) => updateFade(event.currentTarget)} |
| 267 | className={cn( |
| 268 | "max-h-64 min-h-0 space-y-3 self-stretch overflow-y-auto whitespace-pre-wrap wrap-break-word", |
| 269 | getVirtualizedScrollFadeClassName(fade), |
| 270 | )} |
| 271 | > |
| 272 | <blockquote className="border-l-2 border-border pl-3 text-muted-foreground"> |
| 273 | {citation.text} |
| 274 | </blockquote> |
| 275 | {citation.comment ? <p>{citation.comment}</p> : null} |
| 276 | </div> |
| 277 | ); |
| 278 | } |