Multi-agent / module-state correctness
- Hoist WhatsApp outbound init lock to module scope so header + contact-panel
buttons share one guard; add an active-session guard so a second click
returns { status: 'locked' } instead of cleanup()-ing the live call.
- isLocalWhatsappCall() filter on voice_call.outbound_connected and
voice_call.ended cable handlers — account-wide broadcasts no longer
feed foreign SDP into this tab's PeerConnection or stop its recorder.
- Permission-flow path (200 with no call id) now releases the
prepareOutboundOffer() mic + RTCPeerConnection instead of leaving the
mic indicator stuck on.
- Drop the intentionallyClosing guard around sendWhatsappTerminateBeacon
so a hangup-then-close race still terminates Meta's side (beacon
endpoint is idempotent).
- Distinguish locked init from permission_requested in callers to avoid
a false "call initiated" alert.
Provider routing
- joinCall outbound short-circuit now scoped to WhatsApp-like calls so
FloatingCallWidget's auto-join for outbound Twilio still works.
- isWhatsappLikeCall(callId-keyed) so calls seeded by message.updated /
refresh path (which lack provider metadata) route to the WhatsApp flow.
- syncConversationCallVisibility per-call filter via shouldShowCall, so
outbound calls aren't ripped from under the caller on assignee change.
- removeCallsForConversation tears down each active call via
teardownByProvider — WhatsApp gets cleanupWhatsappSession (closes pc,
stops recorder/mic) instead of a Twilio-only endClientCall.
- await reject before dismissing in rejectIncomingCall so a failing reject
keeps the call surfaced for retry.
Per-bubble overhead
- Split useCallSession into the root-mount hook + a lightweight
useCallActions for components like VoiceCall.vue that just need state +
actions without registering global window/Twilio listeners. Globals
attach once via a refcount, dismissed-call sids live at module scope so
the seed watcher can't re-add a locally dismissed ringing call.
Lookup correctness
- /contacts/:id/conversations accepts an optional inbox_id filter; the
WhatsApp call button passes inboxId so a contact's older WhatsApp
thread doesn't fall outside the BE's 20-row cap.
Twilio lifecycle / security
- Defer accepted_by_agent claim to the participant-join webhook so a
failed agent device init doesn't leave the call ringing-but-claimed
with no recovery path. mark_agent_joined still raises 409 if another
agent has already claimed.
- Verify X-Twilio-Signature on recording_status — the controller fetches
the recording with channel auth credentials, so an unsigned POST
could coerce credential-bearing requests to an attacker-controlled host.
Legacy data
- Migration to delete orphaned inboxes whose channel_type still says
'Channel::Voice' after the model was removed. The polymorphic
belongs_to :channel lookup on those rows otherwise crashes the inbox
serializer with `uninitialized constant Channel::Voice`.
A pile of related fixes around the dashboard's WhatsApp call flow:
- ConversationHeader / Contacts/VoiceCallButton: drop the immediate
setCallActive at initiate time. The call sits in incomingCalls
(callDirection: outbound) until the backend signals real pickup, so
the duration timer never starts pre-pickup. Phone button is disabled
whenever there is an active or incoming call.
- FloatingCallWidget:
* Loop a ringtone (bell.mp3) for inbound ringing only.
* Hide the green Join button for outbound — the agent has nothing to
"join", and clicking it routed through acceptIncomingCall →
prepareInboundAnswer → cleanup() and tore down the live outbound
session before the API 409 ("already accepted by another agent").
* Auto-join watcher skips whatsapp outbound (Twilio's joinConference
flow only).
- useCallSession:
* joinCall short-circuits for outbound calls — defense-in-depth so
no future surface can re-trigger the destroyed-session bug.
* endCall + outbound rejectIncomingCall pass call.callId to
endActiveCall, so terminate fires even if module state was wiped.
- useWhatsappCallSession:
* New recorderArmed flag, reset by cleanup. ontrack only calls
setupRecorder when armed.
* Inbound's acceptIncomingCall arms the recorder before the API
round-trip (agent click = pickup).
* armOutboundRecorder exported for the cable handler when ACCEPTED
arrives.
* endActiveCall accepts a callIdOverride to fall back when the
module's activeCallId was nulled by an earlier cleanup.
- actionCable:
* Split the cable contract: outbound_connected only applies the SDP
answer (tunnel-up signal); outbound_accepted (new) is the real
pickup signal — flips active and arms the recorder.
Branch review pass:
- useWhatsappCallSession: drop unused `error` ref and unused exports
(hasActiveWhatsappCall, isWhatsappCallMuted), fold sendWhatsappCallBeacon
into a private beaconTerminate helper since only sendWhatsappTerminateBeacon
consumed it externally. Trim WHAT-comments; keep WHY-comments
(browser-quirk explanations, race-condition notes, auth-cookie rationale).
- VoiceCall.vue bubble: fix `data || data` short-circuit that did nothing —
upstream key transform was the same on both branches; collapse to one read.
- calls/useCallSession/actionCable/FloatingCallWidget/VoiceCallButton:
drop comments that just describe what the next line already says.
Net diff: -63 lines across 7 files. No behavior change.
Inverted the previous decision: only the active (accepted) call gets
terminated on pagehide, since its WebRTC session dies with the page and
genuinely can't be rejoined. Ringing calls have no WebRTC state yet —
they're just a row on the backend and a state on Meta — so killing them
on refresh would needlessly hang up on the customer when the agent
intended to recover their UI, not reject the call.
After this, the flow on refresh during a ringing inbound is:
- beforeunload prompt fires (warning the agent)
- pagehide does nothing for ringing calls; active calls still terminate
- new page loads, conversation messages fetch
- seedCallsFromHydratedMessages picks up status='ringing' voice_call
messages and populates the calls store
- FloatingCallWidget reappears with Accept; agent clicks; acceptIncomingCall
fetches the SDP via /whatsapp_calls/:id and the call connects
Browser-direct WebRTC has no rejoin path, so any call that outlives the
agent's tab becomes permanently orphaned on Meta's side. The existing
pagehide beacon only covered the active (accepted) call — extend it to
every ringing inbound too, so a hard refresh during ringing releases the
call instead of leaving Meta to time it out.
- useWhatsappCallSession: factor sendWhatsappCallBeacon(callId) out of
the active-call wrapper so any caller can post terminate for any callId
without going through the activeCallId / intentionallyClosing guard.
- useCallSession.handlePageHide: after the active-call beacon, iterate
the calls store for any ringing WhatsApp call and beacon /terminate
for each. terminate is the right endpoint because the backend records
it as 'no_answer' when the call was still ringing — accurate UX shape.
- Remove the [debug] useAlert breadcrumbs that surfaced the accept-call
silent-fail (the !conversation guard). The actual fix from
6ed9500792 stays.
- Rubocop Style/IfUnlessModifier on the WhatsApp voice_enabled jbuilder
block — convert to modifier form.
Console logs aren't visible to the agent on staging — switch to
useAlert toasts so the debug breadcrumbs appear directly in the UI.
Will revert once root cause is identified.
Trace every checkpoint between the green Accept button click and the
backend POST so we can see exactly where the silent failure happens
(0 /accept requests landing on backend, no console errors, no network
requests reported by the user). Logs prefixed [CW Voice] for grep.
Will revert once the root cause is identified.
- FloatingCallWidget: drop !conversation early-return in handleJoinCall —
on a fresh account or after a hard refresh the inbound call's conversation
may not be in the Vuex store yet, which silently no-op'd Accept while
Reject worked (Reject doesn't read the conversation).
- useCallSession: seed the calls store from already-loaded voice_call
messages with status='ringing' on mount and whenever the conversation
list changes. Cable events (voice_call.incoming, message.created) are
one-shot and not replayed on reconnect, so without seeding a refresh
during a ringing call leaves the FloatingCallWidget empty.
- useCallSession: extend the beforeunload warning to fire while a call is
ringing too — losing a ringing inbound to refresh is the same UX hit as
losing an active one.
### 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>
Twilio voice now uses first-class `Call` records as the source of truth
for call state, instead of storing it on
`conversation.additional_attributes` and `conversation.identifier`. Each
call gets its own record, its own `voice_call` bubble matched by
`call_sid`, and its own conference name keyed off `Call.id`. Multiple
calls on the same conversation (for `lock_to_single_conversation`
inboxes) now work correctly, and the conversation card stays in sync
with the real latest message.
Fixes https://linear.app/chatwoot/issue/PLA-121/lock-to-single-thread
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>