Compare commits

...
Author SHA1 Message Date
Muhsin ab0370fbe1 refactor(voice): make the whole call bubble the join target and switch active calls
- The bubble itself is now the click target with an inline Join/Rejoin
  caption, matching the original design intent — discoverable on hover
  without a separate footer button.
- If the agent already has a different active call, end it first so the
  switch is clean (mirrors FloatingCallWidget.handleJoinCall).
2026-04-30 18:52:48 +04:00
Muhsin e415b0576a feat(voice): allow joining a call from the conversation timeline bubble
Agents who refresh or dismiss the floating widget can now click a Join /
Rejoin action on the voice_call bubble to (re)connect to a ringing or
in-progress call. The CTA respects the same visibility rules as the
floating widget — outbound calls only surface for the initiator, and
inbound assigned calls only surface for the assignee.

useCallSession gains a manageSessionState option so the bubble can reuse
joinCall/activeCall without registering duplicate Twilio listeners or
running its own duration timer.

Closes PLA-117
2026-04-30 18:46:09 +04:00
353089473e feat(voice): Assignment aware visibility and join conflict for inbound calls (#14333)
### Description
Inbound voice calls now route ownership cleanly: the call widget is
hidden from agents who aren't the conversation assignee, the first agent
to pick up becomes the assignee, and any later join attempt by another
agent is rejected with a clear "<agent> is already handling the call."
alert.

Closes
https://linear.app/chatwoot/issue/PLA-98/inbound-voice-calls-assignment-aware-visibility-auto-assignment-on

### How to test

1. As Agent A and Agent B, open the dashboard for the same voice inbox
in two browsers.
2. Place an inbound call to the inbox with the conversation
**unassigned** — both agents should see the call widget.
3. Have Agent A click **Join**. Agent A's widget transitions to the
active call; Agent B's widget disappears (conversation is now assigned
to Agent A).
4. While the call is in progress, attempt to join from a third agent
(e.g., via the bubble in the conversation timeline) — the join is
rejected with the toast `Agent A is already handling the call.`
5. Resolve the conversation, then place a second call to a conversation
that is already manually assigned to Agent A — only Agent A sees the
widget; nobody else does.
6. Race test: trigger two near-simultaneous join attempts (two agents
click Join within a few hundred ms of each other) — exactly one wins;
the other gets the conflict alert.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-04-30 18:38:10 +04:00
12 changed files with 353 additions and 58 deletions
@@ -1,7 +1,11 @@
<script setup>
import { computed } from 'vue';
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 { canCurrentUserJoinCall } from 'dashboard/helper/voice';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
@@ -11,11 +15,6 @@ const LABEL_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
@@ -30,13 +29,50 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { call } = useMessageContext();
const JOINABLE_STATUSES = [
VOICE_CALL_STATUS.RINGING,
VOICE_CALL_STATUS.IN_PROGRESS,
];
const { t } = useI18n();
const store = useStore();
const { call, conversationId, currentUserId, inboxId, sender } =
useMessageContext();
const { activeCall, hasActiveCall, isJoining, joinCall, endCall } =
useCallSession({
manageSessionState: false,
});
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 didCurrentUserAnswer = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
);
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
// Twilio's call-status webhook flipped the call to in-progress before the
// participant-join webhook claimed it).
const conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.(
conversationId?.value
);
return conversation?.meta?.assignee || null;
});
const displayAgentName = computed(() => {
if (call.value?.accepted_by_agent_name)
return call.value.accepted_by_agent_name;
if (acceptedByAgentId.value) {
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
if (agent?.available_name) return agent.available_name;
if (agent?.name) return agent.name;
}
return conversationAssignee.value?.name || null;
});
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
@@ -50,16 +86,28 @@ const labelKey = computed(() => {
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtextKey = computed(() => {
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) {
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
if (displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: displayAgentName.value,
});
}
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
});
const iconName = computed(() => {
@@ -68,11 +116,83 @@ const iconName = computed(() => {
});
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
const callSid = computed(() => call.value?.provider_call_id);
const isAlreadyOnThisCall = computed(
() => !!callSid.value && activeCall.value?.callSid === callSid.value
);
const resolvedInboxId = computed(() => {
if (inboxId?.value) return inboxId.value;
const conversation = store.getters.getConversationById?.(
conversationId?.value
);
return conversation?.inbox_id || null;
});
const conversationForVisibility = computed(() =>
store.getters.getConversationById?.(conversationId?.value)
);
const isVisibleToCurrentUser = computed(() =>
canCurrentUserJoinCall({
call: call.value,
conversation: conversationForVisibility.value,
senderId: sender?.value?.id,
currentUserId: currentUserId?.value,
})
);
const canJoin = computed(
() =>
JOINABLE_STATUSES.includes(status.value) &&
!!callSid.value &&
!!resolvedInboxId.value &&
!!conversationId?.value &&
!isAlreadyOnThisCall.value &&
isVisibleToCurrentUser.value
);
const joinLabel = computed(() =>
status.value === VOICE_CALL_STATUS.IN_PROGRESS && didCurrentUserAnswer.value
? t('CONVERSATION.VOICE_CALL.REJOIN_CALL')
: t('CONVERSATION.VOICE_CALL.JOIN_CALL')
);
const activeConversation = computed(() => {
const id = activeCall.value?.conversationId;
return id ? store.getters.getConversationById?.(id) : null;
});
const handleJoinClick = async () => {
if (!canJoin.value || isJoining.value) return;
if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) {
const activeInboxId = activeConversation.value?.inbox_id;
if (activeCall.value?.conversationId && activeInboxId) {
await endCall({
conversationId: activeCall.value.conversationId,
inboxId: activeInboxId,
callSid: activeCall.value.callSid,
});
}
}
await joinCall({
conversationId: conversationId.value,
inboxId: resolvedInboxId.value,
callSid: callSid.value,
});
};
</script>
<template>
<BaseBubble class="p-0 border-none" hide-meta>
<div class="flex overflow-hidden flex-col w-full max-w-xs">
<button
type="button"
:disabled="!canJoin || isJoining"
data-test-id="voice-call-join"
class="flex overflow-hidden flex-col w-full max-w-xs text-left bg-transparent border-none"
:class="{
'cursor-pointer transition-colors hover:bg-n-alpha-1 active:bg-n-alpha-2':
canJoin,
'cursor-default': !canJoin,
}"
@click="handleJoinClick"
>
<div class="flex gap-3 items-center p-3 w-full">
<div
class="flex justify-center items-center rounded-full size-10 shrink-0"
@@ -93,10 +213,13 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
{{ subtext }}
</span>
<span v-if="canJoin" class="mt-1 text-xs font-medium text-n-teal-10">
{{ joinLabel }}
</span>
</div>
</div>
</div>
</button>
</BaseBubble>
</template>
@@ -1,11 +1,18 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import Timer from 'dashboard/helper/Timer';
export function useCallSession() {
// `manageSessionState: false` lets sub-consumers (like the timeline VoiceCall
// bubble) reuse joinCall/activeCall without registering duplicate Twilio
// listeners or running their own duration timer — only the floating widget
// owns the session lifecycle.
export function useCallSession({ manageSessionState = true } = {}) {
const callsStore = useCallsStore();
const { t } = useI18n();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
@@ -16,36 +23,38 @@ export function useCallSession() {
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
watch(
hasActiveCall,
active => {
if (active) {
durationTimer.start();
} else {
durationTimer.stop();
callDuration.value = 0;
}
},
{ immediate: true }
);
onMounted(() => {
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
if (manageSessionState) {
watch(
hasActiveCall,
active => {
if (active) {
durationTimer.start();
} else {
durationTimer.stop();
callDuration.value = 0;
}
},
{ immediate: true }
);
});
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
onMounted(() => {
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
}
const endCall = async ({ conversationId, inboxId, callSid }) => {
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
if (manageSessionState) durationTimer.stop();
callsStore.clearActiveCall();
};
@@ -70,10 +79,15 @@ export function useCallSession() {
});
callsStore.setCallActive(callSid);
durationTimer.start();
if (manageSessionState) durationTimer.start();
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
if (error?.response?.status === 409) {
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
}
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
return null;
+81 -8
View File
@@ -22,6 +22,47 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
return callDirection === 'outbound' && senderId !== currentUserId;
};
const extractAssigneeId = conversation => {
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
};
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
if (currentUserId == null) return false;
return !!assigneeId && assigneeId !== currentUserId;
};
const shouldShowCall = ({
callDirection,
senderId,
assigneeId,
currentUserId,
}) => {
if (shouldSkipCall(callDirection, senderId, currentUserId)) return false;
// Outbound calls are scoped to the initiator via shouldSkipCall; the
// conversation may be auto-assigned to a different agent on creation, so
// skip the assignee filter for outbound to avoid hiding the caller's own widget.
if (callDirection === 'outbound') return true;
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
};
// Whether the current user is allowed to take over / join an existing call.
// Mirrors the floating-widget visibility rules so the bubble's join action
// stays in sync.
export const canCurrentUserJoinCall = ({
call,
conversation,
senderId,
currentUserId,
}) => {
if (!call) return false;
return shouldShowCall({
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
senderId,
assigneeId: extractAssigneeId(conversation),
currentUserId,
});
};
function extractCallData(message) {
const call = message?.call || {};
return {
@@ -29,6 +70,7 @@ function extractCallData(message) {
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
assigneeId: extractAssigneeId(message?.conversation),
senderId: message?.sender?.id,
};
}
@@ -36,10 +78,19 @@ function extractCallData(message) {
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, callDirection, conversationId, senderId } =
const { callSid, callDirection, conversationId, assigneeId, senderId } =
extractCallData(message);
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
return;
}
const callsStore = useCallsStore();
callsStore.addCall({
@@ -53,8 +104,14 @@ export function handleVoiceCallCreated(message, currentUserId) {
export function handleVoiceCallUpdated(commit, message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, status, callDirection, conversationId, senderId } =
extractCallData(message);
const {
callSid,
status,
callDirection,
conversationId,
assigneeId,
senderId,
} = extractCallData(message);
const callsStore = useCallsStore();
@@ -66,11 +123,19 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
callSid,
});
const isNewCall =
status === 'ringing' &&
!shouldSkipCall(callDirection, senderId, currentUserId);
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
callsStore.removeCall(callSid);
return;
}
if (isNewCall) {
if (status === 'ringing') {
callsStore.addCall({
callSid,
conversationId,
@@ -79,3 +144,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
});
}
}
export function syncConversationCallVisibility(conversation, currentUserId) {
const assigneeId = extractAssigneeId(conversation);
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
const callsStore = useCallsStore();
callsStore.removeCallsForConversation(conversation.id);
}
@@ -83,7 +83,10 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered"
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call",
"REJOIN_CALL": "Rejoin call"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -16,6 +16,7 @@ import * as Sentry from '@sentry/vue';
import {
handleVoiceCallCreated,
handleVoiceCallUpdated,
syncConversationCallVisibility,
} from 'dashboard/helper/voice';
export const hasMessageFailedWithExternalError = pendingMessage => {
@@ -393,19 +394,18 @@ const actions = {
}
},
updateConversation({ commit, dispatch }, conversation) {
const {
meta: { sender },
} = conversation;
updateConversation({ commit, dispatch, rootGetters }, conversation) {
const sender = conversation.meta?.sender;
commit(types.UPDATE_CONVERSATION, conversation);
syncConversationCallVisibility(conversation, rootGetters?.getCurrentUserID);
dispatch('conversationLabels/setConversationLabel', {
id: conversation.id,
data: conversation.labels,
});
dispatch('contacts/setContact', sender);
if (sender) dispatch('contacts/setContact', sender);
},
updateConversationLastActivity(
+14
View File
@@ -55,5 +55,19 @@ export const useCallsStore = defineStore('calls', {
dismissCall(callSid) {
this.calls = this.calls.filter(call => call.callSid !== callSid);
},
removeCallsForConversation(conversationId) {
const callsToRemove = this.calls.filter(
call => call.conversationId === conversationId
);
if (callsToRemove.some(call => call.isActive)) {
TwilioVoiceClient.endClientCall();
}
this.calls = this.calls.filter(
call => call.conversationId !== conversationId
);
},
},
});
+2
View File
@@ -64,6 +64,8 @@ en:
email_already_exists: 'You have already signed up for an account with %{email}'
invalid_params: 'Invalid, please check the signup paramters and try again'
failed: Signup failed
voice:
call_already_accepted: '%{agent_name} is already handling the call.'
assignment_policy:
not_found: Assignment policy not found
attachments:
@@ -1,5 +1,6 @@
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
before_action :set_voice_inbox_for_conference
rescue_from CustomExceptions::CallAlreadyAccepted, with: :render_call_already_accepted
def token
render json: Voice::Provider::Twilio::TokenService.new(
@@ -54,4 +55,8 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
authorize conversation, :show?
conversation
end
def render_call_already_accepted(error)
render json: { error: error.message }, status: :conflict
end
end
+1
View File
@@ -92,6 +92,7 @@ class Call < ApplicationRecord
duration_seconds: duration_seconds,
conference_sid: conference_sid,
accepted_by_agent_id: accepted_by_agent_id,
accepted_by_agent_name: accepted_by_agent&.available_name,
started_at: started_at&.to_i,
ended_at: ended_at,
from_number: from_number,
@@ -31,10 +31,32 @@ class Voice::Conference::Manager
def join_agent!
user_id = extract_user_id
call.update!(accepted_by_agent_id: user_id) if user_id
claim_for_user!(user_id) if user_id
status_manager.process_status_update('in_progress', timestamp: now)
end
# First-join wins; later joins by other agents are silently ignored so the
# webhook doesn't stomp the original assignee. User-facing rejection happens
# at the API layer.
def claim_for_user!(user_id)
claimed = false
call.with_lock do
next if call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user_id
call.update!(accepted_by_agent_id: user_id) if call.accepted_by_agent_id != user_id
claimed = true
end
auto_assign_conversation!(user_id) if claimed
end
def auto_assign_conversation!(user_id)
conversation = call.conversation
return if conversation.assignee_id.present?
Conversations::AssignmentService.new(conversation: conversation, assignee_id: user_id).perform
end
# Parses agent user_id from participant_label. Only returns an id when the
# label's embedded account id matches the call's account — protects against
# a spoofed/cross-account label attaching a foreign user to the call.
@@ -9,7 +9,8 @@ class Voice::Provider::Twilio::ConferenceService
end
def mark_agent_joined(user:)
call.update!(accepted_by_agent: user)
claim_call!(user)
assign_conversation!(user)
end
def end_conference
@@ -21,4 +22,30 @@ class Voice::Provider::Twilio::ConferenceService
.list(friendly_name: call.conference_sid, status: 'in-progress')
.each { |conf| client.conferences(conf.sid).update(status: 'completed') }
end
private
def claim_call!(user)
call.with_lock do
raise_already_accepted!(call.accepted_by_agent) if claimed_by_other_agent?(user)
call.update!(accepted_by_agent: user) if call.accepted_by_agent_id != user.id
end
end
def claimed_by_other_agent?(user)
call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user.id
end
def raise_already_accepted!(agent)
raise CustomExceptions::CallAlreadyAccepted.new(agent_name: agent&.available_name || agent&.name)
end
# Existing assignments win — manual reassignment and pre-call assignment
# (e.g., lock_to_single_conversation) shouldn't be stomped on pickup.
def assign_conversation!(user)
conversation = call.conversation
return if conversation.assignee_id.present?
Conversations::AssignmentService.new(conversation: conversation, assignee_id: user.id).perform
end
end
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class CustomExceptions::CallAlreadyAccepted < CustomExceptions::Base
def message
I18n.t('errors.voice.call_already_accepted', agent_name: @data[:agent_name])
end
def http_status
409
end
end