shadimage
pipecat
componentconversationsource: ok · hit

Conversation

Live scrolling transcript of the user/agent conversation with role labels, karaoke text rendering, function-call cards, and connection-aware empty states.

npx shadcn add "https://ui.pipecat.ai/r/conversation.json"
free

Where it goes

statsempty-state

Visual style

playful

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@pipecat-ai/client-js@^1.13.1@pipecat-ai/client-react@^1.8.2lucide-react@^1.41.0

Registry dependencies

collapsible

Files2 · source cached 10/14/2026, 11:48:45 AM

src/components/conversation.tsx
"use client";

import type { ConversationMessage } from "@pipecat-ai/client-react";
import {
  useConversationContext,
  usePipecatClientTransportState,
  usePipecatConversation,
} from "@pipecat-ai/client-react";
import { useCallback, useEffect, useMemo, useRef } from "react";

import {
  ConversationMessageItem,
  type ConversationMessageItemProps,
  type TextRenderMode,
} from "@/components/pipecat/conversation-message";
import { cn } from "@/lib/utils";

export interface ConversationViewProps extends Omit<
  ConversationMessageItemProps,
  "message" | "className"
> {
  /** Messages to render. */
  messages?: ConversationMessage[];
  /** Disable automatic scrolling when new messages arrive. */
  noAutoscroll?: boolean;
  /** Newest messages first; auto-scroll targets the top. */
  reverseOrder?: boolean;
  /** Rendered when there are no messages. */
  empty?: React.ReactNode;
  className?: string;
  /** Classes for each message row. */
  messageClassName?: string;
}

/**
 * Scrolling conversation list. Auto-scrolls to the newest message unless
 * the user has scrolled away. Fully props-driven; pair with the connected
 * Conversation for Pipecat wiring.
 */
export function ConversationView({
  messages = [],
  noAutoscroll = false,
  reverseOrder = false,
  empty,
  className,
  messageClassName,
  ...messageProps
}: ConversationViewProps) {
  const scrollRef = useRef<HTMLDivElement>(null);
  const isScrolledToEdge = useRef(true);

  const maybeScrollToEdge = useCallback(() => {
    if (!scrollRef.current) return;
    if (isScrolledToEdge.current) {
      scrollRef.current.scrollTo({
        top: reverseOrder ? 0 : scrollRef.current.scrollHeight,
        behavior: "smooth",
      });
    }
  }, [reverseOrder]);

  const lastScrollTop = useRef(0);

  // Only a scroll away from the edge detaches. Our own smooth scroll reports
  // intermediate positions short of the edge while it moves toward it.
  const updateScrollState = useCallback(() => {
    if (!scrollRef.current) return;
    const { clientHeight, scrollHeight, scrollTop } = scrollRef.current;
    const movedAway = reverseOrder
      ? scrollTop > lastScrollTop.current
      : scrollTop < lastScrollTop.current;
    // Tracked even while noAutoscroll is set, so re-enabling starts fresh.
    lastScrollTop.current = scrollTop;
    if (noAutoscroll) return;
    const atEdge = reverseOrder
      ? scrollTop <= 1
      : Math.ceil(scrollHeight - scrollTop) <= Math.ceil(clientHeight);
    if (atEdge) isScrolledToEdge.current = true;
    else if (movedAway) isScrolledToEdge.current = false;
  }, [noAutoscroll, reverseOrder]);

  useEffect(() => {
    if (noAutoscroll) return;
    if (messages.length === 0) {
      isScrolledToEdge.current = true;
      lastScrollTop.current = 0;
    }
    maybeScrollToEdge();
  }, [messages, maybeScrollToEdge, noAutoscroll]);

  if (messages.length === 0) {
    return (
      <div
        data-slot="conversation"
        className={cn("relative flex h-full flex-col", className)}
      >
        <div className="flex flex-1 items-center justify-center">
          {empty ?? (
            <div className="text-muted-foreground text-sm">
              Waiting for messages…
            </div>
          )}
        </div>
      </div>
    );
  }

  return (
    <div
      data-slot="conversation"
      className={cn("relative flex h-full flex-col", className)}
    >
      <div
        ref={scrollRef}
        onScroll={updateScrollState}
        className="relative flex-1 overflow-y-auto p-4"
      >
        <div className="flex flex-col gap-4">
          {(reverseOrder ? [...messages].reverse() : messages).map(
            (message, index) => (
              <ConversationMessageItem
                key={`${message.createdAt}-${index}`}
                message={message}
                className={messageClassName}
                {...messageProps}
              />
            ),
          )}
        </div>
      </div>
    </div>
  );
}

export interface ConversationProps extends Omit<
  ConversationViewProps,
  "messages" | "empty"
> {
  /** Hide function-call messages (they remain captured in the store). */
  noFunctionCalls?: boolean;
  /** Controls how bot message text is rendered. */
  textRenderMode?: TextRenderMode;
}

/**
 * Live conversation wired to the Pipecat client, with connection-aware
 * empty states. Must be rendered inside a PipecatClientProvider.
 */
export function Conversation({
  noFunctionCalls = false,
  textRenderMode,
  aggregationMetadata,
  ...props
}: ConversationProps) {
  const transportState = usePipecatClientTransportState();

  // "captions" mode only needs spoken text; the other modes need all data.
  const botOutputFilter = useMemo(() => {
    if (textRenderMode === "captions") return { unspoken: false };
    return undefined;
  }, [textRenderMode]);

  const { messages: allMessages } = usePipecatConversation({
    aggregationMetadata,
    botOutputFilter,
  });
  const { botOutputSupported } = useConversationContext();

  const messages = useMemo(
    () =>
      noFunctionCalls
        ? allMessages.filter((m) => m.role !== "function_call")
        : allMessages,
    [allMessages, noFunctionCalls],
  );

  const isConnecting =
    transportState === "authenticating" || transportState === "connecting";
  const isConnected =
    transportState === "connected" || transportState === "ready";

  let empty: React.ReactNode;
  if (isConnecting) {
    empty = (
      <div className="text-muted-foreground text-sm">Connecting to agent…</div>
    );
  } else if (!isConnected) {
    empty = (
      <div className="p-4 text-center">
        <div className="text-muted-foreground mb-2">Not connected to agent</div>
        <p className="text-muted-foreground max-w-md text-sm">
          Connect to an agent to see conversation messages in real-time.
        </p>
      </div>
    );
  } else if (botOutputSupported === false) {
    empty = (
      <div className="max-w-md p-4 text-center">
        <div className="text-destructive mb-2 font-medium">
          BotOutput events not supported
        </div>
        <p className="text-muted-foreground text-sm">
          This server does not support BotOutput events (requires RTVI 1.1.0+).
          Conversation messages cannot be displayed without them.
        </p>
      </div>
    );
  }

  return (
    <ConversationView
      messages={messages}
      empty={empty}
      textRenderMode={textRenderMode}
      aggregationMetadata={aggregationMetadata}
      {...props}
    />
  );
}
src/components/conversation-message.tsx
"use client";

import type {
  AggregationMetadata,
  BotOutputText,
  ConversationMessage,
  ConversationMessagePart,
  FunctionCallData,
  FunctionCallRenderer,
} from "@pipecat-ai/client-react";
import { isMessageEmpty } from "@pipecat-ai/client-react";
import {
  CheckIcon,
  ChevronRightIcon,
  LoaderCircleIcon,
  XIcon,
} from "lucide-react";
import { Fragment, useEffect, useState } from "react";

import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";

/** Controls how bot message text is rendered. */
export type TextRenderMode = "karaoke" | "captions" | "instant";

export type BotOutputRenderer = (
  content: string,
  metadata: { spoken: string; unspoken: string },
) => React.ReactNode;

const timeFormatter = new Intl.DateTimeFormat(undefined, {
  hour: "numeric",
  minute: "numeric",
  second: "numeric",
});

/** Animated "…" typing indicator. */
export function Thinking({
  className,
  interval = 500,
  maxDots = 3,
}: {
  className?: string;
  interval?: number;
  maxDots?: number;
}) {
  const [dots, setDots] = useState(1);

  useEffect(() => {
    const i = setInterval(() => {
      setDots((prev) => (prev % maxDots) + 1);
    }, interval);
    return () => clearInterval(i);
  }, [interval, maxDots]);

  return (
    <span className={className} aria-label="Thinking">
      {".".repeat(dots)}
    </span>
  );
}

export interface MessageRoleProps {
  role: ConversationMessage["role"];
  assistantLabel?: string;
  clientLabel?: string;
  systemLabel?: string;
  functionCallLabel?: string;
  className?: string;
}

/** Role label for a conversation message, colored by participant. */
export function MessageRole({
  role,
  assistantLabel = "assistant",
  clientLabel = "user",
  systemLabel = "system",
  functionCallLabel = "function call",
  className,
}: MessageRoleProps) {
  const labels: Record<string, string> = {
    user: clientLabel,
    assistant: assistantLabel,
    system: systemLabel,
    function_call: functionCallLabel,
  };

  return (
    <div
      data-slot="message-role"
      className={cn(
        "w-max font-mono text-xs leading-6 font-semibold",
        {
          "text-client-foreground": role === "user",
          "text-agent-foreground": role === "assistant",
          "text-muted-foreground":
            role === "system" || role === "function_call",
        },
        className,
      )}
    >
      {labels[role] || role}
    </div>
  );
}

function StatusIcon({
  status,
  cancelled,
}: {
  status: FunctionCallData["status"];
  cancelled?: boolean;
}) {
  if (status === "completed" && cancelled) {
    return <XIcon className="text-destructive size-3.5" />;
  }
  switch (status) {
    case "started":
    case "in_progress":
      return <LoaderCircleIcon className="size-3.5 animate-spin" />;
    case "completed":
      return <CheckIcon className="text-active size-3.5" />;
  }
}

export interface FunctionCallContentProps {
  functionCall: FunctionCallData;
  functionCallLabel?: string;
  /** Replaces the default rendering entirely. */
  functionCallRenderer?: FunctionCallRenderer;
  className?: string;
}

/** Collapsible display of a function call with its args and result. */
export function FunctionCallContent({
  functionCall,
  functionCallLabel = "Function call",
  functionCallRenderer,
  className,
}: FunctionCallContentProps) {
  const [isOpen, setIsOpen] = useState(false);

  if (functionCallRenderer) {
    return <>{functionCallRenderer(functionCall)}</>;
  }

  const hasDetails =
    (functionCall.args && Object.keys(functionCall.args).length > 0) ||
    functionCall.result !== undefined;

  return (
    <Collapsible open={isOpen} onOpenChange={setIsOpen}>
      <div className={cn("flex flex-col gap-1", className)}>
        <CollapsibleTrigger
          disabled={!hasDetails}
          className={cn(
            "text-muted-foreground flex items-center gap-2 font-mono text-xs transition-colors select-none",
            hasDetails
              ? "hover:text-foreground cursor-pointer"
              : "cursor-default",
          )}
        >
          {hasDetails && (
            <ChevronRightIcon
              className={cn(
                "size-3.5 transition-transform duration-200",
                isOpen && "rotate-90",
              )}
            />
          )}
          <StatusIcon
            status={functionCall.status}
            cancelled={functionCall.cancelled}
          />
          <span className="font-semibold">{functionCallLabel}</span>
          {functionCall.function_name && (
            <span className="text-muted-foreground">
              ({functionCall.function_name})
            </span>
          )}
        </CollapsibleTrigger>

        {hasDetails && (
          <CollapsibleContent>
            <div className="border-muted mt-1 ml-3.5 flex flex-col gap-2 border-l-2 pl-3 font-mono text-xs">
              {functionCall.args &&
                Object.keys(functionCall.args).length > 0 && (
                  <div>
                    <div className="text-muted-foreground mb-1 font-semibold">
                      Arguments
                    </div>
                    <pre className="bg-muted/50 overflow-x-auto rounded p-2 break-all whitespace-pre-wrap">
                      {JSON.stringify(functionCall.args, null, 2)}
                    </pre>
                  </div>
                )}
              {functionCall.result !== undefined && (
                <div>
                  <div className="text-muted-foreground mb-1 font-semibold">
                    Result
                  </div>
                  <pre className="bg-muted/50 overflow-x-auto rounded p-2 break-all whitespace-pre-wrap">
                    {typeof functionCall.result === "string"
                      ? functionCall.result
                      : JSON.stringify(functionCall.result, null, 2)}
                  </pre>
                </div>
              )}
            </div>
          </CollapsibleContent>
        )}
      </div>
    </Collapsible>
  );
}

function isBotOutputText(
  part: ConversationMessagePart,
): part is ConversationMessagePart & { text: BotOutputText } {
  const text = part.text;
  return (
    text !== null &&
    typeof text === "object" &&
    "spoken" in text &&
    "unspoken" in text
  );
}

function renderBotOutput(
  spoken: string,
  unspoken: string,
  aggregatedBy?: string,
  customRenderer?: BotOutputRenderer,
  metadata?: AggregationMetadata,
  textRenderMode?: TextRenderMode,
): React.ReactNode {
  if (aggregatedBy && customRenderer) {
    return customRenderer(spoken + unspoken, { spoken, unspoken });
  }

  const displayMode = metadata?.displayMode || "inline";
  const Wrapper = displayMode === "block" ? "div" : "span";

  if (textRenderMode === "instant") {
    return (
      <Wrapper>
        {spoken}
        {unspoken}
      </Wrapper>
    );
  }

  return (
    <Wrapper>
      {spoken}
      {unspoken && <span className="text-muted-foreground">{unspoken}</span>}
    </Wrapper>
  );
}

function renderPartContent(
  part: ConversationMessagePart,
  botOutputRenderers?: Record<string, BotOutputRenderer>,
  aggregationMetadata?: Record<string, AggregationMetadata>,
  textRenderMode?: TextRenderMode,
): React.ReactNode {
  if (!part.text) return null;
  if (isBotOutputText(part)) {
    const text = part.text as BotOutputText;
    const customRenderer = part.aggregatedBy
      ? botOutputRenderers?.[part.aggregatedBy]
      : undefined;
    const metadata = part.aggregatedBy
      ? aggregationMetadata?.[part.aggregatedBy]
      : undefined;
    return renderBotOutput(
      text.spoken,
      text.unspoken,
      part.aggregatedBy,
      customRenderer,
      metadata,
      textRenderMode,
    );
  }
  return part.text as React.ReactNode;
}

export interface MessageContentProps {
  message: ConversationMessage;
  botOutputRenderers?: Record<string, BotOutputRenderer>;
  aggregationMetadata?: Record<string, AggregationMetadata>;
  textRenderMode?: TextRenderMode;
  className?: string;
}

/** Message body: grouped inline/block parts, thinking dots, timestamp. */
export function MessageContent({
  message,
  botOutputRenderers,
  aggregationMetadata,
  textRenderMode,
  className,
}: MessageContentProps) {
  const parts = Array.isArray(message.parts) ? message.parts : [];

  // Group parts by display mode: inline parts flow together, block parts
  // each get their own line.
  const groupedParts: Array<{
    type: "inline" | "block";
    parts: ConversationMessagePart[];
  }> = [];
  let currentInlineGroup: ConversationMessagePart[] = [];

  for (const part of parts) {
    const metadata = part.aggregatedBy
      ? aggregationMetadata?.[part.aggregatedBy]
      : undefined;
    const displayMode = part.displayMode ?? metadata?.displayMode ?? "inline";

    if (displayMode === "block") {
      if (currentInlineGroup.length > 0) {
        groupedParts.push({ type: "inline", parts: currentInlineGroup });
        currentInlineGroup = [];
      }
      groupedParts.push({ type: "block", parts: [part] });
    } else {
      currentInlineGroup.push(part);
    }
  }
  if (currentInlineGroup.length > 0) {
    groupedParts.push({ type: "inline", parts: currentInlineGroup });
  }

  return (
    <div
      data-slot="message-content"
      className={cn("flex flex-col gap-2", className)}
    >
      {groupedParts.map((group, groupIdx) =>
        group.type === "inline" ? (
          <div key={groupIdx} className="inline-block">
            {group.parts.map((part, partIdx) => {
              const content = renderPartContent(
                part,
                botOutputRenderers,
                aggregationMetadata,
                textRenderMode,
              );
              const shouldAddSpace = partIdx > 0 && !isBotOutputText(part);
              return (
                <Fragment key={partIdx}>
                  {shouldAddSpace && " "}
                  {content}
                </Fragment>
              );
            })}
          </div>
        ) : (
          <Fragment key={groupIdx}>
            {group.parts.map((part, partIdx) => (
              <Fragment key={partIdx}>
                {renderPartContent(
                  part,
                  botOutputRenderers,
                  aggregationMetadata,
                  textRenderMode,
                )}
              </Fragment>
            ))}
          </Fragment>
        ),
      )}
      {isMessageEmpty(message) ? <Thinking /> : null}
      <div className="text-muted-foreground mb-1 self-end text-xs">
        {timeFormatter.format(new Date(message.createdAt))}
      </div>
    </div>
  );
}

export interface ConversationMessageItemProps {
  message: ConversationMessage;
  assistantLabel?: string;
  clientLabel?: string;
  systemLabel?: string;
  functionCallLabel?: string;
  functionCallRenderer?: FunctionCallRenderer;
  botOutputRenderers?: Record<string, BotOutputRenderer>;
  aggregationMetadata?: Record<string, AggregationMetadata>;
  textRenderMode?: TextRenderMode;
  className?: string;
}

/** One conversation row: role label plus content (or a function call). */
export function ConversationMessageItem({
  message,
  assistantLabel,
  clientLabel,
  systemLabel,
  functionCallLabel,
  functionCallRenderer,
  botOutputRenderers,
  aggregationMetadata,
  textRenderMode,
  className,
}: ConversationMessageItemProps) {
  if (message.role === "function_call" && message.functionCall) {
    return (
      <div className={cn("flex flex-col gap-2", className)}>
        <FunctionCallContent
          functionCall={message.functionCall}
          functionCallLabel={functionCallLabel}
          functionCallRenderer={functionCallRenderer}
        />
        <div className="text-muted-foreground mb-1 self-end text-xs">
          {timeFormatter.format(new Date(message.createdAt))}
        </div>
      </div>
    );
  }

  return (
    <div className={cn("flex flex-col gap-2", className)}>
      <MessageRole
        role={message.role}
        assistantLabel={assistantLabel}
        clientLabel={clientLabel}
        systemLabel={systemLabel}
        functionCallLabel={functionCallLabel}
      />
      <MessageContent
        message={message}
        botOutputRenderers={botOutputRenderers}
        aggregationMetadata={aggregationMetadata}
        textRenderMode={textRenderMode}
      />
    </div>
  );
}

More from pipecat

Browse all components in this library →