Merge remote-tracking branch 'origin/feature/cw-7495-api' into codex/pr-15017-main

# Conflicts:
#	enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
This commit is contained in:
aakashb95
2026-07-21 18:21:09 +05:30
1182 changed files with 52581 additions and 2192 deletions
@@ -0,0 +1,240 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
import {
VOICE_CALL_DIRECTION,
VOICE_CALL_STATUS,
} from 'dashboard/components-next/message/constants';
import CallStatusBadge from './CallStatusBadge.vue';
import { CALL_KIND, getCallKind } from './constants';
const props = defineProps({
call: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const route = useRoute();
const kind = computed(() => getCallKind(props.call));
const contactName = computed(() =>
(props.call.contact.name || props.call.contact.phoneNumber || '').replace(
/^\+/,
''
)
);
const agentActionLabel = computed(() => {
if (!props.call.agent) return '';
if (kind.value === CALL_KIND.OUTGOING) return t('CALLS_PAGE.ROW.DIALED_BY');
if (kind.value === CALL_KIND.INCOMING) return t('CALLS_PAGE.ROW.PICKED_BY');
// Ongoing collapses direction, so resolve dialed-vs-picked from the raw value.
if (kind.value === CALL_KIND.ONGOING) {
return props.call.direction === VOICE_CALL_DIRECTION.OUTBOUND
? t('CALLS_PAGE.ROW.DIALED_BY')
: t('CALLS_PAGE.ROW.PICKED_BY');
}
return '';
});
const resultLabel = computed(() => {
if (kind.value === CALL_KIND.MISSED) return t('CALLS_PAGE.ROW.NO_AGENT');
if (kind.value === CALL_KIND.NO_REPLY) {
return t('CALLS_PAGE.ROW.NO_CONTACT_ANSWER');
}
if (kind.value === CALL_KIND.FAILED) return t('CALLS_PAGE.ROW.FAILED');
if (kind.value === CALL_KIND.ONGOING) {
return props.call.status === VOICE_CALL_STATUS.RINGING
? t('CALLS_PAGE.ROW.RINGING')
: t('CALLS_PAGE.ROW.IN_PROGRESS');
}
return t('CALLS_PAGE.ROW.ANSWERED');
});
const providerIcon = computed(() =>
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
);
const createdAtLabel = computed(() =>
relativeDayTimestamp(props.call.createdAt, t('CALLS_PAGE.ROW.YESTERDAY'))
);
const conversationRoute = computed(() => ({
name: 'inbox_conversation',
params: {
accountId: route.params.accountId,
conversation_id: props.call.conversation.displayId,
},
query: { messageId: props.call.messageId },
}));
</script>
<template>
<div class="flex flex-col gap-2 py-3.5 border-b border-n-weak lg:hidden">
<div class="flex items-center gap-2 min-w-0">
<Avatar
:src="call.contact.avatar"
:name="contactName"
:size="24"
rounded-full
/>
<span
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
class="text-heading-3 font-medium truncate text-n-slate-12 min-w-0"
>
{{ contactName }}
</span>
<CallStatusBadge :kind="kind" class="ms-auto shrink-0" />
<RouterLink
:to="conversationRoute"
class="inline-flex items-center h-6 gap-1 px-2 text-label-small outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0"
>
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
{{ call.conversation.displayId }}
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
</RouterLink>
</div>
<div class="flex items-center gap-1.5 min-w-0">
<template v-if="agentActionLabel">
<span class="text-label-small text-n-slate-10 shrink-0">
{{ agentActionLabel }}
</span>
<Avatar
:src="call.agent.avatar"
:name="call.agent.name"
:size="20"
rounded-full
/>
<span class="text-body-main truncate text-n-slate-12 min-w-0">
{{ call.agent.name }}
</span>
</template>
<span v-else class="text-body-main truncate text-n-slate-10 min-w-0">
{{ resultLabel }}
</span>
<span class="w-px h-3 bg-n-strong shrink-0" />
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
<span class="text-body-main truncate text-n-slate-11 min-w-0">
{{ call.inbox.name }}
</span>
<span
v-if="!call.recordingUrl"
class="ms-auto shrink-0 text-label-small text-n-slate-11 tabular-nums"
>
{{ createdAtLabel }}
</span>
</div>
<div
v-if="call.recordingUrl"
class="flex items-center gap-2 min-w-0 justify-between"
>
<AudioPlayer
:src="call.recordingUrl"
:fallback-duration="call.durationSeconds || 0"
class="flex-1 sm:flex-[0.7] min-w-0"
/>
<span class="shrink-0 text-label-small text-n-slate-11 tabular-nums">
{{ createdAtLabel }}
</span>
</div>
</div>
<div
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
>
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
<Avatar
:src="call.contact.avatar"
:name="contactName"
:size="24"
rounded-full
/>
<span
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
class="text-heading-3 font-medium truncate text-n-slate-12"
>
{{ contactName }}
</span>
</div>
<div
class="flex flex-nowrap items-center gap-x-2 gap-y-2 min-w-0 grow shrink"
>
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
<CallStatusBadge :kind="kind" class="shrink-0" />
<template v-if="agentActionLabel">
<span
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
>
{{ agentActionLabel }}
</span>
<span class="flex items-center gap-1.5 min-w-16 shrink-[20]">
<Avatar
:src="call.agent.avatar"
:name="call.agent.name"
:size="20"
rounded-full
/>
<span
v-tooltip.top="{
content: call.agent.name,
delay: { show: 500, hide: 0 },
}"
class="text-body-main truncate text-n-slate-12 min-w-0"
>
{{ call.agent.name }}
</span>
</span>
</template>
<span
v-else-if="resultLabel"
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
>
{{ resultLabel }}
</span>
</div>
<AudioPlayer
v-if="call.recordingUrl"
:src="call.recordingUrl"
:fallback-duration="call.durationSeconds || 0"
class="w-auto min-w-44 shrink mx-auto"
/>
</div>
<div
v-tooltip.top="{
content: call.inbox.name,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
>
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
<span class="text-body-main truncate text-n-slate-11">
{{ call.inbox.name }}
</span>
</div>
<RouterLink
:to="conversationRoute"
class="inline-flex items-center h-6 gap-1 px-2 text-label-small py-3.5 outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0 justify-self-start"
>
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
{{ call.conversation.displayId }}
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
</RouterLink>
<span
v-tooltip.top="{
content: createdAtLabel,
delay: { show: 500, hide: 0 },
}"
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
>
{{ createdAtLabel }}
</span>
</div>
</template>
@@ -0,0 +1,158 @@
<script setup>
import { computed, getCurrentInstance, ref, useTemplateRef } from 'vue';
import { downloadFile } from '@chatwoot/utils';
import { useEmitter } from 'dashboard/composables/emitter';
import { emitter } from 'shared/helpers/mitt';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
src: {
type: String,
required: true,
},
fallbackDuration: {
type: Number,
default: 0,
},
});
const PLAYBACK_SPEEDS = [1, 1.5, 2];
const audioPlayer = useTemplateRef('audioPlayer');
const { uid } = getCurrentInstance();
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(props.fallbackDuration);
const playbackSpeed = ref(1);
const onLoadedMetadata = () => {
const loadedDuration = audioPlayer.value?.duration;
if (Number.isFinite(loadedDuration)) duration.value = loadedDuration;
};
const formatTime = time => {
if (!time || Number.isNaN(time)) return '00:00';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const playbackSpeedLabel = computed(() => `${playbackSpeed.value}x`);
const displayedTime = computed(() =>
formatTime(
isPlaying.value || currentTime.value ? currentTime.value : duration.value
)
);
// Only one recording should play at a time across the list.
useEmitter('pause_playing_audio', currentPlayingId => {
if (currentPlayingId !== uid && isPlaying.value) {
audioPlayer.value?.pause();
isPlaying.value = false;
}
});
const playOrPause = () => {
if (isPlaying.value) {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
emitter.emit('pause_playing_audio', uid);
audioPlayer.value.play();
isPlaying.value = true;
}
};
const onTimeUpdate = () => {
currentTime.value = audioPlayer.value?.currentTime;
};
const seek = event => {
const time = Number(event.target.value);
audioPlayer.value.currentTime = time;
currentTime.value = time;
};
const onEnd = () => {
isPlaying.value = false;
currentTime.value = 0;
};
const changePlaybackSpeed = () => {
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed.value);
playbackSpeed.value =
PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length];
audioPlayer.value.playbackRate = playbackSpeed.value;
};
const downloadRecording = () => {
downloadFile({ url: props.src, type: 'audio' });
};
</script>
<template>
<div
class="flex items-center justify-center h-9 gap-2 px-2 rounded-full bg-n-alpha-1 dark:bg-n-alpha-2 overflow-hidden"
@click.stop
>
<audio
ref="audioPlayer"
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="src" />
</audio>
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-12"
@click="playOrPause"
>
<template #icon>
<Icon
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
class="size-4 flex-shrink-0"
/>
</template>
</Button>
<input
type="range"
min="0"
:max="duration || 0"
:value="currentTime"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
@input="seek"
/>
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
{{ displayedTime }}
</span>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
:label="playbackSpeedLabel"
class="!px-1 min-w-6 !text-n-slate-11"
@click="changePlaybackSpeed"
/>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-11"
@click="downloadRecording"
>
<template #icon>
<Icon icon="i-lucide-download" class="size-4 flex-shrink-0" />
</template>
</Button>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { CALL_KIND } from './constants';
const props = defineProps({
kind: {
type: String,
required: true,
},
});
const { t } = useI18n();
const KIND_CONFIG = {
[CALL_KIND.ONGOING]: {
icon: 'i-lucide-phone-call',
class: 'bg-n-teal-3 text-n-teal-11',
},
[CALL_KIND.INCOMING]: {
icon: 'i-lucide-phone-incoming',
class: 'bg-n-slate-3 text-n-slate-11',
},
[CALL_KIND.OUTGOING]: {
icon: 'i-lucide-phone-outgoing',
class: 'bg-n-slate-3 text-n-slate-11',
},
[CALL_KIND.MISSED]: {
icon: 'i-lucide-phone-missed',
class: 'bg-n-ruby-3 text-n-ruby-11',
},
[CALL_KIND.NO_REPLY]: {
icon: 'i-lucide-phone-outgoing',
class: 'bg-n-amber-3 text-n-amber-11',
},
[CALL_KIND.FAILED]: {
icon: 'i-lucide-phone-off',
class: 'bg-n-ruby-3 text-n-ruby-11',
},
};
const config = computed(() => KIND_CONFIG[props.kind]);
</script>
<template>
<span
class="inline-flex items-center justify-center w-20 gap-1.5 h-6 px-1 rounded-md text-label-small shrink-0"
:class="config.class"
>
<Icon :icon="config.icon" class="size-3 flex-shrink-0" />
<span class="truncate">{{
t(`CALLS_PAGE.STATUS.${kind.toUpperCase()}`)
}}</span>
</span>
</template>
@@ -0,0 +1,34 @@
<script setup>
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const setupVoiceChannel = () => {
router.push({
name: 'settings_inbox_new',
params: { accountId: route.params.accountId },
});
};
</script>
<template>
<EmptyStateLayout
:title="t('CALLS_PAGE.SETUP.TITLE')"
:subtitle="t('CALLS_PAGE.SETUP.SUBTITLE')"
:show-backdrop="false"
:action-perms="['administrator']"
>
<template #actions>
<Button
:label="t('CALLS_PAGE.SETUP.ACTION')"
icon="i-lucide-plus"
@click="setupVoiceChannel"
/>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,250 @@
<script setup>
import { computed, ref } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
// Null while a fetch is in flight so stale counts are never shown.
totalCount: {
type: Number,
default: null,
},
agents: {
type: Array,
default: () => [],
},
inboxes: {
type: Array,
default: () => [],
},
// Self-scoped viewers only ever see their own calls, so the assignee filter
// is meaningless for them — only admins get it.
showAssignee: {
type: Boolean,
default: false,
},
});
const activity = defineModel('activity', { type: String, default: null });
const assigneeId = defineModel('assigneeId', { type: Number, default: null });
const inboxId = defineModel('inboxId', { type: Number, default: null });
const { t } = useI18n();
const ACTIVITY_ICONS = {
missed: 'i-lucide-phone-missed',
no_reply: 'i-lucide-phone-outgoing',
incoming: 'i-lucide-phone-incoming',
outgoing: 'i-lucide-phone-outgoing',
in_progress: 'i-lucide-phone-call',
};
const BASE_ACTIVITIES = ['missed', 'no_reply'];
const OTHER_ACTIVITIES = ['incoming', 'outgoing', 'in_progress'];
// A single open-menu identifier keeps the three dropdowns mutually exclusive:
// opening one closes the others without any cross-wiring.
const openMenu = ref(null); // 'activity' | 'assignee' | 'more' | null
const toggleMenu = name => {
openMenu.value = openMenu.value === name ? null : name;
};
// Each dropdown wrapper closes itself on outside clicks. The guard keeps the
// other two wrappers (which the click is also outside of) from closing a
// menu the user is interacting with.
const closeOnOutside = name => {
if (openMenu.value === name) openMenu.value = null;
};
const activityLabel = value => t(`CALLS_PAGE.FILTERS.${value.toUpperCase()}`);
const activeChipLabel = computed(() => {
const label = activityLabel(activity.value);
return props.totalCount === null ? label : `${label} (${props.totalCount})`;
});
const inactiveChips = computed(() =>
BASE_ACTIVITIES.filter(value => value !== activity.value)
);
const otherActivityItems = computed(() =>
OTHER_ACTIVITIES.map(value => ({
label: activityLabel(value),
value,
action: 'filter',
icon: ACTIVITY_ICONS[value],
isSelected: activity.value === value,
}))
);
const assigneeItems = computed(() => [
{
label: t('CALLS_PAGE.FILTERS.ALL_ASSIGNEES'),
value: null,
action: 'filter',
isSelected: !assigneeId.value,
},
...props.agents.map(agent => ({
label: agent.name,
value: agent.id,
action: 'filter',
thumbnail: { name: agent.name, src: agent.thumbnail },
isSelected: assigneeId.value === agent.id,
})),
]);
const moreFiltersSections = computed(() => [
{
title: t('CALLS_PAGE.FILTERS.INBOX'),
items: [
{
label: t('CALLS_PAGE.FILTERS.ALL_INBOXES'),
value: null,
action: 'inbox',
isSelected: !inboxId.value,
},
...props.inboxes.map(inbox => ({
label: inbox.name,
value: inbox.id,
action: 'inbox',
isSelected: inboxId.value === inbox.id,
})),
],
},
]);
const selectedAssigneeLabel = computed(
() =>
props.agents.find(agent => agent.id === assigneeId.value)?.name ||
t('CALLS_PAGE.FILTERS.ASSIGNEE')
);
const hasMoreFilters = computed(() => Boolean(inboxId.value));
const setActivity = value => {
openMenu.value = null;
activity.value = value;
};
const setAssignee = ({ value }) => {
openMenu.value = null;
assigneeId.value = value;
};
const applyMoreFilter = ({ action, value }) => {
openMenu.value = null;
if (action === 'inbox') inboxId.value = value;
};
</script>
<template>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3">
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
{{
totalCount === null
? t('CALLS_PAGE.ALL_CALLS')
: t('CALLS_PAGE.ALL_CALLS_COUNT', { count: totalCount })
}}
</span>
<Button
v-else
variant="outline"
color="blue"
size="sm"
:icon="ACTIVITY_ICONS[activity]"
class="shrink-0"
@click="setActivity(null)"
>
{{ activeChipLabel }}
<Icon icon="i-lucide-x" />
</Button>
<div class="w-px h-4 bg-n-strong shrink-0" />
<Button
v-for="chip in inactiveChips"
:key="chip"
variant="outline"
color="slate"
size="sm"
:icon="ACTIVITY_ICONS[chip]"
:label="activityLabel(chip)"
class="shrink-0 text-n-slate-12"
@click="setActivity(chip)"
/>
<OnClickOutside
class="relative shrink-0"
@trigger="closeOnOutside('activity')"
>
<Button
variant="outline"
color="slate"
size="sm"
icon="i-lucide-phone"
class="text-n-slate-12"
@click="toggleMenu('activity')"
>
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11" />
</Button>
<DropdownMenu
v-if="openMenu === 'activity'"
:menu-items="otherActivityItems"
class="mt-1 start-0 top-full w-44"
@action="setActivity($event.value)"
/>
</OnClickOutside>
</div>
<div class="flex items-center gap-2 shrink-0">
<OnClickOutside
v-if="showAssignee"
class="relative"
@trigger="closeOnOutside('assignee')"
>
<Button
variant="outline"
color="slate"
size="sm"
icon="i-lucide-user-round-cog"
class="max-w-52 text-n-slate-12"
@click="toggleMenu('assignee')"
>
<span class="truncate">{{ selectedAssigneeLabel }}</span>
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
</Button>
<DropdownMenu
v-if="openMenu === 'assignee'"
:menu-items="assigneeItems"
show-search
class="mt-1 end-0 top-full w-56 max-h-72"
@action="setAssignee"
/>
</OnClickOutside>
<OnClickOutside class="relative" @trigger="closeOnOutside('more')">
<Button
variant="outline"
size="sm"
icon="i-lucide-list-filter"
:color="hasMoreFilters ? 'blue' : 'slate'"
:class="hasMoreFilters ? '' : 'text-n-slate-12'"
@click="toggleMenu('more')"
>
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
<Icon
icon="i-lucide-chevron-down"
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
/>
</Button>
<DropdownMenu
v-if="openMenu === 'more'"
:menu-sections="moreFiltersSections"
class="mt-1 end-0 top-full w-56 max-h-80"
@action="applyMoreFilter"
/>
</OnClickOutside>
</div>
</div>
</template>
@@ -0,0 +1,51 @@
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
} from 'dashboard/components-next/message/constants';
export const CALL_KIND = {
ONGOING: 'ongoing',
INCOMING: 'incoming',
OUTGOING: 'outgoing',
MISSED: 'missed',
NO_REPLY: 'no_reply',
FAILED: 'failed',
};
// The API returns display values: status (ringing/in-progress/completed/
// no-answer/failed) and direction (inbound/outbound). The list UI presents
// them as a single "kind" per row.
export const getCallKind = call => {
if (
[VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes(
call.status
)
) {
return CALL_KIND.ONGOING;
}
if (
[VOICE_CALL_STATUS.FAILED, VOICE_CALL_STATUS.REJECTED].includes(call.status)
) {
return CALL_KIND.FAILED;
}
const isInbound = call.direction === VOICE_CALL_DIRECTION.INBOUND;
if (call.status === VOICE_CALL_STATUS.NO_ANSWER) {
return isInbound ? CALL_KIND.MISSED : CALL_KIND.NO_REPLY;
}
return isInbound ? CALL_KIND.INCOMING : CALL_KIND.OUTGOING;
};
// Filter chips map to the status/direction params supported by CallFinder.
export const CALL_ACTIVITY_PARAMS = {
missed: {
status: VOICE_CALL_STATUS.NO_ANSWER,
direction: VOICE_CALL_DIRECTION.INBOUND,
},
no_reply: {
status: VOICE_CALL_STATUS.NO_ANSWER,
direction: VOICE_CALL_DIRECTION.OUTBOUND,
},
incoming: { direction: VOICE_CALL_DIRECTION.INBOUND },
outgoing: { direction: VOICE_CALL_DIRECTION.OUTBOUND },
in_progress: { status: VOICE_CALL_STATUS.IN_PROGRESS },
};
@@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -83,39 +82,18 @@ const navigateToConversation = conversationId => {
const whatsappCallSession = useWhatsappCallSession();
// Find the most recent open conversation for this contact in the picked inbox.
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
// across all inboxes would be treated as having no conversation.
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId, {
inboxId,
});
const conversations = data?.payload || [];
const match = [...conversations].sort(
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
)[0];
return match?.id || null;
};
const startWhatsappCall = async (inboxId, conversationIdHint) => {
// WhatsApp /initiate is conversation-scoped, so we must hand it a
// conversation. Use the caller's hint when given (in-conversation flow);
// otherwise pick the most recent one in the inbox.
const conversationId =
conversationIdHint || (await findWhatsappConversationId(inboxId));
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
const response = await whatsappCallSession.initiateOutboundCall(
conversationIdHint
? { conversationId: conversationIdHint }
: { contactId: props.contactId, inboxId }
);
// The composable returns { status: 'locked' } when an init is already in
// flight or a call is already active; treat that as a soft no-op rather than
// claiming success.
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
const conversationId = response?.conversation_id || conversationIdHint;
if (!response?.id) {
// Permission template path returns no call id. Mirror the header button and
// surface whether the request was just sent or is already pending instead of
@@ -56,6 +56,9 @@ const localeMenuLabels = computed(() => ({
'customize-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
),
'select-popular-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT'
),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
@@ -2,6 +2,7 @@
import { ref } from 'vue';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
import PopularContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -26,6 +27,7 @@ const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const contentDialogRef = ref(null);
const popularContentDialogRef = ref(null);
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
@@ -154,6 +156,8 @@ const handleAction = ({ action }, localeCode) => {
publishLocale({ localeCode: localeCode });
} else if (action === 'customize-content') {
contentDialogRef.value.openForLocale(localeCode);
} else if (action === 'select-popular-content') {
popularContentDialogRef.value.openForLocale(localeCode);
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
@@ -174,5 +178,6 @@ const handleAction = ({ action }, localeCode) => {
@action="handleAction($event, locale.code)"
/>
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
<PopularContentDialog ref="popularContentDialogRef" :portal="portal" />
</ul>
</template>
@@ -0,0 +1,235 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDebounceFn } from '@vueuse/core';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
import categoriesAPI from 'dashboard/api/helpCenter/categories';
import articlesAPI from 'dashboard/api/helpCenter/articles';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ReorderableMultiSelect from 'dashboard/components-next/combobox/ReorderableMultiSelect.vue';
const props = defineProps({
portal: {
type: Object,
default: () => ({}),
},
});
const MAX_CATEGORIES = 3;
const MAX_ARTICLES = 6;
const KEY = 'HELP_CENTER.LOCALES_PAGE.POPULAR_CONTENT_DIALOG';
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const activeLocale = ref('');
// One flag per picker so each shows its skeleton until its own data loads.
const categoriesLoading = ref(false);
const { run: runArticleRequest, isPending: articlesLoading } =
useAbortableRequest();
const categoryOptions = ref([]);
const selectedCategoryIds = ref([]);
// Articles are searched server-side, so cache options for selected items that
// fall outside the current results.
const articleResults = ref([]);
const articleOptionById = ref({});
const selectedArticleIds = ref([]);
const popularContent = computed(
() => props.portal?.config?.popular_content || {}
);
const articleOptions = computed(() => {
const options = new Map();
selectedArticleIds.value.forEach(id => {
if (articleOptionById.value[id])
options.set(id, articleOptionById.value[id]);
});
articleResults.value.forEach(option => options.set(option.value, option));
return [...options.values()];
});
const toArticleOption = article => ({
value: article.id,
label: article.title,
subtitle: t(`${KEY}.ARTICLES.IN_CATEGORY`, {
category: article.category?.name || t(`${KEY}.ARTICLES.UNCATEGORIZED`),
}),
});
const fetchCategories = async localeCode => {
const {
data: { payload },
} = await categoriesAPI.get({
portalSlug: props.portal?.slug,
locale: localeCode,
});
return payload.map(category => ({
value: category.id,
label: category.name,
subtitle: t(`${KEY}.CATEGORIES.ARTICLES_COUNT`, {
count: category.meta?.articles_count || 0,
}),
icon: category.icon,
iconColor: category.icon_color,
}));
};
const requestArticles = async (query, signal) => {
const { data } = await articlesAPI.getArticles({
pageNumber: 1,
portalSlug: props.portal?.slug,
locale: activeLocale.value,
status: 'published',
query,
signal,
});
articleResults.value = data.payload.map(article => {
const option = toArticleOption(article);
articleOptionById.value[article.id] = option;
return option;
});
};
const searchArticles = (query = '') =>
runArticleRequest(signal => requestArticles(query, signal));
const onArticleSearch = useDebounceFn(searchArticles, 300);
// Resolve options for pre-selected articles that aren't in the current results.
const cacheSelectedArticleOptions = async () => {
const unknownIds = selectedArticleIds.value.filter(
id => !articleOptionById.value[id]
);
await Promise.all(
unknownIds.map(async id => {
try {
const { data } = await articlesAPI.getArticle({
id,
portalSlug: props.portal?.slug,
});
articleOptionById.value[id] = toArticleOption(data.payload);
} catch {
// Deleted since it was picked; leave it for the id fallback.
}
})
);
};
const loadCategories = async localeCode => {
categoriesLoading.value = true;
try {
const options = await fetchCategories(localeCode);
// Reopened for another locale mid-flight; drop the stale response.
if (localeCode !== activeLocale.value) return;
categoryOptions.value = options;
} catch (error) {
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
} finally {
if (localeCode === activeLocale.value) categoriesLoading.value = false;
}
};
const loadArticles = async () => {
try {
await runArticleRequest(signal =>
Promise.all([cacheSelectedArticleOptions(), requestArticles('', signal)])
);
} catch (error) {
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
}
};
const openForLocale = localeCode => {
const existing = popularContent.value[localeCode] || {};
activeLocale.value = localeCode;
selectedCategoryIds.value = [...(existing.category_ids || [])];
selectedArticleIds.value = [...(existing.article_ids || [])];
categoryOptions.value = [];
articleResults.value = [];
articleOptionById.value = {};
dialogRef.value?.open();
loadCategories(localeCode);
loadArticles();
};
const onConfirm = async () => {
const updated = { ...popularContent.value };
const entry = {
category_ids: selectedCategoryIds.value,
article_ids: selectedArticleIds.value,
};
if (entry.category_ids.length || entry.article_ids.length) {
updated[activeLocale.value] = entry;
} else {
delete updated[activeLocale.value];
}
try {
await store.dispatch('portals/update', {
portalSlug: props.portal?.slug,
config: { popular_content: updated },
});
dialogRef.value?.close();
useAlert(t(`${KEY}.API.SUCCESS_MESSAGE`));
} catch (error) {
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
}
};
defineExpose({ openForLocale });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t(`${KEY}.TITLE`)"
:description="t(`${KEY}.DESCRIPTION`)"
:confirm-button-label="t(`${KEY}.CONFIRM`)"
@confirm="onConfirm"
>
<div class="flex flex-col gap-5">
<ReorderableMultiSelect
v-model="selectedCategoryIds"
:options="categoryOptions"
:max="MAX_CATEGORIES"
:label="t(`${KEY}.CATEGORIES.LABEL`)"
:add-label="t(`${KEY}.ADD_ANOTHER`)"
:search-placeholder="t(`${KEY}.SEARCH`)"
:empty-state="t(`${KEY}.EMPTY`)"
fallback-icon="i-lucide-folder"
:loading="categoriesLoading"
>
<template #counter="{ remaining }">
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
</template>
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
</ReorderableMultiSelect>
<ReorderableMultiSelect
v-model="selectedArticleIds"
server-search
:options="articleOptions"
:max="MAX_ARTICLES"
:label="t(`${KEY}.ARTICLES.LABEL`)"
:add-label="t(`${KEY}.ADD_ANOTHER`)"
:search-placeholder="t(`${KEY}.SEARCH`)"
:empty-state="t(`${KEY}.EMPTY`)"
:loading="articlesLoading"
@search="onArticleSearch"
>
<template #counter="{ remaining }">
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
</template>
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
</ReorderableMultiSelect>
</div>
</Dialog>
</template>
@@ -0,0 +1,158 @@
<script setup>
import { computed, getCurrentInstance, ref, useTemplateRef } from 'vue';
import { downloadFile } from '@chatwoot/utils';
import { useEmitter } from 'dashboard/composables/emitter';
import { emitter } from 'shared/helpers/mitt';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
src: {
type: String,
required: true,
},
fallbackDuration: {
type: Number,
default: 0,
},
});
const PLAYBACK_SPEEDS = [1, 1.5, 2];
const audioPlayer = useTemplateRef('audioPlayer');
const { uid } = getCurrentInstance();
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(props.fallbackDuration);
const playbackSpeed = ref(1);
const onLoadedMetadata = () => {
const loadedDuration = audioPlayer.value?.duration;
if (Number.isFinite(loadedDuration)) duration.value = loadedDuration;
};
const formatTime = time => {
if (!time || Number.isNaN(time)) return '00:00';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const playbackSpeedLabel = computed(() => `${playbackSpeed.value}x`);
const displayedTime = computed(() =>
formatTime(
isPlaying.value || currentTime.value ? currentTime.value : duration.value
)
);
// Only one recording should play at a time across the list.
useEmitter('pause_playing_audio', currentPlayingId => {
if (currentPlayingId !== uid && isPlaying.value) {
audioPlayer.value?.pause();
isPlaying.value = false;
}
});
const playOrPause = () => {
if (isPlaying.value) {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
emitter.emit('pause_playing_audio', uid);
audioPlayer.value.play();
isPlaying.value = true;
}
};
const onTimeUpdate = () => {
currentTime.value = audioPlayer.value?.currentTime;
};
const seek = event => {
const time = Number(event.target.value);
audioPlayer.value.currentTime = time;
currentTime.value = time;
};
const onEnd = () => {
isPlaying.value = false;
currentTime.value = 0;
};
const changePlaybackSpeed = () => {
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed.value);
playbackSpeed.value =
PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length];
audioPlayer.value.playbackRate = playbackSpeed.value;
};
const downloadRecording = () => {
downloadFile({ url: props.src, type: 'audio' });
};
</script>
<template>
<div
class="flex items-center justify-center h-8 gap-2 px-2 rounded-full bg-n-alpha-1 dark:bg-n-alpha-2 overflow-hidden"
@click.stop
>
<audio
ref="audioPlayer"
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="src" />
</audio>
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-12"
@click="playOrPause"
>
<template #icon>
<Icon
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
class="size-4 flex-shrink-0"
/>
</template>
</Button>
<input
type="range"
min="0"
:max="duration || 0"
:value="currentTime"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
@input="seek"
/>
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
{{ displayedTime }}
</span>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
:label="playbackSpeedLabel"
class="!px-1 min-w-6 !text-n-slate-11"
@click="changePlaybackSpeed"
/>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-11"
@click="downloadRecording"
>
<template #icon>
<Icon icon="i-lucide-download" class="size-4 flex-shrink-0" />
</template>
</Button>
</div>
</template>
@@ -9,6 +9,7 @@ const props = defineProps({
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -45,7 +46,11 @@ const onActivate = () => {
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/>
</div>
<div class="flex items-end justify-between gap-2">
<div v-if="loading" class="flex items-end justify-between gap-2">
<div class="w-20 rounded h-9 bg-n-slate-3 animate-pulse" />
<div class="w-10 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div v-else class="flex items-end justify-between gap-2">
<span
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
>
@@ -9,6 +9,10 @@ const props = defineProps({
type: String,
default: '30',
},
stats: {
type: Object,
default: null,
},
});
const route = useRoute();
@@ -20,22 +24,41 @@ const assistantId = computed(() => route.params.assistantId);
const welcomeMarkdown = ref('');
const isLoading = ref(false);
// Increments on every fetch so a slow response for a superseded
// range/stats/assistant can't overwrite the latest request's state.
let fetchToken = 0;
const fetchSummary = async () => {
fetchToken += 1;
const token = fetchToken;
if (!props.stats) {
welcomeMarkdown.value = '';
isLoading.value = false;
return;
}
isLoading.value = true;
let message = '';
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
stats: props.stats,
});
welcomeMarkdown.value = data.message ?? '';
message = data.message ?? '';
} catch {
welcomeMarkdown.value = '';
} finally {
isLoading.value = false;
message = '';
}
if (token !== fetchToken) return;
welcomeMarkdown.value = message;
isLoading.value = false;
};
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
watch([() => props.range, () => props.stats, assistantId], fetchSummary, {
immediate: true,
});
// Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It
@@ -1,6 +1,8 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
open: {
@@ -27,6 +29,10 @@ const props = defineProps({
type: [String, Number, Array],
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'search']);
@@ -63,13 +69,22 @@ defineExpose({
class="absolute z-50 w-full mt-1 transition-opacity duration-200 border rounded-md shadow-lg bg-n-solid-1 border-n-strong"
>
<div class="relative border-b border-n-strong">
<span class="absolute i-lucide-search top-2.5 size-4 left-3" />
<Spinner
v-if="loading"
:size="16"
class="absolute top-2.5 start-3 text-n-slate-11"
/>
<Icon
v-else
icon="i-lucide-search"
class="absolute top-2.5 size-4 start-3"
/>
<input
ref="searchInput"
:value="searchValue"
type="search"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
class="reset-base w-full py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-n-slate-12"
class="reset-base w-full py-2 !ps-10 !pe-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-n-slate-12"
@input="onInputSearch"
/>
</div>
@@ -0,0 +1,297 @@
<script setup>
import { ref, computed, nextTick } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
// Drag-reorderable multi-select capped at `max`. The model is the ordered list
// of selected values.
const props = defineProps({
// { value, label, subtitle?, icon?, iconColor? } — icon is an emoji or an
// icon-picker value, and falls back to `fallbackIcon`.
options: {
type: Array,
default: () => [],
},
max: {
type: Number,
default: 3,
},
label: {
type: String,
default: '',
},
addLabel: {
type: String,
default: '',
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
fallbackIcon: {
type: String,
default: 'i-lucide-file-text',
},
// Show skeleton rows while options load, so the row height stays stable.
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
// Let the parent filter `options` (via the `search` event) instead of locally.
serverSearch: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['search']);
const selectedIds = defineModel({ type: Array, default: () => [] });
const isOpen = ref(false);
const searchQuery = ref('');
const dragIndex = ref(null);
const dropdownRef = ref(null);
const optionsByValue = computed(
() => new Map(props.options.map(option => [option.value, option]))
);
const selectedRows = computed(() =>
selectedIds.value.map(
id => optionsByValue.value.get(id) || { value: id, label: String(id) }
)
);
const remaining = computed(() => props.max - selectedIds.value.length);
const canAddMore = computed(() => selectedIds.value.length < props.max);
const dropdownOptions = computed(() => {
const available = props.options.filter(
option => !selectedIds.value.includes(option.value)
);
if (props.serverSearch) return available;
const query = searchQuery.value.toLowerCase();
return available.filter(option =>
option.label?.toLowerCase().includes(query)
);
});
const onSearch = value => {
searchQuery.value = value;
emit('search', value);
};
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
if (isOpen.value) {
onSearch('');
nextTick(() => dropdownRef.value?.focus());
}
};
const onSelect = option => {
if (!canAddMore.value) return;
selectedIds.value = [...selectedIds.value, option.value];
if (!canAddMore.value) isOpen.value = false;
};
const removeItem = id => {
selectedIds.value = selectedIds.value.filter(value => value !== id);
};
const onDragStart = index => {
dragIndex.value = index;
};
const onDragOver = index => {
if (dragIndex.value === null || dragIndex.value === index) return;
const ids = [...selectedIds.value];
const [moved] = ids.splice(dragIndex.value, 1);
ids.splice(index, 0, moved);
dragIndex.value = index;
selectedIds.value = ids;
};
const onDragEnd = () => {
dragIndex.value = null;
};
</script>
<template>
<div>
<div
v-if="label || $slots.counter"
class="flex items-center justify-between mb-1.5"
>
<label class="text-sm font-medium text-n-slate-12">{{ label }}</label>
<div class="flex items-center gap-2">
<span class="text-xs text-n-slate-10">
<slot name="counter" :remaining="remaining" :max="max" />
</span>
<div class="flex items-center gap-1">
<span
v-for="slot in max"
:key="slot"
class="w-4 h-1 rounded-full"
:class="slot <= selectedIds.length ? 'bg-n-brand' : 'bg-n-slate-4'"
/>
</div>
</div>
</div>
<OnClickOutside @trigger="isOpen = false">
<div
class="flex flex-col gap-1 p-1 border rounded-xl border-n-weak bg-n-background"
:class="{ 'opacity-50 pointer-events-none': disabled }"
>
<div
v-if="loading && selectedIds.length && !isOpen"
class="flex flex-col gap-1 overflow-y-auto max-h-[216px]"
aria-busy="true"
>
<div
v-for="n in selectedIds.length"
:key="n"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-n-alpha-2"
>
<span
class="flex-shrink-0 opacity-40 i-lucide-grip-vertical size-4 text-n-slate-9"
/>
<div class="flex-shrink-0 rounded-md size-6 bg-n-alpha-3" />
<div class="flex-grow min-w-0">
<p class="mb-0 text-sm">
<span
class="inline-block w-32 h-2.5 align-middle rounded bg-n-alpha-3 animate-pulse"
/>
</p>
<p class="mb-0 text-xs">
<span
class="inline-block w-20 h-2 align-middle rounded bg-n-alpha-3 animate-pulse"
/>
</p>
</div>
<span class="flex-shrink-0 size-6" />
</div>
</div>
<div
v-else-if="selectedRows.length"
class="flex flex-col gap-1 overflow-y-auto max-h-[216px]"
>
<div
v-for="(row, index) in selectedRows"
:key="row.value"
draggable="true"
class="flex items-center gap-2 px-2 py-1.5 transition-colors rounded-lg cursor-grab group/row"
:class="
index === dragIndex
? 'opacity-40 bg-n-alpha-3 ring-1 ring-inset ring-n-brand'
: 'bg-n-alpha-2 hover:bg-n-alpha-3'
"
@dragstart="onDragStart(index)"
@dragover.prevent="onDragOver(index)"
@dragend="onDragEnd"
>
<span
class="flex-shrink-0 transition-colors i-lucide-grip-vertical size-4 text-n-slate-9 group-hover/row:text-n-slate-11"
/>
<div
class="flex items-center justify-center flex-shrink-0 text-sm rounded-md size-6 bg-n-alpha-3 text-n-slate-11"
>
<EmojiIcon
v-if="row.icon"
:value="row.icon"
:color="row.iconColor"
class="shrink-0 size-4"
/>
<span v-else class="size-4" :class="fallbackIcon" />
</div>
<div class="flex-grow min-w-0">
<p class="mb-0 text-sm truncate text-n-slate-12">
{{ row.label }}
</p>
<p
v-if="row.subtitle"
class="mb-0 text-xs truncate text-n-slate-10"
>
{{ row.subtitle }}
</p>
</div>
<Button
type="button"
ghost
slate
xs
no-animation
icon="i-lucide-x"
class="flex-shrink-0"
@click="removeItem(row.value)"
/>
</div>
</div>
<div v-if="canAddMore" class="relative">
<Button
type="button"
ghost
slate
sm
no-animation
justify="start"
:label="addLabel"
:disabled="loading && !isOpen"
class="w-full"
@click="toggleDropdown"
>
<template #icon>
<Spinner
v-if="loading && !isOpen"
:size="16"
class="text-n-slate-11"
/>
<Icon
v-else
icon="i-lucide-search"
class="flex-shrink-0 size-4"
/>
</template>
</Button>
<ComboBoxDropdown
ref="dropdownRef"
:open="isOpen"
:options="dropdownOptions"
:search-value="searchQuery"
:search-placeholder="searchPlaceholder"
:empty-state="emptyState"
:loading="loading"
@update:search-value="onSearch"
@select="onSelect"
/>
</div>
</div>
</OnClickOutside>
<p
v-if="selectedIds.length && $slots.note"
class="flex items-center gap-1.5 mt-1.5 mb-0 text-xs text-n-slate-11"
>
<span class="rounded-full size-1.5 bg-n-teal-9" />
<slot name="note" />
</p>
</div>
</template>
@@ -0,0 +1,224 @@
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import ReorderableMultiSelect from '../ReorderableMultiSelect.vue';
const OPTIONS = [
{ value: 1, label: 'Getting started', subtitle: 'Guides' },
{ value: 2, label: 'Billing', subtitle: 'Payments' },
{ value: 3, label: 'Security' },
{ value: 4, label: 'API', icon: '🔌', iconColor: '#000' },
];
// A findable dropdown stub that exposes the `focus()` the component calls on open.
const ComboBoxDropdownStub = {
name: 'ComboBoxDropdown',
props: [
'open',
'options',
'searchValue',
'searchPlaceholder',
'emptyState',
'loading',
],
emits: ['select', 'update:searchValue'],
methods: { focus() {} },
template: '<div class="combo-dropdown" />',
};
// Renders a real <button> so clicks reach the parent handlers; `data-icon`
// lets specs tell the remove buttons (icon="i-lucide-x") from the add trigger.
const ButtonStub = {
name: 'Button',
props: ['label', 'icon', 'disabled'],
emits: ['click'],
template:
'<button :data-icon="icon" :disabled="disabled" @click="$emit(\'click\')"><slot name="icon" />{{ label }}</button>',
};
const mountSelect = (props = {}, slots = {}) =>
mount(ReorderableMultiSelect, {
props: { options: OPTIONS, max: 3, ...props },
slots,
global: {
stubs: {
Button: ButtonStub,
ComboBoxDropdown: ComboBoxDropdownStub,
Spinner: true,
Icon: true,
EmojiIcon: true,
OnClickOutside: { template: '<div><slot /></div>' },
},
},
});
const dropdown = wrapper => wrapper.findComponent(ComboBoxDropdownStub);
const addTrigger = wrapper =>
wrapper.findAll('button').find(button => !button.attributes('data-icon'));
const removeButtons = wrapper =>
wrapper.findAll('button[data-icon="i-lucide-x"]');
const rows = wrapper => wrapper.findAll('[draggable="true"]');
const lastModel = wrapper => wrapper.emitted('update:modelValue')?.at(-1)?.[0];
describe('ReorderableMultiSelect', () => {
describe('rendering selected rows', () => {
it('renders rows in model order with labels resolved from options', () => {
const wrapper = mountSelect({ modelValue: [2, 1] });
const labels = rows(wrapper).map(row => row.find('p').text());
expect(labels).toEqual(['Billing', 'Getting started']);
});
it('falls back to the stringified id when an option is unknown', () => {
const wrapper = mountSelect({ modelValue: [99] });
expect(rows(wrapper)[0].find('p').text()).toBe('99');
});
it('renders the progress dots filled up to the selection count', () => {
const wrapper = mountSelect({
modelValue: [1, 2],
max: 3,
label: 'Tags',
});
const filled = wrapper.findAll('.bg-n-brand').length;
expect(filled).toBe(2);
});
it('exposes remaining and max to the counter slot', () => {
const wrapper = mountSelect(
{ modelValue: [1], max: 3 },
{ counter: ({ remaining, max }) => h('span', `${remaining}/${max}`) }
);
expect(wrapper.text()).toContain('2/3');
});
});
describe('adding options', () => {
it('appends the chosen option to the model', () => {
const wrapper = mountSelect({ modelValue: [1] });
dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
expect(lastModel(wrapper)).toEqual([1, 2]);
});
it('hides the add trigger once the model reaches max', () => {
const wrapper = mountSelect({ modelValue: [1, 2], max: 2 });
expect(addTrigger(wrapper)).toBeUndefined();
expect(dropdown(wrapper).exists()).toBe(false);
});
it('closes the dropdown when the last slot is filled', async () => {
const wrapper = mountSelect({ modelValue: [1], max: 2 });
await addTrigger(wrapper).trigger('click');
expect(dropdown(wrapper).props('open')).toBe(true);
dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
await wrapper.vm.$nextTick();
// Reaching max removes the trigger (and its dropdown) entirely.
expect(dropdown(wrapper).exists()).toBe(false);
});
it('excludes already-selected options from the dropdown', () => {
const wrapper = mountSelect({ modelValue: [1] });
const values = dropdown(wrapper)
.props('options')
.map(option => option.value);
expect(values).toEqual([2, 3, 4]);
});
});
describe('removing options', () => {
it('removes the clicked item from the model', async () => {
const wrapper = mountSelect({ modelValue: [1, 2, 3] });
await removeButtons(wrapper)[1].trigger('click');
expect(lastModel(wrapper)).toEqual([1, 3]);
});
});
describe('searching', () => {
it('filters options locally by label', async () => {
const wrapper = mountSelect({ modelValue: [] });
dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
await wrapper.vm.$nextTick();
const values = dropdown(wrapper)
.props('options')
.map(option => option.value);
expect(values).toEqual([2]);
});
it('emits search and skips local filtering when serverSearch is set', async () => {
const wrapper = mountSelect({ modelValue: [], serverSearch: true });
dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
await wrapper.vm.$nextTick();
expect(wrapper.emitted('search').at(-1)).toEqual(['bill']);
// All unselected options remain; the parent owns filtering.
expect(dropdown(wrapper).props('options')).toHaveLength(4);
});
it('emits an empty search when the trigger opens', async () => {
const wrapper = mountSelect({ modelValue: [1] });
await addTrigger(wrapper).trigger('click');
expect(wrapper.emitted('search').at(-1)).toEqual(['']);
expect(dropdown(wrapper).props('open')).toBe(true);
});
});
describe('reordering', () => {
it('moves a row to the dropped position within the model', async () => {
const wrapper = mountSelect({ modelValue: [1, 2, 3] });
await rows(wrapper)[0].trigger('dragstart');
await rows(wrapper)[2].trigger('dragover');
expect(lastModel(wrapper)).toEqual([2, 3, 1]);
});
});
describe('loading state', () => {
it('shows skeleton rows when loading a non-empty, closed selection', () => {
const wrapper = mountSelect({ modelValue: [1, 2], loading: true });
const skeleton = wrapper.find('[aria-busy="true"]');
expect(skeleton.exists()).toBe(true);
expect(skeleton.findAll('.animate-pulse').length).toBeGreaterThan(0);
});
it('does not show skeletons when the selection is empty', () => {
const wrapper = mountSelect({ modelValue: [], loading: true });
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
});
it('shows the real rows, not skeletons, while searching in an open dropdown', async () => {
// Open first (trigger is enabled), then a live search turns loading on.
const wrapper = mountSelect({ modelValue: [1, 2] });
await addTrigger(wrapper).trigger('click');
await wrapper.setProps({ loading: true });
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
expect(rows(wrapper)).toHaveLength(2);
});
it('forwards loading to the dropdown and disables the closed trigger', () => {
const wrapper = mountSelect({ modelValue: [1], loading: true });
expect(dropdown(wrapper).props('loading')).toBe(true);
expect(addTrigger(wrapper).attributes('disabled')).toBeDefined();
});
});
});
@@ -3,8 +3,13 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
import {
isTwilioComplete,
isTwilioMediaTemplate,
getTwilioMediaVariableKey,
getTwilioMediaUrl,
applyTwilioMediaFilename,
} from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -40,30 +45,23 @@ const templateBody = computed(() => {
return props.template.body || '';
});
const hasMediaTemplate = computed(() => {
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
});
// Media-template detection and variable extraction are shared with the mobile
// app via @chatwoot/utils.
const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template));
const hasVariables = computed(() => {
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
});
const mediaVariableKey = computed(() => {
if (!hasMediaTemplate.value) return null;
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
if (!mediaUrl) return null;
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
});
const mediaVariableKey = computed(() =>
getTwilioMediaVariableKey(props.template)
);
const hasMediaVariable = computed(() => {
return hasMediaTemplate.value && mediaVariableKey.value !== null;
});
const hasMediaVariable = computed(() => mediaVariableKey.value !== null);
const templateMediaUrl = computed(() => {
if (!hasMediaTemplate.value) return '';
return props.template?.types?.['twilio/media']?.media?.[0] || '';
});
const templateMediaUrl = computed(() =>
hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : ''
);
const variablePattern = computed(() => {
if (!hasVariables.value) return [];
@@ -83,26 +81,10 @@ const renderedTemplate = computed(() => {
return rendered;
});
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaVariable.value) return false;
if (hasVariables.value) {
const hasEmptyVariable = variablePattern.value.some(
variable => !processedParams.value[variable]
);
if (hasEmptyVariable) return true;
}
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
!processedParams.value[mediaVariableKey.value]
) {
return true;
}
return false;
});
// Completeness validation is shared with the mobile app via @chatwoot/utils.
const isFormInvalid = computed(
() => !isTwilioComplete(props.template, processedParams.value)
);
const v$ = useVuelidate(
{
@@ -135,19 +117,11 @@ const sendMessage = () => {
const { friendly_name, language } = props.template;
// Process parameters and extract filename from media URL if needed
const processedParameters = { ...processedParams.value };
// For media templates, extract filename from full URL
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
processedParameters[mediaVariableKey.value]
) {
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
processedParameters[mediaVariableKey.value]
);
}
// For media templates, reduce the media URL to a filename before sending.
const processedParameters = applyTwilioMediaFilename(
props.template,
processedParams.value
);
const payload = {
message: renderedTemplate.value,
@@ -0,0 +1,342 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useMessageContext } from './provider.js';
import { MESSAGE_VARIANTS, ORIENTATION } from './constants';
const props = defineProps({
messageId: { type: Number, required: true },
});
const { t } = useI18n();
const { orientation, variant, createdAt } = useMessageContext();
const store = useStore();
const { isCloudFeatureEnabled } = useAccount();
const isOpen = ref(false);
const showSparkle = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
);
const session = computed(() =>
store.getters['captainAgentSessions/getSessionByMessageId'](props.messageId)
);
const hasFetched = computed(() =>
store.getters['captainAgentSessions/hasFetched'](props.messageId)
);
const isLoading = computed(
() =>
!hasFetched.value ||
store.getters['captainAgentSessions/isFetching'](props.messageId)
);
const citations = computed(() => session.value?.citations || []);
const scenarioTitles = computed(() =>
(session.value?.scenarios || []).reduce((map, scenario) => {
map[scenario.id] = scenario.title;
return map;
}, {})
);
// Fallback for agents without a matching scenario title:
// "chatwoot_assistant" → "Chatwoot assistant",
// "scenario_5_chatwoot_uptime_agent" → "Chatwoot uptime".
const humanizeAgentName = agentName => {
const label = agentName
.replace(/^scenario_\d+_/, '')
.replace(/_agent$/, '')
.replaceAll('_', ' ')
.trim();
return label.charAt(0).toUpperCase() + label.slice(1);
};
const handoffLabel = agentName => {
const scenarioId = agentName.match(/^scenario_(\d+)/)?.[1];
return scenarioTitles.value[scenarioId] || humanizeAgentName(agentName);
};
const ACRONYMS = ['faq', 'api', 'url', 'id', 'sla', 'csat'];
// Tool names arrive as RubyLLM identifiers like
// "captain--tools--faq_lookup" or "custom_get_status_page_overview";
// show "FAQ Lookup" / "Get Status Page Overview" instead.
const humanizeToolName = name => {
return (name || '')
.split('--')
.pop()
.replace(/^custom_/, '')
.split('_')
.filter(Boolean)
.map(word =>
ACRONYMS.includes(word)
? word.toUpperCase()
: word.charAt(0).toUpperCase() + word.slice(1)
)
.join(' ');
};
// Argument keys are camelCased by the store ("labelName"); show "Label Name".
const humanizeArgumentKey = key =>
key
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
const formatArguments = args => {
if (!args || typeof args !== 'object') return '';
return Object.entries(args)
.map(([key, value]) => `${humanizeArgumentKey(key)}: ${value}`)
.join(', ');
};
// Timeline of what Captain did during the run: tool calls (with their
// arguments) and scenario/agent handoffs. Message bodies and raw tool
// results are intentionally not echoed here.
const steps = computed(() => {
const runContext = session.value?.runContext;
const result = [];
let currentAgent = null;
(Array.isArray(runContext) ? runContext : []).forEach(entry => {
if (entry?.role !== 'assistant') return;
const agentName = entry.agentName;
if (agentName && agentName !== currentAgent) {
if (currentAgent !== null) {
result.push({ type: 'handoff', name: handoffLabel(agentName) });
}
currentAgent = agentName;
}
(entry.toolCalls || []).forEach(call => {
// Agent-to-agent transfers surface as "handoff_to_<agent>" tool calls;
// the agent_name change above already yields a handoff step for them.
if (call.name?.startsWith('handoff_to_')) return;
result.push({
type: 'tool',
name: humanizeToolName(call.name),
detail: formatArguments(call.arguments),
});
});
});
return result;
});
// The final assistant entry stores structured content ({response, reasoning});
// surface the model's reasoning for the reply it produced.
const reasoning = computed(() => {
const runContext = session.value?.runContext;
if (!Array.isArray(runContext)) return '';
const entry = [...runContext]
.reverse()
.find(item => item?.role === 'assistant' && item.content?.reasoning);
return entry?.content?.reasoning || '';
});
const STEP_ICONS = {
tool: 'i-ph-wrench',
handoff: 'i-ph-user-switch',
};
const STEP_KEYPATHS = {
tool: 'CONVERSATION.CAPTAIN_GENERATION.STEP_TOOL',
handoff: 'CONVERSATION.CAPTAIN_GENERATION.STEP_HANDOFF',
};
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
// Model and credits are only surfaced to super admins and in development.
const devDetails = computed(() => {
if (!session.value) return null;
if (!import.meta.env.DEV && !isSuperAdmin.value) return null;
const model = t('CONVERSATION.CAPTAIN_GENERATION.MODEL', {
model: session.value.llmModel,
});
const credits = t('CONVERSATION.CAPTAIN_GENERATION.CREDITS', {
credits: session.value.creditsConsumed,
});
return `${model} · ${credits}`;
});
// With the sparkle at the row start, the meta gets pushed to the opposite end;
// without it, fall back to the message orientation.
const rowLayoutClass = computed(() => {
if (showSparkle.value) return 'justify-between';
return orientation.value === ORIENTATION.LEFT
? 'justify-start'
: 'justify-end';
});
// Blend the sparkle with the bubble background: amber on private notes,
// slate everywhere else. Tokens adapt to dark mode on their own.
const sparkleColorClass = computed(() => {
if (variant.value === MESSAGE_VARIANTS.PRIVATE) {
return isOpen.value
? 'text-n-amber-12/80'
: 'text-n-amber-12/40 hover:text-n-amber-12/70';
}
return isOpen.value
? 'text-n-slate-12'
: 'text-n-slate-11/60 hover:text-n-slate-12';
});
const popoverAlign = computed(() =>
orientation.value === ORIENTATION.LEFT ? 'start' : 'end'
);
const prefetch = () => {
store.dispatch('captainAgentSessions/fetch', {
messageId: props.messageId,
createdAt: createdAt.value,
});
};
const onPopoverShow = () => {
isOpen.value = true;
prefetch();
};
const onPopoverHide = () => {
isOpen.value = false;
};
</script>
<template>
<div class="flex items-center gap-1.5" :class="rowLayoutClass">
<Popover
v-if="showSparkle"
:align="popoverAlign"
@show="onPopoverShow"
@hide="onPopoverHide"
>
<button
v-tooltip="t('CONVERSATION.CAPTAIN_GENERATION.TITLE')"
type="button"
class="inline-flex items-center gap-1 p-0 bg-transparent border-0 cursor-pointer"
:class="sparkleColorClass"
@mouseenter="prefetch"
@focus="prefetch"
>
<Icon icon="i-ph-sparkle-fill" class="size-3.5" />
<span class="text-xs">
{{ t('CONVERSATION.CAPTAIN_GENERATION.GENERATED_BY') }}
</span>
</button>
<template #content>
<div class="flex flex-col gap-4 p-4 w-80">
<span v-if="isLoading" class="text-xs text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }}
</span>
<span v-else-if="!session" class="text-xs text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }}
</span>
<template v-else>
<div v-if="steps.length" class="flex flex-col gap-2">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.TIMELINE') }}
</span>
<div class="flex flex-col">
<div
v-for="(step, index) in steps"
:key="index"
class="flex gap-2.5"
>
<div class="flex flex-col items-center">
<span
class="flex items-center justify-center rounded-full size-5 bg-n-alpha-2 text-n-slate-11"
>
<Icon :icon="STEP_ICONS[step.type]" class="size-3" />
</span>
<span
v-if="index < steps.length - 1"
class="flex-1 w-px min-h-2 bg-n-weak"
/>
</div>
<div
class="flex flex-col min-w-0 gap-0.5"
:class="index < steps.length - 1 ? 'pb-3' : ''"
>
<I18nT
:keypath="STEP_KEYPATHS[step.type]"
tag="span"
class="text-xs leading-5 text-n-slate-11"
>
<template #name>
<span class="font-medium text-n-slate-12">
{{ step.name }}
</span>
</template>
</I18nT>
<span
v-if="step.detail"
class="text-xs text-n-slate-11 break-words"
>
{{ step.detail }}
</span>
</div>
</div>
</div>
</div>
<div v-if="citations.length" class="flex flex-col gap-2">
<div class="flex items-baseline gap-1.5">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }}
</span>
<span class="text-xs text-n-slate-10">
{{
t(
'CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY',
citations.length
)
}}
</span>
</div>
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
<li
v-for="citation in citations"
:key="citation.id"
class="text-xs text-n-slate-12"
>
<a
v-if="citation.link"
:href="citation.link"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-n-blue-11 hover:underline"
>
{{ citation.title || citation.link }}
</a>
<span v-else>{{ citation.title }}</span>
</li>
</ul>
</div>
<div v-if="reasoning" class="flex flex-col gap-2">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }}
</span>
<p class="m-0 text-xs leading-normal text-n-slate-12 break-words">
{{ reasoning }}
</p>
</div>
<span v-if="devDetails" class="text-xs text-n-slate-11">
{{ devDetails }}
</span>
</template>
</div>
</template>
</Popover>
<slot name="meta" />
</div>
</template>
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import MessageMeta from '../MessageMeta.vue';
import CaptainGenerationDetails from '../CaptainGenerationDetails.vue';
import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
@@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants';
const props = defineProps({
hideMeta: { type: Boolean, default: false },
});
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
useMessageContext();
const {
variant,
orientation,
inReplyTo,
shouldGroupWithNext,
id,
sender,
senderType,
} = useMessageContext();
const { t } = useI18n();
const isCaptainMessage = computed(
() =>
(sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT
);
const metaColorClass = computed(() =>
variant.value === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11'
);
const emailMetaClass = computed(() =>
variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : ''
);
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
[MESSAGE_VARIANTS.PRIVATE]:
@@ -114,16 +137,21 @@ const replyToPreview = computed(() => {
/>
</div>
<slot />
<MessageMeta
v-if="shouldShowMeta"
:class="[
flexOrientationClass,
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
variant === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11',
]"
class="mt-2"
/>
<template v-if="shouldShowMeta">
<CaptainGenerationDetails
v-if="isCaptainMessage"
:message-id="id"
class="mt-2"
>
<template #meta>
<MessageMeta :class="[emailMetaClass, metaColorClass]" />
</template>
</CaptainGenerationDetails>
<MessageMeta
v-else
:class="[flexOrientationClass, emailMetaClass, metaColorClass]"
class="mt-2"
/>
</template>
</div>
</template>
@@ -263,9 +263,9 @@ const handleCallBack = async () => {
if (!canCallBack.value || isInitiatingCall.value) return;
try {
if (isWhatsapp.value) {
const response = await whatsappCallSession.initiateOutboundCall(
conversationId.value
);
const response = await whatsappCallSession.initiateOutboundCall({
conversationId: conversationId.value,
});
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
@@ -2,6 +2,7 @@
import { h, ref, computed, onMounted, watch } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
@@ -43,7 +44,14 @@ const emit = defineEmits([
]);
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
const { isEnterprise } = useConfig();
const store = useStore();
// Calls run on the enterprise-only API (cloud runs enterprise); hide the entry
// on community so it doesn't lead to a dashboard/CTA the backend can't serve.
const isCallsAvailable = computed(
() => isOnChatwootCloud.value || isEnterprise
);
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
@@ -563,6 +571,17 @@ const menuItems = computed(() => {
},
],
},
...(isCallsAvailable.value
? [
{
name: 'Calls',
label: t('SIDEBAR.CALLS'),
icon: 'i-lucide-phone',
to: accountScopedRoute('calls_dashboard_index'),
activeOn: ['calls_dashboard_index'],
},
]
: []),
{
name: 'Contacts',
label: t('SIDEBAR.CONTACTS'),
@@ -13,6 +13,7 @@ import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { isWhatsAppComplete } from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue';
import {
buildTemplateParameters,
@@ -84,29 +85,10 @@ const renderedTemplate = computed(() => {
return replaceTemplateVariables(bodyText.value, processedParams.value);
});
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaHeader.value) return false;
if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
return true;
}
if (hasVariables.value && processedParams.value.body) {
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
value => !value
);
if (hasEmptyBodyVariable) return true;
}
if (processedParams.value.buttons) {
const hasEmptyButtonParameter = processedParams.value.buttons.some(
button => !button.parameter
);
if (hasEmptyButtonParameter) return true;
}
return false;
});
// Completeness validation is shared with the mobile app via @chatwoot/utils.
const isFormInvalid = computed(
() => !isWhatsAppComplete(props.template, processedParams.value)
);
const v$ = useVuelidate(
{