shadimage
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 · medium
animationmotion
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

Browse all components in this library →