diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
index a270882ac..f9801ac8f 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
@@ -5,6 +5,7 @@ import { VOICE_CALL_STATUS } from '../constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
+import AudioChip from 'dashboard/components-next/message/chips/Audio.vue';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
@@ -30,7 +31,7 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
-const { call } = useMessageContext();
+const { call, attachments } = useMessageContext();
const status = computed(() => call.value?.status);
const isOutbound = computed(() => call.value?.direction === 'outgoing');
@@ -38,6 +39,22 @@ const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
+const audioAttachment = computed(() =>
+ (attachments?.value || []).find(a => a.fileType === 'audio')
+);
+
+const durationSeconds = computed(
+ () => call.value?.durationSeconds || call.value?.duration_seconds
+);
+
+const formattedDuration = computed(() => {
+ const s = Number(durationSeconds.value);
+ if (!s || Number.isNaN(s)) return '';
+ const m = Math.floor(s / 60);
+ const sec = Math.floor(s % 60);
+ return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
+});
+
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
@@ -93,10 +110,13 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
{{ $t(labelKey) }}
- {{ $t(subtextKey) }}
+ {{ formattedDuration || $t(subtextKey) }}
+
diff --git a/app/javascript/dashboard/composables/useWhatsappCallSession.js b/app/javascript/dashboard/composables/useWhatsappCallSession.js
index 34f981ba0..cd4282b41 100644
--- a/app/javascript/dashboard/composables/useWhatsappCallSession.js
+++ b/app/javascript/dashboard/composables/useWhatsappCallSession.js
@@ -262,10 +262,26 @@ export const applyOutboundAnswer = async (callId, sdpAnswer) => {
export const hasActiveWhatsappCall = () => Boolean(activeCallId);
-// Used by the calls store to tear down the WebRTC session when a WhatsApp call
-// is removed by a cable end-event (the other side hung up).
+// Used by the calls store as a sync teardown safety net.
export const cleanupWhatsappSession = () => cleanup();
+// Cable-driven end (contact hung up / call timed out). Flush any in-memory
+// recorder chunks and upload them so the resulting message bubble shows the
+// audio + transcript — without this, only agent-initiated hangups upload.
+export const handleWhatsappRemoteEnd = async callId => {
+ // Snapshot before cleanup nulls activeCallId.
+ const id = callId || activeCallId;
+ if (!id) {
+ cleanup();
+ return;
+ }
+ try {
+ await stopRecorderAndUpload(id);
+ } finally {
+ cleanup();
+ }
+};
+
// Mute helpers — toggle the mic track's enabled flag (instantaneous, no renegotiation).
export const setWhatsappCallMuted = muted => {
if (!localStream) return false;
diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js
index 2b2dd70cb..32cd7ce26 100644
--- a/app/javascript/dashboard/helper/actionCable.js
+++ b/app/javascript/dashboard/helper/actionCable.js
@@ -5,7 +5,10 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { useCallsStore } from 'dashboard/stores/calls';
-import { applyOutboundAnswer } from 'dashboard/composables/useWhatsappCallSession';
+import {
+ applyOutboundAnswer,
+ handleWhatsappRemoteEnd,
+} from 'dashboard/composables/useWhatsappCallSession';
const { isImpersonating } = useImpersonation();
@@ -240,6 +243,9 @@ class ActionCableConnector extends BaseActionCableConnector {
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = data => {
if (data?.provider !== 'whatsapp') return;
+ // Upload any in-memory recording chunks before tearing down the session,
+ // so contact-initiated hangups still produce an audio attachment.
+ handleWhatsappRemoteEnd(data.id).catch(() => {});
useCallsStore().removeCall(data.call_id);
};
}