From dddf82c5d831ab7ca9f0e349fcf1613ee4298382 Mon Sep 17 00:00:00 2001 From: Steven Diaz Date: Sun, 19 Oct 2025 00:19:34 -0700 Subject: [PATCH 1/3] feat: support voice reconnect --- src/components/VapiWidget.tsx | 43 ++++++-- src/components/types.ts | 2 + src/hooks/useVapiCall.ts | 196 ++++++++++++++++++++++++++++++---- src/hooks/useVapiWidget.ts | 31 ++++-- 4 files changed, 233 insertions(+), 39 deletions(-) diff --git a/src/components/VapiWidget.tsx b/src/components/VapiWidget.tsx index e984b9d..995a71b 100644 --- a/src/components/VapiWidget.tsx +++ b/src/components/VapiWidget.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useVapiWidget } from '../hooks'; import { VapiWidgetProps, ColorScheme, StyleConfig } from './types'; @@ -61,6 +61,8 @@ const VapiWidget: React.FC = ({ // Voice configuration voiceShowTranscript, showTranscript = false, // deprecated + voiceAutoReconnect = false, + reconnectStorageKey = 'vapi_widget_web_call', // Consent configuration consentRequired, requireConsent = false, // deprecated @@ -77,7 +79,19 @@ const VapiWidget: React.FC = ({ onMessage, onError, }) => { - const [isExpanded, setIsExpanded] = useState(false); + // Create storage key for expanded state + const expandedStorageKey = `vapi_widget_expanded`; + + // Initialize expanded state from localStorage + const [isExpanded, setIsExpanded] = useState(() => { + try { + const stored = localStorage.getItem(expandedStorageKey); + return stored === 'true'; + } catch { + return false; + } + }); + const [hasConsent, setHasConsent] = useState(false); const [chatInput, setChatInput] = useState(''); const [showEndScreen, setShowEndScreen] = useState(false); @@ -85,6 +99,19 @@ const VapiWidget: React.FC = ({ const conversationEndRef = useRef(null); const inputRef = useRef(null); + // Custom setter that updates both state and localStorage + const updateExpandedState = useCallback( + (expanded: boolean) => { + setIsExpanded(expanded); + try { + localStorage.setItem(expandedStorageKey, expanded.toString()); + } catch (error) { + console.warn('Failed to save expanded state to localStorage:', error); + } + }, + [expandedStorageKey] + ); + const effectiveBorderRadius = borderRadius ?? radius; const effectiveBaseBgColor = baseBgColor ?? baseColor; const effectiveAccentColor = accentColor ?? '#14B8A6'; @@ -122,6 +149,8 @@ const VapiWidget: React.FC = ({ assistantOverrides, apiUrl, firstChatMessage: effectiveChatFirstMessage, + voiceAutoReconnect, + reconnectStorageKey, onCallStart: effectiveOnVoiceStart, onCallEnd: effectiveOnVoiceEnd, onMessage, @@ -232,11 +261,11 @@ const VapiWidget: React.FC = ({ }; const handleConsentCancel = () => { - setIsExpanded(false); + updateExpandedState(false); }; const handleToggleCall = async () => { - await vapi.voice.toggleCall(); + await vapi.voice.toggleCall({ force: voiceAutoReconnect }); }; const handleSendMessage = async () => { @@ -260,7 +289,7 @@ const VapiWidget: React.FC = ({ setShowEndScreen(false); if (vapi.voice.isCallActive) { - vapi.voice.endCall(); + vapi.voice.endCall({ force: voiceAutoReconnect }); } setChatInput(''); @@ -297,11 +326,11 @@ const VapiWidget: React.FC = ({ setShowEndScreen(false); setChatInput(''); } - setIsExpanded(false); + updateExpandedState(false); }; const handleFloatingButtonClick = () => { - setIsExpanded(true); + updateExpandedState(true); }; const renderConversationMessages = () => { diff --git a/src/components/types.ts b/src/components/types.ts index 63aec12..6931d8a 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -48,6 +48,8 @@ export interface VapiWidgetProps { // Voice Configuration voiceShowTranscript?: boolean; + voiceAutoReconnect?: boolean; + reconnectStorageKey?: string; // Consent Configuration consentRequired?: boolean; diff --git a/src/hooks/useVapiCall.ts b/src/hooks/useVapiCall.ts index 8627185..3c1fbea 100644 --- a/src/hooks/useVapiCall.ts +++ b/src/hooks/useVapiCall.ts @@ -1,6 +1,20 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import Vapi from '@vapi-ai/web'; +interface StoredCallData { + webCallUrl: string; + id?: string; + artifactPlan?: { + videoRecordingEnabled?: boolean; + }; + assistant?: { + voice?: { + provider?: string; + }; + }; + timestamp: number; +} + export interface VapiCallState { isCallActive: boolean; isSpeaking: boolean; @@ -11,9 +25,11 @@ export interface VapiCallState { export interface VapiCallHandlers { startCall: () => Promise; - endCall: () => Promise; - toggleCall: () => Promise; + endCall: (opts?: { force?: boolean }) => Promise; + toggleCall: (opts?: { force?: boolean }) => Promise; toggleMute: () => void; + reconnect: () => Promise; + clearStoredCall: () => void; } export interface UseVapiCallOptions { @@ -21,6 +37,8 @@ export interface UseVapiCallOptions { callOptions: any; apiUrl?: string; enabled?: boolean; + roomDeleteOnUserLeaveEnabled?: boolean; + reconnectStorageKey?: string; onCallStart?: () => void; onCallEnd?: () => void; onMessage?: (message: any) => void; @@ -37,6 +55,8 @@ export const useVapiCall = ({ callOptions, apiUrl, enabled = true, + roomDeleteOnUserLeaveEnabled = false, + reconnectStorageKey = 'vapi_widget_web_call', onCallStart, onCallEnd, onMessage, @@ -63,6 +83,54 @@ export const useVapiCall = ({ onTranscript, }); + // localStorage utilities + const storeCallData = useCallback( + (call: any) => { + if (!roomDeleteOnUserLeaveEnabled) { + // Extract webCallUrl from the call object + // The webCallUrl can be in call.webCallUrl or call.transport.callUrl + const webCallUrl = + (call as any).webCallUrl || (call.transport as any)?.callUrl; + + if (webCallUrl) { + const webCallToStore = { + webCallUrl, + id: call.id, + artifactPlan: call.artifactPlan, + assistant: call.assistant, + timestamp: Date.now(), + }; + localStorage.setItem( + reconnectStorageKey, + JSON.stringify(webCallToStore) + ); + console.log('Stored call data for reconnection:', webCallToStore); + } else { + console.warn( + 'No webCallUrl found in call object, cannot store for reconnection' + ); + } + } + }, + [roomDeleteOnUserLeaveEnabled, reconnectStorageKey] + ); + + const getStoredCallData = useCallback((): StoredCallData | null => { + try { + const stored = localStorage.getItem(reconnectStorageKey); + if (!stored) return null; + + return JSON.parse(stored); + } catch { + localStorage.removeItem(reconnectStorageKey); + return null; + } + }, [reconnectStorageKey]); + + const clearStoredCall = useCallback(() => { + localStorage.removeItem(reconnectStorageKey); + }, [reconnectStorageKey]); + useEffect(() => { callbacksRef.current = { onCallStart, @@ -90,6 +158,8 @@ export const useVapiCall = ({ setVolumeLevel(0); setIsSpeaking(false); setIsMuted(false); + // Clear stored call data on successful call end + clearStoredCall(); callbacksRef.current.onCallEnd?.(); }; @@ -144,7 +214,7 @@ export const useVapiCall = ({ vapi.removeListener('message', handleMessage); vapi.removeListener('error', handleError); }; - }, [vapi]); + }, [vapi, clearStoredCall]); useEffect(() => { return () => { @@ -161,33 +231,68 @@ export const useVapiCall = ({ } try { - console.log('Starting call with options:', callOptions); + console.log('Starting call with configuration:', callOptions); + console.log('Starting call with options:', { + roomDeleteOnUserLeaveEnabled, + }); setConnectionStatus('connecting'); - await vapi.start(callOptions); + const call = await vapi.start( + // assistant + callOptions, + // assistant overrides, + undefined, + // squad + undefined, + // workflow + undefined, + // workflow overrides + undefined, + // options + { + roomDeleteOnUserLeaveEnabled, + } + ); + + // Store call data for reconnection if call was successful and auto-reconnect is enabled + if (call && !roomDeleteOnUserLeaveEnabled) { + storeCallData(call); + } } catch (error) { console.error('Error starting call:', error); setConnectionStatus('disconnected'); callbacksRef.current.onError?.(error as Error); } - }, [vapi, callOptions, enabled]); + }, [vapi, callOptions, enabled, roomDeleteOnUserLeaveEnabled, storeCallData]); - const endCall = useCallback(async () => { - if (!vapi) { - console.log('Cannot end call: no vapi instance'); - return; - } + const endCall = useCallback( + async ({ force = false }: { force?: boolean } = {}) => { + if (!vapi) { + console.log('Cannot end call: no vapi instance'); + return; + } - console.log('Ending call'); - vapi.stop(); - }, [vapi]); + console.log('Ending call with force:', force); + if (force) { + // end vapi call and delete daily room + vapi.end(); + } else { + // simply disconnect from daily room + vapi.stop(); + } + }, + [vapi] + ); - const toggleCall = useCallback(async () => { - if (isCallActive) { - await endCall(); - } else { - await startCall(); - } - }, [isCallActive, startCall, endCall]); + const toggleCall = useCallback( + async ({ force = false }: { force?: boolean } = {}) => { + if (isCallActive) { + await endCall({ force }); + } else { + await startCall(); + } + }, + [isCallActive, startCall, endCall] + ); const toggleMute = useCallback(() => { if (!vapi || !isCallActive) { @@ -200,6 +305,53 @@ export const useVapiCall = ({ setIsMuted(newMutedState); }, [vapi, isCallActive, isMuted]); + const reconnect = useCallback(async () => { + if (!vapi || !enabled) { + console.error('Cannot reconnect: no vapi instance or not enabled'); + return; + } + + const storedData = getStoredCallData(); + if (!storedData) { + console.warn('No stored call data found for reconnection'); + return; + } + + setConnectionStatus('connecting'); + try { + await vapi.reconnect({ + webCallUrl: storedData.webCallUrl, + id: storedData.id, + artifactPlan: storedData.artifactPlan, + assistant: storedData.assistant, + }); + console.log('Successfully reconnected to call'); + } catch (error) { + setConnectionStatus('disconnected'); + console.error('Reconnection failed:', error); + clearStoredCall(); + callbacksRef.current.onError?.(error as Error); + } + }, [vapi, enabled, getStoredCallData, clearStoredCall]); + + // Check for stored call data on initialization and attempt reconnection + useEffect(() => { + if (!vapi || !enabled || roomDeleteOnUserLeaveEnabled) { + return; + } + + const storedData = getStoredCallData(); + if (storedData) { + reconnect(); + } + }, [ + vapi, + enabled, + roomDeleteOnUserLeaveEnabled, + getStoredCallData, + reconnect, + ]); + return { // State isCallActive, @@ -212,5 +364,7 @@ export const useVapiCall = ({ endCall, toggleCall, toggleMute, + reconnect, + clearStoredCall, }; }; diff --git a/src/hooks/useVapiWidget.ts b/src/hooks/useVapiWidget.ts index 17fb76c..1084d32 100644 --- a/src/hooks/useVapiWidget.ts +++ b/src/hooks/useVapiWidget.ts @@ -15,6 +15,8 @@ export interface UseVapiWidgetOptions { assistantOverrides?: AssistantOverrides; apiUrl?: string; firstChatMessage?: string; + voiceAutoReconnect?: boolean; + reconnectStorageKey?: string; onCallStart?: () => void; onCallEnd?: () => void; onMessage?: (message: any) => void; @@ -29,6 +31,8 @@ export const useVapiWidget = ({ assistantOverrides, apiUrl, firstChatMessage, + voiceAutoReconnect = false, + reconnectStorageKey, onCallStart, onCallEnd, onMessage, @@ -63,6 +67,8 @@ export const useVapiWidget = ({ callOptions: buildCallOptions(), apiUrl, enabled: voiceEnabled, + roomDeleteOnUserLeaveEnabled: voiceAutoReconnect, + reconnectStorageKey, onCallStart: () => { // In hybrid mode, clear all conversations when starting voice if (mode === 'hybrid') { @@ -123,7 +129,7 @@ export const useVapiWidget = ({ // In hybrid mode, switch to chat and clear all conversations only if switching from voice if (mode === 'hybrid') { if (voice.isCallActive) { - await voice.endCall(); + await voice.endCall({ force: true }); } // Only clear conversations if we're switching from voice mode if (activeMode !== 'chat') { @@ -137,16 +143,19 @@ export const useVapiWidget = ({ [mode, chat, voice, activeMode] ); - const toggleCall = useCallback(async () => { - if (mode === 'hybrid' && !voice.isCallActive) { - // Clear all conversations when switching to voice - chat.clearMessages(); - setVoiceConversation([]); - setActiveMode('voice'); - setIsUserTyping(false); - } - await voice.toggleCall(); - }, [mode, voice, chat]); + const toggleCall = useCallback( + async ({ force }: { force?: boolean } = {}) => { + if (mode === 'hybrid' && !voice.isCallActive) { + // Clear all conversations when switching to voice + chat.clearMessages(); + setVoiceConversation([]); + setActiveMode('voice'); + setIsUserTyping(false); + } + await voice.toggleCall({ force }); + }, + [mode, voice, chat] + ); const clearConversation = useCallback(() => { setVoiceConversation([]); From d321db887b7ecb8eace08ac7b947514822fc4f08 Mon Sep 17 00:00:00 2001 From: Steven Diaz Date: Sun, 19 Oct 2025 02:49:34 -0700 Subject: [PATCH 2/3] refactor: voice reconnect with session storage --- package-lock.json | 28 ++++---- package.json | 2 +- src/hooks/useVapiCall.ts | 121 ++++++++++------------------------- src/hooks/useVapiWidget.ts | 2 +- src/utils/index.ts | 1 + src/utils/vapiCallStorage.ts | 73 +++++++++++++++++++++ src/widget/index.ts | 2 + 7 files changed, 125 insertions(+), 104 deletions(-) create mode 100644 src/utils/vapiCallStorage.ts diff --git a/package-lock.json b/package-lock.json index 38f528c..66183df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "vite-plugin-dts": "^3.9.1" }, "peerDependencies": { - "@vapi-ai/web": "^2.3.7", + "@vapi-ai/web": "^2.5.0", "react": ">=16.8.0", "react-dom": ">=16.8.0" } @@ -318,9 +318,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", "peer": true, "engines": { @@ -376,9 +376,9 @@ } }, "node_modules/@daily-co/daily-js": { - "version": "0.80.0", - "resolved": "https://registry.npmjs.org/@daily-co/daily-js/-/daily-js-0.80.0.tgz", - "integrity": "sha512-zG2NBbKbHfm56P0lg4ddC94vBtn5AQKcgvbYrO5+ohNWPSolMqlJiYxQC9uhOHfFYRhH4ELKQ6NHqGatX9VD7A==", + "version": "0.84.0", + "resolved": "https://registry.npmjs.org/@daily-co/daily-js/-/daily-js-0.84.0.tgz", + "integrity": "sha512-/ynXrMDDkRXhLlHxiFNf9QU5yw4ZGPr56wNARgja/Tiid71UIniundTavCNF5cMb2I1vNoMh7oEJ/q8stg/V7g==", "license": "BSD-2-Clause", "peer": true, "dependencies": { @@ -2173,13 +2173,13 @@ "license": "ISC" }, "node_modules/@vapi-ai/web": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/@vapi-ai/web/-/web-2.3.7.tgz", - "integrity": "sha512-2wG8j2h2FwOiNsVJHXlUKkq828Pj6uQn3UTJSWCjpqiA4g7GSmi/Qb/+hxWjVBnCdiE+t05PsOAM6cUpVY+3zQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@vapi-ai/web/-/web-2.5.0.tgz", + "integrity": "sha512-I+XkLPG99kWspJ2AH4vky/vSgCui9ntb20n4/D9RSP6ChG3RHNvZVXoA79gKiY1fWmowuzBD7hL8sJbGHfJOJw==", "license": "MIT", "peer": true, "dependencies": { - "@daily-co/daily-js": "^0.80.0", + "@daily-co/daily-js": "^0.84.0", "events": "^3.3.0" }, "engines": { @@ -2550,9 +2550,9 @@ } }, "node_modules/bowser": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", - "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", + "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", "license": "MIT", "peer": true }, diff --git a/package.json b/package.json index 8b9af70..c41234a 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ ], "license": "", "peerDependencies": { - "@vapi-ai/web": "^2.3.7", + "@vapi-ai/web": "^2.5.0", "react": ">=16.8.0", "react-dom": ">=16.8.0" }, diff --git a/src/hooks/useVapiCall.ts b/src/hooks/useVapiCall.ts index 3c1fbea..90f676f 100644 --- a/src/hooks/useVapiCall.ts +++ b/src/hooks/useVapiCall.ts @@ -1,19 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import Vapi from '@vapi-ai/web'; - -interface StoredCallData { - webCallUrl: string; - id?: string; - artifactPlan?: { - videoRecordingEnabled?: boolean; - }; - assistant?: { - voice?: { - provider?: string; - }; - }; - timestamp: number; -} +import * as vapiCallStorage from '../utils/vapiCallStorage'; export interface VapiCallState { isCallActive: boolean; @@ -37,7 +24,7 @@ export interface UseVapiCallOptions { callOptions: any; apiUrl?: string; enabled?: boolean; - roomDeleteOnUserLeaveEnabled?: boolean; + voiceAutoReconnect?: boolean; reconnectStorageKey?: string; onCallStart?: () => void; onCallEnd?: () => void; @@ -55,7 +42,7 @@ export const useVapiCall = ({ callOptions, apiUrl, enabled = true, - roomDeleteOnUserLeaveEnabled = false, + voiceAutoReconnect = false, reconnectStorageKey = 'vapi_widget_web_call', onCallStart, onCallEnd, @@ -83,54 +70,6 @@ export const useVapiCall = ({ onTranscript, }); - // localStorage utilities - const storeCallData = useCallback( - (call: any) => { - if (!roomDeleteOnUserLeaveEnabled) { - // Extract webCallUrl from the call object - // The webCallUrl can be in call.webCallUrl or call.transport.callUrl - const webCallUrl = - (call as any).webCallUrl || (call.transport as any)?.callUrl; - - if (webCallUrl) { - const webCallToStore = { - webCallUrl, - id: call.id, - artifactPlan: call.artifactPlan, - assistant: call.assistant, - timestamp: Date.now(), - }; - localStorage.setItem( - reconnectStorageKey, - JSON.stringify(webCallToStore) - ); - console.log('Stored call data for reconnection:', webCallToStore); - } else { - console.warn( - 'No webCallUrl found in call object, cannot store for reconnection' - ); - } - } - }, - [roomDeleteOnUserLeaveEnabled, reconnectStorageKey] - ); - - const getStoredCallData = useCallback((): StoredCallData | null => { - try { - const stored = localStorage.getItem(reconnectStorageKey); - if (!stored) return null; - - return JSON.parse(stored); - } catch { - localStorage.removeItem(reconnectStorageKey); - return null; - } - }, [reconnectStorageKey]); - - const clearStoredCall = useCallback(() => { - localStorage.removeItem(reconnectStorageKey); - }, [reconnectStorageKey]); - useEffect(() => { callbacksRef.current = { onCallStart, @@ -159,7 +98,7 @@ export const useVapiCall = ({ setIsSpeaking(false); setIsMuted(false); // Clear stored call data on successful call end - clearStoredCall(); + vapiCallStorage.clearStoredCall(reconnectStorageKey); callbacksRef.current.onCallEnd?.(); }; @@ -214,7 +153,7 @@ export const useVapiCall = ({ vapi.removeListener('message', handleMessage); vapi.removeListener('error', handleError); }; - }, [vapi, clearStoredCall]); + }, [vapi, reconnectStorageKey]); useEffect(() => { return () => { @@ -233,7 +172,7 @@ export const useVapiCall = ({ try { console.log('Starting call with configuration:', callOptions); console.log('Starting call with options:', { - roomDeleteOnUserLeaveEnabled, + voiceAutoReconnect, }); setConnectionStatus('connecting'); const call = await vapi.start( @@ -249,20 +188,20 @@ export const useVapiCall = ({ undefined, // options { - roomDeleteOnUserLeaveEnabled, + roomDeleteOnUserLeaveEnabled: !voiceAutoReconnect, } ); // Store call data for reconnection if call was successful and auto-reconnect is enabled - if (call && !roomDeleteOnUserLeaveEnabled) { - storeCallData(call); + if (call && voiceAutoReconnect) { + vapiCallStorage.storeCallData(reconnectStorageKey, call, callOptions); } } catch (error) { console.error('Error starting call:', error); setConnectionStatus('disconnected'); callbacksRef.current.onError?.(error as Error); } - }, [vapi, callOptions, enabled, roomDeleteOnUserLeaveEnabled, storeCallData]); + }, [vapi, callOptions, enabled, voiceAutoReconnect, reconnectStorageKey]); const endCall = useCallback( async ({ force = false }: { force?: boolean } = {}) => { @@ -311,13 +250,26 @@ export const useVapiCall = ({ return; } - const storedData = getStoredCallData(); + const storedData = vapiCallStorage.getStoredCallData(reconnectStorageKey); + if (!storedData) { console.warn('No stored call data found for reconnection'); return; } + // Check if callOptions match before reconnecting + if ( + !vapiCallStorage.areCallOptionsEqual(storedData.callOptions, callOptions) + ) { + console.warn( + 'CallOptions have changed since last call, clearing stored data and skipping reconnection' + ); + vapiCallStorage.clearStoredCall(reconnectStorageKey); + return; + } + setConnectionStatus('connecting'); + try { await vapi.reconnect({ webCallUrl: storedData.webCallUrl, @@ -329,28 +281,21 @@ export const useVapiCall = ({ } catch (error) { setConnectionStatus('disconnected'); console.error('Reconnection failed:', error); - clearStoredCall(); + vapiCallStorage.clearStoredCall(reconnectStorageKey); callbacksRef.current.onError?.(error as Error); } - }, [vapi, enabled, getStoredCallData, clearStoredCall]); + }, [vapi, enabled, reconnectStorageKey, callOptions]); + + const clearStoredCall = useCallback(() => { + vapiCallStorage.clearStoredCall(reconnectStorageKey); + }, [reconnectStorageKey]); - // Check for stored call data on initialization and attempt reconnection useEffect(() => { - if (!vapi || !enabled || roomDeleteOnUserLeaveEnabled) { + if (!vapi || !enabled || !voiceAutoReconnect) { return; } - - const storedData = getStoredCallData(); - if (storedData) { - reconnect(); - } - }, [ - vapi, - enabled, - roomDeleteOnUserLeaveEnabled, - getStoredCallData, - reconnect, - ]); + reconnect(); + }, [vapi, enabled, voiceAutoReconnect, reconnect, reconnectStorageKey]); return { // State diff --git a/src/hooks/useVapiWidget.ts b/src/hooks/useVapiWidget.ts index 1084d32..46bb4f2 100644 --- a/src/hooks/useVapiWidget.ts +++ b/src/hooks/useVapiWidget.ts @@ -67,7 +67,7 @@ export const useVapiWidget = ({ callOptions: buildCallOptions(), apiUrl, enabled: voiceEnabled, - roomDeleteOnUserLeaveEnabled: voiceAutoReconnect, + voiceAutoReconnect, reconnectStorageKey, onCallStart: () => { // In hybrid mode, clear all conversations when starting voice diff --git a/src/utils/index.ts b/src/utils/index.ts index 6ec8afc..23d1ffd 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ export * from './vapiChatClient'; +export * from './vapiCallStorage'; diff --git a/src/utils/vapiCallStorage.ts b/src/utils/vapiCallStorage.ts new file mode 100644 index 0000000..aff90e4 --- /dev/null +++ b/src/utils/vapiCallStorage.ts @@ -0,0 +1,73 @@ +export interface StoredCallData { + webCallUrl: string; + id?: string; + artifactPlan?: { + videoRecordingEnabled?: boolean; + }; + assistant?: { + voice?: { + provider?: string; + }; + }; + callOptions?: any; + timestamp: number; +} + +export const storeCallData = ( + reconnectStorageKey: string, + call: any, + callOptions?: any +) => { + const webCallUrl = + (call as any).webCallUrl || (call.transport as any)?.callUrl; + + if (webCallUrl) { + const webCallToStore = { + webCallUrl, + id: call.id, + artifactPlan: call.artifactPlan, + assistant: call.assistant, + callOptions, + timestamp: Date.now(), + }; + sessionStorage.setItem(reconnectStorageKey, JSON.stringify(webCallToStore)); + console.log('Stored call data for reconnection:', webCallToStore); + } else { + console.warn( + 'No webCallUrl found in call object, cannot store for reconnection' + ); + } +}; + +export const getStoredCallData = ( + reconnectStorageKey: string +): StoredCallData | null => { + try { + const stored = sessionStorage.getItem(reconnectStorageKey); + if (!stored) return null; + + return JSON.parse(stored); + } catch { + sessionStorage.removeItem(reconnectStorageKey); + return null; + } +}; + +export const clearStoredCall = (reconnectStorageKey: string) => { + sessionStorage.removeItem(reconnectStorageKey); +}; + +export const areCallOptionsEqual = (options1: any, options2: any): boolean => { + // Handle null/undefined cases + if (options1 === options2) return true; + if (!options1 || !options2) return false; + + try { + // Deep comparison using JSON serialization + // This works for most cases but may not handle functions, dates, etc. + return JSON.stringify(options1) === JSON.stringify(options2); + } catch { + // Fallback to reference equality if JSON.stringify fails + return options1 === options2; + } +}; diff --git a/src/widget/index.ts b/src/widget/index.ts index ebdb4f7..f6a6a26 100644 --- a/src/widget/index.ts +++ b/src/widget/index.ts @@ -168,6 +168,8 @@ function initializeWidgets() { // Voice Configuration 'voice-show-transcript': 'voiceShowTranscript', + 'voice-auto-reconnect': 'voiceAutoReconnect', + 'reconnect-storage-key': 'reconnectStorageKey', // Consent Configuration 'consent-required': 'consentRequired', From 0ac3cdfd6f5be8906be67cc044875c246b78c8f9 Mon Sep 17 00:00:00 2001 From: Steven Diaz Date: Sun, 19 Oct 2025 02:59:32 -0700 Subject: [PATCH 3/3] chore: make expanded session storage --- src/components/VapiWidget.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/VapiWidget.tsx b/src/components/VapiWidget.tsx index 995a71b..06cc9e9 100644 --- a/src/components/VapiWidget.tsx +++ b/src/components/VapiWidget.tsx @@ -85,7 +85,7 @@ const VapiWidget: React.FC = ({ // Initialize expanded state from localStorage const [isExpanded, setIsExpanded] = useState(() => { try { - const stored = localStorage.getItem(expandedStorageKey); + const stored = sessionStorage.getItem(expandedStorageKey); return stored === 'true'; } catch { return false; @@ -104,7 +104,7 @@ const VapiWidget: React.FC = ({ (expanded: boolean) => { setIsExpanded(expanded); try { - localStorage.setItem(expandedStorageKey, expanded.toString()); + sessionStorage.setItem(expandedStorageKey, expanded.toString()); } catch (error) { console.warn('Failed to save expanded state to localStorage:', error); }