10.7k

Subagent

PreviousNext

Displays a subagent run, with its tool calls and result.

Why does image optimization rerun on every deploy?

The cache is keyed on the commit SHA, so every deploy misses it and optimizes all 1,240 images again.

Image optimization reruns because its cache is keyed on the commit SHA, which changes on every deploy. Key the cache on a hash of public/images instead, and unchanged images will be skipped.

<script setup lang="ts">
import {
  CircleAlertIcon,
  CircleCheckIcon,
  LoaderCircleIcon,
} from '@lucide/vue'
import { promiseTimeout, tryOnScopeDispose } from '@vueuse/core'
import { computed, ref } from 'vue'
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 SubagentPart
  = | { type: 'text', text: string }
    | {
      type: 'tool-runCommand'
      toolCallId: string
      state: 'input-available'
      input: { command: string }
    }
    | {
      type: 'tool-runCommand'
      toolCallId: string
      state: 'output-available'
      input: { command: string }
      output: { stdout: string }
    }

interface AgentPart {
  state: 'input-available' | 'output-available' | 'output-error'
  preliminary?: boolean
  output?: { parts: SubagentPart[] }
}

const QUESTION = 'Why does image optimization rerun on every deploy?'
const REPORT
  = 'The cache is keyed on the commit SHA, so every deploy misses it and optimizes all 1,240 images again.'
const ANSWER
  = 'Image optimization reruns because its cache is keyed on the commit SHA, which changes on every deploy. Key the cache on a hash of public/images instead, and unchanged images will be skipped.'

const steps = [
  {
    command: 'grep -rn cacheKey scripts',
    stdout:
      'scripts/optimize-images.ts:14:  const cacheKey = process.env.COMMIT_SHA',
  },
  {
    command: 'sed -n 13,15p scripts/optimize-images.ts',
    stdout:
      'const cacheDir = ".nuxt/cache/images"\nconst cacheKey = process.env.COMMIT_SHA\nconst cached = await restoreCache(cacheDir, cacheKey)',
  },
  {
    command: 'ls public/images | wc -l',
    stdout: '1240',
  },
]

function getSubagentParts(done: number, report?: string): SubagentPart[] {
  const parts: SubagentPart[] = steps.slice(0, done).map((step, index) => ({
    type: 'tool-runCommand',
    toolCallId: `step-${index}`,
    state: 'output-available',
    input: { command: step.command },
    output: { stdout: step.stdout },
  }))

  if (done < steps.length) {
    parts.push({
      type: 'tool-runCommand',
      toolCallId: `step-${done}`,
      state: 'input-available',
      input: { command: steps[done]!.command },
    })
  }

  if (report)
    parts.push({ type: 'text', text: report })

  return parts
}

const agent = ref<AgentPart | undefined>({
  state: 'output-available',
  output: { parts: getSubagentParts(steps.length, REPORT) },
})
const text = ref(ANSWER)
const busy = ref(false)

const row = computed(() => {
  const part = agent.value
  if (!part)
    return undefined

  const done = part.state === 'output-available' && !part.preliminary
  const running = !done && part.state !== 'output-error' && busy.value
  const parts = part.state === 'output-available' ? part.output?.parts ?? [] : []
  const report = parts
    .map(agentPart => (agentPart.type === 'text' ? agentPart.text : ''))
    .join('')

  return {
    done,
    running,
    status: done ? undefined : running ? 'running' as const : 'error' as const,
    title: `${done ? 'Ran' : running ? 'Running' : 'Failed to run'} the research subagent`,
    steps: parts.filter(agentPart => agentPart.type === 'tool-runCommand'),
    hasParts: parts.length > 0,
    report,
  }
})

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

async function wait(ms: number, id: number) {
  await promiseTimeout(ms)
  return id === run
}

async function replay() {
  const id = ++run
  busy.value = true
  agent.value = undefined
  text.value = ''

  agent.value = { state: 'input-available' }
  if (!(await wait(600, id)))
    return

  for (let index = 0; index < steps.length; index++) {
    agent.value = {
      state: 'output-available',
      preliminary: true,
      output: { parts: getSubagentParts(index) },
    }
    if (!(await wait(1000, id)))
      return
  }

  agent.value = {
    state: 'output-available',
    output: { parts: getSubagentParts(steps.length, REPORT) },
  }
  if (!(await wait(400, id)))
    return

  for (const word of ANSWER.split(/(?<= )/)) {
    if (!(await wait(20, id)))
      return
    text.value += word
  }

  busy.value = false
}
</script>

<template>
  <div class="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm">
    <Message align="end">
      <MessageContent>
        <Bubble align="end" variant="muted">
          <BubbleContent>{{ QUESTION }}</BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
    <Message v-if="row || text">
      <MessageContent class="gap-4">
        <Trace v-if="row">
          <TraceCollapsible
            v-if="row.hasParts"
            default-open
            :unmount-on-hide="false"
          >
            <TraceCollapsibleTrigger>
              <TraceLine :status="row.status">
                <TraceIcon>
                  <CircleCheckIcon v-if="row.done" />
                  <LoaderCircleIcon v-else-if="row.running" class="animate-spin" />
                  <CircleAlertIcon v-else />
                </TraceIcon>
                <TraceTitle>{{ row.title }}</TraceTitle>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent class="flex flex-col gap-3">
              <Trace>
                <template v-for="step in row.steps" :key="step.toolCallId">
                  <TraceCollapsible v-if="step.state === 'output-available'">
                    <TraceCollapsibleTrigger>
                      <TraceLine>
                        <TraceIcon>
                          <CircleCheckIcon />
                        </TraceIcon>
                        <TraceTitle>Ran {{ step.input.command }}</TraceTitle>
                      </TraceLine>
                    </TraceCollapsibleTrigger>
                    <TraceCollapsibleContent>
                      <CodeBlock size="sm">
                        <CodeBlockContent wrap class="max-h-64">
                          {{ step.output.stdout }}
                        </CodeBlockContent>
                      </CodeBlock>
                    </TraceCollapsibleContent>
                  </TraceCollapsible>
                  <TraceLine v-else :status="row.running ? 'running' : 'error'">
                    <TraceIcon>
                      <LoaderCircleIcon v-if="row.running" class="animate-spin" />
                      <CircleAlertIcon v-else />
                    </TraceIcon>
                    <TraceTitle>
                      {{ row.running ? 'Running' : 'Failed to run' }} {{ step.input.command }}
                    </TraceTitle>
                  </TraceLine>
                </template>
              </Trace>
              <p v-if="row.report">
                {{ row.report }}
              </p>
            </TraceCollapsibleContent>
          </TraceCollapsible>
          <TraceLine v-else :status="row.status">
            <TraceIcon>
              <CircleCheckIcon v-if="row.done" />
              <LoaderCircleIcon v-else-if="row.running" class="animate-spin" />
              <CircleAlertIcon v-else />
            </TraceIcon>
            <TraceTitle>{{ row.title }}</TraceTitle>
          </TraceLine>
        </Trace>
        <p v-if="text">
          {{ text }}
        </p>
      </MessageContent>
    </Message>
    <Button
      v-if="!busy"
      variant="outline"
      size="sm"
      class="mt-auto self-start"
      @click="replay"
    >
      Replay
    </Button>
  </div>
</template>

Subagent is a line for work the agent hands to a subagent. The run is the title, like "Ran the research agent". Open it for the tool calls the subagent made and what it found.

Subagent is built with Trace with a TraceCollapsible for the run and a nested Trace for its tool calls.

Installation

Subagent 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 :unmount-on-hide="false">
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <CircleCheckIcon />
          </TraceIcon>
          <TraceTitle>Ran the research agent</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent class="flex flex-col gap-3">
        <Trace>
          <TraceLine>
            <TraceIcon>
              <CircleCheckIcon />
            </TraceIcon>
            <TraceTitle>Ran grep -rn cacheKey scripts</TraceTitle>
          </TraceLine>
        </Trace>
        <p>The cache is keyed on the commit SHA.</p>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </Trace>
</template>

Running

While the subagent runs, the line has status="running" and its content shows the tool calls so far. Add the result when the subagent finishes.

Ran grep -rn cacheKey scripts Running sed -n 13,15p scripts/optimize-images.ts
<script setup lang="ts">
import { CircleCheckIcon, LoaderCircleIcon } from '@lucide/vue'
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'
</script>

<template>
  <Trace class="max-w-sm">
    <TraceCollapsible default-open :unmount-on-hide="false">
      <TraceCollapsibleTrigger>
        <TraceLine status="running">
          <TraceIcon>
            <LoaderCircleIcon class="animate-spin" />
          </TraceIcon>
          <TraceTitle>Running the research agent</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <Trace>
          <TraceLine>
            <TraceIcon>
              <CircleCheckIcon />
            </TraceIcon>
            <TraceTitle>Ran grep -rn cacheKey scripts</TraceTitle>
          </TraceLine>
          <TraceLine status="running">
            <TraceIcon>
              <LoaderCircleIcon class="animate-spin" />
            </TraceIcon>
            <TraceTitle>
              Running sed -n 13,15p scripts/optimize-images.ts
            </TraceTitle>
          </TraceLine>
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </Trace>
</template>

Details

Put the task the subagent was given at the top of the content, with its model and tools as Badge items. Show how long it ran in a TraceDescription after the title.

anthropic/claude-sonnet-4.5
runCommand

Find out why image optimization reruns on every deploy.

Ran grep -rn cacheKey scripts Ran sed -n 13,15p scripts/optimize-images.ts

The cache is keyed on the commit SHA.

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

<template>
  <Trace class="max-w-sm">
    <TraceCollapsible default-open :unmount-on-hide="false">
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <CircleCheckIcon />
          </TraceIcon>
          <TraceTitle>Ran the research agent</TraceTitle>
          <TraceDescription>48s</TraceDescription>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent class="flex flex-col gap-3">
        <div class="flex flex-wrap gap-1.5">
          <Badge variant="secondary">
            anthropic/claude-sonnet-4.5
          </Badge>
          <Badge variant="outline" class="font-mono">
            runCommand
          </Badge>
        </div>
        <p>Find out why image optimization reruns on every deploy.</p>
        <Trace>
          <TraceLine>
            <TraceIcon>
              <CircleCheckIcon />
            </TraceIcon>
            <TraceTitle>Ran grep -rn cacheKey scripts</TraceTitle>
          </TraceLine>
          <TraceLine>
            <TraceIcon>
              <CircleCheckIcon />
            </TraceIcon>
            <TraceTitle>
              Ran sed -n 13,15p scripts/optimize-images.ts
            </TraceTitle>
          </TraceLine>
        </Trace>
        <p>The cache is keyed on the commit SHA.</p>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </Trace>
</template>

AI SDK

With the AI SDK, a subagent is a tool whose execute yields the subagent's UIMessage as it streams. Render the tool part as the line and part.output.parts as the nested Trace. Map each nested tool part as in Tool.

Each yield is a preliminary output: the part is output-available with preliminary: true. The final output replaces it, so check preliminary before you show "Ran".

Tool part stateTraceLine statusTitle
input-streaming, input-available"running" while the chat streamsRunning …
output-available with preliminary: true"running" while the chat streamsRunning …
output-availablenoneRan …
output-error, or a running state after the chat stops"error"Failed to run …

Migrating from AI Elements Vue

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

Migrate components/ai-elements/agent/ from AI Elements Vue to the shadcn-vue Trace, Code Block and Badge components.

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

1. Install the components: npx shadcn-vue@latest add trace code-block badge
2. Read https://www.shadcn-vue.com/docs/ai-elements/subagent and build Subagent the same way.
3. Rebuild each part:
   - Import CodeBlock and CodeBlockContent from @/components/ui/code-block, not from ../code-block.
   - Agent renders a Trace with a TraceCollapsible.
   - AgentHeader renders a TraceCollapsibleTrigger with a TraceLine, a TraceIcon and a TraceTitle with the name, like "Ran the research agent". Show model in a Badge.
   - AgentContent renders a TraceCollapsibleContent, with :unmount-on-hide="false" on the TraceCollapsible.
   - AgentTools renders a Trace, and AgentTool a TraceLine for each tool call, instead of an Accordion.
   - Keep AgentInstructions and AgentOutput, with AgentOutput on CodeBlock.
4. Remove the imports the files no longer use.
5. Run vue-tsc and fix any errors.

API Reference

See Trace.