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

Chat

A conversation layout with user and assistant turns, anchored for navigation and paired with a prompt composer.

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-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 &amp;
                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.

ComponentTarget 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