fix(voice): keep LiveKit TTS tracks alive

This commit is contained in:
Yahya Alhinai
2026-06-28 07:20:54 +00:00
parent 46d277d203
commit c4c65f8802
91 changed files with 16863 additions and 21 deletions
@@ -0,0 +1,196 @@
'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/shadcn/utils';
function cloneSingleChild(
children: ReactNode | ReactNode[],
props?: Record<string, unknown>,
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<string, unknown>;
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 AgentAudioVisualizerBarVariants = cva(
[
'relative flex items-center justify-center',
'*: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',
},
}
);
/**
* 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 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 children to render as bars. Each child receives data-lk-index,
* data-lk-highlighted, and style props for height.
*/
children?: ReactNode | 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
* <AgentAudioVisualizerBar
* size="md"
* state="speaking"
* audioTrack={agentAudioTrack}
* />
* ```
*/
export function AgentAudioVisualizerBar({
size = 'md',
state = 'connecting',
barCount,
audioTrack,
className,
children,
...props
}: AgentAudioVisualizerBarProps &
VariantProps<typeof AgentAudioVisualizerBarVariants> &
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]
);
return (
<div className={cn(AgentAudioVisualizerBarVariants({ size }), className)} {...props}>
{bands.map((band: number, idx: number) =>
children ? (
<React.Fragment key={idx}>
{cloneSingleChild(children, {
'data-lk-index': idx,
'data-lk-highlighted': highlightedIndices.includes(idx),
style: { height: `${band * 100}%` },
})}
</React.Fragment>
) : (
<div
key={idx}
data-lk-index={idx}
data-lk-highlighted={highlightedIndices.includes(idx)}
style={{ height: `${band * 100}%` }}
/>
)
)}
</div>
);
}
@@ -0,0 +1,290 @@
'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/shadcn/utils';
function cloneSingleChild(
children: ReactNode | ReactNode[],
props?: Record<string, unknown>,
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<string, unknown>;
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 AgentAudioVisualizerGridVariants = cva(
[
'grid',
'*:size-1 *:rounded-full',
'*:bg-foreground/10 [&_>_[data-lk-highlighted=true]]:bg-foreground [&_>_[data-lk-highlighted=true]]:scale-125 [&_>_[data-lk-highlighted=true]]:shadow-[0px_0px_10px_2px_rgba(255,255,255,0.4)]',
],
{
variants: {
size: {
icon: ['gap-[2px] *:size-[4px]'],
sm: ['gap-[4px] *:size-[4px]'],
md: ['gap-[8px] *:size-[8px]'],
lg: ['gap-[8px] *:size-[8px]'],
xl: ['gap-[8px] *:size-[8px]'],
},
},
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;
/**
* A function to transform the style of each grid cell based on its position.
* Receives the cell index, row count, and column count as arguments.
*/
transformer?: (index: number, rowCount: number, columnCount: number) => CSSProperties;
/**
* Additional CSS class names to apply to the container.
*/
className?: string;
/**
* Custom children to render as grid cells.
*/
children?: ReactNode;
}
const sizeDefaults = {
icon: 3,
sm: 5,
md: 5,
lg: 5,
xl: 5,
};
function useGrid(
size: VariantProps<typeof AgentAudioVisualizerGridVariants>['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;
transformer?: (index: number, rowCount: number, columnCount: number) => CSSProperties;
rowCount: number;
columnCount: number;
volumeBands: number[];
highlightedCoordinate: Coordinate;
children: ReactNode;
}
const GridCell = memo(function GridCell({
index,
state,
interval,
transformer,
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,
});
}
let transformerStyle: CSSProperties | undefined;
if (transformer) {
transformerStyle = transformer(index, rowCount, columnCount);
}
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: {
transitionProperty: 'all',
transitionDuration: `${transitionDurationInSeconds}s`,
transitionTimingFunction: 'ease-out',
...transformerStyle,
},
});
});
/**
* 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 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 children to render as grid cells. Each child receives data-lk-index
* and data-lk-highlighted props.
*/
children?: ReactNode;
} & VariantProps<typeof AgentAudioVisualizerGridVariants>;
/**
* 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
* <AgentAudioVisualizerGrid
* size="md"
* state="speaking"
* rowCount={5}
* columnCount={5}
* audioTrack={agentAudioTrack}
* />
* ```
*/
export function AgentAudioVisualizerGrid({
size = 'md',
state = 'connecting',
radius,
rowCount: _rowCount = 5,
columnCount: _columnCount = 5,
transformer,
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,
});
return (
<div
className={cn(AgentAudioVisualizerGridVariants({ size }), className)}
style={{ ...style, gridTemplateColumns: `repeat(${columnCount}, 1fr)` }}
{...props}
>
{items.map((idx) => (
<GridCell
key={idx}
index={idx}
state={state}
interval={interval}
transformer={transformer}
rowCount={rowCount}
columnCount={columnCount}
volumeBands={volumeBands}
highlightedCoordinate={highlightedCoordinate}
>
{children ?? <div />}
</GridCell>
))}
</div>
);
}
@@ -0,0 +1,205 @@
'use client';
import { type ComponentProps, 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 { useAgentAudioVisualizerRadialAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-radial';
import { cn } from '@/lib/shadcn/utils';
export const AgentAudioVisualizerRadialVariants = cva(
[
'relative flex items-center justify-center',
'[&_[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]]:bg-transparent [&_[data-lk-index]]:data-[lk-highlighted=true]:bg-current',
'has-data-[lk-state=connecting]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=connecting]:[&_[data-lk-index]]:bg-current/10',
'has-data-[lk-state=initializing]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=initializing]:[&_[data-lk-index]]:bg-current/10',
'has-data-[lk-state=listening]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=listening]:[&_[data-lk-index]]:bg-current/10 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 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
* <AgentAudioVisualizerRadial
* size="lg"
* state="speaking"
* barCount={24}
* audioTrack={agentAudioTrack}
* />
* ```
*/
export function AgentAudioVisualizerRadial({
size = 'md',
state = 'connecting',
radius,
barCount,
audioTrack,
className,
...props
}: AgentAudioVisualizerRadialProps &
ComponentProps<'div'> &
VariantProps<typeof AgentAudioVisualizerRadialVariants>) {
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 (
<div
className={cn(AgentAudioVisualizerRadialVariants({ size }), 'relative', className)}
{...props}
>
{bands.map((band, idx) => {
const angle = (idx / _barCount) * Math.PI * 2;
return (
<div
key={`${_barCount}-${idx}`}
data-lk-state={state}
className="absolute top-1/2 left-1/2 h-1 w-1 -translate-x-1/2 -translate-y-1/2"
style={{
transformOrigin: 'center',
transform: `rotate(${angle}rad) translateY(${distanceFromCenter}px)`,
}}
>
<div
data-lk-index={idx}
data-lk-highlighted={highlightedIndices.includes(idx)}
style={{
width: dotSize,
minHeight: dotSize,
height: state === 'speaking' ? `${dotSize * 10 * band}px` : 0,
}}
/>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,89 @@
import { type Ref } from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { type MotionProps, motion } from 'motion/react';
import { cn } from '@/lib/shadcn/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('bg-muted-foreground inline-block size-2.5 rounded-full', {
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<HTMLSpanElement>;
}
/**
* 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' && <AgentChatIndicator size="md" />}
* ```
*/
export function AgentChatIndicator({
size = 'md',
className,
...props
}: AgentChatIndicatorProps & VariantProps<typeof agentChatIndicatorVariants>) {
return (
<motion.span
{...motionAnimationProps}
transition={{ duration: 0.1, ease: 'linear' as const }}
className={cn(agentChatIndicatorVariants({ size }), className)}
{...props}
/>
);
}
@@ -0,0 +1,78 @@
'use client';
import { AnimatePresence } from 'motion/react';
import { type AgentState, type ReceivedMessage } from '@livekit/components-react';
import { AgentChatIndicator } from '@/components/agents-ui/agent-chat-indicator';
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message';
/**
* Props for the AgentChatTranscript component.
*/
export interface AgentChatTranscriptProps {
/**
* 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
* <AgentChatTranscript
* agentState={agentState}
* messages={chatMessages}
* />
* ```
*/
export function AgentChatTranscript({
agentState,
messages = [],
className,
...props
}: AgentChatTranscriptProps) {
return (
<Conversation className={className} {...props}>
<ConversationContent>
{messages.map((receivedMessage) => {
const { id, timestamp, from, message } = receivedMessage;
const locale = navigator?.language ?? 'en-US';
const messageOrigin = from?.isLocal ? 'user' : 'assistant';
const time = new Date(timestamp);
const title = time.toLocaleTimeString(locale, { timeStyle: 'full' });
return (
<Message key={id} title={title} from={messageOrigin}>
<MessageContent>
<MessageResponse>{message}</MessageResponse>
</MessageContent>
</Message>
);
})}
<AnimatePresence>
{agentState === 'thinking' && <AgentChatIndicator size="sm" />}
</AnimatePresence>
</ConversationContent>
<ConversationScrollButton />
</Conversation>
);
}
@@ -0,0 +1,392 @@
'use client';
import { type ComponentProps, useEffect, useRef, useState } from 'react';
import { Track } from 'livekit-client';
import { Loader, MessageSquareTextIcon, SendHorizontal } from 'lucide-react';
import { motion } from 'motion/react';
import { useChat } from '@livekit/components-react';
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 {
type UseInputControlsProps,
useInputControls,
usePublishPermissions,
} from '@/hooks/agents-ui/use-agent-control-bar';
import { cn } from '@/lib/shadcn/utils';
const 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 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 = {
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<HTMLTextAreaElement>(null);
const [isSending, setIsSending] = useState(false);
const [message, setMessage] = useState<string>('');
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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 (
<form
onSubmit={handleSubmit}
className={cn('mb-3 flex grow items-end gap-2 rounded-md pl-1 text-sm', className)}
>
<textarea
autoFocus
ref={inputRef}
value={message}
disabled={!chatOpen}
placeholder="Type something..."
onChange={(e) => setMessage(e.target.value)}
className="field-sizing-content max-h-16 min-h-8 flex-1 py-2 [scrollbar-width:thin] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
<Button
size="icon"
type="submit"
disabled={isDisabled}
variant={isDisabled ? 'secondary' : 'default'}
title={isSending ? 'Sending...' : 'Send'}
className="self-end disabled:cursor-not-allowed"
>
{isSending ? <Loader className="animate-spin" /> : <SendHorizontal />}
</Button>
</form>
);
}
/**
* 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.
*
* @extends ComponentProps<'div'>
*
* @example
* ```tsx
* <AgentControlBar
* variant="livekit"
* isConnected={true}
* onDisconnect={() => handleDisconnect()}
* controls={{
* microphone: true,
* camera: true,
* screenShare: false,
* chat: true,
* leave: true,
* }}
* />
* ```
*/
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 {
micTrackRef,
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 (
<div
aria-label="Voice assistant controls"
className={cn(
'bg-background border-input/50 dark:border-muted flex flex-col border p-3 drop-shadow-md/3',
variant === 'livekit' ? 'rounded-[31px]' : 'rounded-lg',
className
)}
{...props}
>
<motion.div
{...MOTION_PROPS}
inert={!(isChatOpen || isChatOpenUncontrolled)}
animate={isChatOpen || isChatOpenUncontrolled ? 'visible' : 'hidden'}
className="border-input/50 flex w-full items-start overflow-hidden border-b"
>
<AgentChatInput
chatOpen={isChatOpen || isChatOpenUncontrolled}
onSend={handleSendMessage}
className={cn(variant === 'livekit' && '[&_button]:rounded-full')}
/>
</motion.div>
<div className="flex gap-1">
<div className="flex grow gap-1">
{/* Toggle Microphone */}
{visibleControls.microphone && (
<AgentTrackControl
variant={variant === 'outline' ? 'outline' : 'default'}
kind="audioinput"
aria-label="Toggle microphone"
source={Track.Source.Microphone}
pressed={microphoneToggle.enabled}
disabled={microphoneToggle.pending}
audioTrack={micTrackRef}
onPressedChange={microphoneToggle.toggle}
onActiveDeviceChange={handleAudioDeviceChange}
onMediaDeviceError={handleMicrophoneDeviceSelectError}
className={cn(
variant === 'livekit' && [
TOGGLE_VARIANT_1,
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
]
)}
/>
)}
{/* Toggle Camera */}
{visibleControls.camera && (
<AgentTrackControl
variant={variant === 'outline' ? 'outline' : 'default'}
kind="videoinput"
aria-label="Toggle camera"
source={Track.Source.Camera}
pressed={cameraToggle.enabled}
pending={cameraToggle.pending}
disabled={cameraToggle.pending}
onPressedChange={cameraToggle.toggle}
onMediaDeviceError={handleCameraDeviceSelectError}
onActiveDeviceChange={handleVideoDeviceChange}
className={cn(
variant === 'livekit' && [
TOGGLE_VARIANT_1,
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
]
)}
/>
)}
{/* Toggle Screen Share */}
{visibleControls.screenShare && (
<AgentTrackToggle
variant={variant === 'outline' ? 'outline' : 'default'}
aria-label="Toggle screen share"
source={Track.Source.ScreenShare}
pressed={screenShareToggle.enabled}
disabled={screenShareToggle.pending}
onPressedChange={screenShareToggle.toggle}
className={cn(variant === 'livekit' && [TOGGLE_VARIANT_2, 'rounded-full'])}
/>
)}
{/* Toggle Transcript */}
{visibleControls.chat && (
<Toggle
variant={variant === 'outline' ? 'outline' : 'default'}
pressed={isChatOpen || isChatOpenUncontrolled}
aria-label="Toggle transcript"
onPressedChange={(state) => {
if (!onIsChatOpenChange) setIsChatOpenUncontrolled(state);
else onIsChatOpenChange(state);
}}
className={agentTrackToggleVariants({
variant: variant === 'outline' ? 'outline' : 'default',
className: cn(variant === 'livekit' && [TOGGLE_VARIANT_2, 'rounded-full']),
})}
>
<MessageSquareTextIcon />
</Toggle>
)}
</div>
{/* Disconnect */}
{visibleControls.leave && (
<AgentDisconnectButton
onClick={onDisconnect}
disabled={!isConnected}
className={cn(
variant === 'livekit' &&
'bg-destructive/10 dark:bg-destructive/10 text-destructive hover:bg-destructive/20 dark:hover:bg-destructive/20 focus:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/4 rounded-full font-mono text-xs font-bold tracking-wider'
)}
>
<span className="hidden md:inline">END CALL</span>
<span className="inline md:hidden">END</span>
</AgentDisconnectButton>
)}
</div>
</div>
);
}
@@ -0,0 +1,35 @@
'use client';
import { type VariantProps } from 'class-variance-authority';
import { PhoneOffIcon } from 'lucide-react';
import { useSessionContext } from '@livekit/components-react';
import { Button, buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/shadcn/utils';
export interface AgentDisconnectButtonProps
extends React.ComponentProps<'button'>,
VariantProps<typeof buttonVariants> {
icon?: React.ReactNode;
children?: React.ReactNode;
}
export function AgentDisconnectButton({
icon,
size = 'default',
children,
onClick,
...props
}: AgentDisconnectButtonProps) {
const { end } = useSessionContext();
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
end();
};
return (
<Button variant="destructive" size={size} onClick={handleClick} {...props}>
{icon ?? <PhoneOffIcon />}
{children ?? <span className={cn(size?.includes('icon') && 'sr-only')}>END CALL</span>}
</Button>
);
}
@@ -0,0 +1,61 @@
import { Room } from 'livekit-client';
import {
RoomAudioRenderer,
type RoomAudioRendererProps,
SessionProvider,
type SessionProviderProps,
type UseSessionReturn,
} from '@livekit/components-react';
/**
* 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
* <AgentSessionProvider session={agentSession}>
* <AgentControlBar />
* <AgentChatTranscript />
* </AgentSessionProvider>
* ```
*/
export function AgentSessionProvider({
session,
children,
...roomAudioRendererProps
}: AgentSessionProviderProps) {
return (
<SessionProvider session={session}>
{children}
<RoomAudioRenderer {...roomAudioRendererProps} />
</SessionProvider>
);
}
@@ -0,0 +1,323 @@
'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/shadcn/utils';
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',
},
}
);
/**
* Props for the TrackDeviceSelect component. */
type TrackDeviceSelectProps = React.ComponentProps<typeof SelectTrigger> &
VariantProps<typeof selectVariants> & {
/**
* 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
* <TrackDeviceSelect
* size="sm"
* variant="outline"
* kind="audioinput"
* track={micTrackRef}
* />
* ```
*/
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 (
<Select
open={open}
value={activeDeviceId}
onOpenChange={handleOpenChange}
onValueChange={handleActiveDeviceChange}
>
<SelectTrigger className={cn(selectVariants({ size, variant }), className)} {...props}>
{size !== 'sm' && (
<SelectValue className="font-mono text-sm" placeholder={`Select a ${kind}`} />
)}
</SelectTrigger>
<SelectContent position="popper">
{filteredDevices.map((device) => (
<SelectItem key={device.deviceId} value={device.deviceId} className="font-mono text-xs">
{device.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/**
* Props for the AgentTrackControl component.
*/
export type AgentTrackControlProps = VariantProps<typeof toggleVariants> & {
/**
* 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
* <AgentTrackControl
* kind="audioinput"
* source={Track.Source.Microphone}
* pressed={isMicEnabled}
* audioTrack={micTrackRef}
* onPressedChange={(pressed) => setMicEnabled(pressed)}
* onActiveDeviceChange={(deviceId) => setMicDevice(deviceId)}
* />
* ```
*/
export function AgentTrackControl({
kind,
variant = 'default',
source,
pressed,
pending,
disabled,
className,
audioTrack,
onPressedChange,
onMediaDeviceError,
onActiveDeviceChange,
}: AgentTrackControlProps) {
return (
<div
className={cn(
'flex items-center gap-0 rounded-md',
variant === 'outline' && 'shadow-xs [&_button]:shadow-none',
className
)}
>
<AgentTrackToggle
variant={variant ?? 'default'}
source={source}
pressed={pressed}
pending={pending}
disabled={disabled}
onPressedChange={onPressedChange}
className="peer/track group/track focus:z-10 has-[.audiovisualizer]:w-auto has-[.audiovisualizer]:px-3 has-[~_button]:rounded-r-none has-[~_button]:border-r-0 has-[~_button]:pr-2 has-[~_button]:pl-3"
>
{audioTrack && (
<AgentAudioVisualizerBar
size="icon"
barCount={3}
state={pressed ? 'speaking' : 'disconnected'}
audioTrack={pressed ? audioTrack : undefined}
className="audiovisualizer flex h-6 w-auto items-center justify-center gap-0.5"
>
<span
className={cn([
'h-full w-0.5 origin-center',
'group-data-[state=on]/track:bg-foreground group-data-[state=off]/track:bg-destructive',
'data-lk-muted:bg-muted',
])}
/>
</AgentAudioVisualizerBar>
)}
</AgentTrackToggle>
{kind && (
<TrackDeviceSelect
size="sm"
kind={kind}
variant={variant}
requestPermissions={false}
onMediaDeviceError={onMediaDeviceError}
onActiveDeviceChange={onActiveDeviceChange}
className={cn([
'relative',
'before:bg-border before:absolute before:inset-y-0 before:left-0 before:my-2.5 before:w-px has-[~_button]:before:content-[""]',
!pressed && 'before:bg-destructive/20',
])}
/>
)}
</div>
);
}
@@ -0,0 +1,142 @@
import { type ComponentProps, Fragment } from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { Track } from 'livekit-client';
import {
LoaderIcon,
MicIcon,
MicOffIcon,
MonitorOffIcon,
MonitorUpIcon,
VideoIcon,
VideoOffIcon,
} from 'lucide-react';
import { Toggle, toggleVariants } from '@/components/ui/toggle';
import { cn } from '@/lib/shadcn/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 Fragment;
}
}
/**
* Props for the AgentTrackToggle component.
*/
export type AgentTrackToggleProps = VariantProps<typeof toggleVariants> &
ComponentProps<'button'> & {
/**
* 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
* <AgentTrackToggle
* source={Track.Source.Microphone}
* pressed={isMicEnabled}
* onPressedChange={(pressed) => setMicEnabled(pressed)}
* />
* ```
*/
export function AgentTrackToggle({
size = 'default',
variant = 'default',
source,
pending = false,
pressed = false,
defaultPressed = false,
className,
onPressedChange,
...props
}: AgentTrackToggleProps) {
const IconComponent = getSourceIcon(source as Track.Source, pressed ?? false, pending);
return (
<Toggle
size={size}
variant={variant}
pressed={pressed}
defaultPressed={defaultPressed}
aria-label={`Toggle ${source}`}
onPressedChange={onPressedChange}
className={cn(
agentTrackToggleVariants({
variant: variant ?? 'default',
className,
})
)}
{...props}
>
<IconComponent className={cn(pending && 'animate-spin')} />
{props.children}
</Toggle>
);
}
@@ -0,0 +1,57 @@
import { type ComponentProps } from 'react';
import { Room } from 'livekit-client';
import { useEnsureRoom, useStartAudio } from '@livekit/components-react';
import { Button } from '@/components/ui/button';
/**
* 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
* <StartAudioButton label="Click to allow audio playback" />
* ```
*/
export function StartAudioButton({
size = 'default',
variant = 'default',
label,
room,
...props
}: StartAudioButtonProps) {
const roomEnsured = useEnsureRoom(room);
const { mergedProps } = useStartAudio({ room: roomEnsured, props });
return (
<Button size={size} variant={variant} {...mergedProps}>
{label}
</Button>
);
}
@@ -0,0 +1,90 @@
'use client';
import type { ComponentProps } from 'react';
import { useCallback } from 'react';
import { ArrowDownIcon } from 'lucide-react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/shadcn/utils';
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-hidden', className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
<StickToBottom.Content className={cn('flex flex-col gap-8 p-4', className)} {...props} />
);
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) => (
<div
className={cn(
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="text-sm font-medium">{title}</h3>
{description && <p className="text-muted-foreground text-sm">{description}</p>}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
'dark:bg-background dark:hover:bg-muted absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
@@ -0,0 +1,367 @@
'use client';
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
import { createContext, memo, useContext, useEffect, useState } from 'react';
import type { FileUIPart, UIMessage } from 'ai';
import { ChevronLeftIcon, ChevronRightIcon, PaperclipIcon, XIcon } from 'lucide-react';
import { Streamdown } from 'streamdown';
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/shadcn/utils';
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role'];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
'group flex w-full max-w-[95%] flex-col gap-2',
from === 'user' ? 'is-user ml-auto justify-end' : 'is-assistant',
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
<div
className={cn(
'is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm',
'group-[.is-user]:bg-secondary group-[.is-user]:text-foreground group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:px-4 group-[.is-user]:py-3',
'group-[.is-assistant]:text-foreground',
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<'div'>;
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
<div className={cn('flex items-center gap-1', className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = 'ghost',
size = 'icon-sm',
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
type MessageBranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const MessageBranchContext = createContext<MessageBranchContextType | null>(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<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: MessageBranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<MessageBranchContext.Provider value={contextValue}>
<div className={cn('grid w-full gap-2 [&>div]:pb-0', className)} {...props} />
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = Array.isArray(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
className={cn(
'grid gap-2 overflow-hidden [&>div]:pb-0',
index === currentBranch ? 'block' : 'hidden'
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role'];
};
export const MessageBranchSelector = ({
className,
from,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({ children, className, ...props }: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn('text-muted-foreground border-none bg-transparent shadow-none', className)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn('size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0', className)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
);
MessageResponse.displayName = 'MessageResponse';
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: FileUIPart;
className?: string;
onRemove?: () => void;
};
export function MessageAttachment({ data, className, onRemove, ...props }: MessageAttachmentProps) {
const filename = data.filename || '';
const mediaType = data.mediaType?.startsWith('image/') && data.url ? 'image' : 'file';
const isImage = mediaType === 'image';
const attachmentLabel = filename || (isImage ? 'Image' : 'Attachment');
return (
<div className={cn('group relative size-24 overflow-hidden rounded-lg', className)} {...props}>
{isImage ? (
<>
<img
alt={filename || 'attachment'}
className="size-full object-cover"
height={100}
src={data.url}
width={100}
/>
{onRemove && (
<Button
aria-label="Remove attachment"
className="bg-background/80 hover:bg-background absolute top-2 right-2 size-6 rounded-full p-0 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
) : (
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="bg-muted text-muted-foreground flex size-full shrink-0 items-center justify-center rounded-lg">
<PaperclipIcon className="size-4" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{attachmentLabel}</p>
</TooltipContent>
</Tooltip>
{onRemove && (
<Button
aria-label="Remove attachment"
className="hover:bg-accent size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
)}
</div>
);
}
export type MessageAttachmentsProps = ComponentProps<'div'>;
export function MessageAttachments({ children, className, ...props }: MessageAttachmentsProps) {
if (!children) {
return null;
}
return (
<div className={cn('ml-auto flex w-fit flex-wrap items-start gap-2', className)} {...props}>
{children}
</div>
);
}
export type MessageToolbarProps = ComponentProps<'div'>;
export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => (
<div className={cn('mt-4 flex w-full items-center justify-between gap-4', className)} {...props}>
{children}
</div>
);
@@ -0,0 +1,53 @@
'use client';
import { type CSSProperties, type ElementType, type JSX, memo, useMemo } from 'react';
import { motion } from 'motion/react';
import { cn } from '@/lib/shadcn/utils';
export type 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 = motion.create(Component as keyof JSX.IntrinsicElements);
const dynamicSpread = useMemo(() => (children?.length ?? 0) * spread, [children, spread]);
return (
<MotionComponent
animate={{ backgroundPosition: '0% center' }}
className={cn(
'relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent',
'[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))]',
className
)}
initial={{ backgroundPosition: '100% center' }}
style={
{
'--spread': `${dynamicSpread}px`,
backgroundImage:
'var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))',
} as CSSProperties
}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: 'linear',
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);
@@ -0,0 +1,64 @@
'use client';
import { useMemo } from 'react';
import { TokenSource } from 'livekit-client';
import { useSession } from '@livekit/components-react';
import { WarningIcon } from '@phosphor-icons/react/dist/ssr';
import type { AppConfig } from '@/app-config';
import { AgentSessionProvider } from '@/components/agents-ui/agent-session-provider';
import { StartAudioButton } from '@/components/agents-ui/start-audio-button';
import { ViewController } from '@/components/app/view-controller';
import { Toaster } from '@/components/ui/sonner';
import { useAgentErrors } from '@/hooks/useAgentErrors';
import { useDebugMode } from '@/hooks/useDebug';
import { getSandboxTokenSource } from '@/lib/utils';
const IN_DEVELOPMENT = process.env.NODE_ENV !== 'production';
function AppSetup() {
useDebugMode({ enabled: IN_DEVELOPMENT });
useAgentErrors();
return null;
}
interface AppProps {
appConfig: AppConfig;
}
export function App({ appConfig }: AppProps) {
const tokenSource = useMemo(() => {
return typeof process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT === 'string'
? getSandboxTokenSource(appConfig)
: TokenSource.endpoint('/api/connection-details');
}, [appConfig]);
const session = useSession(
tokenSource,
appConfig.agentName ? { agentName: appConfig.agentName } : undefined
);
return (
<AgentSessionProvider session={session}>
<AppSetup />
<main className="grid h-svh grid-cols-1 place-content-center">
<ViewController appConfig={appConfig} />
</main>
<StartAudioButton label="Start Audio" />
<Toaster
icons={{
warning: <WarningIcon weight="bold" />,
}}
position="top-center"
className="toaster group"
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
} as React.CSSProperties
}
/>
</AgentSessionProvider>
);
}
@@ -0,0 +1,65 @@
'use client';
import { AnimatePresence, type HTMLMotionProps, motion } from 'motion/react';
import { type ReceivedMessage, useAgent } from '@livekit/components-react';
import { AgentChatTranscript } from '@/components/agents-ui/agent-chat-transcript';
import { cn } from '@/lib/shadcn/utils';
const MotionContainer = motion.create('div');
const CONTAINER_MOTION_PROPS = {
variants: {
hidden: {
opacity: 0,
transition: {
ease: 'easeOut',
duration: 0.3,
},
},
visible: {
opacity: 1,
transition: {
delay: 0.2,
ease: 'easeOut',
duration: 0.3,
},
},
},
initial: 'hidden',
animate: 'visible',
exit: 'hidden',
};
interface ChatTranscriptProps {
hidden?: boolean;
messages?: ReceivedMessage[];
}
export function ChatTranscript({
hidden = false,
messages = [],
className,
...props
}: ChatTranscriptProps & Omit<HTMLMotionProps<'div'>, 'ref'>) {
const { state: agentState } = useAgent();
return (
<div className="absolute top-0 bottom-[135px] flex w-full flex-col md:bottom-[170px]">
<AnimatePresence>
{!hidden && (
<MotionContainer
{...props}
{...CONTAINER_MOTION_PROPS}
className={cn('flex h-full w-full flex-col gap-4', className)}
>
<AgentChatTranscript
agentState={agentState}
messages={messages}
className="mx-auto w-full max-w-2xl [&_.is-user>div]:rounded-[22px] [&>div>div]:px-4 [&>div>div]:pt-40 md:[&>div>div]:px-6"
/>
</MotionContainer>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,96 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { XIcon } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { type GeneratedImage, useGeneratedImages } from '@/hooks/useGeneratedImages';
import { cn } from '@/lib/shadcn/utils';
const MotionPanel = motion.create('div');
interface ImageCardProps {
image: GeneratedImage;
onDismiss: () => void;
}
function ImageCard({ image, onDismiss }: ImageCardProps) {
const src = image.imageUrl;
return (
<MotionPanel
key={image.id}
layout
initial={{ opacity: 0, scale: 0.92, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: 8 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className="bg-background border-input/50 relative overflow-hidden rounded-xl border shadow-xl"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={image.prompt} className="block max-h-[360px] w-full object-contain" />
{image.prompt && (
<div className="bg-background/80 px-3 py-1.5 backdrop-blur-sm">
<p className="text-muted-foreground line-clamp-2 text-xs">{image.prompt}</p>
</div>
)}
<button
onClick={onDismiss}
className={cn(
'bg-background/70 hover:bg-background absolute top-2 right-2 rounded-full p-1 backdrop-blur-sm transition-colors'
)}
aria-label="Dismiss image"
>
<XIcon className="text-muted-foreground size-3.5" />
</button>
</MotionPanel>
);
}
interface GeneratedImagePanelProps {
chatOpen?: boolean;
}
/**
* Listens for images generated by the agent and shows the most recent one.
* When chat is open, repositions to the top-right corner to avoid covering the transcript.
*
* HACK HERE: swap for a gallery view, a fullscreen modal, download button, etc.
*/
export function GeneratedImagePanel({ chatOpen = false }: GeneratedImagePanelProps) {
const images = useGeneratedImages();
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
const prevLengthRef = useRef(0);
// Auto-scroll to latest image
useEffect(() => {
if (images.length > prevLengthRef.current) {
prevLengthRef.current = images.length;
}
}, [images]);
const visible = images.filter((img) => !dismissed.has(img.id));
const latest = visible.at(-1);
const dismiss = (id: string) => {
setDismissed((prev) => new Set([...prev, id]));
};
return (
<div
className={cn(
'pointer-events-none fixed z-40 flex justify-center px-4',
chatOpen
? 'top-16 right-4 bottom-auto left-auto justify-end'
: 'inset-x-0 bottom-36 md:bottom-44'
)}
>
<div className={cn('pointer-events-auto', chatOpen ? 'w-48' : 'w-full max-w-sm')}>
<AnimatePresence mode="popLayout">
{latest && (
<ImageCard key={latest.id} image={latest} onDismiss={() => dismiss(latest.id)} />
)}
</AnimatePresence>
</div>
</div>
);
}
@@ -0,0 +1,180 @@
'use client';
import { useState } from 'react';
import { ChevronLeftIcon, ImagesIcon, XIcon } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { type GeneratedImage, useGeneratedImages } from '@/hooks/useGeneratedImages';
import { cn } from '@/lib/shadcn/utils';
const MotionPanel = motion.create('div');
const MotionOverlay = motion.create('div');
interface ThumbnailProps {
image: GeneratedImage;
onClick: () => void;
}
function Thumbnail({ image, onClick }: ThumbnailProps) {
return (
<button
onClick={onClick}
className="group border-input/50 bg-muted hover:border-foreground/20 focus-visible:ring-ring relative aspect-square overflow-hidden rounded-lg border transition-all hover:shadow-md focus-visible:ring-2 focus-visible:outline-none"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.imageUrl}
alt={image.prompt}
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
/>
</button>
);
}
interface FullImageViewProps {
image: GeneratedImage;
onBack: () => void;
}
function FullImageView({ image, onBack }: FullImageViewProps) {
return (
<MotionPanel
key="full"
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 24 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex h-full flex-col"
>
<button
onClick={onBack}
className="text-muted-foreground hover:text-foreground mb-3 flex items-center gap-1 text-sm transition-colors"
>
<ChevronLeftIcon className="size-4" />
All images
</button>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.imageUrl} alt={image.prompt} className="w-full rounded-xl object-contain" />
{image.prompt && <p className="text-muted-foreground mt-3 text-sm">{image.prompt}</p>}
</MotionPanel>
);
}
/**
* Floating gallery button + slide-in panel for all agent-generated images.
* The button appears once at least one image has been generated.
*/
export function ImageGallery() {
const images = useGeneratedImages();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<GeneratedImage | null>(null);
if (images.length === 0) return null;
return (
<>
{/* Floating trigger button */}
<AnimatePresence>
{!open && (
<MotionPanel
key="trigger"
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="fixed right-4 bottom-36 z-40 md:bottom-44"
>
<button
onClick={() => setOpen(true)}
aria-label="View generated images"
className={cn(
'relative flex items-center justify-center rounded-full p-3',
'bg-background border-input/50 border shadow-lg',
'hover:bg-accent focus-visible:ring-ring transition-colors focus-visible:ring-2 focus-visible:outline-none'
)}
>
<ImagesIcon className="text-foreground size-5" />
<span className="bg-primary text-primary-foreground absolute -top-1.5 -right-1.5 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold">
{images.length}
</span>
</button>
</MotionPanel>
)}
</AnimatePresence>
{/* Gallery panel + backdrop */}
<AnimatePresence>
{open && (
<>
<MotionOverlay
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm"
onClick={() => {
setSelected(null);
setOpen(false);
}}
/>
<MotionPanel
key="panel"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className="border-input/50 bg-background fixed top-0 right-0 bottom-0 z-[60] flex w-80 flex-col overflow-hidden border-l shadow-2xl md:top-16"
>
{/* Header */}
<div className="border-input/50 flex items-center justify-between border-b px-4 py-3">
<div>
<h2 className="text-sm font-semibold">Generated images</h2>
<p className="text-muted-foreground text-xs">
{images.length} {images.length === 1 ? 'image' : 'images'}
</p>
</div>
<button
onClick={() => {
setSelected(null);
setOpen(false);
}}
aria-label="Close gallery"
className="hover:bg-accent focus-visible:ring-ring rounded-full p-1.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<XIcon className="text-muted-foreground size-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<AnimatePresence mode="wait">
{selected ? (
<FullImageView
key={selected.id}
image={selected}
onBack={() => setSelected(null)}
/>
) : (
<MotionPanel
key="grid"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="grid grid-cols-2 gap-2"
>
{[...images].reverse().map((img) => (
<Thumbnail key={img.id} image={img} onClick={() => setSelected(img)} />
))}
</MotionPanel>
)}
</AnimatePresence>
</div>
</MotionPanel>
</>
)}
</AnimatePresence>
</>
);
}
@@ -0,0 +1,170 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useSessionContext, useSessionMessages } from '@livekit/components-react';
import type { AppConfig } from '@/app-config';
import {
AgentControlBar,
type AgentControlBarControls,
} from '@/components/agents-ui/agent-control-bar';
import { ChatTranscript } from '@/components/app/chat-transcript';
import { GeneratedImagePanel } from '@/components/app/generated-image-panel';
import { ImageGallery } from '@/components/app/image-gallery';
import { TileLayout } from '@/components/app/tile-layout';
import { GeneratedImagesProvider } from '@/hooks/useGeneratedImages';
import { cn } from '@/lib/shadcn/utils';
import { Shimmer } from '../ai-elements/shimmer';
const MotionBottom = motion.create('div');
const MotionMessage = motion.create(Shimmer);
const BOTTOM_VIEW_MOTION_PROPS = {
variants: {
visible: {
opacity: 1,
translateY: '0%',
},
hidden: {
opacity: 0,
translateY: '100%',
},
},
initial: 'hidden',
animate: 'visible',
exit: 'hidden',
transition: {
duration: 0.3,
delay: 0.5,
ease: 'easeOut',
},
};
const SHIMMER_MOTION_PROPS = {
variants: {
visible: {
opacity: 1,
transition: {
ease: 'easeIn',
duration: 0.5,
delay: 0.8,
},
},
hidden: {
opacity: 0,
transition: {
ease: 'easeIn',
duration: 0.5,
delay: 0,
},
},
},
initial: 'hidden',
animate: 'visible',
exit: 'hidden',
};
interface FadeProps {
top?: boolean;
bottom?: boolean;
className?: string;
}
export function Fade({ top = false, bottom = false, className }: FadeProps) {
return (
<div
className={cn(
'from-background pointer-events-none h-4 bg-linear-to-b to-transparent',
top && 'bg-linear-to-b',
bottom && 'bg-linear-to-t',
className
)}
/>
);
}
interface SessionViewProps {
appConfig: AppConfig;
}
export const SessionView = ({
appConfig,
...props
}: React.ComponentProps<'section'> & SessionViewProps) => {
const session = useSessionContext();
const { messages } = useSessionMessages(session);
const [chatOpen, setChatOpen] = useState(false);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const controls: AgentControlBarControls = {
leave: true,
microphone: true,
chat: appConfig.supportsChatInput,
camera: appConfig.supportsVideoInput,
screenShare: appConfig.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 (
<section className="bg-background relative z-10 h-svh w-svw overflow-hidden" {...props}>
<Fade top className="absolute inset-x-4 top-0 z-10 h-40" />
{/* transcript */}
<ChatTranscript
hidden={!chatOpen}
messages={messages}
className="space-y-3 transition-opacity duration-300 ease-out"
/>
{/* Tile layout */}
<TileLayout chatOpen={chatOpen} />
{/* Single provider registers the byte stream handler once for both image components */}
<GeneratedImagesProvider>
{/* Generated image panel — appears when the agent calls generate_image */}
<GeneratedImagePanel chatOpen={chatOpen} />
{/* Gallery — persistent access to all generated images */}
<ImageGallery />
</GeneratedImagesProvider>
{/* Bottom */}
<MotionBottom
{...BOTTOM_VIEW_MOTION_PROPS}
className="fixed inset-x-3 bottom-0 z-50 md:inset-x-12"
>
{/* Pre-connect message */}
{appConfig.isPreConnectBufferEnabled && (
<AnimatePresence>
{messages.length === 0 && (
<MotionMessage
key="pre-connect-message"
duration={2}
aria-hidden={messages.length > 0}
{...SHIMMER_MOTION_PROPS}
className="pointer-events-none mx-auto block w-full max-w-2xl pb-4 text-center text-sm font-semibold"
>
Agent is listening, ask it a question
</MotionMessage>
)}
</AnimatePresence>
)}
<div className="bg-background relative mx-auto max-w-2xl pb-3 md:pb-12">
<Fade bottom className="absolute inset-x-0 top-0 h-4 -translate-y-full" />
<AgentControlBar
variant="livekit"
controls={controls}
isChatOpen={chatOpen}
isConnected={session.isConnected}
onDisconnect={session.end}
onIsChatOpenChange={setChatOpen}
/>
</div>
</MotionBottom>
</section>
);
};
@@ -0,0 +1,11 @@
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
@@ -0,0 +1,59 @@
'use client';
import { useTheme } from 'next-themes';
import { MonitorIcon, MoonIcon, SunIcon } from '@phosphor-icons/react';
import { cn } from '@/lib/shadcn/utils';
interface ThemeToggleProps {
className?: string;
}
export function ThemeToggle({ className }: ThemeToggleProps) {
const { theme, setTheme } = useTheme();
return (
<div
className={cn(
'text-foreground bg-background flex w-full flex-row justify-end divide-x overflow-hidden rounded-full border',
className
)}
>
<span className="sr-only">Color scheme toggle</span>
<button type="button" onClick={() => setTheme('dark')} className="cursor-pointer p-1 pl-1.5">
<span className="sr-only">Enable dark color scheme</span>
<MoonIcon
suppressHydrationWarning
size={16}
weight="bold"
className={cn(theme !== 'dark' && 'opacity-25')}
/>
</button>
<button
type="button"
onClick={() => setTheme('light')}
className="cursor-pointer px-1.5 py-1"
>
<span className="sr-only">Enable light color scheme</span>
<SunIcon
suppressHydrationWarning
size={16}
weight="bold"
className={cn(theme !== 'light' && 'opacity-25')}
/>
</button>
<button
type="button"
onClick={() => setTheme('system')}
className="cursor-pointer p-1 pr-1.5"
>
<span className="sr-only">Enable system color scheme</span>
<MonitorIcon
suppressHydrationWarning
size={16}
weight="bold"
className={cn(theme !== 'system' && 'opacity-25')}
/>
</button>
</div>
);
}
@@ -0,0 +1,237 @@
import React, { useMemo } from 'react';
import { Track } from 'livekit-client';
import { AnimatePresence, motion } from 'motion/react';
import {
type TrackReference,
VideoTrack,
useLocalParticipant,
useTracks,
useVoiceAssistant,
} from '@livekit/components-react';
import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar';
import { cn } from '@/lib/shadcn/utils';
const MotionContainer = motion.create('div');
const ANIMATION_TRANSITION = {
type: 'spring',
stiffness: 675,
damping: 75,
mass: 1,
};
const classNames = {
// 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<TrackReference | undefined>(
() => (publication ? { source, participant: localParticipant, publication } : undefined),
[source, publication, localParticipant]
);
return trackRef;
}
interface TileLayoutProps {
chatOpen: boolean;
}
export function TileLayout({ chatOpen }: TileLayoutProps) {
const {
state: agentState,
audioTrack: agentAudioTrack,
videoTrack: agentVideoTrack,
} = useVoiceAssistant();
const [screenShareTrack] = useTracks([Track.Source.ScreenShare]);
const cameraTrack: TrackReference | undefined = useLocalTrackRef(Track.Source.Camera);
const isCameraEnabled = cameraTrack && !cameraTrack.publication.isMuted;
const isScreenShareEnabled = screenShareTrack && !screenShareTrack.publication.isMuted;
const hasSecondTile = isCameraEnabled || isScreenShareEnabled;
const animationDelay = chatOpen ? 0 : 0.15;
const isAvatar = agentVideoTrack !== undefined;
const videoWidth = agentVideoTrack?.publication.dimensions?.width ?? 0;
const videoHeight = agentVideoTrack?.publication.dimensions?.height ?? 0;
return (
<div className="pointer-events-none fixed inset-x-0 top-8 bottom-32 z-50 md:top-12 md:bottom-40">
<div className="relative mx-auto h-full max-w-2xl px-4 md:px-0">
<div className={cn(classNames.grid)}>
{/* Agent */}
<div
className={cn([
'grid',
!chatOpen && classNames.agentChatClosed,
chatOpen && hasSecondTile && classNames.agentChatOpenWithSecondTile,
chatOpen && !hasSecondTile && classNames.agentChatOpenWithoutSecondTile,
])}
>
<AnimatePresence mode="popLayout">
{!isAvatar && (
// Audio Agent
<MotionContainer
key="agent"
layoutId="agent"
initial={{
opacity: 0,
scale: 0,
}}
animate={{
opacity: 1,
scale: chatOpen ? 1 : 4,
}}
transition={{
...ANIMATION_TRANSITION,
delay: animationDelay,
}}
className={cn(
'bg-background aspect-square h-[90px] rounded-md border border-transparent transition-[border,drop-shadow]',
chatOpen && 'border-input/50 drop-shadow-lg/10 delay-200'
)}
>
<AgentAudioVisualizerBar
barCount={5}
state={agentState}
audioTrack={agentAudioTrack}
className={cn('flex h-full items-center justify-center gap-1 px-4 py-2')}
>
<span
className={cn([
'bg-muted min-h-2.5 w-2.5 rounded-full',
'origin-center transition-colors duration-250 ease-linear',
'data-[lk-highlighted=true]:bg-foreground data-[lk-muted=true]:bg-muted',
])}
/>
</AgentAudioVisualizerBar>
</MotionContainer>
)}
{isAvatar && (
// Avatar Agent
<MotionContainer
key="avatar"
layoutId="avatar"
initial={{
scale: 1,
opacity: 1,
maskImage:
'radial-gradient(circle, rgba(0, 0, 0, 1) 0, rgba(0, 0, 0, 1) 20px, transparent 20px)',
filter: 'blur(20px)',
}}
animate={{
maskImage:
'radial-gradient(circle, rgba(0, 0, 0, 1) 0, rgba(0, 0, 0, 1) 500px, transparent 500px)',
filter: 'blur(0px)',
borderRadius: chatOpen ? 6 : 12,
}}
transition={{
...ANIMATION_TRANSITION,
delay: animationDelay,
maskImage: {
duration: 1,
},
filter: {
duration: 1,
},
}}
className={cn(
'overflow-hidden bg-black drop-shadow-xl/80',
chatOpen ? 'h-[90px]' : 'h-auto w-full'
)}
>
<VideoTrack
width={videoWidth}
height={videoHeight}
trackRef={agentVideoTrack}
className={cn(chatOpen && 'size-[90px] object-cover')}
/>
</MotionContainer>
)}
</AnimatePresence>
</div>
<div
className={cn([
'grid',
chatOpen && classNames.secondTileChatOpen,
!chatOpen && classNames.secondTileChatClosed,
])}
>
{/* Camera & Screen Share */}
<AnimatePresence>
{((cameraTrack && isCameraEnabled) || (screenShareTrack && isScreenShareEnabled)) && (
<MotionContainer
key="camera"
layout="position"
layoutId="camera"
initial={{
opacity: 0,
scale: 0,
}}
animate={{
opacity: 1,
scale: 1,
}}
exit={{
opacity: 0,
scale: 0,
}}
transition={{
...ANIMATION_TRANSITION,
delay: animationDelay,
}}
className="drop-shadow-lg/20"
>
<VideoTrack
trackRef={cameraTrack || screenShareTrack}
width={(cameraTrack || screenShareTrack)?.publication.dimensions?.width ?? 0}
height={(cameraTrack || screenShareTrack)?.publication.dimensions?.height ?? 0}
className="bg-muted aspect-square w-[90px] rounded-md object-cover"
/>
</MotionContainer>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { useSessionContext } from '@livekit/components-react';
import type { AppConfig } from '@/app-config';
import { SessionView } from '@/components/app/session-view';
import { WelcomeView } from '@/components/app/welcome-view';
const MotionWelcomeView = motion.create(WelcomeView);
const MotionSessionView = motion.create(SessionView);
const VIEW_MOTION_PROPS = {
variants: {
visible: {
opacity: 1,
},
hidden: {
opacity: 0,
},
},
initial: 'hidden',
animate: 'visible',
exit: 'hidden',
transition: {
duration: 0.5,
ease: 'linear',
},
};
interface ViewControllerProps {
appConfig: AppConfig;
}
export function ViewController({ appConfig }: ViewControllerProps) {
const { isConnected, start } = useSessionContext();
return (
<AnimatePresence mode="wait">
{/* Welcome view */}
{!isConnected && (
<MotionWelcomeView
key="welcome"
{...VIEW_MOTION_PROPS}
startButtonText={appConfig.startButtonText}
onStartCall={start}
/>
)}
{/* Session view */}
{isConnected && (
<MotionSessionView key="session-view" {...VIEW_MOTION_PROPS} appConfig={appConfig} />
)}
</AnimatePresence>
);
}
@@ -0,0 +1,65 @@
import { Button } from '@/components/ui/button';
function WelcomeImage() {
return (
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-fg0 mb-4 size-16"
>
<path
d="M15 24V40C15 40.7957 14.6839 41.5587 14.1213 42.1213C13.5587 42.6839 12.7956 43 12 43C11.2044 43 10.4413 42.6839 9.87868 42.1213C9.31607 41.5587 9 40.7957 9 40V24C9 23.2044 9.31607 22.4413 9.87868 21.8787C10.4413 21.3161 11.2044 21 12 21C12.7956 21 13.5587 21.3161 14.1213 21.8787C14.6839 22.4413 15 23.2044 15 24ZM22 5C21.2044 5 20.4413 5.31607 19.8787 5.87868C19.3161 6.44129 19 7.20435 19 8V56C19 56.7957 19.3161 57.5587 19.8787 58.1213C20.4413 58.6839 21.2044 59 22 59C22.7956 59 23.5587 58.6839 24.1213 58.1213C24.6839 57.5587 25 56.7957 25 56V8C25 7.20435 24.6839 6.44129 24.1213 5.87868C23.5587 5.31607 22.7956 5 22 5ZM32 13C31.2044 13 30.4413 13.3161 29.8787 13.8787C29.3161 14.4413 29 15.2044 29 16V48C29 48.7957 29.3161 49.5587 29.8787 50.1213C30.4413 50.6839 31.2044 51 32 51C32.7956 51 33.5587 50.6839 34.1213 50.1213C34.6839 49.5587 35 48.7957 35 48V16C35 15.2044 34.6839 14.4413 34.1213 13.8787C33.5587 13.3161 32.7956 13 32 13ZM42 21C41.2043 21 40.4413 21.3161 39.8787 21.8787C39.3161 22.4413 39 23.2044 39 24V40C39 40.7957 39.3161 41.5587 39.8787 42.1213C40.4413 42.6839 41.2043 43 42 43C42.7957 43 43.5587 42.6839 44.1213 42.1213C44.6839 41.5587 45 40.7957 45 40V24C45 23.2044 44.6839 22.4413 44.1213 21.8787C43.5587 21.3161 42.7957 21 42 21ZM52 17C51.2043 17 50.4413 17.3161 49.8787 17.8787C49.3161 18.4413 49 19.2044 49 20V44C49 44.7957 49.3161 45.5587 49.8787 46.1213C50.4413 46.6839 51.2043 47 52 47C52.7957 47 53.5587 46.6839 54.1213 46.1213C54.6839 45.5587 55 44.7957 55 44V20C55 19.2044 54.6839 18.4413 54.1213 17.8787C53.5587 17.3161 52.7957 17 52 17Z"
fill="currentColor"
/>
</svg>
);
}
interface WelcomeViewProps {
startButtonText: string;
onStartCall: () => void;
}
export const WelcomeView = ({
startButtonText,
onStartCall,
ref,
}: React.ComponentProps<'div'> & WelcomeViewProps) => {
return (
<div ref={ref}>
<section className="bg-background flex flex-col items-center justify-center text-center">
<WelcomeImage />
<p className="text-foreground max-w-prose pt-1 leading-6 font-medium">
Voice, vision, images, and music powered by Gemini 3.1, NanoBanana 2, and Lyria
</p>
<Button
size="lg"
onClick={onStartCall}
className="mt-6 w-64 rounded-full font-mono text-xs font-bold tracking-wider uppercase"
>
{startButtonText}
</Button>
</section>
<div className="fixed bottom-5 left-0 flex w-full items-center justify-center">
<p className="text-muted-foreground max-w-prose pt-1 text-xs leading-5 font-normal text-pretty md:text-sm">
New to LiveKit Agents? Check out the{' '}
<a
target="_blank"
rel="noopener noreferrer"
href="https://docs.livekit.io/agents/"
className="underline"
>
LiveKit Agents docs
</a>
.
</p>
</div>
</div>
);
};
@@ -0,0 +1,59 @@
import * as React from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { cn } from '@/lib/shadcn/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };
@@ -0,0 +1,77 @@
import { type VariantProps, cva } from 'class-variance-authority';
import { Slot } from '@radix-ui/react-slot';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/shadcn/utils';
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
vertical:
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
},
},
defaultVariants: {
orientation: 'horizontal',
},
}
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
className
)}
{...props}
/>
);
}
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
@@ -0,0 +1,59 @@
import * as React from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/lib/shadcn/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -0,0 +1,174 @@
'use client';
import * as React from 'react';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { cn } from '@/lib/shadcn/utils';
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'item-aligned',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -0,0 +1,27 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/shadcn/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...props}
/>
);
}
export { Separator };
@@ -0,0 +1,40 @@
'use client';
import { useTheme } from 'next-themes';
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from 'lucide-react';
import { Toaster as Sonner, type ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };
@@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cn } from '@/lib/shadcn/utils';
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };
@@ -0,0 +1,56 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/shadcn/utils';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };