Skip to content

Commit 34c87c7

Browse files
authored
Merge pull request #21 from VapiAI/ash/end-chat
VAP-999 Add end chat button and logic
2 parents b43dfdf + efea48f commit 34c87c7

File tree

6 files changed

+158
-37
lines changed

6 files changed

+158
-37
lines changed

src/components/VapiWidget.tsx

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
5555
emptyHybridMessage = 'Use voice or text to communicate', // deprecated
5656
// Chat configuration
5757
chatFirstMessage,
58+
chatEndMessage,
5859
firstChatMessage, // deprecated
5960
chatPlaceholder,
6061
// Voice configuration
@@ -79,6 +80,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
7980
const [isExpanded, setIsExpanded] = useState(false);
8081
const [hasConsent, setHasConsent] = useState(false);
8182
const [chatInput, setChatInput] = useState('');
83+
const [showEndScreen, setShowEndScreen] = useState(false);
8284

8385
const conversationEndRef = useRef<HTMLDivElement>(null);
8486
const inputRef = useRef<HTMLInputElement>(null);
@@ -109,6 +111,8 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
109111
const effectiveOnVoiceStart = onVoiceStart ?? onCallStart;
110112
const effectiveOnVoiceEnd = onVoiceEnd ?? onCallEnd;
111113
const effectiveChatPlaceholder = chatPlaceholder ?? 'Type your message...';
114+
const effectiveChatEndMessage =
115+
chatEndMessage ?? 'This chat has ended. Thank you.';
112116

113117
const vapi = useVapiWidget({
114118
mode,
@@ -253,6 +257,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
253257

254258
const handleReset = () => {
255259
vapi.clearConversation();
260+
setShowEndScreen(false);
256261

257262
if (vapi.voice.isCallActive) {
258263
vapi.voice.endCall();
@@ -267,6 +272,34 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
267272
}
268273
};
269274

275+
const handleChatComplete = async () => {
276+
try {
277+
await vapi.chat.sendMessage('Ending chat...', true);
278+
setShowEndScreen(true);
279+
} finally {
280+
setChatInput('');
281+
}
282+
};
283+
284+
const handleStartNewChat = () => {
285+
vapi.clearConversation();
286+
setShowEndScreen(false);
287+
if (mode === 'chat' || mode === 'hybrid') {
288+
setTimeout(() => {
289+
inputRef.current?.focus();
290+
}, 100);
291+
}
292+
};
293+
294+
const handleCloseWidget = () => {
295+
if (showEndScreen) {
296+
vapi.clearConversation();
297+
setShowEndScreen(false);
298+
setChatInput('');
299+
}
300+
setIsExpanded(false);
301+
};
302+
270303
const handleFloatingButtonClick = () => {
271304
setIsExpanded(true);
272305
};
@@ -316,6 +349,38 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
316349
};
317350

318351
const renderConversationArea = () => {
352+
if (showEndScreen) {
353+
return (
354+
<div
355+
className="flex flex-col items-center justify-center text-center gap-4"
356+
style={{ width: '100%' }}
357+
>
358+
<div
359+
className={`text-base ${styles.theme === 'dark' ? 'text-gray-200' : 'text-gray-800'}`}
360+
>
361+
{effectiveChatEndMessage}
362+
</div>
363+
<div className="flex items-center gap-3">
364+
<button
365+
onClick={handleStartNewChat}
366+
className="px-3 py-1.5 rounded-md"
367+
style={{
368+
backgroundColor: colors.ctaButtonColor,
369+
color: colors.ctaButtonTextColor,
370+
}}
371+
>
372+
Start new chat
373+
</button>
374+
<button
375+
onClick={handleCloseWidget}
376+
className={`px-3 py-1.5 rounded-md ${styles.theme === 'dark' ? 'bg-gray-800 text-gray-100' : 'bg-gray-100 text-gray-800'}`}
377+
>
378+
Close
379+
</button>
380+
</div>
381+
</div>
382+
);
383+
}
319384
// Chat mode: always show conversation messages
320385
if (mode === 'chat') {
321386
return renderConversationMessages();
@@ -380,6 +445,9 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
380445
};
381446

382447
const renderControls = () => {
448+
if (showEndScreen) {
449+
return null;
450+
}
383451
if (mode === 'voice') {
384452
return (
385453
<VoiceControls
@@ -460,8 +528,10 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
460528
isTyping={vapi.chat.isTyping}
461529
hasActiveConversation={vapi.conversation.length > 0}
462530
mainLabel={effectiveTextWidgetTitle}
463-
onClose={() => setIsExpanded(false)}
531+
onClose={handleCloseWidget}
464532
onReset={handleReset}
533+
onChatComplete={handleChatComplete}
534+
showEndChatButton={!showEndScreen}
465535
colors={colors}
466536
styles={styles}
467537
/>

src/components/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export interface VapiWidgetProps {
4444
// Chat Configuration
4545
chatFirstMessage?: string;
4646
chatPlaceholder?: string;
47+
chatEndMessage?: string;
4748

4849
// Voice Configuration
4950
voiceShowTranscript?: boolean;
@@ -153,6 +154,8 @@ export interface WidgetHeaderProps {
153154
mainLabel: string;
154155
onClose: () => void;
155156
onReset: () => void;
157+
onChatComplete: () => void;
158+
showEndChatButton?: boolean;
156159
colors: ColorScheme;
157160
styles: StyleConfig;
158161
}

src/components/widget/WidgetHeader.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ const WidgetHeader: React.FC<WidgetHeaderProps> = ({
1313
mainLabel,
1414
onClose,
1515
onReset,
16+
onChatComplete,
17+
showEndChatButton,
1618
colors,
1719
styles,
1820
}) => {
@@ -68,6 +70,15 @@ const WidgetHeader: React.FC<WidgetHeaderProps> = ({
6870
</div>
6971
</div>
7072
<div className="flex items-center space-x-2">
73+
{showEndChatButton !== false && (
74+
<button
75+
onClick={onChatComplete}
76+
className={`text-red-600 text-sm font-medium px-2 py-1 border border-transparent hover:border-red-600 rounded-md transition-colors`}
77+
title="End Chat"
78+
>
79+
End Chat
80+
</button>
81+
)}
7182
<button
7283
onClick={onReset}
7384
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all}`}

src/hooks/useVapiChat.ts

Lines changed: 70 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export interface VapiChatState {
2121
}
2222

2323
export interface VapiChatHandlers {
24-
sendMessage: (text: string) => Promise<void>;
24+
sendMessage: (text: string, sessionEnd?: boolean) => Promise<void>;
2525
clearMessages: () => void;
2626
}
2727

@@ -195,6 +195,7 @@ export const useVapiChat = ({
195195
const abortFnRef = useRef<(() => void) | null>(null);
196196
const currentAssistantMessageRef = useRef<string>(''); // Accumulates assistant message content
197197
const assistantMessageIndexRef = useRef<number | null>(null); // Tracks array position
198+
const isEndingSessionRef = useRef<boolean>(false);
198199

199200
useEffect(() => {
200201
if (publicKey && enabled) {
@@ -223,8 +224,15 @@ export const useVapiChat = ({
223224
);
224225

225226
const sendMessage = useCallback(
226-
async (text: string) => {
227+
async (text: string, sessionEnd: boolean = false) => {
227228
try {
229+
if (sessionEnd) {
230+
if (isEndingSessionRef.current) {
231+
return; // IMP: Prevent duplicate end-session sends
232+
}
233+
isEndingSessionRef.current = true;
234+
}
235+
228236
validateChatInput(
229237
text,
230238
enabled,
@@ -235,15 +243,30 @@ export const useVapiChat = ({
235243

236244
setIsLoading(true);
237245

238-
const userMessage = createUserMessage(text);
239-
addMessage(userMessage);
246+
if (!sessionEnd && text.trim()) {
247+
const userMessage = createUserMessage(text);
248+
addMessage(userMessage);
249+
}
240250

241-
resetAssistantMessageTracking(
242-
currentAssistantMessageRef,
243-
assistantMessageIndexRef
244-
);
245-
preallocateAssistantMessage(assistantMessageIndexRef, setMessages);
246-
setIsTyping(true);
251+
if (!sessionEnd) {
252+
resetAssistantMessageTracking(
253+
currentAssistantMessageRef,
254+
assistantMessageIndexRef
255+
);
256+
preallocateAssistantMessage(assistantMessageIndexRef, setMessages);
257+
setIsTyping(true);
258+
} else {
259+
const endingText = text.trim() || 'Ending chat...';
260+
setMessages((prev) => [
261+
...prev,
262+
{
263+
role: 'assistant',
264+
content: endingText,
265+
timestamp: new Date(),
266+
},
267+
]);
268+
setIsTyping(true);
269+
}
247270

248271
const onStreamError = (error: Error) =>
249272
handleStreamError(
@@ -263,33 +286,42 @@ export const useVapiChat = ({
263286
setMessages
264287
);
265288

266-
const onComplete = () =>
267-
handleStreamComplete(
268-
setIsTyping,
269-
assistantMessageIndexRef,
270-
currentAssistantMessageRef,
271-
onMessage
272-
);
289+
const onComplete = sessionEnd
290+
? () => {
291+
setIsTyping(false);
292+
assistantMessageIndexRef.current = null;
293+
}
294+
: () =>
295+
handleStreamComplete(
296+
setIsTyping,
297+
assistantMessageIndexRef,
298+
currentAssistantMessageRef,
299+
onMessage
300+
);
273301

274302
let input: string | Array<{ role: string; content: string }>;
275-
if (
276-
firstChatMessage &&
277-
firstChatMessage.trim() !== '' &&
278-
messages.length === 1 &&
279-
messages[0].role === 'assistant'
280-
) {
281-
input = [
282-
{
283-
role: 'assistant',
284-
content: firstChatMessage,
285-
},
286-
{
287-
role: 'user',
288-
content: text.trim(),
289-
},
290-
];
291-
} else {
303+
if (sessionEnd) {
292304
input = text.trim();
305+
} else {
306+
if (
307+
firstChatMessage &&
308+
firstChatMessage.trim() !== '' &&
309+
messages.length === 1 &&
310+
messages[0].role === 'assistant'
311+
) {
312+
input = [
313+
{
314+
role: 'assistant',
315+
content: firstChatMessage,
316+
},
317+
{
318+
role: 'user',
319+
content: text.trim(),
320+
},
321+
];
322+
} else {
323+
input = text.trim();
324+
}
293325
}
294326

295327
const abort = await clientRef.current!.streamChat(
@@ -299,6 +331,7 @@ export const useVapiChat = ({
299331
assistantOverrides,
300332
sessionId,
301333
stream: true,
334+
sessionEnd,
302335
},
303336
onChunk,
304337
onStreamError,
@@ -314,6 +347,9 @@ export const useVapiChat = ({
314347
throw error;
315348
} finally {
316349
setIsLoading(false);
350+
if (sessionEnd) {
351+
isEndingSessionRef.current = false;
352+
}
317353
}
318354
},
319355
[

src/hooks/useVapiWidget.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export const useVapiWidget = ({
119119
}, []);
120120

121121
const sendMessage = useCallback(
122-
async (text: string) => {
122+
async (text: string, sessionEnd: boolean = false) => {
123123
// In hybrid mode, switch to chat and clear all conversations only if switching from voice
124124
if (mode === 'hybrid') {
125125
if (voice.isCallActive) {
@@ -132,7 +132,7 @@ export const useVapiWidget = ({
132132
}
133133
setActiveMode('chat');
134134
}
135-
await chat.sendMessage(text);
135+
await chat.sendMessage(text, sessionEnd);
136136
},
137137
[mode, chat, voice, activeMode]
138138
);

src/utils/vapiChatClient.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface VapiChatMessage {
1212
assistantOverrides?: AssistantOverrides;
1313
sessionId?: string;
1414
stream?: boolean;
15+
sessionEnd?: boolean;
1516
}
1617

1718
export interface VapiChatStreamChunk {

0 commit comments

Comments
 (0)