# Pull Request Template
## Description
This PR fixes the non-functional resend confirmation feature on the V3
login page where clicking "Resend confirmation" did nothing. The issue
was caused by the V3 store not having the `resendConfirmation` action
that the login page was trying to dispatch.
**Key improvements:**
- Fixed V3 store integration by importing `resendConfirmation` directly
from auth API
- Added comprehensive UX improvements with loading states and 60-second
cooldown timer
- Implemented environment-aware debug logging for development
- Added proper error handling and user feedback
- Enhanced backend test coverage
**Context:** Users with unconfirmed accounts were unable to resend
confirmation emails from the login page, creating a poor user experience
and potential support burden.
Fixes#3157
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
**Backend Testing:**
- All existing resend_confirmation tests passing (7/7)
- Added comprehensive new test suite in
`spec/requests/api/v1/resend_confirmation_spec.rb`
- API endpoint returns 200 OK responses in ~0.39 seconds
- Email delivery confirmed via SMTP with test user `info@airbonar.com`
**Frontend Testing:**
- All frontend tests passing
- ESLint compliant code with automatic corrections applied
- Manual testing of login page functionality:
- 60-second cooldown timer with countdown display
- Error handling with user-friendly messages
- Development logging works (console output in dev mode only)
**Test Configuration:**
- Ruby/Rails backend with RSpec test suite
- Vue.js frontend with Jest/testing-library
- Development environment with Gmail SMTP configured
- Test user: unconfirmed account `info@airbonar.com`
**Reproduction Steps:**
1. Navigate to login page with unconfirmed account
2. Click "Resend confirmation link"
3. Observe loading state, API call, and success feedback
4. Verify 60-second cooldown prevents spam
5. Check email delivery.
## Checklist:
- [ ] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
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`.
- 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) <noreply@anthropic.com>
Matches the existing voice-configuration tab gating so admins on accounts
without the premium channel_voice entitlement don't see the WhatsApp Calling
configuration tab.
The BE now returns 422 (instead of 200) when the contact hasn't opted in
yet — see companion fix in WhatsappCallsController#render_permission_request.
Detect that shape in the catch block and surface it as a normal response so
the existing banner branch in ConversationHeader.vue keeps working without
falling into the error toast path.
# Pull Request Template
## Description
Fixes
https://linear.app/chatwoot/issue/CW-6903/signature-delimiter-renders-as-h2-when-using-enter-line-before
**1**. Fixes an issue where the signature delimiter `--` gets parsed as
an H2 when using **Enter** (new paragraph) before or after it, causing
it to render as a bold `\` in the message bubble.
* Ensures `--` renders as plain text
* Aligns renderer with parser behavior (both disable `lheading`)
* Prevents stray `\` from appearing as heading text
**2**. Also fixes a related editor issue where toggling signature
**off** leaves behind a stray `\` or `-- \`.
* Strips blank paragraph markers (`\`) and dangling hard breaks
(`\<newline>`) from ProseMirror serializer
* Applied in both `appendSignature` and `removeSignature`
* Replaces `trimEnd()` with shared helpers (`trimTrailingBlanks` /
`stripTrailingBlankMarkers`)
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
#### Screenshots
**Before**
<img width="194" height="204" alt="image"
src="https://github.com/user-attachments/assets/b286ab50-7f89-4910-a552-1568902b93b3"
/>
**After**
<img width="194" height="220" alt="image"
src="https://github.com/user-attachments/assets/658cd543-bce2-46e2-a319-35e5374f1aef"
/>
**Editor**
https://linear.app/chatwoot/issue/CW-6903/signature-delimiter-renders-as-in-h2-when-using-enter-line#comment-5814b882
### Steps
#### Editor
1. Enable agent signature
2. Add and remove new lines around the signature using Enter/shift enter
3. Toggle signature off
4. Notice stray `\` or `-- \` remains
#### Bubble
1. Enable agent signature
2. Send a message using Enter between lines
3. Verify `--` renders correctly (no H2, no bold `\`)
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
# Pull Request Template
## Description
This PR fixes multiple issues related to regex patterns and validation
for custom attributes.
1. Fixed regex patterns being double-escaped when saving from Add and
Edit flows
2. Fixed regex validation not being enforced in the widget pre-chat form
3. Minor UI improvements in the Add/Edit custom attribute dialog
Fixes
[CW-6625](https://linear.app/chatwoot/issue/CW-6625/bug-report-custom-attribute-regex-validation-not-working-in-ui),
https://github.com/chatwoot/chatwoot/issues/13771
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
**Loom video**
**Before**
https://www.loom.com/share/14f1983a8bc84f9fabc3663afd83cd50
**After**
https://www.loom.com/share/867c0484741140c1944fcbd43914c9c0
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
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.
Outbound IN_PROGRESS no longer claims "They answered" — the backend can
flip in_progress before the contact actually picks up (Twilio agent-join
flow), so the label "Call in progress" stands on its own. Outbound
no_answer now reads "Contact didn't pick up" instead of the inbound
"Missed call / No answer" framing, and outbound ringing shows
"Calling…" instead of "Not answered yet".
Surface inbox.provider_config['call_permission_request_body'] in the
WhatsappCallingPage settings as an editable textarea — saving submits the
new key alongside calling_enabled. Blank trims to null so the i18n default
keeps applying. Backend reads the override before the i18n fallback.
When an inbound voice call ends, the conversation bubble now (1) renders
an inline audio player as soon as Twilio finishes the recording and (2)
shows the call duration alongside "Call ended" so the agent gets the
at-a-glance summary without opening the recording.
Fixes
https://linear.app/chatwoot/issue/PLA-118/feat-recordings-on-calls-should-be-attached-on-the-conversation
and
https://linear.app/chatwoot/issue/PLA-119/duration-of-the-call-is-not-visible-on-the-chat-bubble
## How to test
1. Set up a Twilio voice inbox and trigger an inbound call.
2. Answer the call from an agent, talk for a few seconds, then hang up.
3. As soon as the call ends, the bubble should read **"Call ended —
0:NN"** (where NN is the call duration in seconds).
4. Wait a few seconds for Twilio to finish processing the recording
(usually <30s after hangup).
5. The same bubble should now show an inline audio player below the
duration. Press play; the recording should be audible.
6. Refresh the page — both the duration and the player should still be
there.
7. End a second call on the same conversation — its bubble should get
its own duration + player, independent of the first.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
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>
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
The previous beacon used navigator.sendBeacon, which can't set custom
request headers — and Chatwoot's API requires devise-token-auth headers
(access-token / client / uid) on every call. So the beacon hit the
terminate endpoint, got 401, and Meta kept the call alive until the
carrier-side timeout (~60s) — exactly the bug reported: refresh during
an in-progress call leaves it ringing on Meta's side.
Switch to fetch with keepalive:true so the request survives page unload
AND lets us rehydrate the auth headers from the cw_d_session_info cookie
the dashboard sets at login. credentials:'same-origin' keeps any session
cookies along for the ride. Empty body is well under the 64KB keepalive
quota so it actually flushes before the tab dies.
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.
Mirror the Twilio voice configuration flow: split the WhatsApp Cloud
calling-enabled toggle out of the generic Configuration tab into a
dedicated Calls tab so the surface for telephony settings is consistent
across providers.
- Added WhatsappCallingPage.vue with the toggle, business phone display,
and a how-it-works blurb (no extra credentials needed since the
embedded-signup token already grants the call scopes).
- Settings.vue: register the page and surface a Calls tab on
WhatsApp Cloud + embedded-signup inboxes.
- ConfigurationPage.vue: drop the inline calling-enabled section, the
watcher that auto-saved on every toggle flip, and the now-orphan
updateWhatsAppCallingEnabled method.
- en/inboxMgmt.json: TABS.CALLS label + WHATSAPP_CALLING.* strings.
- 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.
When an agent shares a conversation link copied from a custom view (e.g.
/custom_view/{id}/conversations/{id}), the link previously broke for
recipients who didn't have access to that custom view. The conversation
now loads regardless — if the custom view isn't available to the
recipient, they're redirected to the direct conversation URL.
### How to reproduce
1. As Agent A, open a conversation from inside a personal custom view
and copy the URL from the address bar.
2. Share the URL with Agent B who does not have access to that custom
view.
3. Before this fix, the link failed to load the conversation. After this
fix, Agent B lands on the conversation via the direct URL.
### What changed
- Added a beforeEnter guard on the conversations_through_folders route.
It checks the user's available conversation custom views (fetching them
on demand for deep links), and if the foldersId in the URL isn't among
them, redirects to the inbox_conversation route with the same
conversation_id.
---------
Co-authored-by: iamsivin <iamsivin@gmail.com>
### 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>