Installation
Complete project setup with vue/reka, then install these components:
pnpm exec frontfriend download message bubble button tooltip icon input-group -f vue --primitive reka Copy reports success only after the clipboard write succeeds. Feedback buttons expose their pressed state. Retry clears the feedback and copy states. The reply uses plain text; no Markdown renderer is required.
Optional AI SDK adapter
Message Parts accepts the structural id, role, and parts fields from an AI SDK UIMessage. Pass a message from your own chat client to the adapter. It renders text parts as escaped text, hides step-start markers, and skips unsupported parts. Add explicit renderers for tools or sources when your application supports them. This adapter adds no AI SDK dependency.
Usage
Complete example
<script setup lang="ts">
import { ref } from "vue";
import { computed } from "vue";
import { nextTick } from "vue";
import { TooltipProvider } from "@/components/ui/tooltip";
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 { MessageFooter } from "@/components/ui/message";
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 { InputGroup } from "@/components/ui/input-group";
import { InputGroupTextarea } from "@/components/ui/input-group";
import { InputGroupAddon } from "@/components/ui/input-group";
import { InputGroupButton } from "@/components/ui/input-group";
import { MessageHeader } from "@/components/ui/message";
const args = {
"reply": "Install Dialog, then connect its open state to the settings button.",
};
const prompt = ref(
"How do I add a settings dialog? It should open from the gear icon in the header.",
);
const draft = ref(prompt.value);
const editing = ref(false);
const editorForm = ref<HTMLFormElement | null>(null);
const feedback = ref<string | null>(null);
const copied = ref<string | null>(null);
const messageRef = ref<HTMLSpanElement | null>(null);
const replyText = computed(() =>
[
args.reply,
"Implementation",
"1. Place the settings content inside DialogContent.",
"2. Wrap the header button with DialogTrigger.",
"3. Give the dialog a title and test the keyboard path.",
].join("\n\n"),
);
const status = ref("");
async function copy(value: string, source: string) {
try {
await navigator.clipboard.writeText(value);
copied.value = source;
status.value = `Copied ${source}.`;
} catch {
status.value = "Copy failed. Select the text and copy it manually.";
}
}
async function edit() {
draft.value = prompt.value;
editing.value = true;
await nextTick();
editorForm.value?.querySelector("textarea")?.focus();
}
function save() {
if (!draft.value.trim()) return;
prompt.value = draft.value.trim();
editing.value = false;
status.value = "Message updated.";
messageRef.value?.focus();
}
function retry() {
feedback.value = null;
copied.value = null;
status.value = "Reply regenerated.";
}
</script>
<template>
<TooltipProvider
><div
style="
width: 100%;
max-width: 560px;
min-width: 0;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 28px;
padding-block: 16px;
font-size: var(--text-sm);
line-height: 1.5;
"
>
<Message align="end"
><MessageContent
><Bubble align="end" variant="muted"
><BubbleContent
><span ref="messageRef" tabindex="-1">{{
prompt
}}</span></BubbleContent
></Bubble
>
<MessageFooter
><Tooltip
><TooltipTrigger as-child
><Button
variant="ghost"
size="icon-sm"
:aria-label="
copied === 'message' ? 'Copied message' : 'Copy message'
"
@click="() => copy(prompt, 'message')"
><Icon
:name="
copied === 'message' ? 'Check' : 'Copy'
" /></Button></TooltipTrigger
><TooltipContent>{{
copied === "message" ? "Copied message" : "Copy message"
}}</TooltipContent></Tooltip
><Tooltip
><TooltipTrigger as-child
><Button
variant="ghost"
size="icon-sm"
aria-label="Edit message"
@click="edit"
><Icon name="Pencil" /></Button></TooltipTrigger
><TooltipContent>Edit message</TooltipContent></Tooltip
></MessageFooter
>
<form v-if="editing" ref="editorForm" @submit.prevent="save">
<InputGroup
><InputGroupTextarea
v-model="draft"
aria-label="Edit message"
/><InputGroupAddon align="block-end"
><InputGroupButton type="submit" variant="default"
>Save</InputGroupButton
></InputGroupAddon
></InputGroup
>
</form>
</MessageContent></Message
>
<Message
><MessageContent
><MessageHeader>Assistant</MessageHeader>
<p>{{ args.reply }}</p>
<strong>Implementation</strong>
<ol
style="
padding-inline-start: 20px;
display: grid;
gap: 6px;
list-style-type: decimal;
"
>
<li>Place the settings content inside DialogContent.</li>
<li>Wrap the header button with DialogTrigger.</li>
<li>Give the dialog a title and test the keyboard path.</li>
</ol>
<MessageFooter
><Tooltip
><TooltipTrigger as-child
><Button
variant="ghost"
size="icon-sm"
:aria-label="
copied === 'reply' ? 'Copied reply' : 'Copy reply'
"
@click="() => copy(replyText, 'reply')"
><Icon
:name="
copied === 'reply' ? 'Check' : 'Copy'
" /></Button></TooltipTrigger
><TooltipContent>{{
copied === "reply" ? "Copied reply" : "Copy reply"
}}</TooltipContent></Tooltip
><Tooltip
><TooltipTrigger as-child
><Button
variant="ghost"
size="icon-sm"
aria-label="Good response"
:aria-pressed="feedback === 'good'"
@click="
() => (feedback = feedback === 'good' ? null : 'good')
"
><Icon name="ThumbsUp" /></Button></TooltipTrigger
><TooltipContent>Good response</TooltipContent></Tooltip
><Tooltip
><TooltipTrigger as-child
><Button
variant="ghost"
size="icon-sm"
aria-label="Bad response"
:aria-pressed="feedback === 'bad'"
@click="() => (feedback = feedback === 'bad' ? null : 'bad')"
><Icon name="ThumbsDown" /></Button></TooltipTrigger
><TooltipContent>Bad response</TooltipContent></Tooltip
><Tooltip
><TooltipTrigger as-child
><Button
variant="ghost"
size="icon-sm"
aria-label="Retry"
@click="retry"
><Icon name="RotateCw" /></Button></TooltipTrigger
><TooltipContent>Retry</TooltipContent></Tooltip
></MessageFooter
>
</MessageContent></Message
>
<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;
"
>
{{ status }}
</p>
</div></TooltipProvider
>
</template> Message Parts
<script setup lang="ts">
import { Message } from "@/components/ui/message";
import { MessageContent } from "@/components/ui/message";
import { MessageHeader } from "@/components/ui/message";
const args = {
};
const message = {
id: "reply",
role: "assistant",
parts: [
{ type: "step-start", text: "" },
{
type: "text",
text: "Install Dialog and connect it to the settings button.",
},
],
};
</script>
<template>
<Message :align="message.role === 'user' ? 'end' : 'start'"
><MessageContent
><MessageHeader>{{
message.role === "user" ? "You" : "Assistant"
}}</MessageHeader
><template v-for="(part, index) in message.parts" :key="index"
><p v-if="part.type === 'text'">{{ part.text }}</p></template
></MessageContent
></Message
>
</template> Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
message | Reka UI API |
bubble | Reka UI API |
button | Reka UI API |
tooltip | Reka UI API |
icon | Reka UI API |
input-group | Reka UI API |