HH-546: flatten conversation AI actions (#128)

* fix(HH-546): flatten conversation AI actions

* fix(HH-546): wrap AI actions on narrow screens

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-23 21:16:19 +08:00
committed by GitHub
co-authored by rogee
parent eb1b5c8066
commit e41d40feb8
4 changed files with 253 additions and 92 deletions
@@ -28,6 +28,14 @@ const props = defineProps({
type: Number,
default: null,
},
inline: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['executeCopilotAction']);
@@ -145,6 +153,11 @@ const generalMenuItems = computed(() => {
return items;
});
const allMenuItems = computed(() => [
...menuItems.value,
...generalMenuItems.value,
]);
const menuRef = useTemplateRef('menuRef');
const { height: menuHeight } = useElementSize(menuRef);
const { width: windowWidth } = useWindowSize();
@@ -188,7 +201,49 @@ const handleSubMenuItemClick = (parentItem, subItem) => {
</script>
<template>
<div
v-if="inline"
data-testid="copilot-inline-actions"
class="flex flex-shrink-0 items-center gap-1"
>
<div
v-for="item in allMenuItems"
:key="item.key"
class="group/submenu relative"
>
<Button
type="button"
:aria-label="item.label"
:title="item.label"
:icon="item.icon"
:disabled="disabled"
slate
ghost
sm
class="text-n-violet-9 hover:enabled:!bg-n-violet-3"
@click="handleMenuItemClick(item)"
/>
<DropdownBody
v-if="item.subMenuItems"
class="bottom-full ltr:right-0 rtl:left-0 mb-2 hidden group-hover/submenu:block group-focus-within/submenu:block [&>ul]:gap-2 [&>ul]:px-3 [&>ul]:py-2.5 min-w-32 z-10"
>
<Button
v-for="subItem in item.subMenuItems"
:key="subItem.key"
type="button"
:label="subItem.label"
:disabled="disabled"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start mb-1"
@click="handleSubMenuItemClick(item, subItem)"
/>
</DropdownBody>
</div>
</div>
<DropdownBody
v-else
ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection && isEditorMenuPopover }"
@@ -0,0 +1,136 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import { ref } from 'vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
import ReplyTopPanel from './ReplyTopPanel.vue';
vi.mock('dashboard/composables/useCaptain', () => ({
useCaptain: () => ({ captainTasksEnabled: ref(true) }),
}));
vi.mock('dashboard/composables/useKeyboardEvents', () => ({
useKeyboardEvents: vi.fn(),
}));
const createWrapper = ({ aiTakeoverActive = false, props = {} } = {}) => {
const startAITakeover = vi.fn();
const exitAITakeover = vi.fn();
const store = createStore({
state: {
currentChat: { id: 42, ai_takeover_active: aiTakeoverActive },
},
getters: {
getSelectedChat: state => state.currentChat,
},
actions: {
startAITakeover,
exitAITakeover,
},
modules: {
draftMessages: {
namespaced: true,
getters: { getReplyEditorMode: () => 'REPLY' },
},
},
});
return {
store,
startAITakeover,
wrapper: mount(ReplyTopPanel, {
props: { conversationId: 42, ...props },
global: {
plugins: [store],
stubs: { NextButton: false },
},
}),
};
};
describe('ReplyTopPanel', () => {
it('shows AI takeover and flat Copilot actions in the same action row', () => {
const { wrapper } = createWrapper();
const actions = wrapper.get('[data-testid="reply-top-panel-actions"]');
const copilotActions = actions.getComponent(CopilotMenuBar);
expect(copilotActions.props('inline')).toBe(true);
expect(actions.text()).toContain('AI takeover');
expect(actions.get('[data-testid="copilot-inline-actions"]')).toBeTruthy();
});
it('wraps all eight primary actions on narrow screens when content is present', () => {
const { wrapper } = createWrapper({ props: { hasContent: true } });
const actions = wrapper.get('[data-testid="reply-top-panel-actions"]');
const copilotActions = actions.get(
'[data-testid="copilot-inline-actions"]'
);
expect(wrapper.classes()).toEqual(
expect.arrayContaining(['flex-wrap', 'sm:flex-nowrap'])
);
expect(actions.classes()).toEqual(
expect.arrayContaining([
'w-full',
'flex-wrap',
'sm:w-auto',
'sm:flex-nowrap',
])
);
expect(actions.findAll(':scope > button')).toHaveLength(2);
expect(copilotActions.findAll(':scope > div > button')).toHaveLength(6);
});
it('keeps AI takeover behavior in the new action row', async () => {
const { startAITakeover, wrapper } = createWrapper();
const takeoverButton = wrapper
.get('[data-testid="reply-top-panel-actions"]')
.findAll('button')
.find(button => button.text() === 'AI takeover');
await takeoverButton.trigger('click');
expect(startAITakeover).toHaveBeenCalledWith(expect.any(Object), 42);
});
it('renders Copilot actions inline and forwards the selected action', async () => {
const store = createStore({
modules: {
draftMessages: {
namespaced: true,
getters: { getReplyEditorMode: () => 'REPLY' },
},
},
});
const wrapper = mount(CopilotMenuBar, {
props: { inline: true, conversationId: 42 },
global: {
plugins: [store],
stubs: { NextButton: false },
},
});
const suggestionButton = wrapper.get(
'[data-testid="copilot-inline-actions"] button[aria-label="Suggest a reply"]'
);
await suggestionButton.trigger('click');
expect(wrapper.emitted('executeCopilotAction')).toEqual([
['reply_suggestion'],
]);
});
it('disables every flat Copilot action with the editor', () => {
const { wrapper } = createWrapper({
props: { hasContent: true, isEditorDisabled: true },
});
const copilotButtons = wrapper
.get('[data-testid="copilot-inline-actions"]')
.findAll('button');
expect(copilotButtons.length).toBeGreaterThan(0);
expect(
copilotButtons.every(button => button.attributes('disabled') === '')
).toBe(true);
});
});
@@ -1,11 +1,12 @@
<script>
import { ref } from 'vue';
import { computed } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
@@ -17,9 +18,6 @@ export default {
EditorModeToggle,
CopilotMenuBar,
},
directives: {
OnClickOutside: vOnClickOutside,
},
props: {
mode: {
type: String,
@@ -60,6 +58,10 @@ export default {
},
emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
setup(props, { emit }) {
const store = useStore();
const { t } = useI18n();
const currentChat = useMapGetter('getSelectedChat');
const setReplyMode = mode => {
emit('setReplyMode', mode);
};
@@ -79,27 +81,32 @@ export default {
};
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
const copilotToggleRef = ref(null);
const handleCopilotAction = (actionKey, data) => {
emit('executeCopilotAction', actionKey, data || props.editorContent);
showCopilotMenu.value = false;
};
const toggleCopilotMenu = () => {
const isOpening = !showCopilotMenu.value;
if (isOpening) {
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
conversationId: props.conversationId,
entryPoint: 'top_panel',
});
const aiTakeoverActive = computed(
() => currentChat.value?.ai_takeover_active === true
);
const toggleAITakeover = async () => {
try {
const wasActive = aiTakeoverActive.value;
await store.dispatch(
wasActive ? 'exitAITakeover' : 'startAITakeover',
currentChat.value?.id
);
useAlert(
wasActive
? t('CONVERSATION.AI_TAKEOVER.EXIT_SUCCESS')
: t('CONVERSATION.AI_TAKEOVER.START_SUCCESS')
);
} catch (error) {
useAlert(
error?.response?.data?.error || t('CONVERSATION.AI_TAKEOVER.ERROR')
);
}
showCopilotMenu.value = isOpening;
};
const handleClickOutside = () => {
showCopilotMenu.value = false;
};
const keyboardEvents = {
@@ -121,10 +128,8 @@ export default {
REPLY_EDITOR_MODES,
captainTasksEnabled,
handleCopilotAction,
showCopilotMenu,
copilotToggleRef,
toggleCopilotMenu,
handleClickOutside,
aiTakeoverActive,
toggleAITakeover,
};
},
computed: {
@@ -152,7 +157,7 @@ export default {
<template>
<div
class="flex justify-between gap-2 h-[3.25rem] items-center ltr:pl-3 ltr:pr-2 rtl:pr-3 rtl:pl-2"
class="flex min-h-[3.25rem] flex-wrap items-center justify-between gap-2 py-2 ltr:pl-3 ltr:pr-2 rtl:pr-3 rtl:pl-2 sm:h-[3.25rem] sm:flex-nowrap sm:py-0"
>
<EditorModeToggle
:mode="mode"
@@ -160,43 +165,44 @@ export default {
:is-reply-restricted="isReplyRestricted"
@toggle-mode="handleModeToggle"
/>
<div class="flex items-center mx-4 my-0">
<div class="flex min-w-0 flex-1 items-center">
<div v-if="isMessageLengthReachingThreshold" class="text-xs">
<span :class="charLengthClass">
{{ characterLengthWarning }}
</span>
</div>
</div>
<div v-if="captainTasksEnabled" class="flex items-center gap-2">
<div class="relative">
<NextButton
ref="copilotToggleRef"
ghost
:disabled="disabled || isEditorDisabled"
:class="{
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
}"
sm
icon="i-ph-sparkle-fill"
@click="toggleCopilotMenu"
/>
<CopilotMenuBar
v-if="showCopilotMenu"
v-on-click-outside="[
handleClickOutside,
{ ignore: [copilotToggleRef] },
]"
:has-selection="false"
:has-content="hasContent"
:conversation-id="conversationId"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
/>
</div>
<div
data-testid="reply-top-panel-actions"
class="flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-2 sm:w-auto sm:flex-nowrap"
>
<NextButton
type="button"
:label="
aiTakeoverActive
? $t('CONVERSATION.AI_TAKEOVER.EXIT')
: $t('CONVERSATION.AI_TAKEOVER.START')
"
slate
outline
sm
class="flex-shrink-0"
@click="toggleAITakeover"
/>
<CopilotMenuBar
v-if="captainTasksEnabled"
inline
:disabled="disabled || isEditorDisabled"
:has-selection="false"
:has-content="hasContent"
:conversation-id="conversationId"
@execute-copilot-action="handleCopilotAction"
/>
<NextButton
v-if="captainTasksEnabled"
type="button"
ghost
class="text-n-slate-11"
class="flex-shrink-0 text-n-slate-11"
sm
icon="i-lucide-maximize-2"
@click="$emit('toggleEditorSize')"
@@ -62,29 +62,6 @@ const showBotHandoffBanner = computed(
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
);
const aiTakeoverActive = computed(
() => currentChat.value?.ai_takeover_active === true
);
const toggleAITakeover = async () => {
try {
const wasActive = aiTakeoverActive.value;
const action = wasActive ? 'exitAITakeover' : 'startAITakeover';
await store.dispatch(action, currentChat.value?.id);
useAlert(
t(
wasActive
? 'CONVERSATION.AI_TAKEOVER.EXIT_SUCCESS'
: 'CONVERSATION.AI_TAKEOVER.START_SUCCESS'
)
);
} catch (error) {
useAlert(
error?.response?.data?.error || t('CONVERSATION.AI_TAKEOVER.ERROR')
);
}
};
const botHandoffActionLabel = computed(() => {
return assignedAgent.value?.id === currentUser.value?.id
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
@@ -132,19 +109,6 @@ const onClickBotHandoff = async () => {
</script>
<template>
<div class="mx-2 mb-2 flex justify-end">
<button
type="button"
class="rounded-md border border-n-slate-5 px-3 py-1.5 text-sm font-medium text-n-slate-12 hover:bg-n-alpha-2"
@click="toggleAITakeover"
>
{{
aiTakeoverActive
? $t('CONVERSATION.AI_TAKEOVER.EXIT')
: $t('CONVERSATION.AI_TAKEOVER.START')
}}
</button>
</div>
<Banner
v-if="showSelfAssignBanner && !showBotHandoffBanner"
action-button-variant="ghost"