Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new
Documentation AI Elements Reka UI Chain of Thought

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 Reka UI. Each example runs with local data.

Installation

Complete project setup with vue/reka, then install these components:

pnpm exec frontfriend download trace code-block badge icon button message bubble -f vue --primitive reka

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

Save as ChainOfThoughtExample.vue.

<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount } from "vue";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Badge } from "@/components/ui/badge";
type Phase = "complete" | "running" | "error" | "pending";
type Step = {
  id: string;
  title: string;
  status?: "running" | "error" | "pending";
  output?: string;
  files?: string[];
  href?: string;
  children?: Step[];
  defaultOpen?: boolean;
};
const props = withDefaults(
  defineProps<{
    state?: Phase;
    nested?: boolean;
    steps?: Step[];
    open?: Record<string, boolean>;
  }>(),
  { state: "complete", nested: false, open: () => ({}) },
);
const emit = defineEmits<{ expand: [id: string, value: boolean] }>();
const phase = ref<Phase>(props.state);
let timer: ReturnType<typeof setTimeout> | undefined;
watch(
  () => props.state,
  (value) => {
    clearTimeout(timer);
    phase.value = value;
  },
);
onBeforeUnmount(() => clearTimeout(timer));
function replay() {
  clearTimeout(timer);
  expanded.value = {};
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    phase.value = "complete";
    return;
  }
  phase.value = "running";
  timer = setTimeout(() => {
    phase.value = "complete";
  }, 3600);
}
const expanded = ref<Record<string, boolean>>({});
function expand(id: string, value: boolean) {
  expanded.value = { ...expanded.value, [id]: value };
}
const steps = computed<Step[]>(() =>
  phase.value === "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.value === "running"
              ? "Searching the web for image cache keys"
              : phase.value === "error"
                ? "Failed to search the web"
                : "Searched the web for image cache keys",
          status:
            phase.value === "running"
              ? "running"
              : phase.value === "error"
                ? "error"
                : undefined,
          output:
            phase.value === "error"
              ? "Search timed out. Try again."
              : undefined,
          children:
            phase.value === "complete"
              ? [
                  {
                    id: "image-docs",
                    title: "Image component · nuxt.com",
                    href: "https://nuxt.com/docs/guide/directory-structure/public",
                  },
                  {
                    id: "cache-docs",
                    title: "Build cache · vercel.com",
                    href: "https://vercel.com/docs/deployments/troubleshoot-a-build",
                  },
                ]
              : undefined,
        },
        {
          id: "code",
          title:
            phase.value === "complete"
              ? "Searched the code for cacheKey"
              : "Search the code for cacheKey",
          status: phase.value === "complete" ? undefined : "pending",
          files:
            phase.value === "complete"
              ? ["nuxt.config.ts", "scripts/optimize-images.ts"]
              : undefined,
        },
      ],
);
function icon(step: Step) {
  return step.href
    ? "Globe"
    : step.status === "error"
      ? "CircleAlert"
      : step.status === "running"
        ? "LoaderCircle"
        : step.status === "pending"
          ? "CircleDashed"
          : "CircleCheck";
}
</script>

<template>
  <Trace v-if="nested">
    <template v-for="step in props.steps" :key="step.id">
      <a
        v-if="step.href"
        :href="step.href"
        target="_blank"
        rel="noreferrer"
        :aria-label="step.title + ' (opens in a new tab)'"
      >
        <TraceLine :status="step.status">
          <TraceIcon>
            <Icon :name="icon(step)" />
          </TraceIcon>
          <TraceTitle>{{ step.title }}</TraceTitle>
        </TraceLine>
      </a>
      <TraceCollapsible
        :unmount-on-hide="false"
        v-else-if="step.output || step.files || step.children"
        :open="open[step.id] ?? step.defaultOpen === true"
        @update:open="emit('expand', step.id, $event)"
      >
        <TraceCollapsibleTrigger>
          <TraceLine :status="step.status">
            <TraceIcon>
              <Icon :name="icon(step)" />
            </TraceIcon>
            <TraceTitle>{{ step.title }}</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <CodeBlock
            v-if="step.output"
            size="sm"
            :variant="step.status === 'error' ? 'destructive' : 'default'"
          >
            <CodeBlockContent wrap style="max-height: 256px">
              {{ step.output }}
            </CodeBlockContent>
          </CodeBlock>
          <div
            v-if="step.files"
            style="display: flex; flex-wrap: wrap; gap: 6px"
          >
            <Badge v-for="file in step.files" :key="file" variant="secondary">
              {{ file }}
            </Badge>
          </div>
          <ChainOfThoughtExample
            v-if="step.children"
            :steps="step.children"
            nested
            :open="open"
            @expand="(id, value) => emit('expand', id, value)"
          />
        </TraceCollapsibleContent>
      </TraceCollapsible>
      <template v-else>
        <TraceLine :status="step.status">
          <TraceIcon>
            <Icon :name="icon(step)" />
          </TraceIcon>
          <TraceTitle>{{ step.title }}</TraceTitle>
        </TraceLine>
      </template>
    </template>
  </Trace>
  <div
    v-else
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      min-height: 288px;
      margin-inline: auto;
      display: flex;
      flex-direction: column;
      gap: 16px;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <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: 16px; min-width: 0">
        <Trace>
          <TraceCollapsible
            :unmount-on-hide="false"
            :open="expanded.summary ?? phase !== 'complete'"
            @update:open="expand('summary', $event)"
          >
            <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>
              <ChainOfThoughtExample
                :steps="steps"
                nested
                :open="expanded"
                @expand="expand"
              />
            </TraceCollapsibleContent>
          </TraceCollapsible>
        </Trace>
        <p v-if="phase === 'complete'">
          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"
        :disabled="phase === 'running'"
        @click="replay"
      >
        Replay
      </Button>
    </div>
  </div>
</template>

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.

Save as AiChainOfThoughtAdapter.vue.

<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed, ref } from "vue";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceCollapsibleContent,
  TraceLine,
  TraceTitle,
  TraceIcon,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
type SdkCommandMessage = UIMessage<
  unknown,
  {},
  {
    runCommand: {
      input: { command: string };
      output: { stdout: string; exitCode: number };
    };
  }
>;
type SdkCommandPart = Extract<
  SdkCommandMessage["parts"][number],
  { type: "tool-runCommand" }
>;
function commandState(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 pending = 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 && !pending && !approved && !streaming);
  const status = denied
    ? undefined
    : pending
      ? "pending"
      : failed
        ? "error"
        : finished
          ? undefined
          : "running";
  return {
    status,
    title: `${denied ? "Denied" : pending ? "Waiting to run" : failed ? "Failed to run" : finished ? "Ran" : "Running"} ${command}`,
    output:
      part.state === "output-error"
        ? part.errorText
        : part.state === "output-available"
          ? part.output.stdout
          : undefined,
  } as const;
}
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}` }
>;
function researchStep(
  part: SdkStepPart,
  streaming: boolean,
): {
  id: string;
  title: string;
  status?: "pending" | "running" | "error";
  output?: string;
} {
  if (part.type === "tool-runCommand")
    return { id: part.toolCallId, ...commandState(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,
  };
}

const props = defineProps<{
  message: SdkChainMessage;
  part?: SdkStepPart;
  streaming: boolean;
}>();
const steps = computed(() =>
  props.message.parts.filter(
    (part): part is SdkStepPart =>
      part.type === "tool-runCommand" ||
      part.type === "tool-searchDocs" ||
      part.type === "tool-readFile",
  ),
);
const running = computed(
  () =>
    props.streaming &&
    !props.message.parts.some(
      (part) => part.type === "text" && part.text.trim().length > 0,
    ),
);
const failed = computed(
  () =>
    steps.value.filter(
      (part) => researchStep(part, props.streaming).status === "error",
    ).length,
);
const row = computed(() =>
  props.part ? researchStep(props.part, props.streaming) : undefined,
);
const open = ref<Record<string, boolean>>({});
function setOpen(id: string, value: boolean) {
  open.value = { ...open.value, [id]: value };
}
</script>
<template>
  <template v-if="part && row">
    <TraceCollapsible
      :unmount-on-hide="false"
      v-if="row.output !== undefined"
      :open="open[row.id] ?? row.status === 'error'"
      @update:open="setOpen(row.id, $event)"
    >
      <TraceCollapsibleTrigger>
        <TraceLine :status="row.status">
          <TraceIcon>
            <Icon :name="row.status === 'error' ? 'CircleAlert' : 'Terminal'" />
          </TraceIcon>
          <TraceTitle>{{ row.title }}</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <CodeBlock
          size="sm"
          :variant="row.status === 'error' ? 'destructive' : 'default'"
        >
          <CodeBlockContent wrap style="max-height: 256px">
            {{ row.output }}
          </CodeBlockContent>
        </CodeBlock>
      </TraceCollapsibleContent>
    </TraceCollapsible>
    <template v-else>
      <TraceLine :status="row.status">
        <TraceIcon>
          <Icon :name="row.status === 'error' ? 'CircleAlert' : 'Terminal'" />
        </TraceIcon>
        <TraceTitle>{{ row.title }}</TraceTitle>
      </TraceLine>
    </template>
  </template>
  <div
    v-else
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      margin-inline: auto;
      display: grid;
      gap: 16px;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <Trace>
      <TraceCollapsible
        :unmount-on-hide="false"
        :open="open.summary ?? running"
        @update:open="setOpen('summary', $event)"
      >
        <TraceCollapsibleTrigger>
          <TraceLine
            :status="running ? 'running' : failed ? 'error' : undefined"
          >
            <TraceTitle>
              {{
                running
                  ? "Researching"
                  : `Researched in ${steps.length} steps${failed ? `, ${failed} failed` : ""}`
              }}
            </TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <Trace>
            <AiChainOfThoughtAdapter
              :message="message"
              v-for="step in steps"
              :key="step.toolCallId"
              :part="step"
              :streaming="streaming"
            />
          </Trace>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
    <template v-for="(item, index) in message.parts" :key="index">
      <p v-if="item.type === 'text'">{{ item.text }}</p>
    </template>
  </div>
</template>

Components and API

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

ComponentTarget reference
trace Reka UI API
code-block Reka UI API
badge Reka UI API
icon Reka UI API
button Reka UI API
message Reka UI API
bubble Reka UI API