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 <iamsivin@gmail.com>
This commit is contained in:
Sojan Jose
2026-07-30 16:31:14 -07:00
committed by GitHub
parent 6ed0f11e6b
commit 226af4959e
6 changed files with 489 additions and 53 deletions

View File

@@ -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 {
<ReplyTopPanel
:mode="replyType"
:conversation-id="conversationId"
:is-reply-restricted="isReplyRestricted"
:is-reply-restricted="!canSendPublicReply"
:disabled="
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
showAudioRecorderEditor

View File

@@ -34,6 +34,7 @@ const assignedAgent = computed({
store.dispatch('setCurrentChatAssignee', {
conversationId: currentChat.value?.id,
assignee: agent,
assigneeType: agent ? 'User' : null,
});
store.dispatch('assignAgent', {
conversationId: currentChat.value?.id,
@@ -42,9 +43,8 @@ const assignedAgent = computed({
},
});
const isUserTyping = computed(
() => props.message !== '' && !props.isOnPrivateNote
);
const hasMessage = computed(() => props.message !== '');
const isUserTyping = computed(() => hasMessage.value && !props.isOnPrivateNote);
const isUnassigned = computed(() => !assignedAgent.value);
const isAssignedToOtherAgent = computed(
() => assignedAgent.value?.id !== currentUser.value?.id
@@ -56,16 +56,24 @@ const showSelfAssignBanner = computed(() => {
);
});
const showBotHandoffBanner = computed(
() =>
isUserTyping.value &&
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
const isPendingConversation = computed(
() => currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
);
const botHandoffActionLabel = computed(() => {
return assignedAgent.value?.id === currentUser.value?.id
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
: t('CONVERSATION.BOT_HANDOFF_ACTION');
const isAgentBotOwned = computed(
() => currentChat.value?.meta?.assignee_type === 'AgentBot'
);
const showBotHandoffBanner = computed(() => {
return isPendingConversation.value && isAgentBotOwned.value;
});
const botAssigneeName = computed(() => {
if (isAgentBotOwned.value && assignedAgent.value?.name) {
return assignedAgent.value.name;
}
return t('CONVERSATION.BOT_HANDOFF_FALLBACK_ASSIGNEE');
});
const selfAssignConversation = async () => {
@@ -89,15 +97,18 @@ const onClickSelfAssign = async () => {
const reopenConversation = async () => {
await store.dispatch('toggleStatus', {
conversationId: currentChat.value?.id,
status: wootConstants.STATUS_TYPE.OPEN,
status: 'open',
});
};
const onClickBotHandoff = async () => {
try {
const shouldAssignToCurrentUser =
isAgentBotOwned.value || needsAssignmentToCurrentUser.value;
await reopenConversation();
if (needsAssignmentToCurrentUser.value) {
if (shouldAssignToCurrentUser) {
await selfAssignConversation();
}
@@ -124,9 +135,13 @@ const onClickBotHandoff = async () => {
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg !py-2"
:banner-message="$t('CONVERSATION.BOT_HANDOFF_MESSAGE')"
:banner-message="
$t('CONVERSATION.BOT_HANDOFF_MESSAGE', {
assigneeName: botAssigneeName,
})
"
has-action-button
:action-button-label="botHandoffActionLabel"
:action-button-label="$t('CONVERSATION.BOT_HANDOFF_ACTION')"
@primary-action="onClickBotHandoff"
/>
</template>

View File

@@ -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 <Transition>, 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
);
});
});
});

View File

@@ -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.",

View File

@@ -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;
}
},

View File

@@ -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', () => {