Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/components/ConversationSelections.tsx

tsx495 行查看原文
// Adapted from ZCode selection guard/menu and ContextAttachmentPill (Apache-2.0).
import * as HoverCard from "@radix-ui/react-hover-card";
import * as Popover from "@radix-ui/react-popover";
import { Code2, MessageSquareText, Quote, Trash2, X } from "lucide-react";
import type { RefObject } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { type ConversationSelection, getFileCommentSelection } from "../conversation-selections.ts";
import type { FileLocation } from "../link-target.ts";

interface SelectionReferenceChipProps {
  references: ConversationSelection[];
  onChange?: (value: ConversationSelection[]) => void;
  onFile?: (path: string, location?: FileLocation) => void;
  onCommentsEmpty?: () => void;
}
export function SelectionReferenceChip({
  references,
  onChange,
  onFile,
  onCommentsEmpty,
}: SelectionReferenceChipProps) {
  const comments = references.filter((reference) => getFileCommentSelection(reference));
  const quotes = references.filter((reference) => !getFileCommentSelection(reference));
  const changeGroup = (group: ConversationSelection[]) =>
    onChange
      ? (remaining: ConversationSelection[]) => {
          onChange(references.filter((item) => !group.includes(item) || remaining.includes(item)));
          if (group === comments && !remaining.length) onCommentsEmpty?.();
        }
      : undefined;
  return (
    <>
      <SelectionGroupChip references={comments} onChange={changeGroup(comments)} onFile={onFile} comments />
      <SelectionGroupChip references={quotes} onChange={changeGroup(quotes)} />
    </>
  );
}
export function SelectionReferenceDetails({
  references,
  onChange,
  onFile,
  comments = false,
}: SelectionReferenceChipProps & { comments?: boolean }) {
  return (
    <>
      {references.map((reference, index) => {
        const comment = comments ? getFileCommentSelection(reference) : null;
        const content = comment ? (
          <>
            <div className="file-comment-heading">
              <strong>{comment.title}</strong>
              <span>{comment.lineLabel}</span>
            </div>
            <small title={comment.path}>{comment.path}</small>
            {comment.comment.trim() && <p>{comment.comment}</p>}
          </>
        ) : (
          <>
            <p>{reference.text}</p>
            {(reference.path || reference.sourceTitle) && (
              <small>{reference.path || reference.sourceTitle}</small>
            )}
          </>
        );
        return (
          <div
            className={`selection-reference-detail${comment ? " file-comment-detail" : ""}`}
            key={reference.id ?? index}
          >
            {comment ? <Code2 size={16} /> : <Quote size={16} />}
            {comment && onFile ? (
              <button
                type="button"
                className="file-comment-source"
                aria-label={`打开 ${comment.title} ${comment.lineLabel}`}
                onClick={() => onFile(comment.path, { line: comment.start, endLine: comment.end })}
              >
                {content}
              </button>
            ) : (
              <div>{content}</div>
            )}
            {onChange && (
              <button
                type="button"
                className="selection-remove-one"
                aria-label={comment ? "移除评论" : "移除对话引用"}
                onClick={(event) => {
                  event.stopPropagation();
                  onChange(references.filter((_, i) => i !== index));
                }}
              >
                <Trash2 size={14} />
              </button>
            )}
          </div>
        );
      })}
    </>
  );
}
function SelectionGroupChip({
  references,
  onChange,
  onFile,
  comments = false,
}: SelectionReferenceChipProps & { comments?: boolean }) {
  const [open, setOpen] = useState(false);
  const interactive = useRef(false);
  const trigger = useRef<HTMLDivElement>(null);
  const returnFocus = useRef(false);
  const content = useRef<HTMLDivElement>(null);
  const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const cancelClose = () => clearTimeout(closeTimer.current);
  const hover = () => {
    cancelClose();
    if (!open) interactive.current = false;
    setOpen(true);
  };
  const leave = () => {
    cancelClose();
    closeTimer.current = setTimeout(() => {
      if (!interactive.current) setOpen(false);
    }, 100);
  };
  const activate = () => {
    cancelClose();
    if (open && interactive.current) {
      setOpen(false);
      return;
    }
    interactive.current = true;
    setOpen(true);
    // Hover may already have mounted the popover without moving focus.
    content.current?.querySelector<HTMLElement>("button:not(:disabled),[tabindex='0']")?.focus();
  };
  useEffect(() => () => clearTimeout(closeTimer.current), []);
  useEffect(() => {
    if (!references.length) setOpen(false);
  }, [references.length]);
  if (!references.length) return null;
  const label = comments
    ? `${references.length} 条评论`
    : references.length === 1 && references[0].path
      ? `${references[0].path.split(/[\\/]/).pop()} · 引用`
      : `${references.length} 条${references.some((ref) => ref.path) ? "引用" : "对话引用"}`;
  const Card = comments ? Popover : HoverCard;
  return (
    <Card.Root open={open} onOpenChange={setOpen} {...(!comments ? { openDelay: 0, closeDelay: 0 } : {})}>
      <Card.Trigger asChild>
        {/* biome-ignore lint/a11y/useSemanticElements: Focusable pill with a separate remove button, matching ZCode. */}
        <div
          ref={trigger}
          className="selection-reference-chip"
          role="button"
          tabIndex={0}
          aria-label={label}
          data-conversation-selection-reference-count={comments ? undefined : references.length}
          data-file-comment-count={comments ? references.length : undefined}
          onPointerEnter={(event) => {
            if (comments && event.pointerType !== "touch") hover();
          }}
          onPointerLeave={comments ? leave : undefined}
          onClick={(event) => {
            if (comments) {
              event.preventDefault();
              activate();
            } else setOpen((value) => !value);
          }}
          onKeyDown={(event) => {
            if (event.target !== event.currentTarget) return;
            if (event.key === "Enter" || event.key === " ") {
              event.preventDefault();
              if (comments) activate();
              else setOpen((value) => !value);
            } else if (event.key === "Escape") setOpen(false);
          }}
        >
          {comments ? <MessageSquareText size={16} /> : <Quote size={16} />}
          <span>{label}</span>
          {onChange && (
            <button
              type="button"
              className="selection-remove-all"
              aria-label={comments ? "移除全部评论" : "移除对话引用"}
              onClick={(event) => {
                event.stopPropagation();
                onChange([]);
                setOpen(false);
              }}
            >
              <X size={14} />
            </button>
          )}
        </div>
      </Card.Trigger>
      <Card.Portal>
        <Card.Content
          ref={content}
          onPointerEnter={comments ? cancelClose : undefined}
          onPointerLeave={comments ? leave : undefined}
          {...(comments
            ? {
                onFocusCapture: () => {
                  interactive.current = true;
                },
                onOpenAutoFocus: (event: Event) => {
                  if (!interactive.current) event.preventDefault();
                },
                onEscapeKeyDown: () => {
                  returnFocus.current = interactive.current;
                },
                onCloseAutoFocus: (event: Event) => {
                  if (returnFocus.current) {
                    // Focusing a hover-open trigger counts as outside interaction to Radix.
                    event.preventDefault();
                    trigger.current?.focus();
                  } else if (!interactive.current) event.preventDefault();
                  returnFocus.current = false;
                },
              }
            : {})}
          role="dialog"
          aria-label={comments ? "评论内容" : "引用内容"}
          className="selection-reference-popover"
          side="top"
          sideOffset={4}
          align={onChange ? "start" : "end"}
          collisionPadding={12}
        >
          <SelectionReferenceDetails
            references={references}
            onChange={
              onChange && comments
                ? (remaining) => {
                    setOpen(false);
                    onChange(remaining);
                  }
                : onChange
            }
            onFile={onFile}
            comments={comments}
          />
        </Card.Content>
      </Card.Portal>
    </Card.Root>
  );
}
interface SelectionState {
  text: string;
  sourceKey: string;
  contentType: ConversationSelection["contentType"];
  path?: string;
  sourceTitle?: string;
  top: number;
  bottom: number;
  center: number;
}
export function ConversationSelectionMenu({
  rootRef,
  scopeKey,
  onAdd,
  addLabel = "添加到当前任务",
  onSideChat,
  sideChatDisabled,
  sideChatDisabledTitle = "请先处理辅助对话中的待处理请求。",
}: {
  rootRef: RefObject<HTMLElement | null>;
  scopeKey: string;
  onAdd?: (reference: ConversationSelection) => void;
  addLabel?: string;
  onSideChat?: (reference: ConversationSelection) => void;
  sideChatDisabled?: boolean;
  sideChatDisabledTitle?: string;
}) {
  const [snapshot, setSnapshot] = useState<{ scopeKey: string; selection: SelectionState }>();
  const menu = useRef<HTMLDivElement>(null);
  const frame = useRef(0);
  // Streaming can replace the action callback without changing selection availability or scope.
  const enabled = Boolean(onAdd);
  const close = useCallback(() => {
    cancelAnimationFrame(frame.current);
    setSnapshot(undefined);
  }, []);
  useEffect(() => {
    close();
    const root = rootRef.current;
    if (!root || !enabled) return;
    const element = (node: Node) => (node instanceof Element ? node : node.parentElement);
    // 正文链接用按钮拦截原生跳转,但其文字仍属于消息选区,不能按操作控件排除。
    const excluded =
      "button:not([data-conversation-inline-link]),input,textarea,[role=button]:not([data-conversation-inline-link]),[role=dialog],[contenteditable=true],[data-conversation-selection-tooltip]";
    const dismiss = () => {
      close();
      const selected = window.getSelection();
      if (selected?.rangeCount && root.contains(selected.getRangeAt(0).commonAncestorContainer))
        selected.removeAllRanges();
    };
    const leaveSelection = (event: Event) => {
      const target = event.target instanceof Node ? element(event.target) : null;
      if (target?.closest("[data-conversation-selection-tooltip]")) return;
      if (
        !target ||
        !root.contains(target) ||
        !target.closest("[data-conversation-selectable]") ||
        target.closest(excluded)
      )
        dismiss();
    };
    const inspect = () => {
      const selected = window.getSelection();
      if (!selected || selected.isCollapsed || selected.rangeCount !== 1) {
        close();
        return;
      }
      const range = selected.getRangeAt(0);
      const start = element(range.startContainer),
        end = element(range.endContainer);
      const region = start?.closest<HTMLElement>("[data-conversation-selectable]");
      if (
        !region ||
        region !== end?.closest("[data-conversation-selectable]") ||
        !root.contains(region) ||
        start?.closest(excluded) ||
        end?.closest(excluded)
      ) {
        close();
        return;
      }
      const text = selected.toString().trim(),
        rect = range.getBoundingClientRect(),
        viewport = root.getBoundingClientRect();
      if (
        !text ||
        (!rect.width && !rect.height) ||
        rect.bottom <= Math.max(0, viewport.top) ||
        rect.top >= Math.min(window.innerHeight, viewport.bottom) ||
        rect.right <= Math.max(0, viewport.left) ||
        rect.left >= Math.min(window.innerWidth, viewport.right)
      ) {
        close();
        return;
      }
      setSnapshot({
        scopeKey,
        selection: {
          text,
          sourceKey: region.dataset.selectionKey ?? scopeKey,
          contentType: region.dataset.conversationSelectable as SelectionState["contentType"],
          path: region.dataset.selectionPath,
          sourceTitle:
            region.dataset.selectionTitle ??
            (
              { user: "用户消息", assistant: "助手消息", reasoning: "推理内容", tool: "工具结果" } as Record<
                string,
                string
              >
            )[region.dataset.conversationSelectable ?? ""],
          top: rect.top,
          bottom: rect.bottom,
          center: rect.left + rect.width / 2,
        },
      });
    };
    const schedule = (event: Event) => {
      const target = event.target instanceof Node ? element(event.target) : null;
      if (!target?.closest("[data-conversation-selectable]") || target.closest(excluded)) {
        close();
        return;
      }
      cancelAnimationFrame(frame.current);
      frame.current = requestAnimationFrame(inspect);
    };
    const key = (event: KeyboardEvent) => {
      if (event.key === "Escape") close();
      else if (
        (event.shiftKey &&
          /^(ArrowLeft|ArrowRight|ArrowUp|ArrowDown|Home|End|PageUp|PageDown)$/.test(event.key)) ||
        ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a")
      ) {
        cancelAnimationFrame(frame.current);
        frame.current = requestAnimationFrame(inspect);
      }
    };
    const changed = () => {
      if (window.getSelection()?.isCollapsed) close();
    };
    // Scrolling must follow the selection instead of closing: streaming
    // stick-to-bottom fires scroll events while the user is still reading.
    const followScroll = () => {
      cancelAnimationFrame(frame.current);
      frame.current = requestAnimationFrame(inspect);
    };
    root.addEventListener("mouseup", schedule);
    root.addEventListener("touchend", schedule);
    root.addEventListener("scroll", followScroll, true);
    document.addEventListener("pointerdown", leaveSelection, true);
    document.addEventListener("focusin", leaveSelection);
    document.addEventListener("keyup", key);
    document.addEventListener("selectionchange", changed);
    window.addEventListener("resize", close);
    window.addEventListener("blur", dismiss);
    return () => {
      cancelAnimationFrame(frame.current);
      root.removeEventListener("mouseup", schedule);
      root.removeEventListener("touchend", schedule);
      root.removeEventListener("scroll", followScroll, true);
      document.removeEventListener("pointerdown", leaveSelection, true);
      document.removeEventListener("focusin", leaveSelection);
      document.removeEventListener("keyup", key);
      document.removeEventListener("selectionchange", changed);
      window.removeEventListener("resize", close);
      window.removeEventListener("blur", dismiss);
    };
  }, [rootRef, scopeKey, enabled, close]);
  const state = snapshot?.scopeKey === scopeKey ? snapshot.selection : undefined;
  useLayoutEffect(() => {
    const element = menu.current;
    if (!state || !element) return;
    const position = () => {
      const rect = element.getBoundingClientRect();
      element.style.left = `${Math.max(12, Math.min(window.innerWidth - rect.width - 12, state.center - rect.width / 2))}px`;
      const top = state.top - rect.height - 8 >= 12 ? state.top - rect.height - 8 : state.bottom + 8;
      element.style.top = `${Math.max(12, Math.min(window.innerHeight - rect.height - 12, top))}px`;
    };
    position();
    const observer = new ResizeObserver(position);
    observer.observe(element);
    return () => observer.disconnect();
  }, [state]);
  return state && onAdd
    ? createPortal(
        <div
          role="toolbar"
          aria-label="选文操作"
          ref={menu}
          className="conversation-selection-menu"
          data-conversation-selection-tooltip="true"
          onPointerDown={(event) => event.preventDefault()}
          onMouseDown={(event) => event.preventDefault()}
        >
          {state.text.length > 8000 ? (
            <span>单条引用最多 8,000 个字符。</span>
          ) : (
            <button
              type="button"
              data-conversation-selection-action="add-to-task"
              onClick={() => {
                window.getSelection()?.removeAllRanges();
                onAdd({
                  id: crypto.randomUUID(),
                  text: state.text,
                  sourceKey: state.sourceKey,
                  contentType: state.contentType,
                  path: state.path,
                  sourceTitle: state.sourceTitle,
                });
                close();
              }}
            >
              {addLabel}
            </button>
          )}
          {onSideChat && state.text.length <= 8000 && (
            <>
              <div className="conversation-selection-divider" aria-hidden="true" />
              <button
                type="button"
                data-conversation-selection-action="ask-in-side-chat"
                disabled={sideChatDisabled}
                title={sideChatDisabled ? sideChatDisabledTitle : undefined}
                onClick={() => {
                  window.getSelection()?.removeAllRanges();
                  onSideChat({
                    id: crypto.randomUUID(),
                    text: state.text,
                    sourceKey: state.sourceKey,
                    contentType: state.contentType,
                    path: state.path,
                    sourceTitle: state.sourceTitle,
                  });
                  close();
                }}
              >
                在辅助对话中提问
              </button>
            </>
          )}
        </div>,
        document.body,
      )
    : null;
}