10.7k

Chat

PreviousNext

The thread. Message Scroller keeps your place while the reply streams.

New Thread
Ask the agent to change your code.
What should we build?

The agent can read your code, run commands and make changes. Press send to start.

<script setup lang="ts">
import {
  ArrowUpIcon,
  CircleCheckIcon,
  GlobeIcon,
  LoaderCircleIcon,
  MessageCircleDashedIcon,
  PaperclipIcon,
  PlusIcon,
  RotateCwIcon,
  TelescopeIcon,
  TerminalIcon,
} from '@lucide/vue'
import { createReusableTemplate, promiseTimeout, tryOnScopeDispose } from '@vueuse/core'
import { computed, reactive, ref } from 'vue'
import Markdown from '@/components/Markdown.vue'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import { CodeBlock, CodeBlockContent } from '@/components/ui/code-block'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/components/ui/empty'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from '@/components/ui/input-group'
import { Message, MessageContent } from '@/components/ui/message'
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from '@/components/ui/message-scroller'
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/ui/tooltip'
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'

// The parts of an agent turn, shaped like AI SDK UI message parts.
type ChatPart
  = | { type: 'reasoning', text: string, state: 'streaming' | 'done' }
    | {
      type: 'tool-runCommand'
      state: 'input-available' | 'output-available' | 'output-error'
      input: { command: string }
      output?: { stdout: string }
      errorText?: string
    }
    | { type: 'text', text: string }

interface ChatMessage {
  id: string
  role: 'user' | 'assistant'
  parts: ChatPart[]
}

// A scripted agent turn. Each step streams into the message in order.
type Step
  = | { type: 'reasoning', text: string }
    | { type: 'sleep', ms: number }
    | { type: 'tool', command: string, ms: number, stdout: string }
    | { type: 'text', text: string }

const script: { user: string, steps: Step[] }[] = [
  {
    user: 'Add a dark mode toggle to the settings page.',
    steps: [
      {
        type: 'reasoning',
        text: 'I need to know how themes are set up before I add a toggle. If @nuxtjs/color-mode is already installed, the toggle only has to set colorMode.preference.',
      },
      { type: 'sleep', ms: 400 },
      {
        type: 'tool',
        command: 'rg -l colorMode nuxt.config.ts app',
        ms: 800,
        stdout: 'nuxt.config.ts',
      },
      { type: 'sleep', ms: 500 },
      {
        type: 'text',
        text: `@nuxtjs/color-mode is already set up in \`nuxt.config.ts\`, so the toggle only has to set \`colorMode.preference\`.

**Changes**

- Added \`components/ModeToggle.vue\` with a Light and Dark option.
- Put \`<ModeToggle />\` next to the Appearance heading in \`pages/settings.vue\`.

\`\`\`ts
const colorMode = useColorMode()
\`\`\`
`,
      },
    ],
  },
  {
    user: 'Make it follow the system setting by default.',
    steps: [
      {
        type: 'reasoning',
        text: 'The default theme lives in the colorMode options. I should check what it is set to now.',
      },
      { type: 'sleep', ms: 400 },
      {
        type: 'tool',
        command: 'rg -n preference nuxt.config.ts',
        ms: 800,
        stdout: '12:    preference: \'light\',',
      },
      { type: 'sleep', ms: 500 },
      {
        type: 'text',
        text: `It was set to \`light\`. I updated the \`colorMode\` options in \`nuxt.config.ts\`:

\`\`\`ts
colorMode: { classSuffix: '', preference: 'system' },
\`\`\`

New visitors now get the theme from their OS. The toggle also has a **System** option, so anyone who picked Light or Dark can switch back.
`,
      },
    ],
  },
  {
    user: 'Run the build before I ship it.',
    steps: [
      {
        type: 'tool',
        command: 'pnpm build',
        ms: 1600,
        stdout:
          'Nuxt 4.0.0 with Nitro 2.12.0\n\n✓ Client built in 4.2s\n✓ Server built in 1.8s\n✓ Prerendered 12 routes',
      },
      { type: 'sleep', ms: 500 },
      {
        type: 'text',
        text: `The build passes, so it is ready to ship.

- Built the client in **4.2s**.
- No build or type errors.
- Prerendered all **12** routes.
`,
      },
    ],
  },
]

const fallback = 'This demo only knows the scripted turns. Press reset to start over.'

const messages = ref<ChatMessage[]>([])
const status = ref<'ready' | 'submitted' | 'streaming'>('ready')
const draft = ref<string | null>(null)
const formRef = ref<HTMLFormElement | null>(null)
// Open state of each reasoning row, set once the person toggles it.
const reasoningOpen = reactive<Record<string, boolean | undefined>>({})

const busy = computed(() => status.value !== 'ready')
const sentCount = computed(() => messages.value.filter(message => message.role === 'user').length)
const nextMessage = computed(() => script[sentCount.value]?.user ?? null)
const text = computed({
  get: () => draft.value ?? nextMessage.value ?? '',
  set: (value: string) => {
    draft.value = value
  },
})
const canSend = computed(() => text.value.trim().length > 0 && !busy.value)

// Simulated streaming. Reset and unmount bump `run`, so a running script
// stops at its next wait.
let run = 0
tryOnScopeDispose(() => run++)

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

async function stream(value: string, write: (text: string) => void, id: number) {
  for (let cursor = 0; cursor < value.length;) {
    cursor = Math.min(value.length, cursor + 3)
    write(value.slice(0, cursor))
    if (!(await sleep(16, id)))
      return false
  }
  return true
}

async function respond(steps: Step[]) {
  const id = run
  status.value = 'submitted'
  if (!(await sleep(600, id)))
    return

  messages.value.push({ id: `assistant-${Date.now()}`, role: 'assistant', parts: [] })
  const message = messages.value.at(-1)!
  status.value = 'streaming'

  for (const step of steps) {
    if (step.type === 'sleep') {
      if (!(await sleep(step.ms, id)))
        return
    }
    else if (step.type === 'reasoning') {
      message.parts.push({ type: 'reasoning', text: '', state: 'streaming' })
      const part = message.parts.at(-1) as Extract<ChatPart, { type: 'reasoning' }>
      if (!(await stream(step.text, (value) => {
        part.text = value
      }, id))) {
        return
      }
      part.state = 'done'
    }
    else if (step.type === 'tool') {
      message.parts.push({
        type: 'tool-runCommand',
        state: 'input-available',
        input: { command: step.command },
      })
      const part = message.parts.at(-1) as Extract<ChatPart, { type: 'tool-runCommand' }>
      if (!(await sleep(step.ms, id)))
        return
      part.output = { stdout: step.stdout }
      part.state = 'output-available'
    }
    else {
      message.parts.push({ type: 'text', text: '' })
      const part = message.parts.at(-1) as Extract<ChatPart, { type: 'text' }>
      if (!(await stream(step.text, (value) => {
        part.text = value
      }, id))) {
        return
      }
    }
  }

  status.value = 'ready'
}

function onSubmit() {
  if (!canSend.value)
    return

  const turn = draft.value === null ? script[sentCount.value] : undefined
  messages.value.push({
    id: `user-${Date.now()}`,
    role: 'user',
    parts: [{ type: 'text', text: text.value.trim() }],
  })
  draft.value = null
  respond(turn?.steps ?? [{ type: 'text', text: fallback }])
}

function onKeydown(event: KeyboardEvent) {
  if (event.key === 'Enter' && !event.shiftKey) {
    event.preventDefault()
    formRef.value?.requestSubmit()
  }
}

function reset() {
  run++
  messages.value = []
  draft.value = null
  status.value = 'ready'
  for (const key in reasoningOpen)
    delete reasoningOpen[key]
}

function getMessageText(message: ChatMessage) {
  return message.parts
    .map(part => (part.type === 'text' ? part.text : ''))
    .join('')
}

type ToolPart = Extract<ChatPart, { type: 'tool-runCommand' }>

const [DefineReasoningRow, ReasoningRow] = createReusableTemplate<{
  id: string
  text: string
  running: boolean
}>()
const [DefineToolRow, ToolRow] = createReusableTemplate<{
  part: ToolPart
  streaming: boolean
}>()

function getSteps(message: ChatMessage) {
  return message.parts.filter(
    part => part.type === 'reasoning' || part.type === 'tool-runCommand',
  ) as Exclude<ChatPart, { type: 'text' }>[]
}
</script>

<template>
  <DefineReasoningRow v-slot="{ id, text: reasoning, running }">
    <TraceCollapsible
      :open="reasoningOpen[id] ?? running"
      @update:open="reasoningOpen[id] = $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">
        {{ reasoning }}
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </DefineReasoningRow>

  <DefineToolRow v-slot="{ part, streaming }">
    <TraceCollapsible
      v-if="part.state === 'output-available' || part.state === 'output-error'"
    >
      <TraceCollapsibleTrigger>
        <TraceLine :status="part.state === 'output-error' ? 'error' : undefined">
          <TraceIcon>
            <TerminalIcon />
          </TraceIcon>
          <TraceTitle>
            {{ part.state === 'output-error' ? 'Failed to run' : 'Ran' }} {{ part.input.command }}
          </TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <CodeBlock size="sm">
          <CodeBlockContent wrap class="max-h-64">
            {{ part.state === 'output-error' ? part.errorText : part.output?.stdout }}
          </CodeBlockContent>
        </CodeBlock>
      </TraceCollapsibleContent>
    </TraceCollapsible>
    <TraceLine v-else :status="streaming ? 'running' : 'error'">
      <TraceIcon>
        <TerminalIcon />
      </TraceIcon>
      <TraceTitle>
        {{ streaming ? 'Running' : 'Failed to run' }} {{ part.input.command }}
      </TraceTitle>
    </TraceLine>
  </DefineToolRow>

  <MessageScrollerProvider>
    <div class="relative flex w-full flex-col gap-4 [[data-slot=preview]:has(&)]:bg-background [[data-slot=preview]:has(&)>div]:p-0!">
      <div class="flex h-200 w-full flex-col gap-4">
        <div class="flex items-start justify-between gap-4 p-8 pb-4">
          <div class="flex flex-col gap-1">
            <div class="font-medium">
              New Thread
            </div>
            <div class="text-sm text-muted-foreground">
              Ask the agent to change your code.
            </div>
          </div>
          <Tooltip>
            <TooltipTrigger as-child>
              <Button
                variant="outline"
                size="icon"
                aria-label="Reset conversation"
                :disabled="busy"
                @click="reset"
              >
                <RotateCwIcon />
              </Button>
            </TooltipTrigger>
            <TooltipContent>
              <p>Reset</p>
            </TooltipContent>
          </Tooltip>
        </div>
        <div class="min-h-0 flex-1">
          <Empty v-if="messages.length === 0" class="h-full">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <MessageCircleDashedIcon />
              </EmptyMedia>
              <EmptyTitle>What should we build?</EmptyTitle>
              <EmptyDescription>
                The agent can read your code, run commands and make changes.
                Press send to start.
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
          <MessageScroller v-else>
            <MessageScrollerViewport>
              <MessageScrollerContent :aria-busy="busy" class="gap-6 px-8 py-0">
                <template v-for="message in messages" :key="message.id">
                  <MessageScrollerItem
                    v-if="message.role === 'user'"
                    :message-id="message.id"
                    scroll-anchor
                    class="ms-anim-slide-up"
                  >
                    <Message align="end">
                      <MessageContent>
                        <Bubble align="end" variant="muted">
                          <BubbleContent>{{ getMessageText(message) }}</BubbleContent>
                        </Bubble>
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                  <MessageScrollerItem v-else :message-id="message.id">
                    <Message>
                      <MessageContent class="gap-3">
                        <Trace v-if="getSteps(message).length > 0">
                          <template
                            v-for="(part, index) in getSteps(message)"
                            :key="index"
                          >
                            <ReasoningRow
                              v-if="part.type === 'reasoning'"
                              :id="`${message.id}-${index}`"
                              :text="part.text"
                              :running="part.state === 'streaming'"
                            />
                            <ToolRow
                              v-else
                              :part="part"
                              :streaming="busy && message === messages.at(-1)"
                            />
                          </template>
                        </Trace>
                        <Markdown
                          v-if="getMessageText(message)"
                          :value="getMessageText(message)"
                          :streaming="busy && message === messages.at(-1)"
                        />
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                </template>
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </div>
        <div class="p-8 pt-0">
          <form ref="formRef" class="w-full" @submit.prevent="onSubmit">
            <InputGroup>
              <InputGroupTextarea
                v-model="text"
                placeholder="Ask the agent..."
                aria-label="Message"
                class="min-h-14"
                @keydown="onKeydown"
              />
              <InputGroupAddon align="block-end" class="pt-1">
                <DropdownMenu>
                  <DropdownMenuTrigger as-child>
                    <InputGroupButton
                      aria-label="Add context"
                      type="button"
                      size="icon-sm"
                      variant="outline"
                    >
                      <PlusIcon />
                    </InputGroupButton>
                  </DropdownMenuTrigger>
                  <DropdownMenuContent align="start" side="top" class="w-44">
                    <DropdownMenuItem>
                      <PaperclipIcon />
                      Add Photos &amp; Files
                    </DropdownMenuItem>
                    <DropdownMenuSeparator />
                    <DropdownMenuItem>
                      <TelescopeIcon />
                      Deep Research
                    </DropdownMenuItem>
                    <DropdownMenuItem>
                      <GlobeIcon />
                      Web Search
                    </DropdownMenuItem>
                  </DropdownMenuContent>
                </DropdownMenu>
                <InputGroupButton
                  type="submit"
                  variant="default"
                  size="icon-sm"
                  :disabled="!canSend"
                  class="ml-auto"
                >
                  <ArrowUpIcon />
                  <span class="sr-only">Send</span>
                </InputGroupButton>
              </InputGroupAddon>
            </InputGroup>
          </form>
        </div>
      </div>
    </div>
  </MessageScrollerProvider>
</template>

Chat is the thread of a conversation with the agent. Each turn is a Message inside Message Scroller. The person's message is the scroll anchor, so a new question settles near the top and the reply streams in under it. The field is a Prompt Input.

Installation

Chat is built with the Message Scroller, Message, Bubble, and Input Group components.

pnpm dlx shadcn-vue@latest add message-scroller message bubble input-group

Usage

<template>
  <MessageScrollerProvider auto-scroll>
    <MessageScroller>
      <MessageScrollerViewport>
        <MessageScrollerContent>
          <MessageScrollerItem :message-id="id" scroll-anchor>
            <Message align="end">
              <MessageContent>
                <Bubble align="end" variant="muted">
                  <BubbleContent>{{ text }}</BubbleContent>
                </Bubble>
              </MessageContent>
            </Message>
          </MessageScrollerItem>
        </MessageScrollerContent>
      </MessageScrollerViewport>
      <MessageScrollerButton />
    </MessageScroller>
  </MessageScrollerProvider>
</template>

scroll-anchor goes on the person's turn. auto-scroll follows the reply while the person stays at the live edge.

See Message Scroller.

AI SDK

Render chat.messages from the AI SDK Chat class. Each message is a MessageScrollerItem keyed by its id, and the person's messages are the scroll anchors.

<script setup lang="ts">
import { Chat } from '@ai-sdk/vue'

const chat = new Chat({})
</script>

<template>
  <MessageScrollerProvider auto-scroll>
    <MessageScroller>
      <MessageScrollerViewport>
        <MessageScrollerContent :aria-busy="chat.status === 'streaming'">
          <MessageScrollerItem
            v-for="message in chat.messages"
            :key="message.id"
            :message-id="message.id"
            :scroll-anchor="message.role === 'user'"
          >
            <!-- Render the message -->
          </MessageScrollerItem>
        </MessageScrollerContent>
      </MessageScrollerViewport>
      <MessageScrollerButton />
    </MessageScroller>
  </MessageScrollerProvider>
</template>

Markdown

The agent's reply streams as markdown. Render it with Comark (@comark/vue) and route fenced code to a Code Block with a custom pre component, the same way as in Message. Pass streaming only to the reply that is still streaming.

<script setup lang="ts">
import { Markdown } from '@comark/vue'
import MarkdownCodeBlock from './MarkdownCodeBlock.vue'
</script>

<template>
  <Message>
    <MessageContent>
      <Markdown
        class="typeset"
        :value="getText(message)"
        :components="{ pre: MarkdownCodeBlock }"
        :streaming="chat.status === 'streaming' && message === chat.messages.at(-1)"
      />
    </MessageContent>
  </Message>
</template>

Migrating from AI Elements Vue

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

Migrate components/ai-elements/conversation/ from AI Elements Vue to the shadcn-vue Message Scroller component.

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 Message Scroller: npx shadcn-vue@latest add message-scroller
2. Read https://www.shadcn-vue.com/docs/ai-elements/chat and build Chat the same way.
3. Rebuild each part:
   - Conversation renders a MessageScrollerProvider with auto-scroll and a MessageScroller, instead of StickToBottom.
   - ConversationContent renders a MessageScrollerViewport and a MessageScrollerContent.
   - ConversationScrollButton renders a MessageScrollerButton.
   - Keep ConversationEmptyState, ConversationDownload and messagesToMarkdown as they are.
4. In the code that renders the thread, wrap each message in a MessageScrollerItem with its message-id, and set scroll-anchor on the person's messages. This is the only change outside the folder.
5. Remove vue-stick-to-bottom from the dependencies.
6. Remove the imports the files no longer use.
7. Run vue-tsc and fix any errors.