Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/components/ToolLayout.tsx

tsx125 行查看原文
// Adapted from ZCode ToolLayout/ToolSummaryRow and collapsible.tsx (Apache-2.0).
import * as Collapsible from "@radix-ui/react-collapsible";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { QueuedSummaryContent } from "./QueuedSummaryContent.tsx";

export function ToolLayout({
  icon: Icon,
  label,
  kindDetail,
  summaryAction,
  summaryLabel,
  running,
  showIcon = true,
  expanded,
  toggle,
  content,
  primary,
  secondary,
  trailing,
  failure,
  separator,
  contentKey,
  animate = false,
  title,
  prioritize = false,
}: {
  icon: LucideIcon;
  label: string;
  kindDetail?: ReactNode;
  summaryAction?: () => void;
  summaryLabel?: string;
  running: boolean;
  showIcon?: boolean;
  expanded: boolean;
  toggle?: () => void;
  content?: ReactNode;
  primary?: ReactNode;
  secondary?: ReactNode;
  trailing?: ReactNode;
  failure?: ReactNode;
  separator?: string;
  contentKey?: string;
  animate?: boolean;
  title?: string;
  prioritize?: boolean;
}) {
  const [renderContent, setRenderContent] = useState(expanded);
  useEffect(() => {
    if (expanded) {
      setRenderContent(true);
      return;
    }
    if (!renderContent) return;
    // ZCode 的关闭动画须保留真实高度,300ms 后再卸载详情。
    const timer = setTimeout(() => setRenderContent(false), 300);
    return () => clearTimeout(timer);
  }, [expanded, renderContent]);
  const summaryContent = (
    <>
      {showIcon && <Icon size={16} aria-hidden="true" className="process-icon" />}
      <strong className={`tool-kind-label${running ? " thinking-label-streaming" : ""}`}>{label}</strong>
      {kindDetail}
      {(primary || secondary || trailing || failure) && (
        <div className="tool-summary-content">
          {separator && <span className="process-separator">{separator}</span>}
          <QueuedSummaryContent
            contentKey={contentKey ?? "summary"}
            primaryText={primary}
            secondaryText={secondary}
            trailingText={trailing}
            enabled={animate && !expanded}
          />
          {failure}
        </div>
      )}
      {toggle && (
        <ChevronRight
          size={16}
          aria-hidden="true"
          className={`process-chevron ${expanded ? "rotated" : ""}`}
        />
      )}
    </>
  );
  const summaryClass = `tool-summary-row ${toggle || summaryAction ? "tool-title" : "tool-summary-static"}${prioritize ? " tool-prioritize-file" : ""}`;
  const summary =
    toggle || summaryAction ? (
      // biome-ignore lint/a11y/useSemanticElements: File buttons inside the summary must not nest within a button.
      <div
        className={summaryClass}
        data-testid="tool-summary"
        title={title}
        role="button"
        tabIndex={0}
        aria-expanded={toggle ? expanded : undefined}
        aria-label={summaryLabel ?? (expanded ? "收起工具详情" : "展开工具详情")}
        onClick={summaryAction ?? toggle}
        onKeyDown={(event) => {
          if (event.target !== event.currentTarget || !["Enter", " "].includes(event.key)) return;
          event.preventDefault();
          (summaryAction ?? toggle)?.();
        }}
      >
        {summaryContent}
      </div>
    ) : (
      <div className={summaryClass} data-testid="tool-summary" title={title}>
        {summaryContent}
      </div>
    );
  return (
    <Collapsible.Root open={expanded} className="tool-layout">
      {summary}
      {toggle && (
        <Collapsible.Content className="tool-content-shell">
          <div className="tool-content-fade">
            <div className="tool-content-spacing">{expanded || renderContent ? content : null}</div>
          </div>
        </Collapsible.Content>
      )}
    </Collapsible.Root>
  );
}