diff --git a/docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx b/docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx new file mode 100644 index 000000000..e99d79f2b --- /dev/null +++ b/docs/storybook/stories/agents-ui/AgentSessionView-01.stories.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { StoryObj } from '@storybook/react-vite'; +import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider'; +import { AgentSessionView_01, AgentSessionView_01Props } from '@agents-ui'; + +export default { + component: AgentSessionView_01, + decorators: [AgentSessionProvider], + 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 } }, + }, + parameters: { + layout: 'centered', + actions: { handles: [] }, + }, +}; + +export const Default: StoryObj = { + args: {}, +}; 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-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/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} diff --git a/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/agent-session-block.tsx b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/agent-session-block.tsx new file mode 100644 index 000000000..5d04ccd5d --- /dev/null +++ b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/agent-session-block.tsx @@ -0,0 +1,274 @@ +'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 { 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); + +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 AgentSessionView_01Props { + /** + * 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}`; + /** 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`. */ + 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; + /** 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_01({ + preConnectMessage = 'Agent is listening, ask it a question', + supportsChatInput = true, + supportsVideoInput = true, + supportsScreenShare = true, + isPreConnectBufferEnabled = true, + + audioVisualizerType, + audioVisualizerColor, + audioVisualizerColorShift, + audioVisualizerBarCount, + audioVisualizerGridRowCount, + audioVisualizerGridColumnCount, + audioVisualizerRadialBarCount, + audioVisualizerRadialRadius, + audioVisualizerWaveLineWidth, + ref, + className, + ...props +}: React.ComponentProps<'section'> & AgentSessionView_01Props) { + 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/components/audio-visualizer.tsx b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/audio-visualizer.tsx new file mode 100644 index 000000000..b1b9943c1 --- /dev/null +++ b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/audio-visualizer.tsx @@ -0,0 +1,153 @@ +'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; + audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'; + audioVisualizerColor?: `#${string}`; + audioVisualizerColorShift?: number; + audioVisualizerWaveLineWidth?: number; + audioVisualizerGridRowCount?: number; + audioVisualizerGridColumnCount?: number; + audioVisualizerRadialBarCount?: number; + audioVisualizerRadialRadius?: number; + audioVisualizerBarCount?: number; + className?: string; +} + +export function AudioVisualizer({ + audioVisualizerType = 'bar', + audioVisualizerColor, + audioVisualizerColorShift = 0.3, + audioVisualizerBarCount = 5, + audioVisualizerRadialRadius = 100, + audioVisualizerRadialBarCount = 25, + audioVisualizerGridRowCount = 15, + audioVisualizerGridColumnCount = 15, + audioVisualizerWaveLineWidth = 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/components/tile-view.tsx b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/tile-view.tsx new file mode 100644 index 000000000..822dfa5b2 --- /dev/null +++ b/packages/shadcn/components/agents-ui/blocks/agent-session-view-01/components/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; + audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'; + audioVisualizerColor?: `#${string}`; + audioVisualizerColorShift?: number; + audioVisualizerWaveLineWidth?: number; + audioVisualizerGridRowCount?: number; + audioVisualizerGridColumnCount?: number; + audioVisualizerRadialBarCount?: number; + audioVisualizerRadialRadius?: number; + audioVisualizerBarCount?: number; +} + +export function TileLayout({ + chatOpen, + audioVisualizerType, + audioVisualizerColor, + audioVisualizerColorShift, + audioVisualizerBarCount, + audioVisualizerRadialBarCount, + audioVisualizerRadialRadius, + audioVisualizerGridRowCount, + audioVisualizerGridColumnCount, + audioVisualizerWaveLineWidth, +}: 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/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/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 diff --git a/packages/shadcn/index.ts b/packages/shadcn/index.ts index d84f4c832..550ce6112 100644 --- a/packages/shadcn/index.ts +++ b/packages/shadcn/index.ts @@ -12,3 +12,4 @@ 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/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 7acbc7001..477c6a46b 100644 --- a/packages/shadcn/registry.json +++ b/packages/shadcn/registry.json @@ -288,6 +288,44 @@ ], "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/components/agent-session-block.tsx", + "type": "registry:component" + }, + { + "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/components/tile-view.tsx", + "type": "registry:component" + }, + { + "path": "components/agents-ui/blocks/agent-session-view-01/index.ts", + "type": "registry:item" + } + ], + "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"] } ] } 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) { 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, + }); + }); + }); +});