Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/ui-lib

This commit is contained in:
Shivam Mishra
2026-03-20 17:34:27 +05:30
2279 changed files with 77643 additions and 20821 deletions
@@ -107,7 +107,8 @@ function onKeydown(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
// Skip if IME composition is active (CJK character confirmation)
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling
@@ -10,11 +10,23 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
defineProps({
const props = defineProps({
hasSelection: {
type: Boolean,
default: false,
},
isEditorMenuPopover: {
type: Boolean,
default: false,
},
editorContent: {
type: String,
default: undefined,
},
conversationId: {
type: Number,
default: null,
},
});
const emit = defineEmits(['executeCopilotAction']);
@@ -25,6 +37,13 @@ const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// When editorContent prop is passed, use it exclusively (even if empty)
// This ensures each editor instance shows menu items based on its own content
// Falls back to global draftMessage only when editorContent is not provided
const effectiveContent = computed(() =>
props.editorContent !== undefined ? props.editorContent : draftMessage.value
);
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
@@ -42,8 +61,9 @@ const menuItems = computed(() => {
icon: 'i-fluent-pen-sparkle-24-regular',
});
} else if (
props.conversationId &&
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
draftMessage.value
effectiveContent.value
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
@@ -52,7 +72,7 @@ const menuItems = computed(() => {
});
}
if (draftMessage.value) {
if (effectiveContent.value) {
items.push(
{
label: t(
@@ -105,7 +125,7 @@ const menuItems = computed(() => {
const generalMenuItems = computed(() => {
const items = [];
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
if (props.conversationId && replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
key: 'reply_suggestion',
@@ -113,7 +133,10 @@ const generalMenuItems = computed(() => {
});
}
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
if (
props.conversationId &&
(replyMode.value === REPLY_EDITOR_MODES.NOTE || true)
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize',
@@ -176,8 +199,8 @@ const handleSubMenuItemClick = (parentItem, subItem) => {
<DropdownBody
ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection }"
:style="hasSelection ? selectionMenuStyle : {}"
:class="{ 'selection-menu': hasSelection && isEditorMenuPopover }"
:style="hasSelection && isEditorMenuPopover ? selectionMenuStyle : {}"
>
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
<div
@@ -28,7 +28,10 @@ import { useAlert } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
CONVERSATION_EVENTS,
CAPTAIN_EVENTS,
} from 'dashboard/helper/AnalyticsHelper/events';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
@@ -87,6 +90,7 @@ const props = defineProps({
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
forceSignature: { type: Boolean, default: false },
conversationId: { type: Number, default: null },
medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
@@ -199,6 +203,11 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
const isEditorMenuPopover = computed(
() =>
editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false
);
const handleCopilotAction = actionKey => {
if (actionKey === 'improve_selection' && editorView?.state) {
const { from, to } = editorView.state.selection;
@@ -208,7 +217,7 @@ const handleCopilotAction = actionKey => {
emit('executeCopilotAction', 'improve', selectedText);
}
} else {
emit('executeCopilotAction', actionKey);
emit('executeCopilotAction', actionKey, props.modelValue);
}
showSelectionMenu.value = false;
@@ -401,7 +410,14 @@ function openFileBrowser() {
}
function handleCopilotClick() {
showSelectionMenu.value = !showSelectionMenu.value;
const isOpening = !showSelectionMenu.value;
if (isOpening) {
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
conversationId: props.conversationId,
entryPoint: 'inline',
});
}
showSelectionMenu.value = isOpening;
}
function handleClickOutside(event) {
@@ -478,6 +494,7 @@ function setToolbarPosition() {
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
if (!isEditorMenuPopover.value) return;
const rect = wrapper.getBoundingClientRect();
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
@@ -823,6 +840,8 @@ onMounted(async () => {
}
});
defineExpose({ focusEditorInputField });
// BUS Event to insert text or markdown into the editor at the
// current cursor position.
// Components using this
@@ -831,7 +850,13 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
</script>
<template>
<div ref="editorRoot" class="relative w-full">
<div
ref="editorRoot"
class="relative w-full"
:class="{
'opacity-50 cursor-not-allowed pointer-events-none': disabled,
}"
>
<TagAgents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@@ -861,8 +886,12 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
v-if="showSelectionMenu"
v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected"
:is-editor-menu-popover="isEditorMenuPopover"
:editor-content="modelValue"
:conversation-id="conversationId"
:show-selection-menu="showSelectionMenu"
:show-general-menu="false"
class="copilot-editor-menu"
@execute-copilot-action="handleCopilotAction"
/>
<input
@@ -967,11 +996,15 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
}
.ProseMirror-prompt-backdrop::backdrop {
@apply bg-n-alpha-black1 backdrop-blur-[4px];
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
@apply bg-n-alpha-3 border border-n-strong p-6 shadow-xl rounded-xl w-96 !important;
h5 {
@apply text-n-slate-12 mb-1.5;
@apply text-n-slate-12 mb-3;
}
.ProseMirror-prompt-buttons {
@@ -1025,6 +1058,17 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
}
// Default copilot menu position (non-popover editors like components-next/Editor)
// When popover-prosemirror-menu is NOT on the wrapper, anchor below the menubar
:not(.popover-prosemirror-menu) > .copilot-editor-menu {
top: 1.5rem !important;
[dir='rtl'] & {
left: auto !important;
right: 0 !important;
}
}
// Float editor menu
.popover-prosemirror-menu {
position: relative;
@@ -326,26 +326,4 @@ export default {
max-height: 7.5rem;
overflow: auto;
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@apply text-n-slate-12 mb-1.5;
}
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
}
}
</style>
@@ -128,6 +128,10 @@ export default {
type: Boolean,
default: false,
},
isEditorDisabled: {
type: Boolean,
default: false,
},
},
emits: [
'replaceText',
@@ -136,7 +140,7 @@ export default {
'selectContentTemplate',
'toggleQuotedReply',
],
setup() {
setup(props) {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings();
@@ -145,6 +149,9 @@ export default {
const keyboardEvents = {
'$mod+Alt+KeyA': {
action: () => {
// Skip if editor is disabled (e.g., WhatsApp 24-hour window expired)
if (props.isEditorDisabled) return;
// TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated.
// Once we have the custom component, we can remove the hacky logic below.
@@ -152,7 +159,7 @@ export default {
const uploadTriggerButton = document.querySelector(
'#conversationAttachment'
);
uploadTriggerButton.click();
if (uploadTriggerButton) uploadTriggerButton.click();
},
allowOnFocusedInput: true,
},
@@ -183,10 +190,12 @@ export default {
};
},
showAttachButton() {
if (this.isEditorDisabled) return false;
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
if (this.isALineChannel) {
if (this.isEditorDisabled) return false;
if (this.isALineChannel || this.isATiktokChannel) {
return false;
}
// Disable audio recorder for safari browser as recording is not supported
@@ -203,6 +212,7 @@ export default {
);
},
showAudioPlayStopButton() {
if (this.isEditorDisabled) return false;
return this.showAudioRecorder && this.isRecordingAudio;
},
isInstagramDM() {
@@ -242,6 +252,7 @@ export default {
}
},
showMessageSignatureButton() {
if (this.isEditorDisabled) return false;
return !this.isOnPrivateNote;
},
sendWithSignature() {
@@ -287,6 +298,7 @@ export default {
<div class="left-wrap">
<NextButton
v-if="allowEmoji"
v-if="!isEditorDisabled"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="i-ph-smiley-sticker"
slate
@@ -296,6 +308,7 @@ export default {
/>
<FileUpload
v-if="allowFileUpload"
v-if="showAttachButton"
ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
@@ -374,11 +387,12 @@ export default {
sm
@click="$emit('selectContentTemplate')"
/>
<VideoCallButton
<VideoCallButtonnPrivateNote
v-if="
allowVideoCall &&
(isAWebWidgetInbox || isAPIInbox) &&
!isOnPrivateNote
!isOnPrivateNote &&
!isEditorDisabled
"
:conversation-id="conversationId"
/>
@@ -2,8 +2,10 @@
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
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';
@@ -35,6 +37,14 @@ export default {
type: Boolean,
default: false,
},
isEditorDisabled: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
default: null,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -43,6 +53,10 @@ export default {
type: Number,
default: () => 0,
},
editorContent: {
type: String,
default: undefined,
},
},
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) {
@@ -67,13 +81,20 @@ export default {
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
const handleCopilotAction = actionKey => {
emit('executeCopilotAction', actionKey);
const handleCopilotAction = (actionKey, data) => {
emit('executeCopilotAction', actionKey, data || props.editorContent);
showCopilotMenu.value = false;
};
const toggleCopilotMenu = () => {
showCopilotMenu.value = !showCopilotMenu.value;
const isOpening = !showCopilotMenu.value;
if (isOpening) {
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
conversationId: props.conversationId,
entryPoint: 'top_panel',
});
}
showCopilotMenu.value = isOpening;
};
const handleClickOutside = () => {
@@ -148,7 +169,7 @@ export default {
<div class="relative">
<NextButton
ghost
:disabled="disabled"
:disabled="disabled || isEditorDisabled"
:class="{
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
@@ -161,6 +182,8 @@ export default {
v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside"
:has-selection="false"
:editor-content="editorContent"
:conversation-id="conversationId"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
/>