For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
1

Tool Call

One call, one row: the name, the gist of what it was given, where it stands, and the arguments and result behind a disclosure for the reader who doubts the line.

doneread_filesrc/auth/session.ts
runninggrepcreateSession in src/
queuedrun_testssrc/auth
failedread_filesrc/auth/sessions.ts
"use client";

import { useEffect, useState } from "react";

Installation

$ pnpm dlx shadcn@latest add https://motif.sweetretry.com/r/tool-call.json

Usage

import {
  ToolCall,
  ToolCallContent,
  ToolCallHeader,
  ToolCallName,
  ToolCallSection,
  ToolCallStatus,
  ToolCallSummary,
} from "@/components/ui/motif/tool-call";
 
export function ReadFile({ call }) {
  return (
    <ToolCall status={call.status}>
      <ToolCallHeader>
        <ToolCallStatus />
        <ToolCallName>read_file</ToolCallName>
        <ToolCallSummary>{call.input.path}</ToolCallSummary>
      </ToolCallHeader>
      <ToolCallContent>
        <ToolCallSection title="Input">{call.inputText}</ToolCallSection>
        <ToolCallSection title="Result">{call.resultText}</ToolCallSection>
      </ToolCallContent>
    </ToolCall>
  );
}

The root owns the status and whether the detail is open. The parts read them and never speak to each other, so a caller can move one, drop one, or put a part of their own in the header.

PartDraws
ToolCallThe column, the status, and the disclosure state
ToolCallHeaderThe row; the control that opens the detail
ToolCallStatusWhere the call stands: ring, indicator, check, cross
ToolCallNameWhat was called, in mono
ToolCallSummaryThe gist of the input, one line, cut with an ellipsis
ToolCallContentThe detail, behind a disclosure
ToolCallSectionA labelled block inside the detail
useToolCallStatus, open state and toggle, for a part of your own

A row, not a card

A tool call is a line in a transcript. A dozen of them have to leave the transcript a transcript, so the row draws no container of its own, and the detail hangs off it behind a rule rather than inside a box. The arguments and the result are for the reader who doubts the line, not for the one following along.

It is also why the summary is one line and cut. It is enough to recognise the call by; the full input is a press away.

Status

Four words, because those are the four things a row can say:

StatusDraws
queuedAn empty ring; the row is dimmed
runningThe loading mark
doneA check, in success
failedA cross, in destructive

A running call reuses the mark from Loading State rather than drawing a spinner. It already means the agent is working, and a second mark for the same thing would be one word in two accents.

The status is in the same place for all four, so the row does not shift when a call moves from one to the next:

queuedread_filesrc/auth/session.ts
"use client";

import { useEffect, useState } from "react";

Each status has a text name for a screen reader. Pass label to ToolCallStatus to translate it.

No detail, no button

The header is the control that opens the detail, but only while there is detail. Leave ToolCallContent out, or do not render it yet as the lifecycle above does, and the row is a plain row: no button, no chevron, nothing that does nothing when pressed.

The content tells the root it is there as it mounts, before paint, so the chevron is in the first frame rather than a frame after the content that justifies it.

Sections

A ToolCallSection with a string is set as preformatted text, so JSON keeps its indentation, and caps its height at twelve rem. Anything else is drawn as given, which is how a failure is shown:

<ToolCallSection title="Error">
  <p className="text-destructive text-xs">{call.error}</p>
</ToolCallSection>

A section with nothing in it draws nothing, so <ToolCallSection title="Result">{result}</ToolCallSection> is safe to write before the result has arrived.

The block is a recessed field, so it takes a border and no fill. A fill that is below the page in one theme is above the card in the other.

Controlled

open and onOpenChange take the disclosure over, and defaultOpen starts it open. A host that opens the failed call and leaves the rest closed passes open={status === "failed"}.

Component source

tool-call.tsx
"use client";

import { Check, ChevronDown, X } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
  createContext,
  useCallback,
  useContext,
  useId,
  useLayoutEffect,
  useMemo,
  useState,
} from "react";

import { AgentDisclosure } from "@/components/ui/motif/agent-disclosure";
import { LoadingStateMark } from "@/components/ui/motif/loading-state";
import { cn } from "@/lib/utils";

/* -- One call, one row ---------------------------------------------------------
 * A tool call is the unit of "what the agent did": a name, a gist of what it was given,
 * and where it stands. That is a line, not a card. A transcript with a dozen of them in
 * it has to stay a transcript, so the row carries no container of its own and the detail
 * is behind a disclosure: the arguments and the result are for the reader who doubts the
 * line, not for the one who is following along.
 *
 * The root owns the status and whether the detail is open; the parts read them and know
 * nothing of each other. A caller who wants a duration at the end of the row puts it in
 * the header. A caller whose call has no detail leaves the content out, and the row stops
 * being a button.
 * --------------------------------------------------------------------------- */

/** Where a call stands. Four words, because those are the four things a row can say:
 *  not yet started, in flight, came back, did not come back. */
export type ToolCallStatusValue = "queued" | "running" | "done" | "failed";

/** What a part reads from the root. Exported with `useToolCall`, so a caller can draw
 *  a part of their own — a retry button that only exists on a failure — from the same
 *  status the shipped parts use. */
export interface ToolCallState {
  /** Whether the detail is showing. Always `false` when there is no detail. */
  isOpen: boolean;
  status: ToolCallStatusValue;
  toggle: () => void;
}

interface ToolCallInternals extends ToolCallState {
  contentId: string;
  /** Called by the content as it mounts, so the header can tell whether there is
   *  anything to open. Returns the call that undoes it. */
  registerContent: () => () => void;
  hasContent: boolean;
}

export interface ToolCallProps extends Omit<ComponentProps<"div">, "children"> {
  children: ReactNode;
  /** Whether the detail starts open. Ignored when `open` is passed. */
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Controlled. Passed, the root never opens or closes itself. */
  open?: boolean;
  status: ToolCallStatusValue;
}

export type ToolCallHeaderProps = ComponentProps<"div">;

export interface ToolCallStatusProps extends ComponentProps<"span"> {
  /** Names the status for a screen reader. Defaults to the status word itself. */
  label?: string;
}

export type ToolCallNameProps = ComponentProps<"span">;
export type ToolCallSummaryProps = ComponentProps<"span">;
export type ToolCallContentProps = ComponentProps<"div">;

export interface ToolCallSectionProps extends Omit<
  ComponentProps<"div">,
  "title"
> {
  /** `Input`, `Result`: what the block under it is. */
  title: ReactNode;
}

/** Nothing to draw: the shapes a conditional leaves behind. A slot with nothing in it
 *  draws nothing, and an empty string is one of those. */
const isEmpty = (children: ReactNode) =>
  children === null ||
  children === undefined ||
  children === false ||
  children === "";

const ToolCallContext = createContext<ToolCallInternals | null>(null);

const useInternals = () => {
  const context = useContext(ToolCallContext);
  if (!context) {
    throw new Error("Tool call parts must be used within a ToolCall.");
  }

  return context;
};

export const useToolCall = (): ToolCallState => {
  const { isOpen, status, toggle } = useInternals();

  return { isOpen, status, toggle };
};

/**
 * The root: it owns the status and the disclosure, and draws nothing but the column the
 * parts stack in.
 */
export const ToolCall = ({
  children,
  className,
  defaultOpen = false,
  onOpenChange,
  open,
  status,
  ...props
}: ToolCallProps) => {
  const contentId = useId();
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
  const [contentCount, setContentCount] = useState(0);

  const hasContent = contentCount > 0;
  // A row with nothing behind it is never open, whatever `open` says: there is no state
  // for the chevron to point at.
  const isOpen = hasContent && (open ?? uncontrolledOpen);

  const toggle = useCallback(() => {
    const next = !isOpen;

    if (open === undefined) {
      setUncontrolledOpen(next);
    }
    onOpenChange?.(next);
  }, [isOpen, onOpenChange, open]);

  const registerContent = useCallback(() => {
    setContentCount((count) => count + 1);

    return () => setContentCount((count) => count - 1);
  }, []);

  const state = useMemo<ToolCallInternals>(
    () => ({
      contentId,
      hasContent,
      isOpen,
      registerContent,
      status,
      toggle,
    }),
    [contentId, hasContent, isOpen, registerContent, status, toggle]
  );

  return (
    <ToolCallContext.Provider value={state}>
      <div
        {...props}
        className={cn("group/tool-call flex flex-col", className)}
        data-slot="tool-call"
        data-status={status}
      >
        {children}
      </div>
    </ToolCallContext.Provider>
  );
};

/**
 * The row: status, name, gist, and whatever else the caller puts in it.
 *
 * It is the control that opens the detail, but only while there is detail to open. A
 * chevron that pointed at nothing, on a row that did nothing when pressed, would be a
 * promise the part could not keep, so without content it is a plain row and draws no
 * chevron.
 */
export const ToolCallHeader = ({
  children,
  className,
  ...props
}: ToolCallHeaderProps) => {
  const { contentId, hasContent, isOpen, toggle } = useInternals();

  const row = (
    <>
      {children}
      {hasContent ? (
        <ChevronDown
          aria-hidden="true"
          className={cn(
            "size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 ease-out motion-reduce:transition-none",
            isOpen && "rotate-180"
          )}
        />
      ) : null}
    </>
  );

  const frame = cn(
    "flex min-w-0 items-center gap-2 text-sm",
    "group-data-[status=queued]/tool-call:text-muted-foreground",
    className
  );

  if (!hasContent) {
    return (
      <div {...props} className={frame} data-slot="tool-call-header">
        {row}
      </div>
    );
  }

  return (
    <div {...props} className={frame} data-slot="tool-call-header">
      <button
        aria-controls={contentId}
        aria-expanded={isOpen}
        className="flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
        onClick={toggle}
        type="button"
      >
        {row}
      </button>
    </div>
  );
};

/**
 * Where the call stands, drawn as one thing in the same place for all four.
 *
 * A running call is the loading mark: it already means "the agent is working", and a
 * second spinner beside it would be the same word in two accents. The other three are still shapes: an empty ring has not started, a check came
 * back, a cross did not.
 */
export const ToolCallStatus = ({
  className,
  label,
  ...props
}: ToolCallStatusProps) => {
  const { status } = useInternals();

  return (
    <span
      {...props}
      className={cn(
        "flex size-5 shrink-0 items-center justify-center",
        className
      )}
      data-slot="tool-call-status"
    >
      {status === "queued" ? (
        <span
          aria-hidden="true"
          className="size-3 rounded-full border border-muted-foreground/50"
        />
      ) : null}
      {status === "running" ? <LoadingStateMark /> : null}
      {status === "done" ? (
        <Check aria-hidden="true" className="size-4 text-success" />
      ) : null}
      {status === "failed" ? (
        <X aria-hidden="true" className="size-4 text-destructive" />
      ) : null}
      <span className="sr-only">{label ?? status}</span>
    </span>
  );
};

/** What was called: `read_file`, `web.search`. Mono, because it is an identifier the
 *  reader may need to find in a log. */
export const ToolCallName = ({
  children,
  className,
  ...props
}: ToolCallNameProps) => {
  if (isEmpty(children)) {
    return null;
  }

  return (
    <span
      {...props}
      className={cn("shrink-0 font-medium font-mono text-xs", className)}
      data-slot="tool-call-name"
    >
      {children}
    </span>
  );
};

/** The gist of what the call was given: a path, a query. One line, cut with an ellipsis,
 *  because the full input is in the detail and this is only enough to recognise it. */
export const ToolCallSummary = ({
  children,
  className,
  ...props
}: ToolCallSummaryProps) => {
  if (isEmpty(children)) {
    return null;
  }

  return (
    <span
      {...props}
      className={cn(
        "min-w-0 flex-1 truncate text-muted-foreground text-xs",
        className
      )}
      data-slot="tool-call-summary"
    >
      {children}
    </span>
  );
};

/**
 * The detail: what the call was given and what came back, behind the row.
 *
 * It indents under the status with a rule on the left, so a stack of calls reads as rows
 * with detail hanging off them rather than as one block of text.
 */
export const ToolCallContent = ({
  children,
  className,
  ...props
}: ToolCallContentProps) => {
  const { contentId, isOpen, registerContent } = useInternals();

  // Before paint, so the header's chevron is there in the first frame rather than a
  // frame after the content that justifies it.
  useLayoutEffect(() => registerContent(), [registerContent]);

  return (
    <AgentDisclosure id={contentId} open={isOpen}>
      <div
        {...props}
        className={cn(
          "ml-2.5 flex flex-col gap-3 border-border border-l py-3 pl-4",
          className
        )}
        data-slot="tool-call-content"
      >
        {children}
      </div>
    </AgentDisclosure>
  );
};

/**
 * One labelled block of the detail. A string is set as preformatted text, so JSON keeps
 * its indentation; anything else is drawn as given.
 *
 * The block is a recessed field and takes a border, not a fill: a fill that is below the
 * surface in one theme is above it in the other.
 */
export const ToolCallSection = ({
  children,
  className,
  title,
  ...props
}: ToolCallSectionProps) => {
  if (isEmpty(children)) {
    return null;
  }

  return (
    <div
      {...props}
      className={cn("flex min-w-0 flex-col gap-1.5", className)}
      data-slot="tool-call-section"
    >
      <span className="text-muted-foreground text-xs">{title}</span>
      {typeof children === "string" ? (
        <pre className="max-h-48 overflow-auto rounded-md border border-border px-3 py-2 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words">
          {children}
        </pre>
      ) : (
        children
      )}
    </div>
  );
};