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 image image image image 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 {
{ - if (error.name === 'AbortError' || error.name === 'CanceledError') { + if ( + error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR || + error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR + ) { return; } const errorMessage = @@ -78,6 +82,24 @@ export function useCaptain() { useAlert(errorMessage); }; + /** + * Classifies API error types for downstream analytics. + * @param {Error} error + * @returns {string} + */ + const getErrorType = error => { + if ( + error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR || + error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR + ) { + return CAPTAIN_ERROR_TYPES.ABORTED; + } + if (error.response?.status) { + return `${CAPTAIN_ERROR_TYPES.HTTP_PREFIX}${error.response.status}`; + } + return CAPTAIN_ERROR_TYPES.API_ERROR; + }; + // === Task Methods === /** * Rewrites content with a specific operation. @@ -103,7 +125,7 @@ export function useCaptain() { return { message: generatedMessage, followUpContext }; } catch (error) { handleAPIError(error); - return { message: '' }; + return { message: '', errorType: getErrorType(error) }; } }; @@ -125,7 +147,7 @@ export function useCaptain() { return { message: generatedMessage, followUpContext }; } catch (error) { handleAPIError(error); - return { message: '' }; + return { message: '', errorType: getErrorType(error) }; } }; @@ -147,7 +169,7 @@ export function useCaptain() { return { message: generatedMessage, followUpContext }; } catch (error) { handleAPIError(error); - return { message: '' }; + return { message: '', errorType: getErrorType(error) }; } }; @@ -171,7 +193,11 @@ export function useCaptain() { return { message: generatedMessage, followUpContext: updatedContext }; } catch (error) { handleAPIError(error); - return { message: '', followUpContext }; + return { + message: '', + followUpContext, + errorType: getErrorType(error), + }; } }; diff --git a/app/javascript/dashboard/composables/useCopilotReply.js b/app/javascript/dashboard/composables/useCopilotReply.js index 492bcb43e..42f506b16 100644 --- a/app/javascript/dashboard/composables/useCopilotReply.js +++ b/app/javascript/dashboard/composables/useCopilotReply.js @@ -3,6 +3,10 @@ import { useCaptain } from 'dashboard/composables/useCaptain'; import { useUISettings } from 'dashboard/composables/useUISettings'; import { useTrack } from 'dashboard/composables'; import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; +import { + CAPTAIN_ERROR_TYPES, + CAPTAIN_GENERATION_FAILURE_REASONS, +} from 'dashboard/composables/captain/constants'; // Actions that map to REWRITE events (with operation attribute) const REWRITE_ACTIONS = [ @@ -52,6 +56,20 @@ function buildPayload(action, conversationId, followUpCount = undefined) { return payload; } +function trackGenerationFailure({ + action, + conversationId, + followUpCount = undefined, + stage, + reason, +}) { + useTrack(CAPTAIN_EVENTS.GENERATION_FAILED, { + ...buildPayload(action, conversationId, followUpCount), + stage, + reason, + }); +} + /** * Composable for managing Copilot reply generation state and actions. * Extracts copilot-related logic from ReplyBox for cleaner code organization. @@ -146,7 +164,8 @@ export function useCopilotReply() { // Reset without tracking dismiss (starting new action) reset(false); - abortController.value = new AbortController(); + const requestController = new AbortController(); + abortController.value = requestController; isGenerating.value = true; isContentReady.value = false; currentAction.value = action; @@ -154,28 +173,66 @@ export function useCopilotReply() { trackedConversationId.value = conversationId.value; try { - const { message: content, followUpContext: newContext } = - await processEvent(action, data, { - signal: abortController.value.signal, - }); + const { + message: content, + followUpContext: newContext, + errorType, + } = await processEvent(action, data, { + signal: requestController.signal, + }); - if (!abortController.value?.signal.aborted) { - generatedContent.value = content; - followUpContext.value = newContext; - if (content) { - showEditor.value = true; - // Track "Used" event on successful generation - const eventKey = `${getEventPrefix(action)}_USED`; - useTrack( - CAPTAIN_EVENTS[eventKey], - buildPayload(action, trackedConversationId.value) - ); + if (requestController.signal.aborted) return; + if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) { + if (abortController.value === requestController) { + isGenerating.value = false; } - isGenerating.value = false; + return; } - } catch { - if (!abortController.value?.signal.aborted) { - isGenerating.value = false; + + generatedContent.value = content; + followUpContext.value = newContext; + if (content) { + showEditor.value = true; + // Track "Used" event on successful generation + const eventKey = `${getEventPrefix(action)}_USED`; + useTrack( + CAPTAIN_EVENTS[eventKey], + buildPayload(action, trackedConversationId.value) + ); + } else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) { + trackGenerationFailure({ + action, + conversationId: trackedConversationId.value, + stage: 'initial', + reason: errorType, + }); + } else { + trackGenerationFailure({ + action, + conversationId: trackedConversationId.value, + stage: 'initial', + reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE, + }); + } + isGenerating.value = false; + } catch (error) { + if ( + requestController.signal.aborted || + error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR || + error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR + ) { + return; + } + trackGenerationFailure({ + action, + conversationId: trackedConversationId.value, + stage: 'initial', + reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION, + }); + isGenerating.value = false; + } finally { + if (abortController.value === requestController) { + abortController.value = null; } } } @@ -187,7 +244,8 @@ export function useCopilotReply() { async function sendFollowUp(message) { if (!followUpContext.value || !message.trim()) return; - abortController.value = new AbortController(); + const requestController = new AbortController(); + abortController.value = requestController; isGenerating.value = true; isContentReady.value = false; @@ -198,24 +256,65 @@ export function useCopilotReply() { followUpCount.value += 1; try { - const { message: content, followUpContext: updatedContext } = - await followUp({ - followUpContext: followUpContext.value, - message, - signal: abortController.value.signal, - }); + const { + message: content, + followUpContext: updatedContext, + errorType, + } = await followUp({ + followUpContext: followUpContext.value, + message, + signal: requestController.signal, + }); - if (!abortController.value?.signal.aborted) { - if (content) { - generatedContent.value = content; - followUpContext.value = updatedContext; - showEditor.value = true; + if (requestController.signal.aborted) return; + if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) { + if (abortController.value === requestController) { + isGenerating.value = false; } - isGenerating.value = false; + return; } - } catch { - if (!abortController.value?.signal.aborted) { - isGenerating.value = false; + + if (content) { + generatedContent.value = content; + followUpContext.value = updatedContext; + showEditor.value = true; + } else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) { + trackGenerationFailure({ + action: currentAction.value, + conversationId: trackedConversationId.value, + followUpCount: followUpCount.value, + stage: 'follow_up', + reason: errorType, + }); + } else { + trackGenerationFailure({ + action: currentAction.value, + conversationId: trackedConversationId.value, + followUpCount: followUpCount.value, + stage: 'follow_up', + reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE, + }); + } + isGenerating.value = false; + } catch (error) { + if ( + requestController.signal.aborted || + error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR || + error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR + ) { + return; + } + trackGenerationFailure({ + action: currentAction.value, + conversationId: trackedConversationId.value, + followUpCount: followUpCount.value, + stage: 'follow_up', + reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION, + }); + isGenerating.value = false; + } finally { + if (abortController.value === requestController) { + abortController.value = null; } } } diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js index 0b6e85d77..c9fefb129 100644 --- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js +++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js @@ -85,6 +85,11 @@ export const PORTALS_EVENTS = Object.freeze({ }); export const CAPTAIN_EVENTS = Object.freeze({ + // Editor funnel events + EDITOR_AI_MENU_OPENED: 'Captain: Editor AI menu opened', + GENERATION_FAILED: 'Captain: Generation failed', + AI_ASSISTED_MESSAGE_SENT: 'Captain: AI-assisted message sent', + // Rewrite events (with operation attribute in payload) REWRITE_USED: 'Captain: Rewrite used', REWRITE_APPLIED: 'Captain: Rewrite applied',