From a7e96afef4676a258ce621ff9a81f8daddd161c3 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 24 Aug 2026 12:49:46 +0800 Subject: [PATCH] HH-598: hide unavailable AI takeover (#155) * fix(HH-598): hide unavailable AI takeover * fix: isolate inbox assistant availability --------- Co-authored-by: Rogee --- .../api/v1/conversation_handler_crud_test.go | 22 +++++++ .../internal/service/conversation_service.go | 2 +- .../widgets/WootWriter/ReplyTopPanel.spec.js | 66 ++++++++++++++++++- .../widgets/WootWriter/ReplyTopPanel.vue | 18 ++++- .../store/modules/conversations/actions.js | 14 +++- .../specs/conversations/actions.spec.js | 45 ++++++++++--- 6 files changed, 151 insertions(+), 16 deletions(-) diff --git a/backend/internal/handler/api/v1/conversation_handler_crud_test.go b/backend/internal/handler/api/v1/conversation_handler_crud_test.go index b12ce90c..21f3efc5 100644 --- a/backend/internal/handler/api/v1/conversation_handler_crud_test.go +++ b/backend/internal/handler/api/v1/conversation_handler_crud_test.go @@ -533,6 +533,28 @@ func (s *ConversationCrudTestSuite) TestInboxAssistant_ReturnsNilWhenUnbound() { assert.Nil(s.T(), resp["assistant"]) } +func (s *ConversationCrudTestSuite) TestInboxAssistant_ReturnsNilWhenBoundAssistantInactive() { + assistant := &model.CaptainAssistant{AccountID: s.testAccount.ID, Name: "Inactive Helper", Config: json.RawMessage(`{}`), Status: model.AssistantStatusDraft} + s.Require().NoError(s.db.Create(assistant).Error) + s.Require().NoError(s.db.Create(&model.CaptainInbox{AccountID: s.testAccount.ID, AssistantID: assistant.ID, InboxID: s.testInbox.ID}).Error) + + assertUnavailable := func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", s.convURL(s.testConv.ID)+"/inbox_assistant", nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Nil(s.T(), resp["assistant"]) + } + + assertUnavailable() + assistant.Status = model.AssistantStatusArchived + s.Require().NoError(s.db.Save(assistant).Error) + assertUnavailable() +} + func (s *ConversationCrudTestSuite) TestInboxAssistant_UsesDisplayIDRoute() { displayID := uint(77) s.testConv.DisplayID = &displayID diff --git a/backend/internal/service/conversation_service.go b/backend/internal/service/conversation_service.go index de5cf535..f36283d2 100644 --- a/backend/internal/service/conversation_service.go +++ b/backend/internal/service/conversation_service.go @@ -207,7 +207,7 @@ func (s *ConversationService) GetInboxAssistant(ctx context.Context, accountID, var assistant model.CaptainAssistant err = s.repo.DB().WithContext(ctx). Joins("JOIN captain_inboxes ON captain_inboxes.captain_assistant_id = captain_assistants.id"). - Where("captain_inboxes.account_id = ? AND captain_inboxes.inbox_id = ? AND captain_assistants.account_id = ?", accountID, conversation.InboxID, accountID). + Where("captain_inboxes.account_id = ? AND captain_inboxes.inbox_id = ? AND captain_inboxes.deleted_at IS NULL AND captain_assistants.account_id = ? AND captain_assistants.status = ?", accountID, conversation.InboxID, accountID, model.AssistantStatusActive). Order("captain_inboxes.id DESC"). First(&assistant).Error if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/frontend/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.spec.js b/frontend/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.spec.js index f0ba171b..c97beeca 100644 --- a/frontend/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.spec.js +++ b/frontend/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.spec.js @@ -1,10 +1,15 @@ -import { mount } from '@vue/test-utils'; +import axios from 'axios'; +import { flushPromises, mount } from '@vue/test-utils'; import { createStore } from 'vuex'; import { ref } from 'vue'; +import conversationActions from 'dashboard/store/modules/conversations/actions'; +import types from 'dashboard/store/mutation-types'; import CopilotMenuBar from './CopilotMenuBar.vue'; import ReplyTopPanel from './ReplyTopPanel.vue'; +vi.mock('axios'); + vi.mock('dashboard/composables/useCaptain', () => ({ useCaptain: () => ({ captainTasksEnabled: ref(true) }), })); @@ -13,19 +18,35 @@ vi.mock('dashboard/composables/useKeyboardEvents', () => ({ useKeyboardEvents: vi.fn(), })); -const createWrapper = ({ aiTakeoverActive = false, props = {} } = {}) => { +const createWrapper = ({ + aiTakeoverActive = false, + inboxAssistant = { id: 7 }, + useRealAssistantAction = false, + props = {}, +} = {}) => { const startAITakeover = vi.fn(); const exitAITakeover = vi.fn(); + const getInboxCaptainAssistantById = vi.fn(); const store = createStore({ state: { currentChat: { id: 42, ai_takeover_active: aiTakeoverActive }, + copilotAssistant: inboxAssistant, }, getters: { getSelectedChat: state => state.currentChat, + getCopilotAssistant: state => state.copilotAssistant, + }, + mutations: { + [types.SET_INBOX_CAPTAIN_ASSISTANT](state, data) { + state.copilotAssistant = data.assistant; + }, }, actions: { startAITakeover, exitAITakeover, + getInboxCaptainAssistantById: useRealAssistantAction + ? conversationActions.getInboxCaptainAssistantById + : getInboxCaptainAssistantById, }, modules: { draftMessages: { @@ -37,7 +58,9 @@ const createWrapper = ({ aiTakeoverActive = false, props = {} } = {}) => { return { store, + exitAITakeover, startAITakeover, + getInboxCaptainAssistantById, wrapper: mount(ReplyTopPanel, { props: { conversationId: 42, ...props }, global: { @@ -93,6 +116,45 @@ describe('ReplyTopPanel', () => { expect(startAITakeover).toHaveBeenCalledWith(expect.any(Object), 42); }); + it('hides AI takeover when the inbox has no AI configured', () => { + const { getInboxCaptainAssistantById, startAITakeover, wrapper } = + createWrapper({ inboxAssistant: null }); + + expect(getInboxCaptainAssistantById).toHaveBeenCalledWith( + expect.any(Object), + 42 + ); + expect(wrapper.text()).not.toContain('AI takeover'); + expect(startAITakeover).not.toHaveBeenCalled(); + }); + + it('updates after the inbox assistant API returns null', async () => { + axios.get.mockResolvedValue({ data: { assistant: null } }); + const { store, wrapper } = createWrapper({ + useRealAssistantAction: true, + }); + + await flushPromises(); + + expect(store.getters.getCopilotAssistant).toBeNull(); + expect(wrapper.text()).not.toContain('AI takeover'); + }); + + it('keeps the exit action when takeover is active without an assistant', async () => { + const { exitAITakeover, wrapper } = createWrapper({ + aiTakeoverActive: true, + inboxAssistant: null, + }); + const exitButton = wrapper + .get('[data-testid="reply-top-panel-actions"]') + .findAll('button') + .find(button => button.text() === 'Exit AI takeover'); + + await exitButton.trigger('click'); + + expect(exitAITakeover).toHaveBeenCalledWith(expect.any(Object), 42); + }); + it('renders Copilot actions inline and forwards the selected action', async () => { const store = createStore({ modules: { diff --git a/frontend/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue b/frontend/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue index ec13f0b6..09eaf85d 100644 --- a/frontend/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue +++ b/frontend/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue @@ -1,5 +1,5 @@