Tool call row

One line per tool call: what it did in plain words, the tool's name, a live timer, and a fold-out with the input and output as copyable JSON. ToolCallRowGroup sums several calls into one line.

View as markdownFreeMITSince 0.1.0

Install#

npx shadcn@latest add @pixilui/tool-call-row

Once per app, add the registry to components.json:

components.json
{
  "registries": {
    "@pixilui": "https://pixilui.com/r/{name}.json"
  }
}

Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/tool-call-row.json

Adds components/pixilui/tool-call-row.tsx. Needs motion, clsx, tailwind-merge.

Preview#

State
Layout
Theme
Accent

Calls land one by one under a single summary line that counts them, each with a sliding bar while it runs and rolling digits on its timer.

Code#

components/pixilui/tool-call-row.tsx
"use client";

import {
  Children,
  forwardRef,
  isValidElement,
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  useSyncExternalStore,
  type ComponentPropsWithoutRef,
  type KeyboardEvent,
  type ReactElement,
  type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "./lib/utils";
import { useMorphSize } from "./lib/use-morph-size";
import { motionTokens, reducedTransition } from "./motion-tokens";
import { Button } from "./button";

const noSubscription = () => () => {};

/**
 * useReducedMotion, held at false until hydration finishes. The server cannot know the
 * preference, so reading it on the first client render would change the markup and break
 * hydration; the reduced branch takes over on the next render instead.
 */
function useReducedMotionAfterHydration() {
  const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
  const reduced = useReducedMotion();
  return hydrated && (reduced ?? false);
}

export type ToolCallRowStatus = "running" | "done" | "error";

/** A JSON value: what tools take and return. Strings render as text, everything else as formatted JSON. */
export type ToolCallRowValue = string | number | boolean | null | ToolCallRowValue[] | { [key: string]: ToolCallRowValue };

export type ToolCallRowLabels = {
  input: string;
  output: string;
  copy: string;
  copied: string;
  retry: string;
  retrying: string;
  failed: string;
  running: string;
};

const defaultLabels: ToolCallRowLabels = {
  input: "Input",
  output: "Output",
  copy: "Copy",
  copied: "Copied",
  retry: "Retry",
  retrying: "Retrying",
  failed: "Failed",
  running: "Running",
};

export type ToolCallRowProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
  /** The tool, as its code name: "zendesk.search_tickets". Set in mono. */
  name: string;
  /** What the call did, in plain words and past tense once done: "Searched 14 tickets". */
  summary?: ReactNode;
  /** Running shows a thin indeterminate bar; error shows the message and Retry. */
  status: ToolCallRowStatus;
  /** The tool's mark, shown in a small tile. */
  icon?: ReactNode;
  /** How long the call took (ms). Shown once it finishes. */
  durationMs?: number;
  /** When the call started (epoch ms). Drives a live timer while running. */
  startedAt?: number;
  /** What the agent sent. Objects are formatted as JSON. */
  input?: ToolCallRowValue;
  /** What the tool returned. Objects are formatted as JSON. */
  output?: ToolCallRowValue;
  /** Why it failed, in a sentence. Shown under the summary on error. */
  error?: ReactNode;
  /** Controlled: whether the input and output are unfolded. */
  expanded?: boolean;
  /** Starting fold when uncontrolled. */
  defaultExpanded?: boolean;
  /** Called when the person folds or unfolds the row. */
  onExpandedChange?: (expanded: boolean) => void;
  /** Shows Retry on error. Return a promise and the button shows its own pending state. */
  onRetry?: () => void | Promise<void>;
  /** Copy overrides, for translation. */
  labels?: Partial<ToolCallRowLabels>;
};

/** One pass of the indeterminate bar: 1.44s, linear. */
const SWEEP = motionTokens.duration.considered * 3;
const TICK_MS = 250;
const COPIED_MS = 1600;

/** "820ms", "1.4s", "1m 04s". */
function formatDuration(ms: number) {
  if (ms < 1000) return `${Math.round(ms)}ms`;
  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
  const s = Math.round(ms / 1000);
  return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}

function formatLive(ms: number) {
  const s = Math.floor(ms / 1000);
  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}

function useElapsed(startedAt: number | undefined, running: boolean) {
  const [now, setNow] = useState<number | null>(null);
  useEffect(() => {
    if (startedAt === undefined || !running) return;
    const tick = () => setNow(Date.now());
    tick();
    const id = setInterval(tick, TICK_MS);
    return () => clearInterval(id);
  }, [startedAt, running]);
  return startedAt === undefined || now === null ? null : Math.max(0, now - startedAt);
}

/** Digits keyed by place that roll up as the time grows. */
function Ticker({ value, reduced }: { value: string; reduced: boolean }) {
  const chars = value.split("");
  return (
    <span role="timer" aria-label={value} className="numeric inline-flex">
      {chars.map((char, index) => {
        const place = chars.length - index;
        if (reduced || !/\d/.test(char)) {
          return (
            <span key={place} aria-hidden="true">
              {char}
            </span>
          );
        }
        return (
          <span key={place} aria-hidden="true" className="relative inline-flex h-[1lh] overflow-hidden">
            <AnimatePresence initial={false} mode="popLayout">
              <motion.span key={char} initial={{ y: "100%" }} animate={{ y: 0 }} exit={{ y: "-100%" }} transition={motionTokens.spring.snappy}>
                {char}
              </motion.span>
            </AnimatePresence>
          </span>
        );
      })}
    </span>
  );
}

/** A thin bar with a segment sliding across on linear easing. Still under reduced motion. */
function IndeterminateBar({ reduced, className }: { reduced: boolean; className?: string }) {
  return (
    <span aria-hidden="true" data-slot="tool-call-row-progress" className={cn("pointer-events-none absolute h-0.5 overflow-hidden rounded-pill bg-border-subtle", className)}>
      {reduced ? null : (
        <motion.span
          className="absolute inset-y-0 left-0 w-1/3 rounded-pill bg-foreground/45"
          initial={{ x: "-100%" }}
          animate={{ x: "300%" }}
          transition={{ duration: SWEEP, ease: motionTokens.ease.linear, repeat: Infinity }}
        />
      )}
    </span>
  );
}

function Chevron({ open }: { open: boolean }) {
  return (
    <svg
      viewBox="0 0 12 12"
      fill="none"
      aria-hidden="true"
      className={cn("size-3 shrink-0 text-muted transition-transform duration-fast ease-swap motion-reduce:transition-none", open && "rotate-90")}
    >
      <path d="M4.5 3l3 3-3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

const JSON_TOKEN = /("(?:\\.|[^"\\])*")(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;

/** Formatted JSON with keys a step quieter than values. Plain strings stay as written. */
function Payload({ value }: { value: ToolCallRowValue }) {
  if (typeof value === "string") return <>{value}</>;
  const text = JSON.stringify(value, null, 2);
  const parts: ReactNode[] = [];
  let last = 0;
  for (const match of text.matchAll(JSON_TOKEN)) {
    const index = match.index!;
    if (index > last) parts.push(text.slice(last, index));
    if (match[2] !== undefined) {
      parts.push(
        <span key={index} className="text-secondary">
          {match[1]}
        </span>,
        match[2],
      );
    } else {
      parts.push(
        <span key={index} className="text-foreground">
          {match[0]}
        </span>,
      );
    }
    last = index + match[0].length;
  }
  parts.push(text.slice(last));
  return <>{parts}</>;
}

function payloadText(value: ToolCallRowValue) {
  return typeof value === "string" ? value : JSON.stringify(value, null, 2);
}

function PayloadBlock({ label, value, labels, reduced }: { label: string; value: ToolCallRowValue; labels: ToolCallRowLabels; reduced: boolean }) {
  const [copied, setCopied] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  useEffect(() => () => {
    if (timer.current) clearTimeout(timer.current);
  }, []);

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(payloadText(value));
      setCopied(true);
      if (timer.current) clearTimeout(timer.current);
      timer.current = setTimeout(() => setCopied(false), COPIED_MS);
    } catch {
      // Clipboard blocked (insecure context, denied permission): nothing to undo.
    }
  };

  const swap = reduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition }
    : {
        initial: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` },
        animate: { opacity: 1, filter: "blur(0px)" },
        exit: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` },
        transition: { duration: motionTokens.duration.quick, ease: motionTokens.ease.swap },
      };

  return (
    <div className="flex min-w-0 flex-col gap-1.5">
      <div className="flex items-center justify-between gap-2">
        <span className="text-12 text-muted">{label}</span>
        <button
          type="button"
          onClick={() => void copy()}
          aria-label={copied ? `${label} ${labels.copied.toLowerCase()}` : `${labels.copy} ${label.toLowerCase()}`}
          className="relative inline-flex h-6 items-center rounded-pill px-2 text-12 text-muted outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted hover-fine:text-foreground focus-visible:focus-ring"
        >
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span key={copied ? "copied" : "copy"} className="inline-flex items-center gap-1" {...swap}>
              {copied ? (
                <svg viewBox="0 0 12 12" fill="none" aria-hidden="true" className="size-3">
                  <path d="M2.5 6.25l2.25 2.25 4.75-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              ) : null}
              {copied ? labels.copied : labels.copy}
            </motion.span>
          </AnimatePresence>
        </button>
      </div>
      <pre
        tabIndex={0}
        aria-label={label}
        className="max-h-48 min-w-0 overflow-auto whitespace-pre-wrap break-words rounded-control bg-surface-muted px-3.5 py-3 font-mono text-12 text-muted outline-none focus-visible:focus-ring"
      >
        <Payload value={value} />
      </pre>
    </div>
  );
}

export const ToolCallRow = forwardRef<HTMLDivElement, ToolCallRowProps>(function ToolCallRow(
  {
    name,
    summary,
    status,
    icon,
    durationMs,
    startedAt,
    input,
    output,
    error,
    expanded: expandedProp,
    defaultExpanded = false,
    onExpandedChange,
    onRetry,
    labels: labelsProp,
    className,
    ...rest
  },
  forwardedRef,
) {
  const labels = { ...defaultLabels, ...labelsProp };
  const reduced = useReducedMotionAfterHydration();
  const foldId = useId();
  const [innerExpanded, setInnerExpanded] = useState(defaultExpanded);
  const [retrying, setRetrying] = useState(false);
  const expandable = input !== undefined || output !== undefined;
  const open = (expandedProp ?? innerExpanded) && expandable;
  const live = useElapsed(startedAt, status === "running");

  const { containerRef, measureRef } = useMorphSize<HTMLDivElement, HTMLDivElement>("height", `${open}:${status}`);
  const setRef = useCallback(
    (node: HTMLDivElement | null) => {
      containerRef.current = node;
      if (typeof forwardedRef === "function") forwardedRef(node);
      else if (forwardedRef) forwardedRef.current = node;
    },
    [forwardedRef, containerRef],
  );

  const toggle = () => {
    const next = !open;
    if (expandedProp === undefined) setInnerExpanded(next);
    onExpandedChange?.(next);
  };

  const retry = async () => {
    if (!onRetry || retrying) return;
    setRetrying(true);
    try {
      await onRetry();
    } finally {
      setRetrying(false);
    }
  };

  const time =
    status === "running" ? (
      live !== null ? (
        <Ticker value={formatLive(live)} reduced={reduced} />
      ) : null
    ) : status === "done" && durationMs !== undefined ? (
      <span className="numeric">{formatDuration(durationMs)}</span>
    ) : null;

  const headerContent = (
    <>
      <span className="grid size-7 shrink-0 place-items-center rounded-control bg-surface-muted text-foreground [&_svg]:size-3.5">{icon}</span>
      <span className="flex min-w-0 flex-1 flex-col">
        <span className="flex min-w-0 items-baseline gap-2">
          <span className="min-w-0 truncate text-foreground">{summary ?? name}</span>
          {summary ? <span className="min-w-0 shrink-[2] truncate font-mono text-12 text-muted max-sm:hidden">{name}</span> : null}
        </span>
        {status === "error" && error ? <span className="min-w-0 truncate text-12 text-danger">{error}</span> : null}
      </span>
      <span className="flex shrink-0 items-center gap-2 text-12 text-muted">
        {status === "error" ? <span className="text-danger">{labels.failed}</span> : null}
        {status === "running" ? <span className="sr-only">{labels.running}</span> : null}
        {time}
      </span>
      {expandable ? <Chevron open={open} /> : null}
    </>
  );

  const headerClass = "relative flex min-w-0 flex-1 items-center gap-3 rounded-control px-2 py-1.5 text-left";

  return (
    <div
      ref={setRef}
      data-status={status}
      data-expanded={open}
      aria-busy={status === "running"}
      className={cn("relative w-full min-w-0 overflow-hidden font-body text-14 text-foreground", className)}
      {...rest}
    >
      <div ref={measureRef}>
        <div className="relative flex min-w-0 items-center gap-1">
          {expandable ? (
            <button
              type="button"
              data-slot="tool-call-row-trigger"
              aria-expanded={open}
              aria-controls={foldId}
              onClick={toggle}
              className={cn(
                headerClass,
                "outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus",
              )}
            >
              {headerContent}
            </button>
          ) : (
            <div className={headerClass}>{headerContent}</div>
          )}
          {status === "error" && onRetry ? (
            <Button variant="secondary" size="sm" loading={retrying} onClick={() => void retry()} className="shrink-0">
              {retrying ? labels.retrying : labels.retry}
            </Button>
          ) : null}
          {status === "running" ? <IndeterminateBar reduced={reduced} className="right-2 bottom-0 left-12" /> : null}
        </div>

        <AnimatePresence initial={false} mode="popLayout">
          {open ? (
            <motion.div
              key="fold"
              id={foldId}
              data-slot="tool-call-row-fold"
              className="flex flex-col gap-3 pt-1.5 pe-2 pb-3 ps-12"
              initial={reduced ? { opacity: 0 } : { opacity: 0, y: motionTokens.distance.micro, filter: `blur(${motionTokens.blur.subtle}px)` }}
              animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={reduced ? { opacity: 0, transition: reducedTransition } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }}
              transition={reduced ? reducedTransition : { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item }}
            >
              {input !== undefined ? <PayloadBlock label={labels.input} value={input} labels={labels} reduced={reduced} /> : null}
              {output !== undefined ? <PayloadBlock label={labels.output} value={output} labels={labels} reduced={reduced} /> : null}
            </motion.div>
          ) : null}
        </AnimatePresence>
      </div>
    </div>
  );
});

export type ToolCallRowGroupCounts = {
  total: number;
  running: number;
  done: number;
  error: number;
  /** Sum of the finished calls' durations (ms). */
  durationMs: number;
};

export type ToolCallRowGroupProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
  /** ToolCallRow elements. The group reads their status, duration and icon to write its summary. */
  children: ReactNode;
  /** The summary line. A function receives the counts; the default reads "Used 4 tools" or "2 of 4 tools done". */
  summary?: ReactNode | ((counts: ToolCallRowGroupCounts) => ReactNode);
  /** Controlled: whether the rows are unfolded. */
  expanded?: boolean;
  /** Starting fold when uncontrolled. */
  defaultExpanded?: boolean;
  /** Called when the person folds or unfolds the group. */
  onExpandedChange?: (expanded: boolean) => void;
};

const defaultSummary = (c: ToolCallRowGroupCounts) => {
  const noun = c.total === 1 ? "tool" : "tools";
  if (c.running > 0) return `${c.done + c.error} of ${c.total} ${noun} done`;
  if (c.error > 0) return `Used ${c.total} ${noun}, ${c.error} failed`;
  return `Used ${c.total} ${noun}`;
};

export const ToolCallRowGroup = forwardRef<HTMLDivElement, ToolCallRowGroupProps>(function ToolCallRowGroup(
  { children, summary = defaultSummary, expanded: expandedProp, defaultExpanded = false, onExpandedChange, className, ...rest },
  forwardedRef,
) {
  const reduced = useReducedMotionAfterHydration();
  const listId = useId();
  const [innerExpanded, setInnerExpanded] = useState(defaultExpanded);
  const open = expandedProp ?? innerExpanded;

  const rows = Children.toArray(children).filter(isValidElement) as ReactElement<Partial<ToolCallRowProps>>[];
  const counts: ToolCallRowGroupCounts = { total: rows.length, running: 0, done: 0, error: 0, durationMs: 0 };
  for (const row of rows) {
    const s = row.props.status;
    if (s === "running") counts.running++;
    else if (s === "error") counts.error++;
    else if (s === "done") counts.done++;
    if (s !== "running" && typeof row.props.durationMs === "number") counts.durationMs += row.props.durationMs;
  }
  const icons = rows.map((row) => row.props.icon).filter(Boolean).slice(0, 3);
  const status: ToolCallRowStatus = counts.running > 0 ? "running" : counts.error > 0 ? "error" : "done";
  const line = typeof summary === "function" ? summary(counts) : summary;

  const { containerRef, measureRef } = useMorphSize<HTMLDivElement, HTMLDivElement>("height", `${open}:${rows.length}`);
  const setRef = useCallback(
    (node: HTMLDivElement | null) => {
      containerRef.current = node;
      if (typeof forwardedRef === "function") forwardedRef(node);
      else if (forwardedRef) forwardedRef.current = node;
    },
    [forwardedRef, containerRef],
  );

  const toggle = () => {
    if (expandedProp === undefined) setInnerExpanded(!open);
    onExpandedChange?.(!open);
  };

  // Up and Down walk the row triggers, Home and End jump to the ends.
  const handleListKeys = (event: KeyboardEvent<HTMLDivElement>) => {
    if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
    const triggers = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[data-slot="tool-call-row-trigger"]'));
    const current = triggers.indexOf(event.target as HTMLElement);
    if (current === -1) return;
    event.preventDefault();
    const next =
      event.key === "Home" ? 0 : event.key === "End" ? triggers.length - 1 : Math.min(triggers.length - 1, Math.max(0, current + (event.key === "ArrowDown" ? 1 : -1)));
    triggers[next]?.focus();
  };

  const enter = reduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: reducedTransition }
    : {
        initial: { opacity: 0, y: motionTokens.distance.small, filter: `blur(${motionTokens.blur.subtle}px)` },
        animate: { opacity: 1, y: 0, filter: "blur(0px)" },
        transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter },
      };

  return (
    <div
      ref={setRef}
      data-status={status}
      data-expanded={open}
      aria-busy={counts.running > 0}
      className={cn("relative w-full min-w-0 overflow-hidden rounded-panel bg-surface font-body text-14 text-foreground shadow-resting hairline", className)}
      {...rest}
    >
      <div ref={measureRef} className="p-1.5">
        <button
          type="button"
          aria-expanded={open}
          aria-controls={listId}
          onClick={toggle}
          className="relative flex w-full min-w-0 items-center gap-3 rounded-control px-2 py-1.5 text-left outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus"
        >
          <span className="flex shrink-0 items-center">
            {icons.length > 0 ? (
              icons.map((icon, index) => (
                <span
                  key={index}
                  className={cn(
                    "grid size-7 place-items-center rounded-control bg-surface-muted text-foreground ring-2 ring-surface [&_svg]:size-3.5",
                    index > 0 && "-ms-2",
                  )}
                >
                  {icon}
                </span>
              ))
            ) : (
              <span className="size-7" />
            )}
          </span>
          <span className="min-w-0 flex-1 truncate text-foreground">{line}</span>
          {counts.running === 0 && counts.durationMs > 0 ? <span className="numeric shrink-0 text-12 text-muted">{formatDuration(counts.durationMs)}</span> : null}
          <Chevron open={open} />
          {counts.running > 0 && !open ? <IndeterminateBar reduced={reduced} className="right-2 bottom-0 left-12" /> : null}
        </button>

        <AnimatePresence initial={false} mode="popLayout">
          {open ? (
            <motion.div
              key="rows"
              id={listId}
              role="group"
              aria-label={typeof line === "string" ? line : undefined}
              className="mt-1 flex flex-col border-t border-border-subtle pt-1"
              onKeyDown={handleListKeys}
              initial={reduced ? { opacity: 0 } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }}
              animate={reduced ? { opacity: 1 } : { opacity: 1, filter: "blur(0px)" }}
              exit={reduced ? { opacity: 0, transition: reducedTransition } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }}
              transition={reduced ? reducedTransition : { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter }}
            >
              <AnimatePresence initial={false}>
                {rows.map((row, index) => (
                  <motion.div key={row.key ?? index} {...enter}>
                    {row}
                  </motion.div>
                ))}
              </AnimatePresence>
            </motion.div>
          ) : null}
        </AnimatePresence>
      </div>
    </div>
  );
});

Usage#

Use it when

  • Showing the tools an agent called inside a chat message or a run, with evidence a person can open.
  • Debug and audit views where people copy a call's arguments or result.

Use something else when

  • The steps of a whole run with approvals: use run-timeline (it can hold tool rows as children).
  • A long log of hundreds of calls: use a virtualised table.
  • Status while a model is thinking with no tools involved: use thinking-state.
Example
import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
import { CreditCard, Database, RotateCcw, SquareKanban, Ticket } from "lucide-react";
import { motionTokens } from "@/components/pixilui/motion-tokens";
import { Button } from "@/components/pixilui/button";
import { ToolCallRow, ToolCallRowGroup, type ToolCallRowStatus, type ToolCallRowValue } from "@/components/pixilui/tool-call-row";

const noSubscription = () => () => {};

/**
 * useReducedMotion, held at false until hydration finishes. The server cannot know the
 * preference, so reading it on the first client render would change the markup and break
 * hydration; the reduced branch takes over on the next render instead.
 */
function useReducedMotionAfterHydration() {
  const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
  const reduced = useReducedMotion();
  return hydrated && (reduced ?? false);
}

// The first option of each knob is the demo's default; the docs preview starts there.
export const knobs = {
  state: ["play", "running", "error", "done"],
  layout: ["group", "rows"],
} as const;

type Call = {
  id: string;
  name: string;
  icon: ReactNode;
  running: string;
  done: string;
  durationMs: number;
  input: ToolCallRowValue;
  output: ToolCallRowValue;
};

const CALLS: Call[] = [
  {
    id: "zendesk",
    name: "zendesk.search_tickets",
    icon: <Ticket strokeWidth={1.5} />,
    running: "Searching tickets about refund delays",
    done: "Searched 14 tickets about refund delays",
    durationMs: 1240,
    input: { query: "refund status:open created>72hours", limit: 25 },
    output: {
      count: 14,
      tickets: [
        { id: "ZD-88412", subject: "Refund still pending after 5 days", requester: "maya.okafor@lumenhaus.com" },
        { id: "ZD-88397", subject: "Where is my money back?", requester: "j.lindqvist@fjordlight.se" },
        { id: "ZD-88351", subject: "Charged twice, refund promised", requester: "dev@parcelpine.io" },
      ],
    },
  },
  {
    id: "stripe",
    name: "stripe.refunds.list",
    icon: <CreditCard strokeWidth={1.5} />,
    running: "Checking refunds in Stripe",
    done: "Found 9 refunds stuck in pending",
    durationMs: 830,
    input: { status: "pending", created: { lt: "2026-10-22T00:00:00Z" }, limit: 100 },
    output: {
      data: [
        { id: "re_3QxT2bL8kZ", amount: 4900, currency: "usd", status: "pending" },
        { id: "re_3QxR9mC1aP", amount: 12900, currency: "usd", status: "pending" },
      ],
      has_more: false,
      total_count: 9,
    },
  },
  {
    id: "postgres",
    name: "postgres.query",
    icon: <Database strokeWidth={1.5} />,
    running: "Matching refunds to accounts",
    done: "Matched 9 refunds to 7 accounts",
    durationMs: 2310,
    input: "select a.id, a.name, r.refund_id\nfrom refunds r\njoin accounts a on a.id = r.account_id\nwhere r.refund_id = any($1);",
    output: { rows: 9, accounts: 7, duplicates_merged: 2 },
  },
  {
    id: "linear",
    name: "linear.create_issue",
    icon: <SquareKanban strokeWidth={1.5} />,
    running: "Opening an issue for the payout webhook",
    done: "Opened ENG-4127 for the payout webhook",
    durationMs: 960,
    input: { team: "ENG", title: "Payout webhook drops refund.updated events", priority: 2 },
    output: { id: "ENG-4127", url: "https://linear.app/northwind/issue/ENG-4127" },
  },
];

type CallState = { status: ToolCallRowStatus; startedAt?: number };
type Frame = { calls: Record<string, CallState>; open: string[]; groupOpen: boolean };

const ERROR_TEXT = "Query timed out after 30s on replica db-read-2";

const done = (ids: string[]): Record<string, CallState> => Object.fromEntries(ids.map((id) => [id, { status: "done" as const }]));

const FINAL: Frame = { calls: done(CALLS.map((c) => c.id)), open: ["postgres"], groupOpen: true };
const EMPTY: Frame = { calls: {}, open: [], groupOpen: true };

export default function ToolCallRowDemo({ state = "play", layout = "group" }: KnobValues<typeof knobs>) {
  const reduced = useReducedMotionAfterHydration();
  const [run, setRun] = useState(0);
  const [frame, setFrame] = useState<Frame>(EMPTY);
  const [finished, setFinished] = useState(false);

  const playing = state === "play" && !reduced;

  // A knob change resets the frame to that state.
  const combo = `${state}-${layout}-${reduced}`;
  const [seen, setSeen] = useState<string | null>(null);
  if (seen !== combo) {
    setSeen(combo);
    setFinished(false);
    setFrame(
      playing
        ? EMPTY
        : state === "play" || state === "done"
          ? { ...FINAL, groupOpen: state === "play" }
          : state === "running"
            ? { calls: { ...done(["zendesk", "stripe"]), postgres: { status: "running" } }, open: [], groupOpen: true }
            : { calls: { ...done(["zendesk", "stripe"]), postgres: { status: "error" } }, open: [], groupOpen: true },
    );
  }

  useEffect(() => {
    if (!playing) return;
    const set = (id: string, call: CallState) => setFrame((f) => ({ ...f, calls: { ...f.calls, [id]: call } }));
    const start = (id: string) => () => set(id, { status: "running", startedAt: Date.now() });
    const finish = (id: string) => () => set(id, { status: "done" });
    const steps: [number, () => void][] = [
      [300, start("zendesk")],
      [1300, finish("zendesk")],
      [1480, start("stripe")],
      [2280, finish("stripe")],
      [2460, start("postgres")],
      [3300, () => set("postgres", { status: "error" })],
      [4300, start("postgres")],
      [5100, finish("postgres")],
      [5280, start("linear")],
      [6100, finish("linear")],
      [6800, () => setFrame((f) => ({ ...f, open: ["postgres"] }))],
      [7400, () => setFinished(true)],
    ];
    const timers = steps.map(([at, fn]) => setTimeout(fn, at));
    return () => timers.forEach(clearTimeout);
  }, [playing, run]);

  // The running state keeps a live timer.
  useEffect(() => {
    if (state !== "running" || reduced) return;
    const id = setTimeout(() => setFrame((f) => ({ ...f, calls: { ...f.calls, postgres: { status: "running", startedAt: Date.now() - 1400 } } })), 0);
    return () => clearTimeout(id);
  }, [state, reduced, run]);

  const retry = async () => {
    setFrame((f) => ({ ...f, calls: { ...f.calls, postgres: { status: "running", startedAt: Date.now() } } }));
    await new Promise((resolve) => setTimeout(resolve, 1100));
    setFrame((f) => ({ ...f, calls: { ...f.calls, postgres: { status: "done" } } }));
  };

  const rows = CALLS.filter((call) => frame.calls[call.id]).map((call) => {
    const { status, startedAt } = frame.calls[call.id]!;
    return (
      <ToolCallRow
        key={call.id}
        name={call.name}
        icon={call.icon}
        status={status}
        summary={status === "done" ? call.done : status === "error" ? "Could not match refunds to accounts" : call.running}
        startedAt={startedAt}
        durationMs={status === "error" ? 30000 : call.durationMs}
        error={status === "error" ? ERROR_TEXT : undefined}
        input={call.input}
        output={status === "done" ? call.output : undefined}
        expanded={frame.open.includes(call.id)}
        onExpandedChange={(open) => setFrame((f) => ({ ...f, open: open ? [...f.open, call.id] : f.open.filter((id) => id !== call.id) }))}
        onRetry={state === "error" ? retry : undefined}
      />
    );
  });

  return (
    <div className="flex w-full min-w-0 max-w-xl flex-col gap-4">
      <div className="min-h-96">
        {rows.length === 0 ? null : layout === "group" ? (
          <motion.div
            initial={{ opacity: 0, y: motionTokens.distance.small, filter: `blur(${motionTokens.blur.subtle}px)` }}
            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
            transition={{ duration: motionTokens.duration.fast, ease: motionTokens.ease.enter }}
          >
            <ToolCallRowGroup expanded={frame.groupOpen} onExpandedChange={(open) => setFrame((f) => ({ ...f, groupOpen: open }))}>
              {rows}
            </ToolCallRowGroup>
          </motion.div>
        ) : (
          <div className="flex flex-col gap-1">{rows}</div>
        )}
      </div>
      <div className="flex h-control-sm justify-center">
        {playing && finished ? (
          <Button
            variant="ghost"
            size="sm"
            icon={<RotateCcw strokeWidth={1.5} />}
            onClick={() => {
              setFrame(EMPTY);
              setFinished(false);
              setRun((n) => n + 1);
            }}
          >
            Replay
          </Button>
        ) : null}
      </div>
    </div>
  );
}

API#

PropTypeDefaultDescription
namerequiredstringThe tool, as its code name: "zendesk.search_tickets". Set in mono.
summaryReactNodeWhat the call did, in plain words and past tense once done: "Searched 14 tickets".
statusrequired"running" | "done" | "error"Running shows a thin indeterminate bar; error shows the message and Retry.
iconReactNodeThe tool's mark, shown in a small tile.
durationMsnumberHow long the call took (ms). Shown once it finishes.
startedAtnumberWhen the call started (epoch ms). Drives a live timer while running.
inputToolCallRowValueWhat the agent sent. Objects are formatted as JSON.
outputToolCallRowValueWhat the tool returned. Objects are formatted as JSON.
errorReactNodeWhy it failed, in a sentence. Shown under the summary on error.
expandedbooleanControlled: whether the input and output are unfolded.
defaultExpandedbooleanfalseStarting fold when uncontrolled.
onExpandedChange(expanded: boolean) => voidCalled when the person folds or unfolds the row.
onRetry() => void | Promise<void>Shows Retry on error. Return a promise and the button shows its own pending state.
labelsPartial<ToolCallRowLabels>Copy overrides, for translation.

Also takes every native <div> attribute, and a ref to it.

Keyboard#

Tab
Moves to a row's trigger, then its copy buttons and output blocks when open, then Retry on a failed call.
Enter
Folds or unfolds the row, or the group from its summary line.
Space
Folds or unfolds the row, or the group from its summary line.
ArrowDownArrowUp
Inside a group, moves between row triggers.
HomeEnd
Inside a group, jumps to the first or last row.

Accessibility#

  • Each row trigger is a button with aria-expanded and aria-controls; rows with nothing to show are plain text.
  • Running rows set aria-busy and carry the word Running in text; failed rows show Failed in text, not only in colour.
  • The input and output blocks are focusable and labelled, so keyboard users can scroll them; copy buttons say what they copy.
  • Retry uses aria-busy while the retry runs, never disabled, so focus stays on it.
  • The group's rows are a labelled group named by the summary line.

Motion#

  • Running shows a thin bar under the row with a segment sliding across on linear easing, not a spinner. Reduced motion keeps the track still.
  • The live timer's digits are keyed by place and roll up on the snappy spring.
  • Unfolding grows the row on the morph spring and folding shrinks it on the smooth spring; the payload fades up out of a 2px blur.
  • Rows added to an open group rise in from 6px out of a 2px blur while the group grows to fit.
  • Copy swaps to a check and Copied with a 150ms blur crossfade.
  • Reduced motion: instant folds, short fades, no sliding bar.

Responsive#

  • Fills its container. The summary truncates first; the tool name hides under 640px so the summary and timing keep their room.
  • Input and output blocks wrap long lines and scroll past 192px tall.

Performance#

  • One ResizeObserver per row and per group drives the folds; nothing animates on first render.
  • Payloads are formatted only while a row is open.

Notes for AI#

Also in the markdown page and llms.txt
  1. Write summary in past tense with a number once done ("Searched 14 tickets") and in the present while running ("Searching tickets").
  2. Pass input and output as plain JSON values or strings (SQL, shell commands); objects are pretty-printed with keys a step quieter.
  3. Pass startedAt while running for a live timer and durationMs once done.
  4. Wrap rows in ToolCallRowGroup to sum them; it reads each row's status, duration and icon, so keep the rows as direct children with stable keys.
  5. Return a promise from onRetry and the button shows its own pending state; update status to running from your store when the retry starts.
  6. Pass the tool's logo as icon; the component never imports an icon library.