pipecat
blockconsolesource: ok · hit
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.
npx shadcn add "https://ui.pipecat.ai/r/console.json"free
Where it goes
stats
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.0react-resizable-panels@^4.12.3
Registry dependencies
alertbuttoncarddrawerinputpopoverresizableselectspinnertabstooltip@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
Files10 · source cached 10/8/2026, 4:05:09 PM
src/blocks/console/console.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
"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
"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
"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
"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
"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
"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
"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
"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
"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}
/>
);
}