From 101eca300339e804d28da508ed1216237faa81d3 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Tue, 17 Feb 2026 13:26:56 +0530
Subject: [PATCH] feat: add captain editor events (#13524)
## Description
Adds missing analytics instrumentation for the editor AI funnel so we
can measure end-to-end usage and outcome quality.
### What was added
- Captain: Editor AI menu opened
- Captain: Generation failed
- Captain: AI-assisted message sent
### Behavior covered
- Tracks AI button click + menu open from both entry points:
- top panel sparkle button
- inline editor copilot button
- Tracks generation failures (initial + follow-up stages).
- Tracks whether accepted AI content was sent as-is or edited before
send.
### Notes
- Applies to editor Captain accept/send flow
(rewrite/summarize/reply_suggestion + follow-ups).
- Does not change Copilot sidebar flow instrumentation.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
### Manual verification steps
1. Open a conversation with Captain tasks enabled.
2. Click AI button in top panel and inline editor.
3. Confirm analytics events fire for:
- AI menu opened
4. Run an AI action and force a failure scenario (or empty response
path) and confirm generation-failed event.
5. Accept AI output, then:
- send without changes -> editedBeforeSend: false
- edit then send -> editedBeforeSend: true
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
.../components/widgets/WootWriter/Editor.vue | 15 +-
.../widgets/WootWriter/ReplyTopPanel.vue | 15 +-
.../widgets/conversation/ReplyBox.vue | 106 +++++++++--
.../composables/captain/constants.js | 12 ++
.../dashboard/composables/useCaptain.js | 36 +++-
.../dashboard/composables/useCopilotReply.js | 171 ++++++++++++++----
.../helper/AnalyticsHelper/events.js | 5 +
7 files changed, 304 insertions(+), 56 deletions(-)
create mode 100644 app/javascript/dashboard/composables/captain/constants.js
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index 4e0722c25..a323d4095 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -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 {
@@ -86,6 +89,7 @@ const props = defineProps({
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
+ 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 },
@@ -396,7 +400,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) {
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue
index 0912cc698..09939f5d2 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue
@@ -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';
@@ -31,6 +33,10 @@ export default {
type: Boolean,
default: false,
},
+ conversationId: {
+ type: Number,
+ default: null,
+ },
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -69,7 +75,14 @@ export default {
};
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 = () => {
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index e9d2f6d08..ee63aa711 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -41,7 +41,10 @@ import {
truncatePreviewText,
appendQuotedTextToMessage,
} from 'dashboard/helper/quotedEmailHelper';
-import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
+import {
+ CONVERSATION_EVENTS,
+ CAPTAIN_EVENTS,
+} from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
@@ -136,6 +139,7 @@ export default {
newConversationModalActive: false,
showArticleSearchPopover: false,
hasRecordedAudio: false,
+ copilotAcceptedMessages: {},
};
},
computed: {
@@ -508,6 +512,24 @@ export default {
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
},
methods: {
+ getDraftKey(
+ conversationId = this.conversationIdByRoute,
+ replyType = this.replyType
+ ) {
+ return `draft-${conversationId}-${replyType}`;
+ },
+ getCopilotAcceptedMessage(replyType = this.replyType) {
+ const key = this.getDraftKey(this.conversationIdByRoute, replyType);
+ return this.copilotAcceptedMessages[key] || '';
+ },
+ setCopilotAcceptedMessage(message, replyType = this.replyType) {
+ const key = this.getDraftKey(this.conversationIdByRoute, replyType);
+ this.copilotAcceptedMessages[key] = trimContent(message || '');
+ },
+ clearCopilotAcceptedMessage(replyType = this.replyType) {
+ const key = this.getDraftKey(this.conversationIdByRoute, replyType);
+ delete this.copilotAcceptedMessages[key];
+ },
handleInsert(article) {
const { url, title } = article;
// Removing empty lines from the title
@@ -559,7 +581,7 @@ export default {
},
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
- const key = `draft-${conversationId}-${replyType}`;
+ const key = this.getDraftKey(conversationId, replyType);
const draftToSave = trimContent(this.message || '');
this.$store.dispatch('draftMessages/set', {
@@ -574,7 +596,7 @@ export default {
},
getFromDraft() {
if (this.conversationIdByRoute) {
- const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
+ const key = this.getDraftKey();
const messageFromStore =
this.$store.getters['draftMessages/get'](key) || '';
@@ -597,7 +619,7 @@ export default {
},
removeFromDraft() {
if (this.conversationIdByRoute) {
- const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
+ const key = this.getDraftKey();
this.$store.dispatch('draftMessages/delete', { key });
}
},
@@ -708,6 +730,7 @@ export default {
return;
}
if (!this.showMentions) {
+ const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
const isOnWhatsApp =
this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel ||
@@ -717,10 +740,17 @@ export default {
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
const isOnInstagram = this.isAnInstagramChannel;
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
- this.sendMessageAsMultipleMessages(this.message);
+ this.sendMessageAsMultipleMessages(
+ this.message,
+ copilotAcceptedMessage
+ );
} else {
const messagePayload = this.getMessagePayload(this.message);
- this.sendMessage(messagePayload);
+ this.sendMessage(
+ messagePayload,
+ this.message,
+ copilotAcceptedMessage
+ );
}
if (!this.isPrivate) {
@@ -732,13 +762,53 @@ export default {
this.$emit('update:popOutReplyBox', false);
}
},
- sendMessageAsMultipleMessages(message) {
+ sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
const messages = this.getMultipleMessagesPayload(message);
messages.forEach(messagePayload => {
- this.sendMessage(messagePayload);
+ this.sendMessage(
+ messagePayload,
+ messagePayload.message || '',
+ copilotAcceptedMessage
+ );
});
},
- sendMessageAnalyticsData(isPrivate) {
+ sendMessageAnalyticsData(
+ isPrivate,
+ { editorMessage = '', copilotAcceptedMessage = '' } = {}
+ ) {
+ const normalizeForComparison = message => {
+ let normalizedMessage = message || '';
+
+ if (this.sendWithSignature && this.messageSignature && !isPrivate) {
+ const effectiveChannelType = getEffectiveChannelType(
+ this.channelType,
+ this.inbox?.medium || ''
+ );
+ normalizedMessage = removeSignature(
+ normalizedMessage,
+ this.messageSignature,
+ effectiveChannelType
+ );
+ }
+
+ return trimContent(normalizedMessage);
+ };
+
+ const normalizedAcceptedMessage = normalizeForComparison(
+ copilotAcceptedMessage
+ );
+ const normalizedEditorMessage = normalizeForComparison(editorMessage);
+
+ if (normalizedAcceptedMessage && normalizedEditorMessage) {
+ useTrack(CAPTAIN_EVENTS.AI_ASSISTED_MESSAGE_SENT, {
+ conversationId: this.conversationIdByRoute,
+ channelType: this.channelType,
+ editedBeforeSend:
+ normalizedAcceptedMessage !== normalizedEditorMessage,
+ isPrivate,
+ });
+ }
+
// Analytics data for message signature is enabled or not in channels
return isPrivate
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
@@ -772,7 +842,11 @@ export default {
this.confirmOnSendReply();
}
},
- async sendMessage(messagePayload) {
+ async sendMessage(
+ messagePayload,
+ editorMessage = '',
+ copilotAcceptedMessage = ''
+ ) {
try {
await this.$store.dispatch(
'createPendingMessageAndSend',
@@ -781,7 +855,10 @@ export default {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
this.removeFromDraft();
- this.sendMessageAnalyticsData(messagePayload.private);
+ this.sendMessageAnalyticsData(messagePayload.private, {
+ editorMessage,
+ copilotAcceptedMessage,
+ });
} catch (error) {
const errorMessage =
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
@@ -855,6 +932,7 @@ export default {
},
clearMessage() {
this.message = '';
+ this.clearCopilotAcceptedMessage();
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
const effectiveChannelType = getEffectiveChannelType(
@@ -1119,7 +1197,9 @@ export default {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
},
onSubmitCopilotReply() {
- this.message = this.copilot.accept();
+ const acceptedMessage = this.copilot.accept();
+ this.message = acceptedMessage;
+ this.setCopilotAcceptedMessage(acceptedMessage);
},
},
};
@@ -1130,6 +1210,7 @@ export default {