-
+
+
+
+ {}"
+ @blur="() => {}"
+ @clear-selection="() => {}"
+ @content-ready="copilot.setContentReady"
+ @send="copilot.sendFollowUp"
+ />
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js b/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js
index 2139e8cf8..5c002d9bd 100644
--- a/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js
+++ b/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js
@@ -177,19 +177,42 @@ export const prepareWhatsAppMessagePayload = ({
};
// API Calls
-export const searchContacts = async query => {
- const trimmed = typeof query === 'string' ? query.trim() : '';
- if (!trimmed) return [];
+const MIN_SEARCH_LENGTH = 2;
- const {
- data: { payload },
- } = await ContactAPI.search(trimmed);
- 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 || [];
+export const createContactSearcher = () => {
+ let controller = null;
+
+ return async (query, { skipMinLength = false } = {}) => {
+ const trimmed = typeof query === 'string' ? query.trim() : '';
+
+ controller?.abort();
+
+ 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 => {
diff --git a/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js b/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js
index 985e473b3..73bd0ce99 100644
--- a/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js
+++ b/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js
@@ -337,7 +337,12 @@ describe('composeConversationHelper', () => {
});
describe('API calls', () => {
- describe('searchContacts', () => {
+ describe('createContactSearcher', () => {
+ let searchContacts;
+ beforeEach(() => {
+ searchContacts = helpers.createContactSearcher();
+ });
+
it('searches contacts and returns camelCase results', async () => {
const mockPayload = [
{
@@ -353,7 +358,7 @@ describe('composeConversationHelper', () => {
data: { payload: mockPayload },
});
- const result = await helpers.searchContacts('john');
+ const result = await searchContacts('john');
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 () => {
@@ -397,7 +451,7 @@ describe('composeConversationHelper', () => {
data: { payload: mockPayload },
});
- const result = await helpers.searchContacts('john');
+ const result = await searchContacts('john');
// Should only return contacts with either email or phone number
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 () => {
@@ -425,7 +485,7 @@ describe('composeConversationHelper', () => {
data: { payload: [] },
});
- const result = await helpers.searchContacts('nonexistent');
+ const result = await searchContacts('nonexistent');
expect(result).toEqual([]);
});
@@ -452,7 +512,7 @@ describe('composeConversationHelper', () => {
data: { payload: mockPayload },
});
- const result = await helpers.searchContacts('test');
+ const result = await searchContacts('test');
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', () => {
it('creates new contact with capitalized name', async () => {
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
diff --git a/app/javascript/dashboard/components-next/taginput/TagInput.vue b/app/javascript/dashboard/components-next/taginput/TagInput.vue
index b0ecb6aff..a736eb5cc 100644
--- a/app/javascript/dashboard/components-next/taginput/TagInput.vue
+++ b/app/javascript/dashboard/components-next/taginput/TagInput.vue
@@ -72,7 +72,7 @@ const isNewTagInValidType = computed(() =>
const showInput = computed(() =>
props.mode === MODE.SINGLE
- ? isFocused.value && !tags.value.length
+ ? !tags.value.length
: isFocused.value || !tags.value.length
);
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue b/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue
index 27353dea4..af9cc9f68 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue
@@ -10,11 +10,23 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
-defineProps({
+const props = defineProps({
hasSelection: {
type: Boolean,
default: false,
},
+ isEditorMenuPopover: {
+ type: Boolean,
+ default: false,
+ },
+ editorContent: {
+ type: String,
+ default: undefined,
+ },
+ conversationId: {
+ type: Number,
+ default: null,
+ },
});
const emit = defineEmits(['executeCopilotAction']);
@@ -25,6 +37,13 @@ const { draftMessage } = useCaptain();
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)
const menuItems = computed(() => {
const items = [];
@@ -42,8 +61,9 @@ const menuItems = computed(() => {
icon: 'i-fluent-pen-sparkle-24-regular',
});
} else if (
+ props.conversationId &&
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
- draftMessage.value
+ effectiveContent.value
) {
items.push({
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(
{
label: t(
@@ -105,7 +125,7 @@ const menuItems = computed(() => {
const generalMenuItems = computed(() => {
const items = [];
- if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
+ if (props.conversationId && replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.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({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize',
@@ -176,8 +199,8 @@ const handleSubMenuItemClick = (parentItem, subItem) => {
+ editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false
+);
+
const handleCopilotAction = actionKey => {
if (actionKey === 'improve_selection' && editorView?.state) {
const { from, to } = editorView.state.selection;
@@ -211,7 +216,7 @@ const handleCopilotAction = actionKey => {
emit('executeCopilotAction', 'improve', selectedText);
}
} else {
- emit('executeCopilotAction', actionKey);
+ emit('executeCopilotAction', actionKey, props.modelValue);
}
showSelectionMenu.value = false;
@@ -484,6 +489,7 @@ function setToolbarPosition() {
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
+ if (!isEditorMenuPopover.value) return;
const rect = wrapper.getBoundingClientRect();
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
@@ -866,8 +872,12 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
v-if="showSelectionMenu"
v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected"
+ :is-editor-menu-popover="isEditorMenuPopover"
+ :editor-content="modelValue"
+ :conversation-id="conversationId"
:show-selection-menu="showSelectionMenu"
:show-general-menu="false"
+ class="copilot-editor-menu"
@execute-copilot-action="handleCopilotAction"
/>
.copilot-editor-menu {
+ top: 1.5rem !important;
+
+ [dir='rtl'] & {
+ left: auto !important;
+ right: 0 !important;
+ }
+}
+
// Float editor menu
.popover-prosemirror-menu {
position: relative;
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue
index ae49145dc..210741481 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue
@@ -49,6 +49,10 @@ export default {
type: Number,
default: () => 0,
},
+ editorContent: {
+ type: String,
+ default: undefined,
+ },
},
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) {
@@ -73,8 +77,8 @@ export default {
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
- const handleCopilotAction = actionKey => {
- emit('executeCopilotAction', actionKey);
+ const handleCopilotAction = (actionKey, data) => {
+ emit('executeCopilotAction', actionKey, data || props.editorContent);
showCopilotMenu.value = false;
};
@@ -174,6 +178,8 @@ export default {
v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside"
:has-selection="false"
+ :editor-content="editorContent"
+ :conversation-id="conversationId"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
/>
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index 23b4d89a8..81c424aec 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -1245,6 +1245,7 @@ export default {
:is-editor-disabled="isEditorDisabled"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
+ :editor-content="message"
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode"
@toggle-popout="togglePopout"
diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json
index 0d9f79984..803cd66cb 100644
--- a/app/javascript/dashboard/i18n/locale/en/contact.json
+++ b/app/javascript/dashboard/i18n/locale/en/contact.json
@@ -613,7 +613,7 @@
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
"CONTACT_SELECTOR": {
"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..."
},
"INBOX_SELECTOR": {
@@ -624,9 +624,9 @@
"SUBJECT_LABEL": "Subject :",
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
"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_PLACEHOLDER": "Search for a contact with their email address",
+ "BCC_PLACEHOLDER": "Enter at least 2 characters to search by email",
"BCC_BUTTON": "Bcc"
},
"MESSAGE_EDITOR": {
diff --git a/app/javascript/dashboard/modules/search/components/SearchContactAgentSelector.vue b/app/javascript/dashboard/modules/search/components/SearchContactAgentSelector.vue
index 17edc33a5..a177296c0 100644
--- a/app/javascript/dashboard/modules/search/components/SearchContactAgentSelector.vue
+++ b/app/javascript/dashboard/modules/search/components/SearchContactAgentSelector.vue
@@ -5,7 +5,7 @@ import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { debounce } from '@chatwoot/utils';
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 { fetchContactDetails } from '../helpers/searchHelper';
@@ -18,6 +18,8 @@ const props = defineProps({
const emit = defineEmits(['change']);
+const searchContacts = createContactSearcher();
+
const FROM_TYPE = {
CONTACT: 'contact',
AGENT: 'agent',
@@ -119,7 +121,10 @@ const debouncedSearch = debounce(async query => {
}
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
const allContacts = selectedContact.value
@@ -130,9 +135,8 @@ const debouncedSearch = debounce(async query => {
: contacts;
searchedContacts.value = allContacts;
+ isSearching.value = false;
} catch {
- // Ignore error
- } finally {
isSearching.value = false;
}
}, 300);