HH-598: hide unavailable AI takeover (#155)

* fix(HH-598): hide unavailable AI takeover

* fix: isolate inbox assistant availability

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-24 12:49:46 +08:00
committed by GitHub
co-authored by rogee
parent 907d8ea4e1
commit a7e96afef4
6 changed files with 151 additions and 16 deletions
@@ -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
@@ -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) {
@@ -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: {
@@ -1,5 +1,5 @@
<script>
import { computed } from 'vue';
import { computed, watch } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
@@ -61,6 +61,17 @@ export default {
const store = useStore();
const { t } = useI18n();
const currentChat = useMapGetter('getSelectedChat');
const inboxAssistant = useMapGetter('getCopilotAssistant');
watch(
() => props.conversationId,
conversationId => {
if (conversationId) {
store.dispatch('getInboxCaptainAssistantById', conversationId);
}
},
{ immediate: true }
);
const setReplyMode = mode => {
emit('setReplyMode', mode);
@@ -89,6 +100,9 @@ export default {
const aiTakeoverActive = computed(
() => currentChat.value?.ai_takeover_active === true
);
const aiTakeoverAvailable = computed(
() => Boolean(inboxAssistant.value?.id) || aiTakeoverActive.value
);
const toggleAITakeover = async () => {
try {
@@ -129,6 +143,7 @@ export default {
captainTasksEnabled,
handleCopilotAction,
aiTakeoverActive,
aiTakeoverAvailable,
toggleAITakeover,
};
},
@@ -177,6 +192,7 @@ export default {
class="flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-2 sm:w-auto sm:flex-nowrap"
>
<NextButton
v-if="aiTakeoverAvailable"
type="button"
:label="
aiTakeoverActive
@@ -44,6 +44,8 @@ const getStoredMessage = (conversation, message) => {
const isNewerMessage = (currentMessage, message) =>
isNewerTimestamp(currentMessage?.updated_at, message.updated_at);
let inboxAssistantRequestId = 0;
// actions
const actions = {
getConversation: async ({ commit }, conversationId) => {
@@ -56,7 +58,10 @@ const actions = {
}
},
fetchAllConversations: async ({ commit, state, dispatch }, explicitFilters) => {
fetchAllConversations: async (
{ commit, state, dispatch },
explicitFilters
) => {
commit(types.CLEAR_LIST_ERROR_STATUS);
commit(types.SET_LIST_LOADING_STATUS);
try {
@@ -574,9 +579,14 @@ const actions = {
},
getInboxCaptainAssistantById: async ({ commit }, conversationId) => {
inboxAssistantRequestId += 1;
const requestId = inboxAssistantRequestId;
commit(types.SET_INBOX_CAPTAIN_ASSISTANT, { assistant: null });
try {
const response = await ConversationApi.getInboxAssistant(conversationId);
commit(types.SET_INBOX_CAPTAIN_ASSISTANT, response.data);
if (requestId === inboxAssistantRequestId) {
commit(types.SET_INBOX_CAPTAIN_ASSISTANT, response.data);
}
} catch (error) {
// Handle error
}
@@ -1002,22 +1002,47 @@ describe('#addMentions', () => {
it('fetches inbox assistant by id', async () => {
axios.get.mockResolvedValue({
data: {
id: 1,
name: 'Assistant',
description: 'Assistant description',
},
});
await actions.getInboxCaptainAssistantById({ commit }, 1);
expect(commit.mock.calls).toEqual([
[
types.SET_INBOX_CAPTAIN_ASSISTANT,
{
assistant: {
id: 1,
name: 'Assistant',
description: 'Assistant description',
},
},
});
await actions.getInboxCaptainAssistantById({ commit }, 1);
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_CAPTAIN_ASSISTANT, { assistant: null }],
[
types.SET_INBOX_CAPTAIN_ASSISTANT,
{
assistant: {
id: 1,
name: 'Assistant',
description: 'Assistant description',
},
},
],
]);
});
it('ignores an older response after switching conversations', async () => {
const resolvers = [];
axios.get.mockImplementation(
() => new Promise(resolve => resolvers.push(resolve))
);
const requestA = actions.getInboxCaptainAssistantById({ commit }, 1);
const requestB = actions.getInboxCaptainAssistantById({ commit }, 2);
resolvers[1]({ data: { assistant: null } });
await requestB;
resolvers[0]({ data: { assistant: { id: 1, name: 'Assistant A' } } });
await requestA;
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_CAPTAIN_ASSISTANT, { assistant: null }],
[types.SET_INBOX_CAPTAIN_ASSISTANT, { assistant: null }],
[types.SET_INBOX_CAPTAIN_ASSISTANT, { assistant: null }],
]);
});
});
});