agents-ui
blockagent-popup-01source: ok · refreshed
Agent Popup
A floating chat-bubble trigger that expands into a compact agent session popup, suitable for embedding on any page.
npx shadcn add "https://livekit.com/ui/r/agent-popup-01.json"free
Where it goes
data-tablechat
Interaction
modal
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
iconslucide-react
Registry dependencies
utilsbutton@agents-ui/agent-session-provider@agents-ui/start-audio-button@agents-ui/agent-session-view-01
Files4 · source cached 10/8/2026, 10:10:58 AM
components/agents-ui/blocks/agent-popup-01/components/agent-popup-block.tsx
'use client';
import { useCallback, useEffect, useRef, useState, type ComponentProps } from 'react';
import { useAgent, useSession } from '@livekit/components-react';
import { type TokenSourceConfigurable, type TokenSourceFixed } from 'livekit-client';
import { type AgentControlBarControls } from '@/components/agents-ui/agent-control-bar';
import { AgentSessionProvider } from '@/components/agents-ui/agent-session-provider';
import type { AudioVisualizerConfig } from '@/components/agents-ui/blocks/agent-session-view-01/components/agent-session-block';
import { PopupView } from './popup-view';
import { Trigger } from './trigger';
const DEFAULT_CONTROLS: AgentControlBarControls = {
leave: false,
microphone: true,
chat: false,
camera: false,
screenShare: false,
};
export interface AgentPopupError {
title: string;
description: string;
}
function TriggerProvider({ ...props }: ComponentProps<typeof Trigger>) {
const { isConnected } = useAgent();
return <Trigger {...props} isConnected={isConnected} />;
}
export interface AgentPopupProps {
/** Logo shown in the trigger bubble in place of the default agent icon. */
logo?: string;
/** @default 'Agent' */
agentName?: string;
/** Where to fetch a LiveKit session token from. See `useSession`'s `tokenSource` argument. */
tokenSource: TokenSourceConfigurable | TokenSourceFixed;
/**
* Theme mode forwarded to the aura visualizer (`audioVisualizer.type === 'aura'`) so the
* shader's blend mode adapts to the theme mode. Ignored by other visualizer types.
*/
themeMode?: 'dark' | 'light';
/**
* Message shown above the controls before the first chat message is sent.
*
* @default 'Agent is listening, ask it a question'
*/
preConnectMessage?: string;
/**
* An object with the following keys: leave, microphone, screenShare, camera, chat. Each key maps
* to a boolean value that determines whether the control is displayed.
*
* @default { leave: false, microphone: true, chat: false, camera: false, screenShare: false }
*/
controls?: AgentControlBarControls;
/** Brand color used for the trigger bubble's idle ring/disc and fallback icon contrast. */
triggerColor?: `#${string}`;
/**
* Shows a pre-connect buffer state with a shimmer message before messages appear.
*
* @default true
*/
isPreConnectBufferEnabled?: boolean;
/**
* Configures the visualizer style rendered in the main tile area.
*
* @default { type: 'bar', size: 'lg' }
*/
audioVisualizer?: AudioVisualizerConfig;
}
export function AgentPopup_01({
logo,
themeMode,
tokenSource,
triggerColor,
agentName = 'Agent',
controls = DEFAULT_CONTROLS,
isPreConnectBufferEnabled,
preConnectMessage,
audioVisualizer = {
type: 'bar',
size: 'lg',
},
}: AgentPopupProps) {
const session = useSession(tokenSource);
const [popupOpen, setPopupOpen] = useState(false);
const [error, setError] = useState<AgentPopupError>();
// Drive the session lifecycle off the popup-open state. Opening the popup
// connects; closing it (or unmounting) tears the room down so we don't
// leave a mic-hot connection while the bubble sits idle.
//
// useSession returns a fresh object on every connectionState change (the
// discriminated union swaps shape), so we can't depend on `session`
// directly - that would tear down + restart the room every time it
// transitioned through connecting -> connected. We pin the start/end
// calls via a ref instead, and only react to popupOpen.
const sessionRef = useRef(session);
sessionRef.current = session;
useEffect(() => {
if (!popupOpen) return;
let cancelled = false;
sessionRef.current.start().catch((cause) => {
if (cancelled) return;
console.error('agent-popup-01: session.start failed:', cause);
setError({
title: 'Could not connect to the agent',
description: cause instanceof Error ? cause.message : String(cause),
});
});
return () => {
cancelled = true;
void sessionRef.current.end();
};
}, [popupOpen]);
const handleToggle = useCallback(() => {
setPopupOpen((open) => {
const next = !open;
if (!next) setError(undefined);
return next;
});
}, []);
const finalControls = {
...DEFAULT_CONTROLS,
...controls,
};
return (
<AgentSessionProvider session={session}>
<TriggerProvider
logo={logo}
error={error}
color={triggerColor}
agentName={agentName}
isPressed={popupOpen}
onToggle={handleToggle}
className="fixed right-4 bottom-4 z-50"
/>
{popupOpen && (
<PopupView
logo={logo}
error={error}
agentName={agentName}
themeMode={themeMode}
isPreConnectBufferEnabled={isPreConnectBufferEnabled}
preConnectMessage={preConnectMessage}
audioVisualizer={audioVisualizer}
controls={finalControls}
onDisconnect={handleToggle}
/>
)}
</AgentSessionProvider>
);
}
components/agents-ui/blocks/agent-popup-01/components/trigger.tsx
'use client';
import { useEffect, useState, type ComponentProps } from 'react';
import { BotIcon, PhoneOffIcon, XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import type { AgentPopupError } from './agent-popup-block';
export interface TriggerProps extends ComponentProps<'button'> {
/** Logo image shown in place of the default bot icon. */
logo?: string;
/** Brand color used for the idle ring/disc and, via contrast, the fallback icon color. */
color?: string;
/** Used to build the default `aria-label` (e.g. "Rex agent") and image `alt` text. */
agentName?: string;
/** Whether the popup is currently open. Drives the connecting/active/error visual states. */
isPressed?: boolean;
/** Whether the agent session is connected. Only relevant while `isPressed` is true. */
isConnected?: boolean;
/** Session error, if any. Only relevant while `isPressed` is true. */
error?: AgentPopupError;
/** Called when the trigger is clicked. */
onToggle: () => void;
}
export function Trigger({
logo,
color,
error,
agentName,
isPressed = false,
isConnected = false,
onToggle,
className,
...props
}: TriggerProps) {
const altText = agentName ? `${agentName} agent` : 'Open assistant';
const isError = isPressed && error !== undefined;
const isActive = isPressed && error === undefined && isConnected;
const isConnecting = isPressed && !isError && !isActive;
const isDestructive = isError || isActive;
const [logoFailed, setLogoFailed] = useState(false);
useEffect(() => {
setLogoFailed(false);
}, [logo]);
const showLogo = Boolean(logo) && !logoFailed;
return (
<Button
type="button"
size="lg"
variant="ghost"
onClick={onToggle}
aria-label={isPressed ? 'Close assistant' : altText}
aria-expanded={isPressed}
className={cn(
'relative m-0 block size-12 rounded-full p-0.5 drop-shadow-sm transition-transform duration-200 hover:scale-105 focus-visible:scale-105',
className,
)}
{...props}
>
<div
className={cn(
'absolute inset-0 rounded-full bg-current transition-colors',
isConnecting && 'animate-spin',
isConnecting &&
'bg-current/30 bg-conic from-transparent from-30% via-current via-50% to-transparent to-70%',
isDestructive && 'bg-transparent',
)}
style={{ color }}
/>
<div
className={cn(
'absolute z-10 grid place-items-center rounded-full transition-colors inset-0',
isConnecting && 'bg-background text-background inset-0.5',
isDestructive && 'bg-destructive text-white',
)}
>
{isConnecting ? null : isError ? (
// The spinning ring is the connecting signal; skip a centred glyph
// so it reads as a clean spinner rather than a flashing X.
<XIcon className="size-5" aria-hidden="true" />
) : isActive ? (
// Crossed-out phone on the solid destructive disc signals
// "click to end the call".
<PhoneOffIcon className="size-5" aria-hidden="true" />
) : showLogo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logo}
alt={altText}
className="size-6 rounded-sm object-contain"
referrerPolicy="no-referrer"
onError={() => setLogoFailed(true)}
/>
) : (
// Pick black vs white based on the user-set brand color so the
// icon stays readable across a bright yellow, deep navy, etc.
<BotIcon
className="text-background size-6"
aria-hidden="true"
style={
color
? {
color: `contrast-color(${color})`,
}
: undefined
}
/>
)}
</div>
</Button>
);
}
components/agents-ui/blocks/agent-popup-01/components/popup-view.tsx
'use client';
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { StartAudioButton } from '@/components/agents-ui/start-audio-button';
import { type AgentControlBarControls } from '@/components/agents-ui/agent-control-bar';
import { AgentSessionView_01 } from '@/components/agents-ui/blocks/agent-session-view-01/components/agent-session-block';
import type { AudioVisualizerConfig } from '@/components/agents-ui/blocks/agent-session-view-01/components/agent-session-block';
import { cn } from '@/lib/utils';
import type { AgentPopupError } from './agent-popup-block';
interface ErrorOverlayProps {
logo?: string;
agentName?: string;
error?: AgentPopupError;
}
function ErrorOverlay({ logo, agentName, error }: ErrorOverlayProps) {
const [logoFailed, setLogoFailed] = useState(false);
useEffect(() => {
setLogoFailed(false);
}, [logo]);
const showLogo = Boolean(logo) && !logoFailed;
return error ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
data-testid="error-overlay"
// @ts-expect-error React's types lag the platform on `inert`.
inert={error === null ? '' : undefined}
className={cn(
'bg-background absolute inset-0 z-50 flex h-full w-full flex-col items-center justify-center gap-5 px-6 text-center transition-opacity',
)}
>
{showLogo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logo}
alt={`${agentName ?? 'Agent'} logo`}
className="size-6 object-contain"
referrerPolicy="no-referrer"
onError={() => setLogoFailed(true)}
/>
) : null}
<span className="text-foreground leading-tight font-medium text-pretty">{error?.title}</span>
<span className="text-muted-foreground text-sm text-balance">{error?.description}</span>
</motion.div>
) : null;
}
export interface PopupViewProps {
agentName?: string;
logo?: string;
error?: AgentPopupError;
controls: AgentControlBarControls;
themeMode?: 'dark' | 'light';
preConnectMessage?: string;
isPreConnectBufferEnabled?: boolean;
audioVisualizer?: AudioVisualizerConfig;
onDisconnect?: () => void;
}
export function PopupView({
agentName,
logo,
error,
controls,
themeMode,
isPreConnectBufferEnabled,
preConnectMessage,
audioVisualizer,
onDisconnect,
}: PopupViewProps) {
return (
<motion.div
initial={{ opacity: 0, translateY: 8 }}
animate={{ opacity: 1, translateY: 0 }}
exit={{ opacity: 0, translateY: 8 }}
transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
className="fixed right-4 bottom-20 left-4 z-50 h-[480px] md:left-auto md:w-[360px]"
>
<div className="relative flex h-full w-full flex-col overflow-hidden rounded-[40px] border drop-shadow-md">
<AnimatePresence>
<ErrorOverlay logo={logo} agentName={agentName} error={error} />
</AnimatePresence>
<StartAudioButton label="Enable Audio" className="mx-4 mt-2 rounded-full" />
<AgentSessionView_01
controls={controls}
themeMode={themeMode}
isPreConnectBufferEnabled={isPreConnectBufferEnabled}
preConnectMessage={preConnectMessage}
audioVisualizer={audioVisualizer}
onDisconnect={onDisconnect}
/>
</div>
</motion.div>
);
}
components/agents-ui/blocks/agent-popup-01/index.ts
export * from './components/agent-popup-block';
export * from './components/trigger';
More from agents-ui
Agent Audio Visualizer AuracomponentAgent Audio Visualizer BarcomponentAgent Audio Visualizer GridcomponentAgent Audio Visualizer RadialcomponentAgent Audio Visualizer WavecomponentAgent Chat IndicatorcomponentAgent Chat TranscriptcomponentAgent Control Barcomponent
Browse all components in this library →