Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new

Chain of Thought

A summary that opens into the research steps, source links, and tool output behind an answer.

Interactive preview

The shared preview uses React Base. The source below uses React Aria. Each example runs with local data.

Installation

Complete project setup with react/aria, then install these components:

pnpm exec frontfriend download trace code-block badge icon button message bubble -f react --primitive aria

Use a nested Trace for each group of steps. A running step uses the present tense; a complete step uses the past tense. Keep disclosure choices in application state so nested steps keep their open state when a parent closes. Map tool output to these records; no AI provider is required.

Usage

Complete example

import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import * as React from "react";
type Phase = "complete" | "running" | "error" | "pending";
function useRun(initial: Phase) {
  const [phase, setPhase] = React.useState(initial);
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
    undefined,
  );
  React.useEffect(() => {
    clearTimeout(timer.current);
    setPhase(initial);
  }, [initial]);
  React.useEffect(() => () => clearTimeout(timer.current), []);
  function replay() {
    clearTimeout(timer.current);
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setPhase("complete");
      return;
    }
    setPhase("running");
    timer.current = setTimeout(() => setPhase("complete"), 3600);
  }
  return { phase, replay };
}
type Step = {
  id: string;
  title: string;
  status?: "running" | "error" | "pending";
  output?: string;
  files?: string[];
  href?: string;
  children?: Step[];
  defaultOpen?: boolean;
};
const frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
function StepRow({
  step,
  open,
  onOpen,
}: {
  step: Step;
  open: Record<string, boolean>;
  onOpen: (id: string, value: boolean) => void;
}) {
  const line = (
    <TraceLine status={step.status}>
      <TraceIcon>
        <Icon
          name={
            step.href
              ? "Globe"
              : step.status === "error"
                ? "CircleAlert"
                : step.status === "running"
                  ? "LoaderCircle"
                  : step.status === "pending"
                    ? "CircleDashed"
                    : "CircleCheck"
          }
        />
      </TraceIcon>
      <TraceTitle>{step.title}</TraceTitle>
    </TraceLine>
  );
  if (step.href)
    return (
      <a
        href={step.href}
        target="_blank"
        rel="noreferrer"
        aria-label={step.title + " (opens in a new tab)"}
      >
        {line}
      </a>
    );
  if (!step.output && !step.files && !step.children) return line;
  return (
    <TraceCollapsible
      isExpanded={open[step.id] ?? step.defaultOpen === true}
      onExpandedChange={(value) => onOpen(step.id, value)}
    >
      <TraceCollapsibleTrigger>{line}</TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        {step.output && (
          <CodeBlock
            size="sm"
            variant={step.status === "error" ? "destructive" : "default"}
          >
            <CodeBlockContent wrap style={{ maxHeight: 256 }}>
              {step.output}
            </CodeBlockContent>
          </CodeBlock>
        )}
        {step.files && (
          <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
            {step.files.map((file) => (
              <Badge key={file} variant="secondary">
                {file}
              </Badge>
            ))}
          </div>
        )}
        {step.children && (
          <Trace>
            {step.children.map((child) => (
              <StepRow
                key={child.id}
                step={child}
                open={open}
                onOpen={onOpen}
              />
            ))}
          </Trace>
        )}
      </TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
export default function AiChainOfThoughtExample() {
  const { phase, replay: run } = useRun("complete");
  const [open, setOpen] = React.useState<Record<string, boolean>>({});
  function onOpen(id: string, value: boolean) {
    setOpen((current) => ({ ...current, [id]: value }));
  }
  function replay() {
    setOpen({});
    run();
  }
  const steps: Step[] =
    phase === "pending"
      ? [
          {
            id: "waiting",
            title: "Waiting to research the deployment",
            status: "pending",
          },
        ]
      : [
          {
            id: "log",
            defaultOpen: true,
            title: "Read the build log for dpl_8Kx2",
            output: "install    41s\ncompile    38s\nimages   3m 52s",
          },
          {
            id: "web",
            title:
              phase === "running"
                ? "Searching the web for image cache keys"
                : phase === "error"
                  ? "Failed to search the web"
                  : "Searched the web for image cache keys",
            status:
              phase === "running"
                ? "running"
                : phase === "error"
                  ? "error"
                  : undefined,
            output:
              phase === "error" ? "Search timed out. Try again." : undefined,
            children:
              phase === "complete"
                ? [
                    {
                      id: "image-docs",
                      title: "Image component · nextjs.org",
                      href: "https://nextjs.org/docs/app/api-reference/components/image",
                    },
                    {
                      id: "cache-docs",
                      title: "Build cache · vercel.com",
                      href: "https://vercel.com/docs/deployments/troubleshoot-a-build",
                    },
                  ]
                : undefined,
          },
          {
            id: "code",
            title:
              phase === "complete"
                ? "Searched the code for cacheKey"
                : "Search the code for cacheKey",
            status: phase === "complete" ? undefined : "pending",
            files:
              phase === "complete"
                ? ["next.config.ts", "scripts/optimize-images.ts"]
                : undefined,
          },
        ];
  return (
    <div style={frame}>
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>Why is the checkout deploy so slow?</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message>
        <MessageContent style={{ gap: 16, minWidth: 0 }}>
          <Trace>
            <TraceCollapsible
              isExpanded={open.summary ?? phase !== "complete"}
              onExpandedChange={(value) => onOpen("summary", value)}
            >
              <TraceCollapsibleTrigger>
                <TraceLine status={phase === "complete" ? undefined : phase}>
                  <TraceIcon>
                    <Icon
                      name={
                        phase === "error"
                          ? "CircleAlert"
                          : phase === "running"
                            ? "LoaderCircle"
                            : "CircleCheck"
                      }
                    />
                  </TraceIcon>
                  <TraceTitle>
                    {phase === "complete"
                      ? "Researched in 3 steps"
                      : phase === "running"
                        ? "Researching"
                        : phase === "pending"
                          ? "Research queued"
                          : "Research interrupted"}
                  </TraceTitle>
                </TraceLine>
              </TraceCollapsibleTrigger>
              <TraceCollapsibleContent>
                <Trace>
                  {steps.map((step) => (
                    <StepRow
                      key={step.id}
                      step={step}
                      open={open}
                      onOpen={onOpen}
                    />
                  ))}
                </Trace>
              </TraceCollapsibleContent>
            </TraceCollapsible>
          </Trace>
          {phase === "complete" && (
            <p>
              Image optimization takes almost four minutes because its cache key
              includes the commit hash. Key it on the image directory instead.
            </p>
          )}
        </MessageContent>
      </Message>
      <div>
        <Button
          size="sm"
          variant="outline"
          onPress={replay}
          isDisabled={phase === "running"}
        >
          Replay
        </Button>
      </div>
    </div>
  );
}

AI SDK

The adapter reads searchDocs, readFile, and runCommand tool parts from a typed UIMessage. It opens the summary while tool parts stream and before a text answer begins. Failed steps determine the summary status. Each call keeps its toolCallId and its own disclosure state.

The basic example runs without a provider. For the AI SDK adapter example only, install the SDK types:

pnpm add ai

Pass a message from your chat client to the adapter. The supplied message does not call a provider.

import type { UIMessage } from "ai";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Badge } from "@/components/ui/badge";
import * as React from "react";
const frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
type SdkChainMessage = UIMessage<
  unknown,
  {},
  {
    runCommand: {
      input: {
        command: string;
      };
      output: {
        stdout: string;
        exitCode: number;
      };
    };
    searchDocs: {
      input: {
        query: string;
      };
      output: {
        summary: string;
      };
    };
    readFile: {
      input: {
        path: string;
      };
      output: {
        content: string;
      };
    };
  }
>;
type SdkStepPart = Extract<
  SdkChainMessage["parts"][number],
  {
    type: `tool-${string}`;
  }
>;
type Step = {
  id: string;
  title: string;
  status?: "running" | "error" | "pending";
  output?: string;
  files?: string[];
  href?: string;
  children?: Step[];
  defaultOpen?: boolean;
};
type SdkCommandMessage = UIMessage<
  unknown,
  {},
  {
    runCommand: {
      input: {
        command: string;
      };
      output: {
        stdout: string;
        exitCode: number;
      };
    };
  }
>;
type SdkCommandPart = Extract<
  SdkCommandMessage["parts"][number],
  {
    type: "tool-runCommand";
  }
>;
function sdkCommandState(part: SdkCommandPart, streaming: boolean) {
  const command = part.input?.command ?? "command";
  const denied =
    part.state === "output-denied" ||
    (part.state === "approval-responded" && !part.approval.approved);
  const finished = part.state === "output-available" && !part.preliminary;
  const waiting = part.state === "approval-requested";
  const approved =
    part.state === "approval-responded" && part.approval.approved;
  const failed =
    part.state === "output-error" ||
    (finished && part.output.exitCode !== 0) ||
    (!denied && !finished && !waiting && !approved && !streaming);
  const status = denied
    ? undefined
    : waiting
      ? "pending"
      : failed
        ? "error"
        : finished
          ? undefined
          : "running";
  const title = `${denied ? "Denied" : waiting ? "Waiting to run" : failed ? "Failed to run" : finished ? "Ran" : "Running"} ${command}`;
  const output =
    part.state === "output-error"
      ? part.errorText
      : part.state === "output-available"
        ? part.output.stdout
        : undefined;
  return { status, title, output } as const;
}
function researchStep(part: SdkStepPart, streaming: boolean): Step {
  if (part.type === "tool-runCommand")
    return { id: part.toolCallId, ...sdkCommandState(part, streaming) };
  const finished = part.state === "output-available" && !part.preliminary;
  const denied =
    part.state === "output-denied" ||
    (part.state === "approval-responded" && !part.approval.approved);
  const pending = part.state === "approval-requested";
  const failed =
    part.state === "output-error" ||
    (!finished && !pending && !denied && !streaming);
  const verb =
    part.type === "tool-searchDocs"
      ? finished
        ? "Searched"
        : "Searching"
      : finished
        ? "Read"
        : "Reading";
  const action =
    part.type === "tool-searchDocs"
      ? (part.input?.query ?? "documentation")
      : (part.input?.path ?? "file");
  const output =
    part.state === "output-error"
      ? part.errorText
      : part.state === "output-available"
        ? part.type === "tool-searchDocs"
          ? part.output.summary
          : part.output.content
        : undefined;
  return {
    id: part.toolCallId,
    title: `${denied ? "Denied" : pending ? "Waiting to" : failed ? "Failed to" : verb} ${denied || pending || failed ? (part.type === "tool-searchDocs" ? "search " : "read ") : ""}${action}`,
    status: denied
      ? undefined
      : pending
        ? "pending"
        : failed
          ? "error"
          : finished
            ? undefined
            : "running",
    output,
  };
}
function SdkPanel({
  title,
  status,
  children,
  initialOpen = false,
}: {
  title: string;
  status?: "running" | "pending" | "error";
  children: React.ReactNode;
  initialOpen?: boolean;
}) {
  const [open, setOpen] = React.useState<boolean>();
  const expanded = open ?? initialOpen;
  return (
    <TraceCollapsible isExpanded={expanded} onExpandedChange={setOpen}>
      <TraceCollapsibleTrigger>
        <TraceLine status={status}>
          <TraceIcon>
            <Icon name={status === "error" ? "CircleAlert" : "Terminal"} />
          </TraceIcon>
          <TraceTitle>{title}</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>{children}</TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
function StepRow({
  step,
  open,
  onOpen,
}: {
  step: Step;
  open: Record<string, boolean>;
  onOpen: (id: string, value: boolean) => void;
}) {
  const line = (
    <TraceLine status={step.status}>
      <TraceIcon>
        <Icon
          name={
            step.href
              ? "Globe"
              : step.status === "error"
                ? "CircleAlert"
                : step.status === "running"
                  ? "LoaderCircle"
                  : step.status === "pending"
                    ? "CircleDashed"
                    : "CircleCheck"
          }
        />
      </TraceIcon>
      <TraceTitle>{step.title}</TraceTitle>
    </TraceLine>
  );
  if (step.href)
    return (
      <a
        href={step.href}
        target="_blank"
        rel="noreferrer"
        aria-label={step.title + " (opens in a new tab)"}
      >
        {line}
      </a>
    );
  if (!step.output && !step.files && !step.children) return line;
  return (
    <TraceCollapsible
      isExpanded={open[step.id] ?? step.defaultOpen === true}
      onExpandedChange={(value) => onOpen(step.id, value)}
    >
      <TraceCollapsibleTrigger>{line}</TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        {step.output && (
          <CodeBlock
            size="sm"
            variant={step.status === "error" ? "destructive" : "default"}
          >
            <CodeBlockContent wrap style={{ maxHeight: 256 }}>
              {step.output}
            </CodeBlockContent>
          </CodeBlock>
        )}
        {step.files && (
          <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
            {step.files.map((file) => (
              <Badge key={file} variant="secondary">
                {file}
              </Badge>
            ))}
          </div>
        )}
        {step.children && (
          <Trace>
            {step.children.map((child) => (
              <StepRow
                key={child.id}
                step={child}
                open={open}
                onOpen={onOpen}
              />
            ))}
          </Trace>
        )}
      </TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
function ChainOfThoughtSdkAdapter({
  message,
  streaming,
}: {
  message: SdkChainMessage;
  streaming: boolean;
}) {
  const [open, setOpen] = React.useState<Record<string, boolean>>({});
  const steps = message.parts
    .filter(
      (part): part is SdkStepPart =>
        part.type === "tool-runCommand" ||
        part.type === "tool-searchDocs" ||
        part.type === "tool-readFile",
    )
    .map((part) => researchStep(part, streaming));
  const answering = message.parts.some(
    (part) => part.type === "text" && part.text.trim().length > 0,
  );
  const running = streaming && !answering;
  const failed = steps.filter((step) => step.status === "error").length;
  return (
    <>
      <Trace>
        <SdkPanel
          title={
            running
              ? "Researching"
              : `Researched in ${steps.length} steps${failed ? `, ${failed} failed` : ""}`
          }
          status={running ? "running" : failed ? "error" : undefined}
          initialOpen={running}
        >
          <Trace>
            {steps.map((step) => (
              <StepRow
                key={step.id}
                step={step}
                open={open}
                onOpen={(id, value) =>
                  setOpen((current) => ({ ...current, [id]: value }))
                }
              />
            ))}
          </Trace>
        </SdkPanel>
      </Trace>
      {message.parts.map((part, index) =>
        part.type === "text" ? <p key={index}>{part.text}</p> : null,
      )}
    </>
  );
}
const sdkMessage: SdkChainMessage = {
  id: "research",
  role: "assistant",
  parts: [
    {
      type: "tool-searchDocs",
      toolCallId: "docs",
      state: "output-available",
      input: { query: "image cache" },
      output: {
        summary: "Reuse a cache key based on the image directory.",
      },
    },
    {
      type: "tool-runCommand",
      toolCallId: "find",
      state: "output-available",
      input: { command: "rg cacheKey scripts" },
      output: {
        stdout: "scripts/optimize-images.ts:14: process.env.COMMIT_SHA",
        exitCode: 0,
      },
    },
    {
      type: "tool-readFile",
      toolCallId: "read",
      state: "output-available",
      input: { path: "scripts/optimize-images.ts" },
      output: { content: "const cacheKey = process.env.COMMIT_SHA" },
    },
    {
      type: "text",
      text: "Key the image cache on the image directory so unchanged files can be reused.",
    },
  ],
};
export default function AiChainOfThoughtExample() {
  return (
    <div style={frame}>
      <ChainOfThoughtSdkAdapter message={sdkMessage} streaming={false} />
    </div>
  );
}

Components and API

This composition has no separate component API. Use the APIs for its installed components.

ComponentTarget reference
trace React Aria API
code-block React Aria API
badge React Aria API
icon React Aria API
button React Aria API
message React Aria API
bubble React Aria API