Skip to content

Commit d8a0190

Browse files
UI improvements (#179)
1 parent d77be35 commit d8a0190

10 files changed

Lines changed: 145 additions & 79 deletions

File tree

app-config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import type { AppConfig } from './lib/types';
22

33
export const APP_CONFIG_DEFAULTS: AppConfig = {
44
companyName: 'LiveKit',
5-
pageTitle: 'Voice Assistant',
6-
pageDescription: 'A voice assistant built with LiveKit',
5+
pageTitle: 'LiveKit Voice Agent',
6+
pageDescription: 'A voice agent built with LiveKit',
77

88
supportsChatInput: true,
99
supportsVideoInput: true,

app/(app)/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export default async function AppLayout({ children }: AppLayoutProps) {
3131
<a
3232
target="_blank"
3333
rel="noopener noreferrer"
34-
href="https://github.com/livekit/agents"
34+
href="https://docs.livekit.io/agents"
3535
className="underline underline-offset-4"
3636
>
3737
LiveKit Agents

components/alert-toast.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
'use client';
22

3+
import { ReactNode } from 'react';
34
import { toast as sonnerToast } from 'sonner';
45
import { WarningIcon } from '@phosphor-icons/react/dist/ssr';
5-
import { Alert, AlertTitle } from './ui/alert';
6+
import { Alert, AlertDescription, AlertTitle } from './ui/alert';
67

78
interface ToastProps {
89
id: string | number;
9-
title: string;
10-
description: string;
10+
title: ReactNode;
11+
description: ReactNode;
1112
}
1213

1314
export function toastAlert(toast: Omit<ToastProps, 'id'>) {
@@ -18,13 +19,13 @@ export function toastAlert(toast: Omit<ToastProps, 'id'>) {
1819
}
1920

2021
function AlertToast(props: ToastProps) {
21-
const { title, id } = props;
22+
const { title, description, id } = props;
2223

2324
return (
2425
<Alert onClick={() => sonnerToast.dismiss(id)} className="bg-accent">
2526
<WarningIcon weight="bold" />
2627
<AlertTitle>{title}</AlertTitle>
27-
{/* <AlertDescription>{description}</AlertDescription> */}
28+
{description && <AlertDescription>{description}</AlertDescription>}
2829
</Alert>
2930
);
3031
}

components/app.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import * as React from 'react';
3+
import { useEffect, useMemo, useState } from 'react';
44
import { Room, RoomEvent } from 'livekit-client';
55
import { motion } from 'motion/react';
66
import { RoomAudioRenderer, RoomContext, StartAudio } from '@livekit/components-react';
@@ -11,15 +11,15 @@ import { Welcome } from '@/components/welcome';
1111
import useConnectionDetails from '@/hooks/useConnectionDetails';
1212
import type { AppConfig } from '@/lib/types';
1313

14-
const MotionSessionView = motion.create(SessionView);
1514
const MotionWelcome = motion.create(Welcome);
15+
const MotionSessionView = motion.create(SessionView);
1616

1717
interface AppProps {
1818
appConfig: AppConfig;
1919
}
2020

2121
export function App({ appConfig }: AppProps) {
22-
const [sessionStarted, setSessionStarted] = React.useState(false);
22+
const [sessionStarted, setSessionStarted] = useState(false);
2323
const { supportsChatInput, supportsVideoInput, supportsScreenShare, startButtonText } = appConfig;
2424

2525
const capabilities = {
@@ -30,9 +30,9 @@ export function App({ appConfig }: AppProps) {
3030

3131
const { connectionDetails, refreshConnectionDetails } = useConnectionDetails();
3232

33-
const room = React.useMemo(() => new Room(), []);
33+
const room = useMemo(() => new Room(), []);
3434

35-
React.useEffect(() => {
35+
useEffect(() => {
3636
const onDisconnected = () => {
3737
setSessionStarted(false);
3838
refreshConnectionDetails();
@@ -51,7 +51,7 @@ export function App({ appConfig }: AppProps) {
5151
};
5252
}, [room, refreshConnectionDetails]);
5353

54-
React.useEffect(() => {
54+
useEffect(() => {
5555
if (sessionStarted && room.state === 'disconnected' && connectionDetails) {
5656
Promise.all([
5757
room.localParticipant.setMicrophoneEnabled(true, undefined, {
@@ -88,9 +88,9 @@ export function App({ appConfig }: AppProps) {
8888
{/* --- */}
8989
<MotionSessionView
9090
key="session-view"
91+
disabled={!sessionStarted}
9192
capabilities={capabilities}
9293
sessionStarted={sessionStarted}
93-
disabled={!sessionStarted}
9494
initial={{ opacity: 0 }}
9595
animate={{ opacity: sessionStarted ? 1 : 0 }}
9696
transition={{

components/livekit/agent-control-bar/agent-control-bar.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import * as React from 'react';
44
import { Track } from 'livekit-client';
5-
import { BarVisualizer } from '@livekit/components-react';
5+
import { BarVisualizer, useRemoteParticipants } from '@livekit/components-react';
66
import { ChatTextIcon, PhoneDisconnectIcon } from '@phosphor-icons/react/dist/ssr';
77
import { ChatInput } from '@/components/livekit/chat/chat-input';
88
import { Button } from '@/components/ui/button';
@@ -37,9 +37,13 @@ export function AgentControlBar({
3737
onDeviceError,
3838
...props
3939
}: AgentControlBarProps) {
40+
const participants = useRemoteParticipants();
4041
const [chatOpen, setChatOpen] = React.useState(false);
4142
const [isSendingMessage, setIsSendingMessage] = React.useState(false);
4243

44+
const isAgentAvailable = participants.some((p) => p.isAgent);
45+
const isInputDisabled = !chatOpen || !isAgentAvailable || isSendingMessage;
46+
4347
const {
4448
micTrackRef,
4549
visibleControls,
@@ -90,7 +94,7 @@ export function AgentControlBar({
9094
)}
9195
>
9296
<div className="flex h-8 w-full">
93-
<ChatInput onSend={handleSendMessage} disabled={isSendingMessage} className="w-full" />
97+
<ChatInput onSend={handleSendMessage} disabled={isInputDisabled} className="w-full" />
9498
</div>
9599
<hr className="border-bg2 my-3" />
96100
</div>
@@ -151,7 +155,7 @@ export function AgentControlBar({
151155
pending={cameraToggle.pending}
152156
disabled={cameraToggle.pending}
153157
onPressedChange={cameraToggle.toggle}
154-
className="peer/track relative w-auto pr-3 pl-3 disabled:opacity-100 md:rounded-r-none md:border-r-0 md:pr-2"
158+
className="peer/track relative w-auto rounded-r-none pr-3 pl-3 disabled:opacity-100 md:border-r-0 md:pr-2"
155159
/>
156160
<hr className="bg-separator1 peer-data-[state=off]/track:bg-separatorSerious relative z-10 -mr-px hidden h-4 w-px md:block" />
157161
<DeviceSelect
@@ -166,7 +170,7 @@ export function AgentControlBar({
166170
'peer-data-[state=off]/track:text-destructive-foreground',
167171
'hover:text-fg1 focus:text-fg1',
168172
'hover:peer-data-[state=off]/track:text-destructive-foreground focus:peer-data-[state=off]/track:text-destructive-foreground',
169-
'hidden rounded-l-none md:block',
173+
'rounded-l-none',
170174
])}
171175
/>
172176
</div>
@@ -191,6 +195,7 @@ export function AgentControlBar({
191195
aria-label="Toggle chat"
192196
pressed={chatOpen}
193197
onPressedChange={setChatOpen}
198+
disabled={!isAgentAvailable}
194199
className="aspect-square h-full"
195200
>
196201
<ChatTextIcon weight="bold" />

components/livekit/chat/chat-input.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef, useState } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
22
import { Button } from '@/components/ui/button';
33
import { cn } from '@/lib/utils';
44

@@ -16,25 +16,31 @@ export function ChatInput({ onSend, className, disabled, ...props }: ChatInputPr
1616
props.onSubmit?.(e);
1717
onSend?.(message);
1818
setMessage('');
19-
inputRef.current?.focus();
2019
};
2120

2221
const isDisabled = disabled || message.trim().length === 0;
2322

23+
useEffect(() => {
24+
if (disabled) return;
25+
// when not disabled refocus on input
26+
inputRef.current?.focus();
27+
}, [disabled]);
28+
2429
return (
2530
<form
2631
{...props}
2732
onSubmit={handleSubmit}
2833
className={cn('flex items-center gap-2 rounded-md pl-1 text-sm', className)}
2934
>
3035
<input
36+
autoFocus
3137
ref={inputRef}
3238
type="text"
33-
className="flex-1 focus:outline-none"
34-
placeholder="Type something..."
3539
value={message}
36-
onChange={(e) => setMessage(e.target.value)}
3740
disabled={disabled}
41+
placeholder="Type something..."
42+
onChange={(e) => setMessage(e.target.value)}
43+
className="flex-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
3844
/>
3945
<Button
4046
size="sm"

components/livekit/media-tiles.tsx

Lines changed: 39 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@ export function MediaTiles({ chatOpen }: MediaTilesProps) {
9595
state: agentState,
9696
audioTrack: agentAudioTrack,
9797
videoTrack: agentVideoTrack,
98-
agent: { isActive: isAgentActive = false } = {},
9998
} = useVoiceAssistant();
10099
const [screenShareTrack] = useTracks([Track.Source.ScreenShare]);
101100
const cameraTrack: TrackReference | undefined = useLocalTrackRef(Track.Source.Camera);
@@ -127,47 +126,45 @@ export function MediaTiles({ chatOpen }: MediaTilesProps) {
127126
<div className="relative mx-auto h-full max-w-2xl px-4 md:px-0">
128127
<div className={cn(classNames.grid)}>
129128
{/* agent */}
130-
{isAgentActive && (
131-
<div
132-
className={cn([
133-
'grid',
134-
// 'bg-[hotpink]', // for debugging
135-
!chatOpen && classNames.agentChatClosed,
136-
chatOpen && hasSecondTile && classNames.agentChatOpenWithSecondTile,
137-
chatOpen && !hasSecondTile && classNames.agentChatOpenWithoutSecondTile,
138-
])}
139-
>
140-
<AnimatePresence mode="popLayout">
141-
{!isAvatar && (
142-
// audio-only agent
143-
<MotionAgentTile
144-
key="agent"
145-
layoutId="agent"
146-
{...animationProps}
147-
animate={agentAnimate}
148-
transition={agentLayoutTransition}
149-
state={agentState}
150-
audioTrack={agentAudioTrack}
151-
className={cn(chatOpen ? 'h-[90px]' : 'h-auto w-full')}
152-
/>
153-
)}
154-
{isAvatar && (
155-
// avatar agent
156-
<MotionAvatarTile
157-
key="avatar"
158-
layoutId="avatar"
159-
{...animationProps}
160-
animate={avatarAnimate}
161-
transition={avatarLayoutTransition}
162-
videoTrack={agentVideoTrack}
163-
className={cn(
164-
chatOpen ? 'h-[90px] [&>video]:h-[90px] [&>video]:w-auto' : 'h-auto w-full'
165-
)}
166-
/>
167-
)}
168-
</AnimatePresence>
169-
</div>
170-
)}
129+
<div
130+
className={cn([
131+
'grid',
132+
// 'bg-[hotpink]', // for debugging
133+
!chatOpen && classNames.agentChatClosed,
134+
chatOpen && hasSecondTile && classNames.agentChatOpenWithSecondTile,
135+
chatOpen && !hasSecondTile && classNames.agentChatOpenWithoutSecondTile,
136+
])}
137+
>
138+
<AnimatePresence mode="popLayout">
139+
{!isAvatar && (
140+
// audio-only agent
141+
<MotionAgentTile
142+
key="agent"
143+
layoutId="agent"
144+
{...animationProps}
145+
animate={agentAnimate}
146+
transition={agentLayoutTransition}
147+
state={agentState}
148+
audioTrack={agentAudioTrack}
149+
className={cn(chatOpen ? 'h-[90px]' : 'h-auto w-full')}
150+
/>
151+
)}
152+
{isAvatar && (
153+
// avatar agent
154+
<MotionAvatarTile
155+
key="avatar"
156+
layoutId="avatar"
157+
{...animationProps}
158+
animate={avatarAnimate}
159+
transition={avatarLayoutTransition}
160+
videoTrack={agentVideoTrack}
161+
className={cn(
162+
chatOpen ? 'h-[90px] [&>video]:h-[90px] [&>video]:w-auto' : 'h-auto w-full'
163+
)}
164+
/>
165+
)}
166+
</AnimatePresence>
167+
</div>
171168

172169
<div
173170
className={cn([

components/session-view.tsx

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
'use client';
22

3-
import React, { useState } from 'react';
3+
import React, { useEffect, useState } from 'react';
44
import { AnimatePresence, motion } from 'motion/react';
5-
import { type ReceivedChatMessage } from '@livekit/components-react';
5+
import {
6+
type AgentState,
7+
type ReceivedChatMessage,
8+
useRoomContext,
9+
useVoiceAssistant,
10+
} from '@livekit/components-react';
11+
import { toastAlert } from '@/components/alert-toast';
612
import { AgentControlBar } from '@/components/livekit/agent-control-bar/agent-control-bar';
713
import { ChatEntry } from '@/components/livekit/chat/chat-entry';
814
import { ChatMessageView } from '@/components/livekit/chat/chat-message-view';
@@ -11,6 +17,10 @@ import useChatAndTranscription from '@/hooks/useChatAndTranscription';
1117
import { useDebugMode } from '@/hooks/useDebug';
1218
import { cn } from '@/lib/utils';
1319

20+
function isAgentAvailable(agentState: AgentState) {
21+
return agentState == 'listening' || agentState == 'thinking' || agentState == 'speaking';
22+
}
23+
1424
interface SessionViewProps {
1525
disabled: boolean;
1626
capabilities: {
@@ -27,15 +37,51 @@ export const SessionView = ({
2737
sessionStarted,
2838
ref,
2939
}: React.ComponentProps<'div'> & SessionViewProps) => {
40+
const { state: agentState } = useVoiceAssistant();
3041
const [chatOpen, setChatOpen] = useState(false);
3142
const { messages, send } = useChatAndTranscription();
43+
const room = useRoomContext();
3244

3345
useDebugMode();
3446

3547
async function handleSendMessage(message: string) {
3648
await send(message);
3749
}
3850

51+
useEffect(() => {
52+
if (sessionStarted) {
53+
const timeout = setTimeout(() => {
54+
if (!isAgentAvailable(agentState)) {
55+
const reason =
56+
agentState === 'connecting'
57+
? 'Agent did not join the room. '
58+
: 'Agent connected but did not complete initializing. ';
59+
60+
toastAlert({
61+
title: 'Session ended',
62+
description: (
63+
<p className="w-full">
64+
{reason}
65+
<a
66+
target="_blank"
67+
rel="noopener noreferrer"
68+
href="https://docs.livekit.io/agents/start/voice-ai/"
69+
className="whitespace-nowrap underline"
70+
>
71+
See quickstart guide
72+
</a>
73+
.
74+
</p>
75+
),
76+
});
77+
room.disconnect();
78+
}
79+
}, 10_000);
80+
81+
return () => clearTimeout(timeout);
82+
}
83+
}, [agentState, sessionStarted, room]);
84+
3985
return (
4086
<main
4187
ref={ref}

0 commit comments

Comments
 (0)