From c1ab59e94622c6cf4810d391f5d757db7baf6f20 Mon Sep 17 00:00:00 2001 From: Thomas Yuill Date: Thu, 4 Dec 2025 13:08:21 -0500 Subject: [PATCH 1/5] add AudioVisualizerBar --- .../agents-ui/AudioVisualizerBar.stories.tsx | 56 +++++++++ .../components/participant/BarVisualizer.tsx | 2 +- .../audio-visualizer-bar.tsx | 113 ++++++++++++++++++ .../hooks/useBarAnimator.ts | 70 +++++++++++ packages/shadcn/index.ts | 1 + packages/shadcn/lib/clone-single-child.ts | 26 ++++ .../participant/_audio-visualizer.scss | 2 +- packages/styles/scss/prefabs/control-bar.scss | 2 +- 8 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 docs/storybook/stories/agents-ui/AudioVisualizerBar.stories.tsx create mode 100644 packages/shadcn/components/agents-ui/audio-visualizer-bar/audio-visualizer-bar.tsx create mode 100644 packages/shadcn/components/agents-ui/audio-visualizer-bar/hooks/useBarAnimator.ts create mode 100644 packages/shadcn/index.ts create mode 100644 packages/shadcn/lib/clone-single-child.ts diff --git a/docs/storybook/stories/agents-ui/AudioVisualizerBar.stories.tsx b/docs/storybook/stories/agents-ui/AudioVisualizerBar.stories.tsx new file mode 100644 index 000000000..5e01c6f75 --- /dev/null +++ b/docs/storybook/stories/agents-ui/AudioVisualizerBar.stories.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { StoryObj } from '@storybook/react-vite'; +import { + AgentSessionProvider, + useMicrophone, +} from '../../.storybook/lk-decorators/AgentSessionProvider'; +import { AudioVisualizerBar, AudioVisualizerBarProps } from '@agents-ui'; + +export default { + component: AudioVisualizerBar, + decorators: [AgentSessionProvider], + render: (args: AudioVisualizerBarProps) => { + const audioTrack = useMicrophone(); + + return ; + }, + args: { + size: 'xl', + barCount: 5, + state: 'connecting', + }, + argTypes: { + size: { + options: ['icon', 'sm', 'md', 'lg', 'xl'], + control: { type: 'radio' }, + }, + state: { + options: [ + 'idle', + 'disconnected', + 'pre-connect-buffering', + 'connecting', + 'initializing', + 'listening', + 'thinking', + 'speaking', + 'failed', + ], + control: { type: 'radio' }, + }, + barCount: { + control: { type: 'range', min: 1, max: 24, step: 1 }, + }, + className: { control: { type: 'text' } }, + }, + parameters: { + layout: 'centered', + actions: { + handles: [], + }, + }, +}; + +export const Default: StoryObj = { + args: {}, +}; diff --git a/packages/react/src/components/participant/BarVisualizer.tsx b/packages/react/src/components/participant/BarVisualizer.tsx index bef470101..69255d99a 100644 --- a/packages/react/src/components/participant/BarVisualizer.tsx +++ b/packages/react/src/components/participant/BarVisualizer.tsx @@ -112,7 +112,7 @@ export const BarVisualizer = /* @__PURE__ */ React.forwardRef_*]:rounded-full [&_>_*]:transition-colors [&_>_*]:duration-250 [&_>_*]:ease-linear', + '[&_>_*]:bg-transparent [&_>_*]:data-[lk-highlighted=true]:bg-current', + ], + { + variants: { + size: { + icon: ['h-[24px] gap-[2px]', '[&_>_*]:w-[4px] [&_>_*]:min-h-[4px]'], + sm: ['h-[56px] gap-[4px]', '[&_>_*]:w-[8px] [&_>_*]:min-h-[8px]'], + md: ['h-[112px] gap-[8px]', '[&_>_*]:w-[16px] [&_>_*]:min-h-[16px]'], + lg: ['h-[224px] gap-[16px]', '[&_>_*]:w-[32px] [&_>_*]:min-h-[32px]'], + xl: ['h-[448px] gap-[32px]', '[&_>_*]:w-[64px] [&_>_*]:min-h-[64px]'], + }, + }, + defaultVariants: { + size: 'md', + }, + }, +); + +export interface AudioVisualizerBarProps { + state?: AgentState; + barCount?: number; + audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder; + className?: string; + children?: ReactNode | ReactNode[]; +} + +export function AudioVisualizerBar({ + size, + state, + barCount, + audioTrack, + className, + children, +}: AudioVisualizerBarProps & VariantProps) { + 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 = useBarAnimator(state, _barCount, sequencerInterval); + const bands = useMemo( + () => (state === 'speaking' ? volumeBands : new Array(_barCount).fill(0)), + [state, volumeBands, _barCount], + ); + + 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/packages/shadcn/components/agents-ui/audio-visualizer-bar/hooks/useBarAnimator.ts b/packages/shadcn/components/agents-ui/audio-visualizer-bar/hooks/useBarAnimator.ts new file mode 100644 index 000000000..fc397ca13 --- /dev/null +++ b/packages/shadcn/components/agents-ui/audio-visualizer-bar/hooks/useBarAnimator.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 const useBarAnimator = ( + 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/packages/shadcn/index.ts b/packages/shadcn/index.ts new file mode 100644 index 000000000..2c3699ee1 --- /dev/null +++ b/packages/shadcn/index.ts @@ -0,0 +1 @@ +export * from './components/agents-ui/audio-visualizer-bar/audio-visualizer-bar'; diff --git a/packages/shadcn/lib/clone-single-child.ts b/packages/shadcn/lib/clone-single-child.ts new file mode 100644 index 000000000..19c71783a --- /dev/null +++ b/packages/shadcn/lib/clone-single-child.ts @@ -0,0 +1,26 @@ +import { ReactNode, Children, isValidElement, cloneElement, CSSProperties } from 'react'; +import { cn } from '@/lib/utils'; + +export 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; + }); +} diff --git a/packages/styles/scss/components/participant/_audio-visualizer.scss b/packages/styles/scss/components/participant/_audio-visualizer.scss index deb9a58e6..e89fb1be3 100644 --- a/packages/styles/scss/components/participant/_audio-visualizer.scss +++ b/packages/styles/scss/components/participant/_audio-visualizer.scss @@ -29,7 +29,7 @@ deprecated } } -.audio-bar-visualizer { +.audio-visualizer-bar { display: flex; align-items: center; justify-content: center; diff --git a/packages/styles/scss/prefabs/control-bar.scss b/packages/styles/scss/prefabs/control-bar.scss index a8f5293b8..af0222cff 100644 --- a/packages/styles/scss/prefabs/control-bar.scss +++ b/packages/styles/scss/prefabs/control-bar.scss @@ -16,7 +16,7 @@ --va-bar-width: 2px; --va-bar-gap: 4px; --va-bar-border-radius: 1px; - & .audio-bar-visualizer { + & .audio-visualizer-bar { & .audio-bar.highlighted { filter: none; } From 64068837b2d012aafc48ac8347c52124e8b094f9 Mon Sep 17 00:00:00 2001 From: Thomas Yuill Date: Wed, 3 Dec 2025 13:25:32 -0500 Subject: [PATCH 2/5] add AgentTrackToggle --- .../agents-ui/AgentTrackToggle.stories.tsx | 106 ++++++++++++++++++ .../agents-ui/agent-track-toggle.tsx | 93 +++++++++++++++ packages/shadcn/index.ts | 1 + 3 files changed, 200 insertions(+) create mode 100644 docs/storybook/stories/agents-ui/AgentTrackToggle.stories.tsx create mode 100644 packages/shadcn/components/agents-ui/agent-track-toggle.tsx diff --git a/docs/storybook/stories/agents-ui/AgentTrackToggle.stories.tsx b/docs/storybook/stories/agents-ui/AgentTrackToggle.stories.tsx new file mode 100644 index 000000000..d96af0a9a --- /dev/null +++ b/docs/storybook/stories/agents-ui/AgentTrackToggle.stories.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; +import { StoryObj } from '@storybook/react-vite'; +import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider'; +import { AgentTrackToggle, AgentTrackToggleProps } from '@agents-ui'; +import { Track } from 'livekit-client'; + +export default { + component: AgentTrackToggle, + decorators: [AgentSessionProvider], + render: (args: AgentTrackToggleProps) => { + const [isPressed, setIsPressed] = React.useState( + args.source === Track.Source.Microphone ? true : false, + ); + + return ( + setIsPressed(pressed)} + /> + ); + }, + args: { + size: 'default', + }, + argTypes: { + size: { + options: ['default', 'sm', 'lg'], + control: { type: 'radio' }, + }, + pending: { control: { type: 'boolean' } }, + className: { control: { type: 'text' } }, + }, + parameters: { + layout: 'centered', + actions: { + handles: [], + }, + }, +}; + +export const Default: StoryObj = { + render: (args: AgentTrackToggleProps) => { + const [isCameraPressed, setIsCameraPressed] = React.useState(true); + const [isMicrophonePressed, setIsMicrophonePressed] = React.useState(false); + const [isScreenSharePressed, setIsScreenSharePressed] = React.useState(true); + + return ( +
+ setIsMicrophonePressed(pressed)} + /> + setIsCameraPressed(pressed)} + /> + setIsScreenSharePressed(pressed)} + /> +
+ ); + }, + args: {}, +}; + +export const Outlined: StoryObj = { + args: { + variant: 'outline', + }, + render: (args: AgentTrackToggleProps) => { + const [isCameraPressed, setIsCameraPressed] = React.useState(true); + const [isMicrophonePressed, setIsMicrophonePressed] = React.useState(false); + const [isScreenSharePressed, setIsScreenSharePressed] = React.useState(true); + + return ( +
+ setIsMicrophonePressed(pressed)} + /> + setIsCameraPressed(pressed)} + /> + setIsScreenSharePressed(pressed)} + /> +
+ ); + }, +}; diff --git a/packages/shadcn/components/agents-ui/agent-track-toggle.tsx b/packages/shadcn/components/agents-ui/agent-track-toggle.tsx new file mode 100644 index 000000000..c4e1be82f --- /dev/null +++ b/packages/shadcn/components/agents-ui/agent-track-toggle.tsx @@ -0,0 +1,93 @@ +'use client'; + +import * as React from 'react'; +import { cva } from 'class-variance-authority'; +import { Track } from 'livekit-client'; +import { useTrackToggle } from '@livekit/components-react'; +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: { + 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]:bg-destructive/10 data-[state=off]:text-destructive data-[state=off]:border-destructive/20', + '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:bg-foreground/10 data-[state=on]:hover:border-foreground/12', + '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 React.Fragment; + } +} + +export type AgentTrackToggleProps = React.ComponentProps & { + source: Parameters[0]['source']; + pending?: boolean; +}; + +export function AgentTrackToggle({ + source, + pressed, + variant, + pending, + className, + ...props +}: AgentTrackToggleProps) { + const IconComponent = getSourceIcon(source, pressed ?? false, pending); + + return ( + + + {props.children} + + ); +} diff --git a/packages/shadcn/index.ts b/packages/shadcn/index.ts index 2c3699ee1..485653e99 100644 --- a/packages/shadcn/index.ts +++ b/packages/shadcn/index.ts @@ -1 +1,2 @@ export * from './components/agents-ui/audio-visualizer-bar/audio-visualizer-bar'; +export * from './components/agents-ui/agent-track-toggle'; From 8b3fd898f0e7d1de70e81082e9be20a020074948 Mon Sep 17 00:00:00 2001 From: Thomas Yuill Date: Wed, 3 Dec 2025 13:25:40 -0500 Subject: [PATCH 3/5] add AgentTrackControl --- .../agents-ui/AgentTrackControl.stories.tsx | 119 +++++++++ .../agents-ui/agent-track-control.tsx | 228 ++++++++++++++++++ packages/shadcn/components/ui/toggle.tsx | 2 +- packages/shadcn/index.ts | 1 + 4 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 docs/storybook/stories/agents-ui/AgentTrackControl.stories.tsx create mode 100644 packages/shadcn/components/agents-ui/agent-track-control.tsx diff --git a/docs/storybook/stories/agents-ui/AgentTrackControl.stories.tsx b/docs/storybook/stories/agents-ui/AgentTrackControl.stories.tsx new file mode 100644 index 000000000..f1970a22d --- /dev/null +++ b/docs/storybook/stories/agents-ui/AgentTrackControl.stories.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import { StoryObj } from '@storybook/react-vite'; +import { + AgentSessionProvider, + useMicrophone, +} from '../../.storybook/lk-decorators/AgentSessionProvider'; +import { AgentTrackControl, type AgentTrackControlProps } from '@agents-ui'; +import { Track } from 'livekit-client'; + +export default { + component: AgentTrackControl, + decorators: [AgentSessionProvider], + render: (args: AgentTrackControlProps) => { + const [isPressed, setIsPressed] = React.useState( + args.source === Track.Source.Microphone ? true : false, + ); + + const microphoneTrack = useMicrophone(); + + return ( + setIsPressed(pressed)} + audioTrack={args.source === Track.Source.Microphone ? microphoneTrack : undefined} + /> + ); + }, + argTypes: { + size: { + options: ['default', 'sm', 'lg'], + control: { type: 'radio' }, + }, + pending: { control: { type: 'boolean' } }, + className: { control: { type: 'text' } }, + }, + parameters: { + layout: 'centered', + actions: { + handles: [], + }, + }, +}; + +export const Default: StoryObj = { + render: (args: AgentTrackControlProps) => { + const [isCameraPressed, setIsCameraPressed] = React.useState(true); + const [isMicrophonePressed, setIsMicrophonePressed] = React.useState(false); + const [isScreenSharePressed, setIsScreenSharePressed] = React.useState(true); + + const microphoneTrack = useMicrophone(); + + return ( +
+ setIsMicrophonePressed(pressed)} + /> + setIsCameraPressed(pressed)} + /> + setIsScreenSharePressed(pressed)} + /> +
+ ); + }, + args: {}, +}; + +export const Outlined: StoryObj = { + args: { + variant: 'outline', + }, + render: (args: AgentTrackControlProps) => { + const [isCameraPressed, setIsCameraPressed] = React.useState(true); + const [isMicrophonePressed, setIsMicrophonePressed] = React.useState(false); + const [isScreenSharePressed, setIsScreenSharePressed] = React.useState(true); + + const microphoneTrack = useMicrophone(); + + return ( +
+ setIsMicrophonePressed(pressed)} + /> + setIsCameraPressed(pressed)} + /> + setIsScreenSharePressed(pressed)} + /> +
+ ); + }, +}; diff --git a/packages/shadcn/components/agents-ui/agent-track-control.tsx b/packages/shadcn/components/agents-ui/agent-track-control.tsx new file mode 100644 index 000000000..80e1087e9 --- /dev/null +++ b/packages/shadcn/components/agents-ui/agent-track-control.tsx @@ -0,0 +1,228 @@ +'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, + useTrackToggle, + useMaybeRoomContext, + useMediaDeviceSelect, +} from '@livekit/components-react'; + +import { AudioVisualizerBar } from '@/components/agents-ui/audio-visualizer-bar/audio-visualizer-bar'; +import { AgentTrackToggle } from '@/components/agents-ui/agent-track-toggle'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; +import { toggleVariants } from '../ui/toggle'; + +const selectVariants = cva( + [ + 'rounded-l-none shadow-none pl-2 ', + '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', + }, + }, +); + +type DeviceSelectProps = React.ComponentProps & + VariantProps & { + kind: MediaDeviceKind; + track?: LocalAudioTrack | LocalVideoTrack | undefined; + requestPermissions?: boolean; + onMediaDeviceError?: (error: Error) => void; + onDeviceListChange?: (devices: MediaDeviceInfo[]) => void; + onActiveDeviceChange?: (deviceId: string) => void; + }; + +export function TrackDeviceSelect({ + kind, + track, + size = 'default', + variant = 'default', + requestPermissions = false, + onMediaDeviceError, + onDeviceListChange, + onActiveDeviceChange, + ...props +}: DeviceSelectProps) { + 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 ( + + ); +} + +export type AgentTrackControlProps = VariantProps & { + kind: MediaDeviceKind; + source: Parameters[0]['source']; + pressed?: boolean; + pending?: boolean; + disabled?: boolean; + className?: string; + audioTrack?: TrackReferenceOrPlaceholder; + onPressedChange?: (pressed: boolean) => void; + onMediaDeviceError?: (error: Error) => void; + onActiveDeviceChange?: (deviceId: string) => void; +}; + +export function AgentTrackControl({ + kind, + variant = 'default', + source, + pressed, + pending, + disabled, + className, + audioTrack, + onPressedChange, + onMediaDeviceError, + onActiveDeviceChange, +}: AgentTrackControlProps) { + return ( +
+ + {audioTrack && ( + + + + )} + + {kind && ( + + )} +
+ ); +} diff --git a/packages/shadcn/components/ui/toggle.tsx b/packages/shadcn/components/ui/toggle.tsx index 4add57b50..d4ce99f22 100644 --- a/packages/shadcn/components/ui/toggle.tsx +++ b/packages/shadcn/components/ui/toggle.tsx @@ -24,7 +24,7 @@ const toggleVariants = cva( variant: 'default', size: 'default', }, - } + }, ); function Toggle({ diff --git a/packages/shadcn/index.ts b/packages/shadcn/index.ts index 485653e99..b748fe5a7 100644 --- a/packages/shadcn/index.ts +++ b/packages/shadcn/index.ts @@ -1,2 +1,3 @@ export * from './components/agents-ui/audio-visualizer-bar/audio-visualizer-bar'; export * from './components/agents-ui/agent-track-toggle'; +export * from './components/agents-ui/agent-track-control'; From 96fe24cb86043371646a98eed1f6891c33c321cb Mon Sep 17 00:00:00 2001 From: Thomas Yuill Date: Wed, 3 Dec 2025 23:36:36 -0500 Subject: [PATCH 4/5] add AgentDisconnectButton --- .../AgentDisconnectButton.stories.tsx | 36 +++++++++++++++++++ .../agents-ui/agent-disconnect-button.tsx | 35 ++++++++++++++++++ packages/shadcn/index.ts | 1 + 3 files changed, 72 insertions(+) create mode 100644 docs/storybook/stories/agents-ui/AgentDisconnectButton.stories.tsx create mode 100644 packages/shadcn/components/agents-ui/agent-disconnect-button.tsx diff --git a/docs/storybook/stories/agents-ui/AgentDisconnectButton.stories.tsx b/docs/storybook/stories/agents-ui/AgentDisconnectButton.stories.tsx new file mode 100644 index 000000000..5357bea31 --- /dev/null +++ b/docs/storybook/stories/agents-ui/AgentDisconnectButton.stories.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { StoryObj } from '@storybook/react-vite'; +import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider'; +import { AgentDisconnectButton, type AgentDisconnectButtonProps } from '@agents-ui'; + +export default { + component: AgentDisconnectButton, + decorators: [AgentSessionProvider], + render: (args: AgentDisconnectButtonProps) => ( + + ), + argTypes: { + size: { + options: ['default', 'sm', 'lg', 'icon', 'icon-sm', 'icon-lg'], + control: { type: 'select' }, + }, + onClick: { action: 'onClick' }, + className: { control: { type: 'text' } }, + }, + parameters: { + layout: 'centered', + actions: { + handles: [], + }, + }, +}; + +export const Default: StoryObj = { + args: {}, +}; + +export const Icon: StoryObj = { + args: { + size: 'icon', + }, +}; diff --git a/packages/shadcn/components/agents-ui/agent-disconnect-button.tsx b/packages/shadcn/components/agents-ui/agent-disconnect-button.tsx new file mode 100644 index 000000000..d85ddc3fb --- /dev/null +++ b/packages/shadcn/components/agents-ui/agent-disconnect-button.tsx @@ -0,0 +1,35 @@ +'use client'; + +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'; + +export interface AgentDisconnectButtonProps + extends React.ComponentProps<'button'>, + VariantProps { + icon?: React.ReactNode; + children?: React.ReactNode; +} + +export function AgentDisconnectButton({ + icon, + size = 'default', + children, + onClick, + ...props +}: AgentDisconnectButtonProps) { + const { end } = useSessionContext(); + const handleClick = (event: React.MouseEvent) => { + onClick?.(event); + end(); + }; + + return ( + + ); +} diff --git a/packages/shadcn/index.ts b/packages/shadcn/index.ts index b748fe5a7..38da93b4c 100644 --- a/packages/shadcn/index.ts +++ b/packages/shadcn/index.ts @@ -1,3 +1,4 @@ export * from './components/agents-ui/audio-visualizer-bar/audio-visualizer-bar'; export * from './components/agents-ui/agent-track-toggle'; export * from './components/agents-ui/agent-track-control'; +export * from './components/agents-ui/agent-disconnect-button'; From 77be9bba5586586d5e1f7fed2393f0398aa98722 Mon Sep 17 00:00:00 2001 From: Thomas Yuill Date: Wed, 3 Dec 2025 13:26:01 -0500 Subject: [PATCH 5/5] add AgentControlBar --- .../agents-ui/AgentControlBar.stories.tsx | 79 ++++++ .../agent-control-bar/agent-chat-input.tsx | 69 +++++ .../agent-control-bar/agent-control-bar.tsx | 246 ++++++++++++++++++ .../hooks/use-input-controls.ts | 136 ++++++++++ .../hooks/use-publish-permissions.ts | 42 +++ packages/shadcn/index.ts | 1 + 6 files changed, 573 insertions(+) create mode 100644 docs/storybook/stories/agents-ui/AgentControlBar.stories.tsx create mode 100644 packages/shadcn/components/agents-ui/agent-control-bar/agent-chat-input.tsx create mode 100644 packages/shadcn/components/agents-ui/agent-control-bar/agent-control-bar.tsx create mode 100644 packages/shadcn/components/agents-ui/agent-control-bar/hooks/use-input-controls.ts create mode 100644 packages/shadcn/components/agents-ui/agent-control-bar/hooks/use-publish-permissions.ts diff --git a/docs/storybook/stories/agents-ui/AgentControlBar.stories.tsx b/docs/storybook/stories/agents-ui/AgentControlBar.stories.tsx new file mode 100644 index 000000000..71dd683c5 --- /dev/null +++ b/docs/storybook/stories/agents-ui/AgentControlBar.stories.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; +import { StoryObj } from '@storybook/react-vite'; +import { + AgentSessionProvider, + useMicrophone, +} from '../../.storybook/lk-decorators/AgentSessionProvider'; +import { AgentControlBar, AgentControlBarProps } from '@agents-ui'; + +export default { + component: AgentControlBar, + decorators: [AgentSessionProvider], + render: (args: AgentControlBarProps) => { + useMicrophone(); + return ; + }, + args: { + isConnected: true, + controls: { + microphone: true, + camera: true, + screenShare: true, + chat: true, + leave: true, + }, + className: 'w-full', + }, + argTypes: { + controls: { control: { type: 'object' } }, + isConnected: { control: { type: 'boolean' } }, + isChatOpen: { control: { type: 'boolean' } }, + }, + parameters: { + layout: 'centered', + actions: { + handles: [], + }, + }, +}; + +export const Default: StoryObj = { + args: {}, +}; + +export const Outline: StoryObj = { + args: { + variant: 'outline', + }, +}; + +export const Livekit: StoryObj = { + args: { + variant: 'livekit', + }, +}; + +export const NoControls: StoryObj = { + args: { + controls: { + microphone: false, + camera: false, + screenShare: false, + chat: false, + leave: false, + }, + }, + render: (args: AgentControlBarProps) => { + return ( + <> +

+ This control bar does not render +
+ because visibleControls contains + only false values. +

+ + + ); + }, +}; diff --git a/packages/shadcn/components/agents-ui/agent-control-bar/agent-chat-input.tsx b/packages/shadcn/components/agents-ui/agent-control-bar/agent-chat-input.tsx new file mode 100644 index 000000000..d1f8bb2a8 --- /dev/null +++ b/packages/shadcn/components/agents-ui/agent-control-bar/agent-chat-input.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from 'react'; +import { SendHorizontal, Loader } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface AgentChatInputProps { + chatOpen: boolean; + onSend?: (message: string) => void; + className?: string; +} + +export function AgentChatInput({ + chatOpen, + onSend = async () => {}, + className, +}: AgentChatInputProps) { + const inputRef = useRef(null); + const [isSending, setIsSending] = useState(false); + const [message, setMessage] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + setIsSending(true); + await onSend(message); + setMessage(''); + } catch (error) { + console.error(error); + } finally { + setIsSending(false); + } + }; + + const isDisabled = isSending || message.trim().length === 0; + + useEffect(() => { + if (chatOpen) return; + // when not disabled refocus on input + inputRef.current?.focus(); + }, [chatOpen]); + + return ( +
+