From 4093a8a48d40a4f97fdc58731ee24d2c7f749048 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 6 May 2026 13:45:10 +0700 Subject: [PATCH] feat(voice): unify call button flow and redesign call widget - Contact-panel call button now continues in the open conversation when the conversation's inbox is voice-capable, mirroring the header button. For non-voice channels it falls back to the inbox picker. - Twilio and WhatsApp paths share the same upstream decision; both pass a conversationId hint to the provider so the agent stays in the same thread. - Floating call widget redesigned: avatar + name + phone with duration on top, a clickable "View chat history" pill linking to the conversation, and labeled End / Join action buttons. Mute is preserved for active WhatsApp calls. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/javascript/dashboard/api/contacts.js | 3 +- .../Contacts/VoiceCallButton.vue | 32 ++- .../components/widgets/FloatingCallWidget.vue | 238 ++++++++++++------ .../i18n/locale/en/conversation.json | 3 +- .../conversation/contact/ContactInfo.vue | 13 +- .../store/modules/contacts/actions.js | 8 +- 6 files changed, 203 insertions(+), 94 deletions(-) diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index bae5623a7..dc79365e3 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -47,9 +47,10 @@ class ContactAPI extends ApiClient { return axios.get(`${this.url}/${contactId}/labels`); } - initiateCall(contactId, inboxId) { + initiateCall(contactId, inboxId, conversationId = null) { return axios.post(`${this.url}/${contactId}/call`, { inbox_id: inboxId, + conversation_id: conversationId, }); } diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index 5d13b1ee1..d338d2108 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -20,6 +20,9 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; const props = defineProps({ phone: { type: String, default: '' }, contactId: { type: [String, Number], required: true }, + // When set, the WhatsApp call continues in this conversation (matching the + // header button) instead of looking up the contact's most recent one. + conversationId: { type: [String, Number], default: null }, label: { type: String, default: '' }, icon: { type: [String, Object, Function], default: '' }, size: { type: String, default: 'sm' }, @@ -76,8 +79,12 @@ const findWhatsappConversationId = async inboxId => { return match?.id || null; }; -const startWhatsappCall = async inboxId => { - const conversationId = await findWhatsappConversationId(inboxId); +const startWhatsappCall = async (inboxId, conversationIdHint) => { + // WhatsApp /initiate is conversation-scoped, so we must hand it a + // conversation. Use the caller's hint when given (in-conversation flow); + // otherwise pick the most recent one in the inbox. + const conversationId = + conversationIdHint || (await findWhatsappConversationId(inboxId)); if (!conversationId) { useAlert(t('CONTACT_PANEL.CALL_FAILED')); return; @@ -108,13 +115,13 @@ const startWhatsappCall = async inboxId => { navigateToConversation(conversationId); }; -const startCall = async inboxId => { +const startCall = async (inboxId, conversationIdHint = null) => { if (isInitiatingCall.value) return; const inbox = (inboxesList.value || []).find(i => i.id === inboxId); if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) { try { - await startWhatsappCall(inboxId); + await startWhatsappCall(inboxId, conversationIdHint); } catch (error) { useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED')); } @@ -125,6 +132,7 @@ const startCall = async inboxId => { const response = await store.dispatch('contacts/initiateCall', { contactId: props.contactId, inboxId, + conversationId: conversationIdHint, }); const { call_sid: callSid, conversation_id: conversationId } = response; @@ -145,6 +153,22 @@ const startCall = async inboxId => { }; const onClick = async () => { + // In conversation context, only stay in this conversation if its inbox is + // itself voice-capable (works the same for Twilio and WhatsApp). For + // non-voice channels (email, web, …) fall back to the picker so the call + // goes out via a voice inbox. + if (props.conversationId) { + const conversation = store.getters.getConversationById( + props.conversationId + ); + const conversationInbox = (inboxesList.value || []).find( + i => i.id === conversation?.inbox_id + ); + if (conversationInbox && isVoiceCallEnabled(conversationInbox)) { + await startCall(conversationInbox.id, props.conversationId); + return; + } + } if (voiceInboxes.value.length > 1) { dialogRef.value?.open(); return; diff --git a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue index d711c7fb4..473087f85 100644 --- a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue @@ -1,14 +1,16 @@