diff --git a/README.md b/README.md
index e190a3ee..0ace0ca5 100644
--- a/README.md
+++ b/README.md
@@ -77,23 +77,34 @@ This starter is designed to be flexible so you can adapt it to your specific age
#### Example: App configuration (`app-config.ts`)
```ts
-export const APP_CONFIG_DEFAULTS = {
+export const APP_CONFIG_DEFAULTS: AppConfig = {
companyName: 'LiveKit',
pageTitle: 'LiveKit Voice Agent',
pageDescription: 'A voice agent built with LiveKit',
+
supportsChatInput: true,
supportsVideoInput: true,
supportsScreenShare: true,
+ isPreConnectBufferEnabled: true,
+
logo: '/lk-logo.svg',
accent: '#002cf2',
logoDark: '/lk-logo-dark.svg',
accentDark: '#1fd5f9',
startButtonText: 'Start call',
+
+ // for LiveKit Cloud Sandbox
+ sandboxId: undefined,
+ agentName: undefined,
};
```
You can update these values in [`app-config.ts`](./app-config.ts) to customize branding, features, and UI text for your deployment.
+> [!NOTE]
+> The `sandboxId` and `agentName` are for the LiveKit Cloud Sandbox environment.
+> They are not used for local development.
+
#### Environment Variables
You'll also need to configure your LiveKit credentials in `.env.local` (copy `.env.example` if you don't have one):
diff --git a/app-config.ts b/app-config.ts
index 87d97dea..cb1a9fd5 100644
--- a/app-config.ts
+++ b/app-config.ts
@@ -1,4 +1,23 @@
-import type { AppConfig } from './lib/types';
+export interface AppConfig {
+ pageTitle: string;
+ pageDescription: string;
+ companyName: string;
+
+ supportsChatInput: boolean;
+ supportsVideoInput: boolean;
+ supportsScreenShare: boolean;
+ isPreConnectBufferEnabled: boolean;
+
+ logo: string;
+ startButtonText: string;
+ accent?: string;
+ logoDark?: string;
+ accentDark?: string;
+
+ // for LiveKit Cloud Sandbox
+ sandboxId?: string;
+ agentName?: string;
+}
export const APP_CONFIG_DEFAULTS: AppConfig = {
companyName: 'LiveKit',
@@ -16,5 +35,7 @@ export const APP_CONFIG_DEFAULTS: AppConfig = {
accentDark: '#1fd5f9',
startButtonText: 'Start call',
+ // for LiveKit Cloud Sandbox
+ sandboxId: undefined,
agentName: undefined,
};
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
index 25847feb..1f56a3ff 100644
--- a/app/(app)/layout.tsx
+++ b/app/(app)/layout.tsx
@@ -1,11 +1,11 @@
import { headers } from 'next/headers';
import { getAppConfig } from '@/lib/utils';
-interface AppLayoutProps {
+interface LayoutProps {
children: React.ReactNode;
}
-export default async function AppLayout({ children }: AppLayoutProps) {
+export default async function Layout({ children }: LayoutProps) {
const hdrs = await headers();
const { companyName, logo, logoDark } = await getAppConfig(hdrs);
@@ -39,6 +39,7 @@ export default async function AppLayout({ children }: AppLayoutProps) {
+
{children}
>
);
diff --git a/app/(app)/opengraph-image.tsx b/app/(app)/opengraph-image.tsx
index a438c0b4..9fccff1e 100644
--- a/app/(app)/opengraph-image.tsx
+++ b/app/(app)/opengraph-image.tsx
@@ -165,7 +165,7 @@ export default async function Image() {
gap: 10,
}}
>
- {/* eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text */}
+ {/* eslint-disable-next-line jsx-a11y/alt-text */}
{/* logo */}
@@ -179,7 +179,7 @@ export default async function Image() {
gap: 10,
}}
>
- {/* eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text */}
+ {/* eslint-disable-next-line jsx-a11y/alt-text */}
{/* title */}
diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx
index eb0e9c56..f4655546 100644
--- a/app/(app)/page.tsx
+++ b/app/(app)/page.tsx
@@ -1,5 +1,5 @@
import { headers } from 'next/headers';
-import { App } from '@/components/app';
+import { App } from '@/components/app/app';
import { getAppConfig } from '@/lib/utils';
export default async function Page() {
diff --git a/app/api/connection-details/route.ts b/app/api/connection-details/route.ts
index 02d6542a..8f2c4841 100644
--- a/app/api/connection-details/route.ts
+++ b/app/api/connection-details/route.ts
@@ -2,6 +2,13 @@ import { NextResponse } from 'next/server';
import { AccessToken, type AccessTokenOptions, type VideoGrant } from 'livekit-server-sdk';
import { RoomConfiguration } from '@livekit/protocol';
+type ConnectionDetails = {
+ serverUrl: string;
+ roomName: string;
+ participantName: string;
+ participantToken: string;
+};
+
// NOTE: you are expected to define the following environment variables in `.env.local`:
const API_KEY = process.env.LIVEKIT_API_KEY;
const API_SECRET = process.env.LIVEKIT_API_SECRET;
@@ -10,13 +17,6 @@ const LIVEKIT_URL = process.env.LIVEKIT_URL;
// don't cache the results
export const revalidate = 0;
-export type ConnectionDetails = {
- serverUrl: string;
- roomName: string;
- participantName: string;
- participantToken: string;
-};
-
export async function POST(req: Request) {
try {
if (LIVEKIT_URL === undefined) {
diff --git a/app/components/Container.tsx b/app/components/Container.tsx
deleted file mode 100644
index 8c47b46b..00000000
--- a/app/components/Container.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { cn } from '@/lib/utils';
-
-interface ContainerProps {
- children: React.ReactNode;
- className?: string;
-}
-
-export function Container({ children, className }: ContainerProps) {
- return (
-
{children}
- );
-}
diff --git a/app/components/Tabs.tsx b/app/components/Tabs.tsx
deleted file mode 100644
index 8b7602da..00000000
--- a/app/components/Tabs.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-'use client';
-
-import Link from 'next/link';
-import { usePathname } from 'next/navigation';
-import { cn } from '@/lib/utils';
-
-export function Tabs() {
- const pathname = usePathname();
-
- return (
-
-
- Base components
-
-
- LiveKit components
-
-
- );
-}
diff --git a/app/components/base/page.tsx b/app/components/base/page.tsx
deleted file mode 100644
index 53792483..00000000
--- a/app/components/base/page.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-import { PlusIcon } from '@phosphor-icons/react/dist/ssr';
-import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
-import { Button } from '@/components/ui/button';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-import { Toggle } from '@/components/ui/toggle';
-import { Container } from '../Container';
-
-const buttonVariants = ['default', 'secondary', 'outline', 'ghost', 'link', 'destructive'] as const;
-const toggleVariants = ['default', 'outline'] as const;
-const alertVariants = ['default', 'destructive'] as const;
-
-export default function Base() {
- return (
- <>
- {/* Button */}
-
- A button component.
-
- {buttonVariants.map((variant) => (
-
-
{variant}
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))}
-
-
-
- {/* Toggle */}
-
- A toggle component.
-
- {toggleVariants.map((variant) => (
-
-
{variant}
-
-
-
- Size sm
-
-
-
-
- Size default
-
-
-
-
- Size lg
-
-
-
-
- ))}
-
-
-
- {/* Alert */}
-
- An alert component.
-
- {alertVariants.map((variant) => (
-
-
{variant}
-
- Alert {variant} title
- This is a {variant} alert description.
-
-
- ))}
-
-
-
- {/* Select */}
-
- A select component.
-
-
-
Size default
-
-
-
-
Size sm
-
-
-
-
- >
- );
-}
diff --git a/app/components/layout.tsx b/app/components/layout.tsx
deleted file mode 100644
index a9240aef..00000000
--- a/app/components/layout.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import * as React from 'react';
-import { headers } from 'next/headers';
-import { Tabs } from '@/app/components/Tabs';
-import { Provider } from '@/components/provider';
-import { cn, getAppConfig } from '@/lib/utils';
-
-export default async function ComponentsLayout({ children }: { children: React.ReactNode }) {
- const hdrs = await headers();
- const appConfig = await getAppConfig(hdrs);
- return (
-
- );
-}
diff --git a/app/components/livekit/page.tsx b/app/components/livekit/page.tsx
deleted file mode 100644
index 2905fe4d..00000000
--- a/app/components/livekit/page.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { Track } from 'livekit-client';
-import { AgentControlBar } from '@/components/livekit/agent-control-bar/agent-control-bar';
-import { DeviceSelect } from '@/components/livekit/device-select';
-import { TrackToggle } from '@/components/livekit/track-toggle';
-import { Container } from '../Container';
-
-export default function LiveKit() {
- return (
- <>
- {/* Device select */}
-
-
-
A device select component.
-
-
-
-
Size default
-
-
-
-
Size sm
-
-
-
-
-
- {/* Track toggle */}
-
-
-
A track toggle component.
-
-
-
-
- Track.Source.Microphone
-
-
-
-
-
- Track.Source.Camera
-
-
-
-
-
-
- {/* Agent control bar */}
-
-
-
A control bar component.
-
-
-
- >
- );
-}
diff --git a/app/components/page.tsx b/app/components/page.tsx
deleted file mode 100644
index eadebf51..00000000
--- a/app/components/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function Components() {
- return redirect('/components/base');
-}
diff --git a/app/layout.tsx b/app/layout.tsx
index f43f71dd..171d4453 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,10 +1,9 @@
import { Public_Sans } from 'next/font/google';
import localFont from 'next/font/local';
import { headers } from 'next/headers';
-import { APP_CONFIG_DEFAULTS } from '@/app-config';
-import { ApplyThemeScript, ThemeToggle } from '@/components/theme-toggle';
-import { getAppConfig } from '@/lib/utils';
-import './globals.css';
+import { ApplyThemeScript, ThemeToggle } from '@/components/app/theme-toggle';
+import { cn, getAppConfig, getStyles } from '@/lib/utils';
+import '@/styles/globals.css';
const publicSans = Public_Sans({
variable: '--font-public-sans',
@@ -12,29 +11,30 @@ const publicSans = Public_Sans({
});
const commitMono = localFont({
+ display: 'swap',
+ variable: '--font-commit-mono',
src: [
{
- path: './fonts/CommitMono-400-Regular.otf',
+ path: '../fonts/CommitMono-400-Regular.otf',
weight: '400',
style: 'normal',
},
{
- path: './fonts/CommitMono-700-Regular.otf',
+ path: '../fonts/CommitMono-700-Regular.otf',
weight: '700',
style: 'normal',
},
{
- path: './fonts/CommitMono-400-Italic.otf',
+ path: '../fonts/CommitMono-400-Italic.otf',
weight: '400',
style: 'italic',
},
{
- path: './fonts/CommitMono-700-Italic.otf',
+ path: '../fonts/CommitMono-700-Italic.otf',
weight: '700',
style: 'italic',
},
],
- variable: '--font-commit-mono',
});
interface RootLayoutProps {
@@ -43,32 +43,27 @@ interface RootLayoutProps {
export default async function RootLayout({ children }: RootLayoutProps) {
const hdrs = await headers();
- const { accent, accentDark, pageTitle, pageDescription } = await getAppConfig(hdrs);
-
- // check provided accent colors against defaults, and apply styles if they differ (or in development mode)
- // generate a hover color for the accent color by mixing it with 20% black
- const styles = [
- process.env.NODE_ENV === 'development' || accent !== APP_CONFIG_DEFAULTS.accent
- ? `:root { --primary: ${accent}; --primary-hover: color-mix(in srgb, ${accent} 80%, #000); }`
- : '',
- process.env.NODE_ENV === 'development' || accentDark !== APP_CONFIG_DEFAULTS.accentDark
- ? `.dark { --primary: ${accentDark}; --primary-hover: color-mix(in srgb, ${accentDark} 80%, #000); }`
- : '',
- ]
- .filter(Boolean)
- .join('\n');
+ const appConfig = await getAppConfig(hdrs);
+ const { pageTitle, pageDescription } = appConfig;
+ const styles = getStyles(appConfig);
return (
-
+
{styles && }
{pageTitle}
-
+
{children}
diff --git a/app/ui/layout.tsx b/app/ui/layout.tsx
new file mode 100644
index 00000000..c7202785
--- /dev/null
+++ b/app/ui/layout.tsx
@@ -0,0 +1,42 @@
+import * as React from 'react';
+import { headers } from 'next/headers';
+import { SessionProvider } from '@/components/app/session-provider';
+import { getAppConfig } from '@/lib/utils';
+
+export default async function ComponentsLayout({ children }: { children: React.ReactNode }) {
+ const hdrs = await headers();
+ const appConfig = await getAppConfig(hdrs);
+
+ return (
+
+
+
+
+ LiveKit UI
+
+ A set of UI components for building LiveKit-powered voice experiences.
+
+
+ Built with{' '}
+
+ Shadcn
+
+ ,{' '}
+
+ Motion
+
+ , and{' '}
+
+ LiveKit
+
+ .
+
+ Open Source.
+
+
+
{children}
+
+
+
+ );
+}
diff --git a/app/ui/page.tsx b/app/ui/page.tsx
new file mode 100644
index 00000000..83e1a7ba
--- /dev/null
+++ b/app/ui/page.tsx
@@ -0,0 +1,268 @@
+import { type VariantProps } from 'class-variance-authority';
+import { Track } from 'livekit-client';
+import { MicrophoneIcon } from '@phosphor-icons/react/dist/ssr';
+import { AgentControlBar } from '@/components/livekit/agent-control-bar/agent-control-bar';
+import { TrackDeviceSelect } from '@/components/livekit/agent-control-bar/track-device-select';
+import { TrackSelector } from '@/components/livekit/agent-control-bar/track-selector';
+import { TrackToggle } from '@/components/livekit/agent-control-bar/track-toggle';
+import { Alert, AlertDescription, AlertTitle, alertVariants } from '@/components/livekit/alert';
+import { AlertToast } from '@/components/livekit/alert-toast';
+import { Button, buttonVariants } from '@/components/livekit/button';
+import { ChatEntry } from '@/components/livekit/chat-entry';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/livekit/select';
+import { ShimmerText } from '@/components/livekit/shimmer-text';
+import { Toggle, toggleVariants } from '@/components/livekit/toggle';
+import { cn } from '@/lib/utils';
+
+type toggleVariantsType = VariantProps
['variant'];
+type toggleVariantsSizeType = VariantProps['size'];
+type buttonVariantsType = VariantProps['variant'];
+type buttonVariantsSizeType = VariantProps['size'];
+type alertVariantsType = VariantProps['variant'];
+
+interface ContainerProps {
+ componentName?: string;
+ children: React.ReactNode;
+ className?: string;
+}
+
+function Container({ componentName, children, className }: ContainerProps) {
+ return (
+
+
+ {componentName}
+
+
+ {children}
+
+
+ );
+}
+
+function StoryTitle({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+
+export default function Base() {
+ return (
+ <>
+ Primitives
+
+ {/* Button */}
+
+
+
+
+ |
+ Small |
+ Default |
+ Large |
+ Icon |
+
+
+
+ {['default', 'primary', 'secondary', 'outline', 'ghost', 'link', 'destructive'].map(
+ (variant) => (
+
+ | {variant} |
+ {['sm', 'default', 'lg', 'icon'].map((size) => (
+
+
+ |
+ ))}
+
+ )
+ )}
+
+
+
+
+ {/* Toggle */}
+
+
+
+
+ |
+ Small |
+ Default |
+ Large |
+ Icon |
+
+
+
+ {['default', 'primary', 'secondary', 'outline'].map((variant) => (
+
+ | {variant} |
+ {['sm', 'default', 'lg', 'icon'].map((size) => (
+
+
+ {size === 'icon' ? : 'Toggle'}
+
+ |
+ ))}
+
+ ))}
+
+
+
+
+ {/* Alert */}
+
+ {['default', 'destructive'].map((variant) => (
+
+
{variant}
+
+ Alert {variant} title
+ This is a {variant} alert description.
+
+
+ ))}
+
+
+ {/* Select */}
+
+
+
+ Size default
+
+
+
+ Size sm
+
+
+
+
+
+ Components
+
+ {/* Agent control bar */}
+
+
+
+
+ {/* Track device select */}
+
+
+
+ Size default
+
+
+
+ Size sm
+
+
+
+
+
+ {/* Track toggle */}
+
+
+
+ Track.Source.Microphone
+
+
+
+ Track.Source.Camera
+
+
+
+
+
+ {/* Track selector */}
+
+
+
+ Track.Source.Camera
+
+
+
+ Track.Source.Microphone
+
+
+
+
+
+ {/* Chat entry */}
+
+
+
+
+
+
+
+ {/* Shimmer text */}
+
+
+ This is shimmer text
+
+
+
+ {/* Alert toast */}
+
+ Alert toast
+
+
+ >
+ );
+}
diff --git a/components/app.tsx b/components/app.tsx
deleted file mode 100644
index 5da7c44b..00000000
--- a/components/app.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-'use client';
-
-import { useEffect, useMemo, useState } from 'react';
-import { Room, RoomEvent } from 'livekit-client';
-import { motion } from 'motion/react';
-import { RoomAudioRenderer, RoomContext, StartAudio } from '@livekit/components-react';
-import { toastAlert } from '@/components/alert-toast';
-import { SessionView } from '@/components/session-view';
-import { Toaster } from '@/components/ui/sonner';
-import { Welcome } from '@/components/welcome';
-import useConnectionDetails from '@/hooks/useConnectionDetails';
-import type { AppConfig } from '@/lib/types';
-
-const MotionWelcome = motion.create(Welcome);
-const MotionSessionView = motion.create(SessionView);
-
-interface AppProps {
- appConfig: AppConfig;
-}
-
-export function App({ appConfig }: AppProps) {
- const room = useMemo(() => new Room(), []);
- const [sessionStarted, setSessionStarted] = useState(false);
- const { refreshConnectionDetails, existingOrRefreshConnectionDetails } =
- useConnectionDetails(appConfig);
-
- useEffect(() => {
- const onDisconnected = () => {
- setSessionStarted(false);
- refreshConnectionDetails();
- };
- const onMediaDevicesError = (error: Error) => {
- toastAlert({
- title: 'Encountered an error with your media devices',
- description: `${error.name}: ${error.message}`,
- });
- };
- room.on(RoomEvent.MediaDevicesError, onMediaDevicesError);
- room.on(RoomEvent.Disconnected, onDisconnected);
- return () => {
- room.off(RoomEvent.Disconnected, onDisconnected);
- room.off(RoomEvent.MediaDevicesError, onMediaDevicesError);
- };
- }, [room, refreshConnectionDetails]);
-
- useEffect(() => {
- let aborted = false;
- if (sessionStarted && room.state === 'disconnected') {
- Promise.all([
- room.localParticipant.setMicrophoneEnabled(true, undefined, {
- preConnectBuffer: appConfig.isPreConnectBufferEnabled,
- }),
- existingOrRefreshConnectionDetails().then((connectionDetails) =>
- room.connect(connectionDetails.serverUrl, connectionDetails.participantToken)
- ),
- ]).catch((error) => {
- if (aborted) {
- // Once the effect has cleaned up after itself, drop any errors
- //
- // These errors are likely caused by this effect rerunning rapidly,
- // resulting in a previous run `disconnect` running in parallel with
- // a current run `connect`
- return;
- }
-
- toastAlert({
- title: 'There was an error connecting to the agent',
- description: `${error.name}: ${error.message}`,
- });
- });
- }
- return () => {
- aborted = true;
- room.disconnect();
- };
- }, [room, sessionStarted, appConfig.isPreConnectBufferEnabled]);
-
- const { startButtonText } = appConfig;
-
- return (
-
- setSessionStarted(true)}
- disabled={sessionStarted}
- initial={{ opacity: 1 }}
- animate={{ opacity: sessionStarted ? 0 : 1 }}
- transition={{ duration: 0.5, ease: 'linear', delay: sessionStarted ? 0 : 0.5 }}
- />
-
-
-
-
- {/* --- */}
-
-
-
-
-
- );
-}
diff --git a/components/app/app.tsx b/components/app/app.tsx
new file mode 100644
index 00000000..b390e062
--- /dev/null
+++ b/components/app/app.tsx
@@ -0,0 +1,24 @@
+'use client';
+
+import { RoomAudioRenderer, StartAudio } from '@livekit/components-react';
+import type { AppConfig } from '@/app-config';
+import { SessionProvider } from '@/components/app/session-provider';
+import { ViewController } from '@/components/app/view-controller';
+import { Toaster } from '@/components/livekit/toaster';
+
+interface AppProps {
+ appConfig: AppConfig;
+}
+
+export function App({ appConfig }: AppProps) {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/app/chat-transcript.tsx b/components/app/chat-transcript.tsx
new file mode 100644
index 00000000..b67d0a67
--- /dev/null
+++ b/components/app/chat-transcript.tsx
@@ -0,0 +1,86 @@
+'use client';
+
+import { AnimatePresence, type HTMLMotionProps, motion } from 'motion/react';
+import { type ReceivedChatMessage } from '@livekit/components-react';
+import { ChatEntry } from '@/components/livekit/chat-entry';
+
+const MotionContainer = motion.create('div');
+const MotionChatEntry = motion.create(ChatEntry);
+
+const CONTAINER_MOTION_PROPS = {
+ variants: {
+ hidden: {
+ opacity: 0,
+ transition: {
+ ease: 'easeOut',
+ duration: 0.3,
+ staggerChildren: 0.1,
+ staggerDirection: -1,
+ },
+ },
+ visible: {
+ opacity: 1,
+ transition: {
+ delay: 0.2,
+ ease: 'easeOut',
+ duration: 0.3,
+ stagerDelay: 0.2,
+ staggerChildren: 0.1,
+ staggerDirection: 1,
+ },
+ },
+ },
+ initial: 'hidden',
+ animate: 'visible',
+ exit: 'hidden',
+};
+
+const MESSAGE_MOTION_PROPS = {
+ variants: {
+ hidden: {
+ opacity: 0,
+ translateY: 10,
+ },
+ visible: {
+ opacity: 1,
+ translateY: 0,
+ },
+ },
+};
+
+interface ChatTranscriptProps {
+ hidden?: boolean;
+ messages?: ReceivedChatMessage[];
+}
+
+export function ChatTranscript({
+ hidden = false,
+ messages = [],
+ ...props
+}: ChatTranscriptProps & Omit, 'ref'>) {
+ return (
+
+ {!hidden && (
+
+ {messages.map(({ id, timestamp, from, message, editTimestamp }: ReceivedChatMessage) => {
+ const locale = navigator?.language ?? 'en-US';
+ const messageOrigin = from?.isLocal ? 'local' : 'remote';
+ const hasBeenEdited = !!editTimestamp;
+
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/components/app/preconnect-message.tsx b/components/app/preconnect-message.tsx
new file mode 100644
index 00000000..719c3813
--- /dev/null
+++ b/components/app/preconnect-message.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import { AnimatePresence, motion } from 'motion/react';
+import { type ReceivedChatMessage } from '@livekit/components-react';
+import { ShimmerText } from '@/components/livekit/shimmer-text';
+import { cn } from '@/lib/utils';
+
+const MotionMessage = motion.create('p');
+
+const VIEW_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 PreConnectMessageProps {
+ messages?: ReceivedChatMessage[];
+ className?: string;
+}
+
+export function PreConnectMessage({ className, messages = [] }: PreConnectMessageProps) {
+ return (
+
+ {messages.length === 0 && (
+ 0}
+ className={cn('pointer-events-none text-center', className)}
+ >
+
+ Agent is listening, ask it a question
+
+
+ )}
+
+ );
+}
diff --git a/components/app/session-provider.tsx b/components/app/session-provider.tsx
new file mode 100644
index 00000000..1906f4ca
--- /dev/null
+++ b/components/app/session-provider.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { createContext, useContext, useMemo } from 'react';
+import { RoomContext } from '@livekit/components-react';
+import { APP_CONFIG_DEFAULTS, type AppConfig } from '@/app-config';
+import { useRoom } from '@/hooks/useRoom';
+
+const SessionContext = createContext<{
+ appConfig: AppConfig;
+ isSessionActive: boolean;
+ startSession: () => void;
+ endSession: () => void;
+}>({
+ appConfig: APP_CONFIG_DEFAULTS,
+ isSessionActive: false,
+ startSession: () => {},
+ endSession: () => {},
+});
+
+interface SessionProviderProps {
+ appConfig: AppConfig;
+ children: React.ReactNode;
+}
+
+export const SessionProvider = ({ appConfig, children }: SessionProviderProps) => {
+ const { room, isSessionActive, startSession, endSession } = useRoom(appConfig);
+ const contextValue = useMemo(
+ () => ({ appConfig, isSessionActive, startSession, endSession }),
+ [appConfig, isSessionActive, startSession, endSession]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export function useSession() {
+ return useContext(SessionContext);
+}
diff --git a/components/app/session-view.tsx b/components/app/session-view.tsx
new file mode 100644
index 00000000..10f6e857
--- /dev/null
+++ b/components/app/session-view.tsx
@@ -0,0 +1,120 @@
+'use client';
+
+import React, { useState } from 'react';
+import { motion } from 'motion/react';
+import type { AppConfig } from '@/app-config';
+import { ChatTranscript } from '@/components/app/chat-transcript';
+import { PreConnectMessage } from '@/components/app/preconnect-message';
+import { TileLayout } from '@/components/app/tile-layout';
+import {
+ AgentControlBar,
+ type ControlBarControls,
+} from '@/components/livekit/agent-control-bar/agent-control-bar';
+import { useChatMessages } from '@/hooks/useChatMessages';
+import { useConnectionTimeout } from '@/hooks/useConnectionTimout';
+import { useDebugMode } from '@/hooks/useDebug';
+import { cn } from '@/lib/utils';
+import { ScrollArea } from '../livekit/scroll-area/scroll-area';
+
+const MotionBottom = motion.create('div');
+
+const IN_DEVELOPMENT = process.env.NODE_ENV !== 'production';
+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',
+ },
+};
+
+interface FadeProps {
+ top?: boolean;
+ bottom?: boolean;
+ className?: string;
+}
+
+export function Fade({ top = false, bottom = false, className }: FadeProps) {
+ return (
+
+ );
+}
+interface SessionViewProps {
+ appConfig: AppConfig;
+}
+
+export const SessionView = ({
+ appConfig,
+ ...props
+}: React.ComponentProps<'section'> & SessionViewProps) => {
+ useConnectionTimeout(200_000);
+ useDebugMode({ enabled: IN_DEVELOPMENT });
+
+ const messages = useChatMessages();
+ const [chatOpen, setChatOpen] = useState(false);
+
+ const controls: ControlBarControls = {
+ leave: true,
+ microphone: true,
+ chat: appConfig.supportsChatInput,
+ camera: appConfig.supportsVideoInput,
+ screenShare: appConfig.supportsVideoInput,
+ };
+
+ return (
+
+ {/* Chat Transcript */}
+
+
+
+
+
+
+
+ {/* Tile Layout */}
+
+
+ {/* Bottom */}
+
+ {appConfig.isPreConnectBufferEnabled && (
+
+ )}
+
+
+
+ );
+};
diff --git a/components/theme-toggle.tsx b/components/app/theme-toggle.tsx
similarity index 98%
rename from components/theme-toggle.tsx
rename to components/app/theme-toggle.tsx
index 33bd83ff..ffefc0da 100644
--- a/components/theme-toggle.tsx
+++ b/components/app/theme-toggle.tsx
@@ -2,7 +2,6 @@
import { useEffect, useState } from 'react';
import { MonitorIcon, MoonIcon, SunIcon } from '@phosphor-icons/react';
-import type { ThemeMode } from '@/lib/types';
import { THEME_MEDIA_QUERY, THEME_STORAGE_KEY, cn } from '@/lib/utils';
const THEME_SCRIPT = `
@@ -23,6 +22,8 @@ const THEME_SCRIPT = `
.replace(/\n/g, '')
.replace(/\s+/g, ' ');
+export type ThemeMode = 'dark' | 'light' | 'system';
+
function applyTheme(theme: ThemeMode) {
const doc = document.documentElement;
diff --git a/components/app/tile-layout.tsx b/components/app/tile-layout.tsx
new file mode 100644
index 00000000..33372276
--- /dev/null
+++ b/components/app/tile-layout.tsx
@@ -0,0 +1,238 @@
+import React, { useMemo } from 'react';
+import { Track } from 'livekit-client';
+import { AnimatePresence, motion } from 'motion/react';
+import {
+ BarVisualizer,
+ type TrackReference,
+ VideoTrack,
+ useLocalParticipant,
+ useTracks,
+ useVoiceAssistant,
+} from '@livekit/components-react';
+import { cn } from '@/lib/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(
+ () => (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 (
+
+
+
+ {/* Agent */}
+
+
+ {!isAvatar && (
+ // Audio Agent
+
+
+
+
+
+ )}
+
+ {isAvatar && (
+ // Avatar Agent
+
+
+
+ )}
+
+
+
+
+ {/* Camera & Screen Share */}
+
+ {((cameraTrack && isCameraEnabled) || (screenShareTrack && isScreenShareEnabled)) && (
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/components/app/view-controller.tsx b/components/app/view-controller.tsx
new file mode 100644
index 00000000..4519c44f
--- /dev/null
+++ b/components/app/view-controller.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { useRef } from 'react';
+import { AnimatePresence, motion } from 'motion/react';
+import { useRoomContext } from '@livekit/components-react';
+import { useSession } from '@/components/app/session-provider';
+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',
+ },
+};
+
+export function ViewController() {
+ const room = useRoomContext();
+ const isSessionActiveRef = useRef(false);
+ const { appConfig, isSessionActive, startSession } = useSession();
+
+ // animation handler holds a reference to stale isSessionActive value
+ isSessionActiveRef.current = isSessionActive;
+
+ // disconnect room after animation completes
+ const handleAnimationComplete = () => {
+ if (!isSessionActiveRef.current && room.state !== 'disconnected') {
+ room.disconnect();
+ }
+ };
+
+ return (
+
+ {/* Welcome screen */}
+ {!isSessionActive && (
+
+ )}
+ {/* Session view */}
+ {isSessionActive && (
+
+ )}
+
+ );
+}
diff --git a/components/app/welcome-view.tsx b/components/app/welcome-view.tsx
new file mode 100644
index 00000000..6356d60d
--- /dev/null
+++ b/components/app/welcome-view.tsx
@@ -0,0 +1,61 @@
+import { Button } from '@/components/livekit/button';
+
+function WelcomeImage() {
+ return (
+
+ );
+}
+
+interface WelcomeViewProps {
+ startButtonText: string;
+ onStartCall: () => void;
+}
+
+export const WelcomeView = ({
+ startButtonText,
+ onStartCall,
+ ref,
+}: React.ComponentProps<'div'> & WelcomeViewProps) => {
+ return (
+
+
+
+
+
+ Chat live with your voice AI agent
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/livekit/agent-control-bar/agent-control-bar.tsx b/components/livekit/agent-control-bar/agent-control-bar.tsx
index 6790feef..1b53b1c4 100644
--- a/components/livekit/agent-control-bar/agent-control-bar.tsx
+++ b/components/livekit/agent-control-bar/agent-control-bar.tsx
@@ -1,26 +1,31 @@
'use client';
-import * as React from 'react';
-import { useCallback } from 'react';
+import { type HTMLAttributes, useCallback, useState } from 'react';
import { Track } from 'livekit-client';
-import { BarVisualizer, useRemoteParticipants } from '@livekit/components-react';
+import { useChat, useRemoteParticipants } from '@livekit/components-react';
import { ChatTextIcon, PhoneDisconnectIcon } from '@phosphor-icons/react/dist/ssr';
-import { ChatInput } from '@/components/livekit/chat/chat-input';
-import { Button } from '@/components/ui/button';
-import { Toggle } from '@/components/ui/toggle';
-import { AppConfig } from '@/lib/types';
+import { useSession } from '@/components/app/session-provider';
+import { TrackToggle } from '@/components/livekit/agent-control-bar/track-toggle';
+import { Button } from '@/components/livekit/button';
+import { Toggle } from '@/components/livekit/toggle';
import { cn } from '@/lib/utils';
-import { DeviceSelect } from '../device-select';
-import { TrackToggle } from '../track-toggle';
-import { UseAgentControlBarProps, useAgentControlBar } from './hooks/use-agent-control-bar';
-
-export interface AgentControlBarProps
- extends React.HTMLAttributes,
- UseAgentControlBarProps {
- capabilities: Pick;
- onChatOpenChange?: (open: boolean) => void;
- onSendMessage?: (message: string) => Promise;
+import { ChatInput } from './chat-input';
+import { UseInputControlsProps, useInputControls } from './hooks/use-input-controls';
+import { usePublishPermissions } from './hooks/use-publish-permissions';
+import { TrackSelector } from './track-selector';
+
+export interface ControlBarControls {
+ leave?: boolean;
+ camera?: boolean;
+ microphone?: boolean;
+ screenShare?: boolean;
+ chat?: boolean;
+}
+
+export interface AgentControlBarProps extends UseInputControlsProps {
+ controls?: ControlBarControls;
onDisconnect?: () => void;
+ onChatOpenChange?: (open: boolean) => void;
onDeviceError?: (error: { source: Track.Source; error: Error }) => void;
}
@@ -30,199 +35,137 @@ export interface AgentControlBarProps
export function AgentControlBar({
controls,
saveUserChoices = true,
- capabilities,
className,
- onSendMessage,
- onChatOpenChange,
onDisconnect,
onDeviceError,
+ onChatOpenChange,
...props
-}: AgentControlBarProps) {
+}: AgentControlBarProps & HTMLAttributes) {
+ const { send } = useChat();
const participants = useRemoteParticipants();
- const [chatOpen, setChatOpen] = React.useState(false);
- const [isSendingMessage, setIsSendingMessage] = React.useState(false);
-
- const isAgentAvailable = participants.some((p) => p.isAgent);
- const isInputDisabled = !chatOpen || !isAgentAvailable || isSendingMessage;
-
- const [isDisconnecting, setIsDisconnecting] = React.useState(false);
+ const [chatOpen, setChatOpen] = useState(false);
+ const publishPermissions = usePublishPermissions();
+ const { isSessionActive, endSession } = useSession();
const {
micTrackRef,
- visibleControls,
cameraToggle,
microphoneToggle,
screenShareToggle,
handleAudioDeviceChange,
handleVideoDeviceChange,
- handleDisconnect,
- } = useAgentControlBar({
- controls,
- saveUserChoices,
- });
+ handleMicrophoneDeviceSelectError,
+ handleCameraDeviceSelectError,
+ } = useInputControls({ onDeviceError, saveUserChoices });
const handleSendMessage = async (message: string) => {
- setIsSendingMessage(true);
- try {
- await onSendMessage?.(message);
- } finally {
- setIsSendingMessage(false);
- }
+ await send(message);
};
- const onLeave = async () => {
- setIsDisconnecting(true);
- await handleDisconnect();
- setIsDisconnecting(false);
+ const handleToggleTranscript = useCallback(
+ (open: boolean) => {
+ setChatOpen(open);
+ onChatOpenChange?.(open);
+ },
+ [onChatOpenChange, setChatOpen]
+ );
+
+ const handleDisconnect = useCallback(async () => {
+ endSession();
onDisconnect?.();
- };
+ }, [endSession, onDisconnect]);
- React.useEffect(() => {
- onChatOpenChange?.(chatOpen);
- }, [chatOpen, onChatOpenChange]);
+ 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 onMicrophoneDeviceSelectError = useCallback(
- (error: Error) => {
- onDeviceError?.({ source: Track.Source.Microphone, error });
- },
- [onDeviceError]
- );
- const onCameraDeviceSelectError = useCallback(
- (error: Error) => {
- onDeviceError?.({ source: Track.Source.Camera, error });
- },
- [onDeviceError]
- );
+ const isAgentAvailable = participants.some((p) => p.isAgent);
return (
- {capabilities.supportsChatInput && (
-
+ {/* Chat Input */}
+ {visibleControls.chat && (
+
)}
-
-
+
+
+ {/* Toggle Microphone */}
{visibleControls.microphone && (
-
-
-
-
-
-
-
-
-
- )}
-
- {capabilities.supportsVideoInput && visibleControls.camera && (
-
-
-
-
-
+
)}
- {capabilities.supportsScreenShare && visibleControls.screenShare && (
-
-
-
+ {/* Toggle Camera */}
+ {visibleControls.camera && (
+
)}
- {visibleControls.chat && (
-
-
-
+ aria-label="Toggle screen share"
+ source={Track.Source.ScreenShare}
+ pressed={screenShareToggle.enabled}
+ disabled={screenShareToggle.pending}
+ onPressedChange={screenShareToggle.toggle}
+ />
)}
+
+ {/* Toggle Transcript */}
+
+
+
+
+ {/* Disconnect */}
{visibleControls.leave && (