# Console

> Workbench for debugging and testing Pipecat agents: connect flow, bot media, conversation and metrics, device and session info, and a live RTVI event stream, with a tabbed mobile layout.

A shadcn/ui block from the [@pipecat](https://ui.pipecat.ai) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/pipecat/console).

- Page, with a live preview: https://shadcn.exchange/r/pipecat/console
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/pipecat/console-light-23dab8fde343b379.png
- Registry item: https://ui.pipecat.ai/r/console.json
- Categories: voice, blocks
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.pipecat.ai/r/console.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@pipecat-ai/client-js`, `@pipecat-ai/client-react`, `lucide-react`, `react-resizable-panels`
- Registry items: `alert`, `button`, `card`, `drawer`, `input`, `popover`, `resizable`, `select`, `spinner`, `tabs`, `tooltip`, `@pipecat/audio-visualizer-bar`, `@pipecat/bot-audio`, `@pipecat/client-status`, `@pipecat/connect-button`, `@pipecat/conversation`, `@pipecat/dtmf-keypad`, `@pipecat/metrics`, `@pipecat/session-info`, `@pipecat/text-input`, `@pipecat/use-pipecat-app`, `@pipecat/use-pipecat-event-stream`, `@pipecat/use-pipecat-metrics`, `@pipecat/user-audio-control`, `@pipecat/user-screen-control`, `@pipecat/user-video-control`

## Notes from the registry

Console builds its own client and PipecatClientProvider; do not nest it in another provider. Pass transportFactory for your installed transport, or registerTransport a loader per type, and render it in a sized container.

## Files

### `src/blocks/console/console.tsx`

```tsx
"use client";

import type {
  APIRequest,
  Participant,
  PipecatClient,
  PipecatClientOptions,
  TransportConnectionParams,
  TransportState,
} from "@pipecat-ai/client-js";
import { RTVIEvent } from "@pipecat-ai/client-js";
import {
  PipecatClientProvider,
  useConversationContext,
  useRTVIClientEvent,
} from "@pipecat-ai/client-react";
import {
  BotIcon,
  ChevronsLeftRightEllipsisIcon,
  InfoIcon,
  MessagesSquareIcon,
  PanelLeftCloseIcon,
  PanelRightCloseIcon,
  XIcon,
} from "lucide-react";
import * as React from "react";
import {
  useDefaultLayout,
  usePanelRef,
  type LayoutStorage,
} from "react-resizable-panels";

import { BotAudioOutput } from "@/components/pipecat/bot-audio";
import { ConnectButton } from "@/components/pipecat/connect-button";
import { ConsoleBotAudioPanel } from "@/components/pipecat/console/bot-audio-panel";
import { ConsoleBotVideoPanel } from "@/components/pipecat/console/bot-video-panel";
import { SmallWebRTCCodecSetter } from "@/components/pipecat/console/codec-setter";
import { ConsoleConversationPanel } from "@/components/pipecat/console/conversation-panel";
import { ConsoleEventsPanel } from "@/components/pipecat/console/events-panel";
import { ConsoleInfoPanel } from "@/components/pipecat/console/info-panel";
import { ConsoleKeypadToggle } from "@/components/pipecat/console/keypad-toggle";
import { PipecatLogo } from "@/components/pipecat/console/logo";
import { useMinWidth } from "@/components/pipecat/console/panel";
import type { ConversationProps } from "@/components/pipecat/conversation";
import type { TextRenderMode } from "@/components/pipecat/conversation-message";
import type { DTMFKeypadMode } from "@/components/pipecat/dtmf-keypad";
import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import {
  usePipecatApp,
  type UsePipecatAppReturn,
} from "@/hooks/use-pipecat-app";
import { usePipecatEventStream } from "@/hooks/use-pipecat-event-stream";
import { usePipecatMetricValue } from "@/hooks/use-pipecat-metrics";
import type {
  TransportFactory,
  TransportOptions,
  TransportType,
} from "@/lib/transports";
import { cn } from "@/lib/utils";

// Connection-URL helpers: resolve the URL the connect button will hit, for
// its hover tooltip.

function extractUrlFromOptions(options: unknown): string | undefined {
  if (typeof options !== "object" || options === null) return undefined;
  const record = options as Record<string, unknown>;
  const requestParams = record.webrtcRequestParams;
  if (typeof requestParams === "object" && requestParams !== null) {
    const endpoint = (requestParams as Record<string, unknown>).endpoint;
    if (typeof endpoint === "string") return endpoint;
  }
  if (typeof record.webrtcUrl === "string") return record.webrtcUrl;
  if (typeof record.connectionUrl === "string") return record.connectionUrl;
  return undefined;
}

function resolveUrl(url: string): string {
  if (typeof window === "undefined") return url;
  try {
    return new URL(url, window.location.origin).toString();
  } catch {
    return url;
  }
}

function getConnectionUrl(
  startBotParams?: APIRequest,
  connectParams?: unknown,
  transportOptions?: unknown,
): string | undefined {
  const candidate =
    (typeof startBotParams?.endpoint === "string" && startBotParams.endpoint) ||
    extractUrlFromOptions(connectParams) ||
    extractUrlFromOptions(transportOptions);
  return candidate ? resolveUrl(candidate) : undefined;
}

// The metrics tab and the mobile events tab unmount while inactive. These keep
// their shared stores collecting, so opening either shows the whole session.
function MetricsCollector() {
  usePipecatMetricValue("ttfb");
  return null;
}

function EventStreamCollector() {
  usePipecatEventStream();
  return null;
}

/** Inert storage handed to useDefaultLayout when persistence is off. */
const MEMORY_STORAGE: LayoutStorage = {
  getItem: () => null,
  setItem: () => {},
} as LayoutStorage;

export interface ConsoleProps {
  // -- Bootstrap (forwarded to usePipecatApp) --------------------------------
  /** Transport backing the client (default "smallwebrtc"). Match it to transportFactory; codec and ICE handling depend on it. */
  transportType?: TransportType;
  /** Creates your installed transport. Read once; remount to change. Without it, the loader registered for transportType is used. */
  transportFactory?: TransportFactory;
  /** Constructor options for the selected transport. */
  transportOptions?: TransportOptions;
  /** Overrides merged into the PipecatClient constructor. */
  clientOptions?: Partial<PipecatClientOptions>;
  /** Connection params, or an APIRequest (with endpoint) for start-and-connect. */
  connectParams?: TransportConnectionParams | APIRequest;
  /** When set, connecting starts a bot via startBot() first. */
  startBotParams?: APIRequest;
  /** Transforms the startBot response before connecting. */
  startBotResponseTransformer?: (
    response: TransportConnectionParams,
  ) => TransportConnectionParams | Promise<TransportConnectionParams>;
  /** Connect automatically once the client is ready. Default false. */
  connectOnMount?: boolean;
  /** Initialize devices as soon as the client exists. Default true. */
  initDevicesOnMount?: boolean;
  /** Fired once per created client, before device init or connect. */
  onClient?: (client: PipecatClient) => void;

  // -- Header ---------------------------------------------------------------
  /** Header title (default "Pipecat Console"); hidden on narrow viewports. */
  titleText?: string;
  /** Replaces the Pipecat logo. */
  logo?: React.ReactNode;
  /** Hides the logo entirely. Default false. */
  noLogo?: boolean;
  /**
   * Extra header content rendered before the connect button — e.g. your
   * app's theme toggle. Rendered only when provided.
   */
  headerSlot?: React.ReactNode;

  // -- Region toggles (all default false unless noted) -----------------------
  noUserAudio?: boolean;
  noUserVideo?: boolean;
  noScreenControl?: boolean;
  /** Hides the DTMF keypad button. */
  noDTMF?: boolean;
  /** Hides the text input under the transcript. */
  noTextInput?: boolean;
  /** Disables bot audio playback entirely (also hides its volume control). */
  noAudioOutput?: boolean;
  /** Hides the bot audio pane. */
  noBotAudio?: boolean;
  /** Hides the volume control in the bot audio pane header. */
  noBotAudioControls?: boolean;
  /** Hides the bot video pane. Default false. */
  noBotVideo?: boolean;
  noConversation?: boolean;
  noMetrics?: boolean;
  /** Hides the events strip. */
  noEvents?: boolean;
  noStatusInfo?: boolean;
  noSessionInfo?: boolean;

  // -- Behavior --------------------------------------------------------------
  /** How the DTMF keypad dispatches presses (default "buffered"). */
  keypadMode?: DTMFKeypadMode;
  /** Seeds the transcript render mode (default "karaoke"). */
  textRenderMode?: TextRenderMode;
  /** Hides the render-mode select. Default false. */
  noTextRenderModeSwitch?: boolean;
  /** Overrides merged onto the Conversation (labels, renderers, …). */
  conversationProps?: Partial<ConversationProps>;
  /** Preferred audio codec (smallwebrtc only). */
  audioCodec?: string;
  /** Preferred video codec (smallwebrtc only). */
  videoCodec?: string;
  /** Start with the info pane collapsed. Default false. */
  collapseInfoPanel?: boolean;
  /** Start with the bot media pane collapsed. Default false. */
  collapseMediaPanel?: boolean;
  /** Start with the events strip collapsed. Default false. */
  collapseEventsPanel?: boolean;
  /**
   * Persist the resizable layout under this key (localStorage). Off when
   * unset.
   */
  layoutPersistenceKey?: string;

  // -- Callbacks -------------------------------------------------------------
  /** Fired on every transport state change. */
  onConnectionStateChange?: (state: TransportState) => void;
  /** Fired for RTVI server messages. */
  onServerMessage?: (data: unknown) => void;
  /** Hands you the transcript's injectMessage function once available. */
  onInjectMessage?: (
    inject: ReturnType<typeof useConversationContext>["injectMessage"],
  ) => void;

  className?: string;
}

/**
 * Full-page Pipecat debugging console: header with connect flow, resizable
 * bot-media / conversation+metrics / info panes over an events strip, and a
 * single-tree mobile layout using bottom tabs.
 *
 * The console builds its own client via usePipecatApp and renders its own
 * PipecatClientProvider — do not nest it inside another provider. Supply
 * the transport with `transportFactory` or `registerTransport`; load
 * failures surface in the error banner.
 */
export function Console(props: ConsoleProps) {
  const {
    transportType = "smallwebrtc",
    transportFactory,
    transportOptions,
    clientOptions,
    connectParams,
    startBotParams,
    startBotResponseTransformer,
    connectOnMount = false,
    initDevicesOnMount = true,
    onClient,
    className,
  } = props;

  const app = usePipecatApp({
    transportType,
    transportFactory,
    transportOptions,
    clientOptions,
    connectParams,
    startBotParams,
    startBotResponseTransformer,
    connectOnMount,
    initDevicesOnMount,
    onClient,
  });

  if (!app.client) {
    return (
      <div
        data-slot="console"
        data-state={app.error ? "error" : "loading"}
        className={cn(
          "bg-background flex h-full min-h-64 items-center justify-center p-4",
          className,
        )}
      >
        {app.error ? (
          <Alert variant="destructive" className="max-w-lg">
            <AlertTitle>Failed to start the console</AlertTitle>
            <AlertDescription>{app.error}</AlertDescription>
          </Alert>
        ) : (
          <Spinner className="size-6" />
        )}
      </div>
    );
  }

  return (
    <PipecatClientProvider client={app.client}>
      <TooltipProvider>
        <ConsoleShell {...props} app={app} />
      </TooltipProvider>
      {!props.noAudioOutput && <BotAudioOutput />}
      {!props.noMetrics && <MetricsCollector />}
      {!props.noEvents && <EventStreamCollector />}
      {transportType === "smallwebrtc" && (
        <SmallWebRTCCodecSetter
          audioCodec={props.audioCodec}
          videoCodec={props.videoCodec}
        />
      )}
    </PipecatClientProvider>
  );
}

function ConsoleShell({
  app,
  transportOptions,
  connectParams,
  startBotParams,
  titleText = "Pipecat Console",
  logo,
  noLogo = false,
  headerSlot,
  noUserAudio = false,
  noUserVideo = false,
  noScreenControl = false,
  noDTMF = false,
  noTextInput = false,
  noAudioOutput = false,
  noBotAudio = false,
  noBotAudioControls = false,
  noBotVideo = false,
  noConversation = false,
  noMetrics = false,
  noEvents = false,
  noStatusInfo = false,
  noSessionInfo = false,
  keypadMode = "buffered",
  textRenderMode,
  noTextRenderModeSwitch = false,
  conversationProps,
  collapseInfoPanel = false,
  collapseMediaPanel = false,
  collapseEventsPanel = false,
  layoutPersistenceKey,
  onConnectionStateChange,
  onServerMessage,
  onInjectMessage,
  className,
}: ConsoleProps & { app: UsePipecatAppReturn }) {
  // Three resizable columns need about 1024px; narrower screens get tabs.
  const isDesktop = useMinWidth(1024);

  // -- Session facts collected from RTVI events ------------------------------
  const [participantId, setParticipantId] = React.useState<string>();
  const [sessionId, setSessionId] = React.useState<string>();

  useRTVIClientEvent(
    RTVIEvent.ParticipantConnected,
    React.useCallback((participant: Participant) => {
      if (participant.local) setParticipantId(participant.id);
    }, []),
  );
  // Belt and braces for transports that never fire ParticipantConnected.
  useRTVIClientEvent(
    RTVIEvent.TrackStarted,
    React.useCallback((_track: MediaStreamTrack, participant?: Participant) => {
      if (participant?.local && participant.id) {
        setParticipantId(participant.id);
      }
    }, []),
  );
  useRTVIClientEvent(
    RTVIEvent.BotStarted,
    React.useCallback((response: unknown) => {
      const id = (response as { sessionId?: string } | null)?.sessionId;
      if (typeof id === "string") setSessionId(id);
    }, []),
  );

  const onServerMessageRef = React.useRef(onServerMessage);
  onServerMessageRef.current = onServerMessage;
  useRTVIClientEvent(
    RTVIEvent.ServerMessage,
    React.useCallback((data: unknown) => {
      onServerMessageRef.current?.(data);
    }, []),
  );

  const onConnectionStateChangeRef = React.useRef(onConnectionStateChange);
  onConnectionStateChangeRef.current = onConnectionStateChange;
  useRTVIClientEvent(
    RTVIEvent.TransportStateChanged,
    React.useCallback((state: TransportState) => {
      onConnectionStateChangeRef.current?.(state);
    }, []),
  );

  const { injectMessage } = useConversationContext();
  const onInjectMessageRef = React.useRef(onInjectMessage);
  onInjectMessageRef.current = onInjectMessage;
  React.useEffect(() => {
    onInjectMessageRef.current?.(injectMessage);
  }, [injectMessage]);

  // -- Region math -----------------------------------------------------------
  const noBotArea = noBotAudio && noBotVideo;
  const noConversationPanel = noConversation && noMetrics;
  const noDevices = noUserAudio && noUserVideo && noScreenControl;
  const noInfoPanel = noStatusInfo && noDevices && noSessionInfo;

  // react-resizable-panels v4 reads bare numbers as pixels, so every size
  // below is a percentage string. Conversation takes whatever the side panes
  // leave so the horizontal defaults always sum to 100.
  const mediaDefaultSize = noBotArea ? 0 : collapseMediaPanel ? 8 : 26;
  const infoDefaultSize = noInfoPanel ? 0 : collapseInfoPanel ? 4 : 27;
  const conversationDefaultSize = 100 - mediaDefaultSize - infoDefaultSize;

  // -- Resizable layout state ------------------------------------------------
  const persist = Boolean(layoutPersistenceKey);
  const storage = persist ? undefined : MEMORY_STORAGE;
  const verticalLayout = useDefaultLayout({
    id: `${layoutPersistenceKey ?? "pipecat-console"}:v`,
    storage,
  });
  const horizontalLayout = useDefaultLayout({
    id: `${layoutPersistenceKey ?? "pipecat-console"}:h`,
    storage,
  });

  const mediaPanelRef = usePanelRef();
  const infoPanelRef = usePanelRef();
  const eventsPanelRef = usePanelRef();
  const [isMediaCollapsed, setIsMediaCollapsed] =
    React.useState(collapseMediaPanel);
  const [isInfoCollapsed, setIsInfoCollapsed] =
    React.useState(collapseInfoPanel);
  const [isEventsCollapsed, setIsEventsCollapsed] =
    React.useState(collapseEventsPanel);

  const connectionUrl = getConnectionUrl(
    startBotParams,
    connectParams,
    transportOptions,
  );

  // -- Shared panel elements (instantiated once per render, one branch) ------
  const mediaStack = (collapsed: boolean) =>
    noBotArea ? null : (
      <div className="flex h-full min-h-0 flex-col gap-2">
        {!noBotAudio && (
          <ConsoleBotAudioPanel
            collapsed={collapsed}
            noControls={noBotAudioControls || noAudioOutput}
            className={noBotVideo ? undefined : "flex-1"}
          />
        )}
        {!noBotVideo && (
          <ConsoleBotVideoPanel
            collapsed={collapsed}
            className={noBotAudio ? undefined : "flex-1"}
          />
        )}
      </div>
    );

  const conversationPanel = noConversationPanel ? null : (
    <ConsoleConversationPanel
      noConversation={noConversation}
      noMetrics={noMetrics}
      noTextInput={noTextInput}
      noTextRenderModeSwitch={noTextRenderModeSwitch}
      textRenderMode={textRenderMode}
      conversationProps={conversationProps}
      className="h-full"
    />
  );

  const infoPanel = (collapsed: boolean) =>
    noInfoPanel ? null : (
      <ConsoleInfoPanel
        noStatusInfo={noStatusInfo}
        noUserAudio={noUserAudio}
        noUserVideo={noUserVideo}
        noScreenControl={noScreenControl}
        noSessionInfo={noSessionInfo}
        sessionId={sessionId}
        participantId={participantId}
        collapsed={collapsed}
        className="h-full"
      />
    );

  const eventsPanel = (collapsed: boolean) =>
    noEvents ? null : (
      <ConsoleEventsPanel collapsed={collapsed} className="h-full" />
    );

  const toggleInfoPanel = () => {
    const handle = infoPanelRef.current;
    if (!handle) return;
    if (handle.isCollapsed()) handle.expand();
    else handle.collapse();
  };

  const connectButton = (
    <ConnectButton
      size="sm"
      onConnect={app.connect}
      onDisconnect={app.disconnect}
    />
  );

  return (
    <div
      data-slot="console"
      data-state="ready"
      className={cn(
        "bg-background text-foreground flex h-full min-h-0 flex-col",
        className,
      )}
    >
      <header
        data-slot="console-header"
        className="flex items-center gap-2 border-b px-3 py-2"
      >
        <div className="flex shrink-0 items-center">
          {noLogo ? (
            <span className="h-6" />
          ) : (
            (logo ?? <PipecatLogo height={20} />)
          )}
        </div>
        <strong className="hidden min-w-0 truncate text-sm md:block">
          {titleText}
        </strong>
        <div className="ml-auto flex min-w-0 items-center justify-end gap-1">
          {/* Slot content may shrink; the console's own controls never do. */}
          {headerSlot && (
            <div className="flex min-w-0 items-center gap-1">{headerSlot}</div>
          )}
          {!noDTMF && <ConsoleKeypadToggle mode={keypadMode} />}
          {!noInfoPanel && (
            <Button
              variant="ghost"
              size="icon-sm"
              className="hidden lg:inline-flex"
              aria-label={
                isInfoCollapsed ? "Expand info panel" : "Collapse info panel"
              }
              onClick={toggleInfoPanel}
            >
              {isInfoCollapsed ? (
                <PanelLeftCloseIcon />
              ) : (
                <PanelRightCloseIcon />
              )}
            </Button>
          )}
          {connectionUrl ? (
            <Tooltip>
              <TooltipTrigger render={<span className="inline-flex" />}>
                {connectButton}
              </TooltipTrigger>
              <TooltipContent side="bottom" align="end">
                {connectionUrl}
              </TooltipContent>
            </Tooltip>
          ) : (
            connectButton
          )}
        </div>
      </header>

      {app.error && (
        <Alert
          variant="destructive"
          className="animate-in fade-in m-2 duration-300"
        >
          <AlertTitle>Session error</AlertTitle>
          <AlertDescription>{app.error}</AlertDescription>
          <AlertAction>
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label="Dismiss error"
              onClick={app.clearError}
            >
              <XIcon />
            </Button>
          </AlertAction>
        </Alert>
      )}

      <div className="min-h-0 flex-1 p-2">
        {isDesktop ? (
          <ResizablePanelGroup
            orientation="vertical"
            defaultLayout={verticalLayout.defaultLayout}
            onLayoutChanged={verticalLayout.onLayoutChanged}
            className="gap-2"
          >
            <ResizablePanel id="main" defaultSize="70%" minSize="50%">
              <ResizablePanelGroup
                orientation="horizontal"
                defaultLayout={horizontalLayout.defaultLayout}
                onLayoutChanged={horizontalLayout.onLayoutChanged}
                className="gap-2"
              >
                {!noBotArea && (
                  <ResizablePanel
                    id="media"
                    defaultSize={`${mediaDefaultSize}%`}
                    minSize="10%"
                    maxSize="30%"
                    collapsible
                    collapsedSize="8%"
                    panelRef={mediaPanelRef}
                    onResize={() =>
                      setIsMediaCollapsed(
                        mediaPanelRef.current?.isCollapsed() ?? false,
                      )
                    }
                  >
                    {/* p-px keeps each card's ring inside the panel's scroll box, which clips it. */}
                    <div className="h-full p-px">
                      {mediaStack(isMediaCollapsed)}
                    </div>
                  </ResizablePanel>
                )}
                {!noBotArea && (!noConversationPanel || !noInfoPanel) && (
                  <ResizableHandle withHandle />
                )}
                {!noConversationPanel && (
                  <ResizablePanel
                    id="conversation"
                    defaultSize={`${conversationDefaultSize}%`}
                    minSize="30%"
                  >
                    <div className="h-full p-px">{conversationPanel}</div>
                  </ResizablePanel>
                )}
                {!noConversationPanel && !noInfoPanel && (
                  <ResizableHandle withHandle />
                )}
                {!noInfoPanel && (
                  <ResizablePanel
                    id="info"
                    defaultSize={`${infoDefaultSize}%`}
                    minSize="15%"
                    collapsible
                    collapsedSize="4%"
                    panelRef={infoPanelRef}
                    onResize={() =>
                      setIsInfoCollapsed(
                        infoPanelRef.current?.isCollapsed() ?? false,
                      )
                    }
                  >
                    <div className="h-full p-px">
                      {infoPanel(isInfoCollapsed)}
                    </div>
                  </ResizablePanel>
                )}
              </ResizablePanelGroup>
            </ResizablePanel>
            {!noEvents && <ResizableHandle withHandle />}
            {!noEvents && (
              <ResizablePanel
                id="events"
                defaultSize={collapseEventsPanel ? "6%" : "30%"}
                minSize="7%"
                collapsible
                collapsedSize="6%"
                panelRef={eventsPanelRef}
                onResize={() =>
                  setIsEventsCollapsed(
                    eventsPanelRef.current?.isCollapsed() ?? false,
                  )
                }
              >
                <div className="h-full p-px">
                  {eventsPanel(isEventsCollapsed)}
                </div>
              </ResizablePanel>
            )}
          </ResizablePanelGroup>
        ) : (
          <Tabs
            defaultValue={
              !noConversationPanel
                ? "conversation"
                : !noBotArea
                  ? "bot"
                  : !noInfoPanel
                    ? "info"
                    : "events"
            }
            className="flex h-full min-h-0 flex-col"
          >
            {!noBotArea && (
              <TabsContent value="bot" className="min-h-0 flex-1">
                {mediaStack(false)}
              </TabsContent>
            )}
            {!noConversationPanel && (
              <TabsContent
                value="conversation"
                keepMounted
                className="min-h-0 flex-1 data-[hidden]:hidden"
              >
                {conversationPanel}
              </TabsContent>
            )}
            {!noInfoPanel && (
              <TabsContent value="info" className="min-h-0 flex-1">
                {infoPanel(false)}
              </TabsContent>
            )}
            {!noEvents && (
              <TabsContent value="events" className="min-h-0 flex-1">
                {eventsPanel(false)}
              </TabsContent>
            )}
            <TabsList className="mt-2 w-full">
              {!noConversationPanel && (
                <TabsTrigger value="conversation" aria-label="Conversation">
                  <MessagesSquareIcon />
                </TabsTrigger>
              )}
              {!noBotArea && (
                <TabsTrigger value="bot" aria-label="Bot media">
                  <BotIcon />
                </TabsTrigger>
              )}
              {!noInfoPanel && (
                <TabsTrigger value="info" aria-label="Info">
                  <InfoIcon />
                </TabsTrigger>
              )}
              {!noEvents && (
                <TabsTrigger value="events" aria-label="Events">
                  <ChevronsLeftRightEllipsisIcon />
                </TabsTrigger>
              )}
            </TabsList>
          </Tabs>
        )}
      </div>
    </div>
  );
}
```

### `src/blocks/console/bot-audio-panel.tsx`

```tsx
"use client";

import { usePipecatClientMediaTrack } from "@pipecat-ai/client-react";
import { MicOffIcon } from "lucide-react";
import { AudioVisualizerBar } from "@/components/pipecat/audio-visualizer-bar";
import { BotAudioControl } from "@/components/pipecat/bot-audio";
import {
  ConsolePanel,
  ConsolePanelActions,
  ConsolePanelContent,
  ConsolePanelHeader,
  ConsolePanelTitle,
} from "@/components/pipecat/console/panel";
import { cn } from "@/lib/utils";

export interface ConsoleBotAudioPanelProps {
  /** Compact rendering for a collapsed pane: no header, tighter bars. */
  collapsed?: boolean;
  /** Hides the volume control in the header. Default false. */
  noControls?: boolean;
  className?: string;
}

/**
 * Console pane visualizing the bot's audio track, with a volume control in
 * the header. Shows a quiet placeholder until a bot audio track exists.
 * Must be rendered inside a PipecatClientProvider.
 */
export function ConsoleBotAudioPanel({
  collapsed = false,
  noControls = false,
  className,
}: ConsoleBotAudioPanelProps) {
  const track = usePipecatClientMediaTrack("audio", "bot");

  return (
    <ConsolePanel
      className={className}
      data-slot="console-bot-audio-panel"
      data-state={collapsed ? "collapsed" : "expanded"}
    >
      {!collapsed && (
        <ConsolePanelHeader>
          <ConsolePanelTitle>Bot audio</ConsolePanelTitle>
          {!noControls && (
            <ConsolePanelActions>
              <BotAudioControl size="icon-sm" variant="ghost" />
            </ConsolePanelActions>
          )}
        </ConsolePanelHeader>
      )}
      <ConsolePanelContent className="text-agent-foreground flex items-center justify-center overflow-hidden">
        {track ? (
          <AudioVisualizerBar
            participantType="bot"
            barCount={collapsed ? 3 : 8}
            barWidth={collapsed ? 4 : 8}
            barGap={collapsed ? 3 : 6}
            barMaxHeight={collapsed ? 32 : 96}
            className={cn("w-auto", collapsed && "opacity-80")}
          />
        ) : (
          <div className="text-muted-foreground flex flex-col items-center gap-1 text-xs">
            <MicOffIcon className="size-4" />
            {!collapsed && <span>No audio</span>}
          </div>
        )}
      </ConsolePanelContent>
    </ConsolePanel>
  );
}
```

### `src/blocks/console/bot-video-panel.tsx`

```tsx
"use client";

import {
  PipecatClientVideo,
  usePipecatClientMediaTrack,
} from "@pipecat-ai/client-react";
import { VideoOffIcon } from "lucide-react";
import {
  ConsolePanel,
  ConsolePanelContent,
  ConsolePanelHeader,
  ConsolePanelTitle,
} from "@/components/pipecat/console/panel";

export interface ConsoleBotVideoPanelProps {
  /** Compact rendering for a collapsed pane: no header. */
  collapsed?: boolean;
  className?: string;
}

/**
 * Console pane rendering the bot's video track, with a quiet placeholder
 * until one exists. Must be rendered inside a PipecatClientProvider.
 */
export function ConsoleBotVideoPanel({
  collapsed = false,
  className,
}: ConsoleBotVideoPanelProps) {
  const track = usePipecatClientMediaTrack("video", "bot");

  return (
    <ConsolePanel
      className={className}
      data-slot="console-bot-video-panel"
      data-state={collapsed ? "collapsed" : "expanded"}
    >
      {!collapsed && (
        <ConsolePanelHeader>
          <ConsolePanelTitle>Bot video</ConsolePanelTitle>
        </ConsolePanelHeader>
      )}
      <ConsolePanelContent className="flex items-center justify-center overflow-hidden">
        {track ? (
          <PipecatClientVideo
            participant="bot"
            fit="contain"
            className="h-full w-full rounded-md"
          />
        ) : (
          <div className="text-muted-foreground flex aspect-video w-full flex-col items-center justify-center gap-1 text-xs">
            <VideoOffIcon className="size-4" />
            {!collapsed && <span>No video</span>}
          </div>
        )}
      </ConsolePanelContent>
    </ConsolePanel>
  );
}
```

### `src/blocks/console/codec-setter.tsx`

```tsx
"use client";

import { usePipecatClient } from "@pipecat-ai/client-react";
import { useEffect } from "react";

export interface SmallWebRTCCodecSetterProps {
  /** Preferred audio codec, e.g. "opus". Default "default" (no override). */
  audioCodec?: string;
  /** Preferred video codec, e.g. "VP8". Default "default" (no override). */
  videoCodec?: string;
}

/**
 * Headless helper that applies audio/video codec preferences to a
 * SmallWebRTC transport. Renders nothing; transports without codec setters
 * are left untouched, so it works whether the transport came from a factory
 * or a registered loader. Must be rendered inside a PipecatClientProvider.
 */
export function SmallWebRTCCodecSetter({
  audioCodec = "default",
  videoCodec = "default",
}: SmallWebRTCCodecSetterProps) {
  const client = usePipecatClient();

  useEffect(() => {
    // Structural check: the transport package's types can't be named here
    // (transports are optional installs).
    const transport = client?.transport as
      | {
          setAudioCodec?: (codec: string) => void;
          setVideoCodec?: (codec: string) => void;
        }
      | undefined;
    if (typeof transport?.setAudioCodec === "function") {
      transport.setAudioCodec(audioCodec);
    }
    if (typeof transport?.setVideoCodec === "function") {
      transport.setVideoCodec(videoCodec);
    }
  }, [audioCodec, client, videoCodec]);

  return null;
}
```

### `src/blocks/console/conversation-panel.tsx`

```tsx
"use client";

import * as React from "react";

import {
  ConsolePanel,
  ConsolePanelActions,
  ConsolePanelContent,
} from "@/components/pipecat/console/panel";
import {
  Conversation,
  type ConversationProps,
} from "@/components/pipecat/conversation";
import type { TextRenderMode } from "@/components/pipecat/conversation-message";
import { Metrics } from "@/components/pipecat/metrics/metrics";
import { TextInput } from "@/components/pipecat/text-input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

const RENDER_MODES: Array<{ value: TextRenderMode; label: string }> = [
  { value: "karaoke", label: "Karaoke" },
  { value: "captions", label: "Captions" },
  { value: "instant", label: "Instant" },
];

export interface ConsoleConversationPanelProps {
  /** Hides the conversation tab. Default false. */
  noConversation?: boolean;
  /** Hides the metrics tab. Default false. */
  noMetrics?: boolean;
  /** Hides the text input under the transcript. Default false. */
  noTextInput?: boolean;
  /** Hides the render-mode select in the header. Default false. */
  noTextRenderModeSwitch?: boolean;
  /** Seeds (and can push) the transcript render mode. Default "karaoke". */
  textRenderMode?: TextRenderMode;
  /** Fired when the user picks a render mode in the header. */
  onTextRenderModeChange?: (mode: TextRenderMode) => void;
  /** Overrides merged onto the Conversation (labels, renderers, …). */
  conversationProps?: Partial<ConversationProps>;
  className?: string;
}

/**
 * Console center pane: the live transcript (with text input) and the
 * metrics dashboard as tabs. The conversation tab stays mounted so scroll
 * position survives tab switches; metrics re-reads its shared store. Must
 * be rendered inside a PipecatClientProvider.
 */
export function ConsoleConversationPanel({
  noConversation = false,
  noMetrics = false,
  noTextInput = false,
  noTextRenderModeSwitch = false,
  textRenderMode,
  onTextRenderModeChange,
  conversationProps,
  className,
}: ConsoleConversationPanelProps) {
  // Local selection seeded from the prop; a defined prop change pushes it.
  const [renderMode, setRenderMode] = React.useState<TextRenderMode>(
    textRenderMode ?? "karaoke",
  );
  React.useEffect(() => {
    if (textRenderMode !== undefined) setRenderMode(textRenderMode);
  }, [textRenderMode]);

  if (noConversation && noMetrics) return null;

  const handleRenderMode = (mode: TextRenderMode) => {
    setRenderMode(mode);
    onTextRenderModeChange?.(mode);
  };

  return (
    <Tabs
      defaultValue={noConversation ? "metrics" : "conversation"}
      className={className}
      data-slot="console-conversation-panel"
    >
      <ConsolePanel>
        <div className="flex items-center gap-2 border-b px-(--card-spacing) pb-(--card-spacing)">
          <TabsList>
            {!noConversation && (
              <TabsTrigger value="conversation">Conversation</TabsTrigger>
            )}
            {!noMetrics && <TabsTrigger value="metrics">Metrics</TabsTrigger>}
          </TabsList>
          {!noConversation && !noTextRenderModeSwitch && (
            <ConsolePanelActions className="ml-auto">
              <Select
                value={renderMode}
                onValueChange={(value) =>
                  handleRenderMode(value as TextRenderMode)
                }
              >
                <SelectTrigger
                  size="sm"
                  aria-label="Text render mode"
                  className="h-7 text-xs"
                >
                  <SelectValue />
                </SelectTrigger>
                <SelectContent align="end">
                  {RENDER_MODES.map((mode) => (
                    <SelectItem key={mode.value} value={mode.value}>
                      {mode.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </ConsolePanelActions>
          )}
        </div>
        {!noConversation && (
          <TabsContent
            value="conversation"
            keepMounted
            className="flex min-h-0 flex-1 flex-col data-[hidden]:hidden"
          >
            <ConsolePanelContent>
              <Conversation
                textRenderMode={renderMode}
                {...conversationProps}
              />
            </ConsolePanelContent>
            {!noTextInput && (
              <div className="border-t p-(--card-spacing)">
                <TextInput />
              </div>
            )}
          </TabsContent>
        )}
        {!noMetrics && (
          <TabsContent value="metrics" className="min-h-0 flex-1">
            <ConsolePanelContent>
              <Metrics />
            </ConsolePanelContent>
          </TabsContent>
        )}
      </ConsolePanel>
    </Tabs>
  );
}
```

### `src/blocks/console/events-panel.tsx`

```tsx
"use client";

import { RTVIEvent, type BotOutputData } from "@pipecat-ai/client-js";
import { PauseIcon, PlayIcon, SearchIcon, Trash2Icon } from "lucide-react";
import * as React from "react";

import {
  ConsolePanel,
  ConsolePanelActions,
  ConsolePanelContent,
  ConsolePanelHeader,
  ConsolePanelTitle,
} from "@/components/pipecat/console/panel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  usePipecatEventStream,
  type PipecatEventLog,
} from "@/hooks/use-pipecat-event-stream";
import { cn } from "@/lib/utils";

const TIME_FORMAT = new Intl.DateTimeFormat(undefined, {
  hour12: false,
  hour: "2-digit",
  minute: "2-digit",
  second: "2-digit",
  fractionalSecondDigits: 3,
});

function summarizeBotOutput(data: Partial<BotOutputData>): string {
  const spoken =
    data.will_be_spoken === false
      ? "not spoken"
      : data.spoken_status !== undefined
        ? `spoken: ${data.spoken_status}`
        : data.spoken !== undefined
          ? `spoken: ${data.spoken}`
          : null;
  const details = [
    data.aggregated_by,
    data.segment_id !== undefined ? `#${data.segment_id}` : null,
    spoken,
  ].filter(Boolean);
  return `(${details.join(", ")}): ${data.text ?? ""}`;
}

function summarize(event: PipecatEventLog): string {
  const { data } = event;
  if (data === undefined) return "";
  if (event.type === RTVIEvent.BotOutput && typeof data === "object") {
    return summarizeBotOutput(data as Partial<BotOutputData>);
  }
  try {
    const json = JSON.stringify(data);
    return json.length > 120 ? `${json.slice(0, 120)}…` : json;
  } catch {
    return String(data);
  }
}

/** Chatter covered by botOutput rows and the metrics panel. */
const HIDDEN_EVENTS: string[] = [
  RTVIEvent.Metrics,
  RTVIEvent.BotTranscript,
  RTVIEvent.BotLlmStarted,
  RTVIEvent.BotLlmText,
  RTVIEvent.BotLlmStopped,
  RTVIEvent.BotTtsStarted,
  RTVIEvent.BotTtsText,
  RTVIEvent.BotTtsStopped,
];

/**
 * RTVI 2.0.0+ re-sends a segment's botOutput on every spoken progress update
 * (about once per TTS word). Keep one row per spoken status change, so an
 * interrupted segment shows as new/in-progress without a completed row. Keys
 * on segment_id, never text: separate responses can share wording.
 */
function dropRepeatedBotOutput(
  events: readonly PipecatEventLog[],
): PipecatEventLog[] {
  const statuses = new Map<number, string>();
  return events.filter((event) => {
    if (event.type !== RTVIEvent.BotOutput) return true;
    const data = event.data as Partial<BotOutputData> | undefined;
    if (data?.segment_id === undefined || data.spoken_status === undefined) {
      return true;
    }
    if (statuses.get(data.segment_id) === data.spoken_status) return false;
    statuses.set(data.segment_id, data.spoken_status);
    return true;
  });
}

function EventRow({ event }: { event: PipecatEventLog }) {
  const [expanded, setExpanded] = React.useState(false);
  const summary = summarize(event);
  return (
    <div data-slot="console-event" className="font-mono text-xs">
      <button
        type="button"
        onClick={() => setExpanded((value) => !value)}
        className="hover:bg-muted/50 grid w-full grid-cols-[min-content_min-content_1fr] items-baseline gap-x-3 rounded-sm px-1 py-0.5 text-left"
        aria-expanded={expanded}
      >
        <span className="text-muted-foreground whitespace-nowrap">
          {TIME_FORMAT.format(event.timestamp)}
        </span>
        <span className="font-semibold whitespace-nowrap">{event.type}</span>
        <span className="text-muted-foreground truncate">{summary}</span>
      </button>
      {expanded && event.data !== undefined && (
        <pre className="bg-muted/50 my-1 ml-4 overflow-x-auto rounded-sm p-2">
          {JSON.stringify(event.data, null, 2)}
        </pre>
      )}
    </div>
  );
}

export interface ConsoleEventsPanelProps {
  /** Compact rendering for a collapsed pane: header strip only. */
  collapsed?: boolean;
  className?: string;
}

/**
 * Live RTVI event log over the shared use-pipecat-event-stream store:
 * filter-as-you-type, pause/resume, clear, click-to-expand payloads, and
 * scroll pinning that follows the tail until you scroll away. Metrics and bot
 * LLM/TTS events are hidden, and botOutput shows once per spoken status.
 * Capture is shared, so a collapsed panel misses nothing. Must be rendered
 * inside a PipecatClientProvider.
 */
export function ConsoleEventsPanel({
  collapsed = false,
  className,
}: ConsoleEventsPanelProps) {
  const { events, paused, setPaused, clear } = usePipecatEventStream({
    ignoreEvents: HIDDEN_EVENTS,
  });
  const [filter, setFilter] = React.useState("");

  const filtered = React.useMemo(() => {
    const needle = filter.trim().toLowerCase();
    const shown = dropRepeatedBotOutput(events);
    if (!needle) return shown;
    return shown.filter((event) => event.type.toLowerCase().includes(needle));
  }, [events, filter]);

  // Scroll pinning: stick to the tail unless the user scrolled away.
  const scrollRef = React.useRef<HTMLDivElement | null>(null);
  const pinnedRef = React.useRef(true);
  const handleScroll = React.useCallback(() => {
    const el = scrollRef.current;
    if (!el) return;
    pinnedRef.current =
      Math.ceil(el.scrollHeight - el.scrollTop) <=
      Math.ceil(el.clientHeight) + 4;
  }, []);
  React.useEffect(() => {
    const el = scrollRef.current;
    if (el && pinnedRef.current) {
      el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
    }
  }, [filtered]);

  return (
    <ConsolePanel
      className={className}
      data-slot="console-events-panel"
      data-state={collapsed ? "collapsed" : "expanded"}
    >
      <ConsolePanelHeader>
        <ConsolePanelTitle>Events</ConsolePanelTitle>
        {!collapsed && (
          <ConsolePanelActions>
            <div className="relative hidden @xs/panel:block">
              <SearchIcon className="text-muted-foreground pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2" />
              <Input
                value={filter}
                onChange={(event) => setFilter(event.target.value)}
                placeholder="Filter events"
                aria-label="Filter events"
                className="h-7 w-40 pl-7 text-xs"
              />
            </div>
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label={
                paused ? "Resume event capture" : "Pause event capture"
              }
              aria-pressed={paused}
              onClick={() => setPaused(!paused)}
            >
              {paused ? <PlayIcon /> : <PauseIcon />}
            </Button>
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label="Clear events"
              onClick={clear}
            >
              <Trash2Icon />
            </Button>
          </ConsolePanelActions>
        )}
      </ConsolePanelHeader>
      {!collapsed && (
        <ConsolePanelContent
          ref={scrollRef}
          onScroll={handleScroll}
          className={cn("flex flex-col gap-0.5")}
        >
          {filtered.length === 0 ? (
            <div className="text-muted-foreground flex h-full min-h-16 items-center justify-center text-xs">
              {!filter.trim()
                ? "Events appear once a session is live."
                : "No events match the filter."}
            </div>
          ) : (
            filtered.map((event) => <EventRow key={event.id} event={event} />)
          )}
        </ConsolePanelContent>
      )}
    </ConsolePanel>
  );
}
```

### `src/blocks/console/info-panel.tsx`

```tsx
"use client";

import { usePipecatClientCamControl } from "@pipecat-ai/client-react";
import { ChevronsLeftRightEllipsisIcon, InfoIcon, MicIcon } from "lucide-react";
import * as React from "react";

import { ClientStatus } from "@/components/pipecat/client-status";
import {
  ConsolePanel,
  ConsolePanelContent,
  ConsolePanelTitle,
} from "@/components/pipecat/console/panel";
import { SessionInfo } from "@/components/pipecat/session-info";
import { UserAudioControl } from "@/components/pipecat/user-audio-control";
import { UserScreenControl } from "@/components/pipecat/user-screen-control";
import { UserVideoControl } from "@/components/pipecat/user-video-control";
import { Button } from "@/components/ui/button";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";

export interface ConsoleInfoPanelProps {
  /** Hides the connection status section. Default false. */
  noStatusInfo?: boolean;
  /** Hides the microphone control. Default false. */
  noUserAudio?: boolean;
  /** Hides the camera control. Default false. */
  noUserVideo?: boolean;
  /** Hides the screen-share control. Default false. */
  noScreenControl?: boolean;
  /** Hides the session info section. Default false. */
  noSessionInfo?: boolean;
  /** Session id collected by the console shell (BotStarted). */
  sessionId?: string;
  /** Local participant id collected by the console shell. */
  participantId?: string;
  /** Icon-strip rendering for a collapsed pane; sections open in popovers. */
  collapsed?: boolean;
  className?: string;
}

function CollapsedSection({
  label,
  icon,
  children,
}: {
  label: string;
  icon: React.ReactNode;
  children: React.ReactNode;
}) {
  return (
    <Popover>
      <PopoverTrigger
        render={
          <Button variant="ghost" size="icon-sm" aria-label={label}>
            {icon}
          </Button>
        }
      />
      <PopoverContent side="left" align="start" className="w-80 max-w-[90vw]">
        {children}
      </PopoverContent>
    </Popover>
  );
}

/**
 * Console info pane: connection status, local device controls, and session
 * details. Renders null when every section is disabled; the collapsed
 * variant is a vertical icon strip whose sections open in popovers. Must be
 * rendered inside a PipecatClientProvider (with a TooltipProvider ancestor
 * for the session copy buttons).
 */
export function ConsoleInfoPanel({
  noStatusInfo = false,
  noUserAudio = false,
  noUserVideo = false,
  noScreenControl = false,
  noSessionInfo = false,
  sessionId,
  participantId,
  collapsed = false,
  className,
}: ConsoleInfoPanelProps) {
  // Most agents never use the camera, so its preview tile only takes space
  // once the camera is on.
  const { isCamEnabled } = usePipecatClientCamControl();
  const noDevices = noUserAudio && noUserVideo && noScreenControl;
  if (noStatusInfo && noDevices && noSessionInfo) return null;

  const devices = (
    <div className="flex flex-col gap-2">
      {!noUserAudio && <UserAudioControl className="w-full" />}
      {!noUserVideo && (
        <UserVideoControl className="w-full" noVideo={!isCamEnabled} />
      )}
      {!noScreenControl && <UserScreenControl className="w-full" />}
    </div>
  );

  if (collapsed) {
    return (
      <div
        data-slot="console-info-panel"
        data-state="collapsed"
        className={className}
      >
        <div className="flex h-full flex-col items-center gap-1 py-2">
          {!noStatusInfo && (
            <CollapsedSection
              label="Connection status"
              icon={<ChevronsLeftRightEllipsisIcon />}
            >
              <ClientStatus />
            </CollapsedSection>
          )}
          {!noDevices && (
            <CollapsedSection label="Devices" icon={<MicIcon />}>
              {devices}
            </CollapsedSection>
          )}
          {!noSessionInfo && (
            <CollapsedSection label="Session info" icon={<InfoIcon />}>
              <SessionInfo
                sessionId={sessionId}
                participantId={participantId}
              />
            </CollapsedSection>
          )}
        </div>
      </div>
    );
  }

  return (
    <ConsolePanel
      className={className}
      data-slot="console-info-panel"
      data-state="expanded"
    >
      <ConsolePanelContent className="flex flex-col gap-(--card-spacing)">
        {!noStatusInfo && (
          <section className="flex flex-col gap-2">
            <ConsolePanelTitle>Status</ConsolePanelTitle>
            <ClientStatus />
          </section>
        )}
        {!noDevices && (
          <section className="flex flex-col gap-2">
            <ConsolePanelTitle>Devices</ConsolePanelTitle>
            {devices}
          </section>
        )}
        {!noSessionInfo && (
          <section className="flex flex-col gap-2">
            <ConsolePanelTitle>Session</ConsolePanelTitle>
            <SessionInfo sessionId={sessionId} participantId={participantId} />
          </section>
        )}
      </ConsolePanelContent>
    </ConsolePanel>
  );
}
```

### `src/blocks/console/keypad-toggle.tsx`

```tsx
"use client";

import { PhoneIcon } from "lucide-react";
import * as React from "react";

import { useMinWidth } from "@/components/pipecat/console/panel";
import {
  DTMFKeypad,
  type DTMFKeypadMode,
} from "@/components/pipecat/dtmf-keypad";
import { Button } from "@/components/ui/button";
import {
  Drawer,
  DrawerContent,
  DrawerDescription,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";

export interface ConsoleKeypadToggleProps {
  /** How the keypad dispatches presses (default "buffered"). */
  mode?: DTMFKeypadMode;
  /** Overrides merged onto the keypad (wins over defaults). */
  keypadProps?: Partial<React.ComponentProps<typeof DTMFKeypad>>;
  className?: string;
}

/**
 * Header button opening the DTMF keypad — a popover on desktop, a drawer on
 * mobile. One instantiation: the surface switches on the viewport instead
 * of rendering both. Must be rendered inside a PipecatClientProvider.
 */
export function ConsoleKeypadToggle({
  mode = "buffered",
  keypadProps,
  className,
}: ConsoleKeypadToggleProps) {
  const isDesktop = useMinWidth(640);

  const trigger = (
    <Button
      variant="ghost"
      size="icon-sm"
      aria-label="Open keypad"
      className={className}
      data-slot="console-keypad-toggle"
    >
      <PhoneIcon />
    </Button>
  );

  if (isDesktop) {
    return (
      <Popover>
        <PopoverTrigger render={trigger} />
        <PopoverContent align="end" side="bottom" className="w-auto">
          <DTMFKeypad mode={mode} {...keypadProps} />
        </PopoverContent>
      </Popover>
    );
  }

  return (
    <Drawer>
      <DrawerTrigger render={trigger} />
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Keypad</DrawerTitle>
          <DrawerDescription className="sr-only">
            Send DTMF tones to the connected bot.
          </DrawerDescription>
        </DrawerHeader>
        <div className="mx-auto w-full max-w-xs p-4 pt-0">
          <DTMFKeypad mode={mode} {...keypadProps} />
        </div>
      </DrawerContent>
    </Drawer>
  );
}
```

### `src/blocks/console/logo.tsx`

```tsx
"use client";

const ASPECT_RATIO = 45 / 26;

export interface PipecatLogoProps {
  /** Accessible label for the logo (default "Pipecat"). */
  alt?: string;
  /** Height in pixels; width derives from the aspect ratio (default 24). */
  height?: number;
  className?: string;
}

/**
 * Inline Pipecat wordmark, drawn in currentColor so it follows the
 * surrounding text color.
 */
export function PipecatLogo({
  alt = "Pipecat",
  height = 24,
  className,
}: PipecatLogoProps) {
  const width = Math.round(height * ASPECT_RATIO);
  return (
    <svg
      data-slot="pipecat-logo"
      width={width}
      height={height}
      viewBox="0 0 45 26"
      xmlns="http://www.w3.org/2000/svg"
      className={className}
      role="img"
      aria-label={alt}
    >
      <path
        d="M6.19825 0.104287C6.83144 -0.13411 7.5461 0.044779 7.99232 0.553371L13.8037 7.17714H31.1545L36.9659 0.553371C37.4121 0.044779 38.1268 -0.13411 38.76 0.104287C39.3932 0.342685 39.8124 0.948477 39.8124 1.62507V16.25H44.9582V19.5H36.5624V5.94116L33.112 9.87383C32.8035 10.2255 32.3584 10.4271 31.8905 10.4271H13.0677C12.5998 10.4271 12.1547 10.2255 11.8462 9.87383L8.39581 5.94116V19.5H0V16.25H5.14582V1.62507C5.14582 0.948477 5.56505 0.342685 6.19825 0.104287Z"
        fill="currentColor"
      />
      <path d="M36.5624 22.75H44.9582V26H36.5624V22.75Z" fill="currentColor" />
      <path d="M0 22.75H8.39581V26H0V22.75Z" fill="currentColor" />
      <path
        d="M17.3333 17.3334C17.3333 18.53 16.3632 19.5 15.1666 19.5C13.97 19.5 13 18.53 13 17.3334C13 16.1367 13.97 15.1667 15.1666 15.1667C16.3632 15.1667 17.3333 16.1367 17.3333 17.3334Z"
        fill="currentColor"
      />
      <path
        d="M31.9582 17.3334C31.9582 18.53 30.9882 19.5 29.7916 19.5C28.595 19.5 27.6249 18.53 27.6249 17.3334C27.6249 16.1367 28.595 15.1667 29.7916 15.1667C30.9882 15.1667 31.9582 16.1367 31.9582 17.3334Z"
        fill="currentColor"
      />
    </svg>
  );
}
```

### `src/blocks/console/panel.tsx`

```tsx
"use client";

import * as React from "react";

import {
  Card,
  CardAction,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";

/**
 * Console-shared viewport check: true at or above the given width. Single
 * source for the desktop/mobile layout switch so panels are instantiated
 * once, in whichever tree renders.
 */
export function useMinWidth(px: number): boolean {
  const subscribe = React.useCallback(
    (onChange: () => void) => {
      const query = window.matchMedia(`(min-width: ${px}px)`);
      query.addEventListener("change", onChange);
      return () => query.removeEventListener("change", onChange);
    },
    [px],
  );
  return React.useSyncExternalStore(
    subscribe,
    () => window.matchMedia(`(min-width: ${px}px)`).matches,
    () => false,
  );
}

/**
 * Panel chrome for the console, styled over the stock Card so consumer Card
 * theming (tokens, radius, ring) flows straight through. Density is
 * container-driven: compact padding by default, relaxing at wider panel
 * widths — the same markup serves expanded panes, collapsed strips, and
 * mobile tabs.
 */
export function ConsolePanel({
  className,
  ...props
}: React.ComponentProps<typeof Card>) {
  return (
    <Card
      size="sm"
      className={cn(
        "@container/panel h-full min-h-0 gap-(--card-spacing) overflow-hidden",
        "[--card-spacing:--spacing(2)] @xs/panel:[--card-spacing:--spacing(3)] @md/panel:[--card-spacing:--spacing(4)]",
        className,
      )}
      data-slot="console-panel"
      {...props}
    />
  );
}

export function ConsolePanelHeader({
  className,
  ...props
}: React.ComponentProps<typeof CardHeader>) {
  return (
    <CardHeader
      className={cn("items-center border-b", className)}
      data-slot="console-panel-header"
      {...props}
    />
  );
}

export function ConsolePanelTitle({
  className,
  ...props
}: React.ComponentProps<typeof CardTitle>) {
  return (
    <CardTitle
      className={cn(
        "text-muted-foreground truncate text-xs font-medium tracking-wide uppercase",
        className,
      )}
      data-slot="console-panel-title"
      {...props}
    />
  );
}

export function ConsolePanelActions({
  className,
  ...props
}: React.ComponentProps<typeof CardAction>) {
  return (
    <CardAction
      className={cn("flex items-center gap-1 self-center", className)}
      data-slot="console-panel-actions"
      {...props}
    />
  );
}

export function ConsolePanelContent({
  className,
  ...props
}: React.ComponentProps<typeof CardContent>) {
  return (
    <CardContent
      className={cn("min-h-0 flex-1 overflow-auto", className)}
      data-slot="console-panel-content"
      {...props}
    />
  );
}
```
