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>