diff --git a/backend/internal/handler/widget/widget_handler.go b/backend/internal/handler/widget/widget_handler.go index 7e6dfc36..9f3886f3 100644 --- a/backend/internal/handler/widget/widget_handler.go +++ b/backend/internal/handler/widget/widget_handler.go @@ -1404,6 +1404,7 @@ func widgetConversationPayload(conversation model.Conversation) gin.H { "inbox_id": conversation.InboxID, "contact_last_seen_at": conversation.ContactLastSeenAt, "status": conversation.Status, + "ai_takeover_active": conversation.AssigneeAgentBotID != nil && conversation.Status == string(model.ConversationStatusPending), "custom_attributes": conversation.CustomAttributes, } } diff --git a/backend/internal/service/widget_service.go b/backend/internal/service/widget_service.go index 9e748334..e663258a 100644 --- a/backend/internal/service/widget_service.go +++ b/backend/internal/service/widget_service.go @@ -529,6 +529,18 @@ func (s *WidgetService) createIncomingMessage(ctx context.Context, conversation if err != nil { return err } + // Pending without an active Captain bot is a human-waiting conversation. + // Reopen it when the customer replies so it returns to the normal inbox + // and does not look like an agent is typing forever in the widget. + if !reopen && !conversation.Muted && conversation.Status == string(model.ConversationStatusPending) && + conversation.AssigneeAgentBotID == nil && captainJob == nil { + reopen = true + if err := tx.Model(&model.Conversation{}). + Where("id = ? AND account_id = ?", conversation.ID, conversation.AccountID). + Updates(map[string]any{"status": string(model.ConversationStatusOpen), "snoozed_until": nil}).Error; err != nil { + return err + } + } if s.realtime != nil && event != nil { var committedConversation model.Conversation if err := tx.Where("id = ? AND account_id = ?", conversation.ID, conversation.AccountID).First(&committedConversation).Error; err != nil { diff --git a/backend/internal/service/widget_service_test.go b/backend/internal/service/widget_service_test.go index d78dfbfa..96f198da 100644 --- a/backend/internal/service/widget_service_test.go +++ b/backend/internal/service/widget_service_test.go @@ -354,6 +354,20 @@ func TestWidgetService_PublicCreateMessageUpdatesConversationBeforeDispatch(t *t assert.Equal(t, wantTimestamp, *eventConversation.LastActivityAt) } +func TestWidgetService_PublicCreateMessageReopensPendingHumanConversation(t *testing.T) { + db, svc := setupWidgetServiceTest(t) + _, _, _, _, conversation, displayID, _ := seedPublicMessageTest(t, db, model.ConversationStatusPending) + + message, reopened, _, err := svc.PublicCreateMessage(context.Background(), "public-api", "visitor-source", displayID, PublicMessageRequest{Content: "reply to human"}) + require.NoError(t, err) + assert.Equal(t, message.CreatedAt.Unix(), *reopened.LastActivityAt) + assert.Equal(t, string(model.ConversationStatusOpen), reopened.Status) + + var persisted model.Conversation + require.NoError(t, db.First(&persisted, conversation.ID).Error) + assert.Equal(t, string(model.ConversationStatusOpen), persisted.Status) +} + func TestWidgetService_PublicCreateMessageReturnsDispatchError(t *testing.T) { db, svc := setupWidgetServiceTest(t) _, _, _, _, _, displayID, _ := seedPublicMessageTest(t, db, model.ConversationStatusOpen) diff --git a/frontend/app/javascript/widget/components/ConversationWrap.spec.js b/frontend/app/javascript/widget/components/ConversationWrap.spec.js new file mode 100644 index 00000000..09876e32 --- /dev/null +++ b/frontend/app/javascript/widget/components/ConversationWrap.spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import { createStore } from 'vuex'; +import { describe, expect, it } from 'vitest'; +import ConversationWrap from './ConversationWrap.vue'; + +const mountConversation = ({ + status = 'pending', + aiTakeoverActive = false, + isAgentTyping = false, + messageType = 0, +} = {}) => + shallowMount(ConversationWrap, { + props: { groupedMessages: [] }, + global: { + plugins: [ + createStore({ + getters: { + 'appConfig/darkMode': () => 'light', + 'conversation/getEarliestMessage': () => ({ id: 1 }), + 'conversation/getLastMessage': () => ({ + message_type: messageType, + }), + 'conversation/getAllMessagesLoaded': () => true, + 'conversation/getIsFetchingList': () => false, + 'conversation/getConversationSize': () => 1, + 'conversation/getIsAgentTyping': () => isAgentTyping, + 'conversationAttributes/getConversationParams': () => ({ + id: 1, + status, + aiTakeoverActive, + }), + }, + }), + ], + }, + }); + +describe('ConversationWrap', () => { + it('does not show typing for an ordinary pending conversation', () => { + const wrapper = mountConversation(); + + expect(wrapper.vm.showStatusIndicator).toBe(false); + }); + + it('shows typing while an AI takeover is waiting for a reply', () => { + const wrapper = mountConversation({ aiTakeoverActive: true }); + + expect(wrapper.vm.showStatusIndicator).toBe(true); + }); + + it('shows typing when an explicit agent typing event is active', () => { + const wrapper = mountConversation({ + status: 'open', + isAgentTyping: true, + }); + + expect(wrapper.vm.showStatusIndicator).toBe(true); + }); +}); diff --git a/frontend/app/javascript/widget/components/ConversationWrap.vue b/frontend/app/javascript/widget/components/ConversationWrap.vue index d3ce0c7e..15397e06 100755 --- a/frontend/app/javascript/widget/components/ConversationWrap.vue +++ b/frontend/app/javascript/widget/components/ConversationWrap.vue @@ -45,14 +45,10 @@ export default { return `${this.darkMode === 'dark' ? 'dark-scheme' : 'light-scheme'}`; }, showStatusIndicator() { - const { status } = this.conversationAttributes; - const isConversationInPendingStatus = status === 'pending'; + const { aiTakeoverActive } = this.conversationAttributes; const isLastMessageIncoming = this.lastMessage.message_type === MESSAGE_TYPE.INCOMING; - return ( - this.isAgentTyping || - (isConversationInPendingStatus && isLastMessageIncoming) - ); + return this.isAgentTyping || (aiTakeoverActive && isLastMessageIncoming); }, }, watch: { diff --git a/frontend/app/javascript/widget/store/modules/conversationAttributes.js b/frontend/app/javascript/widget/store/modules/conversationAttributes.js index ddcd8d6e..322e4f70 100644 --- a/frontend/app/javascript/widget/store/modules/conversationAttributes.js +++ b/frontend/app/javascript/widget/store/modules/conversationAttributes.js @@ -8,6 +8,7 @@ import { getConversationAPI } from '../../api/conversation'; const state = { id: '', status: '', + aiTakeoverActive: false, }; export const getters = { @@ -37,16 +38,21 @@ export const mutations = { [SET_CONVERSATION_ATTRIBUTES]($state, data) { $state.id = data.id; $state.status = data.status; + $state.aiTakeoverActive = data.ai_takeover_active === true; }, [UPDATE_CONVERSATION_ATTRIBUTES]($state, data) { if (data.id === $state.id) { $state.id = data.id; $state.status = data.status; + if (Object.prototype.hasOwnProperty.call(data, 'ai_takeover_active')) { + $state.aiTakeoverActive = data.ai_takeover_active === true; + } } }, [CLEAR_CONVERSATION_ATTRIBUTES]($state) { $state.id = ''; $state.status = ''; + $state.aiTakeoverActive = false; }, }; diff --git a/frontend/app/javascript/widget/store/modules/specs/conversationAttributes/mutations.spec.js b/frontend/app/javascript/widget/store/modules/specs/conversationAttributes/mutations.spec.js index ede3c3a8..58e66ab9 100644 --- a/frontend/app/javascript/widget/store/modules/specs/conversationAttributes/mutations.spec.js +++ b/frontend/app/javascript/widget/store/modules/specs/conversationAttributes/mutations.spec.js @@ -3,42 +3,61 @@ import { mutations } from '../../conversationAttributes'; describe('#mutations', () => { describe('#SET_CONVERSATION_ATTRIBUTES', () => { it('set status of the conversation', () => { - const state = { id: '', status: '' }; + const state = { id: '', status: '', aiTakeoverActive: false }; mutations.SET_CONVERSATION_ATTRIBUTES(state, { id: 1, status: 'open', + ai_takeover_active: true, + }); + expect(state).toEqual({ + id: 1, + status: 'open', + aiTakeoverActive: true, }); - expect(state).toEqual({ id: 1, status: 'open' }); }); }); describe('#UPDATE_CONVERSATION_ATTRIBUTES', () => { it('update status if it is same conversation', () => { - const state = { id: 1, status: 'pending' }; + const state = { id: 1, status: 'pending', aiTakeoverActive: false }; mutations.UPDATE_CONVERSATION_ATTRIBUTES(state, { id: 1, status: 'open', + ai_takeover_active: true, + }); + expect(state).toEqual({ + id: 1, + status: 'open', + aiTakeoverActive: true, }); - expect(state).toEqual({ id: 1, status: 'open' }); }); it('doesnot update status if it is not the same conversation', () => { - const state = { id: 1, status: 'pending' }; + const state = { id: 1, status: 'pending', aiTakeoverActive: false }; mutations.UPDATE_CONVERSATION_ATTRIBUTES(state, { id: 2, status: 'open', + ai_takeover_active: true, + }); + expect(state).toEqual({ + id: 1, + status: 'pending', + aiTakeoverActive: false, }); - expect(state).toEqual({ id: 1, status: 'pending' }); }); }); describe('#CLEAR_CONVERSATION_ATTRIBUTES', () => { it('clear status if it is same conversation', () => { - const state = { id: 1, status: 'open' }; + const state = { id: 1, status: 'open', aiTakeoverActive: true }; mutations.CLEAR_CONVERSATION_ATTRIBUTES(state, { id: 1, status: 'open', }); - expect(state).toEqual({ id: '', status: '' }); + expect(state).toEqual({ + id: '', + status: '', + aiTakeoverActive: false, + }); }); }); });