trove/cn

Tool Run

A single tool call's lifecycle — queued, running, paused on approval, and resolved — with arguments and results one click away.

Basic

A queued call runs and settles into a readable result behind disclosure.

search_docs: Queued
search_docs · query: "reduced motion"
basic

Needs approval

An irreversible call hands its card body to an embedded ApprovalRequest, then resumes on approval.

issue_refund: needs approval

Requested by Support Agent · step 2 of 3

Irreversible

Issue a $84.00 refund for order #4821

needs-approval

Error and retry

A failed call renders an anchored inline error with Retry — never a toast.

deploy_preview: Running
deploy_preview · branch: feat/tool-run
error-recovery

Install

Install the packages this component imports.

bun add lucide-react motion
Terminal

Also requires lib/utils.ts, lib/springs.ts (see Installation), and Button primitive, Approval Request primitive.

Source

Copy and paste the following code into your project.

"use client";

import { useId, useState, type ReactNode } from "react";
import {
  AlertTriangleIcon,
  CheckIcon,
  ChevronDownIcon,
  Loader2Icon,
  RotateCcwIcon,
  XIcon,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

import {
  ApprovalRequest,
  type ApprovalRequestProps,
} from "@/components/trovecn/ai-workbench/approval-request";
import { Button } from "@/components/ui/button";
import { spring } from "@/lib/springs";
import { cn } from "@/lib/utils";

export type ToolRunStatus =
  | "queued"
  | "running"
  | "needs-approval"
  | "success"
  | "error"
  | "cancelled";

export interface ToolRunDetail {
  /** Compact key:value call arguments, rendered in mono. */
  args?: readonly ReactNode[];
  /** The call's result, once it has one — body copy, not a key:value list. */
  result?: ReactNode;
}

export interface ToolRunProps {
  /** Stable identifier for the call. Not rendered — for the caller's own keys/lookups. */
  id: string;
  /** The tool being called, e.g. "search_docs" or "Read file". */
  tool: ReactNode;
  /** A short call summary next to the tool name — an argument preview, not the full arguments. */
  summary?: ReactNode;
  /** Caller-owned source of truth. This component renders it; it never sets it itself. */
  status: ToolRunStatus;
  detail?: ToolRunDetail;
  /** Trailing metadata — elapsed time, a timestamp. */
  meta?: ReactNode;
  errorMessage?: ReactNode;
  /** A rejected retry re-renders the same anchored error — never a toast. */
  onRetry?: () => void | Promise<void>;
  /**
   * Required while status is "needs-approval" — the props ToolRun forwards
   * to an embedded ApprovalRequest, whose card body replaces ToolRun's own
   * for the duration of the pause. `variant` and `className` are owned by
   * ToolRun and can't be overridden here.
   */
  approval?: Omit<ApprovalRequestProps, "variant" | "className">;
  className?: string;
}

type StatusTone = "muted" | "success" | "destructive";

const STATUS_COPY: Record<
  Exclude<ToolRunStatus, "needs-approval">,
  { label: string; tone: StatusTone }
> = {
  queued: { label: "Queued", tone: "muted" },
  running: { label: "Running", tone: "muted" },
  success: { label: "Done", tone: "success" },
  error: { label: "Failed", tone: "destructive" },
  cancelled: { label: "Cancelled", tone: "muted" },
};

function ToolRunStatusIcon({
  status,
  reduceMotion,
}: {
  status: ToolRunStatus;
  reduceMotion: boolean;
}) {
  switch (status) {
    case "queued":
      return <span className="size-1.5 rounded-full bg-muted-foreground" />;
    case "running":
      return (
        <Loader2Icon
          className={cn("size-3.5 text-muted-foreground", !reduceMotion && "animate-spin")}
        />
      );
    case "success":
      return <CheckIcon className="size-3.5 text-success" />;
    case "error":
      return <AlertTriangleIcon className="size-3.5 text-destructive" />;
    case "cancelled":
      return <XIcon className="size-3.5 text-muted-foreground" />;
    case "needs-approval":
      return null;
  }
}

function ToolRunDisclosure({ detail, showResult }: { detail: ToolRunDetail; showResult: boolean }) {
  const [isOpen, setIsOpen] = useState(false);
  const contentId = useId();
  const reduceMotion = useReducedMotion();

  const hasArgs = Boolean(detail.args && detail.args.length > 0);
  const hasResult = showResult && Boolean(detail.result);
  if (!hasArgs && !hasResult) return null;

  const label = hasArgs && hasResult ? "Arguments & result" : hasResult ? "Result" : "Arguments";

  return (
    <div className="mt-1.5">
      <button
        type="button"
        aria-expanded={isOpen}
        aria-controls={contentId}
        onClick={() => setIsOpen((open) => !open)}
        className="group relative inline-flex items-center gap-1 text-caption text-muted-foreground transition-colors duration-quick before:absolute before:-inset-3 before:content-[''] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
      >
        <span>{label}</span>
        <motion.span
          animate={{ transform: `rotate(${isOpen ? 180 : 0}deg)` }}
          transition={reduceMotion ? { duration: 0 } : spring.quick.enter}
        >
          <ChevronDownIcon className="size-3" />
        </motion.span>
      </button>
      <AnimatePresence initial={false}>
        {isOpen ? (
          <motion.div
            id={contentId}
            initial={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}
            animate={reduceMotion ? { opacity: 1 } : { height: "auto", opacity: 1 }}
            exit={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}
            transition={reduceMotion ? spring.quick.exit : spring.moderate.enter}
            className="overflow-hidden"
          >
            <div className="mt-1.5 flex flex-col gap-1.5">
              {hasArgs ? (
                <div className="flex flex-col gap-1 rounded-md bg-muted px-2.5 py-2 font-mono text-meta text-muted-foreground">
                  {detail.args?.map((item, index) => (
                    <span key={index}>{item}</span>
                  ))}
                </div>
              ) : null}
              {hasResult ? (
                <div className="shadow-well rounded-md bg-background px-2.5 py-2 text-body leading-relaxed text-foreground">
                  {detail.result}
                </div>
              ) : null}
            </div>
          </motion.div>
        ) : null}
      </AnimatePresence>
    </div>
  );
}

/**
 * One tool call's lifecycle: queued, running, paused on approval, and its
 * resolution, with call arguments and results always one click away behind
 * a disclosure. A needs-approval status hands the whole card body off to
 * ApprovalRequest (via its embedded variant) instead of growing a second,
 * competing consent affordance — Retry stays anchored to the call that
 * failed rather than surfacing as a toast elsewhere.
 */
function ToolRun({
  tool,
  summary,
  status,
  detail,
  meta,
  errorMessage,
  onRetry,
  approval,
  className,
}: ToolRunProps) {
  const reduceMotion = useReducedMotion();
  const statusId = useId();
  const [isRetrying, setIsRetrying] = useState(false);
  const [retryError, setRetryError] = useState<string | null>(null);

  async function handleRetry() {
    setRetryError(null);
    setIsRetrying(true);
    try {
      await onRetry?.();
    } catch {
      setRetryError("Retry failed. Try again.");
    } finally {
      setIsRetrying(false);
    }
  }

  const isNeedsApproval = status === "needs-approval";
  const statusCopy = isNeedsApproval ? null : STATUS_COPY[status];

  return (
    <motion.div
      data-slot="tool-run"
      initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}
      animate={{ opacity: 1, y: 0 }}
      transition={reduceMotion ? spring.quick.exit : spring.moderate.enter}
      className={cn("shadow-bevel w-full max-w-md rounded-lg bg-card px-4 py-3.5", className)}
    >
      <span id={statusId} role="status" aria-live="polite" className="sr-only">
        {statusCopy ? `${tool}: ${statusCopy.label}` : `${tool}: needs approval`}
      </span>

      <AnimatePresence mode="wait" initial={false}>
        {isNeedsApproval && approval ? (
          <motion.div
            key="approval"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: reduceMotion ? { duration: 0 } : spring.quick.exit }}
            transition={reduceMotion ? { duration: 0 } : spring.quick.enter}
          >
            <ApprovalRequest {...approval} variant="embedded" />
          </motion.div>
        ) : (
          <motion.div
            key="call"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: reduceMotion ? { duration: 0 } : spring.quick.exit }}
            transition={reduceMotion ? { duration: 0 } : spring.quick.enter}
          >
            <div className="flex items-center gap-2">
              <span className="flex size-4 shrink-0 items-center justify-center" aria-hidden="true">
                <ToolRunStatusIcon status={status} reduceMotion={Boolean(reduceMotion)} />
              </span>
              <span
                className={cn(
                  "min-w-0 flex-1 truncate text-control text-foreground",
                  status === "running" && !reduceMotion && "agent-activity-shimmer",
                )}
              >
                {tool}
                {summary ? (
                  <span className="font-normal text-muted-foreground"> · {summary}</span>
                ) : null}
              </span>
              {meta ? (
                <span className="shrink-0 font-mono text-micro text-muted-foreground">{meta}</span>
              ) : null}
            </div>

            {detail ? (
              <ToolRunDisclosure detail={detail} showResult={status === "success"} />
            ) : null}

            {status === "error" ? (
              <div className="mt-2 flex flex-wrap items-center gap-2">
                <span className="text-caption text-destructive">
                  {retryError ?? errorMessage ?? "This call failed."}
                </span>
                {onRetry ? (
                  <Button
                    type="button"
                    variant="secondary"
                    size="sm"
                    disabled={isRetrying}
                    onClick={() => void handleRetry()}
                  >
                    {isRetrying ? (
                      <Loader2Icon
                        className={cn("size-3.5", !reduceMotion && "animate-spin")}
                        aria-hidden="true"
                      />
                    ) : (
                      <RotateCcwIcon className="size-3.5" />
                    )}
                    {isRetrying ? "Retrying…" : "Retry"}
                  </Button>
                ) : null}
              </div>
            ) : null}
          </motion.div>
        )}
      </AnimatePresence>
    </motion.div>
  );
}

export { ToolRun };
tool-run.tsx

API Reference

ToolRun
id

string

Stable identifier for the call. Not rendered — for the caller's own keys/lookups.

tool

ReactNode

The tool being called, e.g. "search_docs" or "Read file".

summary

ReactNode

A short call summary next to the tool name — an argument preview, not the full arguments.

status

"queued" | "running" | "needs-approval" | "success" | "error" | "cancelled"

Caller-owned. The component renders it; it never sets it itself.

detail

{ args?: readonly ReactNode[]; result?: ReactNode }

Optional expandable content. args is a compact mono parameter list; result is body copy, shown only once status is "success".

meta

ReactNode

Trailing metadata — elapsed time, a timestamp.

errorMessage

ReactNode

Shown next to Retry when status is "error".

onRetry

() => void | Promise<void>

A rejected retry re-renders the same anchored error — never a toast.

approval

Omit<ApprovalRequestProps, "variant" | "className">

Required while status is "needs-approval" — forwarded to an embedded ApprovalRequest, whose card body replaces this one for the duration of the pause.

className

string

Extends the card root.