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

Sources

A collapsible list of the unique sources used for an answer.

Interactive preview

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

Installation

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

pnpm exec frontfriend download trace collapsible message bubble button icon -f react --primitive aria

Collect source-url parts after the answer and remove duplicate URLs. If a title is absent, show the hostname. Each link opens in a new tab and includes an accessible notice.

Usage

Complete example

import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import * as React from "react";
type SourcePart = {
  type: "source-url";
  sourceId: string;
  url: string;
  title?: string;
};
const SOURCES: SourcePart[] = [
  {
    type: "source-url",
    sourceId: "source-0",
    url: "https://docs.astro.build/en/install-and-setup/",
    title: "Install Astro | docs.astro.build",
  },
  {
    type: "source-url",
    sourceId: "source-1",
    url: "https://www.npmjs.com/package/create-astro",
    title: "create-astro | npmjs.com",
  },
  {
    type: "source-url",
    sourceId: "source-2",
    url: "https://docs.astro.build/en/install-and-setup/",
    title: "Install Astro | docs.astro.build",
  },
  {
    type: "source-url",
    sourceId: "source-3",
    url: "https://pnpm.io/cli/create",
  },
];
const canvasStyle: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
const ANSWER =
  "Run pnpm create astro@latest and follow the prompts. It scaffolds the project and installs the dependencies.";
export default function AiSourcesExample() {
  const [count, setCount] = React.useState(SOURCES.length);
  const running = count < SOURCES.length;
  React.useEffect(() => {
    if (!running) return;
    const timer = setTimeout(() => setCount((value) => value + 1), 500);
    return () => clearTimeout(timer);
  }, [count, running]);
  const parts = SOURCES.slice(0, count);
  const sources = parts
    .filter((part) => part.type === "source-url")
    .filter(
      (part, index, all) =>
        all.findIndex((other) => other.url === part.url) === index,
    );
  return (
    <div style={canvasStyle}>
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>How do I start an Astro project with pnpm?</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message>
        <MessageContent style={{ gap: 16 }}>
          <p>{ANSWER}</p>
          {sources.length > 0 && (
            <Trace>
              <TraceCollapsible defaultExpanded>
                <TraceCollapsibleTrigger>
                  <TraceLine>
                    <TraceIcon>
                      <Icon name="Globe" />
                    </TraceIcon>
                    <TraceTitle>
                      Used {sources.length}{" "}
                      {sources.length === 1 ? "source" : "sources"}
                    </TraceTitle>
                  </TraceLine>
                </TraceCollapsibleTrigger>
                <TraceCollapsibleContent>
                  <Trace>
                    {sources.map((source) => (
                      <a
                        key={source.url}
                        href={source.url}
                        target="_blank"
                        rel="noreferrer"
                      >
                        <TraceLine>
                          <TraceTitle>
                            {source.title ?? new URL(source.url).hostname}
                            <span
                              style={{
                                position: "absolute",
                                width: 1,
                                height: 1,
                                overflow: "hidden",
                                clipPath: "inset(50%)",
                                whiteSpace: "nowrap",
                              }}
                            >
                              {" "}
                              (opens in a new tab)
                            </span>
                          </TraceTitle>
                          <TraceIcon>
                            <Icon name="ArrowUpRight" />
                          </TraceIcon>
                        </TraceLine>
                      </a>
                    ))}
                  </Trace>
                </TraceCollapsibleContent>
              </TraceCollapsible>
            </Trace>
          )}
        </MessageContent>
      </Message>
      <Button
        variant="outline"
        size="sm"
        style={{ marginTop: "auto", alignSelf: "flex-start" }}
        isDisabled={running}
        onPress={() => setCount(0)}
      >
        Replay
      </Button>
    </div>
  );
}

AI SDK

This optional adapter accepts a UIMessage from your chat client. The complete example needs no AI SDK or provider. Install this extra package only for the adapter:

pnpm add ai

Read source-url parts after the answer, remove duplicate URLs, and use the hostname when the title is absent. Links include the new-tab notice.

import type { UIMessage } from "ai";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
function SourcesAdapter({ message }: { message: UIMessage }) {
  const sources = message.parts
    .filter((part) => part.type === "source-url")
    .filter(
      (part, index, all) =>
        all.findIndex((other) => other.url === part.url) === index,
    );
  return sources.length > 0 ? (
    <Trace>
      <TraceCollapsible defaultExpanded>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <Icon name="Globe" />
            </TraceIcon>
            <TraceTitle>
              Used {sources.length}{" "}
              {sources.length === 1 ? "source" : "sources"}
            </TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <Trace>
            {sources.map((source) => (
              <a
                key={source.url}
                href={source.url}
                target="_blank"
                rel="noreferrer"
              >
                <TraceLine>
                  <TraceTitle>
                    {source.title ?? new URL(source.url).hostname}
                    <span
                      style={{
                        position: "absolute",
                        width: 1,
                        height: 1,
                        overflow: "hidden",
                        clipPath: "inset(50%)",
                        whiteSpace: "nowrap",
                      }}
                    >
                      {" "}
                      (opens in a new tab)
                    </span>
                  </TraceTitle>
                  <TraceIcon>
                    <Icon name="ArrowUpRight" />
                  </TraceIcon>
                </TraceLine>
              </a>
            ))}
          </Trace>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  ) : null;
}
const ANSWER =
  "Run pnpm create astro@latest and follow the prompts. It scaffolds the project and installs the dependencies.";
type SourcePart = {
  type: "source-url";
  sourceId: string;
  url: string;
  title?: string;
};
const SOURCES: SourcePart[] = [
  {
    type: "source-url",
    sourceId: "source-0",
    url: "https://docs.astro.build/en/install-and-setup/",
    title: "Install Astro | docs.astro.build",
  },
  {
    type: "source-url",
    sourceId: "source-1",
    url: "https://www.npmjs.com/package/create-astro",
    title: "create-astro | npmjs.com",
  },
  {
    type: "source-url",
    sourceId: "source-2",
    url: "https://docs.astro.build/en/install-and-setup/",
    title: "Install Astro | docs.astro.build",
  },
  {
    type: "source-url",
    sourceId: "source-3",
    url: "https://pnpm.io/cli/create",
  },
];
export default function AiSourcesExample() {
  return (
    <div
      style={{
        width: "100%",
        maxWidth: 384,
        marginInline: "auto",
        paddingBlock: 24,
      }}
    >
      <SourcesAdapter
        message={{
          id: "answer",
          role: "assistant",
          parts: [{ type: "text", text: ANSWER }, ...SOURCES],
        }}
      />
    </div>
  );
}

Components and API

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

ComponentTarget reference
trace React Aria API
collapsible React Aria API
message React Aria API
bubble React Aria API
button React Aria API
icon React Aria API