Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new

Message

A message pattern for user and assistant turns with copy, feedback, and retry actions.

Interactive preview

The shared preview uses React Base. The source below uses Reka UI. Each example runs with local data.

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.

ComponentTarget 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