feat: compose form improvements (#13668)

This commit is contained in:
Sivin Varghese
2026-03-02 18:27:51 +05:30
committed by GitHub
parent 9aacc0335b
commit 89da4a2292
18 changed files with 354 additions and 73 deletions

View File

@@ -57,14 +57,14 @@ class ContactAPI extends ApiClient {
return axios.post(`${this.url}/${contactId}/labels`, { labels }); return axios.post(`${this.url}/${contactId}/labels`, { labels });
} }
search(search = '', page = 1, sortAttr = 'name', label = '') { search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) {
let requestURL = `${this.url}/search?${buildContactParams( let requestURL = `${this.url}/search?${buildContactParams(
page, page,
sortAttr, sortAttr,
label, label,
search search
)}`; )}`;
return axios.get(requestURL); return axios.get(requestURL, { signal: options.signal });
} }
active(page = 1, sortAttr = 'name') { active(page = 1, sortAttr = 'name') {

View File

@@ -68,7 +68,19 @@ describe('#ContactsAPI', () => {
it('#search', () => { it('#search', () => {
contactAPI.search('leads', 1, 'date', 'customer-support'); contactAPI.search('leads', 1, 'date', 'customer-support');
expect(axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support' '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
{ signal: undefined }
);
});
it('#search with signal', () => {
const controller = new AbortController();
contactAPI.search('leads', 1, 'date', 'customer-support', {
signal: controller.signal,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
{ signal: controller.signal }
); );
}); });

View File

@@ -28,7 +28,7 @@ const props = defineProps({
medium: { type: String, default: '' }, medium: { type: String, default: '' },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue', 'executeCopilotAction']);
const slots = useSlots(); const slots = useSlots();
@@ -113,6 +113,9 @@ watch(
@input="handleInput" @input="handleInput"
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"
@execute-copilot-action="
(...args) => emit('executeCopilotAction', ...args)
"
/> />
<div <div
v-if="showCharacterCount || slots.actions" v-if="showCharacterCount || slots.actions"

View File

@@ -12,7 +12,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { import {
searchContacts, createContactSearcher,
createNewContact, createNewContact,
fetchContactableInboxes, fetchContactableInboxes,
processContactableInboxes, processContactableInboxes,
@@ -39,6 +39,7 @@ const props = defineProps({
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const searchContacts = createContactSearcher();
const store = useStore(); const store = useStore();
const { t } = useI18n(); const { t } = useI18n();
const { width: windowWidth } = useWindowSize(); const { width: windowWidth } = useWindowSize();
@@ -107,15 +108,17 @@ const onContactSearch = debounce(
isSearching.value = true; isSearching.value = true;
contacts.value = []; contacts.value = [];
try { try {
contacts.value = await searchContacts(query); const results = await searchContacts(query);
// null means the request was aborted (a newer search is in-flight),
if (results === null) return;
contacts.value = results;
isSearching.value = false; isSearching.value = false;
} catch (error) { } catch (error) {
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false; isSearching.value = false;
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
} }
}, },
300, 400,
false false
); );
@@ -138,6 +141,7 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
contact = rest; contact = rest;
} }
selectedContact.value = contact; selectedContact.value = contact;
contacts.value = [];
if (contact?.id) { if (contact?.id) {
isFetchingInboxes.value = true; isFetchingInboxes.value = true;
try { try {

View File

@@ -15,6 +15,9 @@ import {
prepareWhatsAppMessagePayload, prepareWhatsAppMessagePayload,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js'; } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import ContactSelector from './ContactSelector.vue'; import ContactSelector from './ContactSelector.vue';
import InboxSelector from './InboxSelector.vue'; import InboxSelector from './InboxSelector.vue';
import EmailOptions from './EmailOptions.vue'; import EmailOptions from './EmailOptions.vue';
@@ -22,6 +25,7 @@ import MessageEditor from './MessageEditor.vue';
import ActionButtons from './ActionButtons.vue'; import ActionButtons from './ActionButtons.vue';
import InboxEmptyState from './InboxEmptyState.vue'; import InboxEmptyState from './InboxEmptyState.vue';
import AttachmentPreviews from './AttachmentPreviews.vue'; import AttachmentPreviews from './AttachmentPreviews.vue';
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
const props = defineProps({ const props = defineProps({
contacts: { type: Array, default: () => [] }, contacts: { type: Array, default: () => [] },
@@ -42,6 +46,7 @@ const props = defineProps({
const emit = defineEmits([ const emit = defineEmits([
'searchContacts', 'searchContacts',
'resetContactSearch',
'discard', 'discard',
'updateSelectedContact', 'updateSelectedContact',
'updateTargetInbox', 'updateTargetInbox',
@@ -51,6 +56,8 @@ const emit = defineEmits([
const DEFAULT_FORMATTING = 'Context::Default'; const DEFAULT_FORMATTING = 'Context::Default';
const copilot = useCopilotReply();
const showContactsDropdown = ref(false); const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false); const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false); const showCcEmailsDropdown = ref(false);
@@ -157,7 +164,7 @@ const isAnyDropdownActive = computed(() => {
}); });
const handleContactSearch = value => { const handleContactSearch = value => {
showContactsDropdown.value = true; showContactsDropdown.value = value.trim().length > 1;
emit('searchContacts', value); emit('searchContacts', value);
}; };
@@ -172,12 +179,16 @@ const handleDropdownUpdate = (type, value) => {
}; };
const searchCcEmails = value => { const searchCcEmails = value => {
showCcEmailsDropdown.value = true; showBccEmailsDropdown.value = false;
emit('resetContactSearch');
showCcEmailsDropdown.value = value.trim().length >= 2;
emit('searchContacts', value); emit('searchContacts', value);
}; };
const searchBccEmails = value => { const searchBccEmails = value => {
showBccEmailsDropdown.value = true; showCcEmailsDropdown.value = false;
emit('resetContactSearch');
showBccEmailsDropdown.value = value.trim().length >= 2;
emit('searchContacts', value); emit('searchContacts', value);
}; };
@@ -196,6 +207,7 @@ const stripMessageFormatting = channelType => {
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => { const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
v$.value.$reset(); v$.value.$reset();
copilot.reset(false);
// Strip unsupported formatting when changing the target inbox // Strip unsupported formatting when changing the target inbox
if (channelType) { if (channelType) {
@@ -222,6 +234,7 @@ const removeSignatureFromMessage = () => {
const removeTargetInbox = value => { const removeTargetInbox = value => {
v$.value.$reset(); v$.value.$reset();
copilot.reset(false);
removeSignatureFromMessage(); removeSignatureFromMessage();
stripMessageFormatting(DEFAULT_FORMATTING); stripMessageFormatting(DEFAULT_FORMATTING);
@@ -231,6 +244,7 @@ const removeTargetInbox = value => {
}; };
const clearSelectedContact = () => { const clearSelectedContact = () => {
copilot.reset(false);
removeSignatureFromMessage(); removeSignatureFromMessage();
emit('clearSelectedContact'); emit('clearSelectedContact');
state.message = ''; state.message = '';
@@ -262,6 +276,7 @@ const handleAttachFile = files => {
}; };
const clearForm = () => { const clearForm = () => {
copilot.reset(false);
Object.assign(state, { Object.assign(state, {
message: '', message: '',
subject: '', subject: '',
@@ -324,6 +339,24 @@ const shouldShowMessageEditor = computed(() => {
!inboxTypes.value.isTwilioWhatsapp !inboxTypes.value.isTwilioWhatsapp
); );
}); });
const isCopilotActive = computed(() => copilot.isActive?.value ?? false);
const onSubmitCopilotReply = () => {
const acceptedMessage = copilot.accept();
state.message = acceptedMessage;
};
useKeyboardEvents({
'$mod+Enter': {
action: () => {
if (isCopilotActive.value && !copilot.isButtonDisabled.value) {
onSubmitCopilotReply();
}
},
allowOnFocusedInput: true,
},
});
</script> </script>
<template> <template>
@@ -354,6 +387,7 @@ const shouldShowMessageEditor = computed(() => {
:show-inboxes-dropdown="showInboxesDropdown" :show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList" :contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid" :has-errors="validationStates.isInboxInvalid"
:is-fetching-inboxes="isFetchingInboxes"
@update-inbox="removeTargetInbox" @update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event" @toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction" @handle-inbox-action="handleInboxAction"
@@ -382,6 +416,7 @@ const shouldShowMessageEditor = computed(() => {
:has-errors="validationStates.isMessageInvalid" :has-errors="validationStates.isMessageInvalid"
:channel-type="inboxChannelType" :channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''" :medium="targetInbox?.medium || ''"
:copilot="copilot"
/> />
<AttachmentPreviews <AttachmentPreviews
@@ -391,7 +426,15 @@ const shouldShowMessageEditor = computed(() => {
/> />
</div> </div>
<CopilotReplyBottomPanel
v-if="isCopilotActive"
:is-generating-content="copilot.isButtonDisabled.value"
class="h-[3.25rem] !px-4 !py-2"
@submit="onSubmitCopilotReply"
@cancel="copilot.reset"
/>
<ActionButtons <ActionButtons
v-else
:attached-files="state.attachedFiles" :attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp" :is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"

View File

@@ -99,7 +99,6 @@ const inputClass = computed(() => {
type="email" type="email"
allow-create allow-create
class="flex-1 min-h-7" class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)" @input="emit('searchCcEmails', $event)"
@on-click-outside="emit('updateDropdown', 'cc', false)" @on-click-outside="emit('updateDropdown', 'cc', false)"
@update:model-value="handleCcUpdate" @update:model-value="handleCcUpdate"
@@ -133,7 +132,6 @@ const inputClass = computed(() => {
allow-create allow-create
class="flex-1 min-h-7" class="flex-1 min-h-7"
focus-on-mount focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@input="emit('searchBccEmails', $event)" @input="emit('searchBccEmails', $event)"
@on-click-outside="emit('updateDropdown', 'bcc', false)" @on-click-outside="emit('updateDropdown', 'bcc', false)"
@update:model-value="handleBccUpdate" @update:model-value="handleBccUpdate"

View File

@@ -1,9 +1,15 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template> <template>
<div <div
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3" class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
> >
<span class="text-sm dark:text-n-amber-11 text-n-amber-11"> <span class="text-sm dark:text-n-amber-11 text-n-amber-11">
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }} {{ t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
</span> </span>
</div> </div>
</template> </template>

View File

@@ -6,6 +6,7 @@ import { generateLabelForContactableInboxesList } from 'dashboard/components-nex
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({ const props = defineProps({
targetInbox: { targetInbox: {
@@ -28,6 +29,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isFetchingInboxes: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -71,7 +76,9 @@ const targetInboxLabel = computed(() => {
v-on-click-outside="() => emit('toggleDropdown', false)" v-on-click-outside="() => emit('toggleDropdown', false)"
class="relative flex items-center h-7" class="relative flex items-center h-7"
> >
<Spinner v-if="isFetchingInboxes" :size="16" />
<Button <Button
v-else
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')" :label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
variant="link" variant="link"
size="sm" size="sm"

View File

@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CopilotEditorSection from 'dashboard/components/widgets/conversation/CopilotEditorSection.vue';
const props = defineProps({ const props = defineProps({
hasErrors: { type: Boolean, default: false }, hasErrors: { type: Boolean, default: false },
@@ -10,6 +11,7 @@ const props = defineProps({
messageSignature: { type: String, default: '' }, messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' }, channelType: { type: String, default: '' },
medium: { type: String, default: '' }, medium: { type: String, default: '' },
copilot: { type: Object, default: null },
}); });
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`); const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
@@ -20,29 +22,67 @@ const modelValue = defineModel({
type: String, type: String,
default: '', default: '',
}); });
const isCopilotActive = computed(() => props.copilot?.isActive?.value ?? false);
const executeCopilotAction = (action, data) => {
if (props.copilot) {
props.copilot.execute(action, data);
}
};
</script> </script>
<template> <template>
<div class="flex-1 h-full"> <div class="flex-1 h-full px-4 py-4">
<Editor <Transition
v-model="modelValue" mode="out-in"
:editor-key="editorKey" enter-active-class="transition-all duration-300 ease-out"
:placeholder=" enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER') enter-to-class="opacity-100 translate-y-0 scale-100"
" leave-active-class="transition-all duration-200 ease-in"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]" leave-from-class="opacity-100 translate-y-0 scale-100"
:class=" leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
hasErrors >
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9' <div
: '' :key="copilot ? copilot.editorTransitionKey.value : 'rich'"
" class="h-full"
enable-variables >
:show-character-count="false" <CopilotEditorSection
:signature="messageSignature" v-if="isCopilotActive"
allow-signature :show-copilot-editor="copilot.showEditor.value"
:send-with-signature="sendWithSignature" :is-generating-content="copilot.isGenerating.value"
:channel-type="channelType" :generated-content="copilot.generatedContent.value"
:medium="medium" class="!mb-0"
/> @focus="() => {}"
@blur="() => {}"
@clear-selection="() => {}"
@content-ready="copilot.setContentReady"
@send="copilot.sendFollowUp"
/>
<Editor
v-else
v-model="modelValue"
:editor-key="editorKey"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-0 [&>div]:py-0 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[12rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
enable-captain-tools
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
@execute-copilot-action="executeCopilotAction"
/>
</div>
</Transition>
</div> </div>
</template> </template>

View File

@@ -177,19 +177,42 @@ export const prepareWhatsAppMessagePayload = ({
}; };
// API Calls // API Calls
export const searchContacts = async query => { const MIN_SEARCH_LENGTH = 2;
const trimmed = typeof query === 'string' ? query.trim() : '';
if (!trimmed) return [];
const { export const createContactSearcher = () => {
data: { payload }, let controller = null;
} = await ContactAPI.search(trimmed);
const camelCasedPayload = camelcaseKeys(payload, { deep: true }); return async (query, { skipMinLength = false } = {}) => {
// Filter contacts that have either phone_number or email const trimmed = typeof query === 'string' ? query.trim() : '';
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email controller?.abort();
);
return filteredPayload || []; if (!trimmed || (!skipMinLength && trimmed.length < MIN_SEARCH_LENGTH))
return [];
controller = new AbortController();
const { signal } = controller;
try {
const {
data: { payload },
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
} catch (error) {
// Return null for aborted requests so callers can distinguish
// "request was cancelled" from "no results found"
if (error?.name === 'AbortError' || error?.name === 'CanceledError') {
return null;
}
throw error;
}
};
}; };
export const createNewContact = async input => { export const createNewContact = async input => {

View File

@@ -337,7 +337,12 @@ describe('composeConversationHelper', () => {
}); });
describe('API calls', () => { describe('API calls', () => {
describe('searchContacts', () => { describe('createContactSearcher', () => {
let searchContacts;
beforeEach(() => {
searchContacts = helpers.createContactSearcher();
});
it('searches contacts and returns camelCase results', async () => { it('searches contacts and returns camelCase results', async () => {
const mockPayload = [ const mockPayload = [
{ {
@@ -353,7 +358,7 @@ describe('composeConversationHelper', () => {
data: { payload: mockPayload }, data: { payload: mockPayload },
}); });
const result = await helpers.searchContacts('john'); const result = await searchContacts('john');
expect(result).toEqual([ expect(result).toEqual([
{ {
@@ -365,7 +370,56 @@ describe('composeConversationHelper', () => {
}, },
]); ]);
expect(ContactAPI.search).toHaveBeenCalledWith('john'); expect(ContactAPI.search).toHaveBeenCalledWith(
'john',
1,
'name',
'',
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
});
it('returns empty array for queries shorter than 2 characters', async () => {
const result = await searchContacts('j');
expect(result).toEqual([]);
expect(ContactAPI.search).not.toHaveBeenCalled();
});
it('returns empty array for empty or whitespace-only queries', async () => {
expect(await searchContacts('')).toEqual([]);
expect(await searchContacts(' ')).toEqual([]);
expect(await searchContacts(null)).toEqual([]);
expect(ContactAPI.search).not.toHaveBeenCalled();
});
it('aborts previous in-flight request when a new search starts', async () => {
const mockPayload = [
{ id: 1, name: 'Result', email: 'r@test.com', phone_number: null },
];
let resolveFirst;
const firstCall = new Promise(resolve => {
resolveFirst = resolve;
});
ContactAPI.search
.mockReturnValueOnce(firstCall)
.mockResolvedValueOnce({ data: { payload: mockPayload } });
// Start first search (will hang)
const first = searchContacts('alpha');
// Start second search (aborts first)
const second = searchContacts('beta');
// Resolve the first call with CanceledError (simulating axios abort)
const canceledError = new Error('canceled');
canceledError.name = 'CanceledError';
resolveFirst(Promise.reject(canceledError));
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult).toBeNull();
expect(secondResult).toEqual([
{ id: 1, name: 'Result', email: 'r@test.com', phoneNumber: null },
]);
}); });
it('searches contacts and returns only contacts with email or phone number', async () => { it('searches contacts and returns only contacts with email or phone number', async () => {
@@ -397,7 +451,7 @@ describe('composeConversationHelper', () => {
data: { payload: mockPayload }, data: { payload: mockPayload },
}); });
const result = await helpers.searchContacts('john'); const result = await searchContacts('john');
// Should only return contacts with either email or phone number // Should only return contacts with either email or phone number
expect(result).toEqual([ expect(result).toEqual([
@@ -417,7 +471,13 @@ describe('composeConversationHelper', () => {
}, },
]); ]);
expect(ContactAPI.search).toHaveBeenCalledWith('john'); expect(ContactAPI.search).toHaveBeenCalledWith(
'john',
1,
'name',
'',
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
}); });
it('handles empty search results', async () => { it('handles empty search results', async () => {
@@ -425,7 +485,7 @@ describe('composeConversationHelper', () => {
data: { payload: [] }, data: { payload: [] },
}); });
const result = await helpers.searchContacts('nonexistent'); const result = await searchContacts('nonexistent');
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@@ -452,7 +512,7 @@ describe('composeConversationHelper', () => {
data: { payload: mockPayload }, data: { payload: mockPayload },
}); });
const result = await helpers.searchContacts('test'); const result = await searchContacts('test');
expect(result).toEqual([ expect(result).toEqual([
{ {
@@ -474,6 +534,36 @@ describe('composeConversationHelper', () => {
}); });
}); });
describe('createContactSearcher isolation', () => {
it('creates isolated searcher instances that do not cancel each other', async () => {
const searcherA = helpers.createContactSearcher();
const searcherB = helpers.createContactSearcher();
const payloadA = [
{ id: 1, name: 'Alice', email: 'a@test.com', phone_number: null },
];
const payloadB = [
{ id: 2, name: 'Bob', email: 'b@test.com', phone_number: null },
];
ContactAPI.search
.mockResolvedValueOnce({ data: { payload: payloadA } })
.mockResolvedValueOnce({ data: { payload: payloadB } });
const [resultA, resultB] = await Promise.all([
searcherA('alice'),
searcherB('bob'),
]);
expect(resultA).toEqual([
{ id: 1, name: 'Alice', email: 'a@test.com', phoneNumber: null },
]);
expect(resultB).toEqual([
{ id: 2, name: 'Bob', email: 'b@test.com', phoneNumber: null },
]);
});
});
describe('createNewContact', () => { describe('createNewContact', () => {
it('creates new contact with capitalized name', async () => { it('creates new contact with capitalized name', async () => {
const mockContact = { id: 1, name: 'John', email: 'john@example.com' }; const mockContact = { id: 1, name: 'John', email: 'john@example.com' };

View File

@@ -72,7 +72,7 @@ const isNewTagInValidType = computed(() =>
const showInput = computed(() => const showInput = computed(() =>
props.mode === MODE.SINGLE props.mode === MODE.SINGLE
? isFocused.value && !tags.value.length ? !tags.value.length
: isFocused.value || !tags.value.length : isFocused.value || !tags.value.length
); );

View File

@@ -10,11 +10,23 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
defineProps({ const props = defineProps({
hasSelection: { hasSelection: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isEditorMenuPopover: {
type: Boolean,
default: false,
},
editorContent: {
type: String,
default: undefined,
},
conversationId: {
type: Number,
default: null,
},
}); });
const emit = defineEmits(['executeCopilotAction']); const emit = defineEmits(['executeCopilotAction']);
@@ -25,6 +37,13 @@ const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode'); const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// When editorContent prop is passed, use it exclusively (even if empty)
// This ensures each editor instance shows menu items based on its own content
// Falls back to global draftMessage only when editorContent is not provided
const effectiveContent = computed(() =>
props.editorContent !== undefined ? props.editorContent : draftMessage.value
);
// Selection-based menu items (when text is selected) // Selection-based menu items (when text is selected)
const menuItems = computed(() => { const menuItems = computed(() => {
const items = []; const items = [];
@@ -42,8 +61,9 @@ const menuItems = computed(() => {
icon: 'i-fluent-pen-sparkle-24-regular', icon: 'i-fluent-pen-sparkle-24-regular',
}); });
} else if ( } else if (
props.conversationId &&
replyMode.value === REPLY_EDITOR_MODES.REPLY && replyMode.value === REPLY_EDITOR_MODES.REPLY &&
draftMessage.value effectiveContent.value
) { ) {
items.push({ items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'), label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
@@ -52,7 +72,7 @@ const menuItems = computed(() => {
}); });
} }
if (draftMessage.value) { if (effectiveContent.value) {
items.push( items.push(
{ {
label: t( label: t(
@@ -105,7 +125,7 @@ const menuItems = computed(() => {
const generalMenuItems = computed(() => { const generalMenuItems = computed(() => {
const items = []; const items = [];
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) { if (props.conversationId && replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({ items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'), label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
key: 'reply_suggestion', key: 'reply_suggestion',
@@ -113,7 +133,10 @@ const generalMenuItems = computed(() => {
}); });
} }
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) { if (
props.conversationId &&
(replyMode.value === REPLY_EDITOR_MODES.NOTE || true)
) {
items.push({ items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'), label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize', key: 'summarize',
@@ -176,8 +199,8 @@ const handleSubMenuItemClick = (parentItem, subItem) => {
<DropdownBody <DropdownBody
ref="menuRef" ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5" class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection }" :class="{ 'selection-menu': hasSelection && isEditorMenuPopover }"
:style="hasSelection ? selectionMenuStyle : {}" :style="hasSelection && isEditorMenuPopover ? selectionMenuStyle : {}"
> >
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5"> <div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
<div <div

View File

@@ -202,6 +202,11 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload'); const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor'); const editor = useTemplateRef('editor');
const isEditorMenuPopover = computed(
() =>
editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false
);
const handleCopilotAction = actionKey => { const handleCopilotAction = actionKey => {
if (actionKey === 'improve_selection' && editorView?.state) { if (actionKey === 'improve_selection' && editorView?.state) {
const { from, to } = editorView.state.selection; const { from, to } = editorView.state.selection;
@@ -211,7 +216,7 @@ const handleCopilotAction = actionKey => {
emit('executeCopilotAction', 'improve', selectedText); emit('executeCopilotAction', 'improve', selectedText);
} }
} else { } else {
emit('executeCopilotAction', actionKey); emit('executeCopilotAction', actionKey, props.modelValue);
} }
showSelectionMenu.value = false; showSelectionMenu.value = false;
@@ -484,6 +489,7 @@ function setToolbarPosition() {
function setMenubarPosition({ selection } = {}) { function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value; const wrapper = editorRoot.value;
if (!selection || !wrapper) return; if (!selection || !wrapper) return;
if (!isEditorMenuPopover.value) return;
const rect = wrapper.getBoundingClientRect(); const rect = wrapper.getBoundingClientRect();
const isRtl = getComputedStyle(wrapper).direction === 'rtl'; const isRtl = getComputedStyle(wrapper).direction === 'rtl';
@@ -866,8 +872,12 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
v-if="showSelectionMenu" v-if="showSelectionMenu"
v-on-click-outside="handleClickOutside" v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected" :has-selection="isTextSelected"
:is-editor-menu-popover="isEditorMenuPopover"
:editor-content="modelValue"
:conversation-id="conversationId"
:show-selection-menu="showSelectionMenu" :show-selection-menu="showSelectionMenu"
:show-general-menu="false" :show-general-menu="false"
class="copilot-editor-menu"
@execute-copilot-action="handleCopilotAction" @execute-copilot-action="handleCopilotAction"
/> />
<input <input
@@ -1026,6 +1036,17 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0; @apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
} }
// Default copilot menu position (non-popover editors like components-next/Editor)
// When popover-prosemirror-menu is NOT on the wrapper, anchor below the menubar
:not(.popover-prosemirror-menu) > .copilot-editor-menu {
top: 1.5rem !important;
[dir='rtl'] & {
left: auto !important;
right: 0 !important;
}
}
// Float editor menu // Float editor menu
.popover-prosemirror-menu { .popover-prosemirror-menu {
position: relative; position: relative;

View File

@@ -49,6 +49,10 @@ export default {
type: Number, type: Number,
default: () => 0, default: () => 0,
}, },
editorContent: {
type: String,
default: undefined,
},
}, },
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'], emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) { setup(props, { emit }) {
@@ -73,8 +77,8 @@ export default {
const { captainTasksEnabled } = useCaptain(); const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false); const showCopilotMenu = ref(false);
const handleCopilotAction = actionKey => { const handleCopilotAction = (actionKey, data) => {
emit('executeCopilotAction', actionKey); emit('executeCopilotAction', actionKey, data || props.editorContent);
showCopilotMenu.value = false; showCopilotMenu.value = false;
}; };
@@ -174,6 +178,8 @@ export default {
v-if="showCopilotMenu" v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside" v-on-click-outside="handleClickOutside"
:has-selection="false" :has-selection="false"
:editor-content="editorContent"
:conversation-id="conversationId"
class="ltr:right-0 rtl:left-0 bottom-full mb-2" class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction" @execute-copilot-action="handleCopilotAction"
/> />

View File

@@ -1245,6 +1245,7 @@ export default {
:is-editor-disabled="isEditorDisabled" :is-editor-disabled="isEditorDisabled"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold" :is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining" :characters-remaining="charactersRemaining"
:editor-content="message"
:popout-reply-box="popOutReplyBox" :popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode" @set-reply-mode="setReplyMode"
@toggle-popout="togglePopout" @toggle-popout="togglePopout"

View File

@@ -613,7 +613,7 @@
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.", "NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
"CONTACT_SELECTOR": { "CONTACT_SELECTOR": {
"LABEL": "To:", "LABEL": "To:",
"TAG_INPUT_PLACEHOLDER": "Search for a contact with name, email or phone number", "TAG_INPUT_PLACEHOLDER": "Enter at least 2 characters to search by name, email, or phone number",
"CONTACT_CREATING": "Creating contact..." "CONTACT_CREATING": "Creating contact..."
}, },
"INBOX_SELECTOR": { "INBOX_SELECTOR": {
@@ -624,9 +624,9 @@
"SUBJECT_LABEL": "Subject :", "SUBJECT_LABEL": "Subject :",
"SUBJECT_PLACEHOLDER": "Enter your email subject here", "SUBJECT_PLACEHOLDER": "Enter your email subject here",
"CC_LABEL": "Cc:", "CC_LABEL": "Cc:",
"CC_PLACEHOLDER": "Search for a contact with their email address", "CC_PLACEHOLDER": "Enter at least 2 characters to search by email",
"BCC_LABEL": "Bcc:", "BCC_LABEL": "Bcc:",
"BCC_PLACEHOLDER": "Search for a contact with their email address", "BCC_PLACEHOLDER": "Enter at least 2 characters to search by email",
"BCC_BUTTON": "Bcc" "BCC_BUTTON": "Bcc"
}, },
"MESSAGE_EDITOR": { "MESSAGE_EDITOR": {

View File

@@ -5,7 +5,7 @@ import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components'; import { vOnClickOutside } from '@vueuse/components';
import { debounce } from '@chatwoot/utils'; import { debounce } from '@chatwoot/utils';
import { useMapGetter } from 'dashboard/composables/store.js'; import { useMapGetter } from 'dashboard/composables/store.js';
import { searchContacts } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper'; import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import { useCamelCase } from 'dashboard/composables/useTransformKeys'; import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { fetchContactDetails } from '../helpers/searchHelper'; import { fetchContactDetails } from '../helpers/searchHelper';
@@ -18,6 +18,8 @@ const props = defineProps({
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
const searchContacts = createContactSearcher();
const FROM_TYPE = { const FROM_TYPE = {
CONTACT: 'contact', CONTACT: 'contact',
AGENT: 'agent', AGENT: 'agent',
@@ -119,7 +121,10 @@ const debouncedSearch = debounce(async query => {
} }
try { try {
const contacts = await searchContacts(query); const contacts = await searchContacts(query, { skipMinLength: true });
// null means the request was aborted (a newer search is in-flight),
if (contacts === null) return;
// Add selected contact to top if not already in results // Add selected contact to top if not already in results
const allContacts = selectedContact.value const allContacts = selectedContact.value
@@ -130,9 +135,8 @@ const debouncedSearch = debounce(async query => {
: contacts; : contacts;
searchedContacts.value = allContacts; searchedContacts.value = allContacts;
isSearching.value = false;
} catch { } catch {
// Ignore error
} finally {
isSearching.value = false; isSearching.value = false;
} }
}, 300); }, 300);