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:
@@ -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 }"
|
||||
|
||||
+136
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user