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

<img width="1906" height="832" alt="image"
src="https://github.com/user-attachments/assets/f0ade43b-aa8d-41be-8ca2-20a091a81f60"
/>

<img width="828" height="280" alt="image"
src="https://github.com/user-attachments/assets/be76219e-fb61-4a6e-bff5-dc085b0a3cc9"
/>

<img width="415" height="147" alt="image"
src="https://github.com/user-attachments/assets/36802c5c-33a7-49ed-bf7e-f0b02d86dccc"
/>

<img width="2040" height="516" alt="image"
src="https://github.com/user-attachments/assets/74b95288-bc86-4312-a282-14211ae8f25c"
/>


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
This commit is contained in:
Aakash Bakhle
2026-02-17 13:26:56 +05:30
committed by GitHub
parent 61eaa098ae
commit 101eca3003
7 changed files with 304 additions and 56 deletions

View File

@@ -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) {

View File

@@ -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 = () => {

View File

@@ -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 {
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
<ReplyTopPanel
:mode="replyType"
:conversation-id="conversationId"
:is-reply-restricted="isReplyRestricted"
:disabled="
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
@@ -1204,6 +1285,7 @@ export default {
<WootMessageEditor
v-else-if="!showAudioRecorderEditor"
v-model="message"
:conversation-id="conversationId"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"

View File

@@ -0,0 +1,12 @@
export const CAPTAIN_ERROR_TYPES = Object.freeze({
ABORTED: 'aborted',
API_ERROR: 'api_error',
HTTP_PREFIX: 'http_',
ABORT_ERROR: 'AbortError',
CANCELED_ERROR: 'CanceledError',
});
export const CAPTAIN_GENERATION_FAILURE_REASONS = Object.freeze({
EMPTY_RESPONSE: 'empty_response',
EXCEPTION: 'exception',
});

View File

@@ -11,6 +11,7 @@ import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TasksAPI from 'dashboard/api/captain/tasks';
import { CAPTAIN_ERROR_TYPES } from 'dashboard/composables/captain/constants';
export function useCaptain() {
const store = useStore();
@@ -69,7 +70,10 @@ export function useCaptain() {
* @param {Error} error - The error object from the API call.
*/
const handleAPIError = error => {
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),
};
}
};

View File

@@ -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;
}
}
}

View File

@@ -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',