Merge branch 'develop' into feat/ui-lib

This commit is contained in:
Shivam Mishra
2026-02-09 16:51:26 +05:30
committed by GitHub
969 changed files with 30544 additions and 7332 deletions
@@ -47,7 +47,7 @@ const onToggle = () => {
</div>
<div class="flex flex-row">
<slot name="button" />
<div class="flex justify-end w-3 text-n-blue-text cursor-pointer">
<div class="flex justify-end w-3 text-n-blue-11 cursor-pointer">
<fluent-icon v-if="isOpen" size="24" icon="subtract" type="solid" />
<fluent-icon v-else size="24" icon="add" type="solid" />
</div>
@@ -55,7 +55,7 @@ const onToggle = () => {
</button>
<div
v-if="isOpen"
class="bg-n-background outline outline-1 outline-n-weak -mt-[-1px] border-t-0 rounded-br-lg rounded-bl-lg"
class="outline outline-1 outline-n-weak -mt-[-1px] border-t-0 rounded-br-lg rounded-bl-lg"
:class="compact ? 'p-0' : 'px-2 py-4'"
>
<slot />
@@ -32,6 +32,7 @@ import ConversationBulkActions from './widgets/conversation/conversationBulkActi
import IntersectionObserver from './IntersectionObserver.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
@@ -46,6 +47,7 @@ import {
} from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter';
import { useEventListener } from '@vueuse/core';
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
import { emitter } from 'shared/helpers/mitt';
@@ -87,6 +89,7 @@ const router = useRouter();
const route = useRoute();
const store = useStore();
const resolveAttributesModalRef = ref(null);
const conversationListRef = ref(null);
const conversationDynamicScroller = ref(null);
@@ -129,6 +132,7 @@ const labels = useMapGetter('labels/getLabels');
const currentAccountId = useMapGetter('getCurrentAccountId');
// We can't useFunctionGetter here since it needs to be called on setup?
const getTeamFn = useMapGetter('teams/getTeam');
const getConversationById = useMapGetter('getConversationById');
useChatListKeyboardEvents(conversationListRef);
const {
@@ -153,6 +157,8 @@ const {
attributeModel: 'conversation_attribute',
});
const { checkMissingAttributes } = useConversationRequiredAttributes();
// computed
const intersectionObserverOptions = computed(() => {
return {
@@ -729,22 +735,76 @@ async function onAssignTeam(team, conversationId = null) {
}
}
function toggleConversationStatus(conversationId, status, snoozedUntil) {
store
.dispatch('toggleStatus', {
conversationId,
status,
function toggleConversationStatus(
conversationId,
status,
snoozedUntil,
customAttributes = null
) {
const payload = {
conversationId,
status,
snoozedUntil,
};
if (customAttributes) {
payload.customAttributes = customAttributes;
}
store.dispatch('toggleStatus', payload).then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
});
}
function handleResolveConversation(conversationId, status, snoozedUntil) {
if (status !== wootConstants.STATUS_TYPE.RESOLVED) {
toggleConversationStatus(conversationId, status, snoozedUntil);
return;
}
// Check for required attributes before resolving
const conversation = getConversationById.value(conversationId);
const currentCustomAttributes = conversation?.custom_attributes || {};
const { hasMissing, missing } = checkMissingAttributes(
currentCustomAttributes
);
if (hasMissing) {
// Pass conversation context through the modal's API
const conversationContext = {
id: conversationId,
snoozedUntil,
})
.then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
});
};
resolveAttributesModalRef.value?.open(
missing,
currentCustomAttributes,
conversationContext
);
} else {
toggleConversationStatus(conversationId, status, snoozedUntil);
}
}
function handleResolveWithAttributes({ attributes, context }) {
if (context) {
const existingConversation = getConversationById.value(context.id);
const currentCustomAttributes =
existingConversation?.custom_attributes || {};
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
toggleConversationStatus(
context.id,
wootConstants.STATUS_TYPE.RESOLVED,
context.snoozedUntil,
mergedAttributes
);
}
}
function allSelectedConversationsStatus(status) {
if (!selectedConversations.value.length) return false;
return selectedConversations.value.every(item => {
return store.getters.getConversationById(item)?.status === status;
return getConversationById.value(item)?.status === status;
});
}
@@ -799,7 +859,7 @@ provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels);
provide('updateConversationStatus', toggleConversationStatus);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
@@ -842,7 +902,7 @@ watch(conversationFilters, (newVal, oldVal) => {
<template>
<div
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
@@ -895,7 +955,7 @@ watch(conversationFilters, (newVal, oldVal) => {
<p
v-if="!chatListLoading && !conversationList.length"
class="flex items-center justify-center p-4 overflow-auto"
class="flex overflow-auto justify-center items-center p-4"
>
{{ $t('CHAT_LIST.LIST.404') }}
</p>
@@ -915,14 +975,14 @@ watch(conversationFilters, (newVal, oldVal) => {
/>
<div
ref="conversationListRef"
class="flex-1 overflow-hidden conversations-list hover:overflow-y-auto"
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
:class="{ 'overflow-hidden': isContextMenuOpen }"
>
<DynamicScroller
ref="conversationDynamicScroller"
:items="conversationList"
:min-item-size="24"
class="w-full h-full overflow-auto"
class="overflow-auto w-full h-full"
>
<template #default="{ item, index, active }">
<!--
@@ -997,5 +1057,9 @@ watch(conversationFilters, (newVal, oldVal) => {
@close="closeAdvanceFiltersModal"
/>
</TeleportWithDirection>
<ConversationResolveAttributesModal
ref="resolveAttributesModalRef"
@submit="handleResolveWithAttributes"
/>
</div>
</template>
@@ -57,7 +57,7 @@ const toggleConversationLayout = () => {
<template>
<div
class="flex items-center justify-between gap-2 px-3 h-12"
class="flex items-center justify-between gap-2 px-3 h-[3.25rem]"
:class="{
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
}"
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useEmitter } from 'dashboard/composables/emitter';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -17,13 +18,16 @@ import {
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const { checkMissingAttributes } = useConversationRequiredAttributes();
const arrowDownButtonRef = ref(null);
const isLoading = ref(false);
const resolveAttributesModalRef = ref(null);
const [showActionsDropdown, toggleDropdown] = useToggle();
const closeDropdown = () => toggleDropdown(false);
@@ -77,19 +81,36 @@ const openSnoozeModal = () => {
ninja.open({ parent: 'snooze_conversation' });
};
const toggleStatus = (status, snoozedUntil) => {
const toggleStatus = (status, snoozedUntil, customAttributes = null) => {
closeDropdown();
isLoading.value = true;
store
.dispatch('toggleStatus', {
conversationId: currentChat.value.id,
status,
snoozedUntil,
})
.then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
isLoading.value = false;
});
const payload = {
conversationId: currentChat.value.id,
status,
snoozedUntil,
};
if (customAttributes) {
payload.customAttributes = customAttributes;
}
store.dispatch('toggleStatus', payload).then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
isLoading.value = false;
});
};
const handleResolveWithAttributes = ({ attributes, context }) => {
if (context) {
const currentCustomAttributes = currentChat.value.custom_attributes || {};
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
toggleStatus(
wootConstants.STATUS_TYPE.RESOLVED,
context.snoozedUntil,
mergedAttributes
);
}
};
const onCmdOpenConversation = () => {
@@ -97,7 +118,24 @@ const onCmdOpenConversation = () => {
};
const onCmdResolveConversation = () => {
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
const currentCustomAttributes = currentChat.value.custom_attributes || {};
const { hasMissing, missing } = checkMissingAttributes(
currentCustomAttributes
);
if (hasMissing) {
const conversationContext = {
id: currentChat.value.id,
snoozedUntil: null,
};
resolveAttributesModalRef.value?.open(
missing,
currentCustomAttributes,
conversationContext
);
} else {
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
}
};
const keyboardEvents = {
@@ -107,13 +145,13 @@ const keyboardEvents = {
},
'Alt+KeyE': {
action: async () => {
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
onCmdResolveConversation();
},
},
'$mod+Alt+KeyE': {
action: async event => {
const { all, activeIndex, lastIndex } = getConversationParams();
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
onCmdResolveConversation();
if (activeIndex < lastIndex) {
all[activeIndex + 1].click();
@@ -133,9 +171,9 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</script>
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div class="flex relative justify-end items-center resolve-actions">
<ButtonGroup
class="rounded-lg shadow outline-1 outline flex-shrink-0"
class="flex-shrink-0 rounded-lg shadow outline-1 outline"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
>
<Button
@@ -212,5 +250,9 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</WootDropdownItem>
</WootDropdownMenu>
</div>
<ConversationResolveAttributesModal
ref="resolveAttributesModalRef"
@submit="handleResolveWithAttributes"
/>
</div>
</template>
@@ -133,7 +133,7 @@ onMounted(() => {
<div
v-if="shouldShowCopilotPanel"
v-on-click-outside="() => closeCopilotPanel()"
class="bg-n-background h-full overflow-hidden flex-col fixed top-0 ltr:right-0 rtl:left-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
class="bg-n-surface-2 h-full overflow-hidden flex-col fixed top-0 ltr:right-0 rtl:left-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
'md:flex': shouldShowCopilotPanel,
@@ -1,11 +1,14 @@
<script setup>
import { ref, watch } from 'vue';
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
getActiveDateRange,
moveCalendarDate,
DATE_RANGE_TYPES,
CALENDAR_TYPES,
CALENDAR_PERIODS,
isNavigableRange,
getRangeAtOffset,
} from './helpers/DatePickerHelper';
import {
isValid,
@@ -13,14 +16,14 @@ import {
subDays,
startOfDay,
endOfDay,
isBefore,
subMonths,
addMonths,
isSameMonth,
differenceInCalendarMonths,
differenceInCalendarWeeks,
setMonth,
setYear,
isAfter,
getWeek,
} from 'date-fns';
import { useAlert } from 'dashboard/composables';
import DatePickerButton from './components/DatePickerButton.vue';
@@ -32,98 +35,187 @@ import CalendarWeek from './components/CalendarWeek.vue';
import CalendarFooter from './components/CalendarFooter.vue';
const emit = defineEmits(['dateRangeChanged']);
const { LAST_7_DAYS, LAST_30_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
const { t } = useI18n();
const dateRange = defineModel('dateRange', {
type: Array,
default: undefined,
});
const rangeType = defineModel('rangeType', {
type: String,
default: undefined,
});
const { LAST_7_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
const showDatePicker = ref(false);
const calendarViews = ref({ start: WEEK, end: WEEK });
const currentDate = ref(new Date());
const selectedStartDate = ref(startOfDay(subDays(currentDate.value, 6))); // LAST_7_DAYS
const selectedEndDate = ref(endOfDay(currentDate.value));
// Setting the start and end calendar
const startCurrentDate = ref(startOfDay(selectedStartDate.value));
// Use dates from v-model if provided, otherwise default to last 7 days
const selectedStartDate = ref(
dateRange.value?.[0]
? startOfDay(dateRange.value[0])
: startOfDay(subDays(currentDate.value, 6)) // LAST_7_DAYS
);
const selectedEndDate = ref(
dateRange.value?.[1]
? endOfDay(dateRange.value[1])
: endOfDay(currentDate.value)
);
// Calendar month positioning (left and right calendars)
// These control which months are displayed in the dual calendar view
const startCurrentDate = ref(startOfMonth(selectedStartDate.value));
const endCurrentDate = ref(
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Moves to the start of the next month if dates are in the same month (Mounted case LAST_7_DAYS)
: startOfMonth(selectedEndDate.value) // Always shows the month of the end date starting from the first (Mounted case LAST_7_DAYS)
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Same month: show next month on right (e.g., Jan 25-31 shows Jan + Feb)
: startOfMonth(selectedEndDate.value) // Different months: show end month on right (e.g., Dec 5 - Jan 3 shows Dec + Jan)
);
const selectingEndDate = ref(false);
const selectedRange = ref(LAST_7_DAYS);
const selectedRange = ref(rangeType.value || LAST_7_DAYS);
const hoveredEndDate = ref(null);
const monthOffset = ref(0);
const showMonthNavigation = computed(() =>
isNavigableRange(selectedRange.value)
);
const canNavigateNext = computed(() => {
if (!isNavigableRange(selectedRange.value)) return false;
// Compare selected start to the current period's start to determine if we're in the past
const currentRange = getActiveDateRange(
selectedRange.value,
currentDate.value
);
return selectedStartDate.value < currentRange.start;
});
const navigationLabel = computed(() => {
const range = selectedRange.value;
if (range === DATE_RANGE_TYPES.MONTH_TO_DATE) {
return new Intl.DateTimeFormat(navigator.language, {
month: 'long',
}).format(selectedStartDate.value);
}
if (range === DATE_RANGE_TYPES.THIS_WEEK) {
const currentWeekRange = getActiveDateRange(range, currentDate.value);
const isCurrentWeek =
selectedStartDate.value.getTime() === currentWeekRange.start.getTime();
if (isCurrentWeek) return null;
const weekNumber = getWeek(selectedStartDate.value, { weekStartsOn: 1 });
return t('DATE_PICKER.WEEK_NUMBER', { weekNumber });
}
return null;
});
const manualStartDate = ref(selectedStartDate.value);
const manualEndDate = ref(selectedEndDate.value);
// Watcher will set the start and end dates based on the selected range
watch(selectedRange, newRange => {
if (newRange !== CUSTOM_RANGE) {
// If selecting a range other than last 7 days or last 30 days, set the start and end dates to the selected start and end dates
// If selecting last 7 days or last 30 days is, set the start date to the selected start date
// and the end date to one month ahead of the start date if the start date and end date are in the same month
// Otherwise set the end date to the selected end date
const isLastSevenOrThirtyDays =
newRange === LAST_7_DAYS || newRange === LAST_30_DAYS;
startCurrentDate.value = selectedStartDate.value;
endCurrentDate.value =
isLastSevenOrThirtyDays &&
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedStartDate.value, 1))
: selectedEndDate.value;
selectingEndDate.value = false;
} else if (!selectingEndDate.value) {
// If selecting a custom range and not selecting an end date, set the start date to the selected start date
startCurrentDate.value = startOfDay(currentDate.value);
}
});
// Watcher will set the input values based on the selected start and end dates
// Watcher 1: Sync v-model props from parent component
// Handles: URL params, parent component updates, rangeType changes
watch(
[selectedStartDate, selectedEndDate],
([newStart, newEnd]) => {
if (isValid(newStart)) {
manualStartDate.value = newStart;
} else {
manualStartDate.value = selectedStartDate.value;
[rangeType, dateRange],
([newRangeType, newDateRange]) => {
if (newRangeType && newRangeType !== selectedRange.value) {
selectedRange.value = newRangeType;
monthOffset.value = 0;
// If rangeType changes without dateRange, recompute dates from the range
if (!newDateRange && newRangeType !== CUSTOM_RANGE) {
const activeDates = getActiveDateRange(newRangeType, currentDate.value);
if (activeDates) {
selectedStartDate.value = startOfDay(activeDates.start);
selectedEndDate.value = endOfDay(activeDates.end);
}
}
}
if (isValid(newEnd)) {
manualEndDate.value = newEnd;
} else {
manualEndDate.value = selectedEndDate.value;
// When parent provides new dateRange (e.g., from URL params)
// Skip if navigating with arrows — offset controls dates in that case
if (newDateRange?.[0] && newDateRange?.[1] && monthOffset.value === 0) {
selectedStartDate.value = startOfDay(newDateRange[0]);
selectedEndDate.value = endOfDay(newDateRange[1]);
// Update calendar to show the months of the new date range
startCurrentDate.value = startOfMonth(newDateRange[0]);
endCurrentDate.value = isSameMonth(newDateRange[0], newDateRange[1])
? startOfMonth(addMonths(newDateRange[1], 1))
: startOfMonth(newDateRange[1]);
// Recalculate offset so arrow navigation is relative to restored range
// TODO: When offset resolves to 0 (current period), the end date may be
// stale if the URL was saved on a previous day. "This month" / "This week"
// should show up-to-today dates for the current period. For now, the stale
// end date is shown until the user clicks an arrow or re-selects the range.
if (isNavigableRange(selectedRange.value)) {
const current = getActiveDateRange(
selectedRange.value,
currentDate.value
);
if (selectedRange.value === DATE_RANGE_TYPES.THIS_WEEK) {
monthOffset.value = differenceInCalendarWeeks(
newDateRange[0],
current.start,
{ weekStartsOn: 1 }
);
} else {
monthOffset.value = differenceInCalendarMonths(
newDateRange[0],
current.start
);
}
}
}
},
{ immediate: true }
);
// Watcher to ensure dates are always in logical order
// This watch is will ensure that the start date is always before the end date
// Watcher 2: Keep manual input fields in sync with selected dates
// Updates the input field values when dates change programmatically
watch(
[startCurrentDate, endCurrentDate],
([newStart, newEnd], [oldStart, oldEnd]) => {
const monthDifference = differenceInCalendarMonths(newEnd, newStart);
if (newStart !== oldStart) {
if (isAfter(newStart, newEnd) || monthDifference === 0) {
// Adjust the end date forward if the start date is adjusted and is after the end date or in the same month
endCurrentDate.value = addMonths(newStart, 1);
}
}
if (newEnd !== oldEnd) {
if (isBefore(newEnd, newStart) || monthDifference === 0) {
// Adjust the start date backward if the end date is adjusted and is before the start date or in the same month
startCurrentDate.value = subMonths(newEnd, 1);
}
}
[selectedStartDate, selectedEndDate],
([newStart, newEnd]) => {
manualStartDate.value = isValid(newStart)
? newStart
: selectedStartDate.value;
manualEndDate.value = isValid(newEnd) ? newEnd : selectedEndDate.value;
},
{ immediate: true, deep: true }
{ immediate: true }
);
const setDateRange = range => {
selectedRange.value = range.value;
monthOffset.value = 0;
const { start, end } = getActiveDateRange(range.value, currentDate.value);
selectedStartDate.value = start;
selectedEndDate.value = end;
// Position calendar to show the months of the selected range
startCurrentDate.value = startOfMonth(start);
endCurrentDate.value = isSameMonth(start, end)
? startOfMonth(addMonths(start, 1))
: startOfMonth(end);
};
const navigateMonth = direction => {
monthOffset.value += direction === 'prev' ? -1 : 1;
if (monthOffset.value > 0) monthOffset.value = 0;
const { start, end } = getRangeAtOffset(
selectedRange.value,
monthOffset.value,
currentDate.value
);
selectedStartDate.value = start;
selectedEndDate.value = end;
startCurrentDate.value = startOfMonth(start);
endCurrentDate.value = isSameMonth(start, end)
? startOfMonth(addMonths(start, 1))
: startOfMonth(end);
emit('dateRangeChanged', [start, end, selectedRange.value]);
};
const moveCalendar = (calendar, direction, period = MONTH) => {
@@ -134,12 +226,27 @@ const moveCalendar = (calendar, direction, period = MONTH) => {
direction,
period
);
startCurrentDate.value = start;
endCurrentDate.value = end;
// Prevent calendar months from overlapping
const monthDiff = differenceInCalendarMonths(end, start);
if (monthDiff === 0) {
// If they would be the same month, adjust the other calendar
if (calendar === START_CALENDAR) {
endCurrentDate.value = addMonths(start, 1);
startCurrentDate.value = start;
} else {
startCurrentDate.value = subMonths(end, 1);
endCurrentDate.value = end;
}
} else {
startCurrentDate.value = start;
endCurrentDate.value = end;
}
};
const selectDate = day => {
selectedRange.value = CUSTOM_RANGE;
monthOffset.value = 0;
if (!selectingEndDate.value || day < selectedStartDate.value) {
selectedStartDate.value = day;
selectedEndDate.value = null;
@@ -175,10 +282,10 @@ const openCalendar = (index, calendarType, period = MONTH) => {
const updateManualInput = (newDate, calendarType) => {
if (calendarType === START_CALENDAR) {
selectedStartDate.value = newDate;
startCurrentDate.value = newDate;
startCurrentDate.value = startOfMonth(newDate);
} else {
selectedEndDate.value = newDate;
endCurrentDate.value = newDate;
endCurrentDate.value = startOfMonth(newDate);
}
selectingEndDate.value = false;
};
@@ -188,13 +295,22 @@ const handleManualInputError = message => {
};
const resetDatePicker = () => {
startCurrentDate.value = startOfDay(currentDate.value); // Resets to today at start of the day
endCurrentDate.value = addMonths(startOfDay(currentDate.value), 1); // Resets to one month ahead
selectedStartDate.value = startOfDay(subDays(currentDate.value, 6));
selectedEndDate.value = endOfDay(currentDate.value);
// Calculate Last 7 days from today
const startDate = startOfDay(subDays(currentDate.value, 6));
const endDate = endOfDay(currentDate.value);
selectedStartDate.value = startDate;
selectedEndDate.value = endDate;
// Position calendar to show the months of Last 7 days
// Example: If today is Feb 5, Last 7 days = Jan 30 - Feb 5, so show Jan + Feb
startCurrentDate.value = startOfMonth(startDate);
endCurrentDate.value = isSameMonth(startDate, endDate)
? startOfMonth(addMonths(startDate, 1))
: startOfMonth(endDate);
selectingEndDate.value = false;
selectedRange.value = LAST_7_DAYS;
// Reset view modes if they are being used to toggle between different calendar views
monthOffset.value = 0;
calendarViews.value = { start: WEEK, end: WEEK };
};
@@ -203,22 +319,58 @@ const emitDateRange = () => {
useAlert('Please select a valid time range');
} else {
showDatePicker.value = false;
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
emit('dateRangeChanged', [
selectedStartDate.value,
selectedEndDate.value,
selectedRange.value,
]);
}
};
// Called when picker opens - positions calendar to show selected date range
// Fixes issue where calendar showed wrong months when loaded from URL params
const initializeCalendarMonths = () => {
if (selectedStartDate.value && selectedEndDate.value) {
startCurrentDate.value = startOfMonth(selectedStartDate.value);
endCurrentDate.value = isSameMonth(
selectedStartDate.value,
selectedEndDate.value
)
? startOfMonth(addMonths(selectedEndDate.value, 1))
: startOfMonth(selectedEndDate.value);
}
};
const toggleDatePicker = () => {
showDatePicker.value = !showDatePicker.value;
if (showDatePicker.value) initializeCalendarMonths();
};
const closeDatePicker = () => {
if (isValid(selectedStartDate.value) && isValid(selectedEndDate.value)) {
emitDateRange();
} else {
showDatePicker.value = false;
}
};
</script>
<template>
<div class="relative font-inter">
<div class="relative flex-shrink-0 font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selected-range="selectedRange"
@open="showDatePicker = !showDatePicker"
:show-month-navigation="showMonthNavigation"
:can-navigate-next="canNavigateNext"
:navigation-label="navigationLabel"
@open="toggleDatePicker"
@navigate-month="navigateMonth"
/>
<div
v-if="showDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
v-on-clickaway="closeDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
>
<CalendarDateRange
:selected-range="selectedRange"
@@ -46,13 +46,13 @@ const onClickSetView = (type, mode) => {
xs
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
@click="onClickPrev(calendarType)"
@click.stop="onClickPrev(calendarType)"
/>
<div class="flex items-center gap-1">
<button
v-if="firstButtonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
@click="onClickSetView(calendarType, viewMode)"
@click.stop="onClickSetView(calendarType, viewMode)"
>
{{ firstButtonLabel }}
</button>
@@ -60,7 +60,7 @@ const onClickSetView = (type, mode) => {
v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12"
:class="{ 'hover:text-n-brand': viewMode }"
@click="onClickSetView(calendarType, YEAR)"
@click.stop="onClickSetView(calendarType, YEAR)"
>
{{ buttonLabel }}
</button>
@@ -71,7 +71,7 @@ const onClickSetView = (type, mode) => {
xs
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
@click="onClickNext(calendarType)"
@click.stop="onClickNext(calendarType)"
/>
</div>
</template>
@@ -18,24 +18,25 @@ const setDateRange = range => {
<template>
<div class="w-[200px] flex flex-col items-start">
<h4
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12"
class="w-full px-5 py-4 text-xs font-bold capitalize text-start text-n-slate-10"
>
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4>
<div class="flex flex-col items-start w-full">
<button
v-for="range in dateRanges"
:key="range.label"
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
:class="
range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12'
"
@click="setDateRange(range)"
>
{{ $t(range.label) }}
</button>
<template v-for="range in dateRanges" :key="range.label">
<div v-if="range.separator" class="w-full border-t border-n-strong" />
<button
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
:class="
range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12'
"
@click="setDateRange(range)"
>
{{ $t(range.label) }}
</button>
</template>
</div>
</div>
</template>
@@ -23,7 +23,6 @@ const onClickApply = () => {
/>
<NextButton
sm
ghost
:label="$t('DATE_PICKER.APPLY_BUTTON')"
@click="onClickApply"
/>
@@ -78,7 +78,7 @@ const selectMonth = index => {
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
index !== activeMonthIndex,
}"
@click="selectMonth(index)"
@click.stop="selectMonth(index)"
>
{{ month }}
</button>
@@ -116,7 +116,7 @@ const dayClasses = day => ({
(isInRange(day) || isHoveringInRange(day)) &&
!isSelectedStartOrEndDate(day) &&
isInCurrentMonth(day),
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-text':
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-11':
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
});
</script>
@@ -77,7 +77,7 @@ const selectYear = year => {
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear,
}"
@click="selectYear(year)"
@click.stop="selectYear(year)"
>
{{ year }}
</button>
@@ -2,6 +2,8 @@
import { computed } from 'vue';
import { dateRanges } from '../helpers/DatePickerHelper';
import { format, isSameYear, isValid } from 'date-fns';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
selectedStartDate: Date,
@@ -10,9 +12,21 @@ const props = defineProps({
type: String,
default: '',
},
showMonthNavigation: {
type: Boolean,
default: false,
},
canNavigateNext: {
type: Boolean,
default: false,
},
navigationLabel: {
type: String,
default: null,
},
});
const emit = defineEmits(['open']);
const emit = defineEmits(['open', 'navigateMonth']);
const formatDateRange = computed(() => {
const startDate = props.selectedStartDate;
@@ -22,19 +36,15 @@ const formatDateRange = computed(() => {
return 'Select a date range';
}
const formatString = isSameYear(startDate, endDate)
? 'MMM d' // Same year: "Apr 1"
: 'MMM d yyyy'; // Different years: "Apr 1 2025"
const crossesYears = !isSameYear(startDate, endDate);
if (isSameYear(startDate, new Date()) && isSameYear(endDate, new Date())) {
// Both dates are in the current year
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
// Always show years when crossing year boundaries
if (crossesYears) {
return `${format(startDate, 'MMM d, yyyy')} - ${format(endDate, 'MMM d, yyyy')}`;
}
// At least one date is not in the current year
return `${format(startDate, formatString)} - ${format(
endDate,
formatString
)}`;
// For same year, always show the year for clarity
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`;
});
const activeDateRange = computed(
@@ -47,17 +57,46 @@ const openDatePicker = () => {
</script>
<template>
<button
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1"
@click="openDatePicker"
>
<fluent-icon class="text-n-slate-12" icon="calendar" size="16" />
<span class="text-sm font-medium text-n-slate-12">
{{ $t(activeDateRange) }}
</span>
<span class="text-sm font-medium text-n-slate-11">
{{ formatDateRange }}
</span>
<fluent-icon class="text-n-slate-12" icon="chevron-down" size="14" />
</button>
<div class="inline-flex items-center gap-1">
<button
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1 flex-shrink-0"
@click="openDatePicker"
>
<Icon
icon="i-lucide-calendar-range"
class="text-n-slate-11 size-3.5 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12 truncate">
{{ navigationLabel || $t(activeDateRange) }}
</span>
<span class="text-sm font-medium text-n-slate-11 truncate">
{{ formatDateRange }}
</span>
<Icon
icon="i-lucide-chevron-down"
class="text-n-slate-12 size-4 flex-shrink-0"
/>
</button>
<NextButton
v-if="showMonthNavigation"
v-tooltip.top="$t('DATE_PICKER.PREVIOUS_PERIOD')"
slate
faded
sm
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
@click="emit('navigateMonth', 'prev')"
/>
<NextButton
v-if="showMonthNavigation"
v-tooltip.top="$t('DATE_PICKER.NEXT_PERIOD')"
slate
faded
sm
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
:disabled="!canNavigateNext"
@click="emit('navigateMonth', 'next')"
/>
</div>
</template>
@@ -10,6 +10,8 @@ import {
isSameMonth,
format,
startOfWeek,
endOfWeek,
addWeeks,
addDays,
eachDayOfInterval,
endOfMonth,
@@ -34,13 +36,27 @@ export const dateRanges = [
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
value: 'last3months',
separator: true,
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
value: 'last6months',
},
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_YEAR', value: 'lastYear' },
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE', value: 'custom' },
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.THIS_WEEK',
value: 'thisWeek',
separator: true,
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.MONTH_TO_DATE',
value: 'monthToDate',
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE',
value: 'custom',
separator: true,
},
];
export const DATE_RANGE_TYPES = {
@@ -49,6 +65,8 @@ export const DATE_RANGE_TYPES = {
LAST_3_MONTHS: 'last3months',
LAST_6_MONTHS: 'last6months',
LAST_YEAR: 'lastYear',
THIS_WEEK: 'thisWeek',
MONTH_TO_DATE: 'monthToDate',
CUSTOM_RANGE: 'custom',
};
@@ -210,6 +228,14 @@ export const getActiveDateRange = (range, currentDate) => {
start: startOfDay(subMonths(currentDate, 12)),
end: endOfDay(currentDate),
}),
thisWeek: () => ({
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: 1 })),
end: endOfDay(currentDate),
}),
monthToDate: () => ({
start: startOfDay(startOfMonth(currentDate)),
end: endOfDay(currentDate),
}),
custom: () => ({ start: currentDate, end: currentDate }),
};
@@ -217,3 +243,48 @@ export const getActiveDateRange = (range, currentDate) => {
ranges[range] || (() => ({ start: currentDate, end: currentDate }))
)();
};
export const isNavigableRange = rangeType =>
rangeType === DATE_RANGE_TYPES.MONTH_TO_DATE ||
rangeType === DATE_RANGE_TYPES.THIS_WEEK;
const WEEK_START = 1; // Monday
const getWeekRangeAtOffset = (offset, currentDate) => {
if (offset === 0) {
return {
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: WEEK_START })),
end: endOfDay(currentDate),
};
}
const targetWeek = addWeeks(currentDate, offset);
return {
start: startOfDay(startOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
end: endOfDay(endOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
};
};
const getMonthRangeAtOffset = (offset, currentDate) => {
if (offset === 0) {
return {
start: startOfDay(startOfMonth(currentDate)),
end: endOfDay(currentDate),
};
}
const targetMonth = addMonths(currentDate, offset);
return {
start: startOfDay(startOfMonth(targetMonth)),
end: endOfDay(endOfMonth(targetMonth)),
};
};
export const getRangeAtOffset = (
rangeType,
offset,
currentDate = new Date()
) => {
if (rangeType === DATE_RANGE_TYPES.THIS_WEEK) {
return getWeekRangeAtOffset(offset, currentDate);
}
return getMonthRangeAtOffset(offset, currentDate);
};
@@ -34,8 +34,8 @@ const value = defineModel({
<input
v-model="value"
:placeholder="inputPlaceholder"
type="text"
class="w-full mb-0 text-sm !outline-0 bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
type="search"
class="w-full mb-0 text-sm !outline-0 !outline-none bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
/>
</div>
<!-- Clear filter button -->
@@ -47,23 +47,23 @@ const onTabClick = event => {
class="flex-shrink-0 my-0 mx-2 ltr:first:ml-0 rtl:first:mr-0 ltr:last:mr-0 rtl:last:ml-0 hover:text-n-slate-12"
>
<a
class="flex items-center flex-row border-b select-none cursor-pointer text-sm relative top-[1px] transition-[border-color] duration-[150ms] ease-[cubic-bezier(0.37,0,0.63,1)]"
class="flex items-center flex-row select-none cursor-pointer relative after:absolute after:bottom-px after:left-0 after:right-0 after:h-[2px] after:rounded-full after:transition-all after:duration-200 text-button"
:class="[
active
? 'border-b border-n-brand text-n-blue-text'
: 'border-transparent text-n-slate-11',
isCompact ? 'py-2 text-sm' : 'text-base py-3',
? 'text-n-blue-11 after:bg-n-brand after:opacity-100'
: 'text-n-slate-11 after:bg-transparent after:opacity-0',
isCompact ? 'py-2.5' : '!text-base py-3',
]"
@click="onTabClick"
>
{{ name }}
<div
v-if="showBadge"
class="rounded-md h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0 min-w-[20px]"
class="rounded-full h-5 flex items-center justify-center text-xs font-medium my-0 ltr:ml-1 rtl:mr-1 px-1.5 py-0 min-w-[20px]"
:class="[
active
? 'bg-n-brand/10 dark:bg-n-brand/20 text-n-blue-text'
: 'bg-n-alpha-black2 dark:bg-n-solid-3 text-n-slate-11',
? 'bg-n-blue-3 text-n-blue-11'
: 'bg-n-alpha-1 text-n-slate-10',
]"
>
<span>
@@ -1,160 +0,0 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import AICTAModal from './AICTAModal.vue';
import AIAssistanceModal from './AIAssistanceModal.vue';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
AIAssistanceModal,
AICTAModal,
AIAssistanceCTAButton,
},
emits: ['replaceText'],
setup(props, { emit }) {
const { uiSettings, updateUISettings } = useUISettings();
const { isAIIntegrationEnabled, draftMessage, recordAnalytics } = useAI();
const { isAdmin } = useAdmin();
const initialMessage = ref('');
const initializeMessage = draftMsg => {
initialMessage.value = draftMsg;
};
const keyboardEvents = {
'$mod+KeyZ': {
action: () => {
if (initialMessage.value) {
emit('replaceText', initialMessage.value);
initialMessage.value = '';
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
uiSettings,
updateUISettings,
isAdmin,
initialMessage,
initializeMessage,
recordAnalytics,
isAIIntegrationEnabled,
draftMessage,
};
},
data: () => ({
showAIAssistanceModal: false,
showAICtaModal: false,
aiOption: '',
}),
computed: {
...mapGetters({
isAChatwootInstance: 'globalConfig/isAChatwootInstance',
}),
isAICTAModalDismissed() {
return this.uiSettings.is_open_ai_cta_modal_dismissed;
},
// Display a AI CTA button for admins if the AI integration has not been added yet and the AI assistance modal has not been dismissed.
shouldShowAIAssistCTAButtonForAdmin() {
return (
this.isAdmin &&
!this.isAIIntegrationEnabled &&
!this.isAICTAModalDismissed &&
this.isAChatwootInstance
);
},
// Display a AI CTA button for agents and other admins who have not yet opened the AI assistance modal.
shouldShowAIAssistCTAButton() {
return this.isAIIntegrationEnabled && !this.isAICTAModalDismissed;
},
},
mounted() {
emitter.on(CMD_AI_ASSIST, this.onAIAssist);
this.initializeMessage(this.draftMessage);
},
methods: {
hideAIAssistanceModal() {
this.recordAnalytics('DISMISS_AI_SUGGESTION', {
aiOption: this.aiOption,
});
this.showAIAssistanceModal = false;
},
openAIAssist() {
// Dismiss the CTA modal if it is not dismissed
if (!this.isAICTAModalDismissed) {
this.updateUISettings({
is_open_ai_cta_modal_dismissed: true,
});
}
this.initializeMessage(this.draftMessage);
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'ai_assist' });
},
hideAICtaModal() {
this.showAICtaModal = false;
},
openAICta() {
this.showAICtaModal = true;
},
onAIAssist(option) {
this.aiOption = option;
this.showAIAssistanceModal = true;
},
insertText(message) {
this.$emit('replaceText', message);
},
},
};
</script>
<template>
<div>
<div v-if="isAIIntegrationEnabled" class="relative">
<AIAssistanceCTAButton
v-if="shouldShowAIAssistCTAButton"
@open="openAIAssist"
/>
<NextButton
v-else
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="i-ph-magic-wand"
slate
faded
sm
@click="openAIAssist"
/>
<woot-modal
v-model:show="showAIAssistanceModal"
:on-close="hideAIAssistanceModal"
>
<AIAssistanceModal
:ai-option="aiOption"
@apply-text="insertText"
@close="hideAIAssistanceModal"
/>
</woot-modal>
</div>
<div v-else-if="shouldShowAIAssistCTAButtonForAdmin" class="relative">
<AIAssistanceCTAButton @click="openAICta" />
<woot-modal v-model:show="showAICtaModal" :on-close="hideAICtaModal">
<AICTAModal @close="hideAICtaModal" />
</woot-modal>
</div>
</div>
</template>
@@ -1,103 +0,0 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['open']);
const onClick = () => {
emit('open');
};
</script>
<template>
<div class="relative">
<NextButton
class="cta-btn cta-btn-light dark:cta-btn-dark hover:cta-btn-light-hover dark:hover:cta-btn-dark-hover"
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="i-ph-magic-wand"
sm
@click="onClick"
/>
<div
class="radar-ping-animation absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand"
/>
<div
class="absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand opacity-50"
/>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
/* Gradient animation */
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.cta-btn {
animation: gradient 5s ease infinite;
@apply text-n-slate-12 border-0 text-xs;
}
.cta-btn-light {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #f2f5f8, #f2f5f8);
}
.cta-btn-dark {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #313538, #313538);
}
.cta-btn-light-hover {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #e3e5e7, #e3e5e7);
}
.cta-btn-dark-hover {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #202425, #202425);
}
/* Radar ping animation */
@keyframes ping {
75%,
100% {
transform: scale(2);
opacity: 0;
}
}
.radar-ping-animation {
animation: ping 1s ease infinite;
}
}
</style>
@@ -1,118 +0,0 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useAI } from 'dashboard/composables/useAI';
import AILoader from './AILoader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
AILoader,
NextButton,
},
props: {
aiOption: {
type: String,
required: true,
},
},
emits: ['close', 'applyText'],
setup() {
const { formatMessage } = useMessageFormatter();
const { draftMessage, processEvent, recordAnalytics } = useAI();
return { draftMessage, processEvent, recordAnalytics, formatMessage };
},
data() {
return {
generatedContent: '',
isGenerating: true,
};
},
computed: {
headerTitle() {
const translationKey = this.aiOption?.toUpperCase();
return translationKey
? this.$t(`INTEGRATION_SETTINGS.OPEN_AI.WITH_AI`, {
option: this.$t(
`INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.${translationKey}`
),
})
: '';
},
},
mounted() {
this.generateAIContent(this.aiOption);
},
methods: {
onClose() {
this.$emit('close');
},
async generateAIContent(type = 'rephrase') {
this.isGenerating = true;
this.generatedContent = await this.processEvent(type);
this.isGenerating = false;
},
applyText() {
this.recordAnalytics(this.aiOption);
this.$emit('applyText', this.generatedContent);
this.onClose();
},
},
};
</script>
<template>
<div class="flex flex-col">
<woot-modal-header :header-title="headerTitle" />
<form
class="flex flex-col w-full modal-content"
@submit.prevent="applyText"
>
<div v-if="draftMessage" class="w-full">
<h4 class="mt-1 text-base text-n-slate-12">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.DRAFT_TITLE') }}
</h4>
<p v-dompurify-html="formatMessage(draftMessage, false)" />
<h4 class="mt-1 text-base text-n-slate-12">
{{
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.GENERATED_TITLE')
}}
</h4>
</div>
<div>
<AILoader v-if="isGenerating" />
<p v-else v-dompurify-html="formatMessage(generatedContent, false)" />
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.CANCEL')
"
@click.prevent="onClose"
/>
<NextButton
type="submit"
:disabled="!generatedContent"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.APPLY')
"
/>
</div>
</form>
</div>
</template>
<style lang="scss" scoped>
.modal-content {
@apply pt-2 px-8 pb-8;
}
.container {
width: 100%;
}
</style>
@@ -1,130 +0,0 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAI } from 'dashboard/composables/useAI';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['close'],
setup() {
const { updateUISettings } = useUISettings();
const { recordAnalytics } = useAI();
const v$ = useVuelidate();
return { updateUISettings, v$, recordAnalytics };
},
data() {
return {
value: '',
};
},
validations: {
value: {
required,
},
},
methods: {
onClose() {
this.$emit('close');
},
onDismiss() {
useAlert(
this.$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.DISMISS_MESSAGE')
);
this.updateUISettings({
is_open_ai_cta_modal_dismissed: true,
});
this.onClose();
},
async finishOpenAI() {
const payload = {
app_id: 'openai',
settings: {
api_key: this.value,
},
};
try {
await this.$store.dispatch('integrations/createHook', payload);
this.alertMessage = this.$t(
'INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.SUCCESS_MESSAGE'
);
this.recordAnalytics(
OPEN_AI_EVENTS.ADDED_AI_INTEGRATION_VIA_CTA_BUTTON
);
this.onClose();
} catch (error) {
const errorMessage = error?.response?.data?.message;
this.alertMessage =
errorMessage || this.$t('INTEGRATION_APPS.ADD.API.ERROR_MESSAGE');
} finally {
useAlert(this.alertMessage);
}
},
openOpenAIDoc() {
window.open('https://www.chatwoot.com/blog/v2-17', '_blank');
},
},
};
</script>
<template>
<div class="flex-1 min-w-0 px-0">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.TITLE')"
:header-content="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.DESC')"
/>
<form
class="flex flex-col flex-wrap modal-content"
@submit.prevent="finishOpenAI"
>
<div class="w-full mt-2">
<woot-input
v-model="value"
type="text"
:class="{ error: v$.value.$error }"
:placeholder="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.KEY_PLACEHOLDER')
"
@blur="v$.value.$touch"
/>
</div>
<div class="flex flex-row justify-between w-full gap-2 px-0 py-2">
<NextButton
ghost
type="button"
class="!px-3"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.NEED_HELP')
"
@click.prevent="openOpenAIDoc"
/>
<div class="flex items-center gap-1">
<NextButton
faded
slate
type="reset"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.DISMISS')
"
@click.prevent="onDismiss"
/>
<NextButton
type="submit"
:disabled="v$.value.$invalid"
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.FINISH')"
/>
</div>
</div>
</form>
</div>
</template>
@@ -46,11 +46,11 @@ const fileName = file => {
</script>
<template>
<div class="flex overflow-auto max-h-[12.5rem]">
<div class="flex flex-wrap gap-y-1 gap-x-2 overflow-auto max-h-[12.5rem]">
<div
v-for="(attachment, index) in nonRecordedAudioAttachments"
:key="attachment.id"
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem] mb-1"
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem]"
>
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img
@@ -48,7 +48,7 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0"
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0 h-10"
@change="onTabChange"
>
<woot-tabs-item
@@ -38,7 +38,7 @@ const toggleShowMore = () => {
<button
v-if="text.length > limit"
class="text-n-brand !p-0 !border-0 align-top"
@click="toggleShowMore"
@click.stop="toggleShowMore"
>
{{ buttonLabel }}
</button>
@@ -0,0 +1,253 @@
<script setup>
import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
import {
buildMessageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
modelValue: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: {
type: String,
default: 'Give copilot additional prompts, or ask anything else...',
},
generatedContent: { type: String, default: '' },
autofocus: {
type: Boolean,
default: true,
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'blur',
'input',
'update:modelValue',
'keyup',
'focus',
'keydown',
'send',
]);
const { formatMessage } = useMessageFormatter();
// Minimal schema with no marks or nodes for copilot input
const copilotSchema = buildMessageSchema([], []);
const handleSubmit = () => emit('send');
const createState = (
content,
placeholder,
plugins = [],
enabledMenuOptions = []
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(copilotSchema).parse(content),
plugins: buildEditor({
schema: copilotSchema,
placeholder,
plugins,
enabledMenuOptions,
}),
});
};
// we don't need them to be reactive
// It cases weird issues where the objects are proxied
// and then the editor doesn't work as expected
let editorView = null;
let state = null;
// reactive data
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
// element refs
const editor = useTemplateRef('editor');
function contentFromEditor() {
if (editorView) {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
}
return '';
}
function focusEditorInputField() {
const { tr } = editorView.state;
const selection = Selection.atEnd(tr.doc);
editorView.dispatch(tr.setSelection(selection));
editorView.focus();
}
function emitOnChange() {
emit('update:modelValue', contentFromEditor());
emit('input', contentFromEditor());
}
function onKeyup() {
emit('keyup');
}
function onKeydown(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling
}
return false; // Allow other keys to work normally
}
function onBlur() {
emit('blur');
}
function onFocus() {
emit('focus');
}
function checkSelection(editorState) {
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
}
// computed properties
const plugins = computed(() => {
return [];
});
const enabledMenuOptions = computed(() => {
return [];
});
function reloadState() {
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
enabledMenuOptions.value
);
editorView.updateState(state);
focusEditorInputField();
}
function createEditorView() {
editorView = new EditorView(editor.value, {
state: state,
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
if (tx.docChanged) {
emitOnChange();
}
checkSelection(state);
},
handleDOMEvents: {
keyup: onKeyup,
focus: onFocus,
blur: onBlur,
keydown: onKeydown,
},
});
}
// watchers
watch(
computed(() => props.modelValue),
(newValue = '') => {
if (newValue !== contentFromEditor()) {
reloadState();
}
}
);
watch(
computed(() => props.editorId),
() => {
reloadState();
}
);
// lifecycle
onMounted(() => {
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
enabledMenuOptions.value
);
createEditorView();
editorView.updateState(state);
if (props.autofocus) {
focusEditorInputField();
}
});
</script>
<template>
<div class="space-y-2 mb-4">
<div
class="overflow-y-auto"
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
>
<p
v-dompurify-html="formatMessage(generatedContent, false)"
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
/>
</div>
<div class="editor-root relative editor--copilot space-x-2">
<div ref="editor" />
<div class="flex items-center justify-end absolute right-2 bottom-2">
<NextButton
class="bg-n-iris-9 text-white !rounded-full"
icon="i-lucide-arrow-up"
solid
sm
@click="handleSubmit"
/>
</div>
</div>
</div>
</template>
<style lang="scss">
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.editor--copilot {
@apply bg-n-iris-5 rounded;
.ProseMirror-woot-style {
min-height: 5rem;
max-height: 7.5rem !important;
overflow: auto;
@apply px-2 !important;
.empty-node {
&::before {
@apply text-n-iris-9 dark:text-n-iris-11;
}
}
}
}
</style>
@@ -0,0 +1,259 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useElementSize, useWindowSize } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import { useCaptain } from 'dashboard/composables/useCaptain';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
defineProps({
hasSelection: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['executeCopilotAction']);
const { t } = useI18n();
const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
// for now, we don't allow improving just aprt of the selection
// we will add this feature later. Once we do, we can revert the change
const hasSelection = false;
// const hasSelection = props.hasSelection
if (hasSelection) {
items.push({
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY_SELECTION'
),
key: 'improve_selection',
icon: 'i-fluent-pen-sparkle-24-regular',
});
} else if (
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
draftMessage.value
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
key: 'improve',
icon: 'i-fluent-pen-sparkle-24-regular',
});
}
if (draftMessage.value) {
items.push(
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.TITLE'
),
key: 'change_tone',
icon: 'i-fluent-sound-wave-circle-sparkle-24-regular',
subMenuItems: [
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.PROFESSIONAL'
),
key: 'professional',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CASUAL'
),
key: 'casual',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.STRAIGHTFORWARD'
),
key: 'straightforward',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CONFIDENT'
),
key: 'confident',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.FRIENDLY'
),
key: 'friendly',
},
],
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.GRAMMAR'),
key: 'fix_spelling_grammar',
icon: 'i-fluent-flow-sparkle-24-regular',
}
);
}
return items;
});
const generalMenuItems = computed(() => {
const items = [];
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
key: 'reply_suggestion',
icon: 'i-fluent-chat-sparkle-16-regular',
});
}
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize',
icon: 'i-fluent-text-bullet-list-square-sparkle-32-regular',
});
}
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.ASK_COPILOT'),
key: 'ask_copilot',
icon: 'i-fluent-circle-sparkle-24-regular',
});
return items;
});
const menuRef = useTemplateRef('menuRef');
const { height: menuHeight } = useElementSize(menuRef);
const { width: windowWidth } = useWindowSize();
// Smart submenu positioning based on available space
const submenuPosition = computed(() => {
const el = menuRef.value?.$el;
if (!el) return 'ltr:right-full rtl:left-full';
const { left, right } = el.getBoundingClientRect();
const SUBMENU_WIDTH = 200;
const spaceRight = (windowWidth.value ?? window.innerWidth) - right;
const spaceLeft = left;
// Prefer right, fallback to side with more space
const showRight = spaceRight >= SUBMENU_WIDTH || spaceRight >= spaceLeft;
return showRight ? 'left-full' : 'right-full';
});
// Computed style for selection menu positioning (only dynamic top offset)
const selectionMenuStyle = computed(() => {
// Dynamically calculate offset based on actual menu height + 10px gap
const dynamicOffset = menuHeight.value > 0 ? menuHeight.value + 10 : 60;
return {
top: `calc(var(--selection-top) - ${dynamicOffset}px)`,
};
});
const handleMenuItemClick = item => {
// For items with submenus, do nothing on click (hover will show submenu)
if (!item.subMenuItems) {
emit('executeCopilotAction', item.key);
}
};
const handleSubMenuItemClick = (parentItem, subItem) => {
emit('executeCopilotAction', subItem.key);
};
</script>
<template>
<DropdownBody
ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection }"
:style="hasSelection ? selectionMenuStyle : {}"
>
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
<div
v-for="item in menuItems"
:key="item.key"
class="w-full relative group/submenu"
>
<Button
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
@click="handleMenuItemClick(item)"
>
<template v-if="item.subMenuItems" #default>
<div class="flex items-center gap-1 justify-between w-full">
<span class="min-w-0 truncate">{{ item.label }}</span>
<Icon
icon="i-lucide-chevron-right"
class="text-n-slate-10 size-3"
/>
</div>
</template>
</Button>
<!-- Hover Submenu -->
<DropdownBody
v-if="item.subMenuItems"
class="group-hover/submenu:block hidden [&>ul]:gap-2 [&>ul]:px-3 [&>ul]:py-2.5 [&>ul]:dark:!border-n-strong max-h-[15rem] min-w-32 z-10 top-0"
:class="submenuPosition"
>
<Button
v-for="subItem in item.subMenuItems"
:key="subItem.key + subItem.label"
:label="subItem.label"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start mb-1"
@click="handleSubMenuItemClick(item, subItem)"
/>
</DropdownBody>
</div>
</div>
<div v-if="menuItems.length > 0" class="h-px w-full bg-n-strong" />
<div class="flex flex-col items-start gap-3">
<Button
v-for="(item, index) in generalMenuItems"
:key="index"
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
@click="handleMenuItemClick(item)"
/>
</div>
</DropdownBody>
</template>
<style scoped lang="scss">
.selection-menu {
position: absolute !important;
// Default/LTR: position from left
left: var(--selection-left);
// RTL: position from right instead
[dir='rtl'] & {
left: auto;
right: var(--selection-right);
}
}
</style>
@@ -0,0 +1,51 @@
<script setup>
import { computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
import { useKbd } from 'dashboard/composables/utils/useKbd';
defineProps({
isGeneratingContent: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const handleCancel = () => {
emit('cancel');
};
const shortcutKey = useKbd(['$mod', '+', 'enter']);
const acceptLabel = computed(() => {
return `${t('GENERAL.ACCEPT')} (${shortcutKey.value})`;
});
const handleSubmit = () => {
emit('submit');
};
</script>
<template>
<div class="flex justify-between items-center p-3 pt-0">
<NextButton
:label="t('GENERAL.DISCARD')"
slate
link
class="!px-1 hover:!no-underline"
sm
:disabled="isGeneratingContent"
@click="handleCancel"
/>
<NextButton
:label="acceptLabel"
class="bg-n-iris-9 text-white"
solid
sm
:disabled="isGeneratingContent"
@click="handleSubmit"
/>
</div>
</template>
@@ -16,13 +16,16 @@ import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
import VariableList from '../conversation/VariableList.vue';
import TagTools from '../conversation/TagTools.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
import { useEmitter } from 'dashboard/composables/emitter';
import { useI18n } from 'vue-i18n';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -55,6 +58,7 @@ import {
getSelectionCoords,
calculateMenuPosition,
getEffectiveChannelType,
stripUnsupportedFormatting,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -99,13 +103,16 @@ const emit = defineEmits([
'focus',
'input',
'update:modelValue',
'executeCopilotAction',
]);
const { t } = useI18n();
const { captainTasksEnabled } = useCaptain();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
@@ -115,24 +122,33 @@ const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate
? DEFAULT_FORMATTING
? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value;
const formatting = getFormattingForEditor(formatType);
const formatting = getFormattingForEditor(
formatType,
captainTasksEnabled.value
);
return buildMessageSchema(formatting.marks, formatting.nodes);
});
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
const formatting = getFormattingForEditor(
formatType,
captainTasksEnabled.value
);
return formatting.menu;
});
const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value;
// Strip unsupported formatting before parsing to prevent "Token type not supported" errors
const sanitizedContent = stripUnsupportedFormatting(content, schema);
return EditorState.create({
doc: new MessageMarkdownTransformer(schema).parse(content),
doc: new MessageMarkdownTransformer(schema).parse(sanitizedContent),
plugins: buildEditor({
schema,
placeholder,
@@ -182,6 +198,21 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
const handleCopilotAction = actionKey => {
if (actionKey === 'improve_selection' && editorView?.state) {
const { from, to } = editorView.state.selection;
const selectedText = editorView.state.doc.textBetween(from, to).trim();
if (from !== to && selectedText) {
emit('executeCopilotAction', 'improve', selectedText);
}
} else {
emit('executeCopilotAction', actionKey);
}
showSelectionMenu.value = false;
};
const contentFromEditor = () => {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
};
@@ -364,13 +395,23 @@ function openFileBrowser() {
imageUpload.value.click();
}
function handleCopilotClick() {
showSelectionMenu.value = !showSelectionMenu.value;
}
function handleClickOutside(event) {
// Check if the clicked element or its parents have the ignored class
if (event.target.closest('.ProseMirror-copilot')) return;
showSelectionMenu.value = false;
}
function reloadState(content = props.modelValue) {
const unrefContent = unref(content);
state = createState(
unrefContent,
props.placeholder,
plugins.value,
{ onImageUpload: openFileBrowser },
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -596,7 +637,12 @@ function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
// Use the editor's current schema to ensure compatibility with buildMessageSchema
const currentSchema = editorView.state.schema;
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
// Strip unsupported formatting before parsing to ensure content can be inserted
// into channels that don't support certain markdown features (e.g., API channels)
const sanitizedContent = stripUnsupportedFormatting(content, currentSchema);
let node = new MessageMarkdownTransformer(currentSchema).parse(
sanitizedContent
);
insertNodeIntoEditor(node, from, undefined);
}
@@ -680,11 +726,18 @@ function createEditorView() {
typingIndicator.stop();
emit('blur');
},
paste: (_view, event) => {
paste: (view, event) => {
if (props.disabled) return;
const data = event.clipboardData.files;
if (data.length > 0) {
event.preventDefault();
const { files } = event.clipboardData;
if (!files.length) return;
event.preventDefault();
// Paste text content alongside files (e.g., spreadsheet data from Numbers app)
// Numbers app includes invalid 0-byte attachments with text, so we paste the text here
// while ReplyBox filters and handles valid file attachments
const text = event.clipboardData.getData('text/plain');
if (text) {
view.dispatch(view.state.tr.insertText(text));
emitOnChange();
}
},
},
@@ -751,7 +804,7 @@ onMounted(async () => {
props.modelValue,
props.placeholder,
plugins.value,
{ onImageUpload: openFileBrowser },
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -796,6 +849,14 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
:search-key="toolSearchKey"
@select-tool="content => insertSpecialContent('tool', content)"
/>
<CopilotMenuBar
v-if="showSelectionMenu"
v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected"
:show-selection-menu="showSelectionMenu"
:show-general-menu="false"
@execute-copilot-action="handleCopilotAction"
/>
<input
ref="imageUpload"
type="file"
@@ -853,6 +914,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply size-full;
}
}
.ProseMirror-copilot svg {
@apply fill-n-violet-9 text-n-violet-9 stroke-none;
}
}
}
@@ -860,6 +925,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
max-height: none !important;
min-height: 0 !important;
padding: 0 !important;
display: none !important;
}
> .ProseMirror {
@@ -991,6 +1057,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0;
}
.ProseMirror-copilot svg {
@apply fill-n-violet-9 text-n-violet-9 stroke-none;
}
}
.ProseMirror-menu-active {
@@ -17,6 +17,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isReplyRestricted: {
type: Boolean,
default: false,
},
});
defineEmits(['toggleMode']);
@@ -29,11 +33,17 @@ const privateModeSize = useElementSize(wootEditorPrivateMode);
/**
* Computed boolean indicating if the editor is in private note mode
* When disabled, always show NOTE mode regardless of actual mode prop
* When isReplyRestricted is true, force switch to private note
* Otherwise, respect the current mode prop
* @type {ComputedRef<boolean>}
*/
const isPrivate = computed(() => {
return props.disabled || props.mode === REPLY_EDITOR_MODES.NOTE;
if (props.isReplyRestricted) {
// Force switch to private note when replies are restricted
return true;
}
// Otherwise respect the current mode
return props.mode === REPLY_EDITOR_MODES.NOTE;
});
/**
@@ -65,8 +75,12 @@ const translateValue = computed(() => {
<template>
<button
class="flex items-center w-auto p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
:disabled="disabled"
:class="[iconOnly ? 'h-7' : 'h-8', { 'cursor-not-allowed': disabled }]"
:disabled="disabled || isReplyRestricted"
:class="{
'h-7': iconOnly,
'h-8': !iconOnly,
'cursor-not-allowed': disabled || isReplyRestricted,
}"
@click="$emit('toggleMode')"
>
<div
@@ -84,11 +98,12 @@ const translateValue = computed(() => {
<template v-else>{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}</template>
</div>
<div
class="absolute shadow-sm rounded-full w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
:class="[
iconOnly ? 'h-5' : 'h-6',
{ 'transition-all duration-300': !disabled },
]"
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
:class="{
'h-5': iconOnly,
'h-6': !iconOnly,
'transition-all duration-300': !disabled && !isReplyRestricted,
}"
:style="{
'--chip-width': width,
'--translate-x': translateValue,
@@ -9,14 +9,13 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
name: 'ReplyBottomPanel',
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
components: { NextButton, FileUpload, VideoCallButton },
mixins: [inboxMixin],
props: {
isNote: {
@@ -98,6 +97,7 @@ export default {
type: Number,
required: true,
},
// eslint-disable-next-line vue/no-unused-properties
message: {
type: String,
default: '',
@@ -382,13 +382,6 @@ export default {
"
:conversation-id="conversationId"
/>
<AIAssistanceButton
v-if="allowAiAssist && !isFetchingAppIntegrations"
:conversation-id="conversationId"
:is-private-note="isOnPrivateNote"
:message="message"
@replace-text="replaceText"
/>
<transition name="modal-fade">
<div
v-show="uploadRef && uploadRef.dropActive"
@@ -1,14 +1,22 @@
<script>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { vOnClickOutside } from '@vueuse/components';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
export default {
name: 'ReplyTopPanel',
components: {
NextButton,
EditorModeToggle,
CopilotMenuBar,
},
directives: {
OnClickOutside: vOnClickOutside,
},
props: {
mode: {
@@ -23,6 +31,10 @@ export default {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -32,7 +44,7 @@ export default {
default: () => 0,
},
},
emits: ['setReplyMode', 'togglePopout'],
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) {
const setReplyMode = mode => {
emit('setReplyMode', mode);
@@ -51,6 +63,23 @@ export default {
: REPLY_EDITOR_MODES.REPLY;
setReplyMode(newMode);
};
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
const handleCopilotAction = actionKey => {
emit('executeCopilotAction', actionKey);
showCopilotMenu.value = false;
};
const toggleCopilotMenu = () => {
showCopilotMenu.value = !showCopilotMenu.value;
};
const handleClickOutside = () => {
showCopilotMenu.value = false;
};
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
@@ -68,6 +97,11 @@ export default {
handleReplyClick,
handleNoteClick,
REPLY_EDITOR_MODES,
captainTasksEnabled,
handleCopilotAction,
showCopilotMenu,
toggleCopilotMenu,
handleClickOutside,
};
},
computed: {
@@ -94,11 +128,13 @@ export default {
</script>
<template>
<div class="flex justify-between h-[3.25rem] gap-2 ms-3">
<div
class="flex justify-between gap-2 h-[3.25rem] items-center ltr:pl-3 ltr:pr-2 rtl:pr-3 rtl:pl-2"
>
<EditorModeToggle
:mode="mode"
:disabled="isReplyRestricted"
class="mt-3"
:disabled="disabled"
:is-reply-restricted="isReplyRestricted"
@toggle-mode="handleModeToggle"
/>
<div class="flex items-center mx-4 my-0">
@@ -108,12 +144,34 @@ export default {
</span>
</div>
</div>
<NextButton
v-if="!disablePopout"
ghost
class="ltr:rounded-bl-md rtl:rounded-br-md ltr:rounded-br-none rtl:rounded-bl-none ltr:rounded-tl-none rtl:rounded-tr-none text-n-slate-11 ltr:rounded-tr-[11px] rtl:rounded-tl-[11px]"
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
<div v-if="captainTasksEnabled" class="flex items-center gap-2">
<div class="relative">
<NextButton
ghost
:disabled="disabled"
:class="{
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
}"
sm
icon="i-ph-sparkle-fill"
@click="toggleCopilotMenu"
/>
<CopilotMenuBar
v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside"
:has-selection="false"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
/>
</div>
<NextButton
ghost
class="text-n-slate-11"
sm
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
</div>
</div>
</template>
@@ -41,6 +41,9 @@ const getTemplateType = template => {
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
}
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
};
@@ -91,7 +91,7 @@ export default {
<template>
<div
class="conversation-details-wrap flex flex-col min-w-0 w-full bg-n-background relative"
class="conversation-details-wrap flex flex-col min-w-0 w-full bg-n-surface-1 relative"
:class="{
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
}"
@@ -104,7 +104,7 @@ export default {
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
:index="activeIndex"
class="-mt-px border-t border-t-n-background"
class="h-10"
@change="onDashboardAppTabChange"
>
<woot-tabs-item
@@ -114,7 +114,6 @@ export default {
:name="tab.name"
:show-badge="false"
is-compact
class="[&_a]:pt-1"
/>
</woot-tabs>
<div v-show="!activeIndex" class="flex h-full min-h-0 m-0">
@@ -236,9 +236,8 @@ const deleteConversation = () => {
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat,
'bg-n-slate-2 dark:bg-n-slate-3': selected,
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
'bg-n-slate-2': selected,
'px-0': compact,
'px-3': !compact,
}"
@@ -95,7 +95,7 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
<template>
<div
ref="conversationHeader"
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 pt-3 pb-2 h-24 xl:h-12"
>
<div
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1"
@@ -42,7 +42,7 @@ const closeContactPanel = () => {
<template>
<div
v-on-click-outside="() => closeContactPanel()"
class="bg-n-background h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
class="bg-n-surface-2 h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
'md:flex': activeTab === 0,
@@ -0,0 +1,99 @@
<script setup>
import { ref } from 'vue';
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
defineProps({
showCopilotEditor: {
type: Boolean,
default: false,
},
isGeneratingContent: {
type: Boolean,
default: false,
},
generatedContent: {
type: String,
default: '',
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'focus',
'blur',
'clearSelection',
'contentReady',
'send',
]);
const copilotEditorContent = ref('');
const onFocus = () => {
emit('focus');
};
const onBlur = () => {
emit('blur');
};
const clearEditorSelection = () => {
emit('clearSelection');
};
const onSend = () => {
emit('send', copilotEditorContent.value);
copilotEditorContent.value = '';
};
</script>
<template>
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
@after-enter="emit('contentReady')"
>
<CopilotEditor
v-if="showCopilotEditor && !isGeneratingContent"
key="copilot-editor"
v-model="copilotEditorContent"
class="copilot-editor"
:generated-content="generatedContent"
:min-height="4"
:enabled-menu-options="[]"
:is-popout="isPopout"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@send="onSend"
/>
<div
v-else-if="isGeneratingContent"
key="loading-state"
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
>
<div class="flex items-center gap-2">
<CaptainLoader class="text-n-iris-10 size-4" />
<span class="text-sm text-n-iris-10">
{{ $t('CONVERSATION.REPLYBOX.COPILOT_THINKING') }}
</span>
</div>
</div>
</Transition>
</template>
<style lang="scss">
.copilot-editor {
.ProseMirror-menubar {
display: none;
}
}
</style>
@@ -58,7 +58,7 @@ export default {
) {
return 'h-full overflow-auto w-full';
}
return 'flex-1 min-w-0 px-0 flex flex-col items-center justify-center h-full';
return 'flex-1 min-w-0 px-0 flex flex-col items-center justify-center h-full bg-n-surface-1';
},
},
};
@@ -11,7 +11,7 @@ const openProfileSettings = () => {
<template>
<div
class="my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
class="my-0 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
>
<p class="w-fit !m-0">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
@@ -1,9 +1,8 @@
<script>
import { ref, provide } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
// components
@@ -49,7 +48,6 @@ export default {
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const keyboardEvents = {
Escape: {
@@ -62,21 +60,18 @@ export default {
useKeyboardEvents(keyboardEvents);
const {
isAIIntegrationEnabled,
captainTasksEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
getLabelSuggestions,
} = useLabelSuggestions();
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
isAIIntegrationEnabled,
captainTasksEnabled,
getLabelSuggestions,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
conversationPanelRef,
};
},
@@ -105,8 +100,8 @@ export default {
shouldShowLabelSuggestions() {
return (
this.isOpen &&
this.isEnterprise &&
this.isAIIntegrationEnabled &&
this.captainTasksEnabled &&
this.isLabelSuggestionFeatureEnabled &&
!this.messageSentSinceOpened
);
},
@@ -291,24 +286,15 @@ export default {
return;
}
if (!this.isEnterprise) {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = this.currentChat?.labels || [];
if (existingLabels.length > 0) return;
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
if (!this.isLabelSuggestionFeatureEnabled) {
if (!this.captainTasksEnabled || !this.isLabelSuggestionFeatureEnabled) {
return;
}
this.labelSuggestions = await this.fetchLabelSuggestions({
conversationId: this.currentChat.id,
});
this.labelSuggestions = await this.getLabelSuggestions();
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
@@ -516,7 +502,7 @@ export default {
class="flex relative flex-col"
:class="{
'modal-mask': isPopOutReplyBox,
'bg-n-background': !isPopOutReplyBox,
'bg-n-surface-1': !isPopOutReplyBox,
}"
>
<div
@@ -29,7 +29,7 @@ defineProps({
<template>
<div
class="h-full w-full bg-n-background border border-n-weak rounded-lg p-4 flex flex-col"
class="h-full w-full bg-n-surface-2 border border-n-weak rounded-lg p-4 flex flex-col"
>
<div class="flex-1 flex items-center justify-center">
<img :src="imageSrc" :alt="imageAlt" class="h-36 w-auto mx-auto" />
@@ -7,14 +7,14 @@ import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
import CopilotEditorSection from './CopilotEditorSection.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
import ReplyBoxBanner from './ReplyBoxBanner.vue';
import QuotedEmailPreview from './QuotedEmailPreview.vue';
@@ -23,6 +23,7 @@ import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vu
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import {
getMessageVariables,
getUndefinedVariablesInMessage,
@@ -46,8 +47,10 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
@@ -72,8 +75,8 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
CopilotEditorSection,
CopilotReplyBottomPanel,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -93,6 +96,8 @@ export default {
} = useUISettings();
const replyEditor = useTemplateRef('replyEditor');
const copilot = useCopilotReply();
const shortcutKey = useKbd(['$mod', '+', 'enter']);
return {
uiSettings,
@@ -101,6 +106,8 @@ export default {
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
replyEditor,
copilot,
shortcutKey,
};
},
data() {
@@ -114,8 +121,6 @@ export default {
recordingAudioState: '',
recordingAudioDurationText: '',
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
toEmails: '',
@@ -148,9 +153,6 @@ export default {
if (!senderId) return {};
return this.$store.getters['contacts/getContact'](senderId);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
},
shouldShowReplyToMessage() {
return (
this.inReplyTo?.id &&
@@ -276,7 +278,7 @@ export default {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
}
const keyLabel = this.isEditorHotKeyEnabled('cmd_enter')
? '(⌘ + ↵)'
? `(${this.shortcutKey})`
: '(↵)';
return `${sendMessageText} ${keyLabel}`;
},
@@ -409,18 +411,8 @@ export default {
!!this.quotedEmailText
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
return false;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
isDefaultEditorMode() {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
},
},
watch: {
@@ -431,6 +423,8 @@ export default {
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
// like self-assign or other updates that do not actually change the conversation context
this.setCCAndToEmailsFromLastChat();
// Reset Copilot editor state (includes cancelling ongoing generation)
this.copilot.reset();
}
if (this.isOnPrivateNote) {
@@ -464,25 +458,7 @@ export default {
this.resetRecorderAndClearAttachments();
}
},
message(updatedMessage) {
// Check if the message starts with a slash.
const bodyWithoutSignature = removeSignature(
updatedMessage,
this.signatureToApply
);
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Determine if the user is potentially typing a slash command.
// This is true if the message starts with a slash and the rich content editor is not active.
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
this.showMentions = this.hasSlashCommand;
// If a slash command is active, extract the command text after the slash.
// If not, reset the mentionSearchKey.
this.mentionSearchKey = this.hasSlashCommand
? bodyWithoutSignature.substring(1)
: '';
message() {
// Autosave the current message draft.
this.doAutoSaveDraft();
},
@@ -518,6 +494,7 @@ export default {
this.onNewConversationModalActive
);
emitter.on(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
emitter.on(CMD_AI_ASSIST, this.executeCopilotAction);
},
unmounted() {
document.removeEventListener('paste', this.onPaste);
@@ -528,24 +505,19 @@ export default {
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
},
methods: {
handleInsert(article) {
const { url, title } = article;
if (this.isRichEditorEnabled) {
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
@@ -614,26 +586,14 @@ export default {
if (this.isPrivate) {
return message;
}
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(
message,
this.messageSignature,
effectiveChannelType
)
: removeSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
? appendSignature(message, this.messageSignature, effectiveChannelType)
: removeSignature(message, this.messageSignature, effectiveChannelType);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
@@ -649,7 +609,6 @@ export default {
Escape: {
action: () => {
this.hideEmojiPicker();
this.hideMentions();
},
allowOnFocusedInput: true,
},
@@ -672,7 +631,9 @@ export default {
},
'$mod+Enter': {
action: () => {
if (this.isAValidEvent('cmd_enter')) {
if (this.copilot.isActive.value && this.isFocused) {
this.onSubmitCopilotReply();
} else if (this.isAValidEvent('cmd_enter')) {
this.onSendReply();
}
},
@@ -692,20 +653,34 @@ export default {
},
onPaste(e) {
// Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) {
return;
}
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
}
if (!data.length || !data[0]) {
return;
}
data.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file: file });
});
if (this.newConversationModalActive) return;
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
.filter(file => file.size > 0)
.filter(file => {
const isAllowed = isFileTypeAllowedForChannel(file, {
channelType: this.channelType || this.inbox?.channel_type,
medium: this.inbox?.medium,
conversationType: this.conversationType,
isInstagramChannel: this.isAnInstagramChannel,
isOnPrivateNote: this.isOnPrivateNote,
});
if (!isAllowed) {
useAlert(
this.$t('CONVERSATION.FILE_TYPE_NOT_SUPPORTED', {
fileName: file.name,
})
);
}
return isAllowed;
})
.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file });
});
},
toggleUserMention(currentMentionState) {
this.showUserMentions = currentMentionState;
@@ -832,19 +807,15 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
} else {
message = appendSignature(message, this.signatureToApply);
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
const updatedMessage = replaceVariablesInMessage({
@@ -868,52 +839,33 @@ export default {
});
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) {
if (this.showRichContentEditor) {
this.updateEditorSelectionWith = content;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
this.updateEditorSelectionWith = content;
this.onFocus();
},
executeCopilotAction(action, data) {
this.copilot.execute(action, data);
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
} else {
this.message = appendSignature(this.message, this.signatureToApply);
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -948,9 +900,6 @@ export default {
this.toggleEmojiPicker();
}
},
hideMentions() {
this.showMentions = false;
},
onTypingOn() {
this.toggleTyping('on');
},
@@ -1169,6 +1118,9 @@ export default {
togglePopout() {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
},
onSubmitCopilotReply() {
this.message = this.copilot.accept();
},
},
};
</script>
@@ -1179,11 +1131,17 @@ export default {
<ReplyTopPanel
:mode="replyType"
:is-reply-restricted="isReplyRestricted"
:disabled="
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
showAudioRecorderEditor
"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode"
@toggle-popout="togglePopout"
@toggle-copilot="copilot.toggleEditor"
@execute-copilot-action="executeCopilotAction"
/>
<ArticleSearchPopover
v-if="showArticleSearchPopover && connectedPortalSlug"
@@ -1191,141 +1149,167 @@ export default {
@insert="handleInsert"
@close="onSearchPopoverClose"
/>
<div class="reply-box__top">
<ReplyToMessage
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
}"
:on-click="addIntoEditor"
/>
<ReplyEmailHead
v-if="showReplyHead"
v-model:cc-emails="ccEmails"
v-model:bcc-emails="bccEmails"
v-model:to-emails="toEmails"
/>
<AudioRecorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<ResizableTextArea
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
allow-signature
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<WootMessageEditor
v-else
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview"
:quoted-email-text="quotedEmailText"
:preview-text="quotedEmailPreviewText"
@toggle="toggleQuotedReply"
/>
</div>
<div
v-if="hasAttachments && !showAudioRecorderEditor"
class="attachment-preview-box"
@paste="onPaste"
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
>
<AttachmentPreview
class="flex-col mt-4"
:attachments="attachedFiles"
@remove-attachment="removeAttachment"
<div :key="copilot.editorTransitionKey.value" class="reply-box__top">
<ReplyToMessage
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
}"
:on-click="addIntoEditor"
/>
<ReplyEmailHead
v-if="showReplyHead && isDefaultEditorMode"
v-model:cc-emails="ccEmails"
v-model:bcc-emails="bccEmails"
v-model:to-emails="toEmails"
/>
<AudioRecorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<CopilotEditorSection
v-if="copilot.isActive.value && !showAudioRecorderEditor"
:show-copilot-editor="copilot.showEditor.value"
:is-generating-content="copilot.isGenerating.value"
:generated-content="copilot.generatedContent.value"
:is-popout="popOutReplyBox"
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@close="copilot.showEditor.value = false"
@content-ready="copilot.setContentReady"
@send="copilot.sendFollowUp"
/>
<WootMessageEditor
v-else-if="!showAudioRecorderEditor"
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
@execute-copilot-action="executeCopilotAction"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview && isDefaultEditorMode"
:quoted-email-text="quotedEmailText"
:preview-text="quotedEmailPreviewText"
class="mb-2"
@toggle="toggleQuotedReply"
/>
<div
v-if="hasAttachments && isDefaultEditorMode"
class="bg-transparent py-0 mb-2"
@paste="onPaste"
>
<AttachmentPreview
class="mt-2"
:attachments="attachedFiles"
@remove-attachment="removeAttachment"
/>
</div>
<MessageSignatureMissingAlert
v-if="
isSignatureEnabledForInbox &&
!isSignatureAvailable &&
isDefaultEditorMode
"
class="mb-2"
/>
</div>
</Transition>
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
>
<CopilotReplyBottomPanel
v-if="copilot.isActive.value"
key="copilot-bottom-panel"
:is-generating-content="copilot.isButtonDisabled.value"
@submit="onSubmitCopilotReply"
@cancel="copilot.reset"
/>
</div>
<MessageSignatureMissingAlert
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
/>
<ReplyBottomPanel
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
:enable-content-templates="showContentTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
:quoted-reply-enabled="quotedReplyPreference"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
allow-signature
allow-emoji
allow-ai-assist
allow-video-call
allow-audio-recorder
allow-file-upload
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
<ReplyBottomPanel
v-else
key="reply-bottom-panel"
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
:enable-content-templates="showContentTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
:quoted-reply-enabled="quotedReplyPreference"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
</Transition>
<WhatsappTemplates
:inbox-id="inbox.id"
:show="showWhatsAppTemplatesModal"
@@ -1355,13 +1339,7 @@ export default {
@apply mb-0;
}
.attachment-preview-box {
@apply bg-transparent py-0 px-4;
}
.reply-box {
transition: height 2s cubic-bezier(0.37, 0, 0.63, 1);
@apply relative mb-2 mx-2 border border-n-weak rounded-xl bg-n-solid-1;
&.is-private {
@@ -1375,10 +1353,6 @@ export default {
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
}
.emoji-dialog {
@@ -1398,9 +1372,4 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style>
@@ -78,14 +78,6 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'country_code',
attributeI18nKey: 'COUNTRY_NAME',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'referer',
attributeI18nKey: 'REFERER_LINK',
@@ -171,10 +163,6 @@ export const filterAttributeGroups = [
key: 'browser_language',
i18nKey: 'BROWSER_LANGUAGE',
},
{
key: 'country_code',
i18nKey: 'COUNTRY_NAME',
},
{
key: 'referer',
i18nKey: 'REFERER_LINK',
@@ -515,6 +515,10 @@ const languages = [
name: 'Portuguese',
id: 'pt',
},
{
name: 'Portuguese (Brazil)',
id: 'pt_BR',
},
{
name: 'Punjabi',
id: 'pa',
@@ -4,7 +4,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
// composables
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
// store & api
@@ -13,7 +13,7 @@ import { mapGetters } from 'vuex';
// utils & constants
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { OPEN_AI_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { CAPTAIN_EVENTS } from '../../../../helper/AnalyticsHelper/events';
export default {
name: 'LabelSuggestion',
@@ -33,9 +33,9 @@ export default {
},
},
setup() {
const { isAIIntegrationEnabled } = useAI();
const { captainTasksEnabled } = useCaptain();
return { isAIIntegrationEnabled };
return { captainTasksEnabled };
},
data() {
return {
@@ -78,7 +78,7 @@ export default {
},
shouldShowSuggestions() {
if (this.isDismissed) return false;
if (!this.isAIIntegrationEnabled) return false;
if (!this.captainTasksEnabled) return false;
return this.preparedLabels.length && this.chatLabels.length === 0;
},
@@ -114,7 +114,7 @@ export default {
// dismiss this once the values are set
this.isDismissed = true;
this.trackLabelEvent(OPEN_AI_EVENTS.DISMISS_LABEL_SUGGESTION);
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_DISMISSED);
},
isConversationDismissed() {
return LocalStorage.getFlag(
@@ -132,7 +132,7 @@ export default {
conversationId: this.conversationId,
labels: labelsToAdd,
});
this.trackLabelEvent(OPEN_AI_EVENTS.APPLY_LABEL_SUGGESTION);
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_APPLIED);
},
trackLabelEvent(event) {
const payload = {
@@ -0,0 +1,33 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
</script>
<template>
<Icon v-once icon="i-woot-captain" class="jumping-logo" />
</template>
<style scoped>
.jumping-logo {
transform-origin: center bottom;
animation: jump 1s cubic-bezier(0.28, 0.84, 0.42, 1) infinite;
will-change: transform;
}
@keyframes jump {
0% {
transform: translateY(0) scale(1, 1);
}
20% {
transform: translateY(0) scale(1.05, 0.95);
}
50% {
transform: translateY(-5px) scale(0.95, 1.05);
}
80% {
transform: translateY(0) scale(1.02, 0.98);
}
100% {
transform: translateY(0) scale(1, 1);
}
}
</style>
@@ -60,7 +60,7 @@ const onClickTabChange = index => {
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col px-8 pb-4 mt-1">
<woot-tabs
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0"
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0 h-10"
:index="selectedTabIndex"
@change="onClickTabChange"
>
@@ -1,6 +1,7 @@
<script setup>
import { ref, watch, computed, nextTick } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({
items: {
@@ -15,6 +16,8 @@ const props = defineProps({
const emit = defineEmits(['mentionSelect']);
const { getPlainText } = useMessageFormatter();
const mentionsListContainerRef = ref(null);
const selectedIndex = ref(0);
@@ -94,7 +97,7 @@ const variableKey = (item = {}) => {
'text-n-slate-12': index === selectedIndex,
}"
>
{{ item.description }}
{{ getPlainText(item.description) }}
</p>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-n-slate-11 group-hover:text-n-slate-12 text-ellipsis whitespace-nowrap"