![]()
+import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
+
+import {
+ buildMessageSchema,
+ buildEditor,
+ EditorView,
+ MessageMarkdownTransformer,
+ MessageMarkdownSerializer,
+ EditorState,
+ Selection,
+} from '@chatwoot/prosemirror-schema';
+
+import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
+
+import NextButton from 'dashboard/components-next/button/Button.vue';
+
+const props = defineProps({
+ modelValue: { type: String, default: '' },
+ editorId: { type: String, default: '' },
+ placeholder: {
+ type: String,
+ default: 'Give copilot additional prompts, or ask anything else...',
+ },
+ generatedContent: { type: String, default: '' },
+ autofocus: {
+ type: Boolean,
+ default: true,
+ },
+ isPopout: {
+ type: Boolean,
+ default: false,
+ },
+});
+
+const emit = defineEmits([
+ 'blur',
+ 'input',
+ 'update:modelValue',
+ 'keyup',
+ 'focus',
+ 'keydown',
+ 'send',
+]);
+
+const { formatMessage } = useMessageFormatter();
+
+// Minimal schema with no marks or nodes for copilot input
+const copilotSchema = buildMessageSchema([], []);
+
+const handleSubmit = () => emit('send');
+
+const createState = (
+ content,
+ placeholder,
+ plugins = [],
+ enabledMenuOptions = []
+) => {
+ return EditorState.create({
+ doc: new MessageMarkdownTransformer(copilotSchema).parse(content),
+ plugins: buildEditor({
+ schema: copilotSchema,
+ placeholder,
+ plugins,
+ enabledMenuOptions,
+ }),
+ });
+};
+
+// we don't need them to be reactive
+// It cases weird issues where the objects are proxied
+// and then the editor doesn't work as expected
+let editorView = null;
+let state = null;
+
+// reactive data
+const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
+
+// element refs
+const editor = useTemplateRef('editor');
+
+function contentFromEditor() {
+ if (editorView) {
+ return MessageMarkdownSerializer.serialize(editorView.state.doc);
+ }
+ return '';
+}
+
+function focusEditorInputField() {
+ const { tr } = editorView.state;
+ const selection = Selection.atEnd(tr.doc);
+
+ editorView.dispatch(tr.setSelection(selection));
+ editorView.focus();
+}
+
+function emitOnChange() {
+ emit('update:modelValue', contentFromEditor());
+ emit('input', contentFromEditor());
+}
+
+function onKeyup() {
+ emit('keyup');
+}
+
+function onKeydown(view, event) {
+ emit('keydown');
+
+ // Handle Enter key to send message (Shift+Enter for new line)
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+ handleSubmit();
+ return true; // Prevent ProseMirror's default Enter handling
+ }
+
+ return false; // Allow other keys to work normally
+}
+
+function onBlur() {
+ emit('blur');
+}
+
+function onFocus() {
+ emit('focus');
+}
+
+function checkSelection(editorState) {
+ const hasSelection = editorState.selection.from !== editorState.selection.to;
+ if (hasSelection === isTextSelected.value) return;
+ isTextSelected.value = hasSelection;
+}
+
+// computed properties
+const plugins = computed(() => {
+ return [];
+});
+
+const enabledMenuOptions = computed(() => {
+ return [];
+});
+
+function reloadState() {
+ state = createState(
+ props.modelValue,
+ props.placeholder,
+ plugins.value,
+ enabledMenuOptions.value
+ );
+ editorView.updateState(state);
+ focusEditorInputField();
+}
+
+function createEditorView() {
+ editorView = new EditorView(editor.value, {
+ state: state,
+ dispatchTransaction: tx => {
+ state = state.apply(tx);
+ editorView.updateState(state);
+ if (tx.docChanged) {
+ emitOnChange();
+ }
+ checkSelection(state);
+ },
+ handleDOMEvents: {
+ keyup: onKeyup,
+ focus: onFocus,
+ blur: onBlur,
+ keydown: onKeydown,
+ },
+ });
+}
+
+// watchers
+watch(
+ computed(() => props.modelValue),
+ (newValue = '') => {
+ if (newValue !== contentFromEditor()) {
+ reloadState();
+ }
+ }
+);
+
+watch(
+ computed(() => props.editorId),
+ () => {
+ reloadState();
+ }
+);
+
+// lifecycle
+onMounted(() => {
+ state = createState(
+ props.modelValue,
+ props.placeholder,
+ plugins.value,
+ enabledMenuOptions.value
+ );
+
+ createEditorView();
+ editorView.updateState(state);
+
+ if (props.autofocus) {
+ focusEditorInputField();
+ }
+});
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue b/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue
new file mode 100644
index 000000000..27353dea4
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue
new file mode 100644
index 000000000..e23cdbf7e
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index 850ca5f4b..4e0722c25 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -16,13 +16,16 @@ import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
import VariableList from '../conversation/VariableList.vue';
import TagTools from '../conversation/TagTools.vue';
+import CopilotMenuBar from './CopilotMenuBar.vue';
import { useEmitter } from 'dashboard/composables/emitter';
import { useI18n } from 'vue-i18n';
+import { useCaptain } from 'dashboard/composables/useCaptain';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
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';
@@ -100,13 +103,16 @@ const emit = defineEmits([
'focus',
'input',
'update:modelValue',
+ 'executeCopilotAction',
]);
const { t } = useI18n();
+const { captainTasksEnabled } = useCaptain();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
+const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
@@ -116,17 +122,24 @@ const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate
- ? DEFAULT_FORMATTING
+ ? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value;
- const formatting = getFormattingForEditor(formatType);
+ const formatting = getFormattingForEditor(
+ formatType,
+ captainTasksEnabled.value
+ );
return buildMessageSchema(formatting.marks, formatting.nodes);
});
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
- ? DEFAULT_FORMATTING
+ ? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value || DEFAULT_FORMATTING;
- const formatting = getFormattingForEditor(formatType);
+ const formatting = getFormattingForEditor(
+ formatType,
+ captainTasksEnabled.value
+ );
+
return formatting.menu;
});
@@ -185,6 +198,21 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
+const handleCopilotAction = actionKey => {
+ if (actionKey === 'improve_selection' && editorView?.state) {
+ const { from, to } = editorView.state.selection;
+ const selectedText = editorView.state.doc.textBetween(from, to).trim();
+
+ if (from !== to && selectedText) {
+ emit('executeCopilotAction', 'improve', selectedText);
+ }
+ } else {
+ emit('executeCopilotAction', actionKey);
+ }
+
+ showSelectionMenu.value = false;
+};
+
const contentFromEditor = () => {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
};
@@ -367,13 +395,23 @@ function openFileBrowser() {
imageUpload.value.click();
}
+function handleCopilotClick() {
+ showSelectionMenu.value = !showSelectionMenu.value;
+}
+
+function handleClickOutside(event) {
+ // Check if the clicked element or its parents have the ignored class
+ if (event.target.closest('.ProseMirror-copilot')) return;
+ showSelectionMenu.value = false;
+}
+
function reloadState(content = props.modelValue) {
const unrefContent = unref(content);
state = createState(
unrefContent,
props.placeholder,
plugins.value,
- { onImageUpload: openFileBrowser },
+ { onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -595,7 +633,12 @@ function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
// Use the editor's current schema to ensure compatibility with buildMessageSchema
const currentSchema = editorView.state.schema;
- let node = new MessageMarkdownTransformer(currentSchema).parse(content);
+ // Strip unsupported formatting before parsing to ensure content can be inserted
+ // into channels that don't support certain markdown features (e.g., API channels)
+ const sanitizedContent = stripUnsupportedFormatting(content, currentSchema);
+ let node = new MessageMarkdownTransformer(currentSchema).parse(
+ sanitizedContent
+ );
insertNodeIntoEditor(node, from, undefined);
}
@@ -757,7 +800,7 @@ onMounted(() => {
props.modelValue,
props.placeholder,
plugins.value,
- { onImageUpload: openFileBrowser },
+ { onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -802,6 +845,14 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
:search-key="toolSearchKey"
@select-tool="content => insertSpecialContent('tool', content)"
/>
+
}
*/
const isPrivate = computed(() => {
- return props.disabled || props.mode === REPLY_EDITOR_MODES.NOTE;
+ if (props.isReplyRestricted) {
+ // Force switch to private note when replies are restricted
+ return true;
+ }
+ // Otherwise respect the current mode
+ return props.mode === REPLY_EDITOR_MODES.NOTE;
});
/**
@@ -60,9 +70,9 @@ const translateValue = computed(() => {