Skip to content

Commit f0003fe

Browse files
add AgentControlBar
1 parent 96fe24c commit f0003fe

File tree

6 files changed

+571
-0
lines changed

6 files changed

+571
-0
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import * as React from 'react';
2+
import { StoryObj } from '@storybook/react-vite';
3+
import {
4+
AgentSessionProvider,
5+
useMicrophone,
6+
} from '../../.storybook/lk-decorators/AgentSessionProvider';
7+
import { AgentControlBar, AgentControlBarProps } from '@agents-ui';
8+
9+
export default {
10+
component: AgentControlBar,
11+
decorators: [AgentSessionProvider],
12+
render: (args: AgentControlBarProps) => {
13+
useMicrophone();
14+
return <AgentControlBar {...args} className="min-w-lg mx-auto" />;
15+
},
16+
args: {
17+
isConnected: true,
18+
controls: {
19+
microphone: true,
20+
camera: true,
21+
screenShare: true,
22+
chat: true,
23+
leave: true,
24+
},
25+
className: 'w-full',
26+
},
27+
argTypes: {
28+
controls: { control: { type: 'object' } },
29+
isConnected: { control: { type: 'boolean' } },
30+
isChatOpen: { control: { type: 'boolean' } },
31+
},
32+
parameters: {
33+
layout: 'centered',
34+
actions: {
35+
handles: [],
36+
},
37+
},
38+
};
39+
40+
export const Default: StoryObj<AgentControlBarProps> = {
41+
args: {},
42+
};
43+
44+
export const Outline: StoryObj<AgentControlBarProps> = {
45+
args: {
46+
variant: 'outline',
47+
},
48+
};
49+
50+
export const Livekit: StoryObj<AgentControlBarProps> = {
51+
args: {
52+
variant: 'livekit',
53+
},
54+
};
55+
56+
export const NoControls: StoryObj<AgentControlBarProps> = {
57+
args: {
58+
controls: {
59+
microphone: false,
60+
camera: false,
61+
screenShare: false,
62+
chat: false,
63+
leave: false,
64+
},
65+
},
66+
render: (args: AgentControlBarProps) => {
67+
return (
68+
<>
69+
<p className="text-center">
70+
This control bar does not render
71+
<br />
72+
because <code className="text-muted-foreground text-sm">visibleControls</code> contains
73+
only false values.
74+
</p>
75+
<AgentControlBar {...args} className="min-w-lg mx-auto" />
76+
</>
77+
);
78+
},
79+
};
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { SendHorizontal, Loader } from 'lucide-react';
3+
import { Button } from '@/components/ui/button';
4+
import { cn } from '@/lib/utils';
5+
6+
interface AgentChatInputProps {
7+
chatOpen: boolean;
8+
onSend?: (message: string) => void;
9+
className?: string;
10+
}
11+
12+
export function AgentChatInput({
13+
chatOpen,
14+
onSend = async () => {},
15+
className,
16+
}: AgentChatInputProps) {
17+
const inputRef = useRef<HTMLTextAreaElement>(null);
18+
const [isSending, setIsSending] = useState(false);
19+
const [message, setMessage] = useState<string>('');
20+
21+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
22+
e.preventDefault();
23+
24+
try {
25+
setIsSending(true);
26+
await onSend(message);
27+
setMessage('');
28+
} catch (error) {
29+
console.error(error);
30+
} finally {
31+
setIsSending(false);
32+
}
33+
};
34+
35+
const isDisabled = isSending || message.trim().length === 0;
36+
37+
useEffect(() => {
38+
if (chatOpen) return;
39+
// when not disabled refocus on input
40+
inputRef.current?.focus();
41+
}, [chatOpen]);
42+
43+
return (
44+
<form
45+
onSubmit={handleSubmit}
46+
className={cn('mb-3 flex grow items-end gap-2 rounded-md pl-1 text-sm', className)}
47+
>
48+
<textarea
49+
autoFocus
50+
ref={inputRef}
51+
value={message}
52+
disabled={!chatOpen}
53+
placeholder="Type something..."
54+
onChange={(e) => setMessage(e.target.value)}
55+
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"
56+
/>
57+
<Button
58+
size="icon"
59+
type="submit"
60+
disabled={isDisabled}
61+
variant={isDisabled ? 'secondary' : 'default'}
62+
title={isSending ? 'Sending...' : 'Send'}
63+
className="self-end disabled:cursor-not-allowed"
64+
>
65+
{isSending ? <Loader className="animate-spin" /> : <SendHorizontal />}
66+
</Button>
67+
</form>
68+
);
69+
}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
'use client';
2+
3+
import { type HTMLAttributes, useState } from 'react';
4+
import { Track } from 'livekit-client';
5+
import { motion } from 'motion/react';
6+
import { useChat } from '@livekit/components-react';
7+
import { MessageSquareTextIcon } from 'lucide-react';
8+
import {
9+
AgentTrackToggle,
10+
agentTrackToggleVariants,
11+
} from '@/components/agents-ui/agent-track-toggle';
12+
import { AgentTrackControl } from '@/components/agents-ui/agent-track-control';
13+
import { Toggle } from '@/components/ui/toggle';
14+
import { cn } from '@/lib/utils';
15+
import { AgentChatInput } from './agent-chat-input';
16+
import { UseInputControlsProps, useInputControls } from './hooks/use-input-controls';
17+
import { usePublishPermissions } from './hooks/use-publish-permissions';
18+
import { AgentDisconnectButton } from '../agent-disconnect-button';
19+
20+
const TOGGLE_VARIANT_1 = [
21+
'[&_[data-state=off]]:bg-accent [&_[data-state=off]]:hover:bg-foreground/10',
22+
'[&_[data-state=off]_~_button]:bg-accent [&_[data-state=off]_~_button]:hover:bg-foreground/10',
23+
'[&_[data-state=off]]:border-border [&_[data-state=off]]:hover:border-foreground/12',
24+
'[&_[data-state=off]_~_button]:border-border [&_[data-state=off]_~_button]:hover:border-foreground/12',
25+
'[&_[data-state=off]]:text-destructive [&_[data-state=off]]:hover:text-destructive [&_[data-state=off]]:focus:text-destructive',
26+
'[&_[data-state=off]]:focus-visible:ring-foreground/12 [&_[data-state=off]]:focus-visible:border-ring',
27+
'dark:[&_[data-state=off]_~_button]:bg-accent dark:[&_[data-state=off]_~_button:hover]:bg-foreground/10',
28+
];
29+
30+
const TOGGLE_VARIANT_2 = [
31+
'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10',
32+
'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12',
33+
'data-[state=off]:focus-visible:border-ring data-[state=off]:focus-visible:ring-foreground/12',
34+
'data-[state=off]:text-foreground data-[state=off]:hover:text-foreground data-[state=off]:focus:text-foreground',
35+
'data-[state=on]:bg-blue-500/20 data-[state=on]:hover:bg-blue-500/30',
36+
'data-[state=on]:border-blue-700/10 data-[state=on]:text-blue-700 data-[state=on]:ring-blue-700/30',
37+
'data-[state=on]:focus-visible:border-blue-700/50',
38+
'dark:data-[state=on]:bg-blue-500/20 dark:data-[state=on]:text-blue-300',
39+
];
40+
41+
const MOTION_PROPS = {
42+
variants: {
43+
hidden: {
44+
height: 0,
45+
opacity: 0,
46+
marginBottom: 0,
47+
},
48+
visible: {
49+
height: 'auto',
50+
opacity: 1,
51+
marginBottom: 12,
52+
},
53+
},
54+
initial: 'hidden',
55+
transition: {
56+
duration: 0.3,
57+
ease: 'easeOut',
58+
},
59+
};
60+
61+
export interface ControlBarControls {
62+
leave?: boolean;
63+
camera?: boolean;
64+
microphone?: boolean;
65+
screenShare?: boolean;
66+
chat?: boolean;
67+
}
68+
69+
export interface AgentControlBarProps extends UseInputControlsProps {
70+
variant?: 'default' | 'outline' | 'livekit';
71+
controls?: ControlBarControls;
72+
isConnected?: boolean;
73+
isChatOpen?: boolean;
74+
onIsChatOpenChange?: (open: boolean) => void;
75+
onDeviceError?: (error: { source: Track.Source; error: Error }) => void;
76+
}
77+
78+
/**
79+
* A control bar specifically designed for voice assistant interfaces
80+
*/
81+
export function AgentControlBar({
82+
variant = 'default',
83+
controls,
84+
isChatOpen = false,
85+
isConnected = false,
86+
saveUserChoices = true,
87+
onDisconnect,
88+
onDeviceError,
89+
onIsChatOpenChange,
90+
className,
91+
...props
92+
}: AgentControlBarProps & HTMLAttributes<HTMLDivElement>) {
93+
const { send } = useChat();
94+
const publishPermissions = usePublishPermissions();
95+
const [isChatOpenUncontrolled, setIsChatOpenUncontrolled] = useState(isChatOpen);
96+
const {
97+
micTrackRef,
98+
cameraToggle,
99+
microphoneToggle,
100+
screenShareToggle,
101+
handleAudioDeviceChange,
102+
handleVideoDeviceChange,
103+
handleMicrophoneDeviceSelectError,
104+
handleCameraDeviceSelectError,
105+
} = useInputControls({ onDeviceError, saveUserChoices });
106+
107+
const handleSendMessage = async (message: string) => {
108+
await send(message);
109+
};
110+
111+
const visibleControls = {
112+
leave: controls?.leave ?? true,
113+
microphone: controls?.microphone ?? publishPermissions.microphone,
114+
screenShare: controls?.screenShare ?? publishPermissions.screenShare,
115+
camera: controls?.camera ?? publishPermissions.camera,
116+
chat: controls?.chat ?? publishPermissions.data,
117+
};
118+
119+
const isEmpty = Object.values(visibleControls).every((value) => !value);
120+
121+
if (isEmpty) {
122+
console.warn('AgentControlBar: `visibleControls` contains only false values.');
123+
return null;
124+
}
125+
126+
return (
127+
<div
128+
aria-label="Voice assistant controls"
129+
className={cn(
130+
'bg-background border-input/50 dark:border-muted flex flex-col border drop-shadow-md/3 p-3',
131+
variant === 'livekit' ? 'rounded-[31px]' : 'rounded-lg',
132+
className,
133+
)}
134+
{...props}
135+
>
136+
<motion.div
137+
{...MOTION_PROPS}
138+
// @ts-ignore
139+
inert={!(isChatOpen || isChatOpenUncontrolled)}
140+
animate={isChatOpen || isChatOpenUncontrolled ? 'visible' : 'hidden'}
141+
className="border-input/50 flex w-full items-start overflow-hidden border-b"
142+
>
143+
<AgentChatInput
144+
chatOpen={isChatOpen || isChatOpenUncontrolled}
145+
onSend={handleSendMessage}
146+
className={cn(variant === 'livekit' && '[&_button]:rounded-full')}
147+
/>
148+
</motion.div>
149+
150+
<div className="flex gap-1">
151+
<div className="flex grow gap-1">
152+
{/* Toggle Microphone */}
153+
{visibleControls.microphone && (
154+
<AgentTrackControl
155+
variant={variant === 'outline' ? 'outline' : 'default'}
156+
kind="audioinput"
157+
aria-label="Toggle microphone"
158+
source={Track.Source.Microphone}
159+
pressed={microphoneToggle.enabled}
160+
disabled={microphoneToggle.pending}
161+
audioTrack={micTrackRef}
162+
onPressedChange={microphoneToggle.toggle}
163+
onActiveDeviceChange={handleAudioDeviceChange}
164+
onMediaDeviceError={handleMicrophoneDeviceSelectError}
165+
className={cn(
166+
TOGGLE_VARIANT_1,
167+
variant === 'livekit' &&
168+
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
169+
)}
170+
/>
171+
)}
172+
173+
{/* Toggle Camera */}
174+
{visibleControls.camera && (
175+
<AgentTrackControl
176+
variant={variant === 'outline' ? 'outline' : 'default'}
177+
kind="videoinput"
178+
aria-label="Toggle camera"
179+
source={Track.Source.Camera}
180+
pressed={cameraToggle.enabled}
181+
pending={cameraToggle.pending}
182+
disabled={cameraToggle.pending}
183+
onPressedChange={cameraToggle.toggle}
184+
onMediaDeviceError={handleCameraDeviceSelectError}
185+
onActiveDeviceChange={handleVideoDeviceChange}
186+
className={cn(
187+
TOGGLE_VARIANT_1,
188+
variant === 'livekit' &&
189+
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
190+
)}
191+
/>
192+
)}
193+
194+
{/* Toggle Screen Share */}
195+
{visibleControls.screenShare && (
196+
<AgentTrackToggle
197+
variant={variant === 'outline' ? 'outline' : 'default'}
198+
aria-label="Toggle screen share"
199+
source={Track.Source.ScreenShare}
200+
pressed={screenShareToggle.enabled}
201+
disabled={screenShareToggle.pending}
202+
onPressedChange={screenShareToggle.toggle}
203+
className={cn(TOGGLE_VARIANT_2, variant === 'livekit' && 'rounded-full')}
204+
/>
205+
)}
206+
207+
{/* Toggle Transcript */}
208+
{visibleControls.chat && (
209+
<Toggle
210+
variant={variant === 'outline' ? 'outline' : 'default'}
211+
pressed={isChatOpen || isChatOpenUncontrolled}
212+
aria-label="Toggle transcript"
213+
onPressedChange={(state) => {
214+
if (!onIsChatOpenChange) setIsChatOpenUncontrolled(state);
215+
else onIsChatOpenChange(state);
216+
}}
217+
className={agentTrackToggleVariants({
218+
variant: variant === 'outline' ? 'outline' : 'default',
219+
className: cn(TOGGLE_VARIANT_2, variant === 'livekit' && 'rounded-full'),
220+
})}
221+
>
222+
<MessageSquareTextIcon />
223+
</Toggle>
224+
)}
225+
</div>
226+
227+
{/* Disconnect */}
228+
{visibleControls.leave && (
229+
<AgentDisconnectButton
230+
onClick={onDisconnect}
231+
disabled={!isConnected}
232+
className={cn(
233+
variant === 'livekit' &&
234+
'rounded-full 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 font-mono text-xs font-bold tracking-wider',
235+
)}
236+
>
237+
<span className="hidden md:inline">END CALL</span>
238+
<span className="inline md:hidden">END</span>
239+
</AgentDisconnectButton>
240+
)}
241+
</div>
242+
</div>
243+
);
244+
}

0 commit comments

Comments
 (0)