diff --git a/src/App.tsx b/src/App.tsx
index 4fddc48..efa0c10 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,20 +1,53 @@
-import { Button } from "@/components/ui/button"
+import { useState } from 'react'
+
+import { HomeView } from '@/views/HomeView'
+import { SettingsView } from '@/views/SettingsView'
+import { JobsView } from '@/views/JobsView'
+import { NotificationsView } from '@/views/NotificationsView'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
export function App() {
+ const [showSettings, setShowSettings] = useState(false)
+ const [showJobs, setShowJobs] = useState(false)
+ const [showNotifications, setShowNotifications] = useState(false)
+
return (
-
-
-
-
Project ready!
-
You may now add components and start building.
-
We've already added the button component for you.
-
-
-
- (Press d to toggle dark mode)
-
-
-
+ <>
+ setShowSettings(true)}
+ onJobsClick={() => setShowJobs(true)}
+ onNotificationsClick={() => setShowNotifications(true)}
+ />
+
+
+
+ >
)
}
diff --git a/src/components/AppBar.tsx b/src/components/AppBar.tsx
new file mode 100644
index 0000000..8972373
--- /dev/null
+++ b/src/components/AppBar.tsx
@@ -0,0 +1,33 @@
+import { BellRingIcon, ListChecksIcon, SettingsIcon } from 'lucide-react'
+
+import { Button } from '@/components/ui/button'
+
+interface AppBarProps {
+ onSettingsClick: () => void
+ onJobsClick: () => void
+ onNotificationsClick: () => void
+}
+
+export function AppBar({ onSettingsClick, onJobsClick, onNotificationsClick }: AppBarProps) {
+ return (
+
+ )
+}
diff --git a/src/components/agents-ui/agent-audio-visualizer-aura.tsx b/src/components/agents-ui/agent-audio-visualizer-aura.tsx
new file mode 100644
index 0000000..6e6af17
--- /dev/null
+++ b/src/components/agents-ui/agent-audio-visualizer-aura.tsx
@@ -0,0 +1,427 @@
+'use client'
+
+import React, { useMemo, type ComponentProps } from 'react'
+import { type VariantProps, cva } from 'class-variance-authority'
+import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client'
+import { type AgentState, type TrackReferenceOrPlaceholder } from '@livekit/components-react'
+
+import { ReactShaderToy } from '@/components/agents-ui/react-shader-toy'
+import { useAgentAudioVisualizerAura } from '@/hooks/agents-ui/use-agent-audio-visualizer-aura'
+import { cn } from '@/lib/utils'
+
+const DEFAULT_COLOR = '#1FD5F9'
+
+function hexToRgb(hexColor: string) {
+ try {
+ const rgbColor = hexColor.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/)
+
+ if (rgbColor) {
+ const [, r, g, b] = rgbColor
+ const color = [r, g, b].map((c = '00') => parseInt(c, 16) / 255)
+
+ return color
+ }
+ } catch (error) {
+ console.error(`Invalid hex color '${hexColor}'.\nFalling back to default color '${DEFAULT_COLOR}'.`)
+ }
+
+ return hexToRgb(DEFAULT_COLOR)
+}
+
+const shaderSource = `
+const float TAU = 6.283185;
+
+// Noise for dithering
+vec2 randFibo(vec2 p) {
+ p = fract(p * vec2(443.897, 441.423));
+ p += dot(p, p.yx + 19.19);
+ return fract((p.xx + p.yx) * p.xy);
+}
+
+// Tonemap
+vec3 Tonemap(vec3 x) {
+ x *= 4.0;
+ return x / (1.0 + x);
+}
+
+// Luma for alpha
+float luma(vec3 color) {
+ return dot(color, vec3(0.299, 0.587, 0.114));
+}
+
+// RGB to HSV
+vec3 rgb2hsv(vec3 c) {
+ vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
+ vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
+ vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
+ float d = q.x - min(q.w, q.y);
+ float e = 1.0e-10;
+ return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
+}
+
+// HSV to RGB
+vec3 hsv2rgb(vec3 c) {
+ vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
+ vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
+ return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
+}
+
+// SDF shapes
+float sdCircle(vec2 st, float r) {
+ return length(st) - r;
+}
+
+float sdLine(vec2 p, float r) {
+ float halfLen = r * 2.0;
+ vec2 a = vec2(-halfLen, 0.0);
+ vec2 b = vec2(halfLen, 0.0);
+ vec2 pa = p - a;
+ vec2 ba = b - a;
+ float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
+ return length(pa - ba * h);
+}
+
+float getSdf(vec2 st) {
+ if(uShape == 1.0) return sdCircle(st, uScale);
+ else if(uShape == 2.0) return sdLine(st, uScale);
+ return sdCircle(st, uScale); // Default
+}
+
+vec2 turb(vec2 pos, float t, float it) {
+ // Initial rotation matrix for swirl direction
+ mat2 rotation = mat2(0.6, -0.25, 0.25, 0.9);
+ // Secondary rotation applied each iteration (approx 53 degree rotation)
+ mat2 layerRotation = mat2(0.6, -0.8, 0.8, 0.6);
+
+ float frequency = mix(2.0, 15.0, uFrequency);
+ float amplitude = uAmplitude;
+ float frequencyGrowth = 1.4;
+ float animTime = t * 0.1 * uSpeed;
+
+ const int LAYERS = 4;
+ for(int i = 0; i < LAYERS; i++) {
+ // Calculate wave displacement for this layer
+ vec2 rotatedPos = pos * rotation;
+ vec2 wave = sin(frequency * rotatedPos + float(i) * animTime + it);
+
+ // Apply displacement along rotation direction
+ pos += (amplitude / frequency) * rotation[0] * wave;
+
+ // Evolve parameters for next layer
+ rotation *= layerRotation;
+ amplitude *= mix(1.0, max(wave.x, wave.y), uVariance);
+ frequency *= frequencyGrowth;
+ }
+
+ return pos;
+}
+
+const float ITERATIONS = 36.0;
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 uv = fragCoord / iResolution.xy;
+
+ vec3 pp = vec3(0.0);
+ vec3 bloom = vec3(0.0);
+ float t = iTime * 0.5;
+ vec2 pos = uv - 0.5;
+
+ vec2 prevPos = turb(pos, t, 0.0 - 1.0 / ITERATIONS);
+ float spacing = mix(1.0, TAU, uSpacing);
+
+ for(float i = 1.0; i < ITERATIONS + 1.0; i++) {
+ float iter = i / ITERATIONS;
+ vec2 st = turb(pos, t, iter * spacing);
+ float d = abs(getSdf(st));
+ float pd = distance(st, prevPos);
+ prevPos = st;
+ float dynamicBlur = exp2(pd * 2.0 * 1.4426950408889634) - 1.0;
+ float ds = smoothstep(0.0, uBlur * 0.05 + max(dynamicBlur * uSmoothing, 0.001), d);
+
+ // Shift color based on iteration using uColorScale
+ vec3 color = uColor;
+ if(uColorShift > 0.01) {
+ vec3 hsv = rgb2hsv(color);
+ // Shift hue by iteration
+ hsv.x = fract(hsv.x + (1.0 - iter) * uColorShift * 0.3);
+ color = hsv2rgb(hsv);
+ }
+
+ float invd = 1.0 / max(d + dynamicBlur, 0.001);
+ pp += (ds - 1.0) * color;
+ bloom += clamp(invd, 0.0, 250.0) * color;
+ }
+
+ pp *= 1.0 / ITERATIONS;
+
+ vec3 color;
+
+ // Dark mode (default)
+ if(uMode < 0.5) {
+ // use bloom effect
+ bloom = bloom / (bloom + 2e4);
+ color = (-pp + bloom * 3.0 * uBloom) * 1.2;
+ color += (randFibo(fragCoord).x - 0.5) / 255.0;
+ color = Tonemap(color);
+ float alpha = luma(color) * uMix;
+ fragColor = vec4(color * uMix, alpha);
+ }
+
+ // Light mode
+ else {
+ // no bloom effect
+ color = -pp;
+ color += (randFibo(fragCoord).x - 0.5) / 255.0;
+
+ // Preserve hue by tone mapping brightness only
+ float brightness = length(color);
+ vec3 direction = brightness > 0.0 ? color / brightness : color;
+
+ // Reinhard on brightness
+ float factor = 2.0;
+ float mappedBrightness = (brightness * factor) / (1.0 + brightness * factor);
+ color = direction * mappedBrightness;
+
+ // Boost saturation to compensate for white background bleed-through
+ // When alpha < 1.0, white bleeds through making colors look desaturated
+ // So we increase saturation to maintain vibrant appearance
+ float gray = dot(color, vec3(0.2, 0.5, 0.1));
+ float saturationBoost = 3.0;
+ color = mix(vec3(gray), color, saturationBoost);
+
+ // Clamp between 0-1
+ color = clamp(color, 0.0, 1.0);
+
+ float alpha = mappedBrightness * clamp(uMix, 1.0, 2.0);
+ fragColor = vec4(color, alpha);
+ }
+}`
+
+interface AuraShaderProps {
+ /**
+ * Aurora wave speed
+ * @default 1.0
+ */
+ speed?: number
+
+ /**
+ * Turbulence amplitude
+ * @default 0.5
+ */
+ amplitude?: number
+
+ /**
+ * Wave frequency and complexity
+ * @default 0.5
+ */
+ frequency?: number
+
+ /**
+ * Shape scale
+ * @default 0.3
+ */
+ scale?: number
+
+ /**
+ * Shape type: 1=circle, 2=line
+ * @default 1
+ */
+ shape?: number
+
+ /**
+ * Edge blur/softness
+ * @default 1.0
+ */
+ blur?: number
+
+ /**
+ * Color of the aura in hexidecimal format.
+ * @default '#1FD5F9'
+ */
+ color?: `#${string}`
+
+ /**
+ * Color variation across layers (0-1)
+ * Controls how much colors change between iterations
+ * @default 0.5
+ * @example 0.0 - minimal color variation (more uniform)
+ * @example 0.5 - moderate variation (default)
+ * @example 1.0 - maximum variation (rainbow effect)
+ */
+ colorShift?: number
+
+ /**
+ * Brightness of the aurora (0-1)
+ * @default 1.0
+ */
+ brightness?: number
+
+ /**
+ * Display mode for different backgrounds
+ * - 'dark': Optimized for dark backgrounds (default)
+ * - 'light': Optimized for light/white backgrounds (inverts colors)
+ * @default 'dark'
+ */
+ themeMode?: 'dark' | 'light'
+}
+
+function AuraShader({
+ shape = 1.0,
+ speed = 1.0,
+ amplitude = 0.5,
+ frequency = 0.5,
+ scale = 0.2,
+ blur = 1.0,
+ color = DEFAULT_COLOR,
+ colorShift = 1.0,
+ brightness = 1.0,
+ themeMode = typeof window !== 'undefined' && document.documentElement.classList.contains('dark') ? 'dark' : 'light',
+ ref,
+ className,
+ ...props
+}: AuraShaderProps & ComponentProps<'div'>) {
+ const rgbColor = useMemo(() => hexToRgb(color), [color])
+
+ return (
+
+ {
+ console.error('Shader error:', error)
+ }}
+ onWarning={(warning) => {
+ console.warn('Shader warning:', warning)
+ }}
+ style={{ width: '100%', height: '100%' }}
+ />
+
+ )
+}
+
+AuraShader.displayName = 'AuraShader'
+
+export const AgentAudioVisualizerAuraVariants = cva(['aspect-square'], {
+ variants: {
+ size: {
+ icon: 'h-[24px] gap-[2px]',
+ sm: 'h-[56px] gap-[4px]',
+ md: 'h-[112px] gap-[8px]',
+ lg: 'h-[224px] gap-[16px]',
+ xl: 'h-[448px] gap-[32px]',
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+})
+
+export interface AgentAudioVisualizerAuraProps {
+ /**
+ * The size of the visualizer.
+ * @defaultValue 'lg'
+ */
+ size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl'
+ /**
+ * Agent state
+ * @default 'connecting'
+ */
+ state?: AgentState
+ /**
+ * The color of the aura in hexidecimal format.
+ * @defaultValue '#1FD5F9'
+ */
+ color?: `#${string}`
+ /**
+ * The color shift of the aura.
+ * @defaultValue 0.05
+ */
+ colorShift?: number
+ /**
+ * The theme mode of the aura.
+ * @defaultValue 'dark'
+ */
+ themeMode?: 'dark' | 'light'
+ /**
+ * The audio track to visualize. Can be a local/remote audio track or a track reference.
+ */
+ audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
+}
+
+/**
+ * An shader-based audio visualizer that responds to agent state and audio levels.
+ * Displays an animated elliptical aura that reacts to the current agent state (connecting, thinking, speaking, etc.)
+ * and audio volume when speaking.
+ *
+ * @extends ComponentProps<'div'>
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function AgentAudioVisualizerAura({
+ size = 'lg',
+ state = 'connecting',
+ color = DEFAULT_COLOR,
+ colorShift = 0.05,
+ audioTrack,
+ themeMode,
+ className,
+ ref,
+ ...props
+}: AgentAudioVisualizerAuraProps & ComponentProps<'div'> & VariantProps) {
+ const { speed, scale, amplitude, frequency, brightness } = useAgentAudioVisualizerAura(state, audioTrack)
+
+ return (
+
+ )
+}
diff --git a/src/components/agents-ui/agent-audio-visualizer-bar.tsx b/src/components/agents-ui/agent-audio-visualizer-bar.tsx
new file mode 100644
index 0000000..041ced0
--- /dev/null
+++ b/src/components/agents-ui/agent-audio-visualizer-bar.tsx
@@ -0,0 +1,209 @@
+'use client'
+
+import React, {
+ type CSSProperties,
+ Children,
+ type ComponentProps,
+ type ReactNode,
+ cloneElement,
+ isValidElement,
+ useMemo,
+} from 'react'
+import { type VariantProps, cva } from 'class-variance-authority'
+import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client'
+import { type AgentState, type TrackReferenceOrPlaceholder, useMultibandTrackVolume } from '@livekit/components-react'
+import { useAgentAudioVisualizerBarAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-bar'
+import { cn } from '@/lib/utils'
+
+function cloneSingleChild(children: ReactNode | ReactNode[], props?: Record, key?: unknown) {
+ return Children.map(children, (child) => {
+ // Checking isValidElement is the safe way and avoids a typescript error too.
+ if (isValidElement(child) && Children.only(children)) {
+ const childProps = child.props as Record
+ if (childProps.className) {
+ // make sure we retain classnames of both passed props and child
+ props ??= {}
+ props.className = cn(childProps.className as string, props.className as string)
+ props.style = {
+ ...(childProps.style as CSSProperties),
+ ...(props.style as CSSProperties),
+ }
+ }
+ return cloneElement(child, { ...props, key: key ? String(key) : undefined })
+ }
+ return child
+ })
+}
+
+export const AgentAudioVisualizerBarElementVariants = cva(
+ ['rounded-full transition-colors duration-250 ease-linear', 'bg-current/10 data-[lk-highlighted=true]:bg-current'],
+ {
+ variants: {
+ size: {
+ icon: 'min-h-[4px] w-[4px]',
+ sm: 'min-h-[8px] w-[8px]',
+ md: 'min-h-[16px] w-[16px]',
+ lg: 'min-h-[32px] w-[32px]',
+ xl: 'min-h-[64px] w-[64px]',
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+ }
+)
+
+export const AgentAudioVisualizerBarVariants = cva('relative flex items-center justify-center', {
+ variants: {
+ size: {
+ icon: 'h-[24px] gap-[2px]',
+ sm: 'h-[56px] gap-[4px]',
+ md: 'h-[112px] gap-[8px]',
+ lg: 'h-[224px] gap-[16px]',
+ xl: 'h-[448px] gap-[32px]',
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+})
+
+/**
+ * Props for the AgentAudioVisualizerBar component.
+ */
+export interface AgentAudioVisualizerBarProps {
+ /**
+ * The size of the visualizer.
+ * @defaultValue 'md'
+ */
+ size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl'
+ /**
+ * The current state of the agent. Determines the animation pattern.
+ * @defaultValue 'connecting'
+ */
+ state?: AgentState
+ /**
+ * The color of the bars in hexidecimal format.
+ */
+ color?: `#${string}`
+ /**
+ * The number of bars to display in the visualizer.
+ * If not provided, defaults based on size: 3 for 'icon'/'sm', 5 for others.
+ */
+ barCount?: number
+ /**
+ * The audio track to visualize. Can be a local/remote audio track or a track reference.
+ */
+ audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
+ /**
+ * Additional CSS class names to apply to the container.
+ */
+ className?: string
+ /**
+ * Custom div element to render as grid cells. Each child receives data-lk-index,
+ * data-lk-highlighted props and style props for height. Must be a single div element.
+ */
+ children?: ReactNode
+}
+
+/**
+ * A bar-style audio visualizer that responds to agent state and audio levels.
+ * Displays animated bars that react to the current agent state (connecting, thinking, speaking, etc.)
+ * and audio volume when speaking.
+ *
+ * @extends ComponentProps<'div'>
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function AgentAudioVisualizerBar({
+ size = 'md',
+ state = 'connecting',
+ color,
+ barCount,
+ audioTrack,
+ className,
+ children,
+ style,
+ ...props
+}: AgentAudioVisualizerBarProps & VariantProps & ComponentProps<'div'>) {
+ const _barCount = useMemo(() => {
+ if (barCount) {
+ return barCount
+ }
+ switch (size) {
+ case 'icon':
+ case 'sm':
+ return 3
+ default:
+ return 5
+ }
+ }, [barCount, size])
+
+ const volumeBands = useMultibandTrackVolume(audioTrack, {
+ bands: _barCount,
+ loPass: 100,
+ hiPass: 200,
+ })
+
+ const sequencerInterval = useMemo(() => {
+ switch (state) {
+ case 'connecting':
+ return 2000 / _barCount
+ case 'initializing':
+ return 2000
+ case 'listening':
+ return 500
+ case 'thinking':
+ return 150
+ default:
+ return 1000
+ }
+ }, [state, _barCount])
+
+ const highlightedIndices = useAgentAudioVisualizerBarAnimator(state, _barCount, sequencerInterval)
+
+ const bands = useMemo(
+ () => (state === 'speaking' ? volumeBands : new Array(_barCount).fill(0)),
+ [state, volumeBands, _barCount]
+ )
+
+ if (children && Array.isArray(children)) {
+ throw new Error('AgentAudioVisualizerBar children must be a single element.')
+ }
+
+ return (
+
+ {bands.map((band: number, idx: number) =>
+ children ? (
+
+ {cloneSingleChild(children, {
+ 'data-lk-index': idx,
+ 'data-lk-highlighted': highlightedIndices.includes(idx),
+ 'style': { height: `${band * 100}%` },
+ })}
+
+ ) : (
+
+ )
+ )}
+
+ )
+}
diff --git a/src/components/agents-ui/agent-audio-visualizer-grid.tsx b/src/components/agents-ui/agent-audio-visualizer-grid.tsx
new file mode 100644
index 0000000..d8e12ea
--- /dev/null
+++ b/src/components/agents-ui/agent-audio-visualizer-grid.tsx
@@ -0,0 +1,275 @@
+'use client'
+
+import React, {
+ type CSSProperties,
+ Children,
+ type ComponentProps,
+ type ReactNode,
+ cloneElement,
+ isValidElement,
+ memo,
+ useMemo,
+} from 'react'
+import { type VariantProps, cva } from 'class-variance-authority'
+import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client'
+import { type AgentState, type TrackReferenceOrPlaceholder, useMultibandTrackVolume } from '@livekit/components-react'
+import { type Coordinate, useAgentAudioVisualizerGridAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-grid'
+import { cn } from '@/lib/utils'
+
+function cloneSingleChild(children: ReactNode | ReactNode[], props?: Record, key?: unknown) {
+ return Children.map(children, (child) => {
+ // Checking isValidElement is the safe way and avoids a typescript error too.
+ if (isValidElement(child) && Children.only(children)) {
+ const childProps = child.props as Record
+ if (childProps.className) {
+ // make sure we retain classnames of both passed props and child
+ props ??= {}
+ props.className = cn(childProps.className as string, props.className as string)
+ props.style = {
+ ...(childProps.style as CSSProperties),
+ ...(props.style as CSSProperties),
+ }
+ }
+ return cloneElement(child, { ...props, key: key ? String(key) : undefined })
+ }
+ return child
+ })
+}
+
+export const AgentAudioVisualizerGridCellVariants = cva(
+ [
+ 'h-1 w-1 place-self-center rounded-full bg-current/10 transition-all ease-out',
+ 'data-[lk-highlighted=true]:bg-current',
+ ],
+ {
+ variants: {
+ size: {
+ icon: ['h-[2px] w-[2px]'],
+ sm: ['h-[4px] w-[4px]'],
+ md: ['h-[8px] w-[8px]'],
+ lg: ['h-[12px] w-[12px]'],
+ xl: ['h-[16px] w-[16px]'],
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+ }
+)
+
+export const AgentAudioVisualizerGridVariants = cva('grid', {
+ variants: {
+ size: {
+ icon: ['gap-[2px]'],
+ sm: ['gap-[4px]'],
+ md: ['gap-[8px]'],
+ lg: ['gap-[12px]'],
+ xl: ['gap-[16px]'],
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+})
+
+/**
+ * Configuration options for the grid visualizer.
+ */
+export interface GridOptions {
+ /**
+ * The radius for the animation spread effect.
+ */
+ radius?: number
+ /**
+ * The interval in milliseconds between animation frames.
+ * @defaultValue 100
+ */
+ interval?: number
+ /**
+ * The number of rows in the grid.
+ * @defaultValue 5
+ */
+ rowCount?: number
+ /**
+ * The number of columns in the grid.
+ * @defaultValue 5
+ */
+ columnCount?: number
+ /**
+ * Additional CSS class names to apply to the container.
+ */
+ className?: string
+}
+
+const sizeDefaults = {
+ icon: 3,
+ sm: 5,
+ md: 5,
+ lg: 5,
+ xl: 5,
+}
+
+function useGrid(
+ size: VariantProps['size'] = 'md',
+ columnCount = sizeDefaults[size as keyof typeof sizeDefaults],
+ rowCount = sizeDefaults[size as keyof typeof sizeDefaults]
+) {
+ return useMemo(() => {
+ const _columnCount = columnCount
+ const _rowCount = rowCount ?? columnCount
+ const items = new Array(_columnCount * _rowCount).fill(0).map((_, idx) => idx)
+
+ return { columnCount: _columnCount, rowCount: _rowCount, items }
+ }, [columnCount, rowCount])
+}
+
+interface GridCellProps {
+ index: number
+ state: AgentState
+ interval: number
+ rowCount: number
+ columnCount: number
+ volumeBands: number[]
+ highlightedCoordinate: Coordinate
+ children?: ReactNode
+}
+
+const GridCell = memo(function GridCell({
+ index,
+ state,
+ interval,
+ rowCount,
+ columnCount,
+ volumeBands,
+ highlightedCoordinate,
+ children,
+}: GridCellProps) {
+ if (state === 'speaking') {
+ const y = Math.floor(index / columnCount)
+ const rowMidPoint = Math.floor(rowCount / 2)
+ const volumeChunks = 1 / (rowMidPoint + 1)
+ const distanceToMid = Math.abs(rowMidPoint - y)
+ const threshold = distanceToMid * volumeChunks
+ const isHighlighted = (volumeBands[index % columnCount] ?? 0) >= threshold
+
+ return cloneSingleChild(children, {
+ 'data-lk-index': index,
+ 'data-lk-highlighted': isHighlighted,
+ })
+ }
+
+ const isHighlighted =
+ highlightedCoordinate.x === index % columnCount && highlightedCoordinate.y === Math.floor(index / columnCount)
+
+ const transitionDurationInSeconds = interval / (isHighlighted ? 1000 : 100)
+
+ return cloneSingleChild(children, {
+ 'data-lk-index': index,
+ 'data-lk-highlighted': isHighlighted,
+ 'style': {
+ transitionDuration: `${transitionDurationInSeconds}s`,
+ },
+ })
+})
+
+/**
+ * Props for the AgentAudioVisualizerGrid component.
+ */
+export type AgentAudioVisualizerGridProps = GridOptions & {
+ /**
+ * The size of the visualizer.
+ * @defaultValue 'md'
+ */
+ size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl'
+ /**
+ * The current state of the agent. Determines the animation pattern.
+ * @defaultValue 'connecting'
+ */
+ state?: AgentState
+ /**
+ * The color of the grid cells in hexidecimal format.
+ */
+ color?: `#${string}`
+ /**
+ * The audio track to visualize. Can be a local/remote audio track or a track reference.
+ */
+ audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
+ /**
+ * Additional CSS class names to apply to the container.
+ */
+ className?: string
+ /**
+ * Custom element to render as grid cells. Each child receives data-lk-index
+ * and data-lk-highlighted props.
+ */
+ children?: ReactNode
+} & VariantProps
+
+/**
+ * A grid-style audio visualizer that responds to agent state and audio levels.
+ * Displays an animated grid of cells that react to the current agent state
+ * and audio volume when speaking.
+ *
+ * @extends ComponentProps<'div'>
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function AgentAudioVisualizerGrid({
+ size = 'md',
+ state = 'connecting',
+ radius,
+ color,
+ rowCount: _rowCount = 5,
+ columnCount: _columnCount = 5,
+ interval = 100,
+ className,
+ children,
+ audioTrack,
+ style,
+ ...props
+}: AgentAudioVisualizerGridProps & ComponentProps<'div'>) {
+ const { columnCount, rowCount, items } = useGrid(size, _columnCount, _rowCount)
+ const highlightedCoordinate = useAgentAudioVisualizerGridAnimator(state, rowCount, columnCount, interval, radius)
+ const volumeBands = useMultibandTrackVolume(audioTrack, {
+ bands: columnCount,
+ loPass: 100,
+ hiPass: 200,
+ })
+
+ if (children && Array.isArray(children)) {
+ throw new Error('AgentAudioVisualizerGrid children must be a single element.')
+ }
+
+ return (
+
+ {items.map((idx) => (
+
+ {children ?? }
+
+ ))}
+
+ )
+}
diff --git a/src/components/agents-ui/agent-audio-visualizer-radial.tsx b/src/components/agents-ui/agent-audio-visualizer-radial.tsx
new file mode 100644
index 0000000..f39300a
--- /dev/null
+++ b/src/components/agents-ui/agent-audio-visualizer-radial.tsx
@@ -0,0 +1,204 @@
+'use client'
+
+import { type ComponentProps, type CSSProperties, useMemo } from 'react'
+import { type VariantProps, cva } from 'class-variance-authority'
+import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client'
+import { type AgentState, type TrackReferenceOrPlaceholder, useMultibandTrackVolume } from '@livekit/components-react'
+import { cn } from '@/lib/utils'
+import { useAgentAudioVisualizerRadialAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-radial'
+
+export const AgentAudioVisualizerRadialVariants = cva(
+ [
+ 'relative flex items-center justify-center',
+ '**:data-lk-index:bg-current/10',
+ '**:data-lk-index:absolute **:data-lk-index:top-1/2 **:data-lk-index:left-1/2 **:data-lk-index:origin-bottom **:data-lk-index:-translate-x-1/2',
+ '**:data-lk-index:rounded-full **:data-lk-index:transition-colors **:data-lk-index:duration-150 **:data-lk-index:ease-linear **:data-lk-index:data-[lk-highlighted=true]:bg-current',
+ 'has-data-[lk-state=connecting]:**:data-lk-index:duration-300',
+ 'has-data-[lk-state=initializing]:**:data-lk-index:duration-300',
+ 'has-data-[lk-state=listening]:**:data-lk-index:duration-300',
+ 'has-data-[lk-state=thinking]:animate-spin has-data-[lk-state=thinking]:[animation-duration:5s] has-data-[lk-state=thinking]:**:data-lk-index:bg-current',
+ ],
+ {
+ variants: {
+ size: {
+ icon: ['h-[24px] gap-[2px]'],
+ sm: ['h-[56px] gap-[4px]'],
+ md: ['h-[112px] gap-[8px]'],
+ lg: ['h-[224px] gap-[16px]'],
+ xl: ['h-[448px] gap-[32px]'],
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+ }
+)
+
+/**
+ * Props for the AgentAudioVisualizerRadial component.
+ */
+export interface AgentAudioVisualizerRadialProps {
+ /**
+ * The size of the visualizer.
+ * @defaultValue 'md'
+ */
+ size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl'
+ /**
+ * The current state of the agent. Determines the animation pattern.
+ * @defaultValue 'connecting'
+ */
+ state?: AgentState
+ /**
+ * The color of the radial bars in hexidecimal format.
+ */
+ color?: `#${string}`
+ /**
+ * The radius (distance from center) for the radial bars.
+ * If not provided, defaults based on size.
+ */
+ radius?: number
+ /**
+ * The number of bars to display around the circle.
+ * Should be divisible by 4 for optimal visual results.
+ * If not provided, defaults to 12 for 'icon'/'sm', 24 for others.
+ */
+ barCount?: number
+ /**
+ * The audio track to visualize. Can be a local/remote audio track or a track reference.
+ */
+ audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
+ /**
+ * Additional CSS class names to apply to the container.
+ */
+ className?: string
+}
+
+/**
+ * A radial (circular) audio visualizer that responds to agent state and audio levels.
+ * Displays animated bars arranged in a circle that react to the current agent state
+ * and audio volume when speaking.
+ *
+ * @extends ComponentProps<'div'>
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function AgentAudioVisualizerRadial({
+ size = 'md',
+ state = 'connecting',
+ color,
+ radius,
+ barCount,
+ audioTrack,
+ className,
+ style,
+ ...props
+}: AgentAudioVisualizerRadialProps & ComponentProps<'div'> & VariantProps) {
+ const _barCount = useMemo(() => {
+ if (barCount) {
+ return barCount
+ }
+ switch (size) {
+ case 'icon':
+ case 'sm':
+ return 12
+ default:
+ return 24
+ }
+ }, [barCount, size])
+
+ const volumeBands = useMultibandTrackVolume(audioTrack, {
+ bands: _barCount,
+ loPass: 100,
+ hiPass: 200,
+ })
+
+ const sequencerInterval = useMemo(() => {
+ switch (state) {
+ case 'connecting':
+ case 'listening':
+ return 500
+ case 'initializing':
+ return 250
+ case 'thinking':
+ return Infinity
+ default:
+ return 1000
+ }
+ }, [state, _barCount])
+
+ const distanceFromCenter = useMemo(() => {
+ if (radius) {
+ return radius
+ }
+ switch (size) {
+ case 'icon':
+ return 6
+ case 'xl':
+ return 128
+ case 'lg':
+ return 64
+ case 'sm':
+ return 16
+ case 'md':
+ default:
+ return 32
+ }
+ }, [size, radius])
+
+ if (_barCount % 4 !== 0) {
+ console.warn('barCount should be divisible by 4 for optimal visual results')
+ }
+
+ const highlightedIndices = useAgentAudioVisualizerRadialAnimator(state, _barCount, sequencerInterval)
+ const bands = useMemo(
+ () => (audioTrack ? volumeBands : new Array(_barCount).fill(0)),
+ [audioTrack, volumeBands, _barCount]
+ )
+
+ const dotSize = useMemo(() => {
+ return (distanceFromCenter * Math.PI) / _barCount
+ }, [distanceFromCenter, _barCount])
+
+ return (
+
+ {bands.map((band, idx) => {
+ const angle = (idx / _barCount) * Math.PI * 2
+
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/agents-ui/agent-audio-visualizer-wave.tsx b/src/components/agents-ui/agent-audio-visualizer-wave.tsx
new file mode 100644
index 0000000..28d902d
--- /dev/null
+++ b/src/components/agents-ui/agent-audio-visualizer-wave.tsx
@@ -0,0 +1,365 @@
+'use client'
+
+import { useMemo, type ComponentProps } from 'react'
+import { type VariantProps, cva } from 'class-variance-authority'
+import { type AgentState, type TrackReferenceOrPlaceholder } from '@livekit/components-react'
+
+import { ReactShaderToy } from '@/components/agents-ui/react-shader-toy'
+import { useAgentAudioVisualizerWave } from '@/hooks/agents-ui/use-agent-audio-visualizer-wave'
+import { cn } from '@/lib/utils'
+import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client'
+
+const DEFAULT_COLOR = '#1FD5F9'
+
+function hexToRgb(hexColor: string) {
+ try {
+ const rgbColor = hexColor.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/)
+
+ if (rgbColor) {
+ const [, r, g, b] = rgbColor
+ const color = [r, g, b].map((c = '00') => parseInt(c, 16) / 255)
+
+ return color
+ }
+ } catch {
+ console.error(`Invalid hex color '${hexColor}'.\nFalling back to default color '${DEFAULT_COLOR}'.`)
+ }
+
+ return hexToRgb(DEFAULT_COLOR)
+}
+
+const shaderSource = `
+const float TAU = 6.28318530718;
+
+// Noise for dithering
+vec2 randFibo(vec2 p) {
+ p = fract(p * vec2(443.897, 441.423));
+ p += dot(p, p.yx + 19.19);
+ return fract((p.xx + p.yx) * p.xy);
+}
+
+// Luma for alpha
+float luma(vec3 color) {
+ return dot(color, vec3(0.299, 0.587, 0.114));
+}
+
+// RGB to HSV
+vec3 rgb2hsv(vec3 c) {
+ vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
+ vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
+ vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
+ float d = q.x - min(q.w, q.y);
+ float e = 1.0e-10;
+ return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
+}
+
+// HSV to RGB
+vec3 hsv2rgb(vec3 c) {
+ vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
+ vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
+ return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
+}
+
+// Bell curve function for attenuation from center with rounded top
+float bellCurve(float distanceFromCenter, float maxDistance) {
+ float normalizedDistance = distanceFromCenter / maxDistance;
+ // Use cosine with high power for smooth rounded top
+ return pow(cos(normalizedDistance * (3.14159265359 / 4.0)), 16.0);
+}
+
+// Calculate the sine wave
+float oscilloscopeWave(float x, float centerX, float time) {
+ float relativeX = x - centerX;
+ float maxDistance = centerX;
+ float distanceFromCenter = abs(relativeX);
+
+ // Apply bell curve for amplitude attenuation
+ float bell = bellCurve(distanceFromCenter, maxDistance);
+
+ // Calculate wave with uniforms and bell curve attenuation
+ float wave = sin(relativeX * uFrequency + time * uSpeed) * uAmplitude * bell;
+
+ return wave;
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 uv = fragCoord / iResolution.xy;
+ vec2 pos = uv - 0.5;
+
+ // Calculate center and positions
+ float centerX = 0.5;
+ float centerY = 0.5;
+ float x = uv.x;
+ float y = uv.y;
+
+ // Convert line width from pixels to UV space
+ // Use the average of width and height to handle aspect ratio
+ float pixelSize = 2.0 / (iResolution.x + iResolution.y);
+ float lineWidthUV = uLineWidth * pixelSize;
+ float smoothingUV = uSmoothing * pixelSize;
+
+ // Find minimum distance to the wave by sampling nearby points
+ // This gives us consistent line width without high-frequency artifacts
+ const int NUM_SAMPLES = 50; // Must be const for GLSL loop
+ float minDist = 1000.0;
+ float sampleRange = 0.02; // Range to search for closest point
+
+ for(int i = 0; i < NUM_SAMPLES; i++) {
+ float offset = (float(i) / float(NUM_SAMPLES - 1) - 0.5) * sampleRange;
+ float sampleX = x + offset;
+ float waveY = centerY + oscilloscopeWave(sampleX, centerX, iTime);
+
+ // Calculate distance from current pixel to this point on the wave
+ vec2 wavePoint = vec2(sampleX, waveY);
+ vec2 currentPoint = vec2(x, y);
+ float dist = distance(currentPoint, wavePoint);
+
+ minDist = min(minDist, dist);
+ }
+
+ // Solid line with smooth edges using minimum distance
+ float line = smoothstep(lineWidthUV + smoothingUV, lineWidthUV - smoothingUV, minDist);
+
+ vec3 color = uColor;
+ if(abs(uColorShift) > 0.01) {
+ // Keep the center 50% at base color, then ramp shift across outer 25% on each side.
+ float centerBandHalfWidth = 0.2;
+ float edgeBandWidth = 0.5;
+ float distanceFromCenter = abs(x - centerX);
+ float edgeFactor = clamp((distanceFromCenter - centerBandHalfWidth) / edgeBandWidth, 0.0, 1.0);
+ vec3 hsv = rgb2hsv(color);
+ // Hue shift is zero in the center band and strongest at far edges.
+ hsv.x = fract(hsv.x + edgeFactor * uColorShift * 0.3);
+ color = hsv2rgb(hsv);
+ }
+
+ // Apply line intensity
+ color *= line;
+
+ // Add dithering for smoother gradients
+ // color += (randFibo(fragCoord).x - 0.5) / 255.0;
+
+ // Calculate alpha based on line intensity
+ float alpha = line * uMix;
+
+ fragColor = vec4(color * uMix, alpha);
+}`
+
+interface WaveShaderProps {
+ /**
+ * Class name
+ * @default ''
+ */
+ className?: string
+ /**
+ * Speed of the oscilloscope
+ * @default 10
+ */
+ speed?: number
+ /**
+ * Amplitude of the oscilloscope
+ * @default 0.02
+ */
+ amplitude?: number
+ /**
+ * Frequency of the oscilloscope
+ * @default 20.0
+ */
+ frequency?: number
+ /**
+ * Color of the oscilloscope in hexidecimal format.
+ * @default '#1FD5F9'
+ */
+ color?: `#${string}`
+ /**
+ * Hue shift amount applied toward the outside of the wave. Center remains at the base color.
+ * @default 0.05
+ */
+ colorShift?: number
+ /**
+ * Mix of the oscilloscope
+ * @default 1.0
+ */
+ mix?: number
+ /**
+ * Line width of the oscilloscope in pixels
+ * @default 2.0
+ */
+ lineWidth?: number
+ /**
+ * Blur of the oscilloscope in pixels
+ * @default 0.5
+ */
+ blur?: number
+}
+
+function WaveShader({
+ speed = 10,
+ color = '#1FD5F9',
+ colorShift = 0.05,
+ mix = 1.0,
+ amplitude = 0.02,
+ frequency = 20.0,
+ lineWidth = 2.0,
+ blur = 0.5,
+ ref,
+ className,
+ ...props
+}: WaveShaderProps & ComponentProps<'div'>) {
+ const rgbColor = useMemo(() => hexToRgb(color), [color])
+
+ return (
+
+ {
+ console.error('Shader error:', error)
+ }}
+ onWarning={(warning) => {
+ console.warn('Shader warning:', warning)
+ }}
+ style={{ width: '100%', height: '100%' }}
+ />
+
+ )
+}
+
+WaveShader.displayName = 'WaveShader'
+
+export const AgentAudioVisualizerWaveVariants = cva(['aspect-square'], {
+ variants: {
+ size: {
+ icon: 'h-[24px]',
+ sm: 'h-[56px]',
+ md: 'h-[112px]',
+ lg: 'h-[224px]',
+ xl: 'h-[448px]',
+ },
+ },
+ defaultVariants: {
+ size: 'lg',
+ },
+})
+
+export interface AgentAudioVisualizerWaveProps {
+ /**
+ * The size of the visualizer.
+ * @defaultValue 'lg'
+ */
+ size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl'
+ /**
+ * The agent state.
+ * @defaultValue 'speaking'
+ */
+ state?: AgentState
+ /**
+ * The color of the wave in hexidecimal format.
+ * @defaultValue '#1FD5F9'
+ */
+ color?: `#${string}`
+ /**
+ * The color shift of the wave. Higher values increase hue variation toward the edges.
+ * @defaultValue 0.05
+ */
+ colorShift?: number
+ /**
+ * The line width of the wave in pixels.
+ * @defaultValue 2.0
+ */
+ lineWidth?: number
+ /**
+ * The blur of the wave in pixels.
+ * @defaultValue 0.5
+ */
+ blur?: number
+ /**
+ * The audio track to visualize. Can be a local/remote audio track or a track reference.
+ */
+ audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
+ /**
+ * Additional CSS class names to apply to the container.
+ */
+ className?: string
+}
+
+/**
+ * A wave-style audio visualizer that responds to agent state and audio levels.
+ * Displays an animated wave that reacts to the current agent state (connecting, thinking, speaking, etc.)
+ * and audio volume when speaking.
+ *
+ * @extends ComponentProps<'div'>
+ *
+ * @example ```tsx
+ *
+ * ```
+ */
+export function AgentAudioVisualizerWave({
+ size = 'lg',
+ state = 'speaking',
+ color,
+ colorShift = 0.05,
+ lineWidth,
+ blur,
+ audioTrack,
+ className,
+ ref,
+ ...props
+}: AgentAudioVisualizerWaveProps & ComponentProps<'div'> & VariantProps) {
+ const _lineWidth = useMemo(() => {
+ if (lineWidth !== undefined) {
+ return lineWidth
+ }
+ switch (size) {
+ case 'icon':
+ case 'sm':
+ return 2
+ default:
+ return 1
+ }
+ }, [lineWidth, size])
+
+ const { speed, amplitude, frequency, opacity } = useAgentAudioVisualizerWave({
+ state,
+ audioTrack,
+ })
+
+ return (
+
+ )
+}
diff --git a/src/components/agents-ui/agent-chat-indicator.tsx b/src/components/agents-ui/agent-chat-indicator.tsx
new file mode 100644
index 0000000..801acac
--- /dev/null
+++ b/src/components/agents-ui/agent-chat-indicator.tsx
@@ -0,0 +1,90 @@
+import { type Ref, type ComponentProps } from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { motion, type MotionProps } from 'motion/react'
+
+import { cn } from '@/lib/utils'
+
+const motionAnimationProps = {
+ variants: {
+ hidden: {
+ opacity: 0,
+ scale: 0.1,
+ transition: {
+ duration: 0.1,
+ ease: 'linear' as const,
+ },
+ },
+ visible: {
+ opacity: [0.5, 1],
+ scale: [1, 1.2],
+ transition: {
+ type: 'spring' as const,
+ bounce: 0,
+ duration: 0.5,
+ repeat: Infinity,
+ repeatType: 'mirror' as const,
+ },
+ },
+ },
+ initial: 'hidden',
+ animate: 'visible',
+ exit: 'hidden',
+}
+
+const agentChatIndicatorVariants = cva('inline-block size-2.5 rounded-full bg-muted-foreground', {
+ variants: {
+ size: {
+ sm: 'size-2.5',
+ md: 'size-4',
+ lg: 'size-6',
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+})
+
+/**
+ * Props for the AgentChatIndicator component.
+ */
+export interface AgentChatIndicatorProps extends MotionProps {
+ /**
+ * The size of the indicator dot.
+ * @defaultValue 'md'
+ */
+ size?: 'sm' | 'md' | 'lg'
+ /**
+ * Additional CSS class names to apply to the indicator.
+ */
+ className?: string
+ /**
+ * Allows getting a ref to the component instance.\nOnce the component unmounts, React will set `ref.current` to `null`\n(or call the ref with `null` if you passed a callback ref).\n@see {@link https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom React Docs}
+ */
+ ref?: Ref
+}
+
+/**
+ * An animated indicator that shows the agent is processing or thinking.
+ * Displays as a pulsing dot, typically used in chat interfaces.
+ *
+ * @extends ComponentProps<'span'>
+ *
+ * @example
+ * ```tsx
+ * {agentState === 'thinking' && }
+ * ```
+ */
+export function AgentChatIndicator({
+ size = 'md',
+ className,
+ ...props
+}: AgentChatIndicatorProps & ComponentProps<'span'> & VariantProps) {
+ return (
+
+ )
+}
diff --git a/src/components/agents-ui/agent-chat-transcript.tsx b/src/components/agents-ui/agent-chat-transcript.tsx
new file mode 100644
index 0000000..4a9b12a
--- /dev/null
+++ b/src/components/agents-ui/agent-chat-transcript.tsx
@@ -0,0 +1,68 @@
+'use client'
+
+import { type ComponentProps } from 'react'
+import { type AgentState, type ReceivedMessage } from '@livekit/components-react'
+import { Conversation, ConversationContent, ConversationScrollButton } from '@/components/ai-elements/conversation'
+import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message'
+import { AgentChatIndicator } from '@/components/agents-ui/agent-chat-indicator'
+import { AnimatePresence } from 'motion/react'
+
+/**
+ * Props for the AgentChatTranscript component.
+ */
+export interface AgentChatTranscriptProps extends ComponentProps<'div'> {
+ /**
+ * The current state of the agent. When 'thinking', displays a loading indicator.
+ */
+ agentState?: AgentState
+ /**
+ * Array of messages to display in the transcript.
+ * @defaultValue []
+ */
+ messages?: ReceivedMessage[]
+ /**
+ * Additional CSS class names to apply to the conversation container.
+ */
+ className?: string
+}
+
+/**
+ * A chat transcript component that displays a conversation between the user and agent.
+ * Shows messages with timestamps and origin indicators, plus a thinking indicator
+ * when the agent is processing.
+ *
+ * @extends ComponentProps<'div'>
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function AgentChatTranscript({ agentState, messages = [], className, ...props }: AgentChatTranscriptProps) {
+ return (
+
+
+ {messages.map((receivedMessage) => {
+ const { id, timestamp, from, message } = receivedMessage
+ const time = new Date(timestamp)
+ const messageOrigin = from?.isLocal ? 'user' : 'assistant'
+ const locale = typeof navigator !== 'undefined' ? navigator.language : 'en-US'
+ const title = time.toLocaleTimeString(locale, { timeStyle: 'full' })
+
+ return (
+
+
+ {message}
+
+
+ )
+ })}
+ {agentState === 'thinking' && }
+
+
+
+ )
+}
diff --git a/src/components/agents-ui/agent-control-bar.tsx b/src/components/agents-ui/agent-control-bar.tsx
new file mode 100644
index 0000000..22e390a
--- /dev/null
+++ b/src/components/agents-ui/agent-control-bar.tsx
@@ -0,0 +1,404 @@
+'use client'
+
+import { useEffect, useRef, useState, type ComponentProps } from 'react'
+import { useChat } from '@livekit/components-react'
+import { Track } from 'livekit-client'
+import { Loader, MessageSquareTextIcon, SendHorizontal } from 'lucide-react'
+import { motion, type MotionProps } from 'motion/react'
+
+import { cn } from '@/lib/utils'
+import { AgentDisconnectButton } from '@/components/agents-ui/agent-disconnect-button'
+import { AgentTrackControl } from '@/components/agents-ui/agent-track-control'
+import { AgentTrackToggle, agentTrackToggleVariants } from '@/components/agents-ui/agent-track-toggle'
+import { Button } from '@/components/ui/button'
+import { Toggle } from '@/components/ui/toggle'
+import {
+ useInputControls,
+ usePublishPermissions,
+ type UseInputControlsProps,
+} from '@/hooks/agents-ui/use-agent-control-bar'
+
+const LK_TOGGLE_VARIANT_1 = [
+ 'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10',
+ 'data-[state=off]:[&_~_button]:bg-accent data-[state=off]:[&_~_button]:hover:bg-foreground/10',
+ 'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12',
+ 'data-[state=off]:[&_~_button]:border-border data-[state=off]:[&_~_button]:hover:border-foreground/12',
+ 'data-[state=off]:text-destructive data-[state=off]:hover:text-destructive data-[state=off]:focus:text-destructive',
+ 'data-[state=off]:focus-visible:ring-foreground/12 data-[state=off]:focus-visible:border-ring',
+ 'dark:data-[state=off]:[&_~_button]:bg-accent dark:data-[state=off]:[&_~_button]:hover:bg-foreground/10',
+]
+
+const LK_TOGGLE_VARIANT_2 = [
+ 'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10',
+ 'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12',
+ 'data-[state=off]:focus-visible:border-ring data-[state=off]:focus-visible:ring-foreground/12',
+ 'data-[state=off]:text-foreground data-[state=off]:hover:text-foreground data-[state=off]:focus:text-foreground',
+ 'data-[state=on]:bg-blue-500/20 data-[state=on]:hover:bg-blue-500/30',
+ 'data-[state=on]:border-blue-700/10 data-[state=on]:text-blue-700 data-[state=on]:ring-blue-700/30',
+ 'data-[state=on]:focus-visible:border-blue-700/50',
+ 'dark:data-[state=on]:bg-blue-500/20 dark:data-[state=on]:text-blue-300',
+]
+
+const MOTION_PROPS: MotionProps = {
+ variants: {
+ hidden: {
+ height: 0,
+ opacity: 0,
+ marginBottom: 0,
+ },
+ visible: {
+ height: 'auto',
+ opacity: 1,
+ marginBottom: 12,
+ },
+ },
+ initial: 'hidden',
+ transition: {
+ duration: 0.3,
+ ease: 'easeOut',
+ },
+}
+
+interface AgentChatInputProps {
+ chatOpen: boolean
+ onSend?: (message: string) => void
+ className?: string
+}
+
+function AgentChatInput({ chatOpen, onSend = async () => {}, className }: AgentChatInputProps) {
+ const inputRef = useRef(null)
+ const [isSending, setIsSending] = useState(false)
+ const [message, setMessage] = useState('')
+ const isDisabled = isSending || message.trim().length === 0
+
+ const handleSend = async () => {
+ if (isDisabled) {
+ return
+ }
+
+ try {
+ setIsSending(true)
+ await onSend(message.trim())
+ setMessage('')
+ } catch (error) {
+ console.error(error)
+ } finally {
+ setIsSending(false)
+ }
+ }
+
+ const handleKeyDown = async (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSend()
+ }
+ }
+
+ const handleButtonClick = async () => {
+ if (isDisabled) return
+ await handleSend()
+ }
+
+ useEffect(() => {
+ if (chatOpen) return
+ // when not disabled refocus on input
+ inputRef.current?.focus()
+ }, [chatOpen])
+
+ return (
+
+
+ )
+}
+
+/** Configuration for which controls to display in the AgentControlBar. */
+export interface AgentControlBarControls {
+ /**
+ * Whether to show the leave/disconnect button.
+ *
+ * @defaultValue true
+ */
+ leave?: boolean
+ /**
+ * Whether to show the camera toggle control.
+ *
+ * @defaultValue true (if camera publish permission is granted)
+ */
+ camera?: boolean
+ /**
+ * Whether to show the microphone toggle control.
+ *
+ * @defaultValue true (if microphone publish permission is granted)
+ */
+ microphone?: boolean
+ /**
+ * Whether to show the screen share toggle control.
+ *
+ * @defaultValue true (if screen share publish permission is granted)
+ */
+ screenShare?: boolean
+ /**
+ * Whether to show the chat toggle control.
+ *
+ * @defaultValue true (if data publish permission is granted)
+ */
+ chat?: boolean
+}
+
+export interface AgentControlBarProps extends UseInputControlsProps {
+ /**
+ * The visual style of the control bar.
+ *
+ * @default 'default'
+ */
+ variant?: 'default' | 'outline' | 'livekit'
+ /**
+ * This takes an object with the following keys: `leave`, `microphone`, `screenShare`, `camera`,
+ * `chat`. Each key maps to a boolean value that determines whether the control is displayed.
+ *
+ * @default
+ * {
+ * leave: true,
+ * microphone: true,
+ * screenShare: true,
+ * camera: true,
+ * chat: true,
+ * }
+ */
+ controls?: AgentControlBarControls
+ /**
+ * Whether to save user choices.
+ *
+ * @default true
+ */
+ saveUserChoices?: boolean
+ /**
+ * Whether the agent is connected to a session.
+ *
+ * @default false
+ */
+ isConnected?: boolean
+ /**
+ * Whether the chat input interface is open.
+ *
+ * @default false
+ */
+ isChatOpen?: boolean
+ /** The callback for when the user disconnects. */
+ onDisconnect?: () => void
+ /** The callback for when the chat is opened or closed. */
+ onIsChatOpenChange?: (open: boolean) => void
+ /** The callback for when a device error occurs. */
+ onDeviceError?: (error: { source: Track.Source; error: Error }) => void
+}
+
+/**
+ * A control bar specifically designed for voice assistant interfaces. Provides controls for
+ * microphone, camera, screen share, chat, and disconnect. Includes an expandable chat input for
+ * text-based interaction with the agent.
+ *
+ * @example
+ *
+ * ```tsx
+ * handleDisconnect()}
+ * controls={{
+ * microphone: true,
+ * camera: true,
+ * screenShare: false,
+ * chat: true,
+ * leave: true,
+ * }}
+ * />;
+ * ```
+ *
+ * @extends ComponentProps<'div'>
+ */
+export function AgentControlBar({
+ variant = 'default',
+ controls,
+ isChatOpen = false,
+ isConnected = false,
+ saveUserChoices = true,
+ onDisconnect,
+ onDeviceError,
+ onIsChatOpenChange,
+ className,
+ ...props
+}: AgentControlBarProps & ComponentProps<'div'>) {
+ const { send } = useChat()
+ const publishPermissions = usePublishPermissions()
+ const [isChatOpenUncontrolled, setIsChatOpenUncontrolled] = useState(isChatOpen)
+ const {
+ microphoneTrack,
+ cameraToggle,
+ microphoneToggle,
+ screenShareToggle,
+ handleAudioDeviceChange,
+ handleVideoDeviceChange,
+ handleMicrophoneDeviceSelectError,
+ handleCameraDeviceSelectError,
+ } = useInputControls({ onDeviceError, saveUserChoices })
+
+ const handleSendMessage = async (message: string) => {
+ await send(message)
+ }
+
+ const visibleControls = {
+ leave: controls?.leave ?? true,
+ microphone: controls?.microphone ?? publishPermissions.microphone,
+ screenShare: controls?.screenShare ?? publishPermissions.screenShare,
+ camera: controls?.camera ?? publishPermissions.camera,
+ chat: controls?.chat ?? publishPermissions.data,
+ }
+
+ const isEmpty = Object.values(visibleControls).every((value) => !value)
+
+ if (isEmpty) {
+ console.warn('AgentControlBar: `visibleControls` contains only false values.')
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+ {/* Toggle Microphone */}
+ {visibleControls.microphone && (
+
+ )}
+
+ {/* Toggle Camera */}
+ {visibleControls.camera && (
+
+ )}
+
+ {/* Toggle Screen Share */}
+ {visibleControls.screenShare && (
+
+ )}
+
+ {/* Toggle Transcript */}
+ {visibleControls.chat && (
+
{
+ if (!onIsChatOpenChange) setIsChatOpenUncontrolled(state)
+ else onIsChatOpenChange(state)
+ }}
+ className={agentTrackToggleVariants({
+ variant: variant === 'outline' ? 'outline' : 'default',
+ className: cn(variant === 'livekit' && [LK_TOGGLE_VARIANT_2, 'rounded-full']),
+ })}
+ >
+
+
+ )}
+
+
+ {/* Disconnect */}
+ {visibleControls.leave && (
+
+ END CALL
+ END
+
+ )}
+
+
+ )
+}
diff --git a/src/components/agents-ui/agent-disconnect-button.tsx b/src/components/agents-ui/agent-disconnect-button.tsx
new file mode 100644
index 0000000..8983dd2
--- /dev/null
+++ b/src/components/agents-ui/agent-disconnect-button.tsx
@@ -0,0 +1,71 @@
+'use client'
+
+import { type ComponentProps } from 'react'
+import { Button, buttonVariants } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+import { useSessionContext } from '@livekit/components-react'
+import { type VariantProps } from 'class-variance-authority'
+import { PhoneOffIcon } from 'lucide-react'
+
+/**
+ * Props for the AgentDisconnectButton component.
+ */
+export interface AgentDisconnectButtonProps extends ComponentProps<'button'>, VariantProps {
+ /**
+ * Custom icon to display. Defaults to PhoneOffIcon.
+ */
+ icon?: React.ReactNode
+ /**
+ * The size of the button.
+ * @default 'default'
+ */
+ size?: 'default' | 'sm' | 'lg' | 'icon'
+ /**
+ * The variant of the button.
+ * @default 'destructive'
+ */
+ variant?: 'default' | 'outline' | 'destructive' | 'ghost' | 'link'
+ /**
+ * The children to render.
+ */
+ children?: React.ReactNode
+ /**
+ * The callback for when the button is clicked.
+ */
+ onClick?: (event: React.MouseEvent) => void
+}
+
+/**
+ * A button to disconnect from the current agent session.
+ * Calls the session's end() method when clicked.
+ *
+ * @extends ComponentProps<'button'>
+ *
+ * @example
+ * ```tsx
+ * console.log('Disconnecting...')} />
+ * ```
+ */
+export function AgentDisconnectButton({
+ icon,
+ size = 'default',
+ variant = 'destructive',
+ children,
+ onClick,
+ ...props
+}: AgentDisconnectButtonProps) {
+ const { end } = useSessionContext()
+ const handleClick = (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (typeof end === 'function') {
+ end()
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/agents-ui/agent-session-provider.tsx b/src/components/agents-ui/agent-session-provider.tsx
new file mode 100644
index 0000000..92d8c2f
--- /dev/null
+++ b/src/components/agents-ui/agent-session-provider.tsx
@@ -0,0 +1,57 @@
+import {
+ SessionProvider,
+ type UseSessionReturn,
+ RoomAudioRenderer,
+ type SessionProviderProps,
+ type RoomAudioRendererProps,
+} from '@livekit/components-react'
+import { Room } from 'livekit-client'
+
+/**
+ * Props for the AgentSessionProvider component.
+ * Combines SessionProviderProps with RoomAudioRendererProps.
+ */
+export type AgentSessionProviderProps = SessionProviderProps &
+ RoomAudioRendererProps & {
+ /**
+ * The room to provide.
+ */
+ room?: Room
+ /**
+ * The volume to set for the audio renderer.
+ */
+ volume?: number
+ /**
+ * Whether to mute the audio renderer.
+ */
+ muted?: boolean
+ /**
+ * The session to provide.
+ */
+ session: UseSessionReturn
+ /**
+ * The children to render.
+ */
+ children: React.ReactNode
+ }
+
+/**
+ * A provider component for agent sessions that wraps SessionProvider
+ * and includes RoomAudioRenderer for audio playback.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function AgentSessionProvider({ session, children, ...roomAudioRendererProps }: AgentSessionProviderProps) {
+ return (
+
+ {children}
+
+
+ )
+}
diff --git a/src/components/agents-ui/agent-track-control.tsx b/src/components/agents-ui/agent-track-control.tsx
new file mode 100644
index 0000000..aa4a02e
--- /dev/null
+++ b/src/components/agents-ui/agent-track-control.tsx
@@ -0,0 +1,306 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import { type VariantProps, cva } from 'class-variance-authority'
+import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'
+import { type TrackReferenceOrPlaceholder, useMaybeRoomContext, useMediaDeviceSelect } from '@livekit/components-react'
+import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar'
+import { AgentTrackToggle } from '@/components/agents-ui/agent-track-toggle'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { toggleVariants } from '@/components/ui/toggle'
+import { cn } from '@/lib/utils'
+
+const selectVariants = cva(
+ [
+ 'rounded-l-none pl-2 shadow-none',
+ 'text-foreground hover:text-muted-foreground',
+ 'peer-data-[state=on]/track:bg-muted peer-data-[state=on]/track:hover:bg-foreground/10',
+ 'peer-data-[state=off]/track:text-destructive',
+ 'peer-data-[state=off]/track:focus-visible:border-destructive peer-data-[state=off]/track:focus-visible:ring-destructive/30',
+ '[&_svg]:opacity-100',
+ ],
+ {
+ variants: {
+ variant: {
+ default: [
+ 'border-none',
+ 'peer-data-[state=off]/track:bg-destructive/10',
+ 'peer-data-[state=off]/track:hover:bg-destructive/15',
+ 'peer-data-[state=off]/track:[&_svg]:text-destructive!',
+
+ 'dark:peer-data-[state=on]/track:bg-accent',
+ 'dark:peer-data-[state=on]/track:hover:bg-foreground/10',
+ 'dark:peer-data-[state=off]/track:bg-destructive/10',
+ 'dark:peer-data-[state=off]/track:hover:bg-destructive/15',
+ ],
+ outline: [
+ 'border border-l-0',
+ 'peer-data-[state=off]/track:border-destructive/20',
+ 'peer-data-[state=off]/track:bg-destructive/10',
+ 'peer-data-[state=off]/track:hover:bg-destructive/15',
+ 'peer-data-[state=off]/track:[&_svg]:text-destructive!',
+ 'peer-data-[state=on]/track:hover:border-foreground/12',
+
+ 'dark:peer-data-[state=off]/track:bg-destructive/10',
+ 'dark:peer-data-[state=off]/track:hover:bg-destructive/15',
+ 'dark:peer-data-[state=on]/track:bg-accent',
+ 'dark:peer-data-[state=on]/track:hover:bg-foreground/10',
+ ],
+ },
+ size: {
+ default: 'w-[180px]',
+ sm: 'w-auto',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ }
+)
+
+/**
+ * Props for the TrackDeviceSelect component. */
+type TrackDeviceSelectProps = React.ComponentProps &
+ VariantProps & {
+ /**
+ * The size of the select.
+ * @defaultValue 'default'
+ */
+ size?: 'default' | 'sm'
+ /**
+ * The variant of the select.
+ * @defaultValue 'default'
+ */
+ variant?: 'default' | 'outline' | null
+ /**
+ * The type of media device (audioinput or videoinput).
+ */
+ kind: MediaDeviceKind
+ /**
+ * The track source to control (Microphone, Camera, or ScreenShare).
+ */
+ track?: LocalAudioTrack | LocalVideoTrack | undefined
+ /**
+ * Whether to request permissions for the media device.
+ */
+ requestPermissions?: boolean
+ /**
+ * Callback when a media device error occurs.
+ */
+ onMediaDeviceError?: (error: Error) => void
+ /**
+ * Callback when the device list changes.
+ */
+ onDeviceListChange?: (devices: MediaDeviceInfo[]) => void
+ /**
+ * Callback when the active device changes.
+ */
+ onActiveDeviceChange?: (deviceId: string) => void
+ }
+
+/**
+ * A select component for selecting a media device.
+ *
+ * @extends ComponentProps<'button'>
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+function TrackDeviceSelect({
+ kind,
+ track,
+ size = 'default',
+ variant = 'default',
+ className,
+ requestPermissions = false,
+ onMediaDeviceError,
+ onDeviceListChange,
+ onActiveDeviceChange,
+ ...props
+}: TrackDeviceSelectProps) {
+ const room = useMaybeRoomContext()
+ const [open, setOpen] = useState(false)
+ const [requestPermissionsState, setRequestPermissionsState] = useState(requestPermissions)
+ const { devices, activeDeviceId, setActiveMediaDevice } = useMediaDeviceSelect({
+ room,
+ kind,
+ track,
+ requestPermissions: requestPermissionsState,
+ onError: onMediaDeviceError,
+ })
+
+ useEffect(() => {
+ onDeviceListChange?.(devices)
+ }, [devices, onDeviceListChange])
+
+ const handleOpenChange = (open: boolean) => {
+ setOpen(open)
+ if (open) {
+ setRequestPermissionsState(true)
+ }
+ }
+
+ const handleActiveDeviceChange = (deviceId: string) => {
+ setActiveMediaDevice(deviceId)
+ onActiveDeviceChange?.(deviceId)
+ }
+
+ const filteredDevices = useMemo(() => devices.filter((d) => d.deviceId !== ''), [devices])
+
+ if (filteredDevices.length < 2) {
+ return null
+ }
+
+ return (
+
+ )
+}
+
+/**
+ * Props for the AgentTrackControl component.
+ */
+export type AgentTrackControlProps = VariantProps & {
+ /**
+ * The type of media device (audioinput or videoinput).
+ */
+ kind: MediaDeviceKind
+ /**
+ * The track source to control (Microphone, Camera, or ScreenShare).
+ */
+ source: 'camera' | 'microphone' | 'screen_share'
+ /**
+ * Whether the track is currently enabled/published.
+ */
+ pressed?: boolean
+ /**
+ * Whether the control is in a pending/loading state.
+ */
+ pending?: boolean
+ /**
+ * Whether the control is disabled.
+ */
+ disabled?: boolean
+ /**
+ * Additional CSS class names to apply to the container.
+ */
+ className?: string
+ /**
+ * The audio track reference for visualization (only for microphone).
+ */
+ audioTrack?: TrackReferenceOrPlaceholder
+ /**
+ * Callback when the pressed state changes.
+ */
+ onPressedChange?: (pressed: boolean) => void
+ /**
+ * Callback when a media device error occurs.
+ */
+ onMediaDeviceError?: (error: Error) => void
+ /**
+ * Callback when the active device changes.
+ */
+ onActiveDeviceChange?: (deviceId: string) => void
+}
+
+/**
+ * A combined track toggle and device selector control.
+ * Includes a toggle button and a dropdown to select the active device.
+ * For microphone tracks, displays an audio visualizer.
+ *
+ * @example
+ * ```tsx
+ * setMicEnabled(pressed)}
+ * onActiveDeviceChange={(deviceId) => setMicDevice(deviceId)}
+ * />
+ * ```
+ */
+export function AgentTrackControl({
+ kind,
+ variant = 'default',
+ source,
+ pressed,
+ pending,
+ disabled,
+ className,
+ audioTrack,
+ onPressedChange,
+ onMediaDeviceError,
+ onActiveDeviceChange,
+}: AgentTrackControlProps) {
+ return (
+
+
+ {audioTrack && (
+
+
+
+ )}
+
+ {kind && (
+
+ )}
+
+ )
+}
diff --git a/src/components/agents-ui/agent-track-toggle.tsx b/src/components/agents-ui/agent-track-toggle.tsx
new file mode 100644
index 0000000..445bfb9
--- /dev/null
+++ b/src/components/agents-ui/agent-track-toggle.tsx
@@ -0,0 +1,156 @@
+import { Fragment, type ComponentProps, useMemo, useState } from 'react'
+import { type VariantProps, cva } from 'class-variance-authority'
+import { Track } from 'livekit-client'
+import { MicIcon, MicOffIcon, MonitorUpIcon, MonitorOffIcon, LoaderIcon, VideoIcon, VideoOffIcon } from 'lucide-react'
+import { Toggle } from '@/components/ui/toggle'
+import { cn } from '@/lib/utils'
+
+export const agentTrackToggleVariants = cva(['size-9'], {
+ variants: {
+ size: {
+ default: 'h-9 min-w-9 px-2',
+ sm: 'h-8 min-w-8 px-1.5',
+ lg: 'h-10 min-w-10 px-2.5',
+ },
+ variant: {
+ default: [
+ 'data-[state=off]:bg-destructive/10 data-[state=off]:text-destructive',
+ 'data-[state=off]:hover:bg-destructive/15',
+ 'data-[state=off]:focus-visible:ring-destructive/30',
+ 'data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
+ 'data-[state=on]:hover:bg-foreground/10',
+ ],
+ outline: [
+ 'data-[state=off]:border-destructive/20 data-[state=off]:bg-destructive/10 data-[state=off]:text-destructive',
+ 'data-[state=off]:hover:bg-destructive/15 data-[state=off]:hover:text-destructive',
+ 'data-[state=off]:focus:text-destructive',
+ 'data-[state=off]:focus-visible:border-destructive data-[state=off]:focus-visible:ring-destructive/30',
+ 'data-[state=on]:hover:border-foreground/12 data-[state=on]:hover:bg-foreground/10',
+ 'dark:data-[state=on]:hover:bg-foreground/10',
+ ],
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+})
+
+function getSourceIcon(source: Track.Source, enabled: boolean, pending = false) {
+ if (pending) {
+ return LoaderIcon
+ }
+
+ switch (source) {
+ case Track.Source.Microphone:
+ return enabled ? MicIcon : MicOffIcon
+ case Track.Source.Camera:
+ return enabled ? VideoIcon : VideoOffIcon
+ case Track.Source.ScreenShare:
+ return enabled ? MonitorUpIcon : MonitorOffIcon
+ default:
+ return Fragment
+ }
+}
+
+/**
+ * Props for the AgentTrackToggle component.
+ */
+export type AgentTrackToggleProps = VariantProps &
+ ComponentProps<'button'> & {
+ /**
+ * The size of the toggle.
+ */
+ size?: 'sm' | 'default' | 'lg'
+ /**
+ * The variant of the toggle.
+ * @defaultValue 'default'
+ */
+ variant?: 'default' | 'outline'
+ /**
+ * The track source to toggle (Microphone, Camera, or ScreenShare).
+ */
+ source: 'camera' | 'microphone' | 'screen_share'
+ /**
+ * Whether the toggle is in a pending/loading state.
+ * When true, displays a loading spinner icon.
+ * @defaultValue false
+ */
+ pending?: boolean
+ /**
+ * Whether the toggle is currently pressed/enabled.
+ * @defaultValue false
+ */
+ pressed?: boolean
+ /**
+ * The default pressed state when uncontrolled.
+ * @defaultValue false
+ */
+ defaultPressed?: boolean
+ /**
+ * Callback fired when the pressed state changes.
+ */
+ onPressedChange?: (pressed: boolean) => void
+ }
+
+/**
+ * A toggle button for controlling track publishing state.
+ * Displays appropriate icons based on the track source and state.
+ *
+ * @extends ComponentProps<'button'>
+ *
+ * @example
+ * ```tsx
+ * setMicEnabled(pressed)}
+ * />
+ * ```
+ */
+export function AgentTrackToggle({
+ size = 'default',
+ variant = 'default',
+ source,
+ pending = false,
+ pressed,
+ defaultPressed = false,
+ className,
+ onPressedChange,
+ ...props
+}: AgentTrackToggleProps) {
+ const [uncontrolledPressed, setUncontrolledPressed] = useState(defaultPressed ?? false)
+ const isControlled = pressed !== undefined
+ const resolvedPressed = useMemo(
+ () => (isControlled ? pressed : uncontrolledPressed) ?? false,
+ [isControlled, pressed, uncontrolledPressed]
+ )
+ const IconComponent = getSourceIcon(source as Track.Source, resolvedPressed, pending)
+ const handlePressedChange = (nextPressed: boolean) => {
+ if (!isControlled) {
+ setUncontrolledPressed(nextPressed)
+ }
+ onPressedChange?.(nextPressed)
+ }
+
+ return (
+
+
+ {props.children}
+
+ )
+}
diff --git a/src/components/agents-ui/blocks/agent-session-view-01/components/agent-session-block.tsx b/src/components/agents-ui/blocks/agent-session-view-01/components/agent-session-block.tsx
new file mode 100644
index 0000000..63b3e08
--- /dev/null
+++ b/src/components/agents-ui/blocks/agent-session-view-01/components/agent-session-block.tsx
@@ -0,0 +1,275 @@
+'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 {
+ /**
+ * Theme mode forwarded to the aura visualizer (`audioVisualizerType="aura"`) so
+ * the shader's blend mode adapts to the theme mode.
+ * Ignored by other visualizer types.
+ */
+ themeMode?: 'dark' | 'light'
+ /**
+ * Message shown above the controls before the first chat message is sent.
+ *
+ * @default 'Agent is listening, ask it a question'
+ */
+ preConnectMessage?: string
+ /**
+ * 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,
+ themeMode,
+ ref,
+ className,
+ ...props
+}: React.ComponentProps<'section'> & AgentSessionView_01Props) {
+ const session = useSessionContext()
+ const { messages } = useSessionMessages(session)
+ const [isChatOpen, setIsChatOpen] = 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 */}
+
+
+
+ {isChatOpen && (
+
+
+
+ )}
+
+
+ {/* 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/src/components/agents-ui/blocks/agent-session-view-01/components/audio-visualizer.tsx b/src/components/agents-ui/blocks/agent-session-view-01/components/audio-visualizer.tsx
new file mode 100644
index 0000000..55472a4
--- /dev/null
+++ b/src/components/agents-ui/blocks/agent-session-view-01/components/audio-visualizer.tsx
@@ -0,0 +1,154 @@
+'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 {
+ themeMode?: 'dark' | 'light'
+ 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({
+ themeMode,
+ isChatOpen,
+ audioVisualizerType = 'bar',
+ audioVisualizerColor,
+ audioVisualizerColorShift = 0.3,
+ audioVisualizerBarCount = 5,
+ audioVisualizerRadialRadius = 100,
+ audioVisualizerRadialBarCount = 25,
+ audioVisualizerGridRowCount = 15,
+ audioVisualizerGridColumnCount = 15,
+ audioVisualizerWaveLineWidth = 3,
+ 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] gap-4 *:min-h-[64px] *:w-[64px]', 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/src/components/agents-ui/blocks/agent-session-view-01/components/tile-view.tsx b/src/components/agents-ui/blocks/agent-session-view-01/components/tile-view.tsx
new file mode 100644
index 0000000..137cd07
--- /dev/null
+++ b/src/components/agents-ui/blocks/agent-session-view-01/components/tile-view.tsx
@@ -0,0 +1,251 @@
+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 {
+ themeMode?: 'dark' | 'light'
+ isChatOpen: 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({
+ themeMode,
+ isChatOpen,
+ 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 = isChatOpen ? 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/src/components/agents-ui/blocks/agent-session-view-01/index.ts b/src/components/agents-ui/blocks/agent-session-view-01/index.ts
new file mode 100644
index 0000000..42be32e
--- /dev/null
+++ b/src/components/agents-ui/blocks/agent-session-view-01/index.ts
@@ -0,0 +1 @@
+export * from './components/agent-session-block'
diff --git a/src/components/agents-ui/react-shader-toy.tsx b/src/components/agents-ui/react-shader-toy.tsx
new file mode 100644
index 0000000..a9be39f
--- /dev/null
+++ b/src/components/agents-ui/react-shader-toy.tsx
@@ -0,0 +1,888 @@
+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);
+ mainImage( color, gl_FragCoord.xy );
+ gl_FragColor = color;
+}`
+const BASIC_FS = `void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
+ vec2 uv = fragCoord/iResolution.xy;
+ vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
+ fragColor = vec4(col,1.0);
+}`
+const BASIC_VS = `attribute vec3 aVertexPosition;
+void main(void) {
+ gl_Position = vec4(aVertexPosition, 1.0);
+}`
+const UNIFORM_TIME = 'iTime'
+const UNIFORM_TIMEDELTA = 'iTimeDelta'
+const UNIFORM_DATE = 'iDate'
+const UNIFORM_FRAME = 'iFrame'
+const UNIFORM_MOUSE = 'iMouse'
+const UNIFORM_RESOLUTION = 'iResolution'
+const UNIFORM_CHANNEL = 'iChannel'
+const UNIFORM_CHANNELRESOLUTION = 'iChannelResolution'
+const UNIFORM_DEVICEORIENTATION = 'iDeviceOrientation'
+
+type Vector4 = [T, T, T, T]
+type UniformType = keyof Uniforms
+
+function isMatrixType(t: string, v: number[] | number): v is number[] {
+ return t.includes('Matrix') && Array.isArray(v)
+}
+function isVectorListType(t: string, v: number[] | number): v is number[] {
+ return t.includes('v') && Array.isArray(v) && v.length > Number.parseInt(t.charAt(0))
+}
+function isVectorType(t: string, v: number[] | number): v is Vector4 {
+ return !t.includes('v') && Array.isArray(v) && v.length > Number.parseInt(t.charAt(0))
+}
+const processUniform = (
+ gl: WebGLRenderingContext,
+ location: WebGLUniformLocation,
+ t: T,
+ value: number | number[]
+) => {
+ if (isVectorType(t, value)) {
+ switch (t) {
+ case '2f':
+ return gl.uniform2f(location, value[0], value[1])
+ case '3f':
+ return gl.uniform3f(location, value[0], value[1], value[2])
+ case '4f':
+ return gl.uniform4f(location, value[0], value[1], value[2], value[3])
+ case '2i':
+ return gl.uniform2i(location, value[0], value[1])
+ case '3i':
+ return gl.uniform3i(location, value[0], value[1], value[2])
+ case '4i':
+ return gl.uniform4i(location, value[0], value[1], value[2], value[3])
+ }
+ }
+ if (typeof value === 'number') {
+ switch (t) {
+ case '1i':
+ return gl.uniform1i(location, value)
+ default:
+ return gl.uniform1f(location, value)
+ }
+ }
+ switch (t) {
+ case '1iv':
+ return gl.uniform1iv(location, value)
+ case '2iv':
+ return gl.uniform2iv(location, value)
+ case '3iv':
+ return gl.uniform3iv(location, value)
+ case '4iv':
+ return gl.uniform4iv(location, value)
+ case '1fv':
+ return gl.uniform1fv(location, value)
+ case '2fv':
+ return gl.uniform2fv(location, value)
+ case '3fv':
+ return gl.uniform3fv(location, value)
+ case '4fv':
+ return gl.uniform4fv(location, value)
+ case 'Matrix2fv':
+ return gl.uniformMatrix2fv(location, false, value)
+ case 'Matrix3fv':
+ return gl.uniformMatrix3fv(location, false, value)
+ case 'Matrix4fv':
+ return gl.uniformMatrix4fv(location, false, value)
+ }
+}
+
+const uniformTypeToGLSLType = (t: string) => {
+ switch (t) {
+ case '1f':
+ return 'float'
+ case '2f':
+ return 'vec2'
+ case '3f':
+ return 'vec3'
+ case '4f':
+ return 'vec4'
+ case '1i':
+ return 'int'
+ case '2i':
+ return 'ivec2'
+ case '3i':
+ return 'ivec3'
+ case '4i':
+ return 'ivec4'
+ case '1iv':
+ return 'int'
+ case '2iv':
+ return 'ivec2'
+ case '3iv':
+ return 'ivec3'
+ case '4iv':
+ return 'ivec4'
+ case '1fv':
+ return 'float'
+ case '2fv':
+ return 'vec2'
+ case '3fv':
+ return 'vec3'
+ case '4fv':
+ return 'vec4'
+ case 'Matrix2fv':
+ return 'mat2'
+ case 'Matrix3fv':
+ return 'mat3'
+ case 'Matrix4fv':
+ return 'mat4'
+ default:
+ console.error(log(`The uniform type "${t}" is not valid, please make sure your uniform type is valid`))
+ }
+}
+
+const LinearFilter = 9729
+const NearestFilter = 9728
+const LinearMipMapLinearFilter = 9987
+const NearestMipMapLinearFilter = 9986
+const LinearMipMapNearestFilter = 9985
+const NearestMipMapNearestFilter = 9984
+const MirroredRepeatWrapping = 33648
+const ClampToEdgeWrapping = 33071
+const RepeatWrapping = 10497
+
+class Texture {
+ gl: WebGLRenderingContext
+ url?: string
+ wrapS?: number
+ wrapT?: number
+ minFilter?: number
+ magFilter?: number
+ source?: HTMLImageElement | HTMLVideoElement
+ pow2canvas?: HTMLCanvasElement
+ isLoaded = false
+ isVideo = false
+ flipY = -1
+ width = 0
+ height = 0
+ _webglTexture: WebGLTexture | null = null
+ constructor(gl: WebGLRenderingContext) {
+ this.gl = gl
+ }
+ updateTexture = (texture: WebGLTexture, video: HTMLVideoElement, flipY: number) => {
+ const { gl } = this
+ const level = 0
+ const internalFormat = gl.RGBA
+ const srcFormat = gl.RGBA
+ const srcType = gl.UNSIGNED_BYTE
+ gl.bindTexture(gl.TEXTURE_2D, texture)
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY)
+ gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, srcFormat, srcType, video)
+ }
+ setupVideo = (url: string) => {
+ const video = document.createElement('video')
+ let playing = false
+ let timeupdate = false
+ video.autoplay = true
+ video.muted = true
+ video.loop = true
+ video.crossOrigin = 'anonymous'
+ const checkReady = () => {
+ if (playing && timeupdate) {
+ this.isLoaded = true
+ }
+ }
+ video.addEventListener(
+ 'playing',
+ () => {
+ playing = true
+ this.width = video.videoWidth || 0
+ this.height = video.videoHeight || 0
+ checkReady()
+ },
+ true
+ )
+ video.addEventListener(
+ 'timeupdate',
+ () => {
+ timeupdate = true
+ checkReady()
+ },
+ true
+ )
+ video.src = url
+ return video
+ }
+ makePowerOf2 = (image: T): T => {
+ if (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || image instanceof ImageBitmap) {
+ if (this.pow2canvas === undefined) this.pow2canvas = document.createElement('canvas')
+ this.pow2canvas.width = 2 ** Math.floor(Math.log(image.width) / Math.LN2)
+ this.pow2canvas.height = 2 ** Math.floor(Math.log(image.height) / Math.LN2)
+ const context = this.pow2canvas.getContext('2d')
+ context?.drawImage(image, 0, 0, this.pow2canvas.width, this.pow2canvas.height)
+ console.warn(
+ log(
+ `Image is not power of two ${image.width} x ${image.height}. Resized to ${this.pow2canvas.width} x ${this.pow2canvas.height};`
+ )
+ )
+ return this.pow2canvas as T
+ }
+ return image
+ }
+ load = async (textureArgs: TextureParams) => {
+ const { gl } = this
+ const { url, wrapS, wrapT, minFilter, magFilter, flipY = -1 }: TextureParams = textureArgs
+ if (!url) {
+ return Promise.reject(
+ new Error(log('Missing url, please make sure to pass the url of your texture { url: ... }'))
+ )
+ }
+ const isImage = /(\.jpg|\.jpeg|\.png|\.gif|\.bmp)$/i.exec(url)
+ const isVideo = /(\.mp4|\.3gp|\.webm|\.ogv)$/i.exec(url)
+ if (isImage === null && isVideo === null) {
+ return Promise.reject(new Error(log(`Please upload a video or an image with a valid format (url: ${url})`)))
+ }
+ Object.assign(this, { url, wrapS, wrapT, minFilter, magFilter, flipY })
+ const level = 0
+ const internalFormat = gl.RGBA
+ const width = 1
+ const height = 1
+ const border = 0
+ const srcFormat = gl.RGBA
+ const srcType = gl.UNSIGNED_BYTE
+ const pixel = new Uint8Array([255, 255, 255, 0])
+ const texture = gl.createTexture()
+ gl.bindTexture(gl.TEXTURE_2D, texture)
+ gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, width, height, border, srcFormat, srcType, pixel)
+ if (isVideo) {
+ const video = this.setupVideo(url)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
+ this._webglTexture = texture
+ this.source = video
+ this.isVideo = true
+ return video.play().then(() => this)
+ }
+ async function loadImage() {
+ return new Promise((resolve, reject) => {
+ const image = new Image()
+ image.crossOrigin = 'anonymous'
+ image.onload = () => {
+ resolve(image)
+ }
+ image.onerror = () => {
+ reject(new Error(log(`failed loading url: ${url}`)))
+ }
+ image.src = url ?? ''
+ })
+ }
+ let image = (await loadImage()) as HTMLImageElement
+ let isPowerOf2 = (image.width & (image.width - 1)) === 0 && (image.height & (image.height - 1)) === 0
+ if (
+ (textureArgs.wrapS !== ClampToEdgeWrapping ||
+ textureArgs.wrapT !== ClampToEdgeWrapping ||
+ (textureArgs.minFilter !== NearestFilter && textureArgs.minFilter !== LinearFilter)) &&
+ !isPowerOf2
+ ) {
+ image = this.makePowerOf2(image)
+ isPowerOf2 = true
+ }
+ gl.bindTexture(gl.TEXTURE_2D, texture)
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY)
+ gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, srcFormat, srcType, image)
+ if (isPowerOf2 && textureArgs.minFilter !== NearestFilter && textureArgs.minFilter !== LinearFilter) {
+ gl.generateMipmap(gl.TEXTURE_2D)
+ }
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, this.wrapS || RepeatWrapping)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, this.wrapT || RepeatWrapping)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this.minFilter || LinearMipMapLinearFilter)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, this.magFilter || LinearFilter)
+ this._webglTexture = texture
+ this.source = image
+ this.isVideo = false
+ this.isLoaded = true
+ this.width = image.width || 0
+ this.height = image.height || 0
+ return this
+ }
+}
+
+const log = (text: string) => `react-shaders: ${text}`
+
+const latestPointerClientCoords = (e: MouseEvent | TouchEvent) => {
+ if ('changedTouches' in e) {
+ const t = e.changedTouches[0]
+ return [t?.clientX ?? 0, t?.clientY ?? 0]
+ }
+ return [e.clientX, e.clientY]
+}
+
+const lerpVal = (v0: number, v1: number, t: number) => v0 * (1 - t) + v1 * t
+const insertStringAtIndex = (currentString: string, string: string, index: number) =>
+ index > 0
+ ? currentString.substring(0, index) + string + currentString.substring(index, currentString.length)
+ : string + currentString
+
+type Uniform = { type: string; value: number[] | number }
+type Uniforms = Record
+type TextureParams = {
+ url: string
+ wrapS?: number
+ wrapT?: number
+ minFilter?: number
+ magFilter?: number
+ flipY?: number
+}
+
+export interface ReactShaderToyProps {
+ /** Fragment shader GLSL code. */
+ fs: string
+
+ /** Vertex shader GLSL code. */
+ vs?: string
+
+ /**
+ * Textures to be passed to the shader. Textures need to be squared or will be
+ * automatically resized.
+ *
+ * Options default to:
+ *
+ * ```js
+ * {
+ * minFilter: LinearMipMapLinearFilter,
+ * magFilter: LinearFilter,
+ * wrapS: RepeatWrapping,
+ * wrapT: RepeatWrapping,
+ * }
+ * ```
+ *
+ * See [textures in the docs](https://rysana.com/docs/react-shaders#textures)
+ * for details.
+ */
+ textures?: TextureParams[]
+
+ /**
+ * Custom uniforms to be passed to the shader.
+ *
+ * See [custom uniforms in the
+ * docs](https://rysana.com/docs/react-shaders#custom-uniforms) for details.
+ */
+ uniforms?: Uniforms
+
+ /**
+ * Color used when clearing the canvas.
+ *
+ * See [the WebGL
+ * docs](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/clearColor)
+ * for details.
+ */
+ clearColor?: Vector4
+
+ /**
+ * GLSL precision qualifier. Defaults to `'highp'`. Balance between
+ * performance and quality.
+ */
+ precision?: 'highp' | 'lowp' | 'mediump'
+
+ /** Custom inline style for canvas. */
+ style?: React.CSSProperties
+
+ /** Customize WebGL context attributes. See [the WebGL docs](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/getContextAttributes) for details. */
+ contextAttributes?: Record
+
+ /** Lerp value for `iMouse` built-in uniform. Must be between 0 and 1. */
+ lerp?: number
+
+ /** Device pixel ratio. */
+ devicePixelRatio?: number
+
+ /**
+ * Callback for when the textures are done loading. Useful if you want to do
+ * something like e.g. hide the canvas until textures are done loading.
+ */
+ onDoneLoadingTextures?: () => void
+
+ /** Custom callback to handle errors. Defaults to `console.error`. */
+ onError?: (error: string) => void
+
+ /** 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({
+ fs,
+ vs = BASIC_VS,
+ textures = [],
+ uniforms: propUniforms,
+ clearColor = [0, 0, 0, 1],
+ precision = 'highp',
+ style,
+ contextAttributes = {},
+ lerp = 1,
+ devicePixelRatio = 1,
+ onDoneLoadingTextures,
+ onError = console.error,
+ onWarning = console.warn,
+ animateWhenNotVisible = false,
+ ...canvasProps
+}: ReactShaderToyProps & ComponentPropsWithoutRef<'canvas'>) {
+ // Refs for WebGL state
+ const canvasRef = useRef(null)
+ const glRef = useRef(null)
+ const squareVerticesBufferRef = useRef(null)
+ 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)
+ const lastMouseArrRef = useRef([0, 0])
+ const texturesArrRef = useRef([])
+ const lastTimeRef = useRef(0)
+ const resizeObserverRef = useRef(undefined)
+ const uniformsRef = useRef<
+ Record
+ >({
+ [UNIFORM_TIME]: { type: 'float', isNeeded: false, value: 0 },
+ [UNIFORM_TIMEDELTA]: { type: 'float', isNeeded: false, value: 0 },
+ [UNIFORM_DATE]: { type: 'vec4', isNeeded: false, value: [0, 0, 0, 0] },
+ [UNIFORM_MOUSE]: { type: 'vec4', isNeeded: false, value: [0, 0, 0, 0] },
+ [UNIFORM_RESOLUTION]: { type: 'vec2', isNeeded: false, value: [0, 0] },
+ [UNIFORM_FRAME]: { type: 'int', isNeeded: false, value: 0 },
+ [UNIFORM_DEVICEORIENTATION]: { type: 'vec4', isNeeded: false, value: [0, 0, 0, 0] },
+ })
+ const propsUniformsRef = useRef(propUniforms)
+
+ const setupChannelRes = (texture: TextureParams | Texture, id: number) => {
+ const width = 'width' in texture ? (texture.width ?? 0) : 0
+ const height = 'height' in texture ? (texture.height ?? 0) : 0
+ const channelResUniform = uniformsRef.current.iChannelResolution
+ if (!channelResUniform) return
+ const channelResValue = Array.isArray(channelResUniform.value)
+ ? channelResUniform.value
+ : (channelResUniform.value = [])
+ channelResValue[id * 3] = width * devicePixelRatio
+ channelResValue[id * 3 + 1] = height * devicePixelRatio
+ channelResValue[id * 3 + 2] = 0
+ }
+
+ const initWebGL = () => {
+ if (!canvasRef.current) return
+ glRef.current = (canvasRef.current.getContext('webgl', contextAttributes) ||
+ canvasRef.current.getContext('experimental-webgl', contextAttributes)) as WebGLRenderingContext | null
+ glRef.current?.getExtension('OES_standard_derivatives')
+ glRef.current?.getExtension('EXT_shader_texture_lod')
+ }
+
+ const initBuffers = () => {
+ const gl = glRef.current
+ squareVerticesBufferRef.current = gl?.createBuffer() ?? null
+ gl?.bindBuffer(gl.ARRAY_BUFFER, squareVerticesBufferRef.current)
+ const vertices = [1.0, 1.0, 0.0, -1.0, 1.0, 0.0, 1.0, -1.0, 0.0, -1.0, -1.0, 0.0]
+ gl?.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW)
+ }
+
+ const onDeviceOrientationChange = ({ alpha, beta, gamma }: DeviceOrientationEvent) => {
+ uniformsRef.current.iDeviceOrientation!.value = [alpha ?? 0, beta ?? 0, gamma ?? 0, window.orientation ?? 0]
+ }
+
+ const mouseDown = (e: MouseEvent | TouchEvent) => {
+ const [clientX = 0, clientY = 0] = latestPointerClientCoords(e)
+ const mouseX = clientX - (canvasPositionRef.current?.left ?? 0) - window.pageXOffset
+ const mouseY =
+ (canvasPositionRef.current?.height ?? 0) - clientY - (canvasPositionRef.current?.top ?? 0) - window.pageYOffset
+ mousedownRef.current = true
+ const mouseValue = Array.isArray(uniformsRef.current.iMouse?.value) ? uniformsRef.current.iMouse.value : undefined
+ if (mouseValue) {
+ mouseValue[2] = mouseX
+ mouseValue[3] = mouseY
+ }
+ lastMouseArrRef.current[0] = mouseX
+ lastMouseArrRef.current[1] = mouseY
+ }
+
+ const mouseMove = (e: MouseEvent | TouchEvent) => {
+ canvasPositionRef.current = canvasRef.current?.getBoundingClientRect()
+ const [clientX = 0, clientY = 0] = latestPointerClientCoords(e)
+ const mouseX = clientX - (canvasPositionRef.current?.left ?? 0)
+ const mouseY = (canvasPositionRef.current?.height ?? 0) - clientY - (canvasPositionRef.current?.top ?? 0)
+ if (lerp !== 1) {
+ lastMouseArrRef.current[0] = mouseX
+ lastMouseArrRef.current[1] = mouseY
+ } else {
+ const mouseValue = Array.isArray(uniformsRef.current.iMouse?.value) ? uniformsRef.current.iMouse.value : undefined
+ if (mouseValue) {
+ mouseValue[0] = mouseX
+ mouseValue[1] = mouseY
+ }
+ }
+ }
+
+ const mouseUp = () => {
+ const mouseValue = Array.isArray(uniformsRef.current.iMouse?.value) ? uniformsRef.current.iMouse.value : undefined
+ if (mouseValue) {
+ mouseValue[2] = 0
+ mouseValue[3] = 0
+ }
+ }
+
+ const onResize = () => {
+ const gl = glRef.current
+ if (!gl) return
+ canvasPositionRef.current = canvasRef.current?.getBoundingClientRect()
+ // Force pixel ratio to be one to avoid expensive calculus on retina display.
+ const realToCSSPixels = devicePixelRatio
+ const displayWidth = Math.floor((canvasPositionRef.current?.width ?? 1) * realToCSSPixels)
+ const displayHeight = Math.floor((canvasPositionRef.current?.height ?? 1) * realToCSSPixels)
+ gl.canvas.width = displayWidth
+ gl.canvas.height = displayHeight
+ if (uniformsRef.current.iResolution?.isNeeded && shaderProgramRef.current) {
+ const rUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_RESOLUTION)
+ gl.uniform2fv(rUniform, [gl.canvas.width, gl.canvas.height])
+ }
+ }
+
+ const createShader = (type: number, shaderCodeAsText: string) => {
+ const gl = glRef.current
+ if (!gl) return null
+ const shader = gl.createShader(type)
+ if (!shader) return null
+ gl.shaderSource(shader, shaderCodeAsText)
+ gl.compileShader(shader)
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ onWarning?.(log(`Error compiling the shader:\n${shaderCodeAsText}`))
+ const compilationLog = gl.getShaderInfoLog(shader)
+ gl.deleteShader(shader)
+ onError?.(log(`Shader compiler log: ${compilationLog}`))
+ }
+ return shader
+ }
+
+ const initShaders = (fragmentShader: string, vertexShader: string) => {
+ const gl = glRef.current
+ if (!gl) return
+ const fragmentShaderObj = createShader(gl.FRAGMENT_SHADER, fragmentShader)
+ const vertexShaderObj = createShader(gl.VERTEX_SHADER, vertexShader)
+ shaderProgramRef.current = gl.createProgram()
+ if (!shaderProgramRef.current || !vertexShaderObj || !fragmentShaderObj) return
+ gl.attachShader(shaderProgramRef.current, vertexShaderObj)
+ gl.attachShader(shaderProgramRef.current, fragmentShaderObj)
+ gl.linkProgram(shaderProgramRef.current)
+ if (!gl.getProgramParameter(shaderProgramRef.current, gl.LINK_STATUS)) {
+ onError?.(log(`Unable to initialize the shader program: ${gl.getProgramInfoLog(shaderProgramRef.current)}`))
+ return
+ }
+ gl.useProgram(shaderProgramRef.current)
+ vertexPositionAttributeRef.current = gl.getAttribLocation(shaderProgramRef.current, 'aVertexPosition')
+ gl.enableVertexAttribArray(vertexPositionAttributeRef.current)
+ }
+
+ const processCustomUniforms = () => {
+ if (propUniforms) {
+ for (const name of Object.keys(propUniforms)) {
+ const uniform = propUniforms[name]
+ if (!uniform) continue
+ const { value, type } = uniform
+ const glslType = uniformTypeToGLSLType(type)
+ if (!glslType) continue
+ const tempObject: { arraySize?: string } = {}
+ if (isMatrixType(type, value)) {
+ const arrayLength = type.length
+ const val = Number.parseInt(type.charAt(arrayLength - 3))
+ const numberOfMatrices = Math.floor(value.length / (val * val))
+ if (value.length > val * val) tempObject.arraySize = `[${numberOfMatrices}]`
+ } else if (isVectorListType(type, value)) {
+ tempObject.arraySize = `[${Math.floor(value.length / Number.parseInt(type.charAt(0)))}]`
+ }
+ uniformsRef.current[name] = { type: glslType, isNeeded: false, value, ...tempObject }
+ }
+ }
+ }
+
+ const processTextures = () => {
+ const gl = glRef.current
+ if (!gl) return
+ if (textures && textures.length > 0) {
+ uniformsRef.current[`${UNIFORM_CHANNELRESOLUTION}`] = {
+ type: 'vec3',
+ isNeeded: false,
+ arraySize: `[${textures.length}]`,
+ value: [],
+ }
+ const texturePromisesArr = textures.map((texture: TextureParams, id: number) => {
+ // Dynamically add textures uniforms.
+ uniformsRef.current[`${UNIFORM_CHANNEL}${id}`] = {
+ type: 'sampler2D',
+ isNeeded: false,
+ }
+ setupChannelRes(texture, id)
+ texturesArrRef.current[id] = new Texture(gl)
+ return texturesArrRef.current[id]?.load(texture).then((t: Texture) => {
+ setupChannelRes(t, id)
+ })
+ })
+ Promise.all(texturePromisesArr)
+ .then(() => {
+ if (onDoneLoadingTextures) onDoneLoadingTextures()
+ })
+ .catch((e) => {
+ onError?.(e)
+ if (onDoneLoadingTextures) onDoneLoadingTextures()
+ })
+ } else if (onDoneLoadingTextures) onDoneLoadingTextures()
+ }
+
+ const preProcessFragment = (fragment: string) => {
+ const isValidPrecision = PRECISIONS.includes(precision ?? 'highp')
+ const precisionString = `precision ${isValidPrecision ? precision : PRECISIONS[1]} float;\n`
+ if (!isValidPrecision) {
+ onWarning?.(
+ log(
+ `wrong precision type ${precision}, please make sure to pass one of a valid precision lowp, mediump, highp, by default you shader precision will be set to highp.`
+ )
+ )
+ }
+ let fragmentShader = precisionString
+ .concat(`#define DPR ${devicePixelRatio.toFixed(1)}\n`)
+ .concat(fragment.replace(/texture\(/g, 'texture2D('))
+ for (const uniform of Object.keys(uniformsRef.current)) {
+ if (fragment.includes(uniform)) {
+ const u = uniformsRef.current[uniform]
+ if (!u) continue
+ fragmentShader = insertStringAtIndex(
+ fragmentShader,
+ `uniform ${u.type} ${uniform}${u.arraySize || ''}; \n`,
+ fragmentShader.lastIndexOf(precisionString) + precisionString.length
+ )
+ u.isNeeded = true
+ }
+ }
+ const isShadertoy = fragment.includes('mainImage')
+ if (isShadertoy) fragmentShader = fragmentShader.concat(FS_MAIN_SHADER)
+ return fragmentShader
+ }
+
+ const setUniforms = (timestamp: number) => {
+ const gl = glRef.current
+ if (!gl || !shaderProgramRef.current) return
+ const delta = lastTimeRef.current ? (timestamp - lastTimeRef.current) / 1000 : 0
+ lastTimeRef.current = timestamp
+ const propUniforms = propsUniformsRef.current
+ if (propUniforms) {
+ for (const name of Object.keys(propUniforms)) {
+ const currentUniform = propUniforms[name]
+ if (!currentUniform) continue
+ if (uniformsRef.current[name]?.isNeeded) {
+ if (!shaderProgramRef.current) return
+ const customUniformLocation = gl.getUniformLocation(shaderProgramRef.current, name)
+ if (!customUniformLocation) return
+ processUniform(gl, customUniformLocation, currentUniform.type as UniformType, currentUniform.value)
+ }
+ }
+ }
+ if (uniformsRef.current.iMouse?.isNeeded) {
+ const mouseUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_MOUSE)
+ gl.uniform4fv(mouseUniform, uniformsRef.current.iMouse.value as number[])
+ }
+ if (uniformsRef.current.iChannelResolution?.isNeeded) {
+ const channelResUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_CHANNELRESOLUTION)
+ gl.uniform3fv(channelResUniform, uniformsRef.current.iChannelResolution.value as number[])
+ }
+ if (uniformsRef.current.iDeviceOrientation?.isNeeded) {
+ const deviceOrientationUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_DEVICEORIENTATION)
+ gl.uniform4fv(deviceOrientationUniform, uniformsRef.current.iDeviceOrientation.value as number[])
+ }
+ if (uniformsRef.current.iTime?.isNeeded) {
+ const timeUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_TIME)
+ gl.uniform1f(timeUniform, (timerRef.current += delta))
+ }
+ if (uniformsRef.current.iTimeDelta?.isNeeded) {
+ const timeDeltaUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_TIMEDELTA)
+ gl.uniform1f(timeDeltaUniform, delta)
+ }
+ if (uniformsRef.current.iDate?.isNeeded) {
+ const d = new Date()
+ const month = d.getMonth() + 1
+ const day = d.getDate()
+ const year = d.getFullYear()
+ const time = d.getHours() * 60 * 60 + d.getMinutes() * 60 + d.getSeconds() + d.getMilliseconds() * 0.001
+ const dateUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_DATE)
+ gl.uniform4fv(dateUniform, [year, month, day, time])
+ }
+ if (uniformsRef.current.iFrame?.isNeeded) {
+ const timeDeltaUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_FRAME)
+ const frame = uniformsRef.current.iFrame.value as number
+ gl.uniform1i(timeDeltaUniform, frame)
+ uniformsRef.current.iFrame.value = frame + 1
+ }
+ if (texturesArrRef.current.length > 0) {
+ for (let index = 0; index < texturesArrRef.current.length; index++) {
+ const texture = texturesArrRef.current[index]
+ if (!texture) return
+ const { isVideo, _webglTexture, source, flipY, isLoaded } = texture
+ if (!isLoaded || !_webglTexture || !source) return
+ if (uniformsRef.current[`iChannel${index}`]?.isNeeded) {
+ if (!shaderProgramRef.current) return
+ const iChannel = gl.getUniformLocation(shaderProgramRef.current, `iChannel${index}`)
+ gl.activeTexture(gl.TEXTURE0 + index)
+ gl.bindTexture(gl.TEXTURE_2D, _webglTexture)
+ gl.uniform1i(iChannel, index)
+ if (isVideo) {
+ texture.updateTexture(_webglTexture, source as HTMLVideoElement, flipY)
+ }
+ }
+ }
+ }
+ }
+
+ const drawScene = (timestamp: number) => {
+ const gl = glRef.current
+ if (!gl) return
+ gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight)
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
+ gl.bindBuffer(gl.ARRAY_BUFFER, squareVerticesBufferRef.current)
+ gl.vertexAttribPointer(vertexPositionAttributeRef.current ?? 0, 3, gl.FLOAT, false, 0, 0)
+ setUniforms(timestamp)
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
+ const mouseValue = uniformsRef.current.iMouse?.value
+ if (uniformsRef.current.iMouse?.isNeeded && lerp !== 1 && Array.isArray(mouseValue)) {
+ const currentX = mouseValue[0] ?? 0
+ const currentY = mouseValue[1] ?? 0
+ mouseValue[0] = lerpVal(currentX, lastMouseArrRef.current[0] ?? 0, lerp)
+ mouseValue[1] = lerpVal(currentY, lastMouseArrRef.current[1] ?? 0, lerp)
+ }
+ if (animateWhenNotVisibleRef.current || isVisibleRef.current) {
+ animFrameIdRef.current = requestAnimationFrame(drawScene)
+ }
+ }
+
+ const addEventListeners = () => {
+ const options = { passive: true }
+ if (uniformsRef.current.iMouse?.isNeeded && canvasRef.current) {
+ canvasRef.current.addEventListener('mousemove', mouseMove, options)
+ canvasRef.current.addEventListener('mouseout', mouseUp, options)
+ canvasRef.current.addEventListener('mouseup', mouseUp, options)
+ canvasRef.current.addEventListener('mousedown', mouseDown, options)
+ canvasRef.current.addEventListener('touchmove', mouseMove, options)
+ canvasRef.current.addEventListener('touchend', mouseUp, options)
+ canvasRef.current.addEventListener('touchstart', mouseDown, options)
+ }
+ if (uniformsRef.current.iDeviceOrientation?.isNeeded) {
+ window.addEventListener('deviceorientation', onDeviceOrientationChange, options)
+ }
+ if (canvasRef.current) {
+ resizeObserverRef.current = new ResizeObserver(onResize)
+ resizeObserverRef.current.observe(canvasRef.current)
+ window.addEventListener('resize', onResize, options)
+ }
+ }
+
+ const removeEventListeners = () => {
+ const options = { passive: true } as EventListenerOptions
+ if (uniformsRef.current.iMouse?.isNeeded && canvasRef.current) {
+ canvasRef.current.removeEventListener('mousemove', mouseMove, options)
+ canvasRef.current.removeEventListener('mouseout', mouseUp, options)
+ canvasRef.current.removeEventListener('mouseup', mouseUp, options)
+ canvasRef.current.removeEventListener('mousedown', mouseDown, options)
+ canvasRef.current.removeEventListener('touchmove', mouseMove, options)
+ canvasRef.current.removeEventListener('touchend', mouseUp, options)
+ canvasRef.current.removeEventListener('touchstart', mouseDown, options)
+ }
+ if (uniformsRef.current.iDeviceOrientation?.isNeeded) {
+ window.removeEventListener('deviceorientation', onDeviceOrientationChange, options)
+ }
+ if (resizeObserverRef.current) {
+ resizeObserverRef.current.disconnect()
+ window.removeEventListener('resize', onResize, options)
+ }
+ }
+
+ useEffect(() => {
+ 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
+
+ function init() {
+ initWebGL()
+ const gl = glRef.current
+ if (gl && canvasRef.current) {
+ gl.clearColor(...clearColor)
+ gl.clearDepth(1.0)
+ gl.enable(gl.DEPTH_TEST)
+ gl.depthFunc(gl.LEQUAL)
+ gl.viewport(0, 0, canvasRef.current.width, canvasRef.current.height)
+ canvasRef.current.height = canvasRef.current.clientHeight
+ canvasRef.current.width = canvasRef.current.clientWidth
+ processCustomUniforms()
+ processTextures()
+ initShaders(preProcessFragment(fs || BASIC_FS), vs || BASIC_VS)
+ initBuffers()
+ requestAnimationFrame(drawScene)
+ addEventListeners()
+ onResize()
+ }
+ }
+
+ initFrameIdRef.current = requestAnimationFrame(init)
+
+ // Cleanup function
+ return () => {
+ const gl = glRef.current
+ if (gl) {
+ gl.getExtension('WEBGL_lose_context')?.loseContext()
+ gl.useProgram(null)
+ gl.deleteProgram(shaderProgramRef.current ?? null)
+ if (textures.length > 0) {
+ for (const texture of textures as Texture[]) {
+ gl.deleteTexture(texture._webglTexture)
+ }
+ }
+ shaderProgramRef.current = null
+ }
+ removeEventListeners()
+ cancelAnimationFrame(initFrameIdRef.current ?? 0)
+ cancelAnimationFrame(animFrameIdRef.current ?? 0)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []) // Empty dependency array to run only once on mount
+
+ return
+}
diff --git a/src/components/agents-ui/start-audio-button.tsx b/src/components/agents-ui/start-audio-button.tsx
new file mode 100644
index 0000000..0eb702a
--- /dev/null
+++ b/src/components/agents-ui/start-audio-button.tsx
@@ -0,0 +1,57 @@
+import { type ComponentProps } from 'react'
+import { useEnsureRoom, useStartAudio } from '@livekit/components-react'
+import { Button } from '@/components/ui/button'
+import { Room } from 'livekit-client'
+
+/**
+ * Props for the StartAudioButton component.
+ */
+export interface StartAudioButtonProps extends ComponentProps<'button'> {
+ /**
+ * The size of the button.
+ * @defaultValue 'default'
+ */
+ size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg'
+ /**
+ * The variant of the button.
+ * @defaultValue 'default'
+ */
+ variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
+ /**
+ * The LiveKit room instance. If not provided, uses the room from context.
+ */
+ room?: Room
+ /**
+ * The label text to display on the button.
+ */
+ label: string
+}
+
+/**
+ * A button that allows users to start audio playback.
+ * Required for browsers that block autoplay of audio.
+ * Only renders when audio playback is blocked.
+ *
+ * @extends ComponentProps<'button'>
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function StartAudioButton({
+ size = 'default',
+ variant = 'default',
+ label,
+ room,
+ ...props
+}: StartAudioButtonProps) {
+ const roomEnsured = useEnsureRoom(room)
+ const { mergedProps } = useStartAudio({ room: roomEnsured, props })
+
+ return (
+
+ )
+}
diff --git a/src/components/ai-elements/conversation.tsx b/src/components/ai-elements/conversation.tsx
new file mode 100644
index 0000000..2c12b61
--- /dev/null
+++ b/src/components/ai-elements/conversation.tsx
@@ -0,0 +1,142 @@
+'use client'
+
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+import type { UIMessage } from 'ai'
+import { ArrowDownIcon, DownloadIcon } from 'lucide-react'
+import type { ComponentProps } from 'react'
+import { useCallback } from 'react'
+import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
+
+export type ConversationProps = ComponentProps
+
+export const Conversation = ({ className, ...props }: ConversationProps) => (
+
+)
+
+export type ConversationContentProps = ComponentProps
+
+export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
+
+)
+
+export type ConversationEmptyStateProps = ComponentProps<'div'> & {
+ title?: string
+ description?: string
+ icon?: React.ReactNode
+}
+
+export const ConversationEmptyState = ({
+ className,
+ title = 'No messages yet',
+ description = 'Start a conversation to see messages here',
+ icon,
+ children,
+ ...props
+}: ConversationEmptyStateProps) => (
+
+ {children ?? (
+ <>
+ {icon &&
{icon}
}
+
+
{title}
+ {description &&
{description}
}
+
+ >
+ )}
+
+)
+
+export type ConversationScrollButtonProps = ComponentProps
+
+export const ConversationScrollButton = ({ className, ...props }: ConversationScrollButtonProps) => {
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext()
+
+ const handleScrollToBottom = useCallback(() => {
+ scrollToBottom()
+ }, [scrollToBottom])
+
+ return (
+ !isAtBottom && (
+
+ )
+ )
+}
+
+const getMessageText = (message: UIMessage): string =>
+ message.parts
+ .filter((part) => part.type === 'text')
+ .map((part) => part.text)
+ .join('')
+
+export type ConversationDownloadProps = Omit, 'onClick'> & {
+ messages: UIMessage[]
+ filename?: string
+ formatMessage?: (message: UIMessage, index: number) => string
+}
+
+const defaultFormatMessage = (message: UIMessage): string => {
+ const roleLabel = message.role.charAt(0).toUpperCase() + message.role.slice(1)
+ return `**${roleLabel}:** ${getMessageText(message)}`
+}
+
+export const messagesToMarkdown = (
+ messages: UIMessage[],
+ formatMessage: (message: UIMessage, index: number) => string = defaultFormatMessage
+): string => messages.map((msg, i) => formatMessage(msg, i)).join('\n\n')
+
+export const ConversationDownload = ({
+ messages,
+ filename = 'conversation.md',
+ formatMessage = defaultFormatMessage,
+ className,
+ children,
+ ...props
+}: ConversationDownloadProps) => {
+ const handleDownload = useCallback(() => {
+ const markdown = messagesToMarkdown(messages, formatMessage)
+ const blob = new Blob([markdown], { type: 'text/markdown' })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = filename
+ document.body.append(link)
+ link.click()
+ link.remove()
+ URL.revokeObjectURL(url)
+ }, [messages, filename, formatMessage])
+
+ return (
+
+ )
+}
diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx
new file mode 100644
index 0000000..7a92232
--- /dev/null
+++ b/src/components/ai-elements/message.tsx
@@ -0,0 +1,280 @@
+'use client'
+
+import { Button } from '@/components/ui/button'
+import { ButtonGroup, ButtonGroupText } from '@/components/ui/button-group'
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import { cjk } from '@streamdown/cjk'
+import { code } from '@streamdown/code'
+import { math } from '@streamdown/math'
+import { mermaid } from '@streamdown/mermaid'
+import type { UIMessage } from 'ai'
+import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
+import type { ComponentProps, HTMLAttributes, ReactElement } from 'react'
+import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'
+import { Streamdown } from 'streamdown'
+
+export type MessageProps = HTMLAttributes & {
+ from: UIMessage['role']
+}
+
+export const Message = ({ className, from, ...props }: MessageProps) => (
+
+)
+
+export type MessageContentProps = HTMLAttributes
+
+export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
+
+ {children}
+
+)
+
+export type MessageActionsProps = ComponentProps<'div'>
+
+export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
+
+ {children}
+
+)
+
+export type MessageActionProps = ComponentProps & {
+ tooltip?: string
+ label?: string
+}
+
+export const MessageAction = ({
+ tooltip,
+ children,
+ label,
+ variant = 'ghost',
+ size = 'icon-sm',
+ ...props
+}: MessageActionProps) => {
+ const button = (
+
+ )
+
+ if (tooltip) {
+ return (
+
+
+ {button}
+
+ {tooltip}
+
+
+
+ )
+ }
+
+ return button
+}
+
+interface MessageBranchContextType {
+ currentBranch: number
+ totalBranches: number
+ goToPrevious: () => void
+ goToNext: () => void
+ branches: ReactElement[]
+ setBranches: (branches: ReactElement[]) => void
+}
+
+const MessageBranchContext = createContext(null)
+
+const useMessageBranch = () => {
+ const context = useContext(MessageBranchContext)
+
+ if (!context) {
+ throw new Error('MessageBranch components must be used within MessageBranch')
+ }
+
+ return context
+}
+
+export type MessageBranchProps = HTMLAttributes & {
+ defaultBranch?: number
+ onBranchChange?: (branchIndex: number) => void
+}
+
+export const MessageBranch = ({ defaultBranch = 0, onBranchChange, className, ...props }: MessageBranchProps) => {
+ const [currentBranch, setCurrentBranch] = useState(defaultBranch)
+ const [branches, setBranches] = useState([])
+
+ const handleBranchChange = useCallback(
+ (newBranch: number) => {
+ setCurrentBranch(newBranch)
+ onBranchChange?.(newBranch)
+ },
+ [onBranchChange]
+ )
+
+ const goToPrevious = useCallback(() => {
+ const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1
+ handleBranchChange(newBranch)
+ }, [currentBranch, branches.length, handleBranchChange])
+
+ const goToNext = useCallback(() => {
+ const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0
+ handleBranchChange(newBranch)
+ }, [currentBranch, branches.length, handleBranchChange])
+
+ const contextValue = useMemo(
+ () => ({
+ branches,
+ currentBranch,
+ goToNext,
+ goToPrevious,
+ setBranches,
+ totalBranches: branches.length,
+ }),
+ [branches, currentBranch, goToNext, goToPrevious]
+ )
+
+ return (
+
+ div]:pb-0', className)} {...props} />
+
+ )
+}
+
+export type MessageBranchContentProps = HTMLAttributes
+
+export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => {
+ const { currentBranch, setBranches, branches } = useMessageBranch()
+ const childrenArray = useMemo(() => (Array.isArray(children) ? children : [children]), [children])
+
+ // Use useEffect to update branches when they change
+ useEffect(() => {
+ if (branches.length !== childrenArray.length) {
+ setBranches(childrenArray)
+ }
+ }, [childrenArray, branches, setBranches])
+
+ return childrenArray.map((branch, index) => (
+ div]:pb-0', index === currentBranch ? 'block' : 'hidden')}
+ key={branch.key}
+ {...props}
+ >
+ {branch}
+
+ ))
+}
+
+export type MessageBranchSelectorProps = ComponentProps
+
+export const MessageBranchSelector = ({ className, ...props }: MessageBranchSelectorProps) => {
+ const { totalBranches } = useMessageBranch()
+
+ // Don't render if there's only one branch
+ if (totalBranches <= 1) {
+ return null
+ }
+
+ return (
+ *:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md', className)}
+ orientation="horizontal"
+ {...props}
+ />
+ )
+}
+
+export type MessageBranchPreviousProps = ComponentProps
+
+export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => {
+ const { goToPrevious, totalBranches } = useMessageBranch()
+
+ return (
+
+ )
+}
+
+export type MessageBranchNextProps = ComponentProps
+
+export const MessageBranchNext = ({ children, ...props }: MessageBranchNextProps) => {
+ const { goToNext, totalBranches } = useMessageBranch()
+
+ return (
+
+ )
+}
+
+export type MessageBranchPageProps = HTMLAttributes
+
+export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => {
+ const { currentBranch, totalBranches } = useMessageBranch()
+
+ return (
+
+ {currentBranch + 1} of {totalBranches}
+
+ )
+}
+
+export type MessageResponseProps = ComponentProps
+
+const streamdownPlugins = { cjk, code, math, mermaid }
+
+export const MessageResponse = memo(
+ ({ className, ...props }: MessageResponseProps) => (
+ *:first-child]:mt-0 [&>*:last-child]:mb-0', className)}
+ plugins={streamdownPlugins}
+ {...props}
+ />
+ ),
+ (prevProps, nextProps) => prevProps.children === nextProps.children && nextProps.isAnimating === prevProps.isAnimating
+)
+
+MessageResponse.displayName = 'MessageResponse'
+
+export type MessageToolbarProps = ComponentProps<'div'>
+
+export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => (
+
+ {children}
+
+)
diff --git a/src/components/ai-elements/shimmer.tsx b/src/components/ai-elements/shimmer.tsx
new file mode 100644
index 0000000..934a68d
--- /dev/null
+++ b/src/components/ai-elements/shimmer.tsx
@@ -0,0 +1,62 @@
+'use client'
+
+import { cn } from '@/lib/utils'
+import type { MotionProps } from 'motion/react'
+import { motion } from 'motion/react'
+import type { CSSProperties, ElementType, JSX } from 'react'
+import { memo, useMemo } from 'react'
+
+type MotionHTMLProps = MotionProps & Record
+
+// Cache motion components at module level to avoid creating during render
+const motionComponentCache = new Map>()
+
+const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
+ let component = motionComponentCache.get(element)
+ if (!component) {
+ component = motion.create(element)
+ motionComponentCache.set(element, component)
+ }
+ return component
+}
+
+export interface TextShimmerProps {
+ children: string
+ as?: ElementType
+ className?: string
+ duration?: number
+ spread?: number
+}
+
+const ShimmerComponent = ({ children, as: Component = 'p', className, duration = 2, spread = 2 }: TextShimmerProps) => {
+ const MotionComponent = getMotionComponent(Component as keyof JSX.IntrinsicElements)
+
+ const dynamicSpread = useMemo(() => (children?.length ?? 0) * spread, [children, spread])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const Shimmer = memo(ShimmerComponent)
diff --git a/src/components/ui/button-group.tsx b/src/components/ui/button-group.tsx
new file mode 100644
index 0000000..b3f8de1
--- /dev/null
+++ b/src/components/ui/button-group.tsx
@@ -0,0 +1,78 @@
+import { mergeProps } from '@base-ui/react/merge-props'
+import { useRender } from '@base-ui/react/use-render'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+import { Separator } from '@/components/ui/separator'
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ '*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
+ vertical:
+ 'flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0',
+ },
+ },
+ defaultVariants: {
+ orientation: 'horizontal',
+ },
+ }
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({ className, render, ...props }: useRender.ComponentProps<'div'>) {
+ return useRender({
+ defaultTagName: 'div',
+ props: mergeProps<'div'>(
+ {
+ className: cn(
+ "flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: 'button-group-text',
+ },
+ })
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants }
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..4355b49
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,158 @@
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx
new file mode 100644
index 0000000..8242639
--- /dev/null
+++ b/src/components/ui/drawer.tsx
@@ -0,0 +1,132 @@
+import * as React from "react"
+import { Drawer as DrawerPrimitive } from "vaul"
+
+import { cn } from "@/lib/utils"
+
+function Drawer({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DrawerTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DrawerPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DrawerClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DrawerOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DrawerContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+ )
+}
+
+function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DrawerTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DrawerDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Drawer,
+ DrawerPortal,
+ DrawerOverlay,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+}
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..e8021f5
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..4f65961
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,23 @@
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..78c0a76
--- /dev/null
+++ b/src/components/ui/sheet.tsx
@@ -0,0 +1,138 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Sheet({ ...props }: SheetPrimitive.Root.Props) {
+ return
+}
+
+function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
+ return
+}
+
+function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
+ return
+}
+
+function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
+ return
+}
+
+function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: SheetPrimitive.Popup.Props & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: SheetPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx
new file mode 100644
index 0000000..555637b
--- /dev/null
+++ b/src/components/ui/toggle.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const toggleVariants = cva(
+ "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline: "border border-input bg-transparent hover:bg-muted",
+ },
+ size: {
+ default:
+ "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Toggle({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: TogglePrimitive.Props & VariantProps) {
+ return (
+
+ )
+}
+
+export { Toggle, toggleVariants }
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..96a9ec2
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,64 @@
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delay = 0,
+ ...props
+}: TooltipPrimitive.Provider.Props) {
+ return (
+
+ )
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+ return
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ return
+}
+
+function TooltipContent({
+ className,
+ side = "top",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ children,
+ ...props
+}: TooltipPrimitive.Popup.Props &
+ Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/src/components/theme-provider.tsx b/src/contexts/ThemeProvider.tsx
similarity index 66%
rename from src/components/theme-provider.tsx
rename to src/contexts/ThemeProvider.tsx
index 1349a0c..5246242 100644
--- a/src/components/theme-provider.tsx
+++ b/src/contexts/ThemeProvider.tsx
@@ -1,8 +1,8 @@
/* eslint-disable react-refresh/only-export-components */
-import * as React from "react"
+import * as React from 'react'
-type Theme = "dark" | "light" | "system"
-type ResolvedTheme = "dark" | "light"
+type Theme = 'dark' | 'light' | 'system'
+type ResolvedTheme = 'dark' | 'light'
type ThemeProviderProps = {
children: React.ReactNode
@@ -16,12 +16,10 @@ type ThemeProviderState = {
setTheme: (theme: Theme) => void
}
-const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"
-const THEME_VALUES: Theme[] = ["dark", "light", "system"]
+const COLOR_SCHEME_QUERY = '(prefers-color-scheme: dark)'
+const THEME_VALUES: Theme[] = ['dark', 'light', 'system']
-const ThemeProviderContext = React.createContext<
- ThemeProviderState | undefined
->(undefined)
+const ThemeProviderContext = React.createContext(undefined)
function isTheme(value: string | null): value is Theme {
if (value === null) {
@@ -33,18 +31,16 @@ function isTheme(value: string | null): value is Theme {
function getSystemTheme(): ResolvedTheme {
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
- return "dark"
+ return 'dark'
}
- return "light"
+ return 'light'
}
function disableTransitionsTemporarily() {
- const style = document.createElement("style")
+ const style = document.createElement('style')
style.appendChild(
- document.createTextNode(
- "*,*::before,*::after{-webkit-transition:none!important;transition:none!important}"
- )
+ document.createTextNode('*,*::before,*::after{-webkit-transition:none!important;transition:none!important}')
)
document.head.appendChild(style)
@@ -67,9 +63,7 @@ function isEditableTarget(target: EventTarget | null) {
return true
}
- const editableParent = target.closest(
- "input, textarea, select, [contenteditable='true']"
- )
+ const editableParent = target.closest("input, textarea, select, [contenteditable='true']")
if (editableParent) {
return true
}
@@ -79,8 +73,8 @@ function isEditableTarget(target: EventTarget | null) {
export function ThemeProvider({
children,
- defaultTheme = "system",
- storageKey = "theme",
+ defaultTheme = 'system',
+ storageKey = 'theme',
disableTransitionOnChange = true,
...props
}: ThemeProviderProps) {
@@ -104,13 +98,10 @@ export function ThemeProvider({
const applyTheme = React.useCallback(
(nextTheme: Theme) => {
const root = document.documentElement
- const resolvedTheme =
- nextTheme === "system" ? getSystemTheme() : nextTheme
- const restoreTransitions = disableTransitionOnChange
- ? disableTransitionsTemporarily()
- : null
+ const resolvedTheme = nextTheme === 'system' ? getSystemTheme() : nextTheme
+ const restoreTransitions = disableTransitionOnChange ? disableTransitionsTemporarily() : null
- root.classList.remove("light", "dark")
+ root.classList.remove('light', 'dark')
root.classList.add(resolvedTheme)
if (restoreTransitions) {
@@ -123,19 +114,19 @@ export function ThemeProvider({
React.useEffect(() => {
applyTheme(theme)
- if (theme !== "system") {
+ if (theme !== 'system') {
return undefined
}
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY)
const handleChange = () => {
- applyTheme("system")
+ applyTheme('system')
}
- mediaQuery.addEventListener("change", handleChange)
+ mediaQuery.addEventListener('change', handleChange)
return () => {
- mediaQuery.removeEventListener("change", handleChange)
+ mediaQuery.removeEventListener('change', handleChange)
}
}, [theme, applyTheme])
@@ -153,29 +144,29 @@ export function ThemeProvider({
return
}
- if (event.key.toLowerCase() !== "d") {
+ if (event.key.toLowerCase() !== 'd') {
return
}
setThemeState((currentTheme) => {
const nextTheme =
- currentTheme === "dark"
- ? "light"
- : currentTheme === "light"
- ? "dark"
- : getSystemTheme() === "dark"
- ? "light"
- : "dark"
+ currentTheme === 'dark'
+ ? 'light'
+ : currentTheme === 'light'
+ ? 'dark'
+ : getSystemTheme() === 'dark'
+ ? 'light'
+ : 'dark'
localStorage.setItem(storageKey, nextTheme)
return nextTheme
})
}
- window.addEventListener("keydown", handleKeyDown)
+ window.addEventListener('keydown', handleKeyDown)
return () => {
- window.removeEventListener("keydown", handleKeyDown)
+ window.removeEventListener('keydown', handleKeyDown)
}
}, [storageKey])
@@ -197,10 +188,10 @@ export function ThemeProvider({
setThemeState(defaultTheme)
}
- window.addEventListener("storage", handleStorageChange)
+ window.addEventListener('storage', handleStorageChange)
return () => {
- window.removeEventListener("storage", handleStorageChange)
+ window.removeEventListener('storage', handleStorageChange)
}
}, [defaultTheme, storageKey])
@@ -223,7 +214,7 @@ export const useTheme = () => {
const context = React.useContext(ThemeProviderContext)
if (context === undefined) {
- throw new Error("useTheme must be used within a ThemeProvider")
+ throw new Error('useTheme must be used within a ThemeProvider')
}
return context
diff --git a/src/hooks/agents-ui/use-agent-audio-visualizer-aura.ts b/src/hooks/agents-ui/use-agent-audio-visualizer-aura.ts
new file mode 100644
index 0000000..74f7d32
--- /dev/null
+++ b/src/hooks/agents-ui/use-agent-audio-visualizer-aura.ts
@@ -0,0 +1,112 @@
+import { useEffect, useRef, useState, useCallback } from 'react'
+import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client'
+import {
+ type AnimationPlaybackControlsWithThen,
+ type ValueAnimationTransition,
+ animate,
+ useMotionValue,
+ useMotionValueEvent,
+} from 'motion/react'
+import {
+ type AgentState,
+ type TrackReference,
+ type TrackReferenceOrPlaceholder,
+ useTrackVolume,
+} from '@livekit/components-react'
+
+const DEFAULT_SPEED = 10
+const DEFAULT_AMPLITUDE = 2
+const DEFAULT_FREQUENCY = 0.5
+const DEFAULT_SCALE = 0.2
+const DEFAULT_BRIGHTNESS = 1.5
+const DEFAULT_TRANSITION: ValueAnimationTransition = { duration: 0.5, ease: 'easeOut' }
+const DEFAULT_PULSE_TRANSITION: ValueAnimationTransition = {
+ duration: 0.35,
+ ease: 'easeOut',
+ repeat: Infinity,
+ repeatType: 'mirror',
+}
+
+function useAnimatedValue(initialValue: T) {
+ const [value, setValue] = useState(initialValue)
+ const motionValue = useMotionValue(initialValue)
+ const controlsRef = useRef(null)
+ useMotionValueEvent(motionValue, 'change', (value) => setValue(value as T))
+
+ const animateFn = useCallback(
+ (targetValue: T | T[], transition: ValueAnimationTransition) => {
+ controlsRef.current = animate(motionValue, targetValue, transition)
+ },
+ [motionValue]
+ )
+
+ return { value, motionValue, controls: controlsRef, animate: animateFn }
+}
+
+export function useAgentAudioVisualizerAura(
+ state: AgentState | undefined,
+ audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
+) {
+ const [speed, setSpeed] = useState(DEFAULT_SPEED)
+ const { value: scale, animate: animateScale, motionValue: scaleMotionValue } = useAnimatedValue(DEFAULT_SCALE)
+ const { value: amplitude, animate: animateAmplitude } = useAnimatedValue(DEFAULT_AMPLITUDE)
+ const { value: frequency, animate: animateFrequency } = useAnimatedValue(DEFAULT_FREQUENCY)
+ const { value: brightness, animate: animateBrightness } = useAnimatedValue(DEFAULT_BRIGHTNESS)
+
+ const volume = useTrackVolume(audioTrack as TrackReference, {
+ fftSize: 512,
+ smoothingTimeConstant: 0.55,
+ })
+
+ useEffect(() => {
+ switch (state) {
+ case 'idle':
+ case 'failed':
+ case 'disconnected':
+ setSpeed(10)
+ animateScale(0.2, DEFAULT_TRANSITION)
+ animateAmplitude(1.2, DEFAULT_TRANSITION)
+ animateFrequency(0.4, DEFAULT_TRANSITION)
+ animateBrightness(1.0, DEFAULT_TRANSITION)
+ return
+ case 'listening':
+ case 'pre-connect-buffering':
+ setSpeed(20)
+ animateScale(0.3, { type: 'spring', duration: 1.0, bounce: 0.35 })
+ animateAmplitude(1.0, DEFAULT_TRANSITION)
+ animateFrequency(0.7, DEFAULT_TRANSITION)
+ animateBrightness([1.5, 2.0], DEFAULT_PULSE_TRANSITION)
+ return
+ case 'thinking':
+ case 'connecting':
+ case 'initializing':
+ setSpeed(30)
+ animateScale(0.3, DEFAULT_TRANSITION)
+ animateAmplitude(0.5, DEFAULT_TRANSITION)
+ animateFrequency(1, DEFAULT_TRANSITION)
+ animateBrightness([0.5, 2.5], DEFAULT_PULSE_TRANSITION)
+ return
+ case 'speaking':
+ setSpeed(70)
+ animateScale(0.3, DEFAULT_TRANSITION)
+ animateAmplitude(0.75, DEFAULT_TRANSITION)
+ animateFrequency(1.25, DEFAULT_TRANSITION)
+ animateBrightness(1.5, DEFAULT_TRANSITION)
+ return
+ }
+ }, [state, animateScale, animateAmplitude, animateFrequency, animateBrightness])
+
+ useEffect(() => {
+ if (state === 'speaking' && volume > 0 && !scaleMotionValue.isAnimating()) {
+ animateScale(0.2 + 0.2 * volume, { duration: 0 })
+ }
+ }, [state, volume, scaleMotionValue, animateScale, animateAmplitude, animateFrequency, animateBrightness])
+
+ return {
+ speed,
+ scale,
+ amplitude,
+ frequency,
+ brightness,
+ }
+}
diff --git a/src/hooks/agents-ui/use-agent-audio-visualizer-bar.ts b/src/hooks/agents-ui/use-agent-audio-visualizer-bar.ts
new file mode 100644
index 0000000..5a6531e
--- /dev/null
+++ b/src/hooks/agents-ui/use-agent-audio-visualizer-bar.ts
@@ -0,0 +1,70 @@
+import { useEffect, useRef, useState } from 'react'
+import { type AgentState } from '@livekit/components-react'
+
+function generateConnectingSequenceBar(columns: number): number[][] {
+ const seq = []
+
+ for (let x = 0; x < columns; x++) {
+ seq.push([x, columns - 1 - x])
+ }
+
+ return seq
+}
+
+function generateListeningSequenceBar(columns: number): number[][] {
+ const center = Math.floor(columns / 2)
+ const noIndex = -1
+
+ return [[center], [noIndex]]
+}
+
+export function useAgentAudioVisualizerBarAnimator(
+ state: AgentState | undefined,
+ columns: number,
+ interval: number
+): number[] {
+ const [index, setIndex] = useState(0)
+ const [sequence, setSequence] = useState([[]])
+
+ useEffect(() => {
+ if (state === 'thinking') {
+ setSequence(generateListeningSequenceBar(columns))
+ } else if (state === 'connecting' || state === 'initializing') {
+ const sequence = [...generateConnectingSequenceBar(columns)]
+ setSequence(sequence)
+ } else if (state === 'listening') {
+ setSequence(generateListeningSequenceBar(columns))
+ } else if (state === undefined || state === 'speaking') {
+ setSequence([new Array(columns).fill(0).map((_, idx) => idx)])
+ } else {
+ setSequence([[]])
+ }
+ setIndex(0)
+ }, [state, columns])
+
+ const animationFrameId = useRef(null)
+ useEffect(() => {
+ let startTime = performance.now()
+
+ const animate = (time: DOMHighResTimeStamp) => {
+ const timeElapsed = time - startTime
+
+ if (timeElapsed >= interval) {
+ setIndex((prev) => prev + 1)
+ startTime = time
+ }
+
+ animationFrameId.current = requestAnimationFrame(animate)
+ }
+
+ animationFrameId.current = requestAnimationFrame(animate)
+
+ return () => {
+ if (animationFrameId.current !== null) {
+ cancelAnimationFrame(animationFrameId.current)
+ }
+ }
+ }, [interval, columns, state, sequence.length])
+
+ return sequence[index % sequence.length] ?? []
+}
diff --git a/src/hooks/agents-ui/use-agent-audio-visualizer-grid.ts b/src/hooks/agents-ui/use-agent-audio-visualizer-grid.ts
new file mode 100644
index 0000000..64a2fbe
--- /dev/null
+++ b/src/hooks/agents-ui/use-agent-audio-visualizer-grid.ts
@@ -0,0 +1,114 @@
+import { useEffect, useState } from 'react'
+import { type AgentState } from '@livekit/components-react'
+
+export interface Coordinate {
+ x: number
+ y: number
+}
+
+export function generateConnectingSequence(rows: number, columns: number, radius: number) {
+ const seq = []
+ const centerY = Math.floor(rows / 2)
+
+ // Calculate the boundaries of the ring based on the ring distance
+ const topLeft = {
+ x: Math.max(0, centerY - radius),
+ y: Math.max(0, centerY - radius),
+ }
+ const bottomRight = {
+ x: columns - 1 - topLeft.x,
+ y: Math.min(rows - 1, centerY + radius),
+ }
+
+ // Top edge
+ for (let x = topLeft.x; x <= bottomRight.x; x++) {
+ seq.push({ x, y: topLeft.y })
+ }
+
+ // Right edge
+ for (let y = topLeft.y + 1; y <= bottomRight.y; y++) {
+ seq.push({ x: bottomRight.x, y })
+ }
+
+ // Bottom edge
+ for (let x = bottomRight.x - 1; x >= topLeft.x; x--) {
+ seq.push({ x, y: bottomRight.y })
+ }
+
+ // Left edge
+ for (let y = bottomRight.y - 1; y > topLeft.y; y--) {
+ seq.push({ x: topLeft.x, y })
+ }
+
+ return seq
+}
+
+export function generateListeningSequence(rows: number, columns: number) {
+ const center = { x: Math.floor(columns / 2), y: Math.floor(rows / 2) }
+ const noIndex = { x: -1, y: -1 }
+
+ return [center, noIndex, noIndex, noIndex, noIndex, noIndex, noIndex, noIndex, noIndex]
+}
+
+export function generateThinkingSequence(rows: number, columns: number) {
+ const seq = []
+ const y = Math.floor(rows / 2)
+ for (let x = 0; x < columns; x++) {
+ seq.push({ x, y })
+ }
+ for (let x = columns - 1; x >= 0; x--) {
+ seq.push({ x, y })
+ }
+
+ return seq
+}
+
+export function useAgentAudioVisualizerGridAnimator(
+ state: AgentState,
+ rows: number,
+ columns: number,
+ interval: number,
+ radius?: number
+): Coordinate {
+ const [index, setIndex] = useState(0)
+ const [sequence, setSequence] = useState(() => [
+ {
+ x: Math.floor(columns / 2),
+ y: Math.floor(rows / 2),
+ },
+ ])
+
+ useEffect(() => {
+ const clampedRadius = radius
+ ? Math.min(radius, Math.floor(Math.max(rows, columns) / 2))
+ : Math.floor(Math.max(rows, columns) / 2)
+
+ if (state === 'thinking') {
+ setSequence(generateThinkingSequence(rows, columns))
+ } else if (state === 'connecting' || state === 'initializing') {
+ const sequence = [...generateConnectingSequence(rows, columns, clampedRadius)]
+ setSequence(sequence)
+ } else if (state === 'listening') {
+ setSequence(generateListeningSequence(rows, columns))
+ } else {
+ setSequence([{ x: Math.floor(columns / 2), y: Math.floor(rows / 2) }])
+ }
+ setIndex(0)
+ }, [state, rows, columns, radius])
+
+ useEffect(() => {
+ if (state === 'speaking') {
+ return
+ }
+
+ const indexInterval = setInterval(() => {
+ setIndex((prev) => {
+ return prev + 1
+ })
+ }, interval)
+
+ return () => clearInterval(indexInterval)
+ }, [interval, columns, rows, state, sequence.length])
+
+ return sequence[index % sequence.length] ?? { x: Math.floor(columns / 2), y: Math.floor(rows / 2) }
+}
diff --git a/src/hooks/agents-ui/use-agent-audio-visualizer-radial.ts b/src/hooks/agents-ui/use-agent-audio-visualizer-radial.ts
new file mode 100644
index 0000000..a1d11ef
--- /dev/null
+++ b/src/hooks/agents-ui/use-agent-audio-visualizer-radial.ts
@@ -0,0 +1,90 @@
+import { useEffect, useRef, useState } from 'react'
+import { type AgentState } from '@livekit/components-react'
+
+function findGcdLessThan(columns: number, max: number = columns): number {
+ function gcd(a: number, b: number): number {
+ while (b !== 0) {
+ const t = b
+ b = a % b
+ a = t
+ }
+ return a
+ }
+ for (let i = max; i >= 1; i--) {
+ if (gcd(columns, i) === i) {
+ return i
+ }
+ }
+ return 1
+}
+
+function generateConnectingSequenceBar(columns: number): number[][] {
+ const seq = []
+ const center = Math.floor(columns / 2)
+
+ for (let x = 0; x < columns; x++) {
+ seq.push([x, (x + center) % columns])
+ }
+
+ return seq
+}
+
+function generateListeningSequenceBar(columns: number): number[][] {
+ const divisor = columns > 8 ? columns / findGcdLessThan(columns, 4) : findGcdLessThan(columns, 2)
+
+ return Array.from({ length: divisor }, (_, idx) => [
+ ...Array(Math.floor(columns / divisor))
+ .fill(1)
+ .map((_, idx2) => idx2 * divisor + idx),
+ ])
+}
+
+export const useAgentAudioVisualizerRadialAnimator = (
+ state: AgentState | undefined,
+ barCount: number,
+ interval: number
+): number[] => {
+ const [index, setIndex] = useState(0)
+ const [sequence, setSequence] = useState([[]])
+
+ useEffect(() => {
+ if (state === 'thinking') {
+ setSequence(generateListeningSequenceBar(barCount))
+ } else if (state === 'connecting' || state === 'initializing') {
+ setSequence(generateConnectingSequenceBar(barCount))
+ } else if (state === 'listening') {
+ setSequence(generateListeningSequenceBar(barCount))
+ } else if (state === undefined || state === 'speaking') {
+ setSequence([new Array(barCount).fill(0).map((_, idx) => idx)])
+ } else {
+ setSequence([[]])
+ }
+ setIndex(0)
+ }, [state, barCount])
+
+ const animationFrameId = useRef(null)
+ useEffect(() => {
+ let startTime = performance.now()
+
+ const animate = (time: DOMHighResTimeStamp) => {
+ const timeElapsed = time - startTime
+
+ if (timeElapsed >= interval) {
+ setIndex((prev) => prev + 1)
+ startTime = time
+ }
+
+ animationFrameId.current = requestAnimationFrame(animate)
+ }
+
+ animationFrameId.current = requestAnimationFrame(animate)
+
+ return () => {
+ if (animationFrameId.current !== null) {
+ cancelAnimationFrame(animationFrameId.current)
+ }
+ }
+ }, [interval, barCount, state, sequence.length])
+
+ return sequence[index % sequence.length] ?? []
+}
diff --git a/src/hooks/agents-ui/use-agent-audio-visualizer-wave.ts b/src/hooks/agents-ui/use-agent-audio-visualizer-wave.ts
new file mode 100644
index 0000000..903df66
--- /dev/null
+++ b/src/hooks/agents-ui/use-agent-audio-visualizer-wave.ts
@@ -0,0 +1,107 @@
+import { useRef, useState, useEffect, useCallback } from 'react'
+import {
+ type AnimationPlaybackControlsWithThen,
+ type ValueAnimationTransition,
+ animate,
+ useMotionValue,
+ useMotionValueEvent,
+} from 'motion/react'
+import {
+ type AgentState,
+ type TrackReference,
+ type TrackReferenceOrPlaceholder,
+ useTrackVolume,
+} from '@livekit/components-react'
+import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client'
+
+const DEFAULT_SPEED = 5
+const DEFAULT_AMPLITUDE = 0.025
+const DEFAULT_FREQUENCY = 10
+const DEFAULT_TRANSITION: ValueAnimationTransition = { duration: 0.2, ease: 'easeOut' }
+
+function useAnimatedValue(initialValue: T) {
+ const [value, setValue] = useState(initialValue)
+ const motionValue = useMotionValue(initialValue)
+ const controlsRef = useRef(null)
+ useMotionValueEvent(motionValue, 'change', (value) => setValue(value as T))
+
+ const animateFn = useCallback(
+ (targetValue: T | T[], transition: ValueAnimationTransition) => {
+ controlsRef.current = animate(motionValue, targetValue, transition)
+ },
+ [motionValue]
+ )
+
+ return { value, controls: controlsRef, animate: animateFn }
+}
+
+interface UseAgentAudioVisualizerWaveAnimatorArgs {
+ state?: AgentState
+ audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
+}
+
+export function useAgentAudioVisualizerWave({ state, audioTrack }: UseAgentAudioVisualizerWaveAnimatorArgs) {
+ const [speed, setSpeed] = useState(DEFAULT_SPEED)
+ const { value: amplitude, animate: animateAmplitude } = useAnimatedValue(DEFAULT_AMPLITUDE)
+ const { value: frequency, animate: animateFrequency } = useAnimatedValue(DEFAULT_FREQUENCY)
+ const { value: opacity, animate: animateOpacity } = useAnimatedValue(1.0)
+
+ const volume = useTrackVolume(audioTrack as TrackReference, {
+ fftSize: 512,
+ smoothingTimeConstant: 0.55,
+ })
+
+ useEffect(() => {
+ switch (state) {
+ case 'disconnected':
+ setSpeed(DEFAULT_SPEED)
+ animateAmplitude(0, DEFAULT_TRANSITION)
+ animateFrequency(0, DEFAULT_TRANSITION)
+ animateOpacity(1.0, DEFAULT_TRANSITION)
+ return
+ case 'listening':
+ setSpeed(DEFAULT_SPEED)
+ animateAmplitude(DEFAULT_AMPLITUDE, DEFAULT_TRANSITION)
+ animateFrequency(DEFAULT_FREQUENCY, DEFAULT_TRANSITION)
+ animateOpacity([1.0, 0.3], {
+ duration: 0.75,
+ repeat: Infinity,
+ repeatType: 'mirror',
+ })
+ return
+ case 'thinking':
+ case 'connecting':
+ case 'initializing':
+ setSpeed(DEFAULT_SPEED * 4)
+ animateAmplitude(DEFAULT_AMPLITUDE / 4, DEFAULT_TRANSITION)
+ animateFrequency(DEFAULT_FREQUENCY * 4, DEFAULT_TRANSITION)
+ animateOpacity([1.0, 0.3], {
+ duration: 0.4,
+ repeat: Infinity,
+ repeatType: 'mirror',
+ })
+ return
+ case 'speaking':
+ default:
+ setSpeed(DEFAULT_SPEED * 2)
+ animateAmplitude(DEFAULT_AMPLITUDE, DEFAULT_TRANSITION)
+ animateFrequency(DEFAULT_FREQUENCY, DEFAULT_TRANSITION)
+ animateOpacity(1.0, DEFAULT_TRANSITION)
+ return
+ }
+ }, [state, setSpeed, animateAmplitude, animateFrequency, animateOpacity])
+
+ useEffect(() => {
+ if (state === 'speaking') {
+ animateAmplitude(0.015 + 0.4 * volume, { duration: 0 })
+ animateFrequency(20 + 60 * volume, { duration: 0 })
+ }
+ }, [state, volume, animateAmplitude, animateFrequency])
+
+ return {
+ speed,
+ amplitude,
+ frequency,
+ opacity,
+ }
+}
diff --git a/src/hooks/agents-ui/use-agent-control-bar.ts b/src/hooks/agents-ui/use-agent-control-bar.ts
new file mode 100644
index 0000000..0d3af36
--- /dev/null
+++ b/src/hooks/agents-ui/use-agent-control-bar.ts
@@ -0,0 +1,167 @@
+import { useCallback } from 'react'
+import { Track } from 'livekit-client'
+import {
+ type TrackReference,
+ useTrackToggle,
+ usePersistentUserChoices,
+ useLocalParticipantPermissions,
+ useSessionContext,
+} from '@livekit/components-react'
+
+const trackSourceToProtocol = (source: Track.Source) => {
+ // NOTE: this mapping avoids importing the protocol package as that leads to a significant bundle size increase
+ switch (source) {
+ case Track.Source.Camera:
+ return 1
+ case Track.Source.Microphone:
+ return 2
+ case Track.Source.ScreenShare:
+ return 3
+ default:
+ return 0
+ }
+}
+
+export interface PublishPermissions {
+ camera: boolean
+ microphone: boolean
+ screenShare: boolean
+ data: boolean
+}
+
+export function usePublishPermissions(): PublishPermissions {
+ const localPermissions = useLocalParticipantPermissions()
+
+ const canPublishSource = (source: Track.Source) => {
+ return (
+ !!localPermissions?.canPublish &&
+ (localPermissions.canPublishSources.length === 0 ||
+ localPermissions.canPublishSources.includes(trackSourceToProtocol(source)))
+ )
+ }
+
+ return {
+ camera: canPublishSource(Track.Source.Camera),
+ microphone: canPublishSource(Track.Source.Microphone),
+ screenShare: canPublishSource(Track.Source.ScreenShare),
+ data: localPermissions?.canPublishData ?? false,
+ }
+}
+
+export interface UseInputControlsProps {
+ saveUserChoices?: boolean
+ onDisconnect?: () => void
+ onDeviceError?: (error: { source: Track.Source; error: Error }) => void
+}
+
+export interface UseInputControlsReturn {
+ microphoneTrack?: TrackReference
+ microphoneToggle: ReturnType>
+ cameraToggle: ReturnType>
+ screenShareToggle: ReturnType>
+ handleAudioDeviceChange: (deviceId: string) => void
+ handleVideoDeviceChange: (deviceId: string) => void
+ handleMicrophoneDeviceSelectError: (error: Error) => void
+ handleCameraDeviceSelectError: (error: Error) => void
+}
+
+export function useInputControls({
+ saveUserChoices = true,
+ onDeviceError,
+}: UseInputControlsProps = {}): UseInputControlsReturn {
+ const {
+ local: { microphoneTrack },
+ } = useSessionContext()
+
+ const microphoneToggle = useTrackToggle({
+ source: Track.Source.Microphone,
+ onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Microphone, error }),
+ })
+
+ const cameraToggle = useTrackToggle({
+ source: Track.Source.Camera,
+ onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Camera, error }),
+ })
+
+ const screenShareToggle = useTrackToggle({
+ source: Track.Source.ScreenShare,
+ onDeviceError: (error) => onDeviceError?.({ source: Track.Source.ScreenShare, error }),
+ })
+
+ const { saveAudioInputEnabled, saveVideoInputEnabled, saveAudioInputDeviceId, saveVideoInputDeviceId } =
+ usePersistentUserChoices({ preventSave: !saveUserChoices })
+
+ const handleAudioDeviceChange = useCallback(
+ (deviceId: string) => {
+ saveAudioInputDeviceId(deviceId ?? 'default')
+ },
+ [saveAudioInputDeviceId]
+ )
+
+ const handleVideoDeviceChange = useCallback(
+ (deviceId: string) => {
+ saveVideoInputDeviceId(deviceId ?? 'default')
+ },
+ [saveVideoInputDeviceId]
+ )
+
+ const handleToggleCamera = useCallback(
+ async (enabled?: boolean) => {
+ if (screenShareToggle.enabled) {
+ screenShareToggle.toggle(false)
+ }
+ await cameraToggle.toggle(enabled)
+ // persist video input enabled preference
+ saveVideoInputEnabled(!cameraToggle.enabled)
+ },
+ [cameraToggle, screenShareToggle, saveVideoInputEnabled]
+ )
+
+ const handleToggleMicrophone = useCallback(
+ async (enabled?: boolean) => {
+ await microphoneToggle.toggle(enabled)
+ // persist audio input enabled preference
+ saveAudioInputEnabled(!microphoneToggle.enabled)
+ },
+ [microphoneToggle, saveAudioInputEnabled]
+ )
+
+ const handleToggleScreenShare = useCallback(
+ async (enabled?: boolean) => {
+ if (cameraToggle.enabled) {
+ cameraToggle.toggle(false)
+ }
+ await screenShareToggle.toggle(enabled)
+ },
+ [cameraToggle, screenShareToggle]
+ )
+ const handleMicrophoneDeviceSelectError = useCallback(
+ (error: Error) => onDeviceError?.({ source: Track.Source.Microphone, error }),
+ [onDeviceError]
+ )
+
+ const handleCameraDeviceSelectError = useCallback(
+ (error: Error) => onDeviceError?.({ source: Track.Source.Camera, error }),
+ [onDeviceError]
+ )
+
+ return {
+ microphoneTrack,
+ cameraToggle: {
+ ...cameraToggle,
+ toggle: handleToggleCamera,
+ },
+ microphoneToggle: {
+ ...microphoneToggle,
+ toggle: handleToggleMicrophone,
+ },
+ screenShareToggle: {
+ ...screenShareToggle,
+ toggle: handleToggleScreenShare,
+ },
+ handleAudioDeviceChange,
+ handleVideoDeviceChange,
+ handleMicrophoneDeviceSelectError,
+ handleCameraDeviceSelectError,
+ }
+}
diff --git a/src/main.tsx b/src/main.tsx
index 2c36773..51c9c35 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,14 +1,17 @@
-import { StrictMode } from "react"
-import { createRoot } from "react-dom/client"
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
-import "./index.css"
-import App from "./App.tsx"
-import { ThemeProvider } from "@/components/theme-provider.tsx"
+import './index.css'
+import App from './App.tsx'
+import { ThemeProvider } from '@/contexts/ThemeProvider.tsx'
+import { TooltipProvider } from '@/components/ui/tooltip'
-createRoot(document.getElementById("root")!).render(
+createRoot(document.getElementById('root')!).render(
-
+
+
+
)
diff --git a/src/views/HomeView.tsx b/src/views/HomeView.tsx
new file mode 100644
index 0000000..9e61333
--- /dev/null
+++ b/src/views/HomeView.tsx
@@ -0,0 +1,138 @@
+import { useRef, useState, useEffect } from 'react'
+import { useSession, useAgent, useChat, useSessionMessages } from '@livekit/components-react'
+import { Keyboard, Loader, SendHorizontal } from 'lucide-react'
+import { TokenSource } from 'livekit-client'
+
+import { AgentAudioVisualizerAura } from '@/components/agents-ui/agent-audio-visualizer-aura'
+import { AgentChatTranscript } from '@/components/agents-ui/agent-chat-transcript'
+import { Button } from '@/components/ui/button'
+import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'
+import { AppBar } from '@/components/AppBar'
+
+interface HomeViewProps {
+ onSettingsClick: () => void
+ onJobsClick: () => void
+ onNotificationsClick: () => void
+}
+
+export function HomeView({ onSettingsClick, onJobsClick, onNotificationsClick }: HomeViewProps) {
+ const session = useSession(TokenSource.sandboxTokenServer('your-sandbox-id'))
+ const { microphoneTrack, state: agentState } = useAgent(session)
+ const { messages } = useSessionMessages(session)
+ const { send } = useChat({ room: session.room })
+
+ const [isOpen, setIsOpen] = useState(false)
+ const [message, setMessage] = useState('')
+ const [isSending, setIsSending] = useState(false)
+ const inputRef = useRef(null)
+
+ const isDisabled = isSending || message.trim().length === 0
+
+ const handleSend = async () => {
+ if (isDisabled) return
+ try {
+ setIsSending(true)
+ await send(message.trim())
+ setMessage('')
+ } catch (error) {
+ console.error(error)
+ } finally {
+ setIsSending(false)
+ }
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSend()
+ }
+ }
+
+ useEffect(() => {
+ if (isOpen && inputRef.current) {
+ inputRef.current.focus()
+ }
+ }, [isOpen])
+
+ // Auto-open when first message arrives
+ const hasAutoOpened = useRef(false)
+ useEffect(() => {
+ if (messages.length > 0 && !hasAutoOpened.current) {
+ hasAutoOpened.current = true
+ setIsOpen(true)
+ }
+ }, [messages.length])
+
+ return (
+
+
+
+
+
+
+ (Press d to toggle dark mode)
+
+
+
+
+
+
+
+
+
+
+
+ Chat
+
+
+ {/* Messages area */}
+
+ {messages.length === 0 ? (
+
+ No messages yet. Start a conversation!
+
+ ) : (
+
+ )}
+
+
+ {/* Input area */}
+
+
+
+
+
+ )
+}
diff --git a/src/views/JobsView.tsx b/src/views/JobsView.tsx
new file mode 100644
index 0000000..42be260
--- /dev/null
+++ b/src/views/JobsView.tsx
@@ -0,0 +1,7 @@
+export function JobsView() {
+ return (
+
+
Running jobs coming soon.
+
+ )
+}
diff --git a/src/views/NotificationsView.tsx b/src/views/NotificationsView.tsx
new file mode 100644
index 0000000..e9e709d
--- /dev/null
+++ b/src/views/NotificationsView.tsx
@@ -0,0 +1,7 @@
+export function NotificationsView() {
+ return (
+
+
Notifications coming soon.
+
+ )
+}
diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx
new file mode 100644
index 0000000..40345e6
--- /dev/null
+++ b/src/views/SettingsView.tsx
@@ -0,0 +1,7 @@
+export function SettingsView() {
+ return (
+
+
Settings coming soon.
+
+ )
+}