10.7k

Reasoning

PreviousNext

Displays the agent's thinking as it streams.

Why is the checkout deploy so slow?
The deploy log has three phases. Install takes 38s and next build takes 52s, both normal for this repo. Image optimization takes 3m 50s. It reruns on every deploy because its cache key includes the commit hash, so the cache never hits.

Image optimization is the bottleneck. Key its cache on the lockfile and the image directory instead of the commit hash, and the deploy should take under two minutes.

<script setup lang="ts">
import { promiseTimeout, tryOnScopeDispose } from '@vueuse/core'
import { computed, reactive, ref } from 'vue'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import { Message, MessageContent } from '@/components/ui/message'
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'

interface ReasoningPart {
  type: 'reasoning'
  text: string
  state: 'streaming' | 'done'
}

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

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

const QUESTION = 'Why is the checkout deploy so slow?'
const REASONING = 'The deploy log has three phases. Install takes 38s and next build takes 52s, both normal for this repo.\n\nImage optimization takes 3m 50s. It reruns on every deploy because its cache key includes the commit hash, so the cache never hits.'
const ANSWER = 'Image optimization is the bottleneck. Key its cache on the lockfile and the image directory instead of the commit hash, and the deploy should take under two minutes.'

function transcript(): ChatMessage[] {
  return [
    { id: 'user-0', role: 'user', parts: [{ type: 'text', text: QUESTION }] },
    {
      id: 'assistant-0',
      role: 'assistant',
      parts: [
        { type: 'reasoning', text: REASONING, state: 'done' },
        { 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 each line was set to by the person. Until then, a line
// follows `running`. The finished transcript starts with its lines open.
const openState = ref<Record<string, boolean | undefined>>({ 'assistant-0-0': true })

// 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 getReasoning(message: ChatMessage) {
  return message.parts.filter((part): part is ReasoningPart => part.type === 'reasoning')
}

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

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'

  const reasoning = reactive<ReasoningPart>({ type: 'reasoning', text: '', state: 'streaming' })
  message.parts.push(reasoning)
  if (!(await streamInto(reasoning, REASONING, 40)))
    return
  reasoning.state = 'done'
  if (!(await sleep(400)))
    return

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

<template>
  <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="getReasoning(message).length > 0">
            <template v-for="(part, index) in getReasoning(message)" :key="index">
              <TraceLine
                v-if="part.text.length === 0"
                :status="isStreaming(message) && part.state === 'streaming' ? 'running' : undefined"
              >
                <TraceTitle>
                  {{ isStreaming(message) && part.state === 'streaming' ? 'Thinking' : 'Thought for 5s' }}
                </TraceTitle>
              </TraceLine>
              <TraceCollapsible
                v-else
                :open="openState[`${message.id}-${index}`] ?? (isStreaming(message) && part.state === 'streaming')"
                @update:open="openState[`${message.id}-${index}`] = $event"
              >
                <TraceCollapsibleTrigger>
                  <TraceLine :status="isStreaming(message) && part.state === 'streaming' ? 'running' : undefined">
                    <TraceTitle>
                      {{ isStreaming(message) && part.state === 'streaming' ? 'Thinking' : 'Thought for 5s' }}
                    </TraceTitle>
                  </TraceLine>
                </TraceCollapsibleTrigger>
                <TraceCollapsibleContent class="whitespace-pre-wrap">
                  {{ part.text }}
                </TraceCollapsibleContent>
              </TraceCollapsible>
            </template>
          </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>

Reasoning is a line for the agent's thinking. It reads "Thinking" and opens while the model thinks, then reads "Thought" and closes when it is done. The thinking itself sits inside, and the line can show how long it took.

Reasoning is built with Trace with a TraceCollapsible for the thinking.

Installation

Reasoning is built with the Trace component.

pnpm dlx shadcn-vue@latest add trace

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>
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <CircleCheckIcon />
          </TraceIcon>
          <TraceTitle>Thought</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent class="whitespace-pre-wrap">
        {{ text }}
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </Trace>
</template>

Duration

The AI SDK does not time reasoning. Measure it on the server, send it as message metadata, and show it in a TraceDescription after the title.

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

const text = 'The build log shows install and compile finish in under a minute. Image optimization takes almost four, and it reruns on every deploy because the cache key includes the commit hash.'
</script>

<template>
  <Trace class="max-w-sm">
    <TraceCollapsible>
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <CircleCheckIcon />
          </TraceIcon>
          <TraceTitle>Thought</TraceTitle>
          <TraceDescription>12s</TraceDescription>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent class="whitespace-pre-wrap">
        {{ text }}
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </Trace>
</template>
<template>
  <TraceLine>
    <TraceIcon>
      <CircleCheckIcon />
    </TraceIcon>
    <TraceTitle>Thought</TraceTitle>
    <TraceDescription>{{ seconds }}s</TraceDescription>
  </TraceLine>
</template>

AI SDK

Render each reasoning part as a line. It starts as a plain TraceLine and becomes a TraceCollapsible once text arrives, so there is nothing to open before the model has written anything. It opens while the model thinks and closes when it finishes. If the person opens or closes it, their choice wins. See Open State.

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

defineProps<{
  text: string
  running: boolean
}>()

const open = ref<boolean>()
</script>

<template>
  <TraceLine v-if="text.length === 0" :status="running ? 'running' : undefined">
    <TraceIcon>
      <LoaderCircleIcon v-if="running" class="animate-spin" />
      <CircleCheckIcon v-else />
    </TraceIcon>
    <TraceTitle>{{ running ? 'Thinking' : 'Thought' }}</TraceTitle>
  </TraceLine>
  <TraceCollapsible v-else :open="open ?? running" @update:open="open = $event">
    <TraceCollapsibleTrigger>
      <TraceLine :status="running ? 'running' : undefined">
        <TraceIcon>
          <LoaderCircleIcon v-if="running" class="animate-spin" />
          <CircleCheckIcon v-else />
        </TraceIcon>
        <TraceTitle>{{ running ? 'Thinking' : 'Thought' }}</TraceTitle>
      </TraceLine>
    </TraceCollapsibleTrigger>
    <TraceCollapsibleContent class="whitespace-pre-wrap">
      {{ text }}
    </TraceCollapsibleContent>
  </TraceCollapsible>
</template>
<template>
  <Trace>
    <ReasoningLine
      v-for="(part, index) in message.parts.filter(part => part.type === 'reasoning')"
      :key="index"
      :text="part.text"
      :running="streaming && part.state === 'streaming'"
    />
  </Trace>
</template>

Some models send several reasoning parts in one message. Each one is a line in the same Trace.

Migrating from AI Elements Vue

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

Migrate components/ai-elements/reasoning/ from AI Elements Vue to the shadcn-vue Trace component.

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 Trace: npx shadcn-vue@latest add trace
2. Read https://www.shadcn-vue.com/docs/ai-elements/reasoning and build Reasoning the same way.
3. Rebuild each part:
   - Reasoning.vue renders a Trace with a TraceCollapsible. Keep isStreaming, open, defaultOpen, the update:open emit and duration, and the reasoning context it provides.
   - ReasoningTrigger.vue renders a TraceCollapsibleTrigger with a TraceLine, a TraceIcon and a TraceTitle. Set status="running" while isStreaming is true, instead of the Shimmer. The title reads "Thinking" while it streams, then the duration, like "Thought for 5s".
   - ReasoningContent.vue renders a TraceCollapsibleContent. Keep the markdown renderer the file already uses for the text.
   - Keep the open behavior: open while it streams, close when it finishes, unless the person opened or closed it.
4. Remove the imports the files no longer use.
5. Run vue-tsc and fix any errors.

API Reference

See Trace.