Skip to content

Commit 68da5ff

Browse files
authored
Merge pull request #27 from VapiAI/steven-diaz/vap-9675-widget-reconnects-to-phone-call
VAP-9675: add reconnect to web calls supports for widget
2 parents 1f60dc9 + 0ac3cdf commit 68da5ff

File tree

9 files changed

+269
-54
lines changed

9 files changed

+269
-54
lines changed

package-lock.json

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
],
6969
"license": "",
7070
"peerDependencies": {
71-
"@vapi-ai/web": "^2.3.7",
71+
"@vapi-ai/web": "^2.5.0",
7272
"react": ">=16.8.0",
7373
"react-dom": ">=16.8.0"
7474
},

src/components/VapiWidget.tsx

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect, useRef } from 'react';
1+
import React, { useState, useEffect, useRef, useCallback } from 'react';
22
import { useVapiWidget } from '../hooks';
33

44
import { VapiWidgetProps, ColorScheme, StyleConfig } from './types';
@@ -61,6 +61,8 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
6161
// Voice configuration
6262
voiceShowTranscript,
6363
showTranscript = false, // deprecated
64+
voiceAutoReconnect = false,
65+
reconnectStorageKey = 'vapi_widget_web_call',
6466
// Consent configuration
6567
consentRequired,
6668
requireConsent = false, // deprecated
@@ -77,14 +79,39 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
7779
onMessage,
7880
onError,
7981
}) => {
80-
const [isExpanded, setIsExpanded] = useState(false);
82+
// Create storage key for expanded state
83+
const expandedStorageKey = `vapi_widget_expanded`;
84+
85+
// Initialize expanded state from localStorage
86+
const [isExpanded, setIsExpanded] = useState(() => {
87+
try {
88+
const stored = sessionStorage.getItem(expandedStorageKey);
89+
return stored === 'true';
90+
} catch {
91+
return false;
92+
}
93+
});
94+
8195
const [hasConsent, setHasConsent] = useState(false);
8296
const [chatInput, setChatInput] = useState('');
8397
const [showEndScreen, setShowEndScreen] = useState(false);
8498

8599
const conversationEndRef = useRef<HTMLDivElement>(null);
86100
const inputRef = useRef<HTMLInputElement>(null);
87101

102+
// Custom setter that updates both state and localStorage
103+
const updateExpandedState = useCallback(
104+
(expanded: boolean) => {
105+
setIsExpanded(expanded);
106+
try {
107+
sessionStorage.setItem(expandedStorageKey, expanded.toString());
108+
} catch (error) {
109+
console.warn('Failed to save expanded state to localStorage:', error);
110+
}
111+
},
112+
[expandedStorageKey]
113+
);
114+
88115
const effectiveBorderRadius = borderRadius ?? radius;
89116
const effectiveBaseBgColor = baseBgColor ?? baseColor;
90117
const effectiveAccentColor = accentColor ?? '#14B8A6';
@@ -122,6 +149,8 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
122149
assistantOverrides,
123150
apiUrl,
124151
firstChatMessage: effectiveChatFirstMessage,
152+
voiceAutoReconnect,
153+
reconnectStorageKey,
125154
onCallStart: effectiveOnVoiceStart,
126155
onCallEnd: effectiveOnVoiceEnd,
127156
onMessage,
@@ -232,11 +261,11 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
232261
};
233262

234263
const handleConsentCancel = () => {
235-
setIsExpanded(false);
264+
updateExpandedState(false);
236265
};
237266

238267
const handleToggleCall = async () => {
239-
await vapi.voice.toggleCall();
268+
await vapi.voice.toggleCall({ force: voiceAutoReconnect });
240269
};
241270

242271
const handleSendMessage = async () => {
@@ -260,7 +289,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
260289
setShowEndScreen(false);
261290

262291
if (vapi.voice.isCallActive) {
263-
vapi.voice.endCall();
292+
vapi.voice.endCall({ force: voiceAutoReconnect });
264293
}
265294

266295
setChatInput('');
@@ -297,11 +326,11 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
297326
setShowEndScreen(false);
298327
setChatInput('');
299328
}
300-
setIsExpanded(false);
329+
updateExpandedState(false);
301330
};
302331

303332
const handleFloatingButtonClick = () => {
304-
setIsExpanded(true);
333+
updateExpandedState(true);
305334
};
306335

307336
const renderConversationMessages = () => {

src/components/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export interface VapiWidgetProps {
4848

4949
// Voice Configuration
5050
voiceShowTranscript?: boolean;
51+
voiceAutoReconnect?: boolean;
52+
reconnectStorageKey?: string;
5153

5254
// Consent Configuration
5355
consentRequired?: boolean;

src/hooks/useVapiCall.ts

Lines changed: 120 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, useEffect, useRef, useCallback } from 'react';
22
import Vapi from '@vapi-ai/web';
3+
import * as vapiCallStorage from '../utils/vapiCallStorage';
34

45
export interface VapiCallState {
56
isCallActive: boolean;
@@ -11,16 +12,20 @@ export interface VapiCallState {
1112

1213
export interface VapiCallHandlers {
1314
startCall: () => Promise<void>;
14-
endCall: () => Promise<void>;
15-
toggleCall: () => Promise<void>;
15+
endCall: (opts?: { force?: boolean }) => Promise<void>;
16+
toggleCall: (opts?: { force?: boolean }) => Promise<void>;
1617
toggleMute: () => void;
18+
reconnect: () => Promise<void>;
19+
clearStoredCall: () => void;
1720
}
1821

1922
export interface UseVapiCallOptions {
2023
publicKey: string;
2124
callOptions: any;
2225
apiUrl?: string;
2326
enabled?: boolean;
27+
voiceAutoReconnect?: boolean;
28+
reconnectStorageKey?: string;
2429
onCallStart?: () => void;
2530
onCallEnd?: () => void;
2631
onMessage?: (message: any) => void;
@@ -37,6 +42,8 @@ export const useVapiCall = ({
3742
callOptions,
3843
apiUrl,
3944
enabled = true,
45+
voiceAutoReconnect = false,
46+
reconnectStorageKey = 'vapi_widget_web_call',
4047
onCallStart,
4148
onCallEnd,
4249
onMessage,
@@ -90,6 +97,8 @@ export const useVapiCall = ({
9097
setVolumeLevel(0);
9198
setIsSpeaking(false);
9299
setIsMuted(false);
100+
// Clear stored call data on successful call end
101+
vapiCallStorage.clearStoredCall(reconnectStorageKey);
93102
callbacksRef.current.onCallEnd?.();
94103
};
95104

@@ -144,7 +153,7 @@ export const useVapiCall = ({
144153
vapi.removeListener('message', handleMessage);
145154
vapi.removeListener('error', handleError);
146155
};
147-
}, [vapi]);
156+
}, [vapi, reconnectStorageKey]);
148157

149158
useEffect(() => {
150159
return () => {
@@ -161,33 +170,68 @@ export const useVapiCall = ({
161170
}
162171

163172
try {
164-
console.log('Starting call with options:', callOptions);
173+
console.log('Starting call with configuration:', callOptions);
174+
console.log('Starting call with options:', {
175+
voiceAutoReconnect,
176+
});
165177
setConnectionStatus('connecting');
166-
await vapi.start(callOptions);
178+
const call = await vapi.start(
179+
// assistant
180+
callOptions,
181+
// assistant overrides,
182+
undefined,
183+
// squad
184+
undefined,
185+
// workflow
186+
undefined,
187+
// workflow overrides
188+
undefined,
189+
// options
190+
{
191+
roomDeleteOnUserLeaveEnabled: !voiceAutoReconnect,
192+
}
193+
);
194+
195+
// Store call data for reconnection if call was successful and auto-reconnect is enabled
196+
if (call && voiceAutoReconnect) {
197+
vapiCallStorage.storeCallData(reconnectStorageKey, call, callOptions);
198+
}
167199
} catch (error) {
168200
console.error('Error starting call:', error);
169201
setConnectionStatus('disconnected');
170202
callbacksRef.current.onError?.(error as Error);
171203
}
172-
}, [vapi, callOptions, enabled]);
204+
}, [vapi, callOptions, enabled, voiceAutoReconnect, reconnectStorageKey]);
173205

174-
const endCall = useCallback(async () => {
175-
if (!vapi) {
176-
console.log('Cannot end call: no vapi instance');
177-
return;
178-
}
206+
const endCall = useCallback(
207+
async ({ force = false }: { force?: boolean } = {}) => {
208+
if (!vapi) {
209+
console.log('Cannot end call: no vapi instance');
210+
return;
211+
}
179212

180-
console.log('Ending call');
181-
vapi.stop();
182-
}, [vapi]);
213+
console.log('Ending call with force:', force);
214+
if (force) {
215+
// end vapi call and delete daily room
216+
vapi.end();
217+
} else {
218+
// simply disconnect from daily room
219+
vapi.stop();
220+
}
221+
},
222+
[vapi]
223+
);
183224

184-
const toggleCall = useCallback(async () => {
185-
if (isCallActive) {
186-
await endCall();
187-
} else {
188-
await startCall();
189-
}
190-
}, [isCallActive, startCall, endCall]);
225+
const toggleCall = useCallback(
226+
async ({ force = false }: { force?: boolean } = {}) => {
227+
if (isCallActive) {
228+
await endCall({ force });
229+
} else {
230+
await startCall();
231+
}
232+
},
233+
[isCallActive, startCall, endCall]
234+
);
191235

192236
const toggleMute = useCallback(() => {
193237
if (!vapi || !isCallActive) {
@@ -200,6 +244,59 @@ export const useVapiCall = ({
200244
setIsMuted(newMutedState);
201245
}, [vapi, isCallActive, isMuted]);
202246

247+
const reconnect = useCallback(async () => {
248+
if (!vapi || !enabled) {
249+
console.error('Cannot reconnect: no vapi instance or not enabled');
250+
return;
251+
}
252+
253+
const storedData = vapiCallStorage.getStoredCallData(reconnectStorageKey);
254+
255+
if (!storedData) {
256+
console.warn('No stored call data found for reconnection');
257+
return;
258+
}
259+
260+
// Check if callOptions match before reconnecting
261+
if (
262+
!vapiCallStorage.areCallOptionsEqual(storedData.callOptions, callOptions)
263+
) {
264+
console.warn(
265+
'CallOptions have changed since last call, clearing stored data and skipping reconnection'
266+
);
267+
vapiCallStorage.clearStoredCall(reconnectStorageKey);
268+
return;
269+
}
270+
271+
setConnectionStatus('connecting');
272+
273+
try {
274+
await vapi.reconnect({
275+
webCallUrl: storedData.webCallUrl,
276+
id: storedData.id,
277+
artifactPlan: storedData.artifactPlan,
278+
assistant: storedData.assistant,
279+
});
280+
console.log('Successfully reconnected to call');
281+
} catch (error) {
282+
setConnectionStatus('disconnected');
283+
console.error('Reconnection failed:', error);
284+
vapiCallStorage.clearStoredCall(reconnectStorageKey);
285+
callbacksRef.current.onError?.(error as Error);
286+
}
287+
}, [vapi, enabled, reconnectStorageKey, callOptions]);
288+
289+
const clearStoredCall = useCallback(() => {
290+
vapiCallStorage.clearStoredCall(reconnectStorageKey);
291+
}, [reconnectStorageKey]);
292+
293+
useEffect(() => {
294+
if (!vapi || !enabled || !voiceAutoReconnect) {
295+
return;
296+
}
297+
reconnect();
298+
}, [vapi, enabled, voiceAutoReconnect, reconnect, reconnectStorageKey]);
299+
203300
return {
204301
// State
205302
isCallActive,
@@ -212,5 +309,7 @@ export const useVapiCall = ({
212309
endCall,
213310
toggleCall,
214311
toggleMute,
312+
reconnect,
313+
clearStoredCall,
215314
};
216315
};

0 commit comments

Comments
 (0)