{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"agent-control-bar","title":"Agent Control Bar","description":"A control bar for managing media tracks (microphone, camera, screen share), disconnecting the agent session, and opening the chat input, with appropriate icons and loading states.","dependencies":["livekit-client@^2.0.0","@livekit/components-react@^2.0.0","class-variance-authority","motion","lucide-react"],"registryDependencies":["utils","button","toggle","@agents-ui/agent-track-control","@agents-ui/agent-track-toggle","@agents-ui/agent-disconnect-button","@agents-ui/agent-audio-visualizer-bar"],"files":[{"path":"components/agents-ui/agent-control-bar.tsx","content":"'use client';\n\nimport { useEffect, useRef, useState, type ComponentProps } from 'react';\nimport { useChat } from '@livekit/components-react';\nimport { Track } from 'livekit-client';\nimport { Loader, MessageSquareTextIcon, SendHorizontal } from 'lucide-react';\nimport { motion, type MotionProps } from 'motion/react';\n\nimport { cn } from '@/lib/utils';\nimport { AgentDisconnectButton } from '@/components/agents-ui/agent-disconnect-button';\nimport { AgentTrackControl } from '@/components/agents-ui/agent-track-control';\nimport {\n  AgentTrackToggle,\n  agentTrackToggleVariants,\n} from '@/components/agents-ui/agent-track-toggle';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport {\n  useInputControls,\n  usePublishPermissions,\n  type UseInputControlsProps,\n} from '@/hooks/agents-ui/use-agent-control-bar';\n\nconst LK_TOGGLE_VARIANT_1 = [\n  'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10',\n  'data-[state=off]:[&_~_button]:bg-accent data-[state=off]:[&_~_button]:hover:bg-foreground/10',\n  'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12',\n  'data-[state=off]:[&_~_button]:border-border data-[state=off]:[&_~_button]:hover:border-foreground/12',\n  'data-[state=off]:text-destructive data-[state=off]:hover:text-destructive data-[state=off]:focus:text-destructive',\n  'data-[state=off]:focus-visible:ring-foreground/12 data-[state=off]:focus-visible:border-ring',\n  'dark:data-[state=off]:[&_~_button]:bg-accent dark:data-[state=off]:[&_~_button]:hover:bg-foreground/10',\n];\n\nconst LK_TOGGLE_VARIANT_2 = [\n  'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10',\n  'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12',\n  'data-[state=off]:focus-visible:border-ring data-[state=off]:focus-visible:ring-foreground/12',\n  'data-[state=off]:text-foreground data-[state=off]:hover:text-foreground data-[state=off]:focus:text-foreground',\n  'data-[state=on]:bg-blue-500/20 data-[state=on]:hover:bg-blue-500/30',\n  'data-[state=on]:border-blue-700/10 data-[state=on]:text-blue-700 data-[state=on]:ring-blue-700/30',\n  'data-[state=on]:focus-visible:border-blue-700/50',\n  'dark:data-[state=on]:bg-blue-500/20 dark:data-[state=on]:text-blue-300',\n];\n\nconst MOTION_PROPS: MotionProps = {\n  variants: {\n    hidden: {\n      height: 0,\n      opacity: 0,\n      marginBottom: 0,\n    },\n    visible: {\n      height: 'auto',\n      opacity: 1,\n      marginBottom: 12,\n    },\n  },\n  initial: 'hidden',\n  transition: {\n    duration: 0.3,\n    ease: 'easeOut',\n  },\n};\n\ninterface AgentChatInputProps {\n  chatOpen: boolean;\n  onSend?: (message: string) => void;\n  className?: string;\n}\n\nfunction AgentChatInput({ chatOpen, onSend = async () => {}, className }: AgentChatInputProps) {\n  const inputRef = useRef<HTMLTextAreaElement>(null);\n  const [isSending, setIsSending] = useState(false);\n  const [message, setMessage] = useState<string>('');\n  const isDisabled = isSending || message.trim().length === 0;\n\n  const handleSend = async () => {\n    if (isDisabled) {\n      return;\n    }\n\n    try {\n      setIsSending(true);\n      await onSend(message.trim());\n      setMessage('');\n    } catch (error) {\n      console.error(error);\n    } finally {\n      setIsSending(false);\n    }\n  };\n\n  const handleKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    if (e.key === 'Enter' && !e.shiftKey) {\n      e.preventDefault();\n      handleSend();\n    }\n  };\n\n  const handleButtonClick = async () => {\n    if (isDisabled) return;\n    await handleSend();\n  };\n\n  useEffect(() => {\n    if (chatOpen) return;\n    // when not disabled refocus on input\n    inputRef.current?.focus();\n  }, [chatOpen]);\n\n  return (\n    <div className={cn('mb-3 flex grow items-end gap-2 rounded-md pl-1 text-sm', className)}>\n      <textarea\n        autoFocus\n        ref={inputRef}\n        value={message}\n        disabled={!chatOpen || isSending}\n        placeholder=\"Type something...\"\n        onKeyDown={handleKeyDown}\n        onChange={(e) => setMessage(e.target.value)}\n        className=\"field-sizing-content max-h-16 min-h-8 flex-1 resize-none py-2 [scrollbar-width:thin] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50\"\n      />\n      <Button\n        size=\"icon\"\n        type=\"button\"\n        disabled={isDisabled}\n        variant={isDisabled ? 'secondary' : 'default'}\n        title={isSending ? 'Sending...' : 'Send'}\n        onClick={handleButtonClick}\n        className=\"self-end disabled:cursor-not-allowed\"\n      >\n        {isSending ? <Loader className=\"animate-spin\" /> : <SendHorizontal />}\n      </Button>\n    </div>\n  );\n}\n\n/** Configuration for which controls to display in the AgentControlBar. */\nexport interface AgentControlBarControls {\n  /**\n   * Whether to show the leave/disconnect button.\n   *\n   * @defaultValue true\n   */\n  leave?: boolean;\n  /**\n   * Whether to show the camera toggle control.\n   *\n   * @defaultValue true (if camera publish permission is granted)\n   */\n  camera?: boolean;\n  /**\n   * Whether to show the microphone toggle control.\n   *\n   * @defaultValue true (if microphone publish permission is granted)\n   */\n  microphone?: boolean;\n  /**\n   * Whether to show the screen share toggle control.\n   *\n   * @defaultValue true (if screen share publish permission is granted)\n   */\n  screenShare?: boolean;\n  /**\n   * Whether to show the chat toggle control.\n   *\n   * @defaultValue true (if data publish permission is granted)\n   */\n  chat?: boolean;\n}\n\nexport interface AgentControlBarProps extends UseInputControlsProps {\n  /**\n   * The visual style of the control bar.\n   *\n   * @default 'default'\n   */\n  variant?: 'default' | 'outline' | 'livekit';\n  /**\n   * This takes an object with the following keys: `leave`, `microphone`, `screenShare`, `camera`,\n   * `chat`. Each key maps to a boolean value that determines whether the control is displayed.\n   *\n   * @default\n   * {\n   *   leave: true,\n   *   microphone: true,\n   *   screenShare: true,\n   *   camera: true,\n   *   chat: true,\n   * }\n   */\n  controls?: AgentControlBarControls;\n  /**\n   * Whether to save user choices.\n   *\n   * @default true\n   */\n  saveUserChoices?: boolean;\n  /**\n   * Whether the agent is connected to a session.\n   *\n   * @default false\n   */\n  isConnected?: boolean;\n  /**\n   * Whether the chat input interface is open.\n   *\n   * @default false\n   */\n  isChatOpen?: boolean;\n  /** The callback for when the user disconnects. */\n  onDisconnect?: () => void;\n  /** The callback for when the chat is opened or closed. */\n  onIsChatOpenChange?: (open: boolean) => void;\n  /** The callback for when a device error occurs. */\n  onDeviceError?: (error: { source: Track.Source; error: Error }) => void;\n}\n\n/**\n * A control bar specifically designed for voice assistant interfaces. Provides controls for\n * microphone, camera, screen share, chat, and disconnect. Includes an expandable chat input for\n * text-based interaction with the agent.\n *\n * @example\n *\n * ```tsx\n * <AgentControlBar\n *   variant=\"livekit\"\n *   isConnected={true}\n *   onDisconnect={() => handleDisconnect()}\n *   controls={{\n *     microphone: true,\n *     camera: true,\n *     screenShare: false,\n *     chat: true,\n *     leave: true,\n *   }}\n * />;\n * ```\n *\n * @extends ComponentProps<'div'>\n */\nexport function AgentControlBar({\n  variant = 'default',\n  controls,\n  isChatOpen = false,\n  isConnected = false,\n  saveUserChoices = true,\n  onDisconnect,\n  onDeviceError,\n  onIsChatOpenChange,\n  className,\n  ...props\n}: AgentControlBarProps & ComponentProps<'div'>) {\n  const { send } = useChat();\n  const publishPermissions = usePublishPermissions();\n  const [isChatOpenUncontrolled, setIsChatOpenUncontrolled] = useState(isChatOpen);\n  const {\n    microphoneTrack,\n    cameraToggle,\n    microphoneToggle,\n    screenShareToggle,\n    handleAudioDeviceChange,\n    handleVideoDeviceChange,\n    handleMicrophoneDeviceSelectError,\n    handleCameraDeviceSelectError,\n  } = useInputControls({ onDeviceError, saveUserChoices });\n\n  const handleSendMessage = async (message: string) => {\n    await send(message);\n  };\n\n  const visibleControls = {\n    leave: controls?.leave ?? true,\n    microphone: controls?.microphone ?? publishPermissions.microphone,\n    screenShare: controls?.screenShare ?? publishPermissions.screenShare,\n    camera: controls?.camera ?? publishPermissions.camera,\n    chat: controls?.chat ?? publishPermissions.data,\n  };\n\n  const isEmpty = Object.values(visibleControls).every((value) => !value);\n\n  if (isEmpty) {\n    console.warn('AgentControlBar: `visibleControls` contains only false values.');\n    return null;\n  }\n\n  return (\n    <div\n      aria-label=\"Voice assistant controls\"\n      className={cn(\n        'bg-background border-input/50 dark:border-muted flex flex-col border p-3 drop-shadow-md/3',\n        variant === 'livekit' ? 'rounded-[31px]' : 'rounded-lg',\n        className,\n      )}\n      {...props}\n    >\n      <motion.div\n        {...MOTION_PROPS}\n        inert={!(isChatOpen || isChatOpenUncontrolled)}\n        animate={isChatOpen || isChatOpenUncontrolled ? 'visible' : 'hidden'}\n        className=\"border-input/50 flex w-full items-start overflow-hidden border-b\"\n      >\n        <AgentChatInput\n          chatOpen={isChatOpen || isChatOpenUncontrolled}\n          onSend={handleSendMessage}\n          className={cn(variant === 'livekit' && '[&_button]:rounded-full')}\n        />\n      </motion.div>\n\n      <div className=\"flex gap-1\">\n        <div className=\"flex grow gap-1\">\n          {/* Toggle Microphone */}\n          {visibleControls.microphone && (\n            <AgentTrackControl\n              variant={variant === 'outline' ? 'outline' : 'default'}\n              kind=\"audioinput\"\n              aria-label=\"Toggle microphone\"\n              source={Track.Source.Microphone}\n              pressed={microphoneToggle.enabled}\n              disabled={microphoneToggle.pending}\n              audioTrack={microphoneTrack}\n              onPressedChange={microphoneToggle.toggle}\n              onActiveDeviceChange={handleAudioDeviceChange}\n              onMediaDeviceError={handleMicrophoneDeviceSelectError}\n              className={cn(\n                variant === 'livekit' && [\n                  LK_TOGGLE_VARIANT_1,\n                  'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',\n                ],\n              )}\n            />\n          )}\n\n          {/* Toggle Camera */}\n          {visibleControls.camera && (\n            <AgentTrackControl\n              variant={variant === 'outline' ? 'outline' : 'default'}\n              kind=\"videoinput\"\n              aria-label=\"Toggle camera\"\n              source={Track.Source.Camera}\n              pressed={cameraToggle.enabled}\n              pending={cameraToggle.pending}\n              disabled={cameraToggle.pending}\n              onPressedChange={cameraToggle.toggle}\n              onMediaDeviceError={handleCameraDeviceSelectError}\n              onActiveDeviceChange={handleVideoDeviceChange}\n              className={cn(\n                variant === 'livekit' && [\n                  LK_TOGGLE_VARIANT_1,\n                  'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',\n                ],\n              )}\n            />\n          )}\n\n          {/* Toggle Screen Share */}\n          {visibleControls.screenShare && (\n            <AgentTrackToggle\n              variant={variant === 'outline' ? 'outline' : 'default'}\n              aria-label=\"Toggle screen share\"\n              source={Track.Source.ScreenShare}\n              pressed={screenShareToggle.enabled}\n              disabled={screenShareToggle.pending}\n              onPressedChange={screenShareToggle.toggle}\n              className={cn(variant === 'livekit' && [LK_TOGGLE_VARIANT_2, 'rounded-full'])}\n            />\n          )}\n\n          {/* Toggle Transcript */}\n          {visibleControls.chat && (\n            <Toggle\n              variant={variant === 'outline' ? 'outline' : 'default'}\n              pressed={isChatOpen || isChatOpenUncontrolled}\n              aria-label=\"Toggle transcript\"\n              onPressedChange={(state) => {\n                if (!onIsChatOpenChange) setIsChatOpenUncontrolled(state);\n                else onIsChatOpenChange(state);\n              }}\n              className={agentTrackToggleVariants({\n                variant: variant === 'outline' ? 'outline' : 'default',\n                className: cn(variant === 'livekit' && [LK_TOGGLE_VARIANT_2, 'rounded-full']),\n              })}\n            >\n              <MessageSquareTextIcon />\n            </Toggle>\n          )}\n        </div>\n\n        {/* Disconnect */}\n        {visibleControls.leave && (\n          <AgentDisconnectButton\n            onClick={onDisconnect}\n            disabled={!isConnected}\n            className={cn(\n              variant === 'livekit' &&\n                'bg-destructive/10 dark:bg-destructive/10 text-destructive hover:bg-destructive/20 dark:hover:bg-destructive/20 focus:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/4 rounded-full font-mono text-xs font-bold tracking-wider',\n            )}\n          >\n            <span className=\"hidden md:inline uppercase\">End call</span>\n            <span className=\"inline md:hidden uppercase\">End</span>\n          </AgentDisconnectButton>\n        )}\n      </div>\n    </div>\n  );\n}\n","type":"registry:component"},{"path":"hooks/agents-ui/use-agent-control-bar.ts","content":"import { useCallback } from 'react';\nimport { Track } from 'livekit-client';\nimport {\n  type TrackReference,\n  useTrackToggle,\n  usePersistentUserChoices,\n  useLocalParticipantPermissions,\n  useSessionContext,\n} from '@livekit/components-react';\n\nconst trackSourceToProtocol = (source: Track.Source) => {\n  // NOTE: this mapping avoids importing the protocol package as that leads to a significant bundle size increase\n  switch (source) {\n    case Track.Source.Camera:\n      return 1;\n    case Track.Source.Microphone:\n      return 2;\n    case Track.Source.ScreenShare:\n      return 3;\n    default:\n      return 0;\n  }\n};\n\nexport interface PublishPermissions {\n  camera: boolean;\n  microphone: boolean;\n  screenShare: boolean;\n  data: boolean;\n}\n\nexport function usePublishPermissions(): PublishPermissions {\n  const localPermissions = useLocalParticipantPermissions();\n\n  const canPublishSource = (source: Track.Source) => {\n    return (\n      !!localPermissions?.canPublish &&\n      (localPermissions.canPublishSources.length === 0 ||\n        localPermissions.canPublishSources.includes(trackSourceToProtocol(source)))\n    );\n  };\n\n  return {\n    camera: canPublishSource(Track.Source.Camera),\n    microphone: canPublishSource(Track.Source.Microphone),\n    screenShare: canPublishSource(Track.Source.ScreenShare),\n    data: localPermissions?.canPublishData ?? false,\n  };\n}\n\nexport interface UseInputControlsProps {\n  saveUserChoices?: boolean;\n  onDisconnect?: () => void;\n  onDeviceError?: (error: { source: Track.Source; error: Error }) => void;\n}\n\nexport interface UseInputControlsReturn {\n  microphoneTrack?: TrackReference;\n  microphoneToggle: ReturnType<typeof useTrackToggle<Track.Source.Microphone>>;\n  cameraToggle: ReturnType<typeof useTrackToggle<Track.Source.Camera>>;\n  screenShareToggle: ReturnType<typeof useTrackToggle<Track.Source.ScreenShare>>;\n  handleAudioDeviceChange: (deviceId: string) => void;\n  handleVideoDeviceChange: (deviceId: string) => void;\n  handleMicrophoneDeviceSelectError: (error: Error) => void;\n  handleCameraDeviceSelectError: (error: Error) => void;\n}\n\nexport function useInputControls({\n  saveUserChoices = true,\n  onDeviceError,\n}: UseInputControlsProps = {}): UseInputControlsReturn {\n  const {\n    local: { microphoneTrack },\n  } = useSessionContext();\n\n  const microphoneToggle = useTrackToggle({\n    source: Track.Source.Microphone,\n    onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Microphone, error }),\n  });\n\n  const cameraToggle = useTrackToggle({\n    source: Track.Source.Camera,\n    onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Camera, error }),\n  });\n\n  const screenShareToggle = useTrackToggle({\n    source: Track.Source.ScreenShare,\n    onDeviceError: (error) => onDeviceError?.({ source: Track.Source.ScreenShare, error }),\n  });\n\n  const {\n    saveAudioInputEnabled,\n    saveVideoInputEnabled,\n    saveAudioInputDeviceId,\n    saveVideoInputDeviceId,\n  } = usePersistentUserChoices({ preventSave: !saveUserChoices });\n\n  const handleAudioDeviceChange = useCallback(\n    (deviceId: string) => {\n      saveAudioInputDeviceId(deviceId ?? 'default');\n    },\n    [saveAudioInputDeviceId],\n  );\n\n  const handleVideoDeviceChange = useCallback(\n    (deviceId: string) => {\n      saveVideoInputDeviceId(deviceId ?? 'default');\n    },\n    [saveVideoInputDeviceId],\n  );\n\n  const handleToggleCamera = useCallback(\n    async (enabled?: boolean) => {\n      if (screenShareToggle.enabled) {\n        screenShareToggle.toggle(false);\n      }\n      await cameraToggle.toggle(enabled);\n      // persist video input enabled preference\n      saveVideoInputEnabled(!cameraToggle.enabled);\n    },\n    [cameraToggle, screenShareToggle, saveVideoInputEnabled],\n  );\n\n  const handleToggleMicrophone = useCallback(\n    async (enabled?: boolean) => {\n      await microphoneToggle.toggle(enabled);\n      // persist audio input enabled preference\n      saveAudioInputEnabled(!microphoneToggle.enabled);\n    },\n    [microphoneToggle, saveAudioInputEnabled],\n  );\n\n  const handleToggleScreenShare = useCallback(\n    async (enabled?: boolean) => {\n      if (cameraToggle.enabled) {\n        cameraToggle.toggle(false);\n      }\n      await screenShareToggle.toggle(enabled);\n    },\n    [cameraToggle, screenShareToggle],\n  );\n  const handleMicrophoneDeviceSelectError = useCallback(\n    (error: Error) => onDeviceError?.({ source: Track.Source.Microphone, error }),\n    [onDeviceError],\n  );\n\n  const handleCameraDeviceSelectError = useCallback(\n    (error: Error) => onDeviceError?.({ source: Track.Source.Camera, error }),\n    [onDeviceError],\n  );\n\n  return {\n    microphoneTrack,\n    cameraToggle: {\n      ...cameraToggle,\n      toggle: handleToggleCamera,\n    },\n    microphoneToggle: {\n      ...microphoneToggle,\n      toggle: handleToggleMicrophone,\n    },\n    screenShareToggle: {\n      ...screenShareToggle,\n      toggle: handleToggleScreenShare,\n    },\n    handleAudioDeviceChange,\n    handleVideoDeviceChange,\n    handleMicrophoneDeviceSelectError,\n    handleCameraDeviceSelectError,\n  };\n}\n","type":"registry:hook"}],"type":"registry:component"}