10.7k

Chain of Thought

PreviousNext

Displays a summary that expands into the steps an agent took.

Why is the checkout deploy so slow?

Image optimization takes almost four minutes because its cache key includes the commit hash. Key it on the image directory instead.

<script setup lang="ts">
import {
  ArrowUpRightIcon,
  CircleAlertIcon,
  CircleCheckIcon,
  GlobeIcon,
  LoaderCircleIcon,
} from '@lucide/vue'
import { createReusableTemplate, promiseTimeout, tryOnScopeDispose } from '@vueuse/core'
import { computed, reactive, ref } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import { CodeBlock, CodeBlockContent } from '@/components/ui/code-block'
import { Message, MessageContent } from '@/components/ui/message'
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'

type ToolState = 'input-available' | 'output-available' | 'output-error'
type Status = 'running' | 'error' | undefined

interface ToolPartBase<TType extends string, TInput, TOutput> {
  type: TType
  toolCallId: string
  state: ToolState
  input: TInput
  output?: TOutput
  errorText?: string
}

type StepPart
  = | ToolPartBase<'tool-readBuildLog', { deployment: string }, { log: string }>
    | ToolPartBase<'tool-searchWeb', { query: string }, { results: { title: string, url: string }[] }>
    | ToolPartBase<'tool-searchCode', { query: string }, { files: string[] }>

interface TextPart {
  type: 'text'
  text: string
}

interface ChatMessage {
  id: string
  role: 'user' | 'assistant'
  parts: (StepPart | TextPart)[]
}

const QUESTION = 'Why is the checkout deploy so slow?'
const ANSWER = 'Image optimization takes almost four minutes because its cache key includes the commit hash. Key it on the image directory instead.'

// The steps the agent takes, in order, with their output.
const SCRIPT: Omit<StepPart, 'toolCallId' | 'state'>[] = [
  {
    type: 'tool-readBuildLog',
    input: { deployment: 'dpl_8Kx2' },
    output: { log: 'install    41s\ncompile    38s\nimages   3m 52s' },
  },
  {
    type: 'tool-searchWeb',
    input: { query: 'next.js image cache key' },
    output: {
      results: [
        {
          title: 'Image component | nextjs.org',
          url: 'https://nextjs.org/docs/app/api-reference/components/image',
        },
        {
          title: 'Build cache | vercel.com',
          url: 'https://vercel.com/docs/deployments/troubleshoot-a-build',
        },
      ],
    },
  },
  {
    type: 'tool-searchCode',
    input: { query: 'cacheKey' },
    output: { files: ['next.config.ts', 'scripts/optimize-images.ts'] },
  },
]

function transcript(): ChatMessage[] {
  return [
    { id: 'user-0', role: 'user', parts: [{ type: 'text', text: QUESTION }] },
    {
      id: 'assistant-0',
      role: 'assistant',
      parts: [
        ...SCRIPT.map((step, index) => ({
          ...step,
          toolCallId: `call-0-${index}`,
          state: 'output-available',
        }) as StepPart),
        { type: 'text', text: ANSWER },
      ],
    },
  ]
}

const messages = ref<ChatMessage[]>(transcript())
const status = ref<'ready' | 'submitted' | 'streaming'>('ready')
const busy = computed(() => status.value !== 'ready')

// The open state of each summary, once the person opens or closes it.
const openState = ref<Record<string, boolean | undefined>>({})

// Unmounting bumps `run`, so a running script stops at its next wait.
let run = 0
tryOnScopeDispose(() => run++)

async function sleep(ms: number) {
  const id = run
  await promiseTimeout(ms)
  return id === run
}

async function streamInto(target: { text: string }, text: string, delayMs: number) {
  for (const chunk of text.match(/\S+\s*/g) ?? []) {
    target.text += chunk
    if (!(await sleep(delayMs)))
      return false
  }
  return true
}

function getText(message: ChatMessage) {
  return message.parts
    .filter((part): part is TextPart => part.type === 'text')
    .map(part => part.text)
    .join('')
}

function getSteps(message: ChatMessage) {
  return message.parts.filter((part): part is StepPart => part.type !== 'text')
}

function isStreaming(message: ChatMessage) {
  return busy.value && message === messages.value.at(-1)
}

function getSummary(message: ChatMessage) {
  const steps = getSteps(message)
  const running = isStreaming(message) && getText(message).length === 0
  const failed = steps.filter(part => part.state === 'output-error').length
  const status: Status = running ? 'running' : failed > 0 ? 'error' : undefined
  const title = running
    ? 'Researching'
    : `Researched in ${steps.length} steps${failed > 0 ? `, ${failed} failed` : ''}`

  return { running, status, title }
}

function getStepStatus(part: StepPart, streaming: boolean): Status {
  if (part.state === 'output-available')
    return undefined
  return part.state === 'output-error' || !streaming ? 'error' : 'running'
}

function getStepTitle(part: StepPart, streaming: boolean) {
  const status = getStepStatus(part, streaming)
  const verb = (running: string, done: string, failed: string) =>
    status === 'running' ? running : status === 'error' ? failed : done

  if (part.type === 'tool-searchWeb')
    return `${verb('Searching', 'Searched', 'Failed to search')} the web for ${part.input.query}`
  if (part.type === 'tool-readBuildLog')
    return `${verb('Reading', 'Read', 'Failed to read')} the build log for ${part.input.deployment}`
  return `${verb('Searching', 'Searched', 'Failed to search')} the code for ${part.input.query}`
}

async function replay() {
  const id = ++run
  messages.value = [{ id: `user-${id}`, role: 'user', parts: [{ type: 'text', text: QUESTION }] }]
  openState.value = {}
  status.value = 'submitted'
  if (!(await sleep(300)))
    return

  const message = reactive<ChatMessage>({ id: `assistant-${id}`, role: 'assistant', parts: [] })
  messages.value.push(message)
  status.value = 'streaming'

  for (const [index, step] of SCRIPT.entries()) {
    const { output, ...rest } = step
    const part = reactive({
      ...rest,
      toolCallId: `call-${id}-${index}`,
      state: 'input-available',
    } as StepPart)
    message.parts.push(part)
    if (!(await sleep(900)))
      return
    part.output = output as never
    part.state = 'output-available'
  }

  const text = reactive<TextPart>({ type: 'text', text: '' })
  message.parts.push(text)
  if (!(await streamInto(text, ANSWER, 20)))
    return
  status.value = 'ready'
}

const [DefineLine, ReuseLine] = createReusableTemplate<{
  title: string
  status: Status
}>()
</script>

<template>
  <DefineLine v-slot="{ title, status: lineStatus }">
    <TraceLine :status="lineStatus">
      <TraceIcon>
        <LoaderCircleIcon v-if="lineStatus === 'running'" class="animate-spin" />
        <CircleAlertIcon v-else-if="lineStatus === 'error'" />
        <CircleCheckIcon v-else />
      </TraceIcon>
      <TraceTitle>{{ title }}</TraceTitle>
    </TraceLine>
  </DefineLine>

  <div class="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm">
    <template v-for="message in messages" :key="message.id">
      <Message v-if="message.role === 'user'" align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>{{ getText(message) }}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message v-else>
        <MessageContent class="gap-4">
          <Trace v-if="getSteps(message).length > 0">
            <TraceCollapsible
              :open="openState[message.id] ?? getSummary(message).running"
              :unmount-on-hide="false"
              @update:open="openState[message.id] = $event"
            >
              <TraceCollapsibleTrigger>
                <ReuseLine
                  :title="getSummary(message).title"
                  :status="getSummary(message).status"
                />
              </TraceCollapsibleTrigger>
              <TraceCollapsibleContent>
                <Trace>
                  <template v-for="part in getSteps(message)" :key="part.toolCallId">
                    <ReuseLine
                      v-if="part.state === 'input-available'"
                      :title="getStepTitle(part, isStreaming(message))"
                      :status="getStepStatus(part, isStreaming(message))"
                    />
                    <TraceCollapsible
                      v-else
                      :default-open="part.type === 'tool-readBuildLog'"
                    >
                      <TraceCollapsibleTrigger>
                        <ReuseLine
                          :title="getStepTitle(part, isStreaming(message))"
                          :status="getStepStatus(part, isStreaming(message))"
                        />
                      </TraceCollapsibleTrigger>
                      <TraceCollapsibleContent>
                        <CodeBlock v-if="part.state === 'output-error'" size="sm" variant="destructive">
                          <CodeBlockContent wrap class="max-h-64">
                            {{ part.errorText }}
                          </CodeBlockContent>
                        </CodeBlock>
                        <Trace v-else-if="part.type === 'tool-searchWeb'">
                          <a
                            v-for="result in part.output?.results"
                            :key="result.url"
                            :href="result.url"
                            target="_blank"
                            rel="noreferrer"
                          >
                            <TraceLine>
                              <TraceIcon>
                                <GlobeIcon />
                              </TraceIcon>
                              <TraceTitle>
                                {{ result.title }}
                                <span class="sr-only"> (opens in a new tab)</span>
                              </TraceTitle>
                              <TraceIcon>
                                <ArrowUpRightIcon />
                              </TraceIcon>
                            </TraceLine>
                          </a>
                        </Trace>
                        <CodeBlock v-else-if="part.type === 'tool-readBuildLog'" size="sm">
                          <CodeBlockContent wrap class="max-h-64">
                            {{ part.output?.log }}
                          </CodeBlockContent>
                        </CodeBlock>
                        <div v-else class="flex flex-wrap gap-1.5">
                          <Badge
                            v-for="file in part.output?.files"
                            :key="file"
                            variant="secondary"
                            class="font-mono"
                          >
                            {{ file }}
                          </Badge>
                        </div>
                      </TraceCollapsibleContent>
                    </TraceCollapsible>
                  </template>
                </Trace>
              </TraceCollapsibleContent>
            </TraceCollapsible>
          </Trace>
          <p v-if="getText(message)">
            {{ getText(message) }}
          </p>
        </MessageContent>
      </Message>
    </template>
    <Button
      v-if="!busy"
      variant="outline"
      size="sm"
      class="mt-auto self-start"
      @click="replay"
    >
      Replay
    </Button>
  </div>
</template>

Chain of Thought is a summary line that expands into the steps an agent took. The summary says what happened, like "Researched in 3 steps". The steps sit inside it, each one a line of its own.

Chain of Thought is built with Trace with a TraceCollapsible for the summary.

Installation

Chain of Thought is built with the Trace, Code Block and Badge components.

pnpm dlx shadcn-vue@latest add trace code-block badge

Usage

<script setup lang="ts">
import { CircleCheckIcon } from '@lucide/vue'
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'
</script>

<template>
  <Trace>
    <TraceCollapsible default-open :unmount-on-hide="false">
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <CircleCheckIcon />
          </TraceIcon>
          <TraceTitle>Researched in 3 steps</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <Trace>
          <TraceLine>
            <TraceIcon>
              <CircleCheckIcon />
            </TraceIcon>
            <TraceTitle>Read the build log for dpl_8Kx2</TraceTitle>
          </TraceLine>
          <TraceLine>
            <TraceIcon>
              <CircleCheckIcon />
            </TraceIcon>
            <TraceTitle>Searched the web for next.js image cache key</TraceTitle>
          </TraceLine>
          <TraceLine>
            <TraceIcon>
              <CircleCheckIcon />
            </TraceIcon>
            <TraceTitle>Searched the code for cacheKey</TraceTitle>
          </TraceLine>
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </Trace>
</template>

Steps

Each step is a TraceLine. Set its status and write the title in the matching tense.

StepstatusTitle
Running"running"Searching for …
Done, with detailnoneSearched for …
Done, links outnoneRead the Image component docs
Failed"error"Failed to search for …

Set :unmount-on-hide="false" on the summary's TraceCollapsible. Steps keep their open state when the summary closes.

Search Results

When a step runs a web search, show the results as a Badge for each hostname in the step's content.

<script setup lang="ts">
function getHostname(url: string) {
  return new URL(url).hostname
}
</script>

<template>
  <TraceCollapsibleContent class="flex flex-wrap gap-1.5">
    <Badge v-for="result in results" :key="result.url" variant="secondary">
      {{ getHostname(result.url) }}
    </Badge>
  </TraceCollapsibleContent>
</template>

AI SDK

Render each tool part as a step. running is true while tool parts stream and before the reply starts. Set the summary's status and title from the steps. The summary opens while the steps run, as in Trace.

<script setup lang="ts">
import { computed, ref } from 'vue'

const props = defineProps<{
  steps: StepPart[]
  streaming: boolean
  answering: boolean
}>()

const open = ref<boolean>()
const running = computed(() => props.streaming && !props.answering)
const failed = computed(
  () => props.steps.filter(part => part.state === 'output-error').length,
)
</script>

<template>
  <TraceCollapsible
    :open="open ?? running"
    :unmount-on-hide="false"
    @update:open="open = $event"
  >
    <TraceCollapsibleTrigger>
      <TraceLine :status="running ? 'running' : failed > 0 ? 'error' : undefined">
        <TraceTitle>
          {{ running ? 'Researching' : `Researched in ${steps.length} steps` }}
        </TraceTitle>
      </TraceLine>
    </TraceCollapsibleTrigger>
    <TraceCollapsibleContent>
      <Trace>
        <StepLine
          v-for="part in steps"
          :key="part.toolCallId"
          :part="part"
          :streaming="streaming"
        />
      </Trace>
    </TraceCollapsibleContent>
  </TraceCollapsible>
</template>

StepLine renders one tool part as a TraceLine, as in Tool.

Migrating from AI Elements Vue

If you added ChainOfThought from AI Elements Vue, your project has a components/ai-elements/chain-of-thought/ folder. Paste this prompt into your coding agent to rebuild it on Trace and Badge. Every export keeps its name and props, so the code that uses it keeps working.

Migrate components/ai-elements/chain-of-thought/ from AI Elements Vue to the shadcn-vue Trace and Badge components.

Keep every export, with the same name, props and emits, so the code that imports it keeps working. Only change how each part renders.

1. Install the components: npx shadcn-vue@latest add trace badge
2. Read https://www.shadcn-vue.com/docs/ai-elements/chain-of-thought and build Chain of Thought the same way.
3. Rebuild each part:
   - ChainOfThought.vue renders a Trace with a TraceCollapsible. Pass open, defaultOpen and the update:open emit to the TraceCollapsible.
   - ChainOfThoughtHeader.vue renders a TraceCollapsibleTrigger with a TraceLine, a TraceIcon and a TraceTitle, like "Researched in 3 steps".
   - ChainOfThoughtContent.vue renders a TraceCollapsibleContent with a Trace for the steps. Set :unmount-on-hide="false" on the TraceCollapsible so the steps stay mounted.
   - ChainOfThoughtStep.vue renders a TraceLine. Its icon goes in a TraceIcon, its label in a TraceTitle and its description in a TraceDescription. Map status "active" to "running", "pending" to "pending", and "complete" to no status.
   - ChainOfThoughtSearchResults.vue renders a flex-wrap list, and ChainOfThoughtSearchResult.vue a Badge with variant="secondary".
   - Keep ChainOfThoughtImage.vue as it is.
4. Remove the imports the files no longer use.
5. Run vue-tsc and fix any errors.

API Reference

See Trace.