Installation
Complete project setup with vue/reka, then install these components:
pnpm exec frontfriend download message-scroller message bubble input-group button icon empty dropdown-menu tooltip -f vue --primitive reka The viewport keeps the thread in one scroll area. Enter sends; Shift+Enter adds a line. Input composition does not send a message. Reset starts a new thread. Text is escaped by the framework. Connect your own response data when you need a provider.
Usage
Complete example
<script setup lang="ts">
import { ref } from "vue";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Tooltip } from "@/components/ui/tooltip";
import { TooltipTrigger } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import { TooltipContent } from "@/components/ui/tooltip";
import { MessageScrollerProvider } from "@/components/ui/message-scroller";
import { MessageScroller } from "@/components/ui/message-scroller";
import { MessageScrollerViewport } from "@/components/ui/message-scroller";
import { MessageScrollerContent } from "@/components/ui/message-scroller";
import { MessageScrollerItem } from "@/components/ui/message-scroller";
import { Message } from "@/components/ui/message";
import { MessageContent } from "@/components/ui/message";
import { Bubble } from "@/components/ui/bubble";
import { BubbleContent } from "@/components/ui/bubble";
import { MessageHeader } from "@/components/ui/message";
import { MessageScrollerButton } from "@/components/ui/message-scroller";
import { Empty } from "@/components/ui/empty";
import { EmptyHeader } from "@/components/ui/empty";
import { EmptyMedia } from "@/components/ui/empty";
import { EmptyTitle } from "@/components/ui/empty";
import { EmptyDescription } from "@/components/ui/empty";
import { InputGroup } from "@/components/ui/input-group";
import { InputGroupTextarea } from "@/components/ui/input-group";
import { InputGroupAddon } from "@/components/ui/input-group";
import { DropdownMenu } from "@/components/ui/dropdown-menu";
import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { InputGroupButton } from "@/components/ui/input-group";
import { DropdownMenuContent } from "@/components/ui/dropdown-menu";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
const args = {
"reply": "The theme provider is already in place. I added the toggle next to Appearance.",
};
const focusedMenu = ref("");
const menuFocusStyle = {
outline: "2px solid var(--border-interactive-mid)",
outlineOffset: "2px",
};
function focusMenu(event: FocusEvent, name: string) {
focusedMenu.value =
event.target instanceof HTMLElement &&
event.target.matches(":focus-visible")
? name
: "";
}
const text = ref("Make it follow the system setting by default.");
const context = ref("");
const messages = ref([
{
id: "user-0",
role: "user",
text: "Add a dark mode toggle to the settings page.",
},
{ id: "assistant-0", role: "assistant", text: args.reply },
]);
function send() {
if (!text.value.trim()) return;
messages.value.push(
{ id: crypto.randomUUID(), role: "user", text: text.value.trim() },
{ id: crypto.randomUUID(), role: "assistant", text: args.reply },
);
text.value = "";
}
function keydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
send();
}
}
function reset() {
messages.value = [];
text.value = "";
context.value = "";
}
</script>
<template>
<div
style="
width: 100%;
max-width: 640px;
min-width: 0;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 20px;
padding-block: 16px;
font-size: var(--text-sm);
line-height: 1.5;
"
>
<header
style="
display: flex;
justify-content: space-between;
align-items: start;
gap: 16px;
"
>
<div style="display: grid; gap: 4px">
<strong>New Thread</strong>
<p>Ask the agent to change your code.</p>
</div>
<TooltipProvider
><Tooltip
><TooltipTrigger as-child
><Button
variant="ghost"
size="icon-sm"
aria-label="Reset conversation"
@click="reset"
><Icon name="RotateCw" /></Button></TooltipTrigger
><TooltipContent>Reset conversation</TooltipContent></Tooltip
></TooltipProvider
>
</header>
<MessageScrollerProvider auto-scroll
><MessageScroller v-if="messages.length"
><MessageScrollerViewport
style="height: auto; max-height: 400px"
tabindex="0"
aria-label="Conversation"
><MessageScrollerContent style="gap: 24px">
<MessageScrollerItem
v-for="message in messages"
:key="message.id"
:message-id="message.id"
:scroll-anchor="message.role === 'user'"
><Message :align="message.role === 'user' ? 'end' : 'start'"
><MessageContent>
<Bubble
v-if="message.role === 'user'"
align="end"
variant="muted"
><BubbleContent>{{ message.text }}</BubbleContent></Bubble
>
<template v-else
><MessageHeader>Assistant</MessageHeader>
<p>{{ message.text }}</p>
<strong>Changes</strong>
<ul
style="
padding-inline-start: 20px;
list-style-type: disc;
display: grid;
gap: 6px;
"
>
<li>Added Light, Dark, and System options.</li>
<li>Placed the toggle next to the Appearance heading.</li>
</ul>
<p>
Use the settings page to check each theme before you ship.
</p></template
>
</MessageContent></Message
></MessageScrollerItem
>
</MessageScrollerContent></MessageScrollerViewport
><MessageScrollerButton
/></MessageScroller>
<Empty v-else
><EmptyHeader
><EmptyMedia variant="icon"><Icon name="MessageCircle" /></EmptyMedia
><EmptyTitle>What should we build?</EmptyTitle
><EmptyDescription
>Describe a change to your code. Send a message to start the
conversation.</EmptyDescription
></EmptyHeader
></Empty
></MessageScrollerProvider
>
<form @submit.prevent="send">
<InputGroup
><InputGroupTextarea
v-model="text"
aria-label="Message"
placeholder="Ask the agent..."
@keydown="keydown" /><InputGroupAddon align="block-end">
<DropdownMenu
><DropdownMenuTrigger as-child
><InputGroupButton
type="button"
size="icon-sm"
variant="outline"
:style="focusedMenu === 'context' ? menuFocusStyle : undefined"
@focus="focusMenu($event, 'context')"
@blur="focusedMenu = ''"
aria-label="Add context"
><Icon name="Plus" /></InputGroupButton></DropdownMenuTrigger
><DropdownMenuContent align="start" side="top"
><DropdownMenuItem @select="context = 'Photos and files'"
><Icon name="Paperclip" />Add Photos &
Files</DropdownMenuItem
><DropdownMenuSeparator /><DropdownMenuItem
@select="context = 'Deep research'"
><Icon name="Search" />Deep Research</DropdownMenuItem
><DropdownMenuItem @select="context = 'Web search'"
><Icon name="Globe" />Web Search</DropdownMenuItem
></DropdownMenuContent
></DropdownMenu
>
<span style="flex: 1" /><InputGroupButton
type="submit"
:variant="text.trim() ? 'default' : 'secondary'"
size="icon-sm"
aria-label="Send"
:aria-disabled="!text.trim()"
><Icon name="ArrowUp"
/></InputGroupButton> </InputGroupAddon
></InputGroup>
</form>
<p
role="status"
style="
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
"
>
{{ context ? "Context: " + context : "" }}
</p>
</div>
</template> Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
message-scroller | Reka UI API |
message | Reka UI API |
bubble | Reka UI API |
input-group | Reka UI API |
button | Reka UI API |
icon | Reka UI API |
empty | Reka UI API |
dropdown-menu | Reka UI API |
tooltip | Reka UI API |