1import type { AssistantCitation } from "@t3tools/contracts";
2import {
3 assistantCitationLabel,
4 serializeAssistantCitation,
5} from "@t3tools/shared/assistantCitations";
6import { Link, useNavigate } from "@tanstack/react-router";
7import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react";
8import {
9 useCallback,
10 useEffect,
11 useEffectEvent,
12 useRef,
13 useState,
14 type MouseEvent as ReactMouseEvent,
15} from "react";
16import {
17 findAssistantCitationSourceAnchor,
18 type AssistantCitationSourceAnchor,
19} from "~/lib/assistantTextSelection";
20import {
21 assistantCitationHash,
22 assistantCitationNavigation,
23} from "../../lib/assistantCitationNavigation";
24import { cn } from "~/lib/utils";
25import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip";
26import { ContextChipPopover } from "../contextChipParts";
27import { Button } from "../ui/button";
28import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover";
29import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
30import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor";
31import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal";
32import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";
33import { composerFloatingLayerProps } from "./composerEventScope";
34import { observeResize } from "~/lib/observeResize";
35
36export 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
248function 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}