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 Base UI. Each example runs with local data.

Installation

Complete project setup with react/base, then install these components:

pnpm exec frontfriend download message bubble button tooltip icon input-group -f react --primitive base

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

import {
  Message,
  MessageContent,
  MessageFooter,
  MessageHeader,
} from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  TooltipProvider,
  Tooltip,
  TooltipTrigger,
  TooltipContent,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import {
  InputGroup,
  InputGroupTextarea,
  InputGroupAddon,
  InputGroupButton,
} from "@/components/ui/input-group";
import * as React from "react";
function AiMessageAction({
  label,
  icon,
  pressed,
  onActivate,
}: {
  label: string;
  icon: string;
  pressed?: boolean;
  onActivate: () => void;
}) {
  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger asChild>
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label={label}
            aria-pressed={pressed}
            onClick={onActivate}
          >
            <Icon name={icon} />
          </Button>
        </TooltipTrigger>
        <TooltipContent>{label}</TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}
export default function AiMessageExample() {
  const [prompt, setPrompt] = React.useState(
    "How do I add a settings dialog? It should open from the gear icon in the header.",
  );
  const [draft, setDraft] = React.useState(prompt);
  const [editing, setEditing] = React.useState(false);
  const editorRef = React.useRef<HTMLTextAreaElement>(null);
  const [feedback, setFeedback] = React.useState<"good" | "bad" | null>(null);
  const [copied, setCopied] = React.useState<string | null>(null);
  const messageRef = React.useRef<HTMLSpanElement>(null);
  const replyText = [
    "Install Dialog, then connect its open state to the settings button.",
    "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, setStatus] = React.useState("");
  React.useEffect(() => {
    if (editing) editorRef.current?.focus();
  }, [editing]);
  async function copy(value: string, source: string) {
    try {
      await navigator.clipboard.writeText(value);
      setCopied(source);
      setStatus(`Copied ${source}.`);
    } catch {
      setStatus("Copy failed. Select the text and copy it manually.");
    }
  }
  return (
    <div
      style={{
        width: "100%",
        maxWidth: 560,
        minWidth: 0,
        marginInline: "auto",
        display: "flex",
        flexDirection: "column",
        gap: 28,
        paddingBlock: 16,
        fontSize: "var(--text-sm)",
        lineHeight: 1.5,
      }}
    >
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>
              <span ref={messageRef} tabIndex={-1}>
                {prompt}
              </span>
            </BubbleContent>
          </Bubble>
          <MessageFooter>
            <AiMessageAction
              label={copied === "message" ? "Copied message" : "Copy message"}
              icon={copied === "message" ? "Check" : "Copy"}
              onActivate={() => void copy(prompt, "message")}
            />
            <AiMessageAction
              label="Edit message"
              icon="Pencil"
              onActivate={() => {
                setDraft(prompt);
                setEditing(true);
              }}
            />
          </MessageFooter>
          {editing && (
            <form
              onSubmit={(event) => {
                event.preventDefault();
                if (!draft.trim()) return;
                setPrompt(draft.trim());
                setEditing(false);
                setStatus("Message updated.");
                messageRef.current?.focus();
              }}
            >
              <InputGroup>
                <InputGroupTextarea
                  ref={editorRef}
                  aria-label="Edit message"
                  value={draft}
                  onChange={(event) => setDraft(event.target.value)}
                />
                <InputGroupAddon align="block-end">
                  <InputGroupButton type="submit" variant="default">
                    Save
                  </InputGroupButton>
                </InputGroupAddon>
              </InputGroup>
            </form>
          )}
        </MessageContent>
      </Message>
      <Message>
        <MessageContent>
          <MessageHeader>Assistant</MessageHeader>
          <p>Install Dialog, then connect its open state to the settings button.</p>
          <strong>Implementation</strong>
          <ol
            style={{
              paddingInlineStart: 20,
              display: "grid",
              gap: 6,
              listStyleType: "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>
            <AiMessageAction
              label={copied === "reply" ? "Copied reply" : "Copy reply"}
              icon={copied === "reply" ? "Check" : "Copy"}
              onActivate={() => void copy(replyText, "reply")}
            />
            <AiMessageAction
              label="Good response"
              icon="ThumbsUp"
              pressed={feedback === "good"}
              onActivate={() =>
                setFeedback((value) => (value === "good" ? null : "good"))
              }
            />
            <AiMessageAction
              label="Bad response"
              icon="ThumbsDown"
              pressed={feedback === "bad"}
              onActivate={() =>
                setFeedback((value) => (value === "bad" ? null : "bad"))
              }
            />
            <AiMessageAction
              label="Retry"
              icon="RotateCw"
              onActivate={() => {
                setFeedback(null);
                setCopied(null);
                setStatus("Reply regenerated.");
              }}
            />
          </MessageFooter>
        </MessageContent>
      </Message>
      <p
        role="status"
        style={{
          position: "absolute",
          width: 1,
          height: 1,
          padding: 0,
          margin: -1,
          overflow: "hidden",
          clipPath: "inset(50%)",
          whiteSpace: "nowrap",
          border: 0,
        }}
      >
        {status}
      </p>
    </div>
  );
}

Message Parts

import {
  Message,
  MessageContent,
  MessageHeader,
} from "@/components/ui/message";
type AdapterMessage = {
  id: string;
  role: string;
  parts: Array<{
    type: string;
    text?: string;
  }>;
};
function MessagePartsAdapter({ message }: { message: AdapterMessage }) {
  return (
    <Message align={message.role === "user" ? "end" : "start"}>
      <MessageContent>
        <MessageHeader>
          {message.role === "user" ? "You" : "Assistant"}
        </MessageHeader>
        {message.parts.map((part, index) =>
          part.type === "text" ? (
            <p key={index}>{part.text}</p>
          ) : part.type === "step-start" ? null : null,
        )}
      </MessageContent>
    </Message>
  );
}
export default function AiMessageExample() {
  return (
    <MessagePartsAdapter
      message={{
        id: "reply",
        role: "assistant",
        parts: [
          { type: "step-start" },
          {
            type: "text",
            text: "Install Dialog and connect it to the settings button.",
          },
        ],
      }}
    />
  );
}

Components and API

This composition has no separate component API. Use the APIs for its installed components.

ComponentTarget reference
message Base UI API
bubble Base UI API
button Base UI API
tooltip Base UI API
icon Base UI API
input-group Base UI API