10.7k

Plan

PreviousNext

Displays the steps the agent plans to take, and how far it has got.

Migrate the dialog components to Base UI.
Install @base-ui/reactReplace the Radix importsRename asChild to renderRun the type checker

Migrated Dialog and AlertDialog. The type checker passes.

<script setup lang="ts">
import {
  CircleCheckIcon,
  CircleDotIcon,
  CircleIcon,
  RouteIcon,
} from '@lucide/vue'
import { createReusableTemplate, 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,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'

interface PlanData {
  title: string
  steps: { title: string, status: 'done' | 'running' | 'pending' }[]
}

interface PlanPart {
  type: 'data-plan'
  id: string
  data: PlanData
}

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

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

const STEPS = [
  'Install @base-ui/react',
  'Replace the Radix imports',
  'Rename asChild to render',
  'Run the type checker',
]

const QUESTION = 'Migrate the dialog components to Base UI.'
const ANSWER = 'Migrated Dialog and AlertDialog. The type checker passes.'

function planAt(current: number): PlanData {
  return {
    title: 'Plan: migrate to Base UI',
    steps: STEPS.map((title, index) => ({
      title,
      status: index < current ? 'done' : index === current ? 'running' : 'pending',
    })),
  }
}

function transcript(): ChatMessage[] {
  return [
    { id: 'user-0', role: 'user', parts: [{ type: 'text', text: QUESTION }] },
    {
      id: 'assistant-0',
      role: 'assistant',
      parts: [
        { type: 'data-plan', id: 'plan', data: planAt(STEPS.length) },
        { type: 'text', text: ANSWER },
      ],
    },
  ]
}

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

// 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 getPlan(message: ChatMessage) {
  return message.parts.find((part): part is PlanPart => part.type === 'data-plan')?.data
}

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

function countDone(plan: PlanData) {
  return plan.steps.filter(step => step.status === 'done').length
}

async function replay() {
  const id = ++run
  messages.value = [{ id: `user-${id}`, role: 'user', parts: [{ type: 'text', text: QUESTION }] }]
  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'

  // A data part with the same id replaces the previous one in place.
  const plan = reactive<PlanPart>({ type: 'data-plan', id: 'plan', data: planAt(0) })
  message.parts.push(plan)
  for (let index = 1; index <= STEPS.length; index++) {
    if (!(await sleep(1200)))
      return
    plan.data = planAt(index)
  }

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

const [DefinePlanRow, PlanRow] = createReusableTemplate<{
  plan: PlanData
  streaming: boolean
}>()
</script>

<template>
  <DefinePlanRow v-slot="{ plan, streaming }">
    <TraceCollapsible default-open>
      <TraceCollapsibleTrigger>
        <TraceLine :status="streaming && countDone(plan) < plan.steps.length ? 'running' : undefined">
          <TraceIcon>
            <RouteIcon />
          </TraceIcon>
          <TraceTitle>{{ plan.title }}</TraceTitle>
          <TraceDescription>
            {{ countDone(plan) }} of {{ plan.steps.length }} done
          </TraceDescription>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <Trace>
          <TraceLine
            v-for="step in plan.steps"
            :key="step.title"
            :status="step.status === 'pending'
              ? 'pending'
              : streaming && step.status === 'running'
                ? 'running'
                : undefined"
          >
            <TraceIcon>
              <CircleCheckIcon v-if="step.status === 'done'" />
              <CircleDotIcon v-else-if="step.status === 'running'" />
              <CircleIcon v-else />
            </TraceIcon>
            <TraceTitle>{{ step.title }}</TraceTitle>
          </TraceLine>
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </DefinePlanRow>

  <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="getPlan(message)">
            <PlanRow :plan="getPlan(message)!" :streaming="isStreaming(message)" />
          </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>

Plan is the list of steps the agent decides on before it starts, such as the steps of a migration. Each step is done, running or pending, and the title counts how many are done.

Plan is built with Trace with a TraceCollapsible for the plan.

When the steps are not known up front, use Task instead.

Installation

Plan is built with the Trace component.

pnpm dlx shadcn-vue@latest add trace

Usage

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

<template>
  <Trace>
    <TraceCollapsible default-open>
      <TraceCollapsibleTrigger>
        <TraceLine status="running">
          <TraceIcon>
            <RouteIcon />
          </TraceIcon>
          <TraceTitle>Plan: migrate to Base UI</TraceTitle>
          <TraceDescription>2 of 5 done</TraceDescription>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <Trace>
          <TraceLine>
            <TraceIcon>
              <CircleCheckIcon />
            </TraceIcon>
            <TraceTitle>Replace the Radix imports</TraceTitle>
          </TraceLine>
          <TraceLine status="running">
            <TraceIcon>
              <CircleDotIcon />
            </TraceIcon>
            <TraceTitle>Rename asChild to render</TraceTitle>
          </TraceLine>
          <TraceLine status="pending">
            <TraceIcon>
              <CircleIcon />
            </TraceIcon>
            <TraceTitle>Run the type checker</TraceTitle>
          </TraceLine>
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </Trace>
</template>

Details

Put a description above the steps, inside TraceCollapsibleContent.

Store the theme in a cookie so the server renders the right one and the page does not flash on load.

Add a dark variant to the theme tokensRead the theme from a cookie on the serverAdd a toggle to the settings pageCheck contrast on the charts
<script setup lang="ts">
import { CircleCheckIcon, CircleIcon, RouteIcon } from '@lucide/vue'
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'

const steps = [
  'Add a dark variant to the theme tokens',
  'Read the theme from a cookie on the server',
  'Add a toggle to the settings page',
  'Check contrast on the charts',
]
</script>

<template>
  <Trace class="max-w-sm">
    <TraceCollapsible default-open>
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <RouteIcon />
          </TraceIcon>
          <TraceTitle>Plan: add dark mode</TraceTitle>
          <TraceDescription>1 of {{ steps.length }} done</TraceDescription>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent class="flex flex-col gap-3">
        <p>
          Store the theme in a cookie so the server renders the right one and
          the page does not flash on load.
        </p>
        <Trace>
          <TraceLine
            v-for="(step, index) in steps"
            :key="step"
            :status="index > 0 ? 'pending' : undefined"
          >
            <TraceIcon>
              <CircleCheckIcon v-if="index === 0" />
              <CircleIcon v-else />
            </TraceIcon>
            <TraceTitle>{{ step }}</TraceTitle>
          </TraceLine>
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </Trace>
</template>

AI SDK

Stream the plan as a data part with a fixed id. The AI SDK replaces a data part with the same id, so it updates in place.

server/api/chat.post.ts
const stream = createUIMessageStream({
  execute: async ({ writer }) => {
    writer.write({ type: 'data-plan', id: 'plan', data: { title, steps } })
  },
})
const plan = computed(() =>
  props.message.parts.find(part => part.type === 'data-plan'),
)

Migrating from AI Elements Vue

If you added Plan from AI Elements Vue, your project has a components/ai-elements/plan/ 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/plan/ 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/plan and build Plan the same way.
3. Rebuild each part:
   - Plan.vue renders a Trace with a TraceCollapsible, instead of a Card. Provide isStreaming to the other parts.
   - PlanHeader.vue renders a TraceCollapsibleTrigger with a TraceLine. Set status="running" on the TraceLine while isStreaming is true, instead of the Shimmer.
   - PlanTitle.vue renders a TraceTitle, and PlanDescription.vue a TraceDescription, like "2 of 4 done".
   - PlanContent.vue renders a TraceCollapsibleContent with a Trace for the steps.
   - PlanTrigger.vue renders only its slot, since the whole line opens the plan.
   - Buttons cannot sit inside a trigger. Render PlanAction.vue next to the TraceCollapsibleTrigger, not inside it.
   - Keep PlanFooter.vue as a plain container.
4. Remove the imports the files no longer use.
5. Run vue-tsc and fix any errors.

API Reference

See Trace.