From 226af4959e16bb96369641311603ab66547b0736 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 30 Jul 2026 16:31:14 -0700 Subject: [PATCH] feat: add pending conversation takeover UI (#14876) Adds a dashboard-only takeover path for pending conversations currently handled by an assistant/bot. Agents see the warning by default, stay in private-note mode while the conversation is pending, and can use Take over to move the conversation back to human handling. ## Related - Original scope: https://linear.app/chatwoot/issue/CW-7450/block-replies-and-add-takeover-for-agent-bot-ownership - Backend follow-up: https://linear.app/chatwoot/issue/CW-7779/enforce-backend-reply-blocking-for-agent-bot-owned-conversations ## Why We want to prevent accidental parallel handling from the dashboard while an assistant is managing a pending conversation, without expanding this PR into API-level enforcement. Backend blocking is tracked separately in CW-7779. ## What changed - Locks the dashboard composer to private-note mode while the conversation status is `pending`. - Shows the takeover banner by default for pending conversations. - Uses the Agent Bot assignee name when the conversation payload exposes one, otherwise falls back to `a bot`. - Simplifies the banner action copy to `Take over`. - Reopens and self-assigns the conversation from the takeover action. - Clears stale local `AgentBot` assignee type when assigning the conversation back to a human in the store. ## How to test - Open a pending conversation assigned to an Agent Bot and verify the banner says it is handled by that bot name. - Verify the reply editor stays in private-note mode and public reply mode cannot be selected while the conversation is pending. - Click Take over and verify the conversation moves to open and is assigned to the current agent. - Open a pending Captain/Dialogflow-style conversation without an Agent Bot assignee payload and verify the banner falls back to `a bot`. --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin --- .../widgets/conversation/ReplyBox.vue | 89 +++-- .../widgets/conversation/ReplyBoxBanner.vue | 45 ++- .../conversation/specs/ReplyBox.spec.js | 357 ++++++++++++++++++ .../i18n/locale/en/conversation.json | 5 +- .../store/modules/conversations/index.js | 4 +- .../specs/conversations/mutations.spec.js | 42 +++ 6 files changed, 489 insertions(+), 53 deletions(-) create mode 100644 app/javascript/dashboard/components/widgets/conversation/specs/ReplyBox.spec.js diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 471d10f3c..6a0c4747e 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -172,15 +172,32 @@ export default { showContentTemplates() { return this.isATwilioWhatsAppChannel && !this.isPrivate; }, - isPrivate() { - if ( - this.currentChat.can_reply || + isWithinMessagingWindow() { + return !!( + this.currentChat?.can_reply || this.isAWhatsAppChannel || this.isAPIInbox - ) { - return this.isOnPrivateNote; - } - return true; + ); + }, + canSendPublicReply() { + return ( + this.isWithinMessagingWindow && !this.isBotOwnedPendingConversation + ); + }, + isPrivate() { + return ( + !this.canSendPublicReply || this.replyType === REPLY_EDITOR_MODES.NOTE + ); + }, + isOnPrivateNote() { + return this.isBotOwnedPendingConversation + ? this.isPrivate + : this.replyType === REPLY_EDITOR_MODES.NOTE; + }, + effectiveReplyMode() { + return this.isOnPrivateNote + ? REPLY_EDITOR_MODES.NOTE + : REPLY_EDITOR_MODES.REPLY; }, hasMeaningfulEditorContent() { const body = this.message || ''; @@ -197,10 +214,10 @@ export default { ); return !!stripped.trim(); }, - isReplyRestricted() { + isBotOwnedPendingConversation() { return ( - !this.currentChat?.can_reply && - !(this.isAWhatsAppChannel || this.isAPIInbox) + this.currentChat?.status === wootConstants.STATUS_TYPE.PENDING && + this.currentChat?.meta?.assignee_type === 'AgentBot' ); }, inboxId() { @@ -330,9 +347,6 @@ export default { showAudioRecorderEditor() { return this.showAudioRecorder && this.isRecordingAudio; }, - isOnPrivateNote() { - return this.replyType === REPLY_EDITOR_MODES.NOTE; - }, isOnExpandedLayout() { const { LAYOUT_TYPES: { CONDENSED }, @@ -375,7 +389,7 @@ export default { return this.conversationId; }, editorStateId() { - return `draft-${this.conversationIdByRoute}-${this.replyType}`; + return `draft-${this.conversationIdByRoute}-${this.effectiveReplyMode}`; }, audioRecordFormat() { if (this.isAWhatsAppCloudChannel) { @@ -456,7 +470,6 @@ export default { }, watch: { currentChat(conversation, oldConversation) { - const { can_reply: canReply } = conversation; if (oldConversation && oldConversation.id !== conversation.id) { // Only update email fields when switching to a completely different conversation (by ID) // This prevents overwriting user input (e.g., CC/BCC fields) when performing actions @@ -470,11 +483,9 @@ export default { return; } - if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) { - this.replyType = REPLY_EDITOR_MODES.REPLY; - } else { - this.replyType = REPLY_EDITOR_MODES.NOTE; - } + this.replyType = this.isWithinMessagingWindow + ? REPLY_EDITOR_MODES.REPLY + : REPLY_EDITOR_MODES.NOTE; this.fetchAndSetReplyTo(); }, @@ -492,7 +503,7 @@ export default { }, conversationIdByRoute(conversationId, oldConversationId) { if (conversationId !== oldConversationId) { - this.setToDraft(oldConversationId, this.replyType); + this.setToDraft(oldConversationId, this.effectiveReplyMode); this.getFromDraft(); this.resetRecorderAndClearAttachments(); } @@ -501,13 +512,25 @@ export default { // Autosave the current message draft. this.doAutoSaveDraft(); }, - replyType(updatedReplyType, oldReplyType) { + showWhatsappTemplates(isAvailable) { + if (!isAvailable) this.hideWhatsappTemplatesModal(); + }, + showContentTemplates(isAvailable) { + if (!isAvailable) this.hideContentTemplatesModal(); + }, + effectiveReplyMode(updatedReplyType, oldReplyType) { + this.$store.dispatch('draftMessages/setReplyEditorMode', { + mode: updatedReplyType, + }); this.setToDraft(this.conversationIdByRoute, oldReplyType); this.getFromDraft(); }, }, mounted() { + this.$store.dispatch('draftMessages/setReplyEditorMode', { + mode: this.effectiveReplyMode, + }); this.getFromDraft(); // Don't use the keyboard listener mixin here as the events here are supposed to be // working even if the editor is focussed. @@ -516,7 +539,7 @@ export default { this.setCCAndToEmailsFromLastChat(); this.doAutoSaveDraft = debounce( () => { - this.saveDraft(this.conversationIdByRoute, this.replyType); + this.saveDraft(this.conversationIdByRoute, this.effectiveReplyMode); }, 500, true @@ -549,22 +572,22 @@ export default { methods: { getDraftKey( conversationId = this.conversationIdByRoute, - replyType = this.replyType + replyType = this.effectiveReplyMode ) { return `draft-${conversationId}-${replyType}`; }, - getCopilotAcceptedMessage(replyType = this.replyType) { + getCopilotAcceptedMessage(replyType = this.effectiveReplyMode) { const key = this.getDraftKey(this.conversationIdByRoute, replyType); return this.copilotAcceptedMessages[key] || ''; }, - setCopilotAcceptedMessage(message, replyType = this.replyType) { + setCopilotAcceptedMessage(message, replyType = this.effectiveReplyMode) { const key = this.getDraftKey(this.conversationIdByRoute, replyType); this.copilotAcceptedMessages[key] = trimContent( message || '', this.maxLength ); }, - clearCopilotAcceptedMessage(replyType = this.replyType) { + clearCopilotAcceptedMessage(replyType = this.effectiveReplyMode) { const key = this.getDraftKey(this.conversationIdByRoute, replyType); delete this.copilotAcceptedMessages[key]; }, @@ -933,12 +956,8 @@ export default { // This is to prevent from breaking the upload rules if (this.attachedFiles.length > 0) this.attachedFiles = []; - const { can_reply: canReply } = this.currentChat; - this.$store.dispatch('draftMessages/setReplyEditorMode', { - mode, - }); - if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) - this.replyType = mode; + this.$store.dispatch('draftMessages/setReplyEditorMode', { mode }); + if (this.canSendPublicReply) this.replyType = mode; if (this.isRecordingAudio) { this.toggleAudioRecorder(); } @@ -1009,7 +1028,7 @@ export default { }, onBlur() { this.isFocused = false; - this.saveDraft(this.conversationIdByRoute, this.replyType); + this.saveDraft(this.conversationIdByRoute, this.effectiveReplyMode); }, onFocus() { this.isFocused = true; @@ -1257,7 +1276,7 @@ export default { diff --git a/app/javascript/dashboard/components/widgets/conversation/specs/ReplyBox.spec.js b/app/javascript/dashboard/components/widgets/conversation/specs/ReplyBox.spec.js new file mode 100644 index 000000000..135a0a638 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/specs/ReplyBox.spec.js @@ -0,0 +1,357 @@ +import { shallowMount } from '@vue/test-utils'; +import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants'; +import { nextTick } from 'vue'; +import { createStore } from 'vuex'; +import ReplyBox from '../ReplyBox.vue'; +import WhatsappTemplates from '../WhatsappTemplates/Modal.vue'; + +const CHANNELS = [ + { name: 'WhatsApp Cloud', inbox: { channel_type: 'Channel::Whatsapp' } }, + { + name: 'Twilio WhatsApp', + inbox: { channel_type: 'Channel::TwilioSms', medium: 'whatsapp' }, + }, + { name: 'API', inbox: { channel_type: 'Channel::Api' } }, + { name: 'Instagram', inbox: { channel_type: 'Channel::Instagram' } }, + { name: 'TikTok', inbox: { channel_type: 'Channel::Tiktok' } }, + { name: 'Facebook', inbox: { channel_type: 'Channel::FacebookPage' } }, + { name: 'Line', inbox: { channel_type: 'Channel::Line' } }, + { name: 'Telegram', inbox: { channel_type: 'Channel::Telegram' } }, + { name: 'Email', inbox: { channel_type: 'Channel::Email' } }, + { name: 'Web widget', inbox: { channel_type: 'Channel::WebWidget' } }, +]; + +const exemptFromMessagingWindow = name => + ['WhatsApp Cloud', 'Twilio WhatsApp', 'API'].includes(name); + +const REPLIABLE = { + id: 1, + inbox_id: 1, + can_reply: true, + status: 'open', + meta: { sender: { id: 2 } }, + messages: [], +}; + +const buildStore = ({ inbox, chat, templates, drafts = {} }) => + createStore({ + state: { + chat: { ...REPLIABLE, ...chat }, + replyEditorMode: REPLY_EDITOR_MODES.REPLY, + drafts: { ...drafts }, + }, + mutations: { + selectChat: (s, c) => { + s.chat = c; + }, + setReplyEditorMode: (s, mode) => { + s.replyEditorMode = mode; + }, + setDraft: (s, { key, message }) => { + s.drafts = { ...s.drafts, [key]: message }; + }, + }, + actions: { + 'draftMessages/setReplyEditorMode': ({ commit }, { mode }) => + commit('setReplyEditorMode', mode), + 'draftMessages/set': ({ commit }, payload) => commit('setDraft', payload), + }, + getters: { + getSelectedChat: s => s.chat, + getCurrentUser: () => ({ id: 7, name: 'Agent', accounts: [] }), + getCurrentAccountId: () => 1, + getMessageSignature: () => '', + getUISettings: () => ({}), + getLastEmailInSelectedChat: () => null, + 'globalConfig/get': () => ({}), + 'inboxes/getInbox': () => () => ({ id: 1, ...inbox }), + 'inboxes/getWhatsAppTemplates': () => () => templates, + 'contacts/getContact': () => () => ({}), + 'draftMessages/get': s => key => s.drafts[key] || '', + 'draftMessages/getReplyEditorMode': s => s.replyEditorMode, + 'accounts/isFeatureEnabledonAccount': () => () => false, + 'accounts/getAccount': () => () => ({}), + 'portals/allPortals': () => [], + 'integrations/getUIFlags': () => ({ isFetching: false }), + }, + }); + +const mountWith = ({ + inbox, + chat, + templates = [{ name: 'greeting' }], + drafts, +}) => { + const store = buildStore({ inbox, chat, templates, drafts }); + const wrapper = shallowMount(ReplyBox, { + global: { + plugins: [store], + mocks: { $t: key => key }, + // The bottom panel sits inside a , which shallowMount stubs + // without rendering its children. + stubs: { transition: false }, + }, + }); + return { wrapper, store }; +}; + +const topPanel = wrapper => + wrapper.findComponent({ name: 'ReplyTopPanel' }).props(); +const bottomPanel = wrapper => + wrapper.findComponent({ name: 'ReplyBottomPanel' }).props(); +const editor = wrapper => + wrapper.findComponent({ name: 'WootMessageEditor' }).props(); + +describe('ReplyBox', () => { + describe.each(CHANNELS)('$name', ({ name, inbox }) => { + it('locks the composer and hides template sends when a bot owns a pending conversation', () => { + const { wrapper } = mountWith({ + inbox, + chat: { + status: 'pending', + meta: { sender: { id: 2 }, assignee_type: 'AgentBot' }, + }, + }); + + expect(topPanel(wrapper).isReplyRestricted).toBe(true); + expect(bottomPanel(wrapper).enableWhatsAppTemplates).toBe(false); + expect(bottomPanel(wrapper).enableContentTemplates).toBe(false); + // The note composer stays usable — this is a restriction, not a lockout. + expect(topPanel(wrapper).isEditorDisabled).toBe(false); + }); + + it('opens directly in note mode when a bot already owns the pending conversation', () => { + const { wrapper, store } = mountWith({ + inbox, + chat: { + can_reply: false, + status: 'pending', + meta: { sender: { id: 2 }, assignee_type: 'AgentBot' }, + }, + }); + + expect(bottomPanel(wrapper).isOnPrivateNote).toBe(true); + expect(store.getters['draftMessages/getReplyEditorMode']).toBe( + REPLY_EDITOR_MODES.NOTE + ); + expect(topPanel(wrapper).isEditorDisabled).toBe(false); + }); + + it('opens in reply mode for every other conversation', () => { + const { wrapper, store } = mountWith({ inbox }); + + expect(bottomPanel(wrapper).isOnPrivateNote).toBe(false); + expect(store.getters['draftMessages/getReplyEditorMode']).toBe( + REPLY_EDITOR_MODES.REPLY + ); + }); + + it.each(['open', 'resolved', 'snoozed'])( + 'leaves the composer open when a bot owns a %s conversation', + status => { + const { wrapper } = mountWith({ + inbox, + chat: { + status, + meta: { sender: { id: 2 }, assignee_type: 'AgentBot' }, + }, + }); + + expect(topPanel(wrapper).isReplyRestricted).toBe(false); + expect(bottomPanel(wrapper).enableWhatsAppTemplates).toBe(true); + } + ); + + it('leaves the composer open when a human owns a pending conversation', () => { + const { wrapper } = mountWith({ + inbox, + chat: { + status: 'pending', + meta: { sender: { id: 2 }, assignee_type: 'User' }, + }, + }); + + expect(topPanel(wrapper).isReplyRestricted).toBe(false); + expect(bottomPanel(wrapper).enableWhatsAppTemplates).toBe(true); + }); + + it('matches the existing messaging-window rule when no bot is involved', () => { + const { wrapper } = mountWith({ + inbox, + chat: { can_reply: false, status: 'resolved' }, + }); + + const stillRepliable = exemptFromMessagingWindow(name); + expect(topPanel(wrapper).isReplyRestricted).toBe(!stillRepliable); + expect(bottomPanel(wrapper).enableWhatsAppTemplates).toBe(stillRepliable); + // WhatsApp/API disable the editor and steer to templates; everywhere + // else the composer falls back to a usable private note. + expect(topPanel(wrapper).isEditorDisabled).toBe(stillRepliable); + }); + }); + + it('hides the template action when the inbox has no templates synced', () => { + const { wrapper } = mountWith({ + inbox: { channel_type: 'Channel::Whatsapp' }, + chat: { can_reply: false, status: 'open' }, + templates: [], + }); + + expect(bottomPanel(wrapper).enableWhatsAppTemplates).toBe(false); + expect(topPanel(wrapper).isReplyRestricted).toBe(false); + }); + + describe('drafts', () => { + const DRAFTS = { + 'draft-1-REPLY': 'half typed reply', + 'draft-1-NOTE': 'a note', + }; + + it('loads the note draft while a bot owns the conversation', async () => { + const { wrapper } = mountWith({ + inbox: { channel_type: 'Channel::WebWidget' }, + chat: { + status: 'pending', + meta: { sender: { id: 2 }, assignee_type: 'AgentBot' }, + }, + drafts: DRAFTS, + }); + await nextTick(); + + expect(editor(wrapper)).toMatchObject({ + editorId: 'draft-1-NOTE', + modelValue: 'a note', + }); + }); + + it('leaves the saved reply draft intact and restores it on takeover', async () => { + const { wrapper, store } = mountWith({ + inbox: { channel_type: 'Channel::WebWidget' }, + chat: { + status: 'pending', + meta: { sender: { id: 2 }, assignee_type: 'AgentBot' }, + }, + drafts: DRAFTS, + }); + await nextTick(); + expect(store.getters['draftMessages/get']('draft-1-REPLY')).toBe( + 'half typed reply' + ); + + store.commit('selectChat', { + ...REPLIABLE, + status: 'open', + meta: { sender: { id: 2 }, assignee_type: 'User' }, + }); + await nextTick(); + + expect(editor(wrapper)).toMatchObject({ + editorId: 'draft-1-REPLY', + modelValue: 'half typed reply', + }); + }); + }); + + it('offers content templates on Twilio WhatsApp when no bot owns the conversation', () => { + const { wrapper } = mountWith({ + inbox: { channel_type: 'Channel::TwilioSms', medium: 'whatsapp' }, + chat: { can_reply: true, status: 'open' }, + }); + + expect(bottomPanel(wrapper).enableContentTemplates).toBe(true); + }); + + describe('on selecting a conversation', () => { + const selectChat = async chat => { + const { wrapper, store } = mountWith({ + inbox: { channel_type: 'Channel::WebWidget' }, + }); + store.commit('selectChat', { ...REPLIABLE, id: 99, ...chat }); + await nextTick(); + return { wrapper, store }; + }; + + it('switches to note mode when a bot owns a pending conversation', async () => { + const { wrapper } = await selectChat({ + status: 'pending', + meta: { sender: { id: 2 }, assignee_type: 'AgentBot' }, + }); + + expect(bottomPanel(wrapper).isOnPrivateNote).toBe(true); + }); + + it('stays in reply mode when a human owns a pending conversation', async () => { + const { wrapper } = await selectChat({ + status: 'pending', + meta: { sender: { id: 2 }, assignee_type: 'User' }, + }); + + expect(bottomPanel(wrapper).isOnPrivateNote).toBe(false); + }); + + it('closes an open template modal when a bot takes over the conversation', async () => { + const { wrapper, store } = mountWith({ + inbox: { channel_type: 'Channel::Whatsapp' }, + }); + wrapper + .findComponent({ name: 'ReplyBottomPanel' }) + .vm.$emit('selectWhatsappTemplate'); + await nextTick(); + expect(wrapper.findComponent(WhatsappTemplates).props('show')).toBe(true); + + store.commit('selectChat', { + ...REPLIABLE, + status: 'pending', + meta: { sender: { id: 2 }, assignee_type: 'AgentBot' }, + }); + await nextTick(); + + expect(wrapper.findComponent(WhatsappTemplates).props('show')).toBe( + false + ); + }); + + it('returns to reply mode once the agent takes over', async () => { + const { wrapper, store } = await selectChat({ + status: 'pending', + meta: { sender: { id: 2 }, assignee_type: 'AgentBot' }, + }); + expect(bottomPanel(wrapper).isOnPrivateNote).toBe(true); + + store.commit('selectChat', { + ...REPLIABLE, + id: 99, + status: 'open', + meta: { sender: { id: 2 }, assignee_type: 'User' }, + }); + await nextTick(); + + expect(bottomPanel(wrapper).isOnPrivateNote).toBe(false); + expect(topPanel(wrapper).isReplyRestricted).toBe(false); + expect(store.getters['draftMessages/getReplyEditorMode']).toBe( + REPLY_EDITOR_MODES.REPLY + ); + }); + + it('keeps the agent in note mode after they chose it themselves', async () => { + const { wrapper } = await selectChat({}); + wrapper + .findComponent({ name: 'ReplyTopPanel' }) + .vm.$emit('setReplyMode', REPLY_EDITOR_MODES.NOTE); + await nextTick(); + + expect(bottomPanel(wrapper).isOnPrivateNote).toBe(true); + }); + + it('mirrors the forced note mode into the draftMessages store', async () => { + const { store } = await selectChat({ + status: 'pending', + meta: { sender: { id: 2 }, assignee_type: 'AgentBot' }, + }); + + expect(store.getters['draftMessages/getReplyEditorMode']).toBe( + REPLY_EDITOR_MODES.NOTE + ); + }); + }); +}); diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 54eaa3b82..2ad4317ad 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -36,8 +36,9 @@ "API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours", "NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?", "ASSIGN_TO_ME": "Assign to me", - "BOT_HANDOFF_MESSAGE": "You are responding to a conversation which is currently handled by an assistant or a bot.", - "BOT_HANDOFF_ACTION": "Mark open and assign to you", + "BOT_HANDOFF_MESSAGE": "This conversation is currently handled by {assigneeName}.", + "BOT_HANDOFF_FALLBACK_ASSIGNEE": "a bot", + "BOT_HANDOFF_ACTION": "Take over", "BOT_HANDOFF_REOPEN_ACTION": "Mark conversation open", "BOT_HANDOFF_SUCCESS": "Conversation has been handed over to you", "BOT_HANDOFF_ERROR": "Failed to take over the conversation. Please try again.", diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index 4f539e22d..fe25616f4 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -112,7 +112,9 @@ export const mutations = { const chat = getConversationById(_state)(conversationId); if (chat) { chat.meta.assignee = assignee; - chat.meta.assignee_type = assigneeType; + const inferredAssigneeType = assignee ? 'User' : null; + chat.meta.assignee_type = + assigneeType === undefined ? inferredAssigneeType : assigneeType; } }, diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js index a97bdad53..a0766d901 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js @@ -718,6 +718,48 @@ describe('#mutations', () => { expect(state.allConversations[0].meta.assignee_type).toEqual('AgentBot'); expect(state.allConversations[1].meta.assignee).toBeUndefined(); }); + + it('should update assignee type when provided', () => { + const assignee = { id: 1, name: 'Agent' }; + const state = { + allConversations: [{ id: 1, meta: { assignee_type: 'AgentBot' } }], + }; + + mutations[types.ASSIGN_AGENT](state, { + conversationId: 1, + assignee, + assigneeType: 'User', + }); + + expect(state.allConversations[0].meta.assignee_type).toEqual('User'); + }); + + it('should infer user assignee type when assignee type is omitted', () => { + const assignee = { id: 1, name: 'Agent' }; + const state = { + allConversations: [{ id: 1, meta: { assignee_type: 'AgentBot' } }], + }; + + mutations[types.ASSIGN_AGENT](state, { + conversationId: 1, + assignee, + }); + + expect(state.allConversations[0].meta.assignee_type).toEqual('User'); + }); + + it('should clear assignee type when assignee type and assignee are omitted', () => { + const state = { + allConversations: [{ id: 1, meta: { assignee_type: 'AgentBot' } }], + }; + + mutations[types.ASSIGN_AGENT](state, { + conversationId: 1, + assignee: null, + }); + + expect(state.allConversations[0].meta.assignee_type).toBeNull(); + }); }); describe('#ASSIGN_PRIORITY', () => {