Skip to content
Source
Sign inSign up
Open History to see the last commit

AssistantCitationChip.tsx

368 lines13.5 KB

1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import type { AssistantCitation, ResponseContextRecord } from "@t3tools/contracts";
3import {
4 assistantCitationLabel,
5 serializeAssistantCitation,
6} from "@t3tools/shared/assistantCitations";
7import { Link, useNavigate } from "@tanstack/react-router";
8import { PencilIcon, QuoteIcon } from "lucide-react";
9import {
10 useEffect,
11 useEffectEvent,
12 useRef,
13 useState,
14 type MouseEvent as ReactMouseEvent,
15 type ReactNode,
16} from "react";
17import {
18 findAssistantCitationSourceAnchor,
19 type AssistantCitationSourceAnchor,
20} from "~/lib/assistantTextSelection";
21import {
22 assistantCitationNavigation,
23 assistantCitationRoute,
24} from "../../lib/assistantCitationNavigation";
25import { cn } from "~/lib/utils";
26import { useQuietThreadShell } from "~/state/entities";
27import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip";
28import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
29import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor";
30import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal";
31import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";
32import { composerFloatingLayerProps } from "./composerEventScope";
33import { textDirection } from "~/textDirection";
34​
35function useCitationSourceLinkProps(citation: AssistantCitation) {
36 const navigate = useNavigate();
37 const sourceThread = useQuietThreadShell(
38 scopeThreadRef(citation.environmentId, citation.threadId),
39 );
40 return {
41 ...assistantCitationRoute(citation, sourceThread),
42 "data-markdown-copy": serializeAssistantCitation(citation),
43 onClick: (event: ReactMouseEvent<HTMLAnchorElement>) => {
44 if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
45 return;
46 }
47 event.preventDefault();
48 void navigate(assistantCitationNavigation(citation, sourceThread));
49 },
50 };
52​
53const flat = (text: string) => text.replace(/\s+/g, " ");
54​
55/**
56 * The card a response shows as inside a message. Clicking it goes to the source; a comment follows
57 * as text. `leading` and `trailing` say it starts or ends its block, which already has spacing.
58 */
59function ResponseCard({
60 citation,
61 copyMarkdown,
62 cardCopyMarkdown = copyMarkdown,
63 label,
64 title,
65 preview,
66 comment,
67 leading,
68 trailing,
69}: {
70 citation: AssistantCitation;
71 copyMarkdown: string;
72 cardCopyMarkdown?: string;
73 label: string;
74 title?: string | undefined;
75 preview: ReactNode;
76 comment?: string | undefined;
77 leading: boolean;
78 trailing: boolean;
79}) {
80 const linkProps = useCitationSourceLinkProps(citation);
81 return (
82 // One copy boundary for the card and its comment, so a copied card carries the comment once.
83 <span
84 data-markdown-copy={copyMarkdown}
85 className={cn("block", !leading && "mt-[0.65rem]", !trailing && "mb-[0.65rem]")}
86 >
87 <Link
88 {...linkProps}
89 // A selection inside the card's boundary copies the card here and the comment as text.
90 data-markdown-copy={cardCopyMarkdown}
91 // The response is findable in its own message; here it is clamped and mostly hidden.
92 data-thread-find-ignore
93 aria-label={label}
94 // The message's markdown styles colour and underline links; a card is neither.
95 className="block min-w-0 rounded-lg bg-black/10 px-2.5 py-1.5 text-start text-sm text-muted-foreground! no-underline! wrap-break-word hover:bg-black/14 focus-visible:outline-2 focus-visible:outline-ring dark:bg-black/35 dark:hover:bg-black/50"
96 >
97 {title ? (
98 <span className="block truncate text-xs font-medium text-foreground/80" dir="auto">
99 {title}
100 </span>
101 ) : null}
102 <span className="line-clamp-2" dir={textDirection(citation.text)}>
103 {preview}
104 </span>
105 </Link>
106 {comment ? (
107 <span className="mt-1.5 block whitespace-pre-wrap" dir={textDirection(comment)}>
108 {comment}
109 </span>
110 ) : null}
111 </span>
112 );
114​
115/** A passage quoted from a response, with its surrounding context and the user's comment. */
116export function AssistantCitationEmbed({
117 citation,
118 leading,
119 trailing,
120}: {
121 citation: AssistantCitation;
122 leading: boolean;
123 trailing: boolean;
124}) {
125 const wholeResponse = citation.prefix === "" && citation.suffix === "";
126 const { comment, ...quoted } = citation;
127 return (
128 <ResponseCard
129 citation={citation}
130 copyMarkdown={serializeAssistantCitation(citation)}
131 cardCopyMarkdown={serializeAssistantCitation(quoted)}
132 label={`Go to quoted response: ${assistantCitationLabel(quoted)}`}
133 comment={comment}
134 leading={leading}
135 trailing={trailing}
136 preview={
137 wholeResponse ? (
138 flat(citation.text)
139 ) : (
140 <>
141 {citation.prefix ? `…${flat(citation.prefix)}` : null}
142 <mark className="rounded-sm bg-foreground/12 px-0.5 text-foreground">
143 {flat(citation.text)}
144 </mark>
145 {citation.suffix ? `${flat(citation.suffix)}…` : null}
146 </>
147 )
148 }
149 />
150 );
152​
153/** The response a message replies to: its subagents, when it had any, and how it starts. */
154export function ResponseReplyCard({
155 record,
156 copyMarkdown,
157 leading,
158 trailing,
159}: {
160 record: ResponseContextRecord;
161 copyMarkdown: string;
162 leading: boolean;
163 trailing: boolean;
164}) {
165 return (
166 <ResponseCard
167 citation={record.citation}
168 copyMarkdown={copyMarkdown}
169 label={`Go to the response this replies to: ${record.label}`}
170 title={record.subagents?.map((subagent) => subagent.title).join(", ")}
171 preview={flat(record.citation.text)}
172 leading={leading}
173 trailing={trailing}
174 />
175 );
177​
178/** The citation chip in the composer, with its comment editor. */
179export function AssistantCitationChip({
180 citation,
181 commentEditor,
182}: {
183 citation: AssistantCitation;
184 commentEditor?: {
185 open: boolean;
186 sourceAnchor?: AssistantCitationSourceAnchor | undefined;
187 onOpenChange: (open: boolean) => void;
188 onCancel?: () => void;
189 onSave: (comment: string) => boolean;
190 onSaveAndSend?: (comment: string) => boolean;
191 onDraftChange?: (comment: string) => void;
192 onRestoreFocus?: () => void;
193 };
194}) {
195 const sourceLinkProps = useCitationSourceLinkProps(citation);
196 const commentInputRef = useRef<HTMLTextAreaElement>(null);
197 const commentPopupRef = useRef<HTMLDivElement>(null);
198 const draftCommentRef = useRef<string | null>(null);
199 const [voiceSetupOpen, setVoiceSetupOpen] = useState(false);
200 const [unavailableSourceAnchor, setUnavailableSourceAnchor] =
201 useState<AssistantCitationSourceAnchor | null>(null);
202 const commentOpen = commentEditor?.open ?? false;
203 const sourceAnchor = commentEditor?.sourceAnchor;
204 const activeSourceAnchor = sourceAnchor === unavailableSourceAnchor ? undefined : sourceAnchor;
205 // 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
206 useEffect(() => {
207 if (!commentOpen) draftCommentRef.current = null;
208 }, [commentOpen]);
209 const settleDraftOnClose = (reason: string): boolean => {
210 const dismissal = resolveAssistantCitationCommentDismissal({
211 reason,
212 draft: draftCommentRef.current,
213 savedComment: citation.comment,
214 });
215 if (dismissal.kind === "commit") return commentEditor?.onSave(dismissal.comment) ?? true;
216 return dismissal.kind !== "keep-open";
217 };
218 const onSourceUnavailable = useEffectEvent(() => {
219 if (!sourceAnchor) return;
220 if (settleDraftOnClose("none")) {
221 commentEditor?.onOpenChange(false);
222 } else {
223 setUnavailableSourceAnchor(sourceAnchor);
224 }
225 });
226 // 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
227 useEffect(() => {
228 if (!commentOpen || sourceAnchor === unavailableSourceAnchor) return;
229 const anchor = sourceAnchor ?? findAssistantCitationSourceAnchor(document, citation);
230 if (!anchor) return;
231 return observeAssistantCitationCommentSource({
232 anchor,
233 citation,
234 onUnavailable: onSourceUnavailable,
235 });
236 }, [citation, commentOpen, sourceAnchor, unavailableSourceAnchor]);
237 const popupAnchor = activeSourceAnchor
238 ? {
239 contextElement: activeSourceAnchor.source,
240 getBoundingClientRect: () => {
241 const rects = activeSourceAnchor.range.getClientRects();
242 const rect =
243 rects.item(rects.length - 1) ?? activeSourceAnchor.range.getBoundingClientRect();
244 // Code blocks and tables scroll sideways inside the message, so the cited
245 // text can end in their hidden overflow. Base UI only shifts the popup while
246 // it still touches the anchor, so clamp the anchor to the message's width.
247 const bounds = activeSourceAnchor.source.getBoundingClientRect();
248 const left = Math.min(Math.max(rect.left, bounds.left), bounds.right);
249 const right = Math.max(Math.min(rect.right, bounds.right), left);
250 return new DOMRect(left, rect.top, right - left, rect.height);
251 },
252 }
253 : undefined;
254 const label = assistantCitationLabel(citation);
255 const composerSourceLink = (
256 <Link
257 {...sourceLinkProps}
258 className="inline-flex min-w-0 items-center gap-[0.2em] self-baseline text-inherit no-underline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current rounded-sm"
259 aria-label={`View cited assistant text: ${label}`}
260 >
261 <QuoteIcon aria-hidden="true" />
262 <ContextChipLabel className="max-w-[16em]" dir={textDirection(label)}>
263 {label}
264 </ContextChipLabel>
265 </Link>
266 );
267 return (
268 <ContextChip
269 kind="citation"
270 contentEditable={false}
271 data-assistant-citation-chip="true"
272 data-markdown-copy={serializeAssistantCitation(citation)}
273 >
274 {composerSourceLink}
275 {commentEditor ? (
276 <Popover
277 open={commentEditor.open && !voiceSetupOpen}
278 onOpenChange={(open, eventDetails) => {
279 if (!open && !settleDraftOnClose(eventDetails.reason)) {
280 eventDetails.cancel();
281 return;
282 }
283 commentEditor.onOpenChange(open);
284 }}
285 >
286 <PopoverTrigger
287 aria-label={citation.comment ? "Edit citation comment" : "Add comment to citation"}
288 data-citation-comment-trigger="true"
289 render={<ContextChipAction />}
290 >
291 <PencilIcon aria-hidden="true" />
292 </PopoverTrigger>
293 {commentEditor.open ? (
294 <PopoverPopup
295 // The editor stays mounted while its voice setup dialog replaces the popover.
296 keepMounted
297 {...composerFloatingLayerProps}
298 side={activeSourceAnchor ? "bottom" : "top"}
299 align="end"
300 anchor={popupAnchor}
301 initialFocus={() => {
302 const input = commentInputRef.current;
303 if (input) {
304 input.focus({ preventScroll: true });
305 input.setSelectionRange(input.value.length, input.value.length);
306 // Moving the caret doesn't scroll a long comment to it.
307 input.scrollTop = input.scrollHeight;
308 }
309 return false;
310 }}
311 finalFocus={
312 commentEditor.onRestoreFocus
313 ? () => {
314 const activeElement = document.activeElement;
315 if (
316 activeElement === document.body ||
317 (activeElement !== null && commentPopupRef.current?.contains(activeElement))
318 ) {
319 commentEditor.onRestoreFocus?.();
320 }
321 return false;
322 }
323 : undefined
324 }
325 ref={commentPopupRef}
326 aria-label="Edit citation comment"
327 width="md"
328 padding="compact"
329 onPointerDown={(event) => event.stopPropagation()}
330 >
331 <AssistantCitationCommentEditor
332 key={serializeAssistantCitation(citation)}
333 citation={citation}
334 inputRef={commentInputRef}
335 onVoiceSetupOpenChange={setVoiceSetupOpen}
336 onDraftChange={(comment) => {
337 draftCommentRef.current = comment;
338 commentEditor.onDraftChange?.(comment);
339 }}
340 onSubmit={(comment) => {
341 if (!commentEditor.onSave(comment)) return false;
342 commentEditor.onOpenChange(false);
343 return true;
344 }}
345 {...(commentEditor.onSaveAndSend
346 ? {
347 onSubmitAndSend: (comment: string) => {
348 if (!commentEditor.onSaveAndSend?.(comment)) return false;
349 commentEditor.onOpenChange(false);
350 return true;
351 },
352 }
353 : {})}
354 onCancel={() => {
355 if (commentEditor.onCancel) {
356 commentEditor.onCancel();
357 } else {
358 commentEditor.onOpenChange(false);
359 }
360 }}
361 />
362 </PopoverPopup>
363 ) : null}
364 </Popover>
365 ) : null}
366 </ContextChip>
367 );