feat(captain): allow agents to report Captain messages (#14799)

Adds a cloud-only flow for agents to flag incorrect or problematic
Captain (AI) responses. Right-clicking a Captain message surfaces a
"Report message" option that opens a dialog to pick a problem type and
add a description, persisted to a new captain_message_reports table for
the team to review.


<img width="636" height="542" alt="Screenshot 2026-06-20 at 9 15 56 AM"
src="https://github.com/user-attachments/assets/afaa233d-6bd6-455a-8a33-a3796a3e3ef6"
/>
<img width="580" height="502" alt="Screenshot 2026-06-20 at 9 16 03 AM"
src="https://github.com/user-attachments/assets/2d220d99-98cc-4c5e-a325-778ceb4f7bc9"
/>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pranav
2026-06-20 14:47:08 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent dfd656a7d9
commit 6a262287d2
15 changed files with 466 additions and 1 deletions
@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class MessageReports extends ApiClient {
constructor() {
super('captain/message_reports', { accountScoped: true });
}
}
export default new MessageReports();
@@ -147,8 +147,14 @@ const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const { replaceInstallationName } = useBranding();
const isCaptainMessage = computed(() => {
const senderType = props.sender?.type ?? props.senderType;
return senderType === SENDER_TYPES.CAPTAIN_ASSISTANT;
});
/**
* Computes the message variant based on props
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
@@ -390,6 +396,10 @@ const contextMenuEnabledOptions = computed(() => {
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
report:
isOnChatwootCloud.value &&
isCaptainMessage.value &&
!isMessageDeleted.value,
};
});
@@ -304,6 +304,25 @@
"MESSAGE": "You cannot undo this action",
"DELETE": "Delete",
"CANCEL": "Cancel"
},
"REPORT_MESSAGE": {
"LABEL": "Report message",
"TITLE": "Report Captain message",
"DESCRIPTION": "Found an issue with this AI response? Let us know what went wrong and our team will review it to help improve Captain's accuracy.",
"PROBLEM_TYPE": "Problem type",
"PROBLEM_TYPE_PLACEHOLDER": "Select a problem type",
"DESCRIPTION_LABEL": "Description",
"DESCRIPTION_PLACEHOLDER": "Describe the problem in detail",
"SUBMIT": "Report",
"SUCCESS": "Thanks for reporting. Our team will take a look.",
"ERROR": "Could not report this message. Please try again.",
"REASONS": {
"incorrect_information": "Incorrect information",
"inappropriate_response": "Inappropriate response",
"incomplete_response": "Incomplete response",
"outdated_information": "Outdated information",
"other": "Other"
}
}
},
"SIDEBAR": {
@@ -14,6 +14,7 @@ import {
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
import { useTrack } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ReportCaptainMessageDialog from './ReportCaptainMessageDialog.vue';
export default {
components: {
@@ -21,6 +22,7 @@ export default {
MenuItem,
ContextMenu,
NextButton,
ReportCaptainMessageDialog,
},
props: {
message: {
@@ -152,6 +154,10 @@ export default {
closeDeleteModal() {
this.showDeleteModal = false;
},
openReportDialog() {
this.handleClose();
this.$refs.reportDialog?.open();
},
},
};
</script>
@@ -243,6 +249,16 @@ export default {
variant="icon"
@click.stop="showCannedResponseModal"
/>
<hr v-if="enabledOptions['report']" />
<MenuItem
v-if="enabledOptions['report']"
:option="{
icon: 'warning',
label: $t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.LABEL'),
}"
variant="icon"
@click.stop="openReportDialog"
/>
<hr v-if="enabledOptions['delete']" />
<MenuItem
v-if="enabledOptions['delete']"
@@ -255,6 +271,11 @@ export default {
/>
</div>
</ContextMenu>
<ReportCaptainMessageDialog
v-if="enabledOptions['report']"
ref="reportDialog"
:message-id="messageId"
/>
</div>
</template>
@@ -0,0 +1,119 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import MessageReportsAPI from 'dashboard/api/captain/messageReports';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Select from 'dashboard/components-next/select/Select.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
const props = defineProps({
messageId: { type: [Number, String], required: true },
});
const { t } = useI18n();
const dialogRef = ref(null);
const isLoading = ref(false);
const REPORT_REASONS = [
'incorrect_information',
'inappropriate_response',
'incomplete_response',
'outdated_information',
'other',
];
const reasonOptions = computed(() =>
REPORT_REASONS.map(value => ({
value,
label: t(`CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.REASONS.${value}`),
}))
);
const form = reactive({ reportReason: '', description: '' });
const isFormInvalid = computed(() => !form.reportReason);
const resetForm = () => {
form.reportReason = '';
form.description = '';
};
const open = () => {
resetForm();
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
const handleConfirm = async () => {
if (isFormInvalid.value) return;
isLoading.value = true;
try {
await MessageReportsAPI.create({
message_id: props.messageId,
report_reason: form.reportReason,
description: form.description.trim() || null,
});
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUCCESS'));
close();
} catch (error) {
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.ERROR'));
} finally {
isLoading.value = false;
}
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.TITLE')"
:description="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION')"
:confirm-button-label="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUBMIT')"
:is-loading="isLoading"
:disable-confirm-button="isFormInvalid"
@confirm="handleConfirm"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE') }}
</label>
<Select
v-model="form.reportReason"
class="!w-full [&>select]:w-full"
:options="reasonOptions"
:placeholder="
t(
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE_PLACEHOLDER'
)
"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_LABEL') }}
</label>
<TextArea
v-model="form.description"
class="w-full"
:placeholder="
t(
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_PLACEHOLDER'
)
"
:max-length="500"
show-character-count
auto-height
/>
</div>
</div>
</Dialog>
</template>