From 898182d018512e9cd51542695b4ee18984558e59 Mon Sep 17 00:00:00 2001 From: Thomas Yuill Date: Wed, 11 Feb 2026 21:15:05 -0500 Subject: [PATCH 1/9] feat(shadcn): agent session view block --- .../agents-ui/AgentSessionView.stories.tsx | 20 + .../agents-ui/agent-session-view.tsx | 647 ++++++++++++++++++ packages/shadcn/index.ts | 1 + 3 files changed, 668 insertions(+) create mode 100644 docs/storybook/stories/agents-ui/AgentSessionView.stories.tsx create mode 100644 packages/shadcn/components/agents-ui/agent-session-view.tsx diff --git a/docs/storybook/stories/agents-ui/AgentSessionView.stories.tsx b/docs/storybook/stories/agents-ui/AgentSessionView.stories.tsx new file mode 100644 index 000000000..d97b4e0c0 --- /dev/null +++ b/docs/storybook/stories/agents-ui/AgentSessionView.stories.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { StoryObj } from '@storybook/react-vite'; +import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider'; +import { AgentSessionView, AgentSessionViewProps } from '@agents-ui'; + +export default { + component: AgentSessionView, + decorators: [AgentSessionProvider], + render: (args: AgentSessionViewProps) => , + args: {}, + argTypes: {}, + parameters: { + layout: 'centered', + actions: { handles: [] }, + }, +}; + +export const Default: StoryObj = { + args: {}, +}; diff --git a/packages/shadcn/components/agents-ui/agent-session-view.tsx b/packages/shadcn/components/agents-ui/agent-session-view.tsx new file mode 100644 index 000000000..f2b4b6043 --- /dev/null +++ b/packages/shadcn/components/agents-ui/agent-session-view.tsx @@ -0,0 +1,647 @@ +'use client'; + +import React, { useEffect, useRef, useState, useMemo } from 'react'; +import { AnimatePresence, motion, type MotionProps, type HTMLMotionProps } from 'motion/react'; +import { useSessionContext, useSessionMessages } from '@livekit/components-react'; +import { + AgentControlBar, + type AgentControlBarControls, +} from '@/components/agents-ui/agent-control-bar'; +import { cn } from '@/lib/utils'; +import { Shimmer } from '@/components/ai-elements/shimmer'; + +import { type ReceivedMessage, useAgent } from '@livekit/components-react'; +import { AgentChatTranscript } from '@/components/agents-ui/agent-chat-transcript'; + +import { Track } from 'livekit-client'; +import { + type TrackReference, + VideoTrack, + useLocalParticipant, + useTracks, + useVoiceAssistant, +} from '@livekit/components-react'; + +import { AgentAudioVisualizerAura } from '@/components/agents-ui/agent-audio-visualizer-aura'; +import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar'; +import { AgentAudioVisualizerGrid } from '@/components/agents-ui/agent-audio-visualizer-grid'; +import { AgentAudioVisualizerRadial } from '@/components/agents-ui/agent-audio-visualizer-radial'; +import { AgentAudioVisualizerWave } from '@/components/agents-ui/agent-audio-visualizer-wave'; + +const MotionMessage = motion.create(Shimmer); +const MotionAgentAudioVisualizerAura = motion.create(AgentAudioVisualizerAura); +const MotionAgentAudioVisualizerBar = motion.create(AgentAudioVisualizerBar); +const MotionAgentAudioVisualizerGrid = motion.create(AgentAudioVisualizerGrid); +const MotionAgentAudioVisualizerRadial = motion.create(AgentAudioVisualizerRadial); +const MotionAgentAudioVisualizerWave = motion.create(AgentAudioVisualizerWave); + +const BOTTOM_VIEW_MOTION_PROPS = { + variants: { + visible: { + opacity: 1, + translateY: '0%', + }, + hidden: { + opacity: 0, + translateY: '100%', + }, + }, + initial: 'hidden', + animate: 'visible', + exit: 'hidden', + transition: { + duration: 0.3, + delay: 0.5, + ease: 'easeOut', + }, +}; + +const SHIMMER_MOTION_PROPS = { + variants: { + visible: { + opacity: 1, + transition: { + ease: 'easeIn', + duration: 0.5, + delay: 0.8, + }, + }, + hidden: { + opacity: 0, + transition: { + ease: 'easeIn', + duration: 0.5, + delay: 0, + }, + }, + }, + initial: 'hidden', + animate: 'visible', + exit: 'hidden', +}; + +interface FadeProps { + top?: boolean; + bottom?: boolean; + className?: string; +} + +export function Fade({ top = false, bottom = false, className }: FadeProps) { + return ( +
+ ); +} + +export interface AgentSessionViewProps { + supportsChatInput?: boolean; + supportsVideoInput?: boolean; + supportsScreenShare?: boolean; + isPreConnectBufferEnabled?: boolean; + + audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'; + audioVisualizerColor?: `#${string}`; + audioVisualizerBarCount?: number; + audioVisualizerGridRowCount?: number; + audioVisualizerGridColumnCount?: number; + audioVisualizerRadialBarCount?: number; + audioVisualizerRadialRadius?: number; + audioVisualizerAuraColorShift?: number; + audioVisualizerWaveLineWidth?: number; +} + +export function AgentSessionView({ + supportsChatInput = true, + supportsVideoInput = true, + supportsScreenShare = true, + isPreConnectBufferEnabled = true, + audioVisualizerType, + audioVisualizerColor, + audioVisualizerBarCount, + audioVisualizerGridRowCount, + audioVisualizerGridColumnCount, + audioVisualizerRadialBarCount, + audioVisualizerRadialRadius, + audioVisualizerAuraColorShift, + audioVisualizerWaveLineWidth, + ...props +}: React.ComponentProps<'section'> & AgentSessionViewProps) { + const session = useSessionContext(); + const { messages } = useSessionMessages(session); + const [chatOpen, setChatOpen] = useState(false); + const scrollAreaRef = useRef(null); + + const controls: AgentControlBarControls = { + leave: true, + microphone: true, + chat: supportsChatInput, + camera: supportsVideoInput, + screenShare: supportsScreenShare, + }; + + useEffect(() => { + const lastMessage = messages.at(-1); + const lastMessageIsLocal = lastMessage?.from?.isLocal === true; + + if (scrollAreaRef.current && lastMessageIsLocal) { + scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight; + } + }, [messages]); + + return ( +
+ + {/* transcript */} +
+ ); +} + +const CHAT_MOTION_PROPS = { + variants: { + hidden: { + opacity: 0, + transition: { + ease: 'easeOut', + duration: 0.3, + }, + }, + visible: { + opacity: 1, + transition: { + delay: 0.2, + ease: 'easeOut', + duration: 0.3, + }, + }, + }, + initial: 'hidden', + animate: 'visible', + exit: 'hidden', +}; + +interface ChatTranscriptProps { + hidden?: boolean; + messages?: ReceivedMessage[]; +} + +export function ChatTranscript({ + hidden = false, + messages = [], + className, + ...props +}: ChatTranscriptProps & Omit, 'ref'>) { + const { state: agentState } = useAgent(); + + return ( +
+ + {!hidden && ( + + + + )} + +
+ ); +} +const ANIMATION_TRANSITION = { + type: 'spring', + stiffness: 675, + damping: 75, + mass: 1, +}; + +const tileViewClassNames = { + // GRID + // 2 Columns x 3 Rows + grid: [ + 'h-full w-full', + 'grid gap-x-2 place-content-center', + 'grid-cols-[1fr_1fr] grid-rows-[90px_1fr_90px]', + ], + // Agent + // chatOpen: true, + // hasSecondTile: true + // layout: Column 1 / Row 1 + // align: x-end y-center + agentChatOpenWithSecondTile: ['col-start-1 row-start-1', 'self-center justify-self-end'], + // Agent + // chatOpen: true, + // hasSecondTile: false + // layout: Column 1 / Row 1 / Column-Span 2 + // align: x-center y-center + agentChatOpenWithoutSecondTile: ['col-start-1 row-start-1', 'col-span-2', 'place-content-center'], + // Agent + // chatOpen: false + // layout: Column 1 / Row 1 / Column-Span 2 / Row-Span 3 + // align: x-center y-center + agentChatClosed: ['col-start-1 row-start-1', 'col-span-2 row-span-3', 'place-content-center'], + // Second tile + // chatOpen: true, + // hasSecondTile: true + // layout: Column 2 / Row 1 + // align: x-start y-center + secondTileChatOpen: ['col-start-2 row-start-1', 'self-center justify-self-start'], + // Second tile + // chatOpen: false, + // hasSecondTile: false + // layout: Column 2 / Row 2 + // align: x-end y-end + secondTileChatClosed: ['col-start-2 row-start-3', 'place-content-end'], +}; + +export function useLocalTrackRef(source: Track.Source) { + const { localParticipant } = useLocalParticipant(); + const publication = localParticipant.getTrackPublication(source); + const trackRef = useMemo( + () => (publication ? { source, participant: localParticipant, publication } : undefined), + [source, publication, localParticipant], + ); + return trackRef; +} + +interface TileLayoutProps { + chatOpen: boolean; + audioVisualizerColor?: `#${string}`; + audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'; + audioVisualizerAuraColorShift?: number; + audioVisualizerWaveLineWidth?: number; + audioVisualizerGridRowCount?: number; + audioVisualizerGridColumnCount?: number; + audioVisualizerRadialBarCount?: number; + audioVisualizerRadialRadius?: number; + audioVisualizerBarCount?: number; +} + +export function TileLayout({ + chatOpen, + audioVisualizerColor, + audioVisualizerType, + audioVisualizerBarCount, + audioVisualizerRadialBarCount, + audioVisualizerRadialRadius, + audioVisualizerGridRowCount, + audioVisualizerGridColumnCount, + audioVisualizerWaveLineWidth, + audioVisualizerAuraColorShift, +}: TileLayoutProps) { + const { videoTrack: agentVideoTrack } = useVoiceAssistant(); + const [screenShareTrack] = useTracks([Track.Source.ScreenShare]); + const cameraTrack: TrackReference | undefined = useLocalTrackRef(Track.Source.Camera); + + const isCameraEnabled = cameraTrack && !cameraTrack.publication.isMuted; + const isScreenShareEnabled = screenShareTrack && !screenShareTrack.publication.isMuted; + const hasSecondTile = isCameraEnabled || isScreenShareEnabled; + + const animationDelay = chatOpen ? 0 : 0.15; + const isAvatar = agentVideoTrack !== undefined; + const videoWidth = agentVideoTrack?.publication.dimensions?.width ?? 0; + const videoHeight = agentVideoTrack?.publication.dimensions?.height ?? 0; + + return ( +
+
+
+ {/* Agent */} +
+ + {!isAvatar && ( + // Audio Agent + + + + )} + + {isAvatar && ( + // Avatar Agent + + + + )} + +
+ +
+ {/* Camera & Screen Share */} + + {((cameraTrack && isCameraEnabled) || (screenShareTrack && isScreenShareEnabled)) && ( + + + + )} + +
+
+
+
+ ); +} +interface AudioVisualizerProps extends MotionProps { + isChatOpen: boolean; + audioVisualizerColor?: `#${string}`; + audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'; + audioVisualizerAuraColorShift?: number; + audioVisualizerWaveLineWidth?: number; + audioVisualizerGridRowCount?: number; + audioVisualizerGridColumnCount?: number; + audioVisualizerRadialBarCount?: number; + audioVisualizerRadialRadius?: number; + audioVisualizerBarCount?: number; + className?: string; +} + +export function AudioVisualizer({ + audioVisualizerColor, + audioVisualizerType = 'bar', + audioVisualizerBarCount = 5, + audioVisualizerRadialRadius = 100, + audioVisualizerRadialBarCount = 25, + audioVisualizerGridRowCount = 15, + audioVisualizerGridColumnCount = 15, + audioVisualizerWaveLineWidth = 3, + audioVisualizerAuraColorShift = 0.3, + isChatOpen, + className, + ...props +}: AudioVisualizerProps) { + const { state, audioTrack } = useVoiceAssistant(); + + switch (audioVisualizerType) { + case 'aura': { + return ( + + ); + } + case 'wave': { + return ( + + + + ); + } + case 'grid': { + const totalCount = audioVisualizerGridRowCount * audioVisualizerGridColumnCount; + + let size: 'icon' | 'sm' | 'md' | 'lg' | 'xl' = 'sm'; + if (totalCount < 100) { + size = 'xl'; + } else if (totalCount < 200) { + size = 'lg'; + } else if (totalCount < 300) { + size = 'md'; + } + + return ( + + ); + } + case 'radial': { + return ( + + + + ); + } + default: { + let size: 'icon' | 'sm' | 'md' | 'lg' | 'xl' = 'icon'; + let sizedClassName = cn('size-[300px] md:size-[450px]', className); + + if (audioVisualizerBarCount <= 10) { + size = 'xl'; + sizedClassName = cn('size-[450px] *:min-h-[64px] *:w-[64px] gap-4', className); + } else if (audioVisualizerBarCount <= 10) { + size = 'lg'; + sizedClassName = cn('size-[450px]', className); + } else if (audioVisualizerBarCount <= 15) { + size = 'md'; + sizedClassName = cn('size-[350px] md:size-[450px]', className); + } else if (audioVisualizerBarCount <= 30) { + size = 'sm'; + sizedClassName = cn('size-[300px] md:size-[450px]', className); + } + + return ( + + + + ); + } + } +} diff --git a/packages/shadcn/index.ts b/packages/shadcn/index.ts index d84f4c832..f06d57150 100644 --- a/packages/shadcn/index.ts +++ b/packages/shadcn/index.ts @@ -11,4 +11,5 @@ export * from './components/agents-ui/agent-audio-visualizer-grid'; export * from './components/agents-ui/agent-audio-visualizer-radial'; export * from './components/agents-ui/agent-audio-visualizer-wave'; export * from './components/agents-ui/agent-audio-visualizer-aura'; +export * from './components/agents-ui/agent-session-view'; export * from './components/agents-ui/react-shader-toy'; From 08673b26cc616e9ebf27874206bdb31518feef59 Mon Sep 17 00:00:00 2001 From: Thomas Yuill Date: Thu, 12 Feb 2026 11:25:23 -0500 Subject: [PATCH 2/9] cleanup --- ...es.tsx => AgentSessionView-01.stories.tsx} | 4 +- .../agents-ui/agent-session-view.tsx | 647 ------------------ .../agent-session-view.tsx | 273 ++++++++ .../audio-visualizer.tsx | 155 +++++ .../agent-session-view-01/tile-view.tsx | 254 +++++++ packages/shadcn/index.ts | 2 +- packages/shadcn/registry.json | 34 + 7 files changed, 720 insertions(+), 649 deletions(-) rename docs/storybook/stories/agents-ui/{AgentSessionView.stories.tsx => AgentSessionView-01.stories.tsx} (91%) delete mode 100644 packages/shadcn/components/agents-ui/agent-session-view.tsx create mode 100644 packages/shadcn/components/agents-ui/blocks/agent-session-view-01/agent-session-view.tsx create mode 100644 packages/shadcn/components/agents-ui/blocks/agent-session-view-01/audio-visualizer.tsx create mode 100644 packages/shadcn/components/agents-ui/blocks/agent-session-view-01/tile-view.tsx diff --git a/docs/storybook/stories/agents-ui/AgentSessionView.stories.tsx b/docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx similarity index 91% rename from docs/storybook/stories/agents-ui/AgentSessionView.stories.tsx rename to docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx index d97b4e0c0..0b3eb6f5a 100644 --- a/docs/storybook/stories/agents-ui/AgentSessionView.stories.tsx +++ b/docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx @@ -7,7 +7,9 @@ export default { component: AgentSessionView, decorators: [AgentSessionProvider], render: (args: AgentSessionViewProps) => , - args: {}, + args: { + className: 'h-screen w-screen', + }, argTypes: {}, parameters: { layout: 'centered', diff --git a/packages/shadcn/components/agents-ui/agent-session-view.tsx b/packages/shadcn/components/agents-ui/agent-session-view.tsx deleted file mode 100644 index f2b4b6043..000000000 --- a/packages/shadcn/components/agents-ui/agent-session-view.tsx +++ /dev/null @@ -1,647 +0,0 @@ -'use client'; - -import React, { useEffect, useRef, useState, useMemo } from 'react'; -import { AnimatePresence, motion, type MotionProps, type HTMLMotionProps } from 'motion/react'; -import { useSessionContext, useSessionMessages } from '@livekit/components-react'; -import { - AgentControlBar, - type AgentControlBarControls, -} from '@/components/agents-ui/agent-control-bar'; -import { cn } from '@/lib/utils'; -import { Shimmer } from '@/components/ai-elements/shimmer'; - -import { type ReceivedMessage, useAgent } from '@livekit/components-react'; -import { AgentChatTranscript } from '@/components/agents-ui/agent-chat-transcript'; - -import { Track } from 'livekit-client'; -import { - type TrackReference, - VideoTrack, - useLocalParticipant, - useTracks, - useVoiceAssistant, -} from '@livekit/components-react'; - -import { AgentAudioVisualizerAura } from '@/components/agents-ui/agent-audio-visualizer-aura'; -import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar'; -import { AgentAudioVisualizerGrid } from '@/components/agents-ui/agent-audio-visualizer-grid'; -import { AgentAudioVisualizerRadial } from '@/components/agents-ui/agent-audio-visualizer-radial'; -import { AgentAudioVisualizerWave } from '@/components/agents-ui/agent-audio-visualizer-wave'; - -const MotionMessage = motion.create(Shimmer); -const MotionAgentAudioVisualizerAura = motion.create(AgentAudioVisualizerAura); -const MotionAgentAudioVisualizerBar = motion.create(AgentAudioVisualizerBar); -const MotionAgentAudioVisualizerGrid = motion.create(AgentAudioVisualizerGrid); -const MotionAgentAudioVisualizerRadial = motion.create(AgentAudioVisualizerRadial); -const MotionAgentAudioVisualizerWave = motion.create(AgentAudioVisualizerWave); - -const BOTTOM_VIEW_MOTION_PROPS = { - variants: { - visible: { - opacity: 1, - translateY: '0%', - }, - hidden: { - opacity: 0, - translateY: '100%', - }, - }, - initial: 'hidden', - animate: 'visible', - exit: 'hidden', - transition: { - duration: 0.3, - delay: 0.5, - ease: 'easeOut', - }, -}; - -const SHIMMER_MOTION_PROPS = { - variants: { - visible: { - opacity: 1, - transition: { - ease: 'easeIn', - duration: 0.5, - delay: 0.8, - }, - }, - hidden: { - opacity: 0, - transition: { - ease: 'easeIn', - duration: 0.5, - delay: 0, - }, - }, - }, - initial: 'hidden', - animate: 'visible', - exit: 'hidden', -}; - -interface FadeProps { - top?: boolean; - bottom?: boolean; - className?: string; -} - -export function Fade({ top = false, bottom = false, className }: FadeProps) { - return ( -
- ); -} - -export interface AgentSessionViewProps { - supportsChatInput?: boolean; - supportsVideoInput?: boolean; - supportsScreenShare?: boolean; - isPreConnectBufferEnabled?: boolean; - - audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'; - audioVisualizerColor?: `#${string}`; - audioVisualizerBarCount?: number; - audioVisualizerGridRowCount?: number; - audioVisualizerGridColumnCount?: number; - audioVisualizerRadialBarCount?: number; - audioVisualizerRadialRadius?: number; - audioVisualizerAuraColorShift?: number; - audioVisualizerWaveLineWidth?: number; -} - -export function AgentSessionView({ - supportsChatInput = true, - supportsVideoInput = true, - supportsScreenShare = true, - isPreConnectBufferEnabled = true, - audioVisualizerType, - audioVisualizerColor, - audioVisualizerBarCount, - audioVisualizerGridRowCount, - audioVisualizerGridColumnCount, - audioVisualizerRadialBarCount, - audioVisualizerRadialRadius, - audioVisualizerAuraColorShift, - audioVisualizerWaveLineWidth, - ...props -}: React.ComponentProps<'section'> & AgentSessionViewProps) { - const session = useSessionContext(); - const { messages } = useSessionMessages(session); - const [chatOpen, setChatOpen] = useState(false); - const scrollAreaRef = useRef(null); - - const controls: AgentControlBarControls = { - leave: true, - microphone: true, - chat: supportsChatInput, - camera: supportsVideoInput, - screenShare: supportsScreenShare, - }; - - useEffect(() => { - const lastMessage = messages.at(-1); - const lastMessageIsLocal = lastMessage?.from?.isLocal === true; - - if (scrollAreaRef.current && lastMessageIsLocal) { - scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight; - } - }, [messages]); - - return ( -
- - {/* transcript */} -
- ); -} - -const CHAT_MOTION_PROPS = { - variants: { - hidden: { - opacity: 0, - transition: { - ease: 'easeOut', - duration: 0.3, - }, - }, - visible: { - opacity: 1, - transition: { - delay: 0.2, - ease: 'easeOut', - duration: 0.3, - }, - }, - }, - initial: 'hidden', - animate: 'visible', - exit: 'hidden', -}; - -interface ChatTranscriptProps { - hidden?: boolean; - messages?: ReceivedMessage[]; -} - -export function ChatTranscript({ - hidden = false, - messages = [], - className, - ...props -}: ChatTranscriptProps & Omit, 'ref'>) { - const { state: agentState } = useAgent(); - - return ( -
- - {!hidden && ( - - - - )} - -
- ); -} -const ANIMATION_TRANSITION = { - type: 'spring', - stiffness: 675, - damping: 75, - mass: 1, -}; - -const tileViewClassNames = { - // GRID - // 2 Columns x 3 Rows - grid: [ - 'h-full w-full', - 'grid gap-x-2 place-content-center', - 'grid-cols-[1fr_1fr] grid-rows-[90px_1fr_90px]', - ], - // Agent - // chatOpen: true, - // hasSecondTile: true - // layout: Column 1 / Row 1 - // align: x-end y-center - agentChatOpenWithSecondTile: ['col-start-1 row-start-1', 'self-center justify-self-end'], - // Agent - // chatOpen: true, - // hasSecondTile: false - // layout: Column 1 / Row 1 / Column-Span 2 - // align: x-center y-center - agentChatOpenWithoutSecondTile: ['col-start-1 row-start-1', 'col-span-2', 'place-content-center'], - // Agent - // chatOpen: false - // layout: Column 1 / Row 1 / Column-Span 2 / Row-Span 3 - // align: x-center y-center - agentChatClosed: ['col-start-1 row-start-1', 'col-span-2 row-span-3', 'place-content-center'], - // Second tile - // chatOpen: true, - // hasSecondTile: true - // layout: Column 2 / Row 1 - // align: x-start y-center - secondTileChatOpen: ['col-start-2 row-start-1', 'self-center justify-self-start'], - // Second tile - // chatOpen: false, - // hasSecondTile: false - // layout: Column 2 / Row 2 - // align: x-end y-end - secondTileChatClosed: ['col-start-2 row-start-3', 'place-content-end'], -}; - -export function useLocalTrackRef(source: Track.Source) { - const { localParticipant } = useLocalParticipant(); - const publication = localParticipant.getTrackPublication(source); - const trackRef = useMemo( - () => (publication ? { source, participant: localParticipant, publication } : undefined), - [source, publication, localParticipant], - ); - return trackRef; -} - -interface TileLayoutProps { - chatOpen: boolean; - audioVisualizerColor?: `#${string}`; - audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'; - audioVisualizerAuraColorShift?: number; - audioVisualizerWaveLineWidth?: number; - audioVisualizerGridRowCount?: number; - audioVisualizerGridColumnCount?: number; - audioVisualizerRadialBarCount?: number; - audioVisualizerRadialRadius?: number; - audioVisualizerBarCount?: number; -} - -export function TileLayout({ - chatOpen, - audioVisualizerColor, - audioVisualizerType, - audioVisualizerBarCount, - audioVisualizerRadialBarCount, - audioVisualizerRadialRadius, - audioVisualizerGridRowCount, - audioVisualizerGridColumnCount, - audioVisualizerWaveLineWidth, - audioVisualizerAuraColorShift, -}: TileLayoutProps) { - const { videoTrack: agentVideoTrack } = useVoiceAssistant(); - const [screenShareTrack] = useTracks([Track.Source.ScreenShare]); - const cameraTrack: TrackReference | undefined = useLocalTrackRef(Track.Source.Camera); - - const isCameraEnabled = cameraTrack && !cameraTrack.publication.isMuted; - const isScreenShareEnabled = screenShareTrack && !screenShareTrack.publication.isMuted; - const hasSecondTile = isCameraEnabled || isScreenShareEnabled; - - const animationDelay = chatOpen ? 0 : 0.15; - const isAvatar = agentVideoTrack !== undefined; - const videoWidth = agentVideoTrack?.publication.dimensions?.width ?? 0; - const videoHeight = agentVideoTrack?.publication.dimensions?.height ?? 0; - - return ( -
-
-
- {/* Agent */} -
- - {!isAvatar && ( - // Audio Agent - - - - )} - - {isAvatar && ( - // Avatar Agent - - - - )} - -
- -
- {/* Camera & Screen Share */} - - {((cameraTrack && isCameraEnabled) || (screenShareTrack && isScreenShareEnabled)) && ( - - - - )} - -
-
-
-
- ); -} -interface AudioVisualizerProps extends MotionProps { - isChatOpen: boolean; - audioVisualizerColor?: `#${string}`; - audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'; - audioVisualizerAuraColorShift?: number; - audioVisualizerWaveLineWidth?: number; - audioVisualizerGridRowCount?: number; - audioVisualizerGridColumnCount?: number; - audioVisualizerRadialBarCount?: number; - audioVisualizerRadialRadius?: number; - audioVisualizerBarCount?: number; - className?: string; -} - -export function AudioVisualizer({ - audioVisualizerColor, - audioVisualizerType = 'bar', - audioVisualizerBarCount = 5, - audioVisualizerRadialRadius = 100, - audioVisualizerRadialBarCount = 25, - audioVisualizerGridRowCount = 15, - audioVisualizerGridColumnCount = 15, - audioVisualizerWaveLineWidth = 3, - audioVisualizerAuraColorShift = 0.3, - isChatOpen, - className, - ...props -}: AudioVisualizerProps) { - const { state, audioTrack } = useVoiceAssistant(); - - switch (audioVisualizerType) { - case 'aura': { - return ( - - ); - } - case 'wave': { - return ( - - - - ); - } - case 'grid': { - const totalCount = audioVisualizerGridRowCount * audioVisualizerGridColumnCount; - - let size: 'icon' | 'sm' | 'md' | 'lg' | 'xl' = 'sm'; - if (totalCount < 100) { - size = 'xl'; - } else if (totalCount < 200) { - size = 'lg'; - } else if (totalCount < 300) { - size = 'md'; - } - - return ( - - ); - } - case 'radial': { - return ( - - - - ); - } - default: { - let size: 'icon' | 'sm' | 'md' | 'lg' | 'xl' = 'icon'; - let sizedClassName = cn('size-[300px] md:size-[450px]', className); - - if (audioVisualizerBarCount <= 10) { - size = 'xl'; - sizedClassName = cn('size-[450px] *:min-h-[64px] *:w-[64px] gap-4', className); - } else if (audioVisualizerBarCount <= 10) { - size = 'lg'; - sizedClassName = cn('size-[450px]', className); - } else if (audioVisualizerBarCount <= 15) { - size = 'md'; - sizedClassName = cn('size-[350px] md:size-[450px]', className); - } else if (audioVisualizerBarCount <= 30) { - size = 'sm'; - sizedClassName = cn('size-[300px] md:size-[450px]', className); - } - - return ( - - - - ); - } - } -} diff --git a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/agent-session-view.tsx b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/agent-session-view.tsx new file mode 100644 index 000000000..fd6ba444d --- /dev/null +++ b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/agent-session-view.tsx @@ -0,0 +1,273 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; +import { useAgent, useSessionContext, useSessionMessages } from '@livekit/components-react'; +import { AnimatePresence, motion, type MotionProps } from 'motion/react'; + +import { cn } from '@/lib/utils'; +import { AgentChatTranscript } from '@/components/agents-ui/agent-chat-transcript'; +import { + AgentControlBar, + type AgentControlBarControls, +} from '@/components/agents-ui/agent-control-bar'; +import { Shimmer } from '@/components/ai-elements/shimmer'; +import { TileLayout } from './tile-view'; + +const MotionMessage = motion.create(Shimmer); + +const BOTTOM_VIEW_MOTION_PROPS: MotionProps = { + variants: { + visible: { + opacity: 1, + translateY: '0%', + }, + hidden: { + opacity: 0, + translateY: '100%', + }, + }, + initial: 'hidden', + animate: 'visible', + exit: 'hidden', + transition: { + duration: 0.3, + delay: 0.5, + ease: 'easeOut', + }, +}; + +const CHAT_MOTION_PROPS: MotionProps = { + variants: { + hidden: { + opacity: 0, + transition: { + ease: 'easeOut', + duration: 0.3, + }, + }, + visible: { + opacity: 1, + transition: { + delay: 0.2, + ease: 'easeOut', + duration: 0.3, + }, + }, + }, + initial: 'hidden', + animate: 'visible', + exit: 'hidden', +}; + +const SHIMMER_MOTION_PROPS: MotionProps = { + variants: { + visible: { + opacity: 1, + transition: { + ease: 'easeIn', + duration: 0.5, + delay: 0.8, + }, + }, + hidden: { + opacity: 0, + transition: { + ease: 'easeIn', + duration: 0.5, + delay: 0, + }, + }, + }, + initial: 'hidden', + animate: 'visible', + exit: 'hidden', +}; + +interface FadeProps { + top?: boolean; + bottom?: boolean; + className?: string; +} + +export function Fade({ top = false, bottom = false, className }: FadeProps) { + return ( +
+ ); +} + +export interface AgentSessionViewProps { + /** + * Message shown above the controls before the first chat message is sent. + * + * @default 'Agent is listening, ask it a question' + */ + preConnectMessage?: string; + /** + * Enables or disables the chat toggle and transcript input controls. + * + * @default true + */ + supportsChatInput?: boolean; + /** + * Enables or disables camera controls in the bottom control bar. + * + * @default true + */ + supportsVideoInput?: boolean; + /** + * Enables or disables screen sharing controls in the bottom control bar. + * + * @default true + */ + supportsScreenShare?: boolean; + /** + * Shows a pre-connect buffer state with a shimmer message before messages appear. + * + * @default true + */ + isPreConnectBufferEnabled?: boolean; + + /** Selects the visualizer style rendered in the main tile area. */ + audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'; + /** Primary hex color used by supported audio visualizer variants. */ + audioVisualizerColor?: `#${string}`; + /** Number of bars to render when `audioVisualizerType` is `bar`. */ + audioVisualizerBarCount?: number; + /** Number of rows in the visualizer when `audioVisualizerType` is `grid`. */ + audioVisualizerGridRowCount?: number; + /** Number of columns in the visualizer when `audioVisualizerType` is `grid`. */ + audioVisualizerGridColumnCount?: number; + /** Number of radial bars when `audioVisualizerType` is `radial`. */ + audioVisualizerRadialBarCount?: number; + /** Base radius of the radial visualizer when `audioVisualizerType` is `radial`. */ + audioVisualizerRadialRadius?: number; + /** Hue shift intensity used by the aura visualizer when type is `aura`. */ + audioVisualizerAuraColorShift?: number; + /** Stroke width of the wave path when `audioVisualizerType` is `wave`. */ + audioVisualizerWaveLineWidth?: number; + /** Optional class name merged onto the outer `
` container. */ + className?: string; +} + +export function AgentSessionView({ + preConnectMessage = 'Agent is listening, ask it a question', + supportsChatInput = true, + supportsVideoInput = true, + supportsScreenShare = true, + isPreConnectBufferEnabled = true, + + audioVisualizerType, + audioVisualizerColor, + audioVisualizerBarCount, + audioVisualizerGridRowCount, + audioVisualizerGridColumnCount, + audioVisualizerRadialBarCount, + audioVisualizerRadialRadius, + audioVisualizerAuraColorShift, + audioVisualizerWaveLineWidth, + className, + ...props +}: React.ComponentProps<'section'> & AgentSessionViewProps) { + const session = useSessionContext(); + const { messages } = useSessionMessages(session); + const [chatOpen, setChatOpen] = useState(false); + const scrollAreaRef = useRef(null); + const { state: agentState } = useAgent(); + + const controls: AgentControlBarControls = { + leave: true, + microphone: true, + chat: supportsChatInput, + camera: supportsVideoInput, + screenShare: supportsScreenShare, + }; + + useEffect(() => { + const lastMessage = messages.at(-1); + const lastMessageIsLocal = lastMessage?.from?.isLocal === true; + + if (scrollAreaRef.current && lastMessageIsLocal) { + scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight; + } + }, [messages]); + + return ( +
+ + {/* transcript */} + +
+ + {chatOpen && ( + + + + )} + +
+ {/* Tile layout */} + + {/* Bottom */} + + {/* Pre-connect message */} + {isPreConnectBufferEnabled && ( + + {messages.length === 0 && ( + 0} + {...SHIMMER_MOTION_PROPS} + className="pointer-events-none mx-auto block w-full max-w-2xl pb-4 text-center text-sm font-semibold" + > + {preConnectMessage} + + )} + + )} +
+ + +
+
+
+ ); +} diff --git a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/audio-visualizer.tsx b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/audio-visualizer.tsx new file mode 100644 index 000000000..07edad32d --- /dev/null +++ b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/audio-visualizer.tsx @@ -0,0 +1,155 @@ +'use client'; + +import React from 'react'; +import { useVoiceAssistant } from '@livekit/components-react'; +import { motion, type MotionProps } from 'motion/react'; +import { cn } from '@/lib/utils'; + +import { AgentAudioVisualizerAura } from '@/components/agents-ui/agent-audio-visualizer-aura'; +import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar'; +import { AgentAudioVisualizerGrid } from '@/components/agents-ui/agent-audio-visualizer-grid'; +import { AgentAudioVisualizerRadial } from '@/components/agents-ui/agent-audio-visualizer-radial'; +import { AgentAudioVisualizerWave } from '@/components/agents-ui/agent-audio-visualizer-wave'; + +const MotionAgentAudioVisualizerAura = motion.create(AgentAudioVisualizerAura); +const MotionAgentAudioVisualizerBar = motion.create(AgentAudioVisualizerBar); +const MotionAgentAudioVisualizerGrid = motion.create(AgentAudioVisualizerGrid); +const MotionAgentAudioVisualizerRadial = motion.create(AgentAudioVisualizerRadial); +const MotionAgentAudioVisualizerWave = motion.create(AgentAudioVisualizerWave); + +interface AudioVisualizerProps extends MotionProps { + isChatOpen: boolean; + audioVisualizerColor?: `#${string}`; + audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'; + audioVisualizerAuraColorShift?: number; + audioVisualizerWaveLineWidth?: number; + audioVisualizerGridRowCount?: number; + audioVisualizerGridColumnCount?: number; + audioVisualizerRadialBarCount?: number; + audioVisualizerRadialRadius?: number; + audioVisualizerBarCount?: number; + className?: string; +} + +export function AudioVisualizer({ + audioVisualizerColor, + audioVisualizerType = 'bar', + audioVisualizerBarCount = 5, + audioVisualizerRadialRadius = 100, + audioVisualizerRadialBarCount = 25, + audioVisualizerGridRowCount = 15, + audioVisualizerGridColumnCount = 15, + audioVisualizerWaveLineWidth = 3, + audioVisualizerAuraColorShift = 0.3, + isChatOpen, + className, + ...props +}: AudioVisualizerProps) { + const { state, audioTrack } = useVoiceAssistant(); + + switch (audioVisualizerType) { + case 'aura': { + return ( + + ); + } + case 'wave': { + return ( + + + + ); + } + case 'grid': { + const totalCount = audioVisualizerGridRowCount * audioVisualizerGridColumnCount; + + let size: 'icon' | 'sm' | 'md' | 'lg' | 'xl' = 'sm'; + if (totalCount < 100) { + size = 'xl'; + } else if (totalCount < 200) { + size = 'lg'; + } else if (totalCount < 300) { + size = 'md'; + } + + return ( + + ); + } + case 'radial': { + return ( + + + + ); + } + default: { + let size: 'icon' | 'sm' | 'md' | 'lg' | 'xl' = 'icon'; + let sizedClassName = cn('size-[300px] md:size-[450px]', className); + + if (audioVisualizerBarCount <= 5) { + size = 'xl'; + sizedClassName = cn('size-[450px] *:min-h-[64px] *:w-[64px] gap-4', className); + } else if (audioVisualizerBarCount <= 10) { + size = 'lg'; + sizedClassName = cn('size-[450px]', className); + } else if (audioVisualizerBarCount <= 15) { + size = 'md'; + sizedClassName = cn('size-[350px] md:size-[450px]', className); + } else if (audioVisualizerBarCount <= 30) { + size = 'sm'; + sizedClassName = cn('size-[300px] md:size-[450px]', className); + } + + return ( + + + + ); + } + } +} diff --git a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/tile-view.tsx b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/tile-view.tsx new file mode 100644 index 000000000..e8b97e180 --- /dev/null +++ b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/tile-view.tsx @@ -0,0 +1,254 @@ +import React, { useMemo } from 'react'; +import { + useLocalParticipant, + useTracks, + useVoiceAssistant, + VideoTrack, + type TrackReference, +} from '@livekit/components-react'; +import { Track } from 'livekit-client'; +import { AnimatePresence, motion, type MotionProps } from 'motion/react'; + +import { cn } from '@/lib/utils'; +import { AudioVisualizer } from './audio-visualizer'; + +const ANIMATION_TRANSITION: MotionProps['transition'] = { + type: 'spring', + stiffness: 675, + damping: 75, + mass: 1, +}; + +const tileViewClassNames = { + // GRID + // 2 Columns x 3 Rows + grid: [ + 'h-full w-full', + 'grid gap-x-2 place-content-center', + 'grid-cols-[1fr_1fr] grid-rows-[90px_1fr_90px]', + ], + // Agent + // chatOpen: true, + // hasSecondTile: true + // layout: Column 1 / Row 1 + // align: x-end y-center + agentChatOpenWithSecondTile: ['col-start-1 row-start-1', 'self-center justify-self-end'], + // Agent + // chatOpen: true, + // hasSecondTile: false + // layout: Column 1 / Row 1 / Column-Span 2 + // align: x-center y-center + agentChatOpenWithoutSecondTile: ['col-start-1 row-start-1', 'col-span-2', 'place-content-center'], + // Agent + // chatOpen: false + // layout: Column 1 / Row 1 / Column-Span 2 / Row-Span 3 + // align: x-center y-center + agentChatClosed: ['col-start-1 row-start-1', 'col-span-2 row-span-3', 'place-content-center'], + // Second tile + // chatOpen: true, + // hasSecondTile: true + // layout: Column 2 / Row 1 + // align: x-start y-center + secondTileChatOpen: ['col-start-2 row-start-1', 'self-center justify-self-start'], + // Second tile + // chatOpen: false, + // hasSecondTile: false + // layout: Column 2 / Row 2 + // align: x-end y-end + secondTileChatClosed: ['col-start-2 row-start-3', 'place-content-end'], +}; + +export function useLocalTrackRef(source: Track.Source) { + const { localParticipant } = useLocalParticipant(); + const publication = localParticipant.getTrackPublication(source); + const trackRef = useMemo( + () => (publication ? { source, participant: localParticipant, publication } : undefined), + [source, publication, localParticipant], + ); + return trackRef; +} + +interface TileLayoutProps { + chatOpen: boolean; + audioVisualizerColor?: `#${string}`; + audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'; + audioVisualizerAuraColorShift?: number; + audioVisualizerWaveLineWidth?: number; + audioVisualizerGridRowCount?: number; + audioVisualizerGridColumnCount?: number; + audioVisualizerRadialBarCount?: number; + audioVisualizerRadialRadius?: number; + audioVisualizerBarCount?: number; +} + +export function TileLayout({ + chatOpen, + audioVisualizerColor, + audioVisualizerType, + audioVisualizerBarCount, + audioVisualizerRadialBarCount, + audioVisualizerRadialRadius, + audioVisualizerGridRowCount, + audioVisualizerGridColumnCount, + audioVisualizerWaveLineWidth, + audioVisualizerAuraColorShift, +}: TileLayoutProps) { + const { videoTrack: agentVideoTrack } = useVoiceAssistant(); + const [screenShareTrack] = useTracks([Track.Source.ScreenShare]); + const cameraTrack: TrackReference | undefined = useLocalTrackRef(Track.Source.Camera); + + const isCameraEnabled = cameraTrack && !cameraTrack.publication.isMuted; + const isScreenShareEnabled = screenShareTrack && !screenShareTrack.publication.isMuted; + const hasSecondTile = isCameraEnabled || isScreenShareEnabled; + + const animationDelay = chatOpen ? 0 : 0.15; + const isAvatar = agentVideoTrack !== undefined; + const videoWidth = agentVideoTrack?.publication.dimensions?.width ?? 0; + const videoHeight = agentVideoTrack?.publication.dimensions?.height ?? 0; + + return ( +
+
+
+ {/* Agent */} +
+ + {!isAvatar && ( + // Audio Agent + + + + )} + + {isAvatar && ( + // Avatar Agent + + + + )} + +
+ +
+ {/* Camera & Screen Share */} + + {((cameraTrack && isCameraEnabled) || (screenShareTrack && isScreenShareEnabled)) && ( + + + + )} + +
+
+
+
+ ); +} diff --git a/packages/shadcn/index.ts b/packages/shadcn/index.ts index f06d57150..4b91bfebd 100644 --- a/packages/shadcn/index.ts +++ b/packages/shadcn/index.ts @@ -11,5 +11,5 @@ export * from './components/agents-ui/agent-audio-visualizer-grid'; export * from './components/agents-ui/agent-audio-visualizer-radial'; export * from './components/agents-ui/agent-audio-visualizer-wave'; export * from './components/agents-ui/agent-audio-visualizer-aura'; -export * from './components/agents-ui/agent-session-view'; +export * from './components/agents-ui/blocks/agent-session-view-01/agent-session-view'; export * from './components/agents-ui/react-shader-toy'; diff --git a/packages/shadcn/registry.json b/packages/shadcn/registry.json index 7acbc7001..b6d103ced 100644 --- a/packages/shadcn/registry.json +++ b/packages/shadcn/registry.json @@ -288,6 +288,40 @@ ], "dependencies": ["livekit-server-sdk", "@livekit/protocol"], "categories": ["agents", "api"] + }, + { + "name": "agent-session-view-01", + "author": "LiveKit (https://livekit.io)", + "type": "registry:block", + "title": "Agent Session View", + "description": "A full-screen agent session block with transcript, controls, and I/O media tiles.", + "files": [ + { + "path": "components/agents-ui/blocks/agent-session-view-01/agent-session-view.tsx", + "type": "registry:component" + }, + { + "path": "components/agents-ui/blocks/agent-session-view-01/audio-visualizer.tsx", + "type": "registry:component" + }, + { + "path": "components/agents-ui/blocks/agent-session-view-01/tile-view.tsx", + "type": "registry:component" + } + ], + "registryDependencies": [ + "utils", + "@ai-elements/shimmer", + "@agents-ui/agent-control-bar", + "@agents-ui/agent-chat-transcript", + "@agents-ui/agent-audio-visualizer-aura", + "@agents-ui/agent-audio-visualizer-bar", + "@agents-ui/agent-audio-visualizer-grid", + "@agents-ui/agent-audio-visualizer-radial", + "@agents-ui/agent-audio-visualizer-wave" + ], + "dependencies": ["@livekit/components-react@^2.0.0", "livekit-client@^2.0.0", "motion"], + "categories": ["agents", "blocks"] } ] } From 6573591104653e37085cb0fbb46ee69143cb1d29 Mon Sep 17 00:00:00 2001 From: Thomas Yuill Date: Wed, 25 Feb 2026 10:28:01 -0500 Subject: [PATCH 3/9] optimize react-shader-toy to not animate when not visible (by default) --- .../components/agents-ui/react-shader-toy.tsx | 94 +++++++++++++------ 1 file changed, 67 insertions(+), 27 deletions(-) diff --git a/packages/shadcn/components/agents-ui/react-shader-toy.tsx b/packages/shadcn/components/agents-ui/react-shader-toy.tsx index 2f95f496f..28e0d242c 100644 --- a/packages/shadcn/components/agents-ui/react-shader-toy.tsx +++ b/packages/shadcn/components/agents-ui/react-shader-toy.tsx @@ -1,30 +1,29 @@ -/* -MIT License - -Copyright (c) 2018 Morgan Villedieu -Copyright (c) 2023 Rysana, Inc. (forked from the above) -Copyright (c) 2026 LiveKit, Inc. (forked from the above) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ +// MIT License +// +// Copyright (c) 2018 Morgan Villedieu +// Copyright (c) 2023 Rysana, Inc. (forked from the above) +// Copyright (c) 2026 LiveKit, Inc. (forked from the above) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. import React, { useEffect, useRef, type ComponentPropsWithoutRef } from 'react'; + const PRECISIONS = ['lowp', 'mediump', 'highp']; const FS_MAIN_SHADER = `\nvoid main(void){ vec4 color = vec4(0.0,0.0,0.0,1.0); @@ -461,6 +460,13 @@ export interface ReactShaderToyProps { /** Custom callback to handle warnings. Defaults to `console.warn`. */ onWarning?: (warning: string) => void; + + /** + * When true, the animation loop runs even when the canvas is not visible in the viewport. + * When false (default), animation runs only while visible (uses Intersection Observer), + * reducing CPU/GPU usage when the shader is off-screen. + */ + animateWhenNotVisible?: boolean; } export function ReactShaderToy({ @@ -477,6 +483,7 @@ export function ReactShaderToy({ onDoneLoadingTextures, onError = console.error, onWarning = console.warn, + animateWhenNotVisible = false, ...canvasProps }: ReactShaderToyProps & ComponentPropsWithoutRef<'canvas'>) { // Refs for WebGL state @@ -486,6 +493,9 @@ export function ReactShaderToy({ const shaderProgramRef = useRef(null); const vertexPositionAttributeRef = useRef(undefined); const animFrameIdRef = useRef(undefined); + const initFrameIdRef = useRef(undefined); + const isVisibleRef = useRef(true); + const animateWhenNotVisibleRef = useRef(animateWhenNotVisible); const mousedownRef = useRef(false); const canvasPositionRef = useRef(undefined); const timerRef = useRef(0); @@ -845,7 +855,9 @@ export function ReactShaderToy({ mouseValue[0] = lerpVal(currentX, lastMouseArrRef.current[0] ?? 0, lerp); mouseValue[1] = lerpVal(currentY, lastMouseArrRef.current[1] ?? 0, lerp); } - animFrameIdRef.current = requestAnimationFrame(drawScene); + if (animateWhenNotVisibleRef.current || isVisibleRef.current) { + animFrameIdRef.current = requestAnimationFrame(drawScene); + } }; const addEventListeners = () => { @@ -893,6 +905,33 @@ export function ReactShaderToy({ propsUniformsRef.current = propUniforms; }, [propUniforms]); + useEffect(() => { + animateWhenNotVisibleRef.current = animateWhenNotVisible; + if (animateWhenNotVisible) { + isVisibleRef.current = true; + } + }, [animateWhenNotVisible]); + + // Intersection Observer: pause animation when off-screen when animateWhenNotVisible is false + useEffect(() => { + if (animateWhenNotVisible || !canvasRef.current) return; + const canvas = canvasRef.current; + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + isVisibleRef.current = entry.isIntersecting; + if (entry.isIntersecting) { + requestAnimationFrame(drawScene); + } + } + }, + { threshold: 0 }, + ); + observer.observe(canvas); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [animateWhenNotVisible]); + // Main effect for initialization and cleanup useEffect(() => { const textures = texturesArrRef.current; @@ -918,7 +957,7 @@ export function ReactShaderToy({ } } - requestAnimationFrame(init); + initFrameIdRef.current = requestAnimationFrame(init); // Cleanup function return () => { @@ -935,6 +974,7 @@ export function ReactShaderToy({ shaderProgramRef.current = null; } removeEventListeners(); + cancelAnimationFrame(initFrameIdRef.current ?? 0); cancelAnimationFrame(animFrameIdRef.current ?? 0); }; // eslint-disable-next-line react-hooks/exhaustive-deps From cbe5a85da0567fe2cdfcc360083301b4ce1fed9c Mon Sep 17 00:00:00 2001 From: Thomas Yuill Date: Wed, 25 Feb 2026 14:37:38 -0500 Subject: [PATCH 4/9] remove round crop on shaders --- .../components/agents-ui/agent-audio-visualizer-aura.tsx | 6 +----- .../components/agents-ui/agent-audio-visualizer-wave.tsx | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-aura.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-aura.tsx index 29bcc9399..416812731 100644 --- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-aura.tsx +++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-aura.tsx @@ -437,11 +437,7 @@ export function AgentAudioVisualizerAura({ amplitude={amplitude} frequency={frequency} brightness={brightness} - className={cn( - AgentAudioVisualizerAuraVariants({ size }), - 'overflow-hidden rounded-full', - className, - )} + className={cn(AgentAudioVisualizerAuraVariants({ size }), className)} {...props} /> ); diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-wave.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-wave.tsx index 426087f7c..25dd651e3 100644 --- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-wave.tsx +++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-wave.tsx @@ -361,7 +361,6 @@ export function AgentAudioVisualizerWave({ className={cn( AgentAudioVisualizerWaveVariants({ size }), 'mask-[linear-gradient(90deg,transparent_0%,black_20%,black_80%,transparent_100%)]', - 'overflow-hidden rounded-full', className, )} {...props} From 02232eed4936334ba1e7395358295fdd6d9e225f Mon Sep 17 00:00:00 2001 From: Thomas Yuill Date: Wed, 25 Feb 2026 18:54:08 -0500 Subject: [PATCH 5/9] various updates --- .../agent-session-view-01/agent-session-view.tsx | 10 +++++----- .../agent-session-view-01/audio-visualizer.tsx | 14 +++++++++----- .../blocks/agent-session-view-01/tile-view.tsx | 12 ++++++------ 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/agent-session-view.tsx b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/agent-session-view.tsx index fd6ba444d..af37d9acb 100644 --- a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/agent-session-view.tsx +++ b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/agent-session-view.tsx @@ -138,6 +138,8 @@ export interface AgentSessionViewProps { audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'; /** Primary hex color used by supported audio visualizer variants. */ audioVisualizerColor?: `#${string}`; + /** Hue shift intensity used by certain visualizers. */ + audioVisualizerColorShift?: number; /** Number of bars to render when `audioVisualizerType` is `bar`. */ audioVisualizerBarCount?: number; /** Number of rows in the visualizer when `audioVisualizerType` is `grid`. */ @@ -148,8 +150,6 @@ export interface AgentSessionViewProps { audioVisualizerRadialBarCount?: number; /** Base radius of the radial visualizer when `audioVisualizerType` is `radial`. */ audioVisualizerRadialRadius?: number; - /** Hue shift intensity used by the aura visualizer when type is `aura`. */ - audioVisualizerAuraColorShift?: number; /** Stroke width of the wave path when `audioVisualizerType` is `wave`. */ audioVisualizerWaveLineWidth?: number; /** Optional class name merged onto the outer `
` container. */ @@ -165,12 +165,12 @@ export function AgentSessionView({ audioVisualizerType, audioVisualizerColor, + audioVisualizerColorShift, audioVisualizerBarCount, audioVisualizerGridRowCount, audioVisualizerGridColumnCount, audioVisualizerRadialBarCount, audioVisualizerRadialRadius, - audioVisualizerAuraColorShift, audioVisualizerWaveLineWidth, className, ...props @@ -225,15 +225,15 @@ export function AgentSessionView({ {/* Tile layout */} {/* Bottom */} @@ -67,6 +67,7 @@ export function AudioVisualizer({ state={state} audioTrack={audioTrack} color={audioVisualizerColor} + colorShift={audioVisualizerColorShift} lineWidth={isChatOpen ? audioVisualizerWaveLineWidth * 2 : audioVisualizerWaveLineWidth} className="size-[300px] md:size-[450px]" /> @@ -89,6 +90,7 @@ export function AudioVisualizer({ Date: Wed, 25 Feb 2026 19:57:40 -0500 Subject: [PATCH 6/9] integration fixes --- .../agents-ui/AgentSessionView-01.stories.tsx | 8 ++-- .../agent-session-block.tsx} | 13 ++++--- .../{ => components}/audio-visualizer.tsx | 0 .../{ => components}/tile-view.tsx | 2 +- .../blocks/agent-session-view-01/index.ts | 1 + packages/shadcn/index.ts | 2 +- packages/shadcn/registry.json | 10 +++-- packages/shadcn/scripts/doc-gen.ts | 37 +++++++++++++++---- 8 files changed, 50 insertions(+), 23 deletions(-) rename packages/shadcn/components/agents-ui/blocks/agent-session-view-01/{agent-session-view.tsx => components/agent-session-block.tsx} (97%) rename packages/shadcn/components/agents-ui/blocks/agent-session-view-01/{ => components}/audio-visualizer.tsx (100%) rename packages/shadcn/components/agents-ui/blocks/agent-session-view-01/{ => components}/tile-view.tsx (99%) create mode 100644 packages/shadcn/components/agents-ui/blocks/agent-session-view-01/index.ts diff --git a/docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx b/docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx index 0b3eb6f5a..0fe8ed5c8 100644 --- a/docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx +++ b/docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { StoryObj } from '@storybook/react-vite'; import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider'; -import { AgentSessionView, AgentSessionViewProps } from '@agents-ui'; +import { AgentSessionView_01, AgentSessionView_01Props } from '@agents-ui'; export default { - component: AgentSessionView, + component: AgentSessionView_01, decorators: [AgentSessionProvider], - render: (args: AgentSessionViewProps) => , + render: (args: AgentSessionView_01Props) => , args: { className: 'h-screen w-screen', }, @@ -17,6 +17,6 @@ export default { }, }; -export const Default: StoryObj = { +export const Default: StoryObj = { args: {}, }; diff --git a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/agent-session-view.tsx b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/agent-session-block.tsx similarity index 97% rename from packages/shadcn/components/agents-ui/blocks/agent-session-view-01/agent-session-view.tsx rename to packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/agent-session-block.tsx index af37d9acb..5d04ccd5d 100644 --- a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/agent-session-view.tsx +++ b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/agent-session-block.tsx @@ -1,16 +1,15 @@ 'use client'; import React, { useEffect, useRef, useState } from 'react'; +import { AnimatePresence, type MotionProps, motion } from 'motion/react'; import { useAgent, useSessionContext, useSessionMessages } from '@livekit/components-react'; -import { AnimatePresence, motion, type MotionProps } from 'motion/react'; - -import { cn } from '@/lib/utils'; import { AgentChatTranscript } from '@/components/agents-ui/agent-chat-transcript'; import { AgentControlBar, type AgentControlBarControls, } from '@/components/agents-ui/agent-control-bar'; import { Shimmer } from '@/components/ai-elements/shimmer'; +import { cn } from '@/lib/utils'; import { TileLayout } from './tile-view'; const MotionMessage = motion.create(Shimmer); @@ -102,7 +101,7 @@ export function Fade({ top = false, bottom = false, className }: FadeProps) { ); } -export interface AgentSessionViewProps { +export interface AgentSessionView_01Props { /** * Message shown above the controls before the first chat message is sent. * @@ -156,7 +155,7 @@ export interface AgentSessionViewProps { className?: string; } -export function AgentSessionView({ +export function AgentSessionView_01({ preConnectMessage = 'Agent is listening, ask it a question', supportsChatInput = true, supportsVideoInput = true, @@ -172,9 +171,10 @@ export function AgentSessionView({ audioVisualizerRadialBarCount, audioVisualizerRadialRadius, audioVisualizerWaveLineWidth, + ref, className, ...props -}: React.ComponentProps<'section'> & AgentSessionViewProps) { +}: React.ComponentProps<'section'> & AgentSessionView_01Props) { const session = useSessionContext(); const { messages } = useSessionMessages(session); const [chatOpen, setChatOpen] = useState(false); @@ -200,6 +200,7 @@ export function AgentSessionView({ return (
diff --git a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/audio-visualizer.tsx b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/audio-visualizer.tsx similarity index 100% rename from packages/shadcn/components/agents-ui/blocks/agent-session-view-01/audio-visualizer.tsx rename to packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/audio-visualizer.tsx diff --git a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/tile-view.tsx b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/tile-view.tsx similarity index 99% rename from packages/shadcn/components/agents-ui/blocks/agent-session-view-01/tile-view.tsx rename to packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/tile-view.tsx index 6336ab52c..822dfa5b2 100644 --- a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/tile-view.tsx +++ b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/tile-view.tsx @@ -241,7 +241,7 @@ export function TileLayout({ trackRef={cameraTrack || screenShareTrack} width={(cameraTrack || screenShareTrack)?.publication.dimensions?.width ?? 0} height={(cameraTrack || screenShareTrack)?.publication.dimensions?.height ?? 0} - className="bg-muted rounded-md object-cover" + className="bg-muted aspect-square size-[90px] rounded-md object-cover" /> )} diff --git a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/index.ts b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/index.ts new file mode 100644 index 000000000..417dff66d --- /dev/null +++ b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/index.ts @@ -0,0 +1 @@ +export * from './components/agent-session-block'; diff --git a/packages/shadcn/index.ts b/packages/shadcn/index.ts index 4b91bfebd..550ce6112 100644 --- a/packages/shadcn/index.ts +++ b/packages/shadcn/index.ts @@ -11,5 +11,5 @@ export * from './components/agents-ui/agent-audio-visualizer-grid'; export * from './components/agents-ui/agent-audio-visualizer-radial'; export * from './components/agents-ui/agent-audio-visualizer-wave'; export * from './components/agents-ui/agent-audio-visualizer-aura'; -export * from './components/agents-ui/blocks/agent-session-view-01/agent-session-view'; export * from './components/agents-ui/react-shader-toy'; +export * from './components/agents-ui/blocks/agent-session-view-01/components/agent-session-block'; diff --git a/packages/shadcn/registry.json b/packages/shadcn/registry.json index b6d103ced..477c6a46b 100644 --- a/packages/shadcn/registry.json +++ b/packages/shadcn/registry.json @@ -297,16 +297,20 @@ "description": "A full-screen agent session block with transcript, controls, and I/O media tiles.", "files": [ { - "path": "components/agents-ui/blocks/agent-session-view-01/agent-session-view.tsx", + "path": "components/agents-ui/blocks/agent-session-view-01/components/agent-session-block.tsx", "type": "registry:component" }, { - "path": "components/agents-ui/blocks/agent-session-view-01/audio-visualizer.tsx", + "path": "components/agents-ui/blocks/agent-session-view-01/components/audio-visualizer.tsx", "type": "registry:component" }, { - "path": "components/agents-ui/blocks/agent-session-view-01/tile-view.tsx", + "path": "components/agents-ui/blocks/agent-session-view-01/components/tile-view.tsx", "type": "registry:component" + }, + { + "path": "components/agents-ui/blocks/agent-session-view-01/index.ts", + "type": "registry:item" } ], "registryDependencies": [ diff --git a/packages/shadcn/scripts/doc-gen.ts b/packages/shadcn/scripts/doc-gen.ts index 7a98cdb8f..f5228763c 100644 --- a/packages/shadcn/scripts/doc-gen.ts +++ b/packages/shadcn/scripts/doc-gen.ts @@ -7,9 +7,35 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); fs.mkdirSync(path.join(__dirname, '../dist'), { recursive: true }); +const agentsUiPath = path.join(__dirname, '../components/agents-ui'); +const blocksPath = path.join(agentsUiPath, 'blocks'); + +function getAllTsxFiles(dir: string): string[] { + const results: string[] = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...getAllTsxFiles(fullPath)); + } else if (entry.name.endsWith('.tsx')) { + results.push(fullPath); + } + } + return results; +} + console.log('Generating documentation for components/agents-ui'); -const files = fs.readdirSync(path.join(__dirname, '../components/agents-ui')); +const topLevelFiles = fs + .readdirSync(agentsUiPath) + .filter((f) => f.endsWith('.tsx')) + .map((f) => path.join(agentsUiPath, f)); +const blocksFiles = fs.existsSync(blocksPath) + ? getAllTsxFiles(blocksPath).filter((filePath) => + path.basename(filePath, '.tsx').endsWith('-block'), + ) + : []; +const files = [...topLevelFiles, ...blocksFiles]; const parser = withDefaultConfig({ shouldExtractLiteralValuesFromEnum: true, shouldRemoveUndefinedFromOptional: true, @@ -38,14 +64,9 @@ const parser = withDefaultConfig({ console.log(`Found ${files.length} files`); const docs: Record = {}; -for (const file of files) { - if (!file.endsWith('.tsx')) { - continue; - } - - const fileName = file.replace('.tsx', ''); +for (const filePath of files) { + const fileName = path.basename(filePath, '.tsx'); console.log(`Generating documentation for ${fileName}`); - const filePath = path.join(__dirname, '../components/agents-ui', file); const documentation = parser.parse(filePath); for (const doc of documentation) { From 7203768a57cf67dcd09552d276480bcd9a8dfc69 Mon Sep 17 00:00:00 2001 From: Thomas Yuill Date: Thu, 26 Feb 2026 09:57:09 -0500 Subject: [PATCH 7/9] fix bar visualizer color application --- .../components/agents-ui/agent-audio-visualizer-bar.tsx | 2 +- .../agent-session-view-01/components/audio-visualizer.tsx | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx index 18636ff45..3f68603c5 100644 --- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx +++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx @@ -46,7 +46,7 @@ function cloneSingleChild( export const AgentAudioVisualizerBarElementVariants = cva( [ 'rounded-full transition-colors duration-250 ease-linear', - 'bg-transparent data-[lk-highlighted=true]:bg-current', + 'bg-current/10 data-[lk-highlighted=true]:bg-current', ], { variants: { diff --git a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/audio-visualizer.tsx b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/audio-visualizer.tsx index eaba68e0e..b1b9943c1 100644 --- a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/audio-visualizer.tsx +++ b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/audio-visualizer.tsx @@ -145,13 +145,7 @@ export function AudioVisualizer({ className={sizedClassName} {...props} > - + ); } From 1ed864d292e9454c4a9a9e290a178d436a8c1fc3 Mon Sep 17 00:00:00 2001 From: Thomas Yuill Date: Thu, 26 Feb 2026 09:57:20 -0500 Subject: [PATCH 8/9] update agent session view story --- .../agents-ui/AgentSessionView-01.stories.tsx | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx b/docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx index 0fe8ed5c8..e99d79f2b 100644 --- a/docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx +++ b/docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx @@ -9,8 +9,39 @@ export default { render: (args: AgentSessionView_01Props) => , args: { className: 'h-screen w-screen', + supportsChatInput: true, + supportsVideoInput: true, + supportsScreenShare: true, + isPreConnectBufferEnabled: true, + preConnectMessage: 'Agent is listening, ask it a question', + audioVisualizerType: 'bar', + audioVisualizerColor: undefined, + audioVisualizerColorShift: 0, + audioVisualizerBarCount: 5, + audioVisualizerGridRowCount: 10, + audioVisualizerGridColumnCount: 10, + audioVisualizerRadialBarCount: 25, + audioVisualizerRadialRadius: 80, + audioVisualizerWaveLineWidth: 10, + }, + argTypes: { + supportsChatInput: { control: { type: 'boolean' } }, + supportsVideoInput: { control: { type: 'boolean' } }, + supportsScreenShare: { control: { type: 'boolean' } }, + isPreConnectBufferEnabled: { control: { type: 'boolean' } }, + preConnectMessage: { control: { type: 'text' } }, + audioVisualizerType: { + control: { type: 'select', options: ['bar', 'wave', 'grid', 'radial', 'aura'] }, + }, + audioVisualizerColor: { control: { type: 'color' } }, + audioVisualizerColorShift: { control: { type: 'range', min: 0, max: 2, step: 0.1 } }, + audioVisualizerBarCount: { control: { type: 'range', min: 1, max: 21, step: 1 } }, + audioVisualizerGridRowCount: { control: { type: 'range', min: 3, max: 21, step: 2 } }, + audioVisualizerGridColumnCount: { control: { type: 'range', min: 3, max: 21, step: 2 } }, + audioVisualizerRadialBarCount: { control: { type: 'range', min: 4, max: 64, step: 4 } }, + audioVisualizerRadialRadius: { control: { type: 'range', min: 30, max: 120, step: 1 } }, + audioVisualizerWaveLineWidth: { control: { type: 'range', min: 1, max: 10, step: 0.1 } }, }, - argTypes: {}, parameters: { layout: 'centered', actions: { handles: [] }, From b64c1f46de1ecb74295ad0edf83825e68054ec74 Mon Sep 17 00:00:00 2001 From: Thomas Yuill Date: Thu, 26 Feb 2026 10:06:00 -0500 Subject: [PATCH 9/9] add tests --- .../shadcn/tests/agent-session-block.test.tsx | 359 ++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 packages/shadcn/tests/agent-session-block.test.tsx diff --git a/packages/shadcn/tests/agent-session-block.test.tsx b/packages/shadcn/tests/agent-session-block.test.tsx new file mode 100644 index 000000000..293527b10 --- /dev/null +++ b/packages/shadcn/tests/agent-session-block.test.tsx @@ -0,0 +1,359 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { AgentSessionView_01 } from '@/components/agents-ui/blocks/agent-session-view-01/components/agent-session-block'; + +const mockSession = { + isConnected: true, + end: vi.fn(), +} as any; + +const mockMessages: any[] = []; + +vi.mock('@livekit/components-react', () => ({ + useSessionContext: () => mockSession, + useSessionMessages: () => ({ messages: mockMessages }), + useAgent: () => ({ state: 'listening' }), +})); + +const tileLayoutMock = vi.fn((props: any) => ( +
+)); + +const agentControlBarMock = vi.fn((props: any) => ( +
+)); + +vi.mock( + '@/components/agents-ui/blocks/agent-session-view-01/components/tile-view', + () => ({ + TileLayout: (props: any) => tileLayoutMock(props), + }), +); + +vi.mock('@/components/agents-ui/agent-control-bar', () => ({ + AgentControlBar: (props: any) => agentControlBarMock(props), +})); + +vi.mock('@/components/agents-ui/agent-chat-transcript', () => ({ + AgentChatTranscript: ({ className }: any) => ( +
+ ), +})); + +vi.mock('@/components/ai-elements/shimmer', () => ({ + Shimmer: ({ children, ...props }: any) => ( +
+ {children} +
+ ), +})); + +vi.mock('motion/react', () => ({ + motion: { + div: ({ children, ...props }: any) =>
{children}
, + create: (Component: any) => (props: any) => ( + + ), + }, + AnimatePresence: ({ children }: any) => <>{children}, +})); + +describe('AgentSessionView_01', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockMessages.length = 0; + }); + + describe('style, className, and ref', () => { + it('applies className to the section element', () => { + render( + , + ); + const section = screen.getByTestId('session-view'); + expect(section.tagName).toBe('SECTION'); + expect(section).toHaveClass('custom-section-class'); + expect(section).toHaveClass('bg-background'); + }); + + it('applies style to the section element', () => { + render( + , + ); + const section = screen.getByTestId('session-view'); + expect(section).toHaveStyle({ opacity: '0.9', minHeight: '100px' }); + }); + + it('forwards ref to the section element', () => { + const ref = React.createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLElement); + expect(ref.current?.tagName).toBe('SECTION'); + expect(ref.current).toBe(screen.getByTestId('session-view')); + }); + + it('applies html attributes (id, aria)', () => { + render( + , + ); + const section = screen.getByTestId('session-view'); + expect(section).toHaveAttribute('id', 'agent-session-01'); + expect(section).toHaveAttribute('aria-label', 'Agent session view'); + }); + }); + + describe('preConnectMessage', () => { + it('shows default pre-connect message when no messages and buffer enabled', () => { + render( + , + ); + expect(screen.getByText('Agent is listening, ask it a question')).toBeInTheDocument(); + }); + + it('shows custom preConnectMessage when provided', () => { + render( + , + ); + expect(screen.getByText('Please wait, connecting...')).toBeInTheDocument(); + }); + }); + + describe('supportsChatInput', () => { + it('passes chat: true to AgentControlBar by default', () => { + render(); + const call = agentControlBarMock.mock.calls[0][0]; + expect(call.controls).toEqual( + expect.objectContaining({ chat: true, leave: true, microphone: true }), + ); + }); + + it('passes chat: false when supportsChatInput is false', () => { + render( + , + ); + const call = agentControlBarMock.mock.calls[0][0]; + expect(call.controls.chat).toBe(false); + }); + }); + + describe('supportsVideoInput', () => { + it('passes camera: true to AgentControlBar by default', () => { + render(); + const call = agentControlBarMock.mock.calls[0][0]; + expect(call.controls.camera).toBe(true); + }); + + it('passes camera: false when supportsVideoInput is false', () => { + render( + , + ); + const call = agentControlBarMock.mock.calls[0][0]; + expect(call.controls.camera).toBe(false); + }); + }); + + describe('supportsScreenShare', () => { + it('passes screenShare: true to AgentControlBar by default', () => { + render(); + const call = agentControlBarMock.mock.calls[0][0]; + expect(call.controls.screenShare).toBe(true); + }); + + it('passes screenShare: false when supportsScreenShare is false', () => { + render( + , + ); + const call = agentControlBarMock.mock.calls[0][0]; + expect(call.controls.screenShare).toBe(false); + }); + }); + + describe('isPreConnectBufferEnabled', () => { + it('shows pre-connect message when true and no messages', () => { + render( + , + ); + expect(screen.getByText('Waiting...')).toBeInTheDocument(); + }); + + it('hides pre-connect message when isPreConnectBufferEnabled is false', () => { + render( + , + ); + expect(screen.queryByText('Should not appear')).not.toBeInTheDocument(); + }); + }); + + describe('audioVisualizer props passed to TileLayout', () => { + it('passes audioVisualizerType to TileLayout', () => { + render( + , + ); + const props = JSON.parse( + screen.getByTestId('tile-layout').getAttribute('data-props')!, + ); + expect(props.audioVisualizerType).toBe('aura'); + }); + + it('passes audioVisualizerColor to TileLayout', () => { + render( + , + ); + const props = JSON.parse( + screen.getByTestId('tile-layout').getAttribute('data-props')!, + ); + expect(props.audioVisualizerColor).toBe('#ff00ff'); + }); + + it('passes audioVisualizerColorShift to TileLayout', () => { + render( + , + ); + const props = JSON.parse( + screen.getByTestId('tile-layout').getAttribute('data-props')!, + ); + expect(props.audioVisualizerColorShift).toBe(0.5); + }); + + it('passes audioVisualizerBarCount to TileLayout', () => { + render( + , + ); + const props = JSON.parse( + screen.getByTestId('tile-layout').getAttribute('data-props')!, + ); + expect(props.audioVisualizerBarCount).toBe(11); + }); + + it('passes audioVisualizerGridRowCount and audioVisualizerGridColumnCount to TileLayout', () => { + render( + , + ); + const props = JSON.parse( + screen.getByTestId('tile-layout').getAttribute('data-props')!, + ); + expect(props.audioVisualizerGridRowCount).toBe(8); + expect(props.audioVisualizerGridColumnCount).toBe(12); + }); + + it('passes audioVisualizerRadialBarCount and audioVisualizerRadialRadius to TileLayout', () => { + render( + , + ); + const props = JSON.parse( + screen.getByTestId('tile-layout').getAttribute('data-props')!, + ); + expect(props.audioVisualizerRadialBarCount).toBe(32); + expect(props.audioVisualizerRadialRadius).toBe(60); + }); + + it('passes audioVisualizerWaveLineWidth to TileLayout', () => { + render( + , + ); + const props = JSON.parse( + screen.getByTestId('tile-layout').getAttribute('data-props')!, + ); + expect(props.audioVisualizerWaveLineWidth).toBe(3); + }); + + it('passes all audio visualizer props to TileLayout when set', () => { + const visualizerProps = { + audioVisualizerType: 'grid' as const, + audioVisualizerColor: '#00ff00' as const, + audioVisualizerColorShift: 1, + audioVisualizerBarCount: 7, + audioVisualizerGridRowCount: 6, + audioVisualizerGridColumnCount: 8, + audioVisualizerRadialBarCount: 16, + audioVisualizerRadialRadius: 90, + audioVisualizerWaveLineWidth: 2, + }; + render( + , + ); + const props = JSON.parse( + screen.getByTestId('tile-layout').getAttribute('data-props')!, + ); + expect(props).toMatchObject(visualizerProps); + }); + }); + + describe('control bar controls together', () => { + it('passes all controls false when all support props are false', () => { + render( + , + ); + const call = agentControlBarMock.mock.calls[0][0]; + expect(call.controls).toEqual({ + leave: true, + microphone: true, + chat: false, + camera: false, + screenShare: false, + }); + }); + }); +});