199 lines · 7.6 KB · TypeScript
RawDownload
import type { AssistantCitation } from "@t3tools/contracts";import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";import { Link, useNavigate } from "@tanstack/react-router";import { PencilIcon, QuoteIcon, XIcon } from "lucide-react";import { useEffect, useEffectEvent, useRef, type MouseEvent as ReactMouseEvent } from "react";import {  findAssistantCitationSourceAnchor,  type AssistantCitationSourceAnchor,} from "~/lib/assistantTextSelection";import { cn } from "~/lib/utils";import {  assistantCitationHash,  assistantCitationNavigation,} from "../../lib/assistantCitationNavigation";import {  CHAT_INLINE_CHIP_CLASS_NAME,  COMPOSER_INLINE_CHIP_CLASS_NAME,  COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME,  COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,  COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,} from "../composerInlineChip";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor";import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";import { composerFloatingLayerProps } from "./composerEventScope"; const CITATION_ACTION_BUTTON_CLASS_NAME = cn(  COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME,  "text-primary/80 hover:bg-primary/10 hover:text-primary",); export function AssistantCitationChip({  citation,  onRemove,  commentEditor,}: {  citation: AssistantCitation;  onRemove?: () => void;  commentEditor?: {    open: boolean;    sourceAnchor?: AssistantCitationSourceAnchor | undefined;    onOpenChange: (open: boolean) => void;    onCancel?: () => void;    onSave: (comment: string) => boolean;    onSaveAndSend?: (comment: string) => boolean;  };}) {  const navigate = useNavigate();  const commentInputRef = useRef<HTMLTextAreaElement>(null);  const commentOpen = commentEditor?.open ?? false;  const sourceAnchor = commentEditor?.sourceAnchor;  const onSourceUnavailable = useEffectEvent(() => {    if (sourceAnchor) commentEditor?.onOpenChange(false);  });  useEffect(() => {    if (!commentOpen) return;    const anchor = sourceAnchor ?? findAssistantCitationSourceAnchor(document, citation);    if (!anchor) return;    return observeAssistantCitationCommentSource({      anchor,      citation,      onUnavailable: onSourceUnavailable,    });  }, [citation, commentOpen, sourceAnchor]);  // A multi-line selection's bounding box spans the full message width; anchor  // the bubble to the selection's last line, where the pointer released.  const popupAnchor = sourceAnchor    ? {        contextElement: sourceAnchor.source,        getBoundingClientRect: () => {          const rects = sourceAnchor.range.getClientRects();          return rects.item(rects.length - 1) ?? sourceAnchor.range.getBoundingClientRect();        },      }    : undefined;  const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " ");  const label = preview.length > 64 ? `${preview.slice(0, 64)}…` : preview;  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<HTMLAnchorElement>) => {      if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {        return;      }      event.preventDefault();      void navigate(assistantCitationNavigation(citation));    },  };  const composerSourceLink = (    <Link      {...sourceLinkProps}      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-primary"      aria-label={`View cited assistant text: ${label}`}    >      <QuoteIcon aria-hidden="true" className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} />      <span className={cn(COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, "max-w-[16em]")}>{label}</span>    </Link>  );  const chatSourceLink = (    <Link      {...sourceLinkProps}      className="inline-flex h-full min-w-0 items-center gap-[0.33em] rounded-sm text-inherit no-underline hover:bg-primary/10 focus-visible:outline-2 focus-visible:outline-primary"      aria-label={`View cited assistant text: ${label}`}    >      <QuoteIcon aria-hidden="true" className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} />      <span className={cn(COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, "max-w-[16em]")}>{label}</span>    </Link>  );  return (    <span      className={cn(        onRemove ? COMPOSER_INLINE_CHIP_CLASS_NAME : CHAT_INLINE_CHIP_CLASS_NAME,        "border-primary/20 bg-primary/8 text-primary",      )}      contentEditable={false}      data-assistant-citation-chip="true"      data-markdown-copy={serializeAssistantCitation(citation)}    >      {onRemove ? (        composerSourceLink      ) : (        <Tooltip>          <TooltipTrigger render={chatSourceLink} />          <TooltipPopup side="top">View source</TooltipPopup>        </Tooltip>      )}      {commentEditor ? (        <Popover open={commentEditor.open} onOpenChange={commentEditor.onOpenChange}>          <PopoverTrigger            aria-label={citation.comment ? "Edit citation comment" : "Add comment to citation"}            className={CITATION_ACTION_BUTTON_CLASS_NAME}          >            <PencilIcon aria-hidden="true" className="size-[0.85em]" />          </PopoverTrigger>          {commentEditor.open ? (            <PopoverPopup              {...composerFloatingLayerProps}              side={sourceAnchor ? "bottom" : "top"}              align="end"              anchor={popupAnchor}              initialFocus={() => {                commentInputRef.current?.focus({ preventScroll: true });                return false;              }}              aria-label="Edit citation comment"              className="w-72 max-w-[calc(100vw-1rem)]"              viewportClassName="p-3"              onPointerDown={(event) => event.stopPropagation()}            >              <AssistantCitationCommentEditor                key={serializeAssistantCitation(citation)}                citation={citation}                inputRef={commentInputRef}                onSubmit={(comment) => {                  if (!commentEditor.onSave(comment)) return false;                  commentEditor.onOpenChange(false);                  return true;                }}                {...(commentEditor.onSaveAndSend                  ? {                      onSubmitAndSend: (comment: string) => {                        if (!commentEditor.onSaveAndSend?.(comment)) return false;                        commentEditor.onOpenChange(false);                        return true;                      },                    }                  : {})}                onCancel={() => {                  if (commentEditor.onCancel) {                    commentEditor.onCancel();                  } else {                    commentEditor.onOpenChange(false);                  }                }}              />            </PopoverPopup>          ) : null}        </Popover>      ) : null}      {onRemove ? (        <button          type="button"          onClick={onRemove}          aria-label="Remove assistant citation"          className={cn(            COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME,            "text-primary/85 hover:bg-primary/10 hover:text-primary",          )}        >          <XIcon aria-hidden="true" className="size-[0.85em]" />        </button>      ) : null}    </span>  );}