From 61eaa098ae5524e492a5abd70d1349f2c2078b82 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 16 Feb 2026 23:55:13 -0800 Subject: [PATCH 1/8] fix(messages): reduce audio transcription 400 retry noise (#13487) ## Summary This PR reduces duplicate failure noise for audio transcription jobs that fail with permanent HTTP 400 responses, and fixes a file-format edge case causing intermittent 400s. Sentry issue: [CHATWOOT-99E / 6660541334](https://chatwoot-p3.sentry.io/issues/6660541334/) ## Confirmed root cause For some attachments, the stored filename had no extension (example: `speech`, content type `audio/mpeg`). When the temporary transcription upload file was created without an extension, OpenAI returned: `Unrecognized file format` (HTTP 400). ## Scope of changes 1. `Messages::AudioTranscriptionJob` - Keeps `discard_on Faraday::BadRequestError` to avoid retry storms on permanent request errors. - Adds explicit Rails warning logs for discarded jobs with attachment/job/status context. 2. `Messages::AudioTranscriptionService` - Keeps guaranteed temp file cleanup via `ensure`. - Ensures temp upload files include an extension when the original filename has none, derived from blob `content_type`. - This addresses intermittent failures like extensionless `audio/mpeg` files. ## Reproduction Enable audio transcription for an account and process an audio attachment whose stored filename has no extension (for example `speech`) but valid audio content type (`audio/mpeg`). Before this fix, OpenAI transcription could return HTTP 400 `Unrecognized file format` for that attachment while similar attachments with extensions succeeded. ## Testing Ran: `bundle exec rubocop enterprise/app/jobs/messages/audio_transcription_job.rb enterprise/app/services/messages/audio_transcription_service.rb` Result: both modified files pass lint with no offenses. --- .../jobs/messages/audio_transcription_job.rb | 9 ++++++ .../messages/audio_transcription_service.rb | 30 +++++++++++++++---- .../audio_transcription_service_spec.rb | 24 +++++++++++++-- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/enterprise/app/jobs/messages/audio_transcription_job.rb b/enterprise/app/jobs/messages/audio_transcription_job.rb index ce35405c8..5daf1e160 100644 --- a/enterprise/app/jobs/messages/audio_transcription_job.rb +++ b/enterprise/app/jobs/messages/audio_transcription_job.rb @@ -1,6 +1,15 @@ class Messages::AudioTranscriptionJob < ApplicationJob queue_as :low + discard_on Faraday::BadRequestError do |job, error| + log_context = { + attachment_id: job.arguments.first, + job_id: job.job_id, + status_code: error.response&.dig(:status) + } + + Rails.logger.warn("Discarding audio transcription job due to bad request: #{log_context}") + end retry_on ActiveStorage::FileNotFoundError, wait: 2.seconds, attempts: 3 def perform(attachment_id) diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index 1676dd862..4aa156f47 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -31,12 +31,20 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService end def fetch_audio_file + blob = attachment.file.blob temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions') FileUtils.mkdir_p(temp_dir) - temp_file_path = File.join(temp_dir, "#{attachment.file.blob.key}-#{attachment.file.filename}") + temp_file_name = "#{blob.key}-#{blob.filename}" + + if blob.filename.extension_without_delimiter.blank? + extension = extension_from_content_type(blob.content_type) + temp_file_name = "#{temp_file_name}.#{extension}" if extension.present? + end + + temp_file_path = File.join(temp_dir, temp_file_name) File.open(temp_file_path, 'wb') do |file| - attachment.file.blob.open do |blob_file| + blob.open do |blob_file| IO.copy_stream(blob_file, file) end end @@ -49,13 +57,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService return transcribed_text if transcribed_text.present? temp_file_path = fetch_audio_file - transcribed_text = nil File.open(temp_file_path, 'rb') do |file| response = @client.audio.transcribe( parameters: { - model: 'whisper-1', + model: WHISPER_MODEL, file: file, temperature: 0.4 } @@ -63,10 +70,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService transcribed_text = response['text'] end - FileUtils.rm_f(temp_file_path) - update_transcription(transcribed_text) transcribed_text + ensure + FileUtils.rm_f(temp_file_path) if temp_file_path.present? end def instrumentation_params(file_path) @@ -90,4 +97,15 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService message.reindex end + + def extension_from_content_type(content_type) + subtype = content_type.to_s.downcase.split(';').first.to_s.split('/').last.to_s + return if subtype.blank? + + { + 'x-m4a' => 'm4a', + 'x-wav' => 'wav', + 'x-mp3' => 'mp3' + }.fetch(subtype, subtype) + end end diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb index 41a4cae83..7ece2540a 100644 --- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb +++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb @@ -8,8 +8,8 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do before do # Create required installation configs - create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-api-key') - create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini') + InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_API_KEY') { |config| config.value = 'test-api-key' } + InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_MODEL') { |config| config.value = 'gpt-4o-mini' } # Mock usage limits for transcription to be available allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } }) @@ -64,4 +64,24 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do end end end + + describe '#fetch_audio_file' do + let(:service) { described_class.new(attachment) } + + before do + attachment.file.attach( + io: File.open(Rails.public_path.join('audio/widget/ding.mp3')), + filename: 'speech', + content_type: 'audio/mpeg' + ) + end + + it 'adds extension from content type when filename has no extension' do + temp_file_path = service.send(:fetch_audio_file) + + expect(File.extname(temp_file_path)).to eq('.mpeg') + ensure + FileUtils.rm_f(temp_file_path) if temp_file_path.present? + end + end end 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 2/8] 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', From 38743836987a218aeec0ee2599df1151bc0e2de6 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:28:26 +0530 Subject: [PATCH 3/8] feat: insrument captain v2 (#13439) # Pull Request Template ## Description Instruments captain v2 ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Local testing: image ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] 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 - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Shivam Mishra --- Gemfile | 2 +- Gemfile.lock | 16 ++--- .../captain/assistant/agent_runner_service.rb | 61 +++++++++++++++++++ .../assistant/agent_runner_service_spec.rb | 55 +++++++++++++++++ 4 files changed, 125 insertions(+), 9 deletions(-) diff --git a/Gemfile b/Gemfile index 2023c32b1..e6c5a5250 100644 --- a/Gemfile +++ b/Gemfile @@ -191,7 +191,7 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.7.0' +gem 'ai-agents' # TODO: Move this gem as a dependency of ai-agents gem 'ruby_llm', '>= 1.8.2' diff --git a/Gemfile.lock b/Gemfile.lock index db9b59c66..cc1f9e253 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,8 +126,8 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.7.0) - ruby_llm (~> 1.8.2) + ai-agents (0.9.0) + ruby_llm (~> 1.9.1) annotaterb (4.20.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) @@ -314,7 +314,7 @@ GEM faraday-net_http_persistent (2.1.0) faraday (~> 2.5) net-http-persistent (~> 4.0) - faraday-retry (2.2.1) + faraday-retry (2.4.0) faraday (~> 2.0) faraday_middleware-aws-sigv4 (1.0.1) aws-sigv4 (~> 1.0) @@ -540,7 +540,7 @@ GEM net-imap net-pop net-smtp - marcel (1.0.4) + marcel (1.1.0) maxminddb (0.1.22) meta_request (0.8.5) rack-contrib (>= 1.1, < 3) @@ -559,7 +559,7 @@ GEM multi_json (1.15.0) multi_xml (0.8.0) bigdecimal (>= 3.1, < 5) - multipart-post (2.3.0) + multipart-post (2.4.1) mutex_m (0.3.0) neighbor (0.2.3) activerecord (>= 5.2) @@ -825,7 +825,7 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_llm (1.8.2) + ruby_llm (1.9.2) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) @@ -1004,7 +1004,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.6.17) + zeitwerk (2.7.4) PLATFORMS arm64-darwin-20 @@ -1024,7 +1024,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.7.0) + ai-agents annotaterb attr_extras audited (~> 5.4, >= 5.4.1) diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb index 9c4e56841..a40e096e6 100644 --- a/enterprise/app/services/captain/assistant/agent_runner_service.rb +++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb @@ -1,6 +1,9 @@ require 'agents' +require 'agents/instrumentation' class Captain::Assistant::AgentRunnerService + include Integrations::LlmInstrumentationConstants + CONVERSATION_STATE_ATTRIBUTES = %i[ id display_id inbox_id contact_id status priority label_list custom_attributes additional_attributes @@ -22,7 +25,9 @@ class Captain::Assistant::AgentRunnerService context = build_context(message_history) message_to_process = extract_last_user_message(message_history) runner = Agents::Runner.with_agents(*agents) + runner = add_usage_metadata_callback(runner) runner = add_callbacks_to_runner(runner) if @callbacks.any? + install_instrumentation(runner) result = runner.run(message_to_process, context: context, max_turns: 100) process_agent_result(result) @@ -50,6 +55,7 @@ class Captain::Assistant::AgentRunnerService end { + session_id: "#{@assistant.account_id}_#{@conversation&.display_id}", conversation_history: conversation_history, state: build_state } @@ -124,6 +130,31 @@ class Captain::Assistant::AgentRunnerService [assistant_agent] + scenario_agents end + def install_instrumentation(runner) + return unless ChatwootApp.otel_enabled? + + Agents::Instrumentation.install( + runner, + tracer: OpentelemetryConfig.tracer, + trace_name: 'llm.captain_v2', + span_attributes: { + ATTR_LANGFUSE_TAGS => ['captain_v2'].to_json + }, + attribute_provider: ->(context_wrapper) { dynamic_trace_attributes(context_wrapper) } + ) + end + + def dynamic_trace_attributes(context_wrapper) + state = context_wrapper&.context&.dig(:state) || {} + conversation = state[:conversation] || {} + { + ATTR_LANGFUSE_USER_ID => state[:account_id], + format(ATTR_LANGFUSE_METADATA, 'assistant_id') => state[:assistant_id], + format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id], + format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id] + }.compact.transform_values(&:to_s) + end + def add_callbacks_to_runner(runner) runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking] runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start] @@ -132,6 +163,36 @@ class Captain::Assistant::AgentRunnerService runner end + def add_usage_metadata_callback(runner) + return runner unless ChatwootApp.otel_enabled? + + handoff_tool_name = Captain::Tools::HandoffTool.new(@assistant).name + + runner.on_tool_complete do |tool_name, _tool_result, context_wrapper| + track_handoff_usage(tool_name, handoff_tool_name, context_wrapper) + end + + runner.on_run_complete do |_agent_name, _result, context_wrapper| + write_credits_used_metadata(context_wrapper) + end + runner + end + + def track_handoff_usage(tool_name, handoff_tool_name, context_wrapper) + return unless context_wrapper&.context + return unless tool_name.to_s == handoff_tool_name + + context_wrapper.context[:captain_v2_handoff_tool_called] = true + end + + def write_credits_used_metadata(context_wrapper) + root_span = context_wrapper&.context&.dig(:__otel_tracing, :root_span) + return unless root_span + + credits_used = !context_wrapper.context[:captain_v2_handoff_tool_called] + root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credits_used'), credits_used) + end + def add_agent_thinking_callback(runner) runner.on_agent_thinking do |*args| @callbacks[:on_agent_thinking].call(*args) diff --git a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb index 2c05860e2..04fa0f967 100644 --- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb +++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb @@ -75,6 +75,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do it 'runs agent with extracted user message and context' do expected_context = { + session_id: "#{account.id}_#{conversation.display_id}", conversation_history: [ { role: :user, content: 'Hello there', agent_name: nil }, { role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' }, @@ -306,6 +307,60 @@ RSpec.describe Captain::Assistant::AgentRunnerService do end end + describe '#add_usage_metadata_callback' do + it 'sets credits_used=false when handoff tool is used' do + service = described_class.new(assistant: assistant, conversation: conversation) + runner = instance_double(Agents::AgentRunner) + tool_complete_callback = nil + run_complete_callback = nil + span_class = Class.new do + def set_attribute(*); end + end + root_span = instance_double(span_class) + context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } }) + + allow(ChatwootApp).to receive(:otel_enabled?).and_return(true) + allow(runner).to receive(:on_tool_complete) do |&block| + tool_complete_callback = block + runner + end + allow(runner).to receive(:on_run_complete) do |&block| + run_complete_callback = block + runner + end + + service.send(:add_usage_metadata_callback, runner) + + tool_complete_callback.call(Captain::Tools::HandoffTool.new(assistant).name, 'ok', context_wrapper) + + expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credits_used', false) + run_complete_callback.call('assistant', nil, context_wrapper) + end + + it 'sets credits_used=true when handoff tool is not used' do + service = described_class.new(assistant: assistant, conversation: conversation) + runner = instance_double(Agents::AgentRunner) + run_complete_callback = nil + span_class = Class.new do + def set_attribute(*); end + end + root_span = instance_double(span_class) + context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } }) + + allow(ChatwootApp).to receive(:otel_enabled?).and_return(true) + allow(runner).to receive(:on_tool_complete).and_return(runner) + allow(runner).to receive(:on_run_complete) do |&block| + run_complete_callback = block + runner + end + + service.send(:add_usage_metadata_callback, runner) + + expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credits_used', true) + run_complete_callback.call('assistant', nil, context_wrapper) + end + end + describe 'constants' do it 'defines conversation state attributes' do expect(described_class::CONVERSATION_STATE_ATTRIBUTES).to include( From aa7e3c2d382bd2696737b058983b3af72fa2853c Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:30:04 +0530 Subject: [PATCH 4/8] feat: langfuse logging improvements (#13534) Langfuse logging improvements ## Description Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires. Fixes # (issue) For reply suggestion: the errors are being stored inside output field, but observations should be marked as errors. For assistant: add credit_used metadata to filter handoffs from ai-replies For langfuse tool call: add `observation_type=tool` ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. before: image after: image `credit_used` to filter handoffs from AI replies that cause credit usage image set `observation_type` to `tool` image ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] 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 - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --- .../helpers/captain/chat_response_helper.rb | 23 +++++++++++++++++++ lib/captain/tool_instrumentation.rb | 9 ++++++++ lib/integrations/llm_instrumentation.rb | 3 +++ .../llm_instrumentation_constants.rb | 1 + lib/integrations/llm_instrumentation_spans.rb | 1 + 5 files changed, 37 insertions(+) diff --git a/enterprise/app/helpers/captain/chat_response_helper.rb b/enterprise/app/helpers/captain/chat_response_helper.rb index e0323996f..bfb8adc11 100644 --- a/enterprise/app/helpers/captain/chat_response_helper.rb +++ b/enterprise/app/helpers/captain/chat_response_helper.rb @@ -1,10 +1,13 @@ module Captain::ChatResponseHelper + include Integrations::LlmInstrumentationConstants + private def build_response(response) Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" } parsed = parse_json_response(response.content) + apply_credit_usage_metadata(parsed) persist_message(parsed, 'assistant') parsed @@ -19,6 +22,26 @@ module Captain::ChatResponseHelper { 'content' => content } end + def apply_credit_usage_metadata(parsed_response) + return unless captain_v1_assistant? + + OpenTelemetry::Trace.current_span.set_attribute( + format(ATTR_LANGFUSE_METADATA, 'credit_used'), + credit_used_for_response?(parsed_response).to_s + ) + rescue StandardError => e + Rails.logger.warn "#{self.class.name} Assistant: #{@assistant.id}, Failed to set credit usage metadata: #{e.message}" + end + + def credit_used_for_response?(parsed_response) + response = parsed_response['response'] + response.present? && response != 'conversation_handoff' + end + + def captain_v1_assistant? + feature_name == 'assistant' && !@assistant.account.feature_enabled?('captain_integration_v2') + end + def persist_thinking_message(tool_call) return if @copilot_thread.blank? diff --git a/lib/captain/tool_instrumentation.rb b/lib/captain/tool_instrumentation.rb index af79a3fca..157aab829 100644 --- a/lib/captain/tool_instrumentation.rb +++ b/lib/captain/tool_instrumentation.rb @@ -15,6 +15,7 @@ module Captain::ToolInstrumentation response = yield executed = true span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json) + set_tool_session_error_attributes(span, response) if response.is_a?(Hash) end response rescue StandardError => e @@ -29,6 +30,14 @@ module Captain::ToolInstrumentation span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json) end + def set_tool_session_error_attributes(span, response) + error = response[:error] || response['error'] + return if error.blank? + + span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json) + span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000)) + end + def record_generation(chat, message, model) return unless ChatwootApp.otel_enabled? return unless message.respond_to?(:role) && message.role.to_s == 'assistant' diff --git a/lib/integrations/llm_instrumentation.rb b/lib/integrations/llm_instrumentation.rb index c3baf291e..326bb901e 100644 --- a/lib/integrations/llm_instrumentation.rb +++ b/lib/integrations/llm_instrumentation.rb @@ -37,6 +37,7 @@ module Integrations::LlmInstrumentation result = yield executed = true span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json) + set_error_attributes(span, result) if result.is_a?(Hash) result end rescue StandardError => e @@ -50,9 +51,11 @@ module Integrations::LlmInstrumentation return yield unless ChatwootApp.otel_enabled? tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span| + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool') span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json) result = yield span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json) + set_error_attributes(span, result) if result.is_a?(Hash) result end end diff --git a/lib/integrations/llm_instrumentation_constants.rb b/lib/integrations/llm_instrumentation_constants.rb index 6ce296ee9..dfe1e7704 100644 --- a/lib/integrations/llm_instrumentation_constants.rb +++ b/lib/integrations/llm_instrumentation_constants.rb @@ -26,6 +26,7 @@ module Integrations::LlmInstrumentationConstants ATTR_LANGFUSE_METADATA = 'langfuse.trace.metadata.%s' ATTR_LANGFUSE_TRACE_INPUT = 'langfuse.trace.input' ATTR_LANGFUSE_TRACE_OUTPUT = 'langfuse.trace.output' + ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type' ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input' ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output' end diff --git a/lib/integrations/llm_instrumentation_spans.rb b/lib/integrations/llm_instrumentation_spans.rb index 824b6aa4a..85ea599f8 100644 --- a/lib/integrations/llm_instrumentation_spans.rb +++ b/lib/integrations/llm_instrumentation_spans.rb @@ -39,6 +39,7 @@ module Integrations::LlmInstrumentationSpans tool_name = tool_call.name.to_s span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name)) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool') span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json) @pending_tool_spans ||= [] From cfe3061b5d7ca88738a5fa2df841dc4d3f61b9da Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:30:55 +0530 Subject: [PATCH 5/8] feat: Allow removing labels via conversation context menu (#13525) # Pull Request Template ## Description This PR adds support for removing labels from the conversation card context menu. Assigned labels now show a checkmark, and clicking an already-selected label will remove it. Fixes https://linear.app/chatwoot/issue/CW-6400/allow-removing-labels-directly-from-the-right-click-menu https://github.com/chatwoot/chatwoot/issues/13367 ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? **Screencast** https://github.com/user-attachments/assets/4e3a6080-a67d-4851-9d10-d8dbf3ceeb04 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] 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 - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../dashboard/components/ChatList.vue | 2 ++ .../dashboard/components/ConversationItem.vue | 2 ++ .../widgets/conversation/ConversationCard.vue | 8 ++++++- .../conversation/contextMenu/Index.vue | 17 ++++++++++++-- .../conversation/contextMenu/menuItem.vue | 10 +++++++- .../composables/chatlist/useBulkActions.js | 23 +++++++++++++++++++ .../i18n/locale/en/conversation.json | 4 ++++ 7 files changed, 62 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 08e2057e4..3b2a66929 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -145,6 +145,7 @@ const { isConversationSelected, onAssignAgent, onAssignLabels, + onRemoveLabels, onAssignTeamsForBulk, onUpdateConversations, } = useBulkActions(); @@ -859,6 +860,7 @@ provide('deSelectConversation', deSelectConversation); provide('assignAgent', onAssignAgent); provide('assignTeam', onAssignTeam); provide('assignLabels', onAssignLabels); +provide('removeLabels', onRemoveLabels); provide('updateConversationStatus', handleResolveConversation); provide('toggleContextMenu', onContextMenuToggle); provide('markAsUnread', markAsUnread); diff --git a/app/javascript/dashboard/components/ConversationItem.vue b/app/javascript/dashboard/components/ConversationItem.vue index a705dd067..fcd41ad45 100644 --- a/app/javascript/dashboard/components/ConversationItem.vue +++ b/app/javascript/dashboard/components/ConversationItem.vue @@ -10,6 +10,7 @@ export default { 'assignAgent', 'assignTeam', 'assignLabels', + 'removeLabels', 'updateConversationStatus', 'toggleContextMenu', 'markAsUnread', @@ -63,6 +64,7 @@ export default { @assign-agent="assignAgent" @assign-team="assignTeam" @assign-label="assignLabels" + @remove-label="removeLabels" @update-conversation-status="updateConversationStatus" @context-menu-toggle="toggleContextMenu" @mark-as-unread="markAsUnread" diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index 5093e5c4b..9f7805b4e 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -34,6 +34,7 @@ const emit = defineEmits([ 'contextMenuToggle', 'assignAgent', 'assignLabel', + 'removeLabel', 'assignTeam', 'markAsUnread', 'markAsRead', @@ -203,7 +204,10 @@ const onAssignAgent = agent => { const onAssignLabel = label => { emit('assignLabel', [label.title], [props.chat.id]); - closeContextMenu(); +}; + +const onRemoveLabel = label => { + emit('removeLabel', [label.title], [props.chat.id]); }; const onAssignTeam = team => { @@ -379,11 +383,13 @@ const deleteConversation = () => { :priority="chat.priority" :chat-id="chat.id" :has-unread-messages="hasUnread" + :conversation-labels="chat.labels" :conversation-url="conversationPath" :allowed-options="allowedContextMenuOptions" @update-conversation="onUpdateConversation" @assign-agent="onAssignAgent" @assign-label="onAssignLabel" + @remove-label="onRemoveLabel" @assign-team="onAssignTeam" @mark-as-unread="markAsUnread" @mark-as-read="markAsRead" diff --git a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue index a6f79500a..34009935c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue +++ b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue @@ -53,6 +53,10 @@ export default { type: String, default: null, }, + conversationLabels: { + type: Array, + default: () => [], + }, conversationUrl: { type: String, default: '', @@ -70,6 +74,7 @@ export default { 'assignAgent', 'assignTeam', 'assignLabel', + 'removeLabel', 'deleteConversation', 'close', ], @@ -334,8 +339,16 @@ export default { v-for="label in labels" :key="label.id" :option="generateMenuLabelConfig(label, 'label')" - variant="label" - @click.stop="$emit('assignLabel', label)" + :variant=" + conversationLabels.includes(label.title) + ? 'label-assigned' + : 'label' + " + @click.stop=" + conversationLabels.includes(label.title) + ? $emit('removeLabel', label) + : $emit('assignLabel', label) + " /> import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; +import Icon from 'dashboard/components-next/icon/Icon.vue'; defineProps({ option: { @@ -22,7 +23,9 @@ defineProps({ class="flex-shrink-0" /> @@ -37,6 +40,11 @@ defineProps({ +
diff --git a/app/javascript/dashboard/composables/chatlist/useBulkActions.js b/app/javascript/dashboard/composables/chatlist/useBulkActions.js index a32c4c512..45421b978 100644 --- a/app/javascript/dashboard/composables/chatlist/useBulkActions.js +++ b/app/javascript/dashboard/composables/chatlist/useBulkActions.js @@ -102,6 +102,28 @@ export function useBulkActions() { } } + // Only used in context menu + async function onRemoveLabels(labelsToRemove, conversationId = null) { + try { + await store.dispatch('bulkActions/process', { + type: 'Conversation', + ids: conversationId || selectedConversations.value, + labels: { + remove: labelsToRemove, + }, + }); + + useAlert( + t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', { + labelName: labelsToRemove[0], + conversationId, + }) + ); + } catch (err) { + useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED')); + } + } + async function onAssignTeamsForBulk(team) { try { await store.dispatch('bulkActions/process', { @@ -189,6 +211,7 @@ export function useBulkActions() { isConversationSelected, onAssignAgent, onAssignLabels, + onRemoveLabels, onAssignTeamsForBulk, onUpdateConversations, }; diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 99e0bb072..c1c87bc0c 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -174,6 +174,10 @@ "SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}", "FAILED": "Couldn't assign label. Please try again." }, + "LABEL_REMOVAL": { + "SUCCESFUL": "Removed label #{labelName} from conversation id {conversationId}", + "FAILED": "Couldn't remove label. Please try again." + }, "TEAM_ASSIGNMENT": { "SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}", "FAILED": "Couldn't assign team. Please try again." From fb2f5e1d427540729f16d5e33551b24357eaeba7 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:57:44 +0530 Subject: [PATCH 6/8] fix: Persist compose form state on accidental outside click (#13529) --- .../NewConversation/ComposeConversation.vue | 38 +++++++++++++++++-- .../components/ComposeNewConversationForm.vue | 7 ++-- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue index ee71bf51a..8e24f3d50 100644 --- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue +++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue @@ -1,5 +1,5 @@