Skip to content

Commit 6d06e58

Browse files
committed
Usability improvements
1 parent 7418acd commit 6d06e58

File tree

7 files changed

+137
-45
lines changed

7 files changed

+137
-45
lines changed

src/components/AnimatedStatusIcon.tsx

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export interface AnimatedStatusIconProps {
2929
isTyping?: boolean;
3030
/** Whether there's an error */
3131
isError?: boolean;
32+
/** Volume level (0-1) for volume-responsive animations */
33+
volumeLevel?: number;
3234
/** Base color for inactive bars */
3335
baseColor?: string;
3436
/** Override animation type */
@@ -61,6 +63,7 @@ const AnimatedStatusIcon: React.FC<AnimatedStatusIconProps> = ({
6163
isSpeaking,
6264
isTyping,
6365
isError,
66+
volumeLevel = 0,
6467
baseColor = '#9CA3AF',
6568
animationType: overrideAnimationType,
6669
animationSpeed: overrideAnimationSpeed,
@@ -226,30 +229,97 @@ const AnimatedStatusIcon: React.FC<AnimatedStatusIconProps> = ({
226229
}
227230

228231
case 'scale': {
229-
// Scale animation for line layout
232+
// Speech pattern animation - each bar reacts differently to volume
230233
if (useLineLayout && 'delay' in bar) {
231234
const lineBar = bar as LineBar;
232-
const cycleTime = (time + lineBar.delay) % 1;
233-
const scaleTime = cycleTime < 0.5 ? cycleTime * 2 : 2 - cycleTime * 2;
234235

235-
// Ease in-out cubic
236-
const easeInOutCubic = (t: number) => {
237-
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
238-
};
239-
240-
const easedTime = easeInOutCubic(scaleTime);
241-
// Scale from base height to max height while maintaining the relative proportions
242-
const scaleFactor = 1 + easedTime * 0.5; // Scale up to 1.5x
243-
const height = lineBar.baseHeight * scaleFactor;
244-
const y = 12 - height / 2; // Keep bars centered vertically
236+
// Use volume level to determine overall activity
237+
const volumeIntensity = Math.max(0, Math.min(1, volumeLevel));
238+
239+
// Each bar has different sensitivity and frequency response
240+
const barCharacteristics = [
241+
{ sensitivity: 0.8, frequency: 1.2, baseActivity: 0.3 }, // Bar 0 - Low freq, less sensitive
242+
{ sensitivity: 1.0, frequency: 1.8, baseActivity: 0.4 }, // Bar 1 - Mid-low freq
243+
{ sensitivity: 1.2, frequency: 2.5, baseActivity: 0.5 }, // Bar 2 - Center, most responsive
244+
{ sensitivity: 1.0, frequency: 2.0, baseActivity: 0.4 }, // Bar 3 - Mid-high freq
245+
{ sensitivity: 0.9, frequency: 1.5, baseActivity: 0.35 }, // Bar 4 - High freq
246+
];
247+
248+
const barChar = barCharacteristics[index] || barCharacteristics[2];
249+
250+
// Create dynamic time-based variation for each bar
251+
const time = animationTime % 1;
252+
const barTime = (time * barChar.frequency) % 1;
253+
254+
// Generate pseudo-random speech-like pattern
255+
const speechPattern =
256+
Math.sin(barTime * 2 * Math.PI) * 0.3 +
257+
Math.sin(barTime * 6 * Math.PI) * 0.2 +
258+
Math.sin(barTime * 12 * Math.PI) * 0.1;
259+
260+
// Each bar responds differently based on volume and its characteristics
261+
const barResponse = volumeIntensity * barChar.sensitivity;
262+
263+
// Combine base activity, volume response, and speech pattern
264+
const activity = Math.max(
265+
0,
266+
Math.min(
267+
1,
268+
barChar.baseActivity +
269+
barResponse * 0.6 +
270+
speechPattern * volumeIntensity * 0.4
271+
)
272+
);
273+
274+
// Scale the bar height based on activity
275+
const heightScale = 0.7 + activity * 1.1; // Range from 0.7x to 1.8x
276+
const height = lineBar.baseHeight * heightScale;
277+
const y = 12 - height / 2;
278+
279+
// Opacity varies with activity
280+
const opacity = 0.4 + activity * 0.6;
245281

246282
return {
247-
opacity: 1,
283+
opacity,
248284
height,
249285
y,
250286
transform: '',
251287
};
252288
}
289+
290+
// For circular layout, create a wave-like speech pattern
291+
if (!useLineLayout) {
292+
const volumeIntensity = Math.max(0, Math.min(1, volumeLevel));
293+
const time = animationTime % 1;
294+
295+
// Create different wave patterns for each bar
296+
const barAngle = (index / actualBarCount) * 2 * Math.PI;
297+
const wavePattern =
298+
Math.sin(time * 4 * Math.PI + barAngle) * 0.3 +
299+
Math.sin(time * 8 * Math.PI + barAngle * 2) * 0.2 +
300+
Math.sin(time * 16 * Math.PI + barAngle * 3) * 0.1;
301+
302+
// Each bar has different sensitivity based on position
303+
const positionSensitivity =
304+
0.7 + 0.3 * Math.sin(barAngle + Math.PI / 4);
305+
306+
const activity = Math.max(
307+
0,
308+
Math.min(
309+
1,
310+
0.3 +
311+
volumeIntensity * positionSensitivity * 0.5 +
312+
wavePattern * volumeIntensity * 0.2
313+
)
314+
);
315+
316+
return {
317+
opacity: 0.4 + activity * 0.6,
318+
transform:
319+
activity > 0.5 ? `scale(${1 + (activity - 0.5) * 0.4})` : '',
320+
};
321+
}
322+
253323
return { opacity: 1, transform: '' };
254324
}
255325

src/components/VapiWidget.tsx

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { sizeStyles, radiusStyles, positionStyles } from './constants';
88
import ConsentForm from './widget/ConsentForm';
99
import FloatingButton from './widget/FloatingButton';
1010
import WidgetHeader from './widget/WidgetHeader';
11-
import VolumeIndicator from './widget/conversation/VolumeIndicator';
11+
import AnimatedStatusIcon from './AnimatedStatusIcon';
1212
import ConversationMessage from './widget/conversation/Message';
1313
import EmptyConversation from './widget/conversation/EmptyState';
1414
import VoiceControls from './widget/controls/VoiceControls';
@@ -35,7 +35,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
3535
mainLabel = 'Talk with AI',
3636
startButtonText = 'Start',
3737
endButtonText = 'End Call',
38-
emptyVoiceMessage = 'Click the microphone to start talking',
38+
emptyVoiceMessage = 'Click the start button to begin a conversation',
3939
emptyVoiceActiveMessage = 'Listening...',
4040
emptyChatMessage = 'Type a message to start chatting',
4141
emptyHybridMessage = 'Use voice or text to communicate',
@@ -127,8 +127,9 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
127127
!showTranscript &&
128128
vapi.voice.isCallActive &&
129129
(mode === 'voice' || mode === 'hybrid');
130+
const showingEmptyState = !vapi.voice.isCallActive;
130131

131-
if (isEmpty || hideTranscript) {
132+
if (isEmpty || hideTranscript || showingEmptyState) {
132133
return {
133134
display: 'flex',
134135
alignItems: 'center',
@@ -253,27 +254,26 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
253254
};
254255

255256
const renderConversationArea = () => {
256-
const shouldShowTranscript =
257-
showTranscript ||
258-
mode === 'chat' ||
259-
(mode === 'hybrid' && !vapi.voice.isCallActive);
260-
261-
const shouldShowVolumeIndicator =
262-
!shouldShowTranscript && vapi.voice.isCallActive;
263-
264-
if (shouldShowTranscript) {
265-
return renderConversationMessages();
266-
}
267-
268-
if (shouldShowVolumeIndicator) {
269-
return (
270-
<VolumeIndicator
271-
volumeLevel={vapi.voice.volumeLevel}
272-
isCallActive={vapi.voice.isCallActive}
273-
isSpeaking={vapi.voice.isSpeaking}
274-
theme={styles.theme}
275-
/>
276-
);
257+
if (vapi.voice.isCallActive) {
258+
const shouldShowTranscript =
259+
showTranscript || mode === 'chat' || mode === 'hybrid';
260+
261+
if (shouldShowTranscript) {
262+
return renderConversationMessages();
263+
} else {
264+
return (
265+
<AnimatedStatusIcon
266+
size={150}
267+
connectionStatus={vapi.voice.connectionStatus}
268+
isCallActive={vapi.voice.isCallActive}
269+
isSpeaking={vapi.voice.isSpeaking}
270+
isTyping={vapi.chat.isTyping}
271+
volumeLevel={vapi.voice.volumeLevel}
272+
baseColor={colors.accentColor}
273+
colors={colors.accentColor}
274+
/>
275+
);
276+
}
277277
}
278278

279279
return (
@@ -371,6 +371,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
371371

372372
{/* Conversation Area */}
373373
<div
374+
className="vapi-conversation-area"
374375
style={{
375376
...getConversationAreaStyle(),
376377
...getConversationLayoutStyle(),
@@ -402,6 +403,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
402403
connectionStatus={vapi.voice.connectionStatus}
403404
isSpeaking={vapi.voice.isSpeaking}
404405
isTyping={vapi.chat.isTyping}
406+
volumeLevel={vapi.voice.volumeLevel}
405407
onClick={handleFloatingButtonClick}
406408
onToggleCall={handleToggleCall}
407409
mainLabel={mainLabel}

src/components/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export interface FloatingButtonProps {
7474
connectionStatus: 'disconnected' | 'connecting' | 'connected';
7575
isSpeaking: boolean;
7676
isTyping: boolean;
77+
volumeLevel: number;
7778
onClick: () => void;
7879
onToggleCall?: () => void;
7980
mainLabel: string;

src/components/widget/FloatingButton.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const FloatingButton: React.FC<FloatingButtonProps> = ({
88
connectionStatus,
99
isSpeaking,
1010
isTyping,
11+
volumeLevel,
1112
onClick,
1213
onToggleCall,
1314
mainLabel,
@@ -68,6 +69,7 @@ const FloatingButton: React.FC<FloatingButtonProps> = ({
6869
isTyping={isTyping}
6970
baseColor={colors.accentColor}
7071
colors={colors.accentColor}
72+
volumeLevel={volumeLevel}
7173
/>
7274

7375
{(styles.size === 'compact' || styles.size === 'full') &&

src/components/widget/conversation/Message.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
1111
<div className="markdown-content">
1212
<ReactMarkdown
1313
components={{
14-
p: ({ children }) => <p className="mb-1 last:mb-0">{children}</p>,
14+
p: ({ children }) => <p className="mb-3 last:mb-0">{children}</p>,
1515
ul: ({ children }) => (
16-
<ul className="list-disc list-inside mb-1 last:mb-0">{children}</ul>
16+
<ul className="list-disc list-inside mb-3 last:mb-0">{children}</ul>
1717
),
1818
ol: ({ children }) => (
19-
<ol className="list-decimal list-inside mb-1 last:mb-0">
19+
<ol className="list-decimal list-inside mb-3 last:mb-0">
2020
{children}
2121
</ol>
2222
),
@@ -57,7 +57,7 @@ const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
5757
<h3 className="text-sm font-bold mb-1">{children}</h3>
5858
),
5959
blockquote: ({ children }) => (
60-
<blockquote className="border-l-2 pl-2 my-1 opacity-80">
60+
<blockquote className="border-l-2 pl-2 my-3 opacity-80">
6161
{children}
6262
</blockquote>
6363
),

src/styles/globals.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,14 @@
88
box-sizing: border-box;
99
}
1010
}
11+
12+
/* Hide scrollbar completely */
13+
.vapi-conversation-area {
14+
/* Firefox */
15+
scrollbar-width: none;
16+
}
17+
18+
/* Webkit browsers */
19+
.vapi-conversation-area::-webkit-scrollbar {
20+
display: none;
21+
}

tests/widget-embed.spec.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ test.describe('VapiWidget Embed Tests', () => {
3535
// Verify the widget has created some content (React root or shadow DOM)
3636
const widgetInitialized = await page.evaluate(() => {
3737
const widget = document.querySelector('#vapi-widget-1');
38-
if (!widget) return false;
38+
if (!widget) {
39+
return false;
40+
}
3941
// Check for React root or any child elements
4042
return (
4143
widget.children.length > 0 ||
@@ -74,7 +76,9 @@ test.describe('VapiWidget Embed Tests', () => {
7476
// Verify the widget has been initialized
7577
const widgetInitialized = await page.evaluate(() => {
7678
const widget = document.querySelector('#vapi-widget-2');
77-
if (!widget) return false;
79+
if (!widget) {
80+
return false;
81+
}
7882
return (
7983
widget.children.length > 0 ||
8084
widget.shadowRoot !== null ||
@@ -111,7 +115,9 @@ test.describe('VapiWidget Embed Tests', () => {
111115
// Verify the custom element has been initialized
112116
const widgetInitialized = await page.evaluate(() => {
113117
const widget = document.querySelector('vapi-widget');
114-
if (!widget) return false;
118+
if (!widget) {
119+
return false;
120+
}
115121
return (
116122
widget.children.length > 0 ||
117123
widget.shadowRoot !== null ||

0 commit comments

Comments
 (0)