From ea8761099991507593dc8ec5e497379645c22900 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 4 May 2026 12:24:31 +0400 Subject: [PATCH] feat(voice): Join active call from the conversation bubble (#14343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents can now click **Join call** directly on the incoming call bubble in the conversation timeline. If they refresh the page or miss the floating widget while a call is still ringing, the bubble becomes the recovery affordance — one click joins the conference, no need to wait for the next event. The button only appears when the call is still ringing, no other agent has claimed it, and the conversation is unassigned or assigned to the current agent (mirroring the floating widget's eligibility rules). It disappears as soon as anyone joins the call or it ends. Fixes https://linear.app/chatwoot/issue/PLA-117/ability-to-join-the-call-by-clicking-on-call-bubble-in-a-conversation ## How to test 1. Set up a Twilio voice inbox and trigger an inbound call to it. 2. As an agent who is eligible to answer (unassigned conversation, or assigned to you), open the conversation **without answering from the floating widget**. The bubble should show a teal **Join call** link under "Not answered yet". 3. Refresh the page mid-ring — the link should still be there. 4. Click **Join call** — you should be connected to the conference, the bubble should flip to "Call in progress / You answered", and the link should disappear. 5. As a second agent who is **not** eligible (conversation assigned to someone else), open the same conversation — the link should not appear. 6. Wait for the call to end — the bubble should show "Call ended" with no Join link. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../message/bubbles/VoiceCall.vue | 58 +++++++++++++++++-- .../i18n/locale/en/conversation.json | 3 +- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index f2383551c..229fbec03 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'; import { useStore } from 'vuex'; import { useMessageContext } from '../provider.js'; import { VOICE_CALL_STATUS } from '../constants'; +import { useCallSession } from 'dashboard/composables/useCallSession'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import BaseBubble from 'next/message/bubbles/Base.vue'; @@ -29,14 +30,16 @@ const BG_COLOR_MAP = { const { t } = useI18n(); const store = useStore(); -const { call, conversationId, currentUserId } = useMessageContext(); +const { call, conversationId, currentUserId, inboxId } = useMessageContext(); +const { joinCall, endCall, activeCall, hasActiveCall, isJoining } = + useCallSession(); const status = computed(() => call.value?.status); const isOutbound = computed(() => call.value?.direction === 'outgoing'); const isFailed = computed(() => [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value) ); -const acceptedByAgentId = computed(() => call.value?.accepted_by_agent_id); +const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId); const didCurrentUserAnswer = computed( () => !!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value @@ -52,8 +55,7 @@ const conversationAssignee = computed(() => { return conversation?.meta?.assignee || null; }); const displayAgentName = computed(() => { - if (call.value?.accepted_by_agent_name) - return call.value.accepted_by_agent_name; + if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName; if (acceptedByAgentId.value) { const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value); if (agent?.available_name) return agent.available_name; @@ -104,6 +106,45 @@ const iconName = computed(() => { }); const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9'); + +const callSid = computed(() => call.value?.providerCallId); + +// Show "Join call" when the call is still ringing, no agent has claimed it, +// and the conversation is unassigned or assigned to the current user. Mirrors +// the eligibility used by FloatingCallWidget so the bubble can act as a +// recovery affordance after a refresh or missed widget. +const canJoinCall = computed(() => { + if (status.value !== VOICE_CALL_STATUS.RINGING) return false; + if (isOutbound.value) return false; + if (acceptedByAgentId.value) return false; + if (!callSid.value || !inboxId.value || !conversationId.value) return false; + // Suppress the button once this call is the local active session — the + // message status webhook may lag behind, so we can't rely on `status` alone + // to hide it after a successful join from this client. + if (hasActiveCall.value && activeCall.value?.callSid === callSid.value) + return false; + const assignee = conversationAssignee.value; + if (assignee?.id && assignee.id !== currentUserId.value) return false; + return true; +}); + +const handleJoinCall = async () => { + if (!canJoinCall.value || isJoining.value) return; + + if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) { + await endCall({ + conversationId: activeCall.value.conversationId, + inboxId: activeCall.value.inboxId, + callSid: activeCall.value.callSid, + }); + } + + await joinCall({ + conversationId: conversationId.value, + inboxId: inboxId.value, + callSid: callSid.value, + }); +};