Merge branch 'develop' into feat/ui-lib

This commit is contained in:
Shivam Mishra
2025-06-04 15:51:21 +05:30
committed by GitHub
828 changed files with 52346 additions and 8928 deletions
@@ -0,0 +1,65 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
defineProps({
selectedContact: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const [showDeleteSection, toggleDeleteSection] = useToggle();
const confirmDeleteContactDialogRef = ref(null);
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
};
</script>
<template>
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
</div>
</div>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
</template>
@@ -7,6 +7,7 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
@@ -149,15 +150,15 @@ const onClickViewDetails = () => emit('showContact', props.id);
/>
<template #after>
<transition
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
enter-from-class="overflow-hidden opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
leave-to-class="overflow-hidden opacity-0 max-h-0"
<div
class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class="
isExpanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div v-show="isExpanded" class="w-full">
<div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
@@ -176,8 +177,14 @@ const onClickViewDetails = () => emit('showContact', props.id);
/>
</div>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div>
</transition>
</div>
</template>
</CardLayout>
</template>
@@ -47,11 +47,7 @@ defineExpose({ dialogRef });
ref="dialogRef"
type="alert"
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
:description="
t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', {
contactName: props.selectedContact.name,
})
"
:description="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION')"
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
@confirm="handleDialogConfirm"
/>
@@ -0,0 +1,87 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { computed } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useMapGetter } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
const { updateUISettings } = useUISettings();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
const { uiSettings } = useUISettings();
const isContactSidebarOpen = computed(
() => uiSettings.value.is_contact_sidebar_open
);
const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open
);
const toggleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_copilot_panel_open: false,
});
};
const handleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: true,
is_copilot_panel_open: false,
});
};
const handleCopilotSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
});
};
const keyboardEvents = {
'Alt+KeyO': {
action: toggleConversationSidebarToggle,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
ghost
slate
sm
class="!rounded-full"
:class="{
'bg-n-alpha-2': isContactSidebarOpen,
}"
icon="i-ph-user-bold"
@click="handleConversationSidebarToggle"
/>
<Button
v-if="showCopilotTab"
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
ghost
slate
class="!rounded-full"
:class="{
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
}"
sm
icon="i-woot-captain"
@click="handleCopilotSidebarToggle"
/>
</div>
</template>
@@ -200,6 +200,7 @@ defineExpose({ state, isSubmitDisabled });
:label="state.icon"
color="slate"
size="sm"
type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
@@ -0,0 +1,29 @@
<script setup>
import SidebarActionsHeader from './SidebarActionsHeader.vue';
</script>
<template>
<Story
title="Components/SidebarActionsHeader"
:layout="{ type: 'grid', width: '800px' }"
>
<!-- Default State -->
<Variant title="Default State">
<SidebarActionsHeader title="Default State" />
</Variant>
<!-- With New Conversation Button -->
<Variant title="With New Conversation Button">
<!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand -->
<SidebarActionsHeader
title="With New Conversation Button"
:buttons="[
{
key: 'new_conversation',
icon: 'i-lucide-plus',
},
]"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,47 @@
<script setup>
import Button from './button/Button.vue';
defineProps({
title: {
type: String,
required: true,
},
buttons: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['click', 'close']);
const handleButtonClick = button => {
emit('click', button.key);
};
</script>
<template>
<div
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">{{ title }}</span>
<div class="flex items-center">
<Button
v-for="button in buttons"
:key="button.key"
v-tooltip="button.tooltip"
:icon="button.icon"
ghost
sm
@click="handleButtonClick(button)"
/>
<Button
v-tooltip="$t('GENERAL.CLOSE')"
icon="i-lucide-x"
ghost
sm
@click="$emit('close')"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,28 @@
<!--
* Preserves RTL/LTR context when teleporting content
* Ensures direction-specific classes (ltr:tailwind-class, rtl:tailwind-class) work correctly
* when content is teleported outside the app's container with [dir] attribute
-->
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
defineProps({
to: {
type: String,
default: 'body',
},
});
const isRTL = useMapGetter('accounts/isRTL');
const contentDirection = computed(() => (isRTL.value ? 'rtl' : 'ltr'));
</script>
<template>
<Teleport :to="to">
<div :dir="contentDirection">
<slot />
</div>
</Teleport>
</template>
@@ -117,7 +117,7 @@ const STYLE_CONFIG = {
'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8',
ghost:
'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 hover:enabled:underline focus-visible:underline outline-transparent',
link: 'text-n-ruby-9 dark:text-n-ruby-11 hover:enabled:underline focus-visible:underline outline-transparent',
},
amber: {
solid:
@@ -0,0 +1,41 @@
<script setup>
import ConfirmButton from './ConfirmButton.vue';
import { ref } from 'vue';
const count = ref(0);
const incrementCount = () => {
count.value += 1;
};
</script>
<template>
<Story
title="Components/ConfirmButton"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Basic">
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
<p>{{ count }}</p>
<ConfirmButton
label="Delete"
confirm-label="Confirm?"
@click="incrementCount"
/>
</div>
</Variant>
<Variant title="Color Change">
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
<p>{{ count }}</p>
<ConfirmButton
label="Archive"
confirm-label="Confirm?"
color="slate"
confirm-color="amber"
@click="incrementCount"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { ref, computed } from 'vue';
import Button from './Button.vue';
const props = defineProps({
label: { type: [String, Number], default: '' },
confirmLabel: { type: [String, Number], default: '' },
color: { type: String, default: 'blue' },
confirmColor: { type: String, default: 'ruby' },
confirmHint: { type: String, default: '' },
variant: { type: String, default: null },
size: { type: String, default: null },
justify: { type: String, default: null },
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
const isConfirmMode = ref(false);
const isClicked = ref(false);
const currentLabel = computed(() => {
return isConfirmMode.value ? props.confirmLabel : props.label;
});
const currentColor = computed(() => {
return isConfirmMode.value ? props.confirmColor : props.color;
});
const resetConfirmMode = () => {
isConfirmMode.value = false;
isClicked.value = false;
};
const handleClick = () => {
if (!isConfirmMode.value) {
isConfirmMode.value = true;
} else {
isClicked.value = true;
emit('click');
setTimeout(resetConfirmMode, 400);
}
};
</script>
<template>
<div
class="relative"
:class="{
'animate-bounce-complete': isClicked,
}"
>
<Button
type="button"
:label="currentLabel"
:color="currentColor"
:variant="variant"
:size="size"
:justify="justify"
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
@click="handleClick"
@blur="resetConfirmMode"
>
<template v-if="$slots.default" #default>
<slot />
</template>
<template v-if="$slots.icon" #icon>
<slot name="icon" />
</template>
</Button>
<div
v-if="isConfirmMode && confirmHint"
class="absolute mt-1 w-full text-[10px] text-center text-n-slate-10"
>
{{ confirmHint }}
</div>
</div>
</template>
<style scoped>
@keyframes bounce-complete {
0% {
transform: scale(0.95);
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
.animate-bounce-complete {
animation: bounce-complete 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
</style>
@@ -76,7 +76,7 @@ const handlePageChange = event => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 xl:px-0">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto">
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
@@ -116,7 +116,7 @@ const handlePageChange = event => {
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" />
<div
@@ -42,6 +42,7 @@ const initialState = {
conversationFaqs: false,
memories: false,
},
temperature: 1,
};
const state = reactive({ ...initialState });
@@ -87,6 +88,7 @@ const updateStateFromAssistant = assistant => {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
};
state.temperature = config.temperature || 1;
};
const handleBasicInfoUpdate = async () => {
@@ -100,7 +102,10 @@ const handleBasicInfoUpdate = async () => {
const payload = {
name: state.name,
description: state.description,
product_name: state.productName,
config: {
...props.assistant.config,
product_name: state.productName,
},
};
emit('submit', payload);
@@ -133,6 +138,7 @@ const handleInstructionsUpdate = async () => {
const payload = {
config: {
...props.assistant.config,
temperature: state.temperature || 1,
instructions: state.instructions,
},
};
@@ -209,7 +215,7 @@ watch(
<!-- Instructions Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-4">
<Editor
v-model="state.instructions"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
@@ -218,6 +224,25 @@ watch(
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2 mt-4">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div class="flex justify-end">
<Button
size="small"
@@ -1,29 +1,24 @@
<script setup>
import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { nextTick, ref, watch, computed } from 'vue';
import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useUISettings } from 'dashboard/composables/useUISettings';
import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import Icon from '../icon/Icon.vue';
import CopilotEmptyState from './CopilotEmptyState.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
supportAgent: {
type: Object,
default: () => ({}),
},
messages: {
type: Array,
default: () => [],
},
isCaptainTyping: {
type: Boolean,
default: false,
},
conversationInboxType: {
type: String,
required: true,
@@ -42,22 +37,11 @@ const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const { t } = useI18n();
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => {
emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
};
const useSuggestion = opt => {
emit('sendMessage', t(opt.prompt));
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const handleReset = () => {
emit('reset');
};
const chatContainer = ref(null);
const scrollToBottom = async () => {
@@ -67,23 +51,72 @@ const scrollToBottom = async () => {
}
};
const promptOptions = [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
];
const groupedMessages = computed(() => {
const result = [];
let thinkingGroup = [];
props.messages.forEach(message => {
if (message.message_type === 'assistant_thinking') {
thinkingGroup.push(message);
} else {
if (thinkingGroup.length > 0) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
thinkingGroup = [];
}
result.push(message);
}
});
if (thinkingGroup.length > 0) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
}
return result;
});
const isLastMessageFromAssistant = computed(() => {
return (
groupedMessages.value[groupedMessages.value.length - 1].message_type ===
'assistant'
);
});
const { updateUISettings } = useUISettings();
const closeCopilotPanel = () => {
updateUISettings({
is_copilot_panel_open: false,
is_contact_sidebar_open: false,
});
};
const handleSidebarAction = action => {
if (action === 'reset') {
emit('reset');
}
};
const hasAssistants = computed(() => props.assistants.length > 0);
const hasMessages = computed(() => props.messages.length > 0);
const copilotButtons = computed(() => {
if (hasMessages.value) {
return [
{
key: 'reset',
icon: 'i-lucide-refresh-ccw',
tooltip: t('CAPTAIN.COPILOT.RESET'),
},
];
}
return [];
});
watch(
[() => props.messages, () => props.isCaptainTyping],
[() => props.messages],
() => {
scrollToBottom();
},
@@ -93,62 +126,59 @@ watch(
<template>
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
v-if="message.role === 'user'"
:support-agent="supportAgent"
:message="message"
/>
<CopilotAssistantMessage
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
:message="message"
:conversation-inbox-type="conversationInboxType"
/>
</template>
<CopilotLoader v-if="isCaptainTyping" />
</div>
<SidebarActionsHeader
:title="$t('CAPTAIN.COPILOT.TITLE')"
:buttons="copilotButtons"
@click="handleSidebarAction"
@close="closeCopilotPanel"
/>
<div
v-if="!messages.length"
class="h-full w-full flex items-center justify-center"
ref="chatContainer"
class="flex-1 flex px-4 py-4 overflow-y-auto items-start"
>
<div class="h-fit px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span>
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
<div v-if="hasMessages" class="space-y-6 flex-1 flex flex-col w-full">
<template v-for="(item, index) in groupedMessages" :key="item.id">
<CopilotAgentMessage
v-if="item.message_type === 'user'"
:message="item.message"
/>
<CopilotAssistantMessage
v-else-if="item.message_type === 'assistant'"
:message="item.message"
:is-last-message="index === groupedMessages.length - 1"
:conversation-inbox-type="conversationInboxType"
/>
<CopilotThinkingGroup
v-else
:messages="item.messages"
:default-collapsed="isLastMessageFromAssistant"
/>
</template>
<CopilotLoader v-if="!isLastMessageFromAssistant" />
</div>
<CopilotEmptyState
v-else
:has-assistants="hasAssistants"
@use-suggestion="sendMessage"
/>
</div>
<div class="mx-3 mt-px mb-2">
<div class="flex items-center gap-2 justify-between w-full mb-1">
<ToggleCopilotAssistant
v-if="assistants.length"
v-if="assistants.length > 1"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="$event => emit('setAssistant', $event)"
/>
<div v-else />
<button
v-if="messages.length"
class="text-xs flex items-center gap-1 hover:underline"
@click="handleReset"
>
<i class="i-lucide-refresh-ccw" />
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
</button>
</div>
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
<CopilotInput
v-if="hasAssistants"
class="mb-1 w-full"
@send="sendMessage"
/>
</div>
</div>
</template>
@@ -1,31 +1,17 @@
<script setup>
import Avatar from '../avatar/Avatar.vue';
defineProps({
message: {
type: Object,
required: true,
},
supportAgent: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="flex flex-row gap-2">
<Avatar
:name="supportAgent.available_name"
:src="supportAgent.avatar_url"
:size="24"
rounded-full
/>
<div class="space-y-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
<div class="break-words">
{{ message.content }}
</div>
<div class="space-y-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
<div class="break-words">
{{ message.content }}
</div>
</div>
</template>
@@ -9,9 +9,12 @@ import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from '../avatar/Avatar.vue';
const props = defineProps({
isLastMessage: {
type: Boolean,
default: false,
},
message: {
type: Object,
required: true,
@@ -21,6 +24,15 @@ const props = defineProps({
required: true,
},
});
const hasEmptyMessageContent = computed(() => !props.message?.content);
const showUseButton = computed(() => {
return (
!hasEmptyMessageContent.value &&
props.message.reply_suggestion &&
props.isLastMessage
);
});
const messageContent = computed(() => {
const formatter = new MessageFormatter(props.message.content);
@@ -33,8 +45,6 @@ const insertIntoRichEditor = computed(() => {
);
});
const hasEmptyMessageContent = computed(() => !props.message?.content);
const useCopilotResponse = () => {
if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
@@ -46,33 +56,25 @@ const useCopilotResponse = () => {
</script>
<template>
<div class="flex flex-row gap-2">
<Avatar
name="Captain Copilot"
icon-name="i-woot-captain"
:size="24"
rounded-full
<div class="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
</span>
<div
v-else
v-dompurify-html="messageContent"
class="prose-sm break-words"
/>
<div class="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
</span>
<div
v-else
v-dompurify-html="messageContent"
class="prose-sm break-words"
<div class="flex flex-row mt-1">
<Button
v-if="showUseButton"
:label="$t('CAPTAIN.COPILOT.USE')"
faded
sm
slate
@click="useCopilotResponse"
/>
<div class="flex flex-row mt-1">
<Button
v-if="!hasEmptyMessageContent"
:label="$t('CAPTAIN.COPILOT.USE')"
faded
sm
slate
@click="useCopilotResponse"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,108 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Icon from '../icon/Icon.vue';
defineProps({
hasAssistants: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['useSuggestion']);
const { t } = useI18n();
const route = useRoute();
const routePromptMap = {
conversations: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
],
dashboard: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.CONTENT',
},
],
};
const getCurrentRoute = () => {
const path = route.path;
if (path.includes('/conversations')) return 'conversations';
if (path.includes('/dashboard')) return 'dashboard';
if (path.includes('/contacts')) return 'contacts';
if (path.includes('/articles')) return 'articles';
return 'dashboard';
};
const promptOptions = computed(() => {
const currentRoute = getCurrentRoute();
return routePromptMap[currentRoute] || routePromptMap.conversations;
});
const handleSuggestion = opt => {
emit('useSuggestion', t(opt.prompt));
};
</script>
<template>
<div class="flex-1 flex flex-col gap-6 px-2">
<div class="flex flex-col space-y-4 py-4">
<Icon icon="i-woot-captain" class="text-n-slate-9 text-4xl" />
<div class="space-y-1">
<h3 class="text-base font-medium text-n-slate-12 leading-8">
{{ $t('CAPTAIN.COPILOT.PANEL_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.COPILOT.KICK_OFF_MESSAGE') }}
</p>
</div>
</div>
<div v-if="!hasAssistants" class="w-full space-y-2">
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.ASSISTANTS.NO_ASSISTANTS_AVAILABLE') }}
</p>
<router-link
:to="{
name: 'captain_assistants_index',
params: { accountId: route.params.accountId },
}"
class="text-n-slate-11 underline hover:text-n-slate-12"
>
{{ $t('CAPTAIN.ASSISTANTS.ADD_NEW') }}
</router-link>
</div>
<div v-else class="w-full space-y-2">
<span class="text-xs text-n-slate-10 block">
{{ $t('CAPTAIN.COPILOT.TRY_THESE_PROMPTS') }}
</span>
<div class="space-y-1">
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="w-full px-3 py-2 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center justify-between hover:bg-n-slate-3 transition-colors"
@click="handleSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
</div>
</div>
</template>
@@ -13,19 +13,16 @@ const sendMessage = () => {
</script>
<template>
<form
class="border border-n-weak bg-n-alpha-3 rounded-lg h-12 flex"
@submit.prevent="sendMessage"
>
<form class="relative" @submit.prevent="sendMessage">
<input
v-model="message"
type="text"
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
class="w-full reset-base bg-transparent px-4 py-3 text-n-slate-11 text-sm"
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-n-slate-11 text-sm border border-n-weak rounded-lg focus:outline-none focus:ring-1 focus:ring-n-blue-11 focus:border-n-blue-11"
@keyup.enter="sendMessage"
/>
<button
class="h-auto w-12 flex items-center justify-center text-n-slate-11"
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
type="submit"
>
<i class="i-ph-arrow-up" />
@@ -0,0 +1,62 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const isConversationRoute = computed(() => {
const CONVERSATION_ROUTES = [
'inbox_conversation',
'conversation_through_inbox',
'conversations_through_label',
'team_conversations_through_label',
'conversations_through_folders',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
];
return CONVERSATION_ROUTES.includes(route.name);
});
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotLauncher = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
return (
isCaptainEnabled &&
!uiSettings.value.is_copilot_panel_open &&
!isConversationRoute.value
);
});
const toggleSidebar = () => {
updateUISettings({
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
is_contact_sidebar_open: false,
});
};
</script>
<template>
<div v-if="showCopilotLauncher" class="fixed bottom-4 right-4 z-50">
<div class="rounded-full bg-n-alpha-2 p-1">
<Button
icon="i-woot-captain"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
lg
@click="toggleSidebar"
/>
</div>
</div>
<template v-else />
</template>
@@ -0,0 +1,25 @@
<script setup>
import Icon from '../../components-next/icon/Icon.vue';
defineProps({
content: {
type: String,
required: true,
},
});
</script>
<template>
<div
class="flex flex-col gap-2 p-3 rounded-lg bg-n-background/50 border border-n-weak hover:bg-n-background/80 transition-colors duration-200"
>
<div class="flex items-start gap-2">
<Icon
icon="i-lucide-sparkles"
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
/>
<div class="text-sm text-n-slate-12">
{{ content }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup>
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
const messages = [
{
id: 1,
content: 'Analyzing the user query',
reasoning: 'Breaking down the request into actionable steps',
},
{
id: 2,
content: 'Searching codebase',
reasoning: 'Looking for relevant files and functions',
},
{
id: 3,
content: 'Generating response',
reasoning: 'Composing a helpful and accurate answer',
},
];
</script>
<template>
<Story title="Captain/Copilot/CopilotThinkingGroup" group="components">
<Variant title="Default">
<CopilotThinkingGroup :messages="messages" />
</Variant>
<Variant title="With Default Collapsed">
<!-- eslint-disable-next-line -->
<CopilotThinkingGroup :messages="messages" :default-collapsed="true" />
</Variant>
</Story>
</template>
@@ -0,0 +1,61 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from '../icon/Icon.vue';
import CopilotThinkingBlock from './CopilotThinkingBlock.vue';
const props = defineProps({
messages: { type: Array, required: true },
defaultCollapsed: { type: Boolean, default: false },
});
const { t } = useI18n();
const isExpanded = ref(!props.defaultCollapsed);
const thinkingCount = computed(() => props.messages.length);
watch(
() => props.defaultCollapsed,
newValue => {
if (newValue) {
isExpanded.value = false;
}
}
);
</script>
<template>
<div class="flex flex-col gap-2">
<button
class="group flex items-center gap-2 text-xs text-n-slate-10 hover:text-n-slate-11 transition-colors duration-200 -ml-3"
@click="isExpanded = !isExpanded"
>
<Icon
:icon="isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
class="w-4 h-4 transition-transform duration-200 group-hover:scale-110"
/>
<span class="flex items-center gap-2">
{{ t('CAPTAIN.COPILOT.SHOW_STEPS') }}
<span
class="inline-flex items-center justify-center h-4 min-w-4 px-1 text-xs font-medium rounded-full bg-n-solid-3 text-n-slate-11"
>
{{ thinkingCount }}
</span>
</span>
</button>
<div
v-show="isExpanded"
class="space-y-3 transition-all duration-200"
:class="{
'opacity-100': isExpanded,
'opacity-0 max-h-0 overflow-hidden': !isExpanded,
}"
>
<CopilotThinkingBlock
v-for="copilotMessage in messages"
:key="copilotMessage.id"
:content="copilotMessage.message.content"
:reasoning="copilotMessage.message.reasoning"
/>
</div>
</div>
</template>
@@ -2,9 +2,9 @@
import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
type: {
@@ -59,8 +59,6 @@ const emit = defineEmits(['confirm', 'close']);
const { t } = useI18n();
const isRTL = useMapGetter('accounts/isRTL');
const dialogRef = ref(null);
const dialogContentRef = ref(null);
@@ -94,7 +92,7 @@ defineExpose({ open, close });
</script>
<template>
<Teleport to="body">
<TeleportWithDirection to="body">
<dialog
ref="dialogRef"
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
@@ -102,7 +100,6 @@ defineExpose({ open, close });
maxWidthClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]"
:dir="isRTL ? 'rtl' : 'ltr'"
@close="close"
>
<OnClickOutside @trigger="close">
@@ -152,7 +149,7 @@ defineExpose({ open, close });
</form>
</OnClickOutside>
</dialog>
</Teleport>
</TeleportWithDirection>
</template>
<style scoped>
@@ -81,6 +81,7 @@ onMounted(() => {
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
@@ -1,5 +1,6 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useElementBounding, useWindowSize } from '@vueuse/core';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
@@ -25,6 +26,10 @@ const props = defineProps({
type: String,
default: 'faded',
},
label: {
type: String,
default: null,
},
});
const selected = defineModel({
@@ -32,6 +37,13 @@ const selected = defineModel({
required: true,
});
const triggerRef = ref(null);
const dropdownRef = ref(null);
const { top } = useElementBounding(triggerRef);
const { height } = useWindowSize();
const { height: dropdownHeight } = useElementBounding(dropdownRef);
const selectedOption = computed(() => {
return props.options.find(o => o.value === selected.value) || {};
});
@@ -41,6 +53,16 @@ const iconToRender = computed(() => {
return selectedOption.value.icon || 'i-lucide-chevron-down';
});
const dropdownPosition = computed(() => {
const DROPDOWN_MAX_HEIGHT = 340;
// Get actual height if available or use default
const menuHeight = dropdownHeight.value
? dropdownHeight.value + 20
: DROPDOWN_MAX_HEIGHT;
const spaceBelow = height.value - top.value;
return spaceBelow < menuHeight ? 'bottom-0' : 'top-0';
});
const updateSelected = newValue => {
selected.value = newValue;
};
@@ -51,17 +73,24 @@ const updateSelected = newValue => {
<template #trigger="{ toggle }">
<slot name="trigger" :toggle="toggle">
<Button
ref="triggerRef"
type="button"
sm
slate
:variant
:icon="iconToRender"
:trailing-icon="selectedOption.icon ? false : true"
:label="hideLabel ? null : selectedOption.label"
:label="label || (hideLabel ? null : selectedOption.label)"
@click="toggle"
/>
</slot>
</template>
<DropdownBody class="top-0 min-w-48 z-50" strong>
<DropdownBody
ref="dropdownRef"
class="min-w-48 z-50"
:class="dropdownPosition"
strong
>
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="option in options"
@@ -9,7 +9,14 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options } = defineProps({
const {
options,
disableSearch,
placeholderIcon,
placeholder,
placeholderTrailingIcon,
searchPlaceholder,
} = defineProps({
options: {
type: Array,
required: true,
@@ -18,6 +25,22 @@ const { options } = defineProps({
type: Boolean,
default: false,
},
placeholderIcon: {
type: String,
default: 'i-lucide-plus',
},
placeholder: {
type: String,
default: '',
},
placeholderTrailingIcon: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
});
const { t } = useI18n();
@@ -69,15 +92,26 @@ const toggleSelected = option => {
sm
slate
faded
type="button"
:icon="selectedItem.icon"
:label="selectedItem.name"
@click="toggle"
/>
<Button v-else sm slate faded @click="toggle">
<Button
v-else
sm
slate
faded
type="button"
:trailing-icon="placeholderTrailingIcon"
@click="toggle"
>
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
<Icon :icon="placeholderIcon" class="text-n-slate-11" />
</template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
<span class="text-n-slate-11">{{
placeholder || t('COMBOBOX.PLACEHOLDER')
}}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-56 z-50" strong>
@@ -87,7 +121,7 @@ const toggleSelected = option => {
v-model="searchTerm"
autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection class="max-h-80 overflow-scroll">
@@ -1,7 +1,8 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, watch } from 'vue';
import Input from './Input.vue';
import { useI18n } from 'vue-i18n';
import { DURATION_UNITS } from './constants';
const props = defineProps({
min: { type: Number, default: 0 },
@@ -11,36 +12,50 @@ const props = defineProps({
const { t } = useI18n();
const duration = defineModel('modelValue', { type: Number, default: null });
const unit = defineModel('unit', {
type: String,
default: DURATION_UNITS.MINUTES,
validate(value) {
return Object.values(DURATION_UNITS).includes(value);
},
});
const UNIT_TYPES = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
const convertToMinutes = newValue => {
if (unit.value === DURATION_UNITS.MINUTES) {
return Math.floor(newValue);
}
if (unit.value === DURATION_UNITS.HOURS) {
return Math.floor(newValue) * 60;
}
return Math.floor(newValue) * 24 * 60;
};
const unit = ref(UNIT_TYPES.MINUTES);
const transformedValue = computed({
get() {
if (unit.value === UNIT_TYPES.MINUTES) return duration.value;
if (unit.value === UNIT_TYPES.HOURS) return Math.floor(duration.value / 60);
if (unit.value === UNIT_TYPES.DAYS)
if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
if (unit.value === DURATION_UNITS.HOURS)
return Math.floor(duration.value / 60);
if (unit.value === DURATION_UNITS.DAYS)
return Math.floor(duration.value / 24 / 60);
return 0;
},
set(newValue) {
let minuteValue;
if (unit.value === UNIT_TYPES.MINUTES) {
minuteValue = Math.floor(newValue);
} else if (unit.value === UNIT_TYPES.HOURS) {
minuteValue = Math.floor(newValue * 60);
} else if (unit.value === UNIT_TYPES.DAYS) {
minuteValue = Math.floor(newValue * 24 * 60);
}
let minuteValue = convertToMinutes(newValue);
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
},
});
// when unit is changed set the nearest value to that unit
// so if the minute is set to 900, and the user changes the unit to "days"
// the transformed value will show 0, but the real value will still be 900
// this might create some confusion, especially when saving
// this watcher fixes it by rounding the duration basically, to the nearest unit value
watch(unit, () => {
let adjustedValue = convertToMinutes(transformedValue.value);
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
});
</script>
<template>
@@ -57,10 +72,12 @@ const transformedValue = computed({
:disabled="disabled"
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
>
<option :value="UNIT_TYPES.MINUTES">
<option :value="DURATION_UNITS.MINUTES">
{{ t('DURATION_INPUT.MINUTES') }}
</option>
<option :value="UNIT_TYPES.HOURS">{{ t('DURATION_INPUT.HOURS') }}</option>
<option :value="UNIT_TYPES.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
<option :value="DURATION_UNITS.HOURS">
{{ t('DURATION_INPUT.HOURS') }}
</option>
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</select>
</template>
@@ -0,0 +1,5 @@
export const DURATION_UNITS = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
};
@@ -186,12 +186,20 @@ const isBotOrAgentMessage = computed(() => {
return true;
}
const senderId = props.senderId ?? props.sender?.id;
const senderType = props.senderType ?? props.sender?.type;
const senderType = props.sender?.type ?? props.senderType;
if (!senderType || !senderId) {
return true;
}
if (
[SENDER_TYPES.AGENT_BOT, SENDER_TYPES.CAPTAIN_ASSISTANT].includes(
senderType
)
) {
return true;
}
return senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase();
});
@@ -307,11 +315,7 @@ const componentToRender = computed(() => {
});
const shouldShowContextMenu = computed(() => {
return !(
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS ||
props.contentAttributes?.isUnsupported
);
return !props.contentAttributes?.isUnsupported;
});
const isBubble = computed(() => {
@@ -336,12 +340,23 @@ const contextMenuEnabledOptions = computed(() => {
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
const isFailedOrProcessing =
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS;
return {
copy: hasText,
delete: hasText || hasAttachments,
cannedResponse: isOutgoing && hasText,
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
delete:
(hasText || hasAttachments) &&
!isFailedOrProcessing &&
!isMessageDeleted.value,
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
copyLink: !isFailedOrProcessing,
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
replyTo:
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
};
});
@@ -406,7 +421,7 @@ const avatarInfo = computed(() => {
const { name, type, avatarUrl, thumbnail } = sender || {};
// If sender type is agent bot, use avatarUrl
if (type === SENDER_TYPES.AGENT_BOT) {
if ([SENDER_TYPES.AGENT_BOT, SENDER_TYPES.CAPTAIN_ASSISTANT].includes(type)) {
return {
name: name ?? '',
src: avatarUrl ?? '',
@@ -491,8 +506,8 @@ provideMessageContext({
<div
class="[grid-area:bubble] flex"
:class="{
'ltr:pl-8 rtl:pr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:pr-8 rtl:pl-8': orientation === ORIENTATION.LEFT,
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}"
@contextmenu="openContextMenu($event)"
@@ -508,7 +523,7 @@ provideMessageContext({
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
<ContextMenu
v-if="isBubble && !isMessageDeleted"
v-if="isBubble"
:context-menu-position="contextMenuPosition"
:is-open="showContextMenu"
:enabled-options="contextMenuEnabledOptions"
@@ -40,7 +40,7 @@ const senderName = computed(() => {
<Icon :icon="icon" class="text-white size-4" />
</slot>
</div>
<div class="space-y-1">
<div class="space-y-1 overflow-hidden">
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
{{
t(senderTranslationKey, {
@@ -2,10 +2,10 @@
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import { useI18n } from 'vue-i18n';
import { CSAT_RATINGS } from 'shared/constants/messages';
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import { useMessageContext } from '../provider.js';
const { contentAttributes } = useMessageContext();
const { contentAttributes, content } = useMessageContext();
const { t } = useI18n();
const response = computed(() => {
@@ -16,6 +16,14 @@ const isRatingSubmitted = computed(() => {
return !!response.value.rating;
});
const displayType = computed(() => {
return contentAttributes.value?.displayType || CSAT_DISPLAY_TYPES.EMOJI;
});
const isStarRating = computed(() => {
return displayType.value === CSAT_DISPLAY_TYPES.STAR;
});
const rating = computed(() => {
if (isRatingSubmitted.value) {
return CSAT_RATINGS.find(
@@ -25,16 +33,33 @@ const rating = computed(() => {
return null;
});
const starRatingValue = computed(() => {
return response.value.rating || 0;
});
</script>
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
<h4>{{ t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<dl v-if="isRatingSubmitted" class="mt-4">
<dt class="text-n-slate-11 italic">
{{ t('CONVERSATION.RATING_TITLE') }}
</dt>
<dd>{{ t(rating.translationKey) }}</dd>
<dd v-if="!isStarRating">
{{ t(rating.translationKey) }}
</dd>
<dd v-else class="flex mt-1">
<span v-for="n in 5" :key="n" class="text-2xl mr-1">
<i
:class="[
n <= starRatingValue
? 'i-ri-star-fill text-n-amber-9'
: 'i-ri-star-line text-n-slate-10',
]"
/>
</span>
</dd>
<dt v-if="response.feedbackMessage" class="text-n-slate-11 italic mt-2">
{{ t('CONVERSATION.FEEDBACK_TITLE') }}
@@ -21,6 +21,7 @@ export const SENDER_TYPES = {
CONTACT: 'Contact',
USER: 'User',
AGENT_BOT: 'agent_bot',
CAPTAIN_ASSISTANT: 'captain_assistant',
};
export const ORIENTATION = {
@@ -185,6 +185,7 @@ watch(
"
trailing-icon
:disabled="disabled"
type="button"
class="!h-[1.875rem] top-1 ltr:ml-px rtl:mr-px !px-2 outline-0 !outline-none !rounded-lg border-0 ltr:!rounded-r-none rtl:!rounded-l-none"
@click="toggleCountryDropdown"
>
@@ -477,7 +477,7 @@ const menuItems = computed(() => {
<template>
<aside
class="w-[12.5rem] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
@@ -519,7 +519,7 @@ const menuItems = computed(() => {
</div>
</section>
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
<ul class="flex flex-col gap-2 m-0 list-none">
<ul class="flex flex-col gap-1.5 m-0 list-none">
<SidebarGroup
v-for="item in menuItems"
:key="item.name"
@@ -4,6 +4,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import wootConstants from 'dashboard/constants/globals';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import {
DropdownContainer,
@@ -20,6 +21,8 @@ const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
const currentAccountId = useMapGetter('getCurrentAccountId');
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
const { isImpersonating } = useImpersonation();
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
const statusList = computed(() => {
return [
@@ -46,6 +49,10 @@ const activeStatus = computed(() => {
});
function changeAvailabilityStatus(availability) {
if (isImpersonating.value) {
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
return;
}
try {
store.dispatch('updateAvailability', {
availability,