Skip to content

Commit 8a4c571

Browse files
add AgentControlBar
1 parent 749a7d2 commit 8a4c571

File tree

6 files changed

+567
-0
lines changed

6 files changed

+567
-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: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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+
'dark:[&_[data-state=off]_~_button]:bg-accent dark:[&_[data-state=off]_~_button:hover]:bg-foreground/10',
27+
];
28+
29+
const TOGGLE_VARIANT_2 = [
30+
'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10',
31+
'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12',
32+
'data-[state=off]:text-foreground data-[state=off]:hover:text-foreground data-[state=off]:focus:text-foreground',
33+
'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',
34+
'dark:data-[state=on]:bg-blue-500/20 dark:data-[state=on]:text-blue-300',
35+
];
36+
37+
const MOTION_PROPS = {
38+
variants: {
39+
hidden: {
40+
height: 0,
41+
opacity: 0,
42+
marginBottom: 0,
43+
},
44+
visible: {
45+
height: 'auto',
46+
opacity: 1,
47+
marginBottom: 12,
48+
},
49+
},
50+
initial: 'hidden',
51+
transition: {
52+
duration: 0.3,
53+
ease: 'easeOut',
54+
},
55+
};
56+
57+
export interface ControlBarControls {
58+
leave?: boolean;
59+
camera?: boolean;
60+
microphone?: boolean;
61+
screenShare?: boolean;
62+
chat?: boolean;
63+
}
64+
65+
export interface AgentControlBarProps extends UseInputControlsProps {
66+
variant?: 'default' | 'outline' | 'livekit';
67+
controls?: ControlBarControls;
68+
isConnected?: boolean;
69+
isChatOpen?: boolean;
70+
onIsChatOpenChange?: (open: boolean) => void;
71+
onDeviceError?: (error: { source: Track.Source; error: Error }) => void;
72+
}
73+
74+
/**
75+
* A control bar specifically designed for voice assistant interfaces
76+
*/
77+
export function AgentControlBar({
78+
variant = 'default',
79+
controls,
80+
isChatOpen = false,
81+
isConnected = false,
82+
saveUserChoices = true,
83+
onDisconnect,
84+
onDeviceError,
85+
onIsChatOpenChange,
86+
className,
87+
...props
88+
}: AgentControlBarProps & HTMLAttributes<HTMLDivElement>) {
89+
const { send } = useChat();
90+
const publishPermissions = usePublishPermissions();
91+
const [isChatOpenUncontrolled, setIsChatOpenUncontrolled] = useState(isChatOpen);
92+
const {
93+
micTrackRef,
94+
cameraToggle,
95+
microphoneToggle,
96+
screenShareToggle,
97+
handleAudioDeviceChange,
98+
handleVideoDeviceChange,
99+
handleMicrophoneDeviceSelectError,
100+
handleCameraDeviceSelectError,
101+
} = useInputControls({ onDeviceError, saveUserChoices });
102+
103+
const handleSendMessage = async (message: string) => {
104+
await send(message);
105+
};
106+
107+
const visibleControls = {
108+
leave: controls?.leave ?? true,
109+
microphone: controls?.microphone ?? publishPermissions.microphone,
110+
screenShare: controls?.screenShare ?? publishPermissions.screenShare,
111+
camera: controls?.camera ?? publishPermissions.camera,
112+
chat: controls?.chat ?? publishPermissions.data,
113+
};
114+
115+
const isEmpty = Object.values(visibleControls).every((value) => !value);
116+
117+
if (isEmpty) {
118+
console.warn('`visibleControls` contained only false values.');
119+
return null;
120+
}
121+
122+
return (
123+
<div
124+
aria-label="Voice assistant controls"
125+
className={cn(
126+
'bg-background border-input/50 dark:border-muted flex flex-col border drop-shadow-md/3 p-3',
127+
variant === 'livekit' ? 'rounded-[31px]' : 'rounded-lg',
128+
className,
129+
)}
130+
{...props}
131+
>
132+
<motion.div
133+
{...MOTION_PROPS}
134+
// @ts-ignore
135+
inert={!(isChatOpen || isChatOpenUncontrolled)}
136+
animate={isChatOpen || isChatOpenUncontrolled ? 'visible' : 'hidden'}
137+
className="border-input/50 flex w-full items-start overflow-hidden border-b"
138+
>
139+
<AgentChatInput
140+
chatOpen={isChatOpen || isChatOpenUncontrolled}
141+
onSend={handleSendMessage}
142+
className={cn(variant === 'livekit' && '[&_button]:rounded-full')}
143+
/>
144+
</motion.div>
145+
146+
<div className="flex gap-1">
147+
<div className="flex grow gap-1">
148+
{/* Toggle Microphone */}
149+
{visibleControls.microphone && (
150+
<AgentTrackControl
151+
variant={variant === 'outline' ? 'outline' : 'default'}
152+
kind="audioinput"
153+
aria-label="Toggle microphone"
154+
source={Track.Source.Microphone}
155+
pressed={microphoneToggle.enabled}
156+
disabled={microphoneToggle.pending}
157+
audioTrack={micTrackRef}
158+
onPressedChange={microphoneToggle.toggle}
159+
onActiveDeviceChange={handleAudioDeviceChange}
160+
onMediaDeviceError={handleMicrophoneDeviceSelectError}
161+
className={cn(
162+
TOGGLE_VARIANT_1,
163+
variant === 'livekit' &&
164+
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
165+
)}
166+
/>
167+
)}
168+
169+
{/* Toggle Camera */}
170+
{visibleControls.camera && (
171+
<AgentTrackControl
172+
variant={variant === 'outline' ? 'outline' : 'default'}
173+
kind="videoinput"
174+
aria-label="Toggle camera"
175+
source={Track.Source.Camera}
176+
pressed={cameraToggle.enabled}
177+
pending={cameraToggle.pending}
178+
disabled={cameraToggle.pending}
179+
onPressedChange={cameraToggle.toggle}
180+
onMediaDeviceError={handleCameraDeviceSelectError}
181+
onActiveDeviceChange={handleVideoDeviceChange}
182+
className={cn(
183+
TOGGLE_VARIANT_1,
184+
variant === 'livekit' &&
185+
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
186+
)}
187+
/>
188+
)}
189+
190+
{/* Toggle Screen Share */}
191+
{visibleControls.screenShare && (
192+
<AgentTrackToggle
193+
variant={variant === 'outline' ? 'outline' : 'default'}
194+
aria-label="Toggle screen share"
195+
source={Track.Source.ScreenShare}
196+
pressed={screenShareToggle.enabled}
197+
disabled={screenShareToggle.pending}
198+
onPressedChange={screenShareToggle.toggle}
199+
className={cn(TOGGLE_VARIANT_2, variant === 'livekit' && 'rounded-full')}
200+
/>
201+
)}
202+
203+
{/* Toggle Transcript */}
204+
{visibleControls.chat && (
205+
<Toggle
206+
variant={variant === 'outline' ? 'outline' : 'default'}
207+
pressed={isChatOpen || isChatOpenUncontrolled}
208+
aria-label="Toggle transcript"
209+
onPressedChange={(state) => {
210+
if (!onIsChatOpenChange) setIsChatOpenUncontrolled(state);
211+
else onIsChatOpenChange(state);
212+
}}
213+
className={agentTrackToggleVariants({
214+
variant: variant === 'outline' ? 'outline' : 'default',
215+
className: cn(TOGGLE_VARIANT_2, variant === 'livekit' && 'rounded-full'),
216+
})}
217+
>
218+
<MessageSquareTextIcon />
219+
</Toggle>
220+
)}
221+
</div>
222+
223+
{/* Disconnect */}
224+
{visibleControls.leave && (
225+
<AgentDisconnectButton
226+
onClick={onDisconnect}
227+
disabled={!isConnected}
228+
className={cn(
229+
variant === 'livekit' &&
230+
'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',
231+
)}
232+
>
233+
<span className="hidden md:inline">END CALL</span>
234+
<span className="inline md:hidden">END</span>
235+
</AgentDisconnectButton>
236+
)}
237+
</div>
238+
</div>
239+
);
240+
}

0 commit comments

Comments
 (0)