Compare commits

...
Author SHA1 Message Date
Shivam Mishra e06a15a432 fix: base scope 2026-01-21 20:52:20 +05:30
Shivam Mishra e5cbc174d5 feat: add sankey generator 2026-01-21 19:15:46 +05:30
Shivam Mishra fc9af7f315 feat: remove duplicate event 2026-01-21 18:53:10 +05:30
Shivam Mishra cba8e83b91 feat: update seeder to match new reporting event 2026-01-21 18:26:45 +05:30
Shivam Mishra 6325bd667a feat: include additional information 2026-01-21 17:55:38 +05:30
Shivam Mishra 92449e273c feat: update reporting_events to include new fields 2026-01-21 17:51:25 +05:30
Shivam Mishra fb81a4fc89 feat(reporting): add conversation_created event to ReportingEventListener
Add a new reporting event that fires when a conversation is created.
This event serves as a denominator for calculating rates like deflection
rate and escalation rate directly from the reporting_events table.
2026-01-21 17:40:04 +05:30
Vinay KeerthiandGitHub f84e95ed6c fix: use safe DOM manipulation for article heading permalinks (#13239) 2026-01-21 13:44:15 +05:30
6a482926b4 feat: new Captain Editor (#13235)
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
2026-01-21 13:39:07 +05:30
Sojan Jose c77c9c9d8a Merge branch 'release/4.10.1' into develop 2026-01-20 08:44:21 -08:00
Sojan Jose ecd4892a23 Bump version to 4.10.1 2026-01-20 08:43:11 -08:00
Muhsin KelothandGitHub 457430e8d9 fix: Remove phone_number_id param from WhatsApp media retrieval for incoming messages (#13319)
Fixes https://github.com/chatwoot/chatwoot/issues/13317
Fixes an issue where WhatsApp attachment messages (images, audio, video,
documents) were failing to download. Messages were being created but
without attachments.

The `phone_number_id` parameter was being passed to the `GET
/<MEDIA_ID>` endpoint when downloading incoming media. According to
Meta's documentation:

> "Note that `phone_number_id` is optional. If included, the request
will only be processed if the business phone number ID included in the
query matches the ID of the business
  phone number **that the media was uploaded on**."

For incoming messages, media is uploaded by the customer, not by the
business phone number. Passing the business's `phone_number_id` causes
validation to fail with error: `Param phone_number_id is not a valid
whatsapp business phone number id ID`

This PR removes the `phone_number_id` parameter from the media URL
request for incoming messages.
2026-01-20 20:32:23 +04:00
Shivam MishraandGitHub e13e3c873a feat: add report download task (#13250) 2026-01-19 18:31:52 +05:30
Shivam MishraandGitHub 0346e9a2c7 fix: captain inbox modal shows wrong assistant data (#13302) 2026-01-19 18:31:46 +05:30
Muhsin KelothandGitHub 7e4d93f649 fix: Setup webhooks for manual WhatsApp Cloud channel creation (#13278)
Fixes https://github.com/chatwoot/chatwoot/issues/13097

### Problem
The PR #12176 removed the `before_save :setup_webhooks` callback to fix
a race condition where Meta's webhook verification request arrived
before the channel was saved to the database. This change broke manual
WhatsApp Cloud channel setup. While embedded signup explicitly calls
`channel.setup_webhooks` in `EmbeddedSignupService`, manual setup had no
equivalent call - meaning the `subscribed_apps` endpoint was never
invoked and Meta never sent webhook events to Chatwoot.


### Solution
Added an `after_commit` callback that triggers webhook setup for manual
WhatsApp Cloud channels
2026-01-19 14:12:36 +04:00
b2ffad1998 fix: Validate status and priority params in search conversations tool (#13295)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 14:08:32 +05:30
Sojan Jose b2eca91c79 Merge branch 'release/4.10.0' into develop 2026-01-15 22:20:33 -08:00
Sojan Jose aee0740bcc Bump version to 4.10.0 2026-01-15 22:19:33 -08:00
96b5780ea7 fix: Respect survey label rules for WhatsApp CSAT template (#13285)
Ensure CSAT survey label rules are evaluated once in CsatSurveyService
before any channel-specific sending (including WhatsApp/Twilio
templates), remove the duplicated rule check from the template builder,
and cover the blocking-label scenario in service specs while simplifying
the template specs accordingly.

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-01-15 22:16:00 -08:00
d451615811 fix: prevent NoMethodError in mute helpers when contact is nil (#13277)
## Linear Ticket

https://linear.app/chatwoot/issue/CW-4569/nomethoderror-undefined-method-blocked-for-nil-nomethoderror

## Description
Fixes NoMethodError in ConversationMuteHelpers that occurs during
contact deletion race condition.
When a contact is deleted, there's a brief window (~50-150ms) where
contact_id becomes nil but conversations still exist. If ResolutionJob
runs during this window, the muted? method crashes trying to call
blocked? on nil.Fixes # (issue)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- Created orphaned conversations (contact_id = nil)
- Called muted?, mute!, unmute! - all return gracefully
- Verified async deletion still works correctly

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] 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



Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-01-15 22:00:09 -08:00
7d68c25c97 chore: upgrade node to v24.13 (#13291)
- upgrade node to v24.13

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-01-15 20:28:13 -08:00
3e560cb4cc chore: Remove year in review from sidebar UI (#13292)
Remove the Year In review from the UI

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 19:57:01 -08:00
PranavandGitHub a8b302d4cd feat(ee): Review Notes for CSAT Reports (#13289)
CSAT scores are helpful, but on their own they rarely tell the full
story. A drop in rating can come from delayed timelines, unclear
expectations, or simple misunderstandings, even when the issue itself
was handled correctly.

Review Notes for CSAT let admins/report manager roles add internal-only
context next to each CSAT response. This makes it easier to interpret
scores properly and focus on patterns and root causes, not just numbers.


<img width="2170" height="1680" alt="image"
src="https://github.com/user-attachments/assets/56df7fab-d0a7-4a94-95b9-e4c459ad33d5"
/>


### Why this matters

* Capture the real context behind individual CSAT ratings
* Clarify whether a low score points to a genuine service issue or a
process gap
* Spot recurring themes across conversations and teams
* Make CSAT reviews more useful for leadership reviews and
retrospectives

### How Review Notes work

**View CSAT responses**
Open the CSAT report to see overall metrics, rating distribution, and
individual responses.

**Add a Review Note**
For any CSAT entry, managers can add a Review Note directly below the
customer’s feedback.

**Document internal insights**
Use Review Notes to capture things like:

* Why a score was lower or higher than expected
* Patterns you are seeing across similar cases
* Observations around communication, timelines, or customer expectations

Review Notes are visible only to administrators and people with report
access only. We may expand visibility to agents in the future based on
feedback. However, customers never see them.

Each note clearly shows who added it and when, making it easy to review
context and changes over time.
2026-01-15 19:53:57 -08:00
Gabriel JablonskiandGitHub da42a4e4a1 ci: fixed bundler version to avoid bundler 4.0.4 incompatibility (#13290)
### Problem

Docker builds started failing with a misleading dependency resolution
error:
```
Could not find compatible versions
Because every version of devise-secure_password depends on railties >= 5.0.0, < 8.0.0
...
```

### Root Cause

[Bundler
4.0.4](https://github.com/ruby/rubygems/releases/tag/bundler-v4.0.4) was
released on January 14, 2026. The Dockerfile used `gem install bundler`
without version pinning, which installed the latest (4.0.4) instead of
the version specified in `Gemfile.lock` (2.5.16).

### Fix

- Pin Bundler installation to match `Gemfile.lock`: `gem install bundler
-v "$BUNDLER_VERSION"`
- Update `BUNDLER_VERSION` from 2.5.11 to 2.5.16

### ⚠️ Note

Fix found by Claude while checking why the deploy was failing, and have
not yet checked for alternatives which might be more appropriate, so
further investigation might be required.
2026-01-15 19:51:16 -08:00
157 changed files with 6424 additions and 2420 deletions
+3 -3
View File
@@ -76,7 +76,7 @@ jobs:
bundle install
- node/install:
node-version: '24.12'
node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -117,7 +117,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '24.12'
node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -148,7 +148,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '24.12'
node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
+1 -1
View File
@@ -10,7 +10,7 @@ services:
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.12.0'
NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
+1 -1
View File
@@ -11,7 +11,7 @@ services:
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.12.0'
NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
+1 -1
View File
@@ -1 +1 @@
24.12.0
24.13.0
+1 -1
View File
@@ -1 +1 @@
4.9.2
4.10.1
@@ -50,3 +50,5 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@current_page = params[:page] || 1
end
end
Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
@@ -49,7 +49,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT]
}
@allowed_configs = mapping.fetch(
+6 -7
View File
@@ -2,13 +2,6 @@
# No need to replicate the same values in two places
# ------- Premium Features ------- #
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-captain'
config_key: 'captain'
enterprise: true
saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
@@ -48,6 +41,12 @@ help_center:
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: true
icon: 'icon-captain'
config_key: 'captain'
# ------- Communication Channels ------- #
live_chat:
@@ -0,0 +1,107 @@
/* global axios */
import ApiClient from '../ApiClient';
/**
* A client for the Captain Tasks API.
* @extends ApiClient
*/
class TasksAPI extends ApiClient {
/**
* Creates a new TasksAPI instance.
*/
constructor() {
super('captain/tasks', { accountScoped: true });
}
/**
* Rewrites content with a specific operation.
* @param {Object} options - The rewrite options.
* @param {string} options.content - The content to rewrite.
* @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc).
* @param {string} [options.conversationId] - The conversation ID for context (required for 'improve').
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the rewritten content.
*/
rewrite({ content, operation, conversationId }, signal) {
return axios.post(
`${this.url}/rewrite`,
{
content,
operation,
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Summarizes a conversation.
* @param {string} conversationId - The conversation ID to summarize.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the summary.
*/
summarize(conversationId, signal) {
return axios.post(
`${this.url}/summarize`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Gets a reply suggestion for a conversation.
* @param {string} conversationId - The conversation ID.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the reply suggestion.
*/
replySuggestion(conversationId, signal) {
return axios.post(
`${this.url}/reply_suggestion`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Gets label suggestions for a conversation.
* @param {string} conversationId - The conversation ID.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with label suggestions.
*/
labelSuggestion(conversationId, signal) {
return axios.post(
`${this.url}/label_suggestion`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Sends a follow-up message to continue refining a previous task result.
* @param {Object} options - The follow-up options.
* @param {Object} options.followUpContext - The follow-up context from a previous task.
* @param {string} options.message - The follow-up message/request from the user.
* @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context.
*/
followUp({ followUpContext, message, conversationId }, signal) {
return axios.post(
`${this.url}/follow_up`,
{
follow_up_context: followUpContext,
message,
conversation_display_id: conversationId,
},
{ signal }
);
}
}
export default new TasksAPI();
@@ -1,81 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
/**
* Represents the data object for a OpenAI hook.
* @typedef {Object} ConversationMessageData
* @property {string} [tone] - The tone of the message.
* @property {string} [content] - The content of the message.
* @property {string} [conversation_display_id] - The display ID of the conversation (optional).
*/
/**
* A client for the OpenAI API.
* @extends ApiClient
*/
class OpenAIAPI extends ApiClient {
/**
* Creates a new OpenAIAPI instance.
*/
constructor() {
super('integrations', { accountScoped: true });
/**
* The conversation events supported by the API.
* @type {string[]}
*/
this.conversation_events = [
'summarize',
'reply_suggestion',
'label_suggestion',
];
/**
* The message events supported by the API.
* @type {string[]}
*/
this.message_events = ['rephrase'];
}
/**
* Processes an event using the OpenAI API.
* @param {Object} options - The options for the event.
* @param {string} [options.type='rephrase'] - The type of event to process.
* @param {string} [options.content] - The content of the event.
* @param {string} [options.tone] - The tone of the event.
* @param {string} [options.conversationId] - The ID of the conversation to process the event for.
* @param {string} options.hookId - The ID of the hook to use for processing the event.
* @returns {Promise} A promise that resolves with the result of the event processing.
*/
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
/**
* @type {ConversationMessageData}
*/
let data = {
tone,
content,
};
// Always include conversation_display_id when available for session tracking
if (conversationId) {
data.conversation_display_id = conversationId;
}
// For conversation-level events, only send conversation_display_id
if (this.conversation_events.includes(type)) {
data = {
conversation_display_id: conversationId,
};
}
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
event: {
name: type,
data,
},
});
}
}
export default new OpenAIAPI();
@@ -94,6 +94,19 @@
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--violet-1: 253 252 254;
--violet-2: 250 248 255;
--violet-3: 244 240 254;
--violet-4: 235 228 255;
--violet-5: 225 217 255;
--violet-6: 212 202 254;
--violet-7: 194 178 248;
--violet-8: 169 153 236;
--violet-9: 110 86 207;
--violet-10: 100 84 196;
--violet-11: 101 85 183;
--violet-12: 47 38 95;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
@@ -209,6 +222,19 @@
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--violet-1: 20 17 31;
--violet-2: 27 21 37;
--violet-3: 41 31 67;
--violet-4: 50 37 85;
--violet-5: 60 46 105;
--violet-6: 71 56 135;
--violet-7: 86 70 151;
--violet-8: 110 86 171;
--violet-9: 110 86 207;
--violet-10: 125 109 217;
--violet-11: 169 153 236;
--violet-12: 226 221 254;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
@@ -15,7 +15,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
@@ -661,7 +660,6 @@ const menuItems = computed(() => {
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
/>
<YearInReviewBanner />
<SidebarChangelogCard
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
/>
@@ -1,13 +1,11 @@
<script setup>
import { ref, computed } from 'vue';
import { computed } from 'vue';
import Auth from 'dashboard/api/auth';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Avatar from 'next/avatar/Avatar.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
import {
DropdownContainer,
@@ -24,7 +22,6 @@ defineOptions({
});
const { t } = useI18n();
const { uiSettings } = useUISettings();
const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
@@ -34,29 +31,6 @@ const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showYearInReviewModal = ref(false);
const bannerClosedKey = computed(() => {
return `yir_closed_${accountId.value}_2025`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const showYearInReviewMenuItem = computed(() => {
return isBannerClosed.value;
});
const openYearInReviewModal = () => {
showYearInReviewModal.value = true;
emit('close');
};
const closeYearInReviewModal = () => {
showYearInReviewModal.value = false;
};
const showChatSupport = computed(() => {
return (
isFeatureEnabledonAccount.value(
@@ -68,13 +42,6 @@ const showChatSupport = computed(() => {
const menuItems = computed(() => {
return [
{
show: showYearInReviewMenuItem.value,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
icon: 'i-lucide-gift',
click: openYearInReviewModal,
},
{
show: showChatSupport.value,
showOnCustomBrandedInstance: false,
@@ -190,9 +157,4 @@ const allowedMenuItems = computed(() => {
</template>
</DropdownBody>
</DropdownContainer>
<YearInReviewModal
:show="showYearInReviewModal"
@close="closeYearInReviewModal"
/>
</template>
@@ -206,10 +206,14 @@ const emitDateRange = () => {
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
}
};
const closeDatePicker = () => {
showDatePicker.value = false;
};
</script>
<template>
<div class="relative font-inter">
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
@@ -1,160 +0,0 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import AICTAModal from './AICTAModal.vue';
import AIAssistanceModal from './AIAssistanceModal.vue';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
AIAssistanceModal,
AICTAModal,
AIAssistanceCTAButton,
},
emits: ['replaceText'],
setup(props, { emit }) {
const { uiSettings, updateUISettings } = useUISettings();
const { isAIIntegrationEnabled, draftMessage, recordAnalytics } = useAI();
const { isAdmin } = useAdmin();
const initialMessage = ref('');
const initializeMessage = draftMsg => {
initialMessage.value = draftMsg;
};
const keyboardEvents = {
'$mod+KeyZ': {
action: () => {
if (initialMessage.value) {
emit('replaceText', initialMessage.value);
initialMessage.value = '';
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
uiSettings,
updateUISettings,
isAdmin,
initialMessage,
initializeMessage,
recordAnalytics,
isAIIntegrationEnabled,
draftMessage,
};
},
data: () => ({
showAIAssistanceModal: false,
showAICtaModal: false,
aiOption: '',
}),
computed: {
...mapGetters({
isAChatwootInstance: 'globalConfig/isAChatwootInstance',
}),
isAICTAModalDismissed() {
return this.uiSettings.is_open_ai_cta_modal_dismissed;
},
// Display a AI CTA button for admins if the AI integration has not been added yet and the AI assistance modal has not been dismissed.
shouldShowAIAssistCTAButtonForAdmin() {
return (
this.isAdmin &&
!this.isAIIntegrationEnabled &&
!this.isAICTAModalDismissed &&
this.isAChatwootInstance
);
},
// Display a AI CTA button for agents and other admins who have not yet opened the AI assistance modal.
shouldShowAIAssistCTAButton() {
return this.isAIIntegrationEnabled && !this.isAICTAModalDismissed;
},
},
mounted() {
emitter.on(CMD_AI_ASSIST, this.onAIAssist);
this.initializeMessage(this.draftMessage);
},
methods: {
hideAIAssistanceModal() {
this.recordAnalytics('DISMISS_AI_SUGGESTION', {
aiOption: this.aiOption,
});
this.showAIAssistanceModal = false;
},
openAIAssist() {
// Dismiss the CTA modal if it is not dismissed
if (!this.isAICTAModalDismissed) {
this.updateUISettings({
is_open_ai_cta_modal_dismissed: true,
});
}
this.initializeMessage(this.draftMessage);
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'ai_assist' });
},
hideAICtaModal() {
this.showAICtaModal = false;
},
openAICta() {
this.showAICtaModal = true;
},
onAIAssist(option) {
this.aiOption = option;
this.showAIAssistanceModal = true;
},
insertText(message) {
this.$emit('replaceText', message);
},
},
};
</script>
<template>
<div>
<div v-if="isAIIntegrationEnabled" class="relative">
<AIAssistanceCTAButton
v-if="shouldShowAIAssistCTAButton"
@open="openAIAssist"
/>
<NextButton
v-else
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="i-ph-magic-wand"
slate
faded
sm
@click="openAIAssist"
/>
<woot-modal
v-model:show="showAIAssistanceModal"
:on-close="hideAIAssistanceModal"
>
<AIAssistanceModal
:ai-option="aiOption"
@apply-text="insertText"
@close="hideAIAssistanceModal"
/>
</woot-modal>
</div>
<div v-else-if="shouldShowAIAssistCTAButtonForAdmin" class="relative">
<AIAssistanceCTAButton @click="openAICta" />
<woot-modal v-model:show="showAICtaModal" :on-close="hideAICtaModal">
<AICTAModal @close="hideAICtaModal" />
</woot-modal>
</div>
</div>
</template>
@@ -1,103 +0,0 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['open']);
const onClick = () => {
emit('open');
};
</script>
<template>
<div class="relative">
<NextButton
class="cta-btn cta-btn-light dark:cta-btn-dark hover:cta-btn-light-hover dark:hover:cta-btn-dark-hover"
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="i-ph-magic-wand"
sm
@click="onClick"
/>
<div
class="radar-ping-animation absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand"
/>
<div
class="absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand opacity-50"
/>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
/* Gradient animation */
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.cta-btn {
animation: gradient 5s ease infinite;
@apply text-n-slate-12 border-0 text-xs;
}
.cta-btn-light {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #f2f5f8, #f2f5f8);
}
.cta-btn-dark {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #313538, #313538);
}
.cta-btn-light-hover {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #e3e5e7, #e3e5e7);
}
.cta-btn-dark-hover {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #202425, #202425);
}
/* Radar ping animation */
@keyframes ping {
75%,
100% {
transform: scale(2);
opacity: 0;
}
}
.radar-ping-animation {
animation: ping 1s ease infinite;
}
}
</style>
@@ -1,118 +0,0 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useAI } from 'dashboard/composables/useAI';
import AILoader from './AILoader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
AILoader,
NextButton,
},
props: {
aiOption: {
type: String,
required: true,
},
},
emits: ['close', 'applyText'],
setup() {
const { formatMessage } = useMessageFormatter();
const { draftMessage, processEvent, recordAnalytics } = useAI();
return { draftMessage, processEvent, recordAnalytics, formatMessage };
},
data() {
return {
generatedContent: '',
isGenerating: true,
};
},
computed: {
headerTitle() {
const translationKey = this.aiOption?.toUpperCase();
return translationKey
? this.$t(`INTEGRATION_SETTINGS.OPEN_AI.WITH_AI`, {
option: this.$t(
`INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.${translationKey}`
),
})
: '';
},
},
mounted() {
this.generateAIContent(this.aiOption);
},
methods: {
onClose() {
this.$emit('close');
},
async generateAIContent(type = 'rephrase') {
this.isGenerating = true;
this.generatedContent = await this.processEvent(type);
this.isGenerating = false;
},
applyText() {
this.recordAnalytics(this.aiOption);
this.$emit('applyText', this.generatedContent);
this.onClose();
},
},
};
</script>
<template>
<div class="flex flex-col">
<woot-modal-header :header-title="headerTitle" />
<form
class="flex flex-col w-full modal-content"
@submit.prevent="applyText"
>
<div v-if="draftMessage" class="w-full">
<h4 class="mt-1 text-base text-n-slate-12">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.DRAFT_TITLE') }}
</h4>
<p v-dompurify-html="formatMessage(draftMessage, false)" />
<h4 class="mt-1 text-base text-n-slate-12">
{{
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.GENERATED_TITLE')
}}
</h4>
</div>
<div>
<AILoader v-if="isGenerating" />
<p v-else v-dompurify-html="formatMessage(generatedContent, false)" />
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.CANCEL')
"
@click.prevent="onClose"
/>
<NextButton
type="submit"
:disabled="!generatedContent"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.APPLY')
"
/>
</div>
</form>
</div>
</template>
<style lang="scss" scoped>
.modal-content {
@apply pt-2 px-8 pb-8;
}
.container {
width: 100%;
}
</style>
@@ -1,130 +0,0 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAI } from 'dashboard/composables/useAI';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['close'],
setup() {
const { updateUISettings } = useUISettings();
const { recordAnalytics } = useAI();
const v$ = useVuelidate();
return { updateUISettings, v$, recordAnalytics };
},
data() {
return {
value: '',
};
},
validations: {
value: {
required,
},
},
methods: {
onClose() {
this.$emit('close');
},
onDismiss() {
useAlert(
this.$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.DISMISS_MESSAGE')
);
this.updateUISettings({
is_open_ai_cta_modal_dismissed: true,
});
this.onClose();
},
async finishOpenAI() {
const payload = {
app_id: 'openai',
settings: {
api_key: this.value,
},
};
try {
await this.$store.dispatch('integrations/createHook', payload);
this.alertMessage = this.$t(
'INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.SUCCESS_MESSAGE'
);
this.recordAnalytics(
OPEN_AI_EVENTS.ADDED_AI_INTEGRATION_VIA_CTA_BUTTON
);
this.onClose();
} catch (error) {
const errorMessage = error?.response?.data?.message;
this.alertMessage =
errorMessage || this.$t('INTEGRATION_APPS.ADD.API.ERROR_MESSAGE');
} finally {
useAlert(this.alertMessage);
}
},
openOpenAIDoc() {
window.open('https://www.chatwoot.com/blog/v2-17', '_blank');
},
},
};
</script>
<template>
<div class="flex-1 min-w-0 px-0">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.TITLE')"
:header-content="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.DESC')"
/>
<form
class="flex flex-col flex-wrap modal-content"
@submit.prevent="finishOpenAI"
>
<div class="w-full mt-2">
<woot-input
v-model="value"
type="text"
:class="{ error: v$.value.$error }"
:placeholder="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.KEY_PLACEHOLDER')
"
@blur="v$.value.$touch"
/>
</div>
<div class="flex flex-row justify-between w-full gap-2 px-0 py-2">
<NextButton
ghost
type="button"
class="!px-3"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.NEED_HELP')
"
@click.prevent="openOpenAIDoc"
/>
<div class="flex items-center gap-1">
<NextButton
faded
slate
type="reset"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.DISMISS')
"
@click.prevent="onDismiss"
/>
<NextButton
type="submit"
:disabled="v$.value.$invalid"
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.FINISH')"
/>
</div>
</div>
</form>
</div>
</template>
@@ -46,11 +46,11 @@ const fileName = file => {
</script>
<template>
<div class="flex overflow-auto max-h-[12.5rem]">
<div class="flex flex-wrap gap-y-1 gap-x-2 overflow-auto max-h-[12.5rem]">
<div
v-for="(attachment, index) in nonRecordedAudioAttachments"
:key="attachment.id"
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem] mb-1"
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem]"
>
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img
@@ -38,7 +38,7 @@ const toggleShowMore = () => {
<button
v-if="text.length > limit"
class="text-n-brand !p-0 !border-0 align-top"
@click="toggleShowMore"
@click.stop="toggleShowMore"
>
{{ buttonLabel }}
</button>
@@ -0,0 +1,253 @@
<script setup>
import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
import {
buildMessageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
modelValue: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: {
type: String,
default: 'Give copilot additional prompts, or ask anything else...',
},
generatedContent: { type: String, default: '' },
autofocus: {
type: Boolean,
default: true,
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'blur',
'input',
'update:modelValue',
'keyup',
'focus',
'keydown',
'send',
]);
const { formatMessage } = useMessageFormatter();
// Minimal schema with no marks or nodes for copilot input
const copilotSchema = buildMessageSchema([], []);
const handleSubmit = () => emit('send');
const createState = (
content,
placeholder,
plugins = [],
enabledMenuOptions = []
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(copilotSchema).parse(content),
plugins: buildEditor({
schema: copilotSchema,
placeholder,
plugins,
enabledMenuOptions,
}),
});
};
// we don't need them to be reactive
// It cases weird issues where the objects are proxied
// and then the editor doesn't work as expected
let editorView = null;
let state = null;
// reactive data
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
// element refs
const editor = useTemplateRef('editor');
function contentFromEditor() {
if (editorView) {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
}
return '';
}
function focusEditorInputField() {
const { tr } = editorView.state;
const selection = Selection.atEnd(tr.doc);
editorView.dispatch(tr.setSelection(selection));
editorView.focus();
}
function emitOnChange() {
emit('update:modelValue', contentFromEditor());
emit('input', contentFromEditor());
}
function onKeyup() {
emit('keyup');
}
function onKeydown(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling
}
return false; // Allow other keys to work normally
}
function onBlur() {
emit('blur');
}
function onFocus() {
emit('focus');
}
function checkSelection(editorState) {
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
}
// computed properties
const plugins = computed(() => {
return [];
});
const enabledMenuOptions = computed(() => {
return [];
});
function reloadState() {
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
enabledMenuOptions.value
);
editorView.updateState(state);
focusEditorInputField();
}
function createEditorView() {
editorView = new EditorView(editor.value, {
state: state,
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
if (tx.docChanged) {
emitOnChange();
}
checkSelection(state);
},
handleDOMEvents: {
keyup: onKeyup,
focus: onFocus,
blur: onBlur,
keydown: onKeydown,
},
});
}
// watchers
watch(
computed(() => props.modelValue),
(newValue = '') => {
if (newValue !== contentFromEditor()) {
reloadState();
}
}
);
watch(
computed(() => props.editorId),
() => {
reloadState();
}
);
// lifecycle
onMounted(() => {
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
enabledMenuOptions.value
);
createEditorView();
editorView.updateState(state);
if (props.autofocus) {
focusEditorInputField();
}
});
</script>
<template>
<div class="space-y-2 mb-4">
<div
class="overflow-y-auto"
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
>
<p
v-dompurify-html="formatMessage(generatedContent, false)"
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
/>
</div>
<div class="editor-root relative editor--copilot space-x-2">
<div ref="editor" />
<div class="flex items-center justify-end absolute right-2 bottom-2">
<NextButton
class="bg-n-iris-9 text-white !rounded-full"
icon="i-lucide-arrow-up"
solid
sm
@click="handleSubmit"
/>
</div>
</div>
</div>
</template>
<style lang="scss">
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.editor--copilot {
@apply bg-n-iris-5 rounded;
.ProseMirror-woot-style {
min-height: 5rem;
max-height: 7.5rem !important;
overflow: auto;
@apply px-2 !important;
.empty-node {
&::before {
@apply text-n-iris-9 dark:text-n-iris-11;
}
}
}
}
</style>
@@ -0,0 +1,259 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useElementSize, useWindowSize } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import { useCaptain } from 'dashboard/composables/useCaptain';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
defineProps({
hasSelection: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['executeCopilotAction']);
const { t } = useI18n();
const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
// for now, we don't allow improving just aprt of the selection
// we will add this feature later. Once we do, we can revert the change
const hasSelection = false;
// const hasSelection = props.hasSelection
if (hasSelection) {
items.push({
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY_SELECTION'
),
key: 'improve_selection',
icon: 'i-fluent-pen-sparkle-24-regular',
});
} else if (
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
draftMessage.value
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
key: 'improve',
icon: 'i-fluent-pen-sparkle-24-regular',
});
}
if (draftMessage.value) {
items.push(
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.TITLE'
),
key: 'change_tone',
icon: 'i-fluent-sound-wave-circle-sparkle-24-regular',
subMenuItems: [
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.PROFESSIONAL'
),
key: 'professional',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CASUAL'
),
key: 'casual',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.STRAIGHTFORWARD'
),
key: 'straightforward',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CONFIDENT'
),
key: 'confident',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.FRIENDLY'
),
key: 'friendly',
},
],
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.GRAMMAR'),
key: 'fix_spelling_grammar',
icon: 'i-fluent-flow-sparkle-24-regular',
}
);
}
return items;
});
const generalMenuItems = computed(() => {
const items = [];
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
key: 'reply_suggestion',
icon: 'i-fluent-chat-sparkle-16-regular',
});
}
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize',
icon: 'i-fluent-text-bullet-list-square-sparkle-32-regular',
});
}
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.ASK_COPILOT'),
key: 'ask_copilot',
icon: 'i-fluent-circle-sparkle-24-regular',
});
return items;
});
const menuRef = useTemplateRef('menuRef');
const { height: menuHeight } = useElementSize(menuRef);
const { width: windowWidth } = useWindowSize();
// Smart submenu positioning based on available space
const submenuPosition = computed(() => {
const el = menuRef.value?.$el;
if (!el) return 'ltr:right-full rtl:left-full';
const { left, right } = el.getBoundingClientRect();
const SUBMENU_WIDTH = 200;
const spaceRight = (windowWidth.value ?? window.innerWidth) - right;
const spaceLeft = left;
// Prefer right, fallback to side with more space
const showRight = spaceRight >= SUBMENU_WIDTH || spaceRight >= spaceLeft;
return showRight ? 'left-full' : 'right-full';
});
// Computed style for selection menu positioning (only dynamic top offset)
const selectionMenuStyle = computed(() => {
// Dynamically calculate offset based on actual menu height + 10px gap
const dynamicOffset = menuHeight.value > 0 ? menuHeight.value + 10 : 60;
return {
top: `calc(var(--selection-top) - ${dynamicOffset}px)`,
};
});
const handleMenuItemClick = item => {
// For items with submenus, do nothing on click (hover will show submenu)
if (!item.subMenuItems) {
emit('executeCopilotAction', item.key);
}
};
const handleSubMenuItemClick = (parentItem, subItem) => {
emit('executeCopilotAction', subItem.key);
};
</script>
<template>
<DropdownBody
ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection }"
:style="hasSelection ? selectionMenuStyle : {}"
>
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
<div
v-for="item in menuItems"
:key="item.key"
class="w-full relative group/submenu"
>
<Button
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
@click="handleMenuItemClick(item)"
>
<template v-if="item.subMenuItems" #default>
<div class="flex items-center gap-1 justify-between w-full">
<span class="min-w-0 truncate">{{ item.label }}</span>
<Icon
icon="i-lucide-chevron-right"
class="text-n-slate-10 size-3"
/>
</div>
</template>
</Button>
<!-- Hover Submenu -->
<DropdownBody
v-if="item.subMenuItems"
class="group-hover/submenu:block hidden [&>ul]:gap-2 [&>ul]:px-3 [&>ul]:py-2.5 [&>ul]:dark:!border-n-strong max-h-[15rem] min-w-32 z-10 top-0"
:class="submenuPosition"
>
<Button
v-for="subItem in item.subMenuItems"
:key="subItem.key + subItem.label"
:label="subItem.label"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start mb-1"
@click="handleSubMenuItemClick(item, subItem)"
/>
</DropdownBody>
</div>
</div>
<div v-if="menuItems.length > 0" class="h-px w-full bg-n-strong" />
<div class="flex flex-col items-start gap-3">
<Button
v-for="(item, index) in generalMenuItems"
:key="index"
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
@click="handleMenuItemClick(item)"
/>
</div>
</DropdownBody>
</template>
<style scoped lang="scss">
.selection-menu {
position: absolute !important;
// Default/LTR: position from left
left: var(--selection-left);
// RTL: position from right instead
[dir='rtl'] & {
left: auto;
right: var(--selection-right);
}
}
</style>
@@ -0,0 +1,51 @@
<script setup>
import { computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
import { useKbd } from 'dashboard/composables/utils/useKbd';
defineProps({
isGeneratingContent: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const handleCancel = () => {
emit('cancel');
};
const shortcutKey = useKbd(['$mod', '+', 'enter']);
const acceptLabel = computed(() => {
return `${t('GENERAL.ACCEPT')} (${shortcutKey.value})`;
});
const handleSubmit = () => {
emit('submit');
};
</script>
<template>
<div class="flex justify-between items-center p-3 pt-0">
<NextButton
:label="t('GENERAL.DISCARD')"
slate
link
class="!px-1 hover:!no-underline"
sm
:disabled="isGeneratingContent"
@click="handleCancel"
/>
<NextButton
:label="acceptLabel"
class="bg-n-iris-9 text-white"
solid
sm
:disabled="isGeneratingContent"
@click="handleSubmit"
/>
</div>
</template>
@@ -16,13 +16,16 @@ import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
import VariableList from '../conversation/VariableList.vue';
import TagTools from '../conversation/TagTools.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
import { useEmitter } from 'dashboard/composables/emitter';
import { useI18n } from 'vue-i18n';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
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';
@@ -100,13 +103,16 @@ const emit = defineEmits([
'focus',
'input',
'update:modelValue',
'executeCopilotAction',
]);
const { t } = useI18n();
const { captainTasksEnabled } = useCaptain();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
@@ -116,17 +122,24 @@ const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate
? DEFAULT_FORMATTING
? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value;
const formatting = getFormattingForEditor(formatType);
const formatting = getFormattingForEditor(
formatType,
captainTasksEnabled.value
);
return buildMessageSchema(formatting.marks, formatting.nodes);
});
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
const formatting = getFormattingForEditor(
formatType,
captainTasksEnabled.value
);
return formatting.menu;
});
@@ -185,6 +198,21 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
const handleCopilotAction = actionKey => {
if (actionKey === 'improve_selection' && editorView?.state) {
const { from, to } = editorView.state.selection;
const selectedText = editorView.state.doc.textBetween(from, to).trim();
if (from !== to && selectedText) {
emit('executeCopilotAction', 'improve', selectedText);
}
} else {
emit('executeCopilotAction', actionKey);
}
showSelectionMenu.value = false;
};
const contentFromEditor = () => {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
};
@@ -367,13 +395,23 @@ function openFileBrowser() {
imageUpload.value.click();
}
function handleCopilotClick() {
showSelectionMenu.value = !showSelectionMenu.value;
}
function handleClickOutside(event) {
// Check if the clicked element or its parents have the ignored class
if (event.target.closest('.ProseMirror-copilot')) return;
showSelectionMenu.value = false;
}
function reloadState(content = props.modelValue) {
const unrefContent = unref(content);
state = createState(
unrefContent,
props.placeholder,
plugins.value,
{ onImageUpload: openFileBrowser },
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -595,7 +633,12 @@ function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
// Use the editor's current schema to ensure compatibility with buildMessageSchema
const currentSchema = editorView.state.schema;
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
// Strip unsupported formatting before parsing to ensure content can be inserted
// into channels that don't support certain markdown features (e.g., API channels)
const sanitizedContent = stripUnsupportedFormatting(content, currentSchema);
let node = new MessageMarkdownTransformer(currentSchema).parse(
sanitizedContent
);
insertNodeIntoEditor(node, from, undefined);
}
@@ -757,7 +800,7 @@ onMounted(() => {
props.modelValue,
props.placeholder,
plugins.value,
{ onImageUpload: openFileBrowser },
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -802,6 +845,14 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
:search-key="toolSearchKey"
@select-tool="content => insertSpecialContent('tool', content)"
/>
<CopilotMenuBar
v-if="showSelectionMenu"
v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected"
:show-selection-menu="showSelectionMenu"
:show-general-menu="false"
@execute-copilot-action="handleCopilotAction"
/>
<input
ref="imageUpload"
type="file"
@@ -855,6 +906,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply size-full;
}
}
.ProseMirror-copilot svg {
@apply fill-n-violet-9 text-n-violet-9 stroke-none;
}
}
}
@@ -994,6 +1049,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0;
}
.ProseMirror-copilot svg {
@apply fill-n-violet-9 text-n-violet-9 stroke-none;
}
}
.ProseMirror-menu-active {
@@ -12,6 +12,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isReplyRestricted: {
type: Boolean,
default: false,
},
});
defineEmits(['toggleMode']);
@@ -24,11 +28,17 @@ const privateModeSize = useElementSize(wootEditorPrivateMode);
/**
* Computed boolean indicating if the editor is in private note mode
* When disabled, always show NOTE mode regardless of actual mode prop
* When isReplyRestricted is true, force switch to private note
* Otherwise, respect the current mode prop
* @type {ComputedRef<boolean>}
*/
const isPrivate = computed(() => {
return props.disabled || props.mode === REPLY_EDITOR_MODES.NOTE;
if (props.isReplyRestricted) {
// Force switch to private note when replies are restricted
return true;
}
// Otherwise respect the current mode
return props.mode === REPLY_EDITOR_MODES.NOTE;
});
/**
@@ -60,9 +70,9 @@ const translateValue = computed(() => {
<template>
<button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
:disabled="disabled"
:disabled="disabled || isReplyRestricted"
:class="{
'cursor-not-allowed': disabled,
'cursor-not-allowed': disabled || isReplyRestricted,
}"
@click="$emit('toggleMode')"
>
@@ -75,7 +85,7 @@ const translateValue = computed(() => {
<div
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
:class="{
'transition-all duration-300': !disabled,
'transition-all duration-300': !disabled && !isReplyRestricted,
}"
:style="{
'--chip-width': width,
@@ -9,14 +9,13 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
name: 'ReplyBottomPanel',
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
components: { NextButton, FileUpload, VideoCallButton },
mixins: [inboxMixin],
props: {
isNote: {
@@ -98,6 +97,7 @@ export default {
type: Number,
required: true,
},
// eslint-disable-next-line vue/no-unused-properties
message: {
type: String,
default: '',
@@ -370,13 +370,6 @@ export default {
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
:conversation-id="conversationId"
/>
<AIAssistanceButton
v-if="!isFetchingAppIntegrations"
:conversation-id="conversationId"
:is-private-note="isOnPrivateNote"
:message="message"
@replace-text="replaceText"
/>
<transition name="modal-fade">
<div
v-show="uploadRef && uploadRef.dropActive"
@@ -1,14 +1,22 @@
<script>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { vOnClickOutside } from '@vueuse/components';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
export default {
name: 'ReplyTopPanel',
components: {
NextButton,
EditorModeToggle,
CopilotMenuBar,
},
directives: {
OnClickOutside: vOnClickOutside,
},
props: {
mode: {
@@ -19,6 +27,10 @@ export default {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -28,7 +40,7 @@ export default {
default: () => 0,
},
},
emits: ['setReplyMode', 'togglePopout'],
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) {
const setReplyMode = mode => {
emit('setReplyMode', mode);
@@ -47,6 +59,23 @@ export default {
: REPLY_EDITOR_MODES.REPLY;
setReplyMode(newMode);
};
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
const handleCopilotAction = actionKey => {
emit('executeCopilotAction', actionKey);
showCopilotMenu.value = false;
};
const toggleCopilotMenu = () => {
showCopilotMenu.value = !showCopilotMenu.value;
};
const handleClickOutside = () => {
showCopilotMenu.value = false;
};
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
@@ -64,6 +93,11 @@ export default {
handleReplyClick,
handleNoteClick,
REPLY_EDITOR_MODES,
captainTasksEnabled,
handleCopilotAction,
showCopilotMenu,
toggleCopilotMenu,
handleClickOutside,
};
},
computed: {
@@ -90,11 +124,13 @@ export default {
</script>
<template>
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
<div
class="flex justify-between gap-2 h-[3.25rem] items-center ltr:pl-3 ltr:pr-2 rtl:pr-3 rtl:pl-2"
>
<EditorModeToggle
:mode="mode"
:disabled="isReplyRestricted"
class="mt-3"
:disabled="disabled"
:is-reply-restricted="isReplyRestricted"
@toggle-mode="handleModeToggle"
/>
<div class="flex items-center mx-4 my-0">
@@ -104,11 +140,34 @@ export default {
</span>
</div>
</div>
<NextButton
ghost
class="ltr:rounded-bl-md rtl:rounded-br-md ltr:rounded-br-none rtl:rounded-bl-none ltr:rounded-tl-none rtl:rounded-tr-none text-n-slate-11 ltr:rounded-tr-[11px] rtl:rounded-tl-[11px]"
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
<div v-if="captainTasksEnabled" class="flex items-center gap-2">
<div class="relative">
<NextButton
ghost
:disabled="disabled"
:class="{
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
}"
sm
icon="i-ph-sparkle-fill"
@click="toggleCopilotMenu"
/>
<CopilotMenuBar
v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside"
:has-selection="false"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
/>
</div>
<NextButton
ghost
class="text-n-slate-11"
sm
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
</div>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { ref } from 'vue';
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
defineProps({
showCopilotEditor: {
type: Boolean,
default: false,
},
isGeneratingContent: {
type: Boolean,
default: false,
},
generatedContent: {
type: String,
default: '',
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'focus',
'blur',
'clearSelection',
'contentReady',
'send',
]);
const copilotEditorContent = ref('');
const onFocus = () => {
emit('focus');
};
const onBlur = () => {
emit('blur');
};
const clearEditorSelection = () => {
emit('clearSelection');
};
const onSend = () => {
emit('send', copilotEditorContent.value);
copilotEditorContent.value = '';
};
</script>
<template>
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
@after-enter="emit('contentReady')"
>
<CopilotEditor
v-if="showCopilotEditor && !isGeneratingContent"
key="copilot-editor"
v-model="copilotEditorContent"
class="copilot-editor"
:generated-content="generatedContent"
:min-height="4"
:enabled-menu-options="[]"
:is-popout="isPopout"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@send="onSend"
/>
<div
v-else-if="isGeneratingContent"
key="loading-state"
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
>
<div class="flex items-center gap-2">
<CaptainLoader class="text-n-iris-10 size-4" />
<span class="text-sm text-n-iris-10">
{{ $t('CONVERSATION.REPLYBOX.COPILOT_THINKING') }}
</span>
</div>
</div>
</Transition>
</template>
<style lang="scss">
.copilot-editor {
.ProseMirror-menubar {
display: none;
}
}
</style>
@@ -11,7 +11,7 @@ const openProfileSettings = () => {
<template>
<div
class="my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
class="my-0 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
>
<p class="w-fit !m-0">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
@@ -1,9 +1,8 @@
<script>
import { ref, provide } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
// components
@@ -49,7 +48,6 @@ export default {
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const keyboardEvents = {
Escape: {
@@ -61,22 +59,14 @@ export default {
useKeyboardEvents(keyboardEvents);
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
const { captainTasksEnabled, getLabelSuggestions } = useCaptain();
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
captainTasksEnabled,
getLabelSuggestions,
conversationPanelRef,
};
},
@@ -104,10 +94,7 @@ export default {
},
shouldShowLabelSuggestions() {
return (
this.isOpen &&
this.isEnterprise &&
this.isAIIntegrationEnabled &&
!this.messageSentSinceOpened
this.isOpen && this.captainTasksEnabled && !this.messageSentSinceOpened
);
},
inboxId() {
@@ -291,24 +278,15 @@ export default {
return;
}
if (!this.isEnterprise) {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = this.currentChat?.labels || [];
if (existingLabels.length > 0) return;
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
if (!this.isLabelSuggestionFeatureEnabled) {
if (!this.captainTasksEnabled) {
return;
}
this.labelSuggestions = await this.fetchLabelSuggestions({
conversationId: this.currentChat.id,
});
this.labelSuggestions = await this.getLabelSuggestions();
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
@@ -12,7 +12,9 @@ import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.v
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
import CopilotEditorSection from './CopilotEditorSection.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
import ReplyBoxBanner from './ReplyBoxBanner.vue';
import QuotedEmailPreview from './QuotedEmailPreview.vue';
@@ -21,6 +23,7 @@ import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vu
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import {
getMessageVariables,
getUndefinedVariablesInMessage,
@@ -45,6 +48,8 @@ import {
removeSignature,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -70,6 +75,8 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
CopilotEditorSection,
CopilotReplyBottomPanel,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -89,6 +96,8 @@ export default {
} = useUISettings();
const replyEditor = useTemplateRef('replyEditor');
const copilot = useCopilotReply();
const shortcutKey = useKbd(['$mod', '+', 'enter']);
return {
uiSettings,
@@ -97,6 +106,8 @@ export default {
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
replyEditor,
copilot,
shortcutKey,
};
},
data() {
@@ -267,7 +278,7 @@ export default {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
}
const keyLabel = this.isEditorHotKeyEnabled('cmd_enter')
? '(⌘ + ↵)'
? `(${this.shortcutKey})`
: '(↵)';
return `${sendMessageText} ${keyLabel}`;
},
@@ -400,6 +411,9 @@ export default {
!!this.quotedEmailText
);
},
isDefaultEditorMode() {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -409,6 +423,8 @@ export default {
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
// like self-assign or other updates that do not actually change the conversation context
this.setCCAndToEmailsFromLastChat();
// Reset Copilot editor state (includes cancelling ongoing generation)
this.copilot.reset();
}
if (this.isOnPrivateNote) {
@@ -478,6 +494,7 @@ export default {
this.onNewConversationModalActive
);
emitter.on(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
emitter.on(CMD_AI_ASSIST, this.executeCopilotAction);
},
unmounted() {
document.removeEventListener('paste', this.onPaste);
@@ -488,6 +505,7 @@ export default {
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
},
methods: {
handleInsert(article) {
@@ -613,7 +631,9 @@ export default {
},
'$mod+Enter': {
action: () => {
if (this.isAValidEvent('cmd_enter')) {
if (this.copilot.isActive.value && this.isFocused) {
this.onSubmitCopilotReply();
} else if (this.isAValidEvent('cmd_enter')) {
this.onSendReply();
}
},
@@ -830,6 +850,9 @@ export default {
this.updateEditorSelectionWith = content;
this.onFocus();
},
executeCopilotAction(action, data) {
this.copilot.execute(action, data);
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
@@ -1095,6 +1118,9 @@ export default {
togglePopout() {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
},
onSubmitCopilotReply() {
this.message = this.copilot.accept();
},
},
};
</script>
@@ -1105,11 +1131,17 @@ export default {
<ReplyTopPanel
:mode="replyType"
:is-reply-restricted="isReplyRestricted"
:disabled="
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
showAudioRecorderEditor
"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode"
@toggle-popout="togglePopout"
@toggle-copilot="copilot.toggleEditor"
@execute-copilot-action="executeCopilotAction"
/>
<ArticleSearchPopover
v-if="showArticleSearchPopover && connectedPortalSlug"
@@ -1117,112 +1149,167 @@ export default {
@insert="handleInsert"
@close="onSearchPopoverClose"
/>
<div class="reply-box__top">
<ReplyToMessage
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
}"
:on-click="addIntoEditor"
/>
<ReplyEmailHead
v-if="showReplyHead"
v-model:cc-emails="ccEmails"
v-model:bcc-emails="bccEmails"
v-model:to-emails="toEmails"
/>
<AudioRecorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<WootMessageEditor
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview"
:quoted-email-text="quotedEmailText"
:preview-text="quotedEmailPreviewText"
@toggle="toggleQuotedReply"
/>
</div>
<div
v-if="hasAttachments && !showAudioRecorderEditor"
class="attachment-preview-box"
@paste="onPaste"
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
>
<AttachmentPreview
class="flex-col mt-4"
:attachments="attachedFiles"
@remove-attachment="removeAttachment"
<div :key="copilot.editorTransitionKey.value" class="reply-box__top">
<ReplyToMessage
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
}"
:on-click="addIntoEditor"
/>
<ReplyEmailHead
v-if="showReplyHead && isDefaultEditorMode"
v-model:cc-emails="ccEmails"
v-model:bcc-emails="bccEmails"
v-model:to-emails="toEmails"
/>
<AudioRecorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<CopilotEditorSection
v-if="copilot.isActive.value && !showAudioRecorderEditor"
:show-copilot-editor="copilot.showEditor.value"
:is-generating-content="copilot.isGenerating.value"
:generated-content="copilot.generatedContent.value"
:is-popout="popOutReplyBox"
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@close="copilot.showEditor.value = false"
@content-ready="copilot.setContentReady"
@send="copilot.sendFollowUp"
/>
<WootMessageEditor
v-else-if="!showAudioRecorderEditor"
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
@execute-copilot-action="executeCopilotAction"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview && isDefaultEditorMode"
:quoted-email-text="quotedEmailText"
:preview-text="quotedEmailPreviewText"
class="mb-2"
@toggle="toggleQuotedReply"
/>
<div
v-if="hasAttachments && isDefaultEditorMode"
class="bg-transparent py-0 mb-2"
@paste="onPaste"
>
<AttachmentPreview
class="mt-2"
:attachments="attachedFiles"
@remove-attachment="removeAttachment"
/>
</div>
<MessageSignatureMissingAlert
v-if="
isSignatureEnabledForInbox &&
!isSignatureAvailable &&
isDefaultEditorMode
"
class="mb-2"
/>
</div>
</Transition>
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
>
<CopilotReplyBottomPanel
v-if="copilot.isActive.value"
key="copilot-bottom-panel"
:is-generating-content="copilot.isButtonDisabled.value"
@submit="onSubmitCopilotReply"
@cancel="copilot.toggleEditor"
/>
</div>
<MessageSignatureMissingAlert
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
/>
<ReplyBottomPanel
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
:enable-content-templates="showContentTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
:quoted-reply-enabled="quotedReplyPreference"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
<ReplyBottomPanel
v-else
key="reply-bottom-panel"
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
:enable-content-templates="showContentTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
:quoted-reply-enabled="quotedReplyPreference"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
</Transition>
<WhatsappTemplates
:inbox-id="inbox.id"
:show="showWhatsAppTemplatesModal"
@@ -1252,13 +1339,7 @@ export default {
@apply mb-0;
}
.attachment-preview-box {
@apply bg-transparent py-0 px-4;
}
.reply-box {
transition: height 2s cubic-bezier(0.37, 0, 0.63, 1);
@apply relative mb-2 mx-2 border border-n-weak rounded-xl bg-n-solid-1;
&.is-private {
@@ -4,7 +4,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
// composables
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
// store & api
@@ -33,9 +33,9 @@ export default {
},
},
setup() {
const { isAIIntegrationEnabled } = useAI();
const { captainTasksEnabled } = useCaptain();
return { isAIIntegrationEnabled };
return { captainTasksEnabled };
},
data() {
return {
@@ -78,7 +78,7 @@ export default {
},
shouldShowSuggestions() {
if (this.isDismissed) return false;
if (!this.isAIIntegrationEnabled) return false;
if (!this.captainTasksEnabled) return false;
return this.preparedLabels.length && this.chatLabels.length === 0;
},
@@ -0,0 +1,33 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
</script>
<template>
<Icon v-once icon="i-woot-captain" class="jumping-logo" />
</template>
<style scoped>
.jumping-logo {
transform-origin: center bottom;
animation: jump 1s cubic-bezier(0.28, 0.84, 0.42, 1) infinite;
will-change: transform;
}
@keyframes jump {
0% {
transform: translateY(0) scale(1, 1);
}
20% {
transform: translateY(0) scale(1.05, 0.95);
}
50% {
transform: translateY(-5px) scale(0.95, 1.05);
}
80% {
transform: translateY(0) scale(1.02, 0.98);
}
100% {
transform: translateY(0) scale(1, 1);
}
}
</style>
@@ -3,7 +3,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import {
@@ -18,7 +18,7 @@ vi.mock('dashboard/composables/store');
vi.mock('vue-i18n');
vi.mock('vue-router');
vi.mock('dashboard/composables/useConversationLabels');
vi.mock('dashboard/composables/useAI');
vi.mock('dashboard/composables/useCaptain');
vi.mock('dashboard/composables/useAgentsList');
describe('useConversationHotKeys', () => {
@@ -49,7 +49,7 @@ describe('useConversationHotKeys', () => {
addLabelToConversation: vi.fn(),
removeLabelFromConversation: vi.fn(),
});
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: true } });
useCaptain.mockReturnValue({ captainTasksEnabled: { value: true } });
useAgentsList.mockReturnValue({
agentsList: { value: [] },
assignableAgents: { value: mockAssignableAgents },
@@ -67,7 +67,7 @@ describe('useConversationHotKeys', () => {
expect(conversationHotKeys.value.length).toBeGreaterThan(0);
});
it('should include AI assist actions when AI integration is enabled', () => {
it('should include AI assist actions when captain tasks is enabled', () => {
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'
@@ -75,8 +75,8 @@ describe('useConversationHotKeys', () => {
expect(aiAssistAction).toBeDefined();
});
it('should not include AI assist actions when AI integration is disabled', () => {
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: false } });
it('should not include AI assist actions when captain tasks is disabled', () => {
useCaptain.mockReturnValue({ captainTasksEnabled: { value: false } });
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'
@@ -4,7 +4,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { emitter } from 'shared/helpers/mitt';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
@@ -102,8 +102,8 @@ const createNonDraftMessageAIAssistActions = (t, replyMode) => {
const createDraftMessageAIAssistActions = t => {
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
key: 'rephrase',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CONFIDENT'),
key: 'confident',
icon: ICON_AI_ASSIST,
},
{
@@ -112,28 +112,23 @@ const createDraftMessageAIAssistActions = t => {
icon: ICON_AI_GRAMMAR,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
key: 'expand',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.PROFESSIONAL'),
key: 'professional',
icon: ICON_AI_EXPAND,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
key: 'shorten',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CASUAL'),
key: 'casual',
icon: ICON_AI_SHORTEN,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
key: 'make_friendly',
key: 'friendly',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
key: 'make_formal',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
key: 'simplify',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.STRAIGHTFORWARD'),
key: 'straightforward',
icon: ICON_AI_ASSIST,
},
];
@@ -151,7 +146,7 @@ export function useConversationHotKeys() {
removeLabelFromConversation,
} = useConversationLabels();
const { isAIIntegrationEnabled } = useAI();
const { captainTasksEnabled } = useCaptain();
const { agentsList } = useAgentsList();
const currentChat = useMapGetter('getSelectedChat');
@@ -386,7 +381,7 @@ export function useConversationHotKeys() {
...labelActions.value,
...assignPriorityActions.value,
];
if (isAIIntegrationEnabled.value) {
if (captainTasksEnabled.value) {
return [...defaultConversationHotKeys, ...AIAssistActions.value];
}
return defaultConversationHotKeys;
@@ -1,122 +0,0 @@
import { useAI } from '../useAI';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import OpenAPI from 'dashboard/api/integrations/openapi';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
vi.mock('dashboard/composables/store');
vi.mock('vue-i18n');
vi.mock('dashboard/api/integrations/openapi');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
OPEN_AI_EVENTS: {
TEST_EVENT: 'open_ai_test_event',
},
}));
describe('useAI', () => {
const mockStore = {
dispatch: vi.fn(),
};
const mockGetters = {
'integrations/getUIFlags': { value: { isFetching: false } },
'draftMessages/get': { value: () => 'Draft message' },
};
beforeEach(() => {
vi.clearAllMocks();
useStore.mockReturnValue(mockStore);
useStoreGetters.mockReturnValue(mockGetters);
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
useI18n.mockReturnValue({ t: vi.fn() });
});
it('initializes computed properties correctly', async () => {
const { uiFlags, appIntegrations, currentChat, replyMode, draftMessage } =
useAI();
expect(uiFlags.value).toEqual({ isFetching: false });
expect(appIntegrations.value).toEqual([]);
expect(currentChat.value).toEqual({ id: '123' });
expect(replyMode.value).toBe('reply');
expect(draftMessage.value).toBe('Draft message');
});
it('fetches integrations if required', async () => {
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).toHaveBeenCalledWith('integrations/get');
});
it('does not fetch integrations if already loaded', async () => {
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [{ id: 'openai' }],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
it('records analytics correctly', async () => {
// const mockTrack = analyticsHelper.track;
const { recordAnalytics } = useAI();
await recordAnalytics('TEST_EVENT', { data: 'test' });
expect(analyticsHelper.track).toHaveBeenCalledWith('open_ai_test_event', {
type: 'TEST_EVENT',
data: 'test',
});
});
it('fetches label suggestions', async () => {
OpenAPI.processEvent.mockResolvedValue({
data: { message: 'label1, label2' },
});
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [
{ id: 'openai', hooks: [{ id: 'hook1' }] },
],
getSelectedChat: { id: '123' },
};
return { value: mockValues[getter] };
});
const { fetchLabelSuggestions } = useAI();
const result = await fetchLabelSuggestions();
expect(OpenAPI.processEvent).toHaveBeenCalledWith({
type: 'label_suggestion',
hookId: 'hook1',
conversationId: '123',
});
expect(result).toEqual(['label1', 'label2']);
});
});
@@ -0,0 +1,213 @@
import { useCaptain } from '../useCaptain';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useI18n } from 'vue-i18n';
import TasksAPI from 'dashboard/api/captain/tasks';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useAccount');
vi.mock('dashboard/composables/useConfig');
vi.mock('vue-i18n');
vi.mock('dashboard/api/captain/tasks');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
OPEN_AI_EVENTS: {
TEST_EVENT: 'open_ai_test_event',
},
}));
describe('useCaptain', () => {
const mockStore = {
dispatch: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
useStore.mockReturnValue(mockStore);
useFunctionGetter.mockReturnValue({ value: 'Draft message' });
useMapGetter.mockImplementation(getter => {
const mockValues = {
'accounts/getUIFlags': { isFetchingLimits: false },
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
useI18n.mockReturnValue({ t: vi.fn() });
useAccount.mockReturnValue({
isCloudFeatureEnabled: vi.fn().mockReturnValue(true),
currentAccount: { value: { limits: { captain: {} } } },
});
useConfig.mockReturnValue({
isEnterprise: false,
});
});
it('initializes computed properties correctly', async () => {
const { captainEnabled, captainTasksEnabled, currentChat, draftMessage } =
useCaptain();
expect(captainEnabled.value).toBe(true);
expect(captainTasksEnabled.value).toBe(true);
expect(currentChat.value).toEqual({ id: '123' });
expect(draftMessage.value).toBe('Draft message');
});
it('records analytics correctly', async () => {
const { recordAnalytics } = useCaptain();
await recordAnalytics('TEST_EVENT', { data: 'test' });
expect(analyticsHelper.track).toHaveBeenCalledWith('open_ai_test_event', {
type: 'TEST_EVENT',
data: 'test',
});
});
it('gets label suggestions', async () => {
TasksAPI.labelSuggestion.mockResolvedValue({
data: { message: 'label1, label2' },
});
const { getLabelSuggestions } = useCaptain();
const result = await getLabelSuggestions();
expect(TasksAPI.labelSuggestion).toHaveBeenCalledWith('123');
expect(result).toEqual(['label1', 'label2']);
});
it('rewrites content', async () => {
TasksAPI.rewrite.mockResolvedValue({
data: { message: 'Rewritten content', follow_up_context: { id: 'ctx1' } },
});
const { rewriteContent } = useCaptain();
const result = await rewriteContent('Original content', 'improve', {});
expect(TasksAPI.rewrite).toHaveBeenCalledWith(
{
content: 'Original content',
operation: 'improve',
conversationId: '123',
},
undefined
);
expect(result).toEqual({
message: 'Rewritten content',
followUpContext: { id: 'ctx1' },
});
});
it('summarizes conversation', async () => {
TasksAPI.summarize.mockResolvedValue({
data: { message: 'Summary', follow_up_context: { id: 'ctx2' } },
});
const { summarizeConversation } = useCaptain();
const result = await summarizeConversation({});
expect(TasksAPI.summarize).toHaveBeenCalledWith('123', undefined);
expect(result).toEqual({
message: 'Summary',
followUpContext: { id: 'ctx2' },
});
});
it('gets reply suggestion', async () => {
TasksAPI.replySuggestion.mockResolvedValue({
data: { message: 'Reply suggestion', follow_up_context: { id: 'ctx3' } },
});
const { getReplySuggestion } = useCaptain();
const result = await getReplySuggestion({});
expect(TasksAPI.replySuggestion).toHaveBeenCalledWith('123', undefined);
expect(result).toEqual({
message: 'Reply suggestion',
followUpContext: { id: 'ctx3' },
});
});
it('sends follow-up message', async () => {
TasksAPI.followUp.mockResolvedValue({
data: {
message: 'Follow-up response',
follow_up_context: { id: 'ctx4' },
},
});
const { followUp } = useCaptain();
const result = await followUp({
followUpContext: { id: 'ctx3' },
message: 'Make it shorter',
});
expect(TasksAPI.followUp).toHaveBeenCalledWith(
{
followUpContext: { id: 'ctx3' },
message: 'Make it shorter',
conversationId: '123',
},
undefined
);
expect(result).toEqual({
message: 'Follow-up response',
followUpContext: { id: 'ctx4' },
});
});
it('processes event and routes to correct method', async () => {
TasksAPI.summarize.mockResolvedValue({
data: { message: 'Summary' },
});
TasksAPI.replySuggestion.mockResolvedValue({
data: { message: 'Reply' },
});
TasksAPI.rewrite.mockResolvedValue({
data: { message: 'Rewritten' },
});
const { processEvent } = useCaptain();
// Test summarize
await processEvent('summarize', '', {});
expect(TasksAPI.summarize).toHaveBeenCalled();
// Test reply_suggestion
await processEvent('reply_suggestion', '', {});
expect(TasksAPI.replySuggestion).toHaveBeenCalled();
// Test rewrite (improve)
await processEvent('improve', 'content', {});
expect(TasksAPI.rewrite).toHaveBeenCalled();
});
it('returns empty array when no conversation ID for label suggestions', async () => {
useMapGetter.mockImplementation(getter => {
const mockValues = {
'accounts/getUIFlags': { isFetchingLimits: false },
getSelectedChat: { id: null },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
const { getLabelSuggestions } = useCaptain();
const result = await getLabelSuggestions();
expect(result).toEqual([]);
expect(TasksAPI.labelSuggestion).not.toHaveBeenCalled();
});
});
@@ -1,203 +0,0 @@
import { computed, onMounted } from 'vue';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import OpenAPI from 'dashboard/api/integrations/openapi';
/**
* Cleans and normalizes a list of labels.
* @param {string} labels - A comma-separated string of labels.
* @returns {string[]} An array of cleaned and unique labels.
*/
const cleanLabels = labels => {
return labels
.toLowerCase() // Set it to lowercase
.split(',') // split the string into an array
.filter(label => label.trim()) // remove any empty strings
.map(label => label.trim()) // trim the words
.filter((label, index, self) => self.indexOf(label) === index);
};
/**
* A composable function for AI-related operations in the dashboard.
* @returns {Object} An object containing AI-related methods and computed properties.
*/
export function useAI() {
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
/**
* Computed property for UI flags.
* @type {import('vue').ComputedRef<Object>}
*/
const uiFlags = computed(() => getters['integrations/getUIFlags'].value);
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
/**
* Computed property for the AI integration.
* @type {import('vue').ComputedRef<Object|undefined>}
*/
const aiIntegration = computed(
() =>
appIntegrations.value.find(
integration => integration.id === 'openai' && !!integration.hooks.length
)?.hooks[0]
);
/**
* Computed property to check if AI integration is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
/**
* Computed property to check if label suggestion feature is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isLabelSuggestionFeatureEnabled = computed(() => {
if (aiIntegration.value) {
const { settings = {} } = aiIntegration.value || {};
return settings.label_suggestion;
}
return false;
});
/**
* Computed property to check if app integrations are being fetched.
* @type {import('vue').ComputedRef<boolean>}
*/
const isFetchingAppIntegrations = computed(() => uiFlags.value.isFetching);
/**
* Computed property for the hook ID.
* @type {import('vue').ComputedRef<string|undefined>}
*/
const hookId = computed(() => aiIntegration.value?.id);
/**
* Computed property for the conversation ID.
* @type {import('vue').ComputedRef<string|undefined>}
*/
const conversationId = computed(() => currentChat.value?.id);
/**
* Computed property for the draft key.
* @type {import('vue').ComputedRef<string>}
*/
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
/**
* Computed property for the draft message.
* @type {import('vue').ComputedRef<string>}
*/
const draftMessage = computed(() =>
getters['draftMessages/get'].value(draftKey.value)
);
/**
* Fetches integrations if they haven't been loaded yet.
* @returns {Promise<void>}
*/
const fetchIntegrationsIfRequired = async () => {
if (!appIntegrations.value.length) {
await store.dispatch('integrations/get');
}
};
/**
* Records analytics for AI-related events.
* @param {string} type - The type of event.
* @param {Object} payload - Additional data for the event.
* @returns {Promise<void>}
*/
const recordAnalytics = async (type, payload) => {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
useTrack(event, {
type,
...payload,
});
}
};
/**
* Fetches label suggestions for the current conversation.
* @returns {Promise<string[]>} An array of suggested labels.
*/
const fetchLabelSuggestions = async () => {
if (!conversationId.value) return [];
try {
const result = await OpenAPI.processEvent({
type: 'label_suggestion',
hookId: hookId.value,
conversationId: conversationId.value,
});
const {
data: { message: labels },
} = result;
return cleanLabels(labels);
} catch {
return [];
}
};
/**
* Processes an AI event, such as rephrasing content.
* @param {string} [type='rephrase'] - The type of AI event to process.
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
*/
const processEvent = async (type = 'rephrase') => {
try {
const result = await OpenAPI.processEvent({
hookId: hookId.value,
type,
content: draftMessage.value,
conversationId: conversationId.value,
});
const {
data: { message: generatedMessage },
} = result;
return generatedMessage;
} catch (error) {
const errorData = error.response.data.error;
const errorMessage =
errorData?.error?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
return '';
}
};
onMounted(() => {
fetchIntegrationsIfRequired();
});
return {
draftMessage,
uiFlags,
appIntegrations,
currentChat,
replyMode,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
isFetchingAppIntegrations,
fetchIntegrationsIfRequired,
recordAnalytics,
fetchLabelSuggestions,
processEvent,
};
}
@@ -1,20 +1,56 @@
import { computed } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import TasksAPI from 'dashboard/api/captain/tasks';
/**
* Cleans and normalizes a list of labels.
* @param {string} labels - A comma-separated string of labels.
* @returns {string[]} An array of cleaned and unique labels.
*/
const cleanLabels = labels => {
return labels
.toLowerCase()
.split(',')
.filter(label => label.trim())
.map(label => label.trim())
.filter((label, index, self) => self.indexOf(label) === index);
};
export function useCaptain() {
const store = useStore();
const { t } = useI18n();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const uiFlags = useMapGetter('accounts/getUIFlags');
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
const conversationId = computed(() => currentChat.value?.id);
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
const draftMessage = useFunctionGetter('draftMessages/get', draftKey);
// === Feature Flags ===
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
});
const captainTasksEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
// === Limits (Enterprise) ===
const captainLimits = computed(() => {
return currentAccount.value?.limits?.captain;
});
@@ -23,7 +59,6 @@ export function useCaptain() {
if (captainLimits.value?.documents) {
return useCamelCase(captainLimits.value.documents);
}
return null;
});
@@ -31,7 +66,6 @@ export function useCaptain() {
if (captainLimits.value?.responses) {
return useCamelCase(captainLimits.value.responses);
}
return null;
});
@@ -43,12 +77,198 @@ export function useCaptain() {
}
};
// === Error Handling ===
/**
* Handles API errors and displays appropriate error messages.
* Silently returns for aborted requests.
* @param {Error} error - The error object from the API call.
*/
const handleAPIError = error => {
if (error.name === 'AbortError' || error.name === 'CanceledError') {
return;
}
const errorMessage =
error.response?.data?.error ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
};
// === Analytics ===
/**
* Records analytics for AI-related events.
* @param {string} type - The type of event.
* @param {Object} payload - Additional data for the event.
* @returns {Promise<void>}
*/
const recordAnalytics = async (type, payload) => {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
useTrack(event, {
type,
...payload,
});
}
};
// === Task Methods ===
/**
* Rewrites content with a specific operation.
* @param {string} content - The content to rewrite.
* @param {string} operation - The operation (fix_spelling_grammar, casual, professional, expand, shorten, improve, etc).
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The rewritten content and optional follow-up context.
*/
const rewriteContent = async (content, operation, options = {}) => {
try {
const result = await TasksAPI.rewrite(
{
content: content || draftMessage.value,
operation,
conversationId: conversationId.value,
},
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
}
};
/**
* Summarizes a conversation.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The summary and optional follow-up context.
*/
const summarizeConversation = async (options = {}) => {
try {
const result = await TasksAPI.summarize(
conversationId.value,
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
}
};
/**
* Gets a reply suggestion for the current conversation.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The reply suggestion and optional follow-up context.
*/
const getReplySuggestion = async (options = {}) => {
try {
const result = await TasksAPI.replySuggestion(
conversationId.value,
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
}
};
/**
* Gets label suggestions for the current conversation.
* @returns {Promise<string[]>} An array of suggested labels.
*/
const getLabelSuggestions = async () => {
if (!conversationId.value) return [];
try {
const result = await TasksAPI.labelSuggestion(conversationId.value);
const {
data: { message: labels },
} = result;
return cleanLabels(labels);
} catch {
return [];
}
};
/**
* Sends a follow-up message to refine a previous AI task result.
* @param {Object} options - The follow-up options.
* @param {Object} options.followUpContext - The follow-up context from a previous task.
* @param {string} options.message - The follow-up message/request from the user.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext: Object}>} The follow-up response and updated context.
*/
const followUp = async ({ followUpContext, message, signal }) => {
try {
const result = await TasksAPI.followUp(
{ followUpContext, message, conversationId: conversationId.value },
signal
);
const {
data: { message: generatedMessage, follow_up_context: updatedContext },
} = result;
return { message: generatedMessage, followUpContext: updatedContext };
} catch (error) {
handleAPIError(error);
return { message: '', followUpContext };
}
};
/**
* Processes an AI event. Routes to the appropriate method based on type.
* @param {string} [type='improve'] - The type of AI event to process.
* @param {string} [content=''] - The content to process.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The generated message and optional follow-up context.
*/
const processEvent = async (type = 'improve', content = '', options = {}) => {
if (type === 'summarize') {
return summarizeConversation(options);
}
if (type === 'reply_suggestion') {
return getReplySuggestion(options);
}
// All other types are rewrite operations
return rewriteContent(content, type, options);
};
return {
// Feature flags
captainEnabled,
captainTasksEnabled,
// Limits (Enterprise)
captainLimits,
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
// Conversation context
draftMessage,
currentChat,
// Task methods
rewriteContent,
summarizeConversation,
getReplySuggestion,
getLabelSuggestions,
followUp,
processEvent,
// Analytics
recordAnalytics,
};
}
@@ -0,0 +1,162 @@
import { ref, computed } from 'vue';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useUISettings } from 'dashboard/composables/useUISettings';
/**
* Composable for managing Copilot reply generation state and actions.
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
*
* @returns {Object} Copilot reply state and methods
*/
export function useCopilotReply() {
const { processEvent, followUp } = useCaptain();
const { updateUISettings } = useUISettings();
const showEditor = ref(false);
const isGenerating = ref(false);
const isContentReady = ref(false);
const generatedContent = ref('');
const followUpContext = ref(null);
const abortController = ref(null);
const isActive = computed(() => showEditor.value || isGenerating.value);
const isButtonDisabled = computed(
() => isGenerating.value || !isContentReady.value
);
const editorTransitionKey = computed(() =>
isActive.value ? 'copilot' : 'rich'
);
/**
* Resets all copilot editor state and cancels any ongoing generation.
*/
function reset() {
if (abortController.value) {
abortController.value.abort();
abortController.value = null;
}
showEditor.value = false;
isGenerating.value = false;
isContentReady.value = false;
generatedContent.value = '';
followUpContext.value = null;
}
/**
* Toggles the copilot editor visibility.
*/
function toggleEditor() {
showEditor.value = !showEditor.value;
}
/**
* Marks content as ready (called after transition completes).
*/
function setContentReady() {
isContentReady.value = true;
}
/**
* Executes a copilot action (e.g., improve, fix grammar).
* @param {string} action - The action type
* @param {string} data - The content to process
*/
async function execute(action, data) {
if (action === 'ask_copilot') {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
});
return;
}
// Reset and start new generation
reset();
abortController.value = new AbortController();
isGenerating.value = true;
isContentReady.value = false;
try {
const { message: content, followUpContext: newContext } =
await processEvent(action, data, {
signal: abortController.value.signal,
});
if (!abortController.value?.signal.aborted) {
generatedContent.value = content;
followUpContext.value = newContext;
if (content) showEditor.value = true;
isGenerating.value = false;
}
} catch {
if (!abortController.value?.signal.aborted) {
isGenerating.value = false;
}
}
}
/**
* Sends a follow-up message to refine the current generated content.
* @param {string} message - The follow-up message from the user
*/
async function sendFollowUp(message) {
if (!followUpContext.value || !message.trim()) return;
abortController.value = new AbortController();
isGenerating.value = true;
isContentReady.value = false;
try {
const { message: content, followUpContext: updatedContext } =
await followUp({
followUpContext: followUpContext.value,
message,
signal: abortController.value.signal,
});
if (!abortController.value?.signal.aborted) {
if (content) {
generatedContent.value = content;
followUpContext.value = updatedContext;
showEditor.value = true;
}
isGenerating.value = false;
}
} catch {
if (!abortController.value?.signal.aborted) {
isGenerating.value = false;
}
}
}
/**
* Accepts the generated content and returns it.
* Note: Formatting is automatically stripped by the Editor component's
* createState function based on the channel's schema.
* @returns {string} The content ready for the editor
*/
function accept() {
const content = generatedContent.value;
showEditor.value = false;
return content;
}
return {
showEditor,
isGenerating,
isContentReady,
generatedContent,
followUpContext,
isActive,
isButtonDisabled,
editorTransitionKey,
reset,
toggleEditor,
setContentReady,
execute,
sendFollowUp,
accept,
};
}
@@ -1,14 +1,25 @@
import { computed } from 'vue';
function isMacOS() {
// Check modern userAgentData API first
if (navigator.userAgentData?.platform) {
return navigator.userAgentData.platform === 'macOS';
}
// Fallback to navigator.platform
return (
navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone'
);
}
export function useKbd(keys) {
const keySymbols = {
$mod: navigator.platform.includes('Mac') ? '⌘' : 'Ctrl',
$mod: isMacOS() ? '⌘' : 'Ctrl',
shift: '⇧',
alt: '⌥',
ctrl: 'Ctrl',
cmd: '⌘',
option: '⌥',
enter: '↩',
enter: '↵',
tab: '⇥',
esc: '⎋',
};
@@ -16,7 +27,11 @@ export function useKbd(keys) {
return computed(() => {
return keys
.map(key => keySymbols[key.toLowerCase()] || key)
.join('')
.join(' ')
.toUpperCase();
});
}
export function getModifierKey() {
return isMacOS() ? '⌘' : 'Ctrl';
}
+24 -3
View File
@@ -7,6 +7,7 @@ export const FORMATTING = {
marks: ['strong', 'em', 'code', 'link'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -21,6 +22,7 @@ export const FORMATTING = {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -35,12 +37,13 @@ export const FORMATTING = {
'Channel::Api': {
marks: ['strong', 'em'],
nodes: [],
menu: ['strong', 'em', 'undo', 'redo'],
menu: ['copilot', 'strong', 'em', 'undo', 'redo'],
},
'Channel::FacebookPage': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -70,6 +73,7 @@ export const FORMATTING = {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -83,17 +87,18 @@ export const FORMATTING = {
'Channel::Line': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['codeBlock'],
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
menu: ['copilot', 'strong', 'em', 'code', 'strike', 'undo', 'redo'],
},
'Channel::Telegram': {
marks: ['strong', 'em', 'link', 'code'],
nodes: [],
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
menu: ['copilot', 'strong', 'em', 'link', 'code', 'undo', 'redo'],
},
'Channel::Instagram': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -115,6 +120,22 @@ export const FORMATTING = {
menu: [],
},
// Special contexts (not actual channels)
'Context::PrivateNote': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'copilot',
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Context::Default': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
+1
View File
@@ -40,6 +40,7 @@ export const FEATURE_FLAGS = {
CHANNEL_TIKTOK: 'channel_tiktok',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_V2: 'captain_integration_v2',
CAPTAIN_TASKS: 'captain_tasks',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
@@ -88,6 +88,7 @@ export const OPEN_AI_EVENTS = Object.freeze({
SUMMARIZE: 'OpenAI: Used summarize',
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
REPHRASE: 'OpenAI: Used rephrase',
IMPROVE: 'OpenAI: Used improve',
FIX_SPELLING_AND_GRAMMAR: 'OpenAI: Used fix spelling and grammar',
SHORTEN: 'OpenAI: Used shorten',
EXPAND: 'OpenAI: Used expand',
@@ -519,12 +519,19 @@ export const getContentNode = (
/**
* Get the formatting configuration for a specific channel type.
* Returns the appropriate marks, nodes, and menu items for the editor.
* TODO: We're hiding captain, enable it back when we add selection improvements
*
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
*/
export function getFormattingForEditor(channelType) {
return FORMATTING[channelType] || FORMATTING['Context::Default'];
export function getFormattingForEditor(channelType, showCaptain = false) {
const formatting = FORMATTING[channelType] || FORMATTING['Context::Default'];
return {
...formatting,
menu: showCaptain
? formatting.menu
: formatting.menu.filter(item => item !== 'copilot'),
};
}
/**
@@ -795,62 +795,6 @@ describe('getContentNode', () => {
});
describe('getFormattingForEditor', () => {
describe('channel-specific formatting', () => {
it('returns full formatting for Email channel', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toEqual(FORMATTING['Channel::Email']);
});
it('returns full formatting for WebWidget channel', () => {
const result = getFormattingForEditor('Channel::WebWidget');
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
});
it('returns limited formatting for WhatsApp channel', () => {
const result = getFormattingForEditor('Channel::Whatsapp');
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
});
it('returns no formatting for API channel', () => {
const result = getFormattingForEditor('Channel::Api');
expect(result).toEqual(FORMATTING['Channel::Api']);
});
it('returns limited formatting for FacebookPage channel', () => {
const result = getFormattingForEditor('Channel::FacebookPage');
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
});
it('returns no formatting for TwitterProfile channel', () => {
const result = getFormattingForEditor('Channel::TwitterProfile');
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
});
it('returns no formatting for SMS channel', () => {
const result = getFormattingForEditor('Channel::Sms');
expect(result).toEqual(FORMATTING['Channel::Sms']);
});
it('returns limited formatting for Telegram channel', () => {
const result = getFormattingForEditor('Channel::Telegram');
expect(result).toEqual(FORMATTING['Channel::Telegram']);
});
it('returns formatting for Instagram channel', () => {
const result = getFormattingForEditor('Channel::Instagram');
expect(result).toEqual(FORMATTING['Channel::Instagram']);
});
});
describe('context-specific formatting', () => {
it('returns default formatting for Context::Default', () => {
const result = getFormattingForEditor('Context::Default');
@@ -186,6 +186,7 @@
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update",
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
},
@@ -205,7 +206,7 @@
"DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"": "",
"COPILOT_THINKING": "Copilot is thinking",
"EMAIL_HEAD": {
"TO": "TO",
"ADD_BCC": "Add bcc",
@@ -8,6 +8,8 @@
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
"ACCEPT": "Accept",
"DISCARD": "Discard",
"PREFERRED": "Preferred"
}
}
@@ -145,7 +145,29 @@
"EXPAND": "Expand",
"MAKE_FRIENDLY": "Change message tone to friendly",
"MAKE_FORMAL": "Use formal tone",
"SIMPLIFY": "Simplify"
"SIMPLIFY": "Simplify",
"CONFIDENT": "Use confident tone",
"PROFESSIONAL": "Use professional tone",
"CASUAL": "Use casual tone",
"STRAIGHTFORWARD": "Use straightforward tone"
},
"REPLY_OPTIONS": {
"IMPROVE_REPLY": "Improve reply",
"IMPROVE_REPLY_SELECTION": "Improve the selection",
"CHANGE_TONE": {
"TITLE": "Change tone",
"OPTIONS": {
"PROFESSIONAL": "Professional",
"CASUAL": "Casual",
"STRAIGHTFORWARD": "Straightforward",
"CONFIDENT": "Confident",
"FRIENDLY": "Friendly"
}
},
"GRAMMAR": "Fix grammar & spelling",
"SUGGESTION": "Suggest a reply",
"SUMMARIZE": "Summarize the conversation",
"ASK_COPILOT": "Ask Copilot"
},
"ASSISTANCE_MODAL": {
"DRAFT_TITLE": "Draft content",
@@ -402,22 +402,48 @@
},
"CSAT_REPORTS": {
"HEADER": "CSAT Reports",
"NO_RECORDS": "There are no CSAT survey responses available.",
"NO_RECORDS": "No responses yet",
"NO_RECORDS_DESCRIPTION": "CSAT survey responses will appear here once customers start providing feedback.",
"DOWNLOAD": "Download CSAT Reports",
"DOWNLOAD_FAILED": "Failed to download CSAT Reports",
"FILTERS": {
"ADD_FILTER": "Add filter",
"CLEAR_ALL": "Clear all",
"NO_FILTER": "No filters available",
"INPUT_PLACEHOLDER": {
"AGENTS": "Search agents",
"INBOXES": "Search inboxes",
"TEAMS": "Search teams",
"RATINGS": "Search ratings"
},
"AGENTS": {
"PLACEHOLDER": "Choose Agents"
"LABEL": "Agent"
},
"INBOXES": {
"LABEL": "Inbox"
},
"TEAMS": {
"LABEL": "Team"
},
"RATINGS": {
"LABEL": "Rating"
}
},
"TABLE": {
"HEADER": {
"CONTACT_NAME": "Contact",
"AGENT_NAME": "Assigned agent",
"AGENT_NAME": "Agent",
"RATING": "Rating",
"FEEDBACK_TEXT": "Feedback comment"
}
"FEEDBACK_TEXT": "Feedback comment",
"CONVERSATION": "Conversation",
"CUSTOMER": "Customer",
"RESPONSE": "Response",
"HANDLED_BY": "Handled by"
},
"UNKNOWN_CUSTOMER": "Unknown customer"
},
"NO_AGENT": "No assigned agent",
"NO_FEEDBACK": "No feedback provided",
"METRIC": {
"TOTAL_RESPONSES": {
"LABEL": "Total responses",
@@ -430,6 +456,25 @@
"RESPONSE_RATE": {
"LABEL": "Response rate",
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
},
"RATING_DISTRIBUTION": "Rating distribution"
},
"REVIEW_NOTES": {
"TITLE": "Review notes",
"PLACEHOLDER": "Add review notes about this rating...",
"SAVE": "Save",
"CANCEL": "Cancel",
"SAVING": "Saving...",
"SAVED": "Notes saved successfully",
"SAVE_ERROR": "Failed to save notes",
"UPDATED_BY": "Updated by {name} {time}",
"UPDATED_BY_LABEL": "Updated by",
"PAYWALL": {
"TITLE": "Upgrade to add review notes",
"AVAILABLE_ON": "The review notes feature is only available in the Business and Enterprise plans.",
"UPGRADE_PROMPT": "Add internal context to every CSAT response with review notes. Capture what really happened, spot patterns faster, and make better decisions from your feedback.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
}
}
},
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { computed, watch, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -49,10 +49,14 @@ const handleCreateClose = () => {
selectedInbox.value = null;
};
onMounted(() =>
store.dispatch('captainInboxes/get', {
assistantId: assistantId.value,
})
watch(
assistantId,
newId => {
store.dispatch('captainInboxes/get', {
assistantId: newId,
});
},
{ immediate: true }
);
</script>
@@ -3,7 +3,7 @@ import { mapGetters } from 'vuex';
import { useAlert, useTrack } from 'dashboard/composables';
import CsatMetrics from './components/CsatMetrics.vue';
import CsatTable from './components/CsatTable.vue';
import ReportFilterSelector from './components/FilterSelector.vue';
import CsatFilters from './components/Csat/CsatFilters.vue';
import { generateFileName } from '../../../../helper/downloadHelper';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { FEATURE_FLAGS } from '../../../../featureFlags';
@@ -15,7 +15,7 @@ export default {
components: {
CsatMetrics,
CsatTable,
ReportFilterSelector,
CsatFilters,
ReportHeader,
V4Button,
},
@@ -90,7 +90,7 @@ export default {
selectedTeam,
selectedRating,
}) {
// do not track filter change on inital load
// do not track filter change on initial load
if (this.from !== 0 && this.to !== 0) {
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
filterType: 'date',
@@ -121,16 +121,11 @@ export default {
/>
</ReportHeader>
<div class="flex flex-col gap-4">
<ReportFilterSelector
show-agents-filter
show-inbox-filter
show-rating-filter
<div class="flex flex-col gap-6">
<CsatFilters
:show-team-filter="isTeamsEnabled"
:show-business-hours-switch="false"
@filter-change="onFilterChange"
/>
<CsatMetrics :filters="requestPayload" />
<CsatTable :page-index="pageIndex" @page-change="onPageNumberChange" />
</div>
@@ -1,26 +0,0 @@
<script setup>
import { computed } from 'vue';
const { row } = defineProps({
row: {
type: Object,
required: true,
},
});
const routerParams = computed(() => ({
name: 'inbox_conversation',
params: { conversation_id: row.original.conversationId },
}));
</script>
<template>
<div class="text-right">
<router-link :to="routerParams" class="hover:underline">
{{ `#${row.original.conversationId}` }}
</router-link>
<div v-tooltip="row.original.createdAt" class="text-n-slate-11 text-sm">
{{ row.original.createdAgo }}
</div>
</div>
</template>
@@ -0,0 +1,36 @@
import { CSAT_RATINGS } from 'shared/constants/messages';
export const buildFilterList = (items, type) =>
items.map(item => ({
id: item.id,
name: type === 'ratings' ? item.emoji : item.name,
type,
}));
export const buildRatingsList = t =>
CSAT_RATINGS.map(rating => ({
id: rating.value,
name: `${t(rating.translationKey)}`,
type: 'ratings',
}));
export const getActiveFilter = (filters, type, key) =>
filters.find(item => item.id.toString() === key.toString());
export const getFilterType = (input, direction) => {
const filterMap = {
keyToType: {
user_ids: 'agents',
inbox_id: 'inboxes',
team_id: 'teams',
rating: 'ratings',
},
typeToKey: {
agents: 'user_ids',
inboxes: 'inbox_id',
teams: 'team_id',
ratings: 'rating',
},
};
return filterMap[direction][input];
};
@@ -0,0 +1,267 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import {
buildFilterList,
buildRatingsList,
getActiveFilter,
getFilterType,
} from './CsatFilterHelpers';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
const props = defineProps({
showTeamFilter: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['filterChange']);
const { t } = useI18n();
const store = useStore();
const showDropdownMenu = ref(false);
const showSubDropdownMenu = ref(false);
const activeFilterType = ref('');
const customDateRange = ref([new Date(), new Date()]);
const appliedFilters = ref({
user_ids: null,
inbox_id: null,
team_id: null,
rating: null,
});
const agents = computed(() => store.getters['agents/getAgents']);
const inboxes = computed(() => store.getters['inboxes/getInboxes']);
const teams = computed(() => store.getters['teams/getTeams']);
const ratings = computed(() => buildRatingsList(t));
const from = computed(() => getUnixStartOfDay(customDateRange.value[0]));
const to = computed(() => getUnixEndOfDay(customDateRange.value[1]));
const getFilterSource = type => {
const sources = {
agents: agents.value,
inboxes: inboxes.value,
teams: teams.value,
ratings: ratings.value,
};
return sources[type] || [];
};
const getFilterOptions = type => {
if (type === 'ratings') {
return ratings.value;
}
return buildFilterList(getFilterSource(type), type);
};
const filterListMenuItems = computed(() => {
const filterTypes = [
{
id: '1',
name: t('CSAT_REPORTS.FILTERS.AGENTS.LABEL'),
type: 'agents',
},
{
id: '2',
name: t('CSAT_REPORTS.FILTERS.INBOXES.LABEL'),
type: 'inboxes',
},
{
id: '3',
name: t('CSAT_REPORTS.FILTERS.RATINGS.LABEL'),
type: 'ratings',
},
];
if (props.showTeamFilter) {
filterTypes.splice(2, 0, {
id: '4',
name: t('CSAT_REPORTS.FILTERS.TEAMS.LABEL'),
type: 'teams',
});
}
const activeFilterKeys = Object.keys(appliedFilters.value).filter(
key => appliedFilters.value[key]
);
const activeFilterTypes = activeFilterKeys.map(key =>
getFilterType(key, 'keyToType')
);
return filterTypes
.filter(({ type }) => !activeFilterTypes.includes(type))
.map(({ id, name, type }) => ({
id,
name,
type,
options: getFilterOptions(type),
}));
});
const activeFilters = computed(() => {
const activeKeys = Object.keys(appliedFilters.value).filter(
key => appliedFilters.value[key]
);
return activeKeys.map(key => {
const filterType = getFilterType(key, 'keyToType');
const items = getFilterSource(filterType);
const item = getActiveFilter(items, filterType, appliedFilters.value[key]);
return {
id: item?.id,
name: item?.name || '',
type: filterType,
options: getFilterOptions(filterType),
};
});
});
const hasActiveFilters = computed(() =>
Object.values(appliedFilters.value).some(value => value !== null)
);
const isAllFilterSelected = computed(() => !filterListMenuItems.value.length);
const emitChange = () => {
emit('filterChange', {
from: from.value,
to: to.value,
selectedAgents: appliedFilters.value.user_ids
? [{ id: appliedFilters.value.user_ids }]
: [],
selectedInbox: appliedFilters.value.inbox_id
? { id: appliedFilters.value.inbox_id }
: null,
selectedTeam: appliedFilters.value.team_id
? { id: appliedFilters.value.team_id }
: null,
selectedRating: appliedFilters.value.rating
? { value: appliedFilters.value.rating }
: null,
});
};
const closeDropdown = () => {
showDropdownMenu.value = false;
};
const closeActiveFilterDropdown = () => {
activeFilterType.value = '';
showSubDropdownMenu.value = false;
};
const resetDropdown = () => {
closeDropdown();
closeActiveFilterDropdown();
};
const addFilter = item => {
const { type, id } = item;
const filterKey = getFilterType(type, 'typeToKey');
appliedFilters.value[filterKey] = id;
emitChange();
resetDropdown();
};
const removeFilter = type => {
const filterKey = getFilterType(type, 'typeToKey');
appliedFilters.value[filterKey] = null;
emitChange();
};
const clearAllFilters = () => {
appliedFilters.value = {
user_ids: null,
inbox_id: null,
team_id: null,
rating: null,
};
emitChange();
resetDropdown();
};
const showDropdown = () => {
showSubDropdownMenu.value = false;
showDropdownMenu.value = !showDropdownMenu.value;
};
const openActiveFilterDropdown = filterType => {
closeDropdown();
activeFilterType.value = filterType;
showSubDropdownMenu.value = !showSubDropdownMenu.value;
};
const onDateRangeChange = value => {
customDateRange.value = value;
emitChange();
};
onMounted(() => {
emitChange();
});
</script>
<template>
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
<WootDatePicker @date-range-changed="onDateRangeChange" />
<div
class="flex flex-col flex-wrap items-start gap-2 md:items-center md:flex-nowrap md:flex-row"
>
<div v-if="hasActiveFilters" class="flex flex-wrap gap-2 md:flex-nowrap">
<ActiveFilterChip
v-for="filter in activeFilters"
v-bind="filter"
:key="filter.type"
:placeholder="
$t(
`CSAT_REPORTS.FILTERS.INPUT_PLACEHOLDER.${filter.type.toUpperCase()}`
)
"
:active-filter-type="activeFilterType"
:show-menu="showSubDropdownMenu"
enable-search
@toggle-dropdown="openActiveFilterDropdown"
@close-dropdown="closeActiveFilterDropdown"
@add-filter="addFilter"
@remove-filter="removeFilter"
/>
</div>
<div
v-if="hasActiveFilters && !isAllFilterSelected"
class="w-full h-px border md:w-px md:h-5 border-n-weak"
/>
<div class="flex items-center gap-2">
<AddFilterChip
v-if="!isAllFilterSelected"
placeholder-i18n-key="CSAT_REPORTS.FILTERS.INPUT_PLACEHOLDER"
:name="$t('CSAT_REPORTS.FILTERS.ADD_FILTER')"
:menu-option="filterListMenuItems"
:show-menu="showDropdownMenu"
:empty-state-message="$t('CSAT_REPORTS.FILTERS.NO_FILTER')"
@toggle-dropdown="showDropdown"
@close-dropdown="closeDropdown"
@add-filter="addFilter"
/>
<div v-if="hasActiveFilters" class="w-px h-5 border border-n-weak" />
<FilterButton
v-if="hasActiveFilters"
:button-text="$t('CSAT_REPORTS.FILTERS.CLEAR_ALL')"
@click="clearAllFilters"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,52 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
defineProps({
contact: {
type: Object,
default: null,
},
conversationId: {
type: Number,
required: true,
},
createdAgo: {
type: String,
default: '',
},
createdAt: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex items-center gap-2">
<Avatar
:src="contact?.thumbnail || ''"
:name="contact?.name || ''"
:size="32"
rounded-full
/>
<div class="flex flex-col gap-0.5">
<span class="text-sm text-n-slate-12 font-medium capitalize">
{{ contact?.name || '—' }}
</span>
<div
class="flex items-center gap-1 text-xs text-n-slate-10 whitespace-nowrap"
>
<a
:href="`/app/accounts/${$route.params.accountId}/conversations/${conversationId}`"
class="flex items-center text-xs gap-0.5 hover:text-n-brand hover:underline"
target="_blank"
rel="noopener noreferrer nofollow"
>
<span>#{{ conversationId }}</span>
</a>
<span>·</span>
<span :title="createdAt">{{ createdAgo }}</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,32 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
icon: {
type: String,
default: 'i-lucide-message-square-off',
},
});
</script>
<template>
<div class="flex flex-col items-center justify-center py-16 px-6 text-center">
<div
class="size-16 rounded-full bg-n-alpha-2 flex items-center justify-center mb-4"
>
<i :class="icon" class="size-8 text-n-slate-10" />
</div>
<h3 class="text-base font-medium text-n-slate-12 mb-1">
{{ title }}
</h3>
<p v-if="description" class="text-sm text-n-slate-10 max-w-sm">
{{ description }}
</p>
</div>
</template>
@@ -0,0 +1,162 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CsatReviewNotesPaywall from './CsatReviewNotesPaywall.vue';
import { dynamicTime } from 'shared/helpers/timeHelper';
const props = defineProps({
response: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const store = useStore();
const { isCloudFeatureEnabled, isOnChatwootCloud } = useAccount();
const { formatMessage } = useMessageFormatter();
const isFeatureEnabled = computed(() =>
isCloudFeatureEnabled('csat_review_notes')
);
const showPaywall = computed(
() => !isFeatureEnabled.value && isOnChatwootCloud.value
);
const reviewNotes = ref(props.response.csat_review_notes || '');
const isEditing = ref(!props.response.csat_review_notes);
const isSaving = ref(false);
const hasExistingReviewNotes = computed(
() => !!props.response.csat_review_notes
);
const hasChanges = computed(
() => reviewNotes.value !== (props.response.csat_review_notes || '')
);
const startEditing = () => {
isEditing.value = true;
};
const cancelEditing = () => {
reviewNotes.value = props.response.csat_review_notes || '';
if (hasExistingReviewNotes.value) {
isEditing.value = false;
}
};
const saveReviewNotes = async () => {
isSaving.value = true;
try {
await store.dispatch('csat/update', {
id: props.response.id,
reviewNotes: reviewNotes.value,
});
useAlert(t('CSAT_REPORTS.REVIEW_NOTES.SAVED'));
isEditing.value = false;
} catch {
useAlert(t('CSAT_REPORTS.REVIEW_NOTES.SAVE_ERROR'));
} finally {
isSaving.value = false;
}
};
</script>
<template>
<div class="py-4 px-5 border-t border-n-container bg-n-background">
<CsatReviewNotesPaywall v-if="showPaywall" />
<div v-else-if="isFeatureEnabled" class="flex flex-col gap-3">
<div class="flex items-start gap-4">
<div
class="flex items-center gap-1.5 text-n-slate-11 shrink-0 w-36 pt-3"
>
<i class="i-lucide-notebook-pen size-4" />
<span class="text-sm font-medium">
{{ $t('CSAT_REPORTS.REVIEW_NOTES.TITLE') }}
</span>
</div>
<div class="flex-1 max-w-2xl">
<div
v-if="hasExistingReviewNotes && !isEditing"
class="group flex items-start gap-2 py-2 px-3 rounded-lg hover:bg-n-slate-2 dark:hover:bg-n-solid-3 cursor-pointer transition-colors"
@click.stop="startEditing"
>
<p
v-dompurify-html="formatMessage(response.csat_review_notes || '')"
class="flex-1 text-sm text-n-slate-12 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0"
/>
<i
class="i-lucide-pencil size-4 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5"
/>
</div>
<div
v-else
class="flex flex-col gap-3 [&_.ProseMirror]:min-h-32 [&_.ProseMirror]:max-h-64 [&_.ProseMirror-menubar]:!mt-0"
@click.stop
>
<Editor
v-model="reviewNotes"
:placeholder="$t('CSAT_REPORTS.REVIEW_NOTES.PLACEHOLDER')"
:show-character-count="false"
:enable-canned-responses="false"
focus-on-mount
>
<template #actions>
<div class="flex items-center gap-2 py-2">
<Button
v-if="hasExistingReviewNotes"
variant="ghost"
size="xs"
:label="$t('CSAT_REPORTS.REVIEW_NOTES.CANCEL')"
@click.stop="cancelEditing"
/>
<Button
:label="$t('CSAT_REPORTS.REVIEW_NOTES.SAVE')"
:disabled="!hasChanges || isSaving"
:loading="isSaving"
size="xs"
@click.stop="saveReviewNotes"
/>
</div>
</template>
</Editor>
</div>
</div>
</div>
<div
v-if="
hasExistingReviewNotes &&
!isEditing &&
response.review_notes_updated_by
"
class="flex items-center gap-4"
>
<div class="flex items-center gap-1.5 text-n-slate-11 shrink-0 w-36">
<i class="i-lucide-user-pen size-4" />
<span class="text-sm font-medium">
{{ $t('CSAT_REPORTS.REVIEW_NOTES.UPDATED_BY_LABEL') }}
</span>
</div>
<div class="flex items-center gap-1 flex-1 max-w-2xl px-3">
<span class="text-sm text-n-slate-12">
{{ response.review_notes_updated_by.name }}
</span>
<span class="text-n-slate-10">·</span>
<span class="text-sm text-n-slate-10">
{{ dynamicTime(response.review_notes_updated_at) }}
</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,53 @@
<script setup>
import CsatMetricCard from './CsatMetricCard.vue';
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/Reports/CsatMetricCard"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Default">
<div class="p-4 bg-n-background">
<CsatMetricCard
label="Total Responses"
tooltip="Total number of responses received"
value="1,234"
/>
</div>
</Variant>
<Variant title="Percentage Value">
<div class="p-4 bg-n-background">
<CsatMetricCard
label="Satisfaction Score"
tooltip="Percentage of positive responses"
value="85%"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-4 bg-n-background">
<CsatMetricCard
label="Response Rate"
tooltip="Percentage of conversations with responses"
value="0"
is-loading
/>
</div>
</Variant>
<Variant title="Zero Value">
<div class="p-4 bg-n-background">
<CsatMetricCard
label="Total Responses"
tooltip="Total number of responses received"
value="0"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,41 @@
<script setup>
defineProps({
label: {
type: String,
required: true,
},
tooltip: {
type: String,
required: true,
},
value: {
type: [String, Number],
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
</script>
<template>
<div class="flex flex-col gap-2 items-start justify-center min-w-[10rem]">
<span
class="inline-flex items-center gap-1 text-sm font-medium text-n-slate-11"
>
{{ label }}
<span
v-tooltip.right="tooltip"
class="i-lucide-info flex flex-shrink-0 text-n-slate-10 size-3.5"
/>
</span>
<div
v-if="isLoading"
class="w-16 h-8 rounded-md bg-n-slate-3 animate-pulse"
/>
<span v-else class="text-2xl font-medium text-n-slate-12">
{{ value }}
</span>
</div>
</template>
@@ -1,135 +1,63 @@
<script>
import { mapGetters } from 'vuex';
import CsatMetricCard from './ReportMetricCard.vue';
import { CSAT_RATINGS } from 'shared/constants/messages';
import BarChart from 'shared/components/charts/BarChart.vue';
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import CsatMetricCard from './CsatMetricCard.vue';
import CsatRatingDistribution from './CsatRatingDistribution.vue';
export default {
components: { BarChart, CsatMetricCard },
props: {
filters: {
type: Object,
required: true,
},
},
data() {
return {
csatRatings: CSAT_RATINGS,
};
},
computed: {
...mapGetters({
metrics: 'csat/getMetrics',
ratingPercentage: 'csat/getRatingPercentage',
satisfactionScore: 'csat/getSatisfactionScore',
responseRate: 'csat/getResponseRate',
}),
ratingFilterEnabled() {
return Boolean(this.filters.rating);
},
chartData() {
const sortedRatings = [...CSAT_RATINGS].sort((a, b) => b.value - a.value);
return {
labels: ['Rating'],
datasets: sortedRatings.map(rating => ({
label: rating.emoji,
data: [this.ratingPercentage[rating.value]],
backgroundColor: rating.color,
})),
};
},
responseCount() {
return this.metrics.totalResponseCount
? this.metrics.totalResponseCount.toLocaleString()
: '--';
},
chartOptions() {
return {
indexAxis: 'y',
responsive: true,
plugins: {
legend: {
display: false,
},
title: {
display: false,
},
tooltip: {
enabled: false,
},
},
scales: {
x: {
display: false,
stacked: true,
},
y: {
display: false,
stacked: true,
},
},
};
},
},
methods: {
formatToPercent(value) {
return value ? `${value}%` : '--';
},
ratingToEmoji(value) {
return CSAT_RATINGS.find(rating => rating.value === Number(value)).emoji;
},
},
};
const metrics = useMapGetter('csat/getMetrics');
const ratingPercentage = useMapGetter('csat/getRatingPercentage');
const ratingCount = useMapGetter('csat/getRatingCount');
const satisfactionScore = useMapGetter('csat/getSatisfactionScore');
const responseRate = useMapGetter('csat/getResponseRate');
const uiFlags = useMapGetter('csat/getUIFlags');
const isLoading = computed(() => uiFlags.value.isFetchingMetrics);
const responseCount = computed(() =>
metrics.value.totalResponseCount
? metrics.value.totalResponseCount.toLocaleString()
: '0'
);
const formatPercent = value => (value ? `${value}%` : '0%');
</script>
<!-- eslint-disable vue/no-unused-refs -->
<!-- Added ref for writing specs -->
<template>
<div
class="flex-col lg:flex-row flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-8 gap-4"
>
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
:value="responseCount"
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
/>
<CsatMetricCard
:disabled="ratingFilterEnabled"
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
:value="ratingFilterEnabled ? '--' : formatToPercent(satisfactionScore)"
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
/>
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
:value="formatToPercent(responseRate)"
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
/>
<div class="flex flex-col gap-4">
<div
v-if="metrics.totalResponseCount && !ratingFilterEnabled"
ref="csatBarChart"
class="w-full md:w-1/2 md:max-w-[50%] flex-1 rtl:[direction:initial]"
class="flex sm:flex-row flex-col w-full gap-4 sm:gap-14 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
>
<h3
class="flex items-center m-0 text-xs font-medium md:text-sm text-n-slate-12"
>
<div class="flex flex-row-reverse justify-end">
<div
v-for="(rating, key, index) in ratingPercentage"
:key="rating + key + index"
class="ltr:pr-4 rtl:pl-4"
>
<span class="my-0 mx-0.5">{{ ratingToEmoji(key) }}</span>
<span>{{ formatToPercent(rating) }}</span>
</div>
</div>
</h3>
<div class="mt-2 h-6">
<BarChart :collection="chartData" :chart-options="chartOptions" />
</div>
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
:tooltip="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
:value="responseCount"
:is-loading="isLoading"
/>
<div class="w-full sm:w-px bg-n-strong" />
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
:tooltip="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
:value="formatPercent(satisfactionScore)"
:is-loading="isLoading"
/>
<div class="w-full sm:w-px bg-n-strong" />
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
:tooltip="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
:value="formatPercent(responseRate)"
:is-loading="isLoading"
/>
</div>
<CsatRatingDistribution
:rating-percentage="ratingPercentage"
:rating-count="ratingCount"
:total-response-count="metrics.totalResponseCount"
:is-loading="isLoading"
/>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup>
import CsatRatingDistribution from './CsatRatingDistribution.vue';
const sampleRatingPercentage = {
1: 5,
2: 10,
3: 15,
4: 25,
5: 45,
};
const sampleRatingCount = {
1: 50,
2: 100,
3: 150,
4: 250,
5: 450,
};
const emptyRatingPercentage = {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
};
const emptyRatingCount = {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
};
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/Reports/CsatRatingDistribution"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="With Data">
<div class="p-4 bg-n-background">
<CsatRatingDistribution
:rating-percentage="sampleRatingPercentage"
:rating-count="sampleRatingCount"
:total-response-count="1000"
/>
</div>
</Variant>
<Variant title="Empty State">
<div class="p-4 bg-n-background">
<CsatRatingDistribution
:rating-percentage="emptyRatingPercentage"
:rating-count="emptyRatingCount"
:total-response-count="0"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-4 bg-n-background">
<CsatRatingDistribution
:rating-percentage="emptyRatingPercentage"
:rating-count="emptyRatingCount"
:total-response-count="0"
is-loading
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,101 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { CSAT_RATINGS } from 'shared/constants/messages';
const props = defineProps({
ratingPercentage: {
type: Object,
default: () => ({}),
},
ratingCount: {
type: Object,
default: () => ({}),
},
totalResponseCount: {
type: Number,
default: 0,
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const sortedRatings = computed(() =>
[...CSAT_RATINGS].sort((a, b) => b.value - a.value)
);
const formatPercent = value => (value ? `${value}%` : '0%');
const getRatingLabel = value => {
const rating = CSAT_RATINGS.find(r => r.value === value);
return rating ? t(rating.translationKey) : '';
};
const getRatingCount = value => {
return props.ratingCount[value] || 0;
};
</script>
<template>
<div
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
>
<span class="text-sm font-medium text-n-slate-11">
{{ $t('CSAT_REPORTS.METRIC.RATING_DISTRIBUTION') }}
</span>
<div v-if="isLoading" class="mt-4">
<div class="h-6 w-full rounded-full bg-n-slate-3 animate-pulse" />
<div class="flex gap-6 mt-4">
<div
v-for="n in 5"
:key="n"
class="h-4 w-20 rounded bg-n-slate-3 animate-pulse"
/>
</div>
</div>
<div v-else class="mt-4">
<div
v-if="totalResponseCount"
class="flex h-6 w-full rounded-full overflow-hidden bg-n-alpha-2"
>
<div
v-for="rating in sortedRatings"
:key="rating.value"
v-tooltip="
`${getRatingLabel(rating.value)}: ${formatPercent(ratingPercentage[rating.value])} (${getRatingCount(rating.value)})`
"
:style="{
width: `${ratingPercentage[rating.value]}%`,
backgroundColor: rating.color,
}"
class="h-full transition-all duration-300 first:rounded-s-full last:rounded-e-full cursor-default"
/>
</div>
<div v-else class="h-6 w-full rounded-full bg-n-alpha-2" />
<div class="flex flex-wrap gap-x-6 gap-y-2 mt-4">
<div
v-for="rating in sortedRatings"
:key="rating.value"
class="flex items-center gap-2"
>
<span class="text-sm text-n-slate-11">
{{ getRatingLabel(rating.value) }}
</span>
<span class="text-sm font-medium text-n-slate-12">
{{ formatPercent(ratingPercentage[rating.value]) }}
</span>
<span class="text-xs text-n-slate-10">
({{ getRatingCount(rating.value) }})
</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup>
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const router = useRouter();
const currentAccountId = useMapGetter('getCurrentAccountId');
const goToBillingSettings = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: currentAccountId.value },
});
};
</script>
<template>
<div class="py-4 px-5 bg-n-background">
<div class="flex justify-center">
<BasePaywallModal
feature-prefix="CSAT_REPORTS.REVIEW_NOTES"
i18n-key="PAYWALL"
is-on-chatwoot-cloud
@upgrade="goToBillingSettings"
/>
</div>
</div>
</template>
@@ -1,19 +1,19 @@
<script setup>
import { defineEmits, computed, h } from 'vue';
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useI18n } from 'vue-i18n';
// [TODO] Instead of converting the values to their reprentation when building the tableData
// We should do the change in the cell
import { messageStamp, dynamicTime } from 'shared/helpers/timeHelper';
// components
import Table from 'dashboard/components/table/Table.vue';
import Pagination from 'dashboard/components/table/Pagination.vue';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import ConversationCell from './ConversationCell.vue';
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
import CsatContactCell from './CsatContactCell.vue';
import CsatExpandedRow from './CsatExpandedRow.vue';
import CsatEmptyState from './CsatEmptyState.vue';
import CsatTableLoader from './CsatTableLoader.vue';
// constants
import { CSAT_RATINGS } from 'shared/constants/messages';
import {
@@ -31,97 +31,82 @@ const { pageIndex } = defineProps({
const emit = defineEmits(['pageChange']);
const { t } = useI18n();
// const isRTL = useMapGetter('accounts/isRTL');
const { isCloudFeatureEnabled, isOnChatwootCloud } = useAccount();
const csatResponses = useMapGetter('csat/getCSATResponses');
const isFeatureEnabled = computed(() =>
isCloudFeatureEnabled('csat_review_notes')
);
const showExpandableRows = computed(
() => isFeatureEnabled.value || isOnChatwootCloud.value
);
const metrics = useMapGetter('csat/getMetrics');
const uiFlags = useMapGetter('csat/getUIFlags');
const isLoading = computed(() => uiFlags.value.isFetching);
const expandedRows = ref({});
const toggleRow = id => {
expandedRows.value = {
...expandedRows.value,
[id]: !expandedRows.value[id],
};
};
const isRowExpanded = id => !!expandedRows.value[id];
const tableData = computed(() => {
return csatResponses.value.map(response => ({
id: response.id,
contact: response.contact,
assignedAgent: response.assigned_agent,
rating: response.rating,
feedbackText: response.feedback_message || '---',
feedbackText: response.feedback_message || '',
conversationId: response.conversation_id,
csatReviewNotes: response.csat_review_notes,
createdAgo: dynamicTime(response.created_at),
createdAt: messageStamp(response.created_at, 'LLL d yyyy, h:mm a'),
_original: response,
}));
});
const defaultSpanRender = cellProps => {
const value = cellProps.getValue() || '---';
return h(
'span',
{
class: 'line-clamp-5 break-words max-w-full text-n-slate-12',
title: value,
},
value
);
const getRatingData = rating => {
return CSAT_RATINGS.find(r => r.value === rating) || {};
};
const columnHelper = createColumnHelper();
const columns = computed(() => [
columnHelper.accessor('contact', {
header: t('CSAT_REPORTS.TABLE.HEADER.CONTACT_NAME'),
width: 200,
cell: cellProps => {
const { contact } = cellProps.row.original;
if (contact) {
return h(UserAvatarWithName, {
user: contact,
class: 'max-w-[200px] overflow-hidden',
});
}
return '--';
},
}),
columnHelper.accessor('assignedAgent', {
header: t('CSAT_REPORTS.TABLE.HEADER.AGENT_NAME'),
width: 200,
cell: cellProps => {
const { assignedAgent } = cellProps.row.original;
if (assignedAgent) {
return h(UserAvatarWithName, {
user: assignedAgent,
class: 'max-w-[200px] overflow-hidden',
});
}
return '--';
},
}),
columnHelper.accessor('rating', {
header: t('CSAT_REPORTS.TABLE.HEADER.RATING'),
align: 'center',
width: 80,
cell: cellProps => {
const { rating: giveRating } = cellProps.row.original;
const [ratingObject = {}] = CSAT_RATINGS.filter(
rating => rating.value === giveRating
);
const columns = computed(() => {
const baseColumns = [
columnHelper.accessor('contact', {
header: t('CSAT_REPORTS.TABLE.HEADER.CONTACT_NAME'),
}),
columnHelper.accessor('rating', {
header: t('CSAT_REPORTS.TABLE.HEADER.RATING'),
size: 120,
}),
columnHelper.accessor('feedbackText', {
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
size: 500,
}),
columnHelper.accessor('assignedAgent', {
header: t('CSAT_REPORTS.TABLE.HEADER.HANDLED_BY'),
size: 160,
}),
];
return h(
'span',
{
class: ratingObject.emoji
? 'emoji-response text-lg'
: 'text-n-slate-10',
},
ratingObject.emoji || '---'
);
},
}),
columnHelper.accessor('feedbackText', {
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
width: 400,
cell: defaultSpanRender,
}),
columnHelper.accessor('conversationId', {
header: '',
width: 100,
cell: cellProps => h(ConversationCell, cellProps),
}),
]);
if (showExpandableRows.value) {
baseColumns.push(
columnHelper.accessor('actions', {
header: '',
size: 50,
})
);
}
return baseColumns;
});
const paginationParams = computed(() => {
return {
@@ -149,25 +134,124 @@ const table = useVueTable({
},
},
onPaginationChange: updater => {
const newPagintaion = updater(paginationParams.value);
emit('pageChange', newPagintaion.pageIndex);
const newPagination = updater(paginationParams.value);
emit('pageChange', newPagination.pageIndex);
},
});
</script>
<template>
<div
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 overflow-hidden"
>
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
<div
v-show="!tableData.length"
class="h-48 flex items-center justify-center text-n-slate-12 text-sm"
>
{{ $t('CSAT_REPORTS.NO_RECORDS') }}
<CsatTableLoader v-if="isLoading" />
<div v-else-if="tableData.length" class="overflow-x-auto">
<table class="w-full">
<thead class="bg-n-solid-2 border-b border-n-container">
<tr>
<th
v-for="header in table.getFlatHeaders()"
:key="header.id"
:style="{
width: header.getSize() ? `${header.getSize()}px` : 'auto',
}"
class="text-left py-3 px-5 font-medium text-sm text-n-slate-12"
>
{{ header.column.columnDef.header }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-container">
<template v-for="row in tableData" :key="row.id">
<tr
class="group hover:bg-n-slate-2 dark:hover:bg-n-solid-3 transition-colors"
:class="{
'bg-n-slate-2 dark:bg-n-solid-3': isRowExpanded(row.id),
'cursor-pointer': showExpandableRows,
}"
@click="showExpandableRows && toggleRow(row.id)"
>
<td class="py-4 px-5">
<CsatContactCell
:contact="row.contact"
:conversation-id="row.conversationId"
:created-ago="row.createdAgo"
:created-at="row.createdAt"
/>
</td>
<td class="py-4 px-5">
<div
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-lg"
:style="{
backgroundColor: `${getRatingData(row.rating).color}20`,
}"
>
<span class="text-sm font-medium text-n-slate-12 truncate">
{{ $t(getRatingData(row.rating).translationKey) }}
</span>
</div>
</td>
<td class="py-4 px-5">
<span
v-if="!row.feedbackText"
class="text-n-slate-10 italic text-sm"
>
{{ $t('CSAT_REPORTS.NO_FEEDBACK') }}
</span>
<div v-else class="text-sm text-n-slate-12">
<ShowMore :text="row.feedbackText" :limit="100" />
</div>
</td>
<td class="py-4 px-5">
<UserAvatarWithName
v-if="row.assignedAgent"
:user="row.assignedAgent"
:size="28"
/>
<span v-else class="text-n-slate-10 text-sm italic">
{{ $t('CSAT_REPORTS.NO_AGENT') }}
</span>
</td>
<td v-if="showExpandableRows" class="py-4 px-5">
<div
class="p-1.5 rounded-md text-n-slate-10 group-hover:text-n-slate-12 transition-colors"
>
<i
class="size-4 block transition-transform duration-200"
:class="
isRowExpanded(row.id)
? 'i-lucide-chevron-up'
: 'i-lucide-chevron-down'
"
/>
</div>
</td>
</tr>
<tr
v-if="showExpandableRows && isRowExpanded(row.id)"
class="!border-t-0"
>
<td colspan="5" class="p-0 !border-t-0">
<CsatExpandedRow :response="row._original" />
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div v-if="metrics.totalResponseCount" class="table-pagination">
<Pagination class="mt-2" :table="table" />
<CsatEmptyState
v-else
:title="$t('CSAT_REPORTS.NO_RECORDS')"
:description="$t('CSAT_REPORTS.NO_RECORDS_DESCRIPTION')"
/>
<div
v-if="metrics.totalResponseCount"
class="px-6 py-4 border-t border-n-weak"
>
<Pagination :table="table" />
</div>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup>
defineProps({
rows: {
type: Number,
default: 5,
},
});
</script>
<template>
<div class="p-6">
<div class="space-y-4">
<div class="flex gap-4 pb-3 border-b border-n-weak">
<div class="h-4 w-32 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 w-28 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 w-20 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 flex-1 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 w-20 rounded bg-n-slate-3 animate-pulse" />
</div>
<div v-for="n in rows" :key="n" class="flex items-center gap-4 py-3">
<div class="flex items-center gap-2 w-48">
<div class="size-8 rounded-full bg-n-slate-3 animate-pulse" />
<div class="h-4 w-24 rounded bg-n-slate-3 animate-pulse" />
</div>
<div class="flex items-center gap-2 w-44">
<div class="size-8 rounded-full bg-n-slate-3 animate-pulse" />
<div class="h-4 w-20 rounded bg-n-slate-3 animate-pulse" />
</div>
<div class="h-7 w-24 rounded-lg bg-n-slate-3 animate-pulse" />
<div class="h-4 flex-1 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 w-16 rounded bg-n-slate-3 animate-pulse" />
</div>
</div>
</div>
</template>
@@ -6,7 +6,6 @@ describe('CsatMetrics.vue', () => {
let getters;
let store;
let wrapper;
const filters = { rating: 3 };
beforeEach(() => {
getters = {
@@ -18,8 +17,16 @@ describe('CsatMetrics.vue', () => {
4: 30,
5: 10,
}),
'csat/getRatingCount': () => ({
1: 10,
2: 20,
3: 30,
4: 30,
5: 10,
}),
'csat/getSatisfactionScore': () => 85,
'csat/getResponseRate': () => 90,
'csat/getUIFlags': () => ({ isFetchingMetrics: false }),
};
store = createStore({
@@ -28,40 +35,31 @@ describe('CsatMetrics.vue', () => {
wrapper = shallowMount(CsatMetrics, {
global: {
plugins: [store], // Ensure the store is injected here
plugins: [store],
mocks: {
$t: msg => msg, // mock translation function
$t: msg => msg,
},
stubs: {
CsatMetricCard: '<csat-metric-card/>',
BarChart: '<woot-horizontal-bar/>',
CsatMetricCard: true,
CsatRatingDistribution: true,
},
},
props: { filters },
});
});
it('computes response count correctly', () => {
expect(wrapper.vm.responseCount).toBe('100');
expect(wrapper.html()).toMatchSnapshot();
});
it('formats values to percent correctly', () => {
expect(wrapper.vm.formatToPercent(85)).toBe('85%');
expect(wrapper.vm.formatToPercent(null)).toBe('--');
it('renders metric cards with correct values', () => {
const metricCards = wrapper.findAllComponents({ name: 'CsatMetricCard' });
expect(metricCards).toHaveLength(3);
});
it('maps rating value to emoji correctly', () => {
const rating = wrapper.vm.csatRatings[0]; // assuming this is { value: 1, emoji: '😡' }
expect(wrapper.vm.ratingToEmoji(rating.value)).toBe(rating.emoji);
});
it('hides report card if rating filter is enabled', () => {
expect(wrapper.html()).not.toContain('bar-chart-stub');
});
it('shows report card if rating filter is not enabled', async () => {
await wrapper.setProps({ filters: {} });
expect(wrapper.html()).toContain('bar-chart-stub');
it('renders rating distribution component', () => {
const distribution = wrapper.findComponent({
name: 'CsatRatingDistribution',
});
expect(distribution.exists()).toBe(true);
});
});
@@ -1,10 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CsatMetrics.vue > computes response count correctly 1`] = `
"<div class="flex-col lg:flex-row flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-8 gap-4">
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP" disabled="false" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="100"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL" infotext="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP" disabled="true" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="--"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL" infotext="CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP" disabled="false" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="90%"></csat-metric-card-stub>
<!--v-if-->
</div>"
`;
@@ -82,6 +82,9 @@ export const getters = {
),
};
},
getRatingCount(_state) {
return _state.metrics.ratingsCount;
},
};
export const actions = {
@@ -115,6 +118,13 @@ export const actions = {
});
});
},
update: async ({ commit }, { id, reviewNotes }) => {
const response = await CSATReports.update(id, {
csat_review_notes: reviewNotes,
});
commit(types.UPDATE_CSAT_RESPONSE, response.data);
return response.data;
},
};
export const mutations = {
@@ -144,6 +154,7 @@ export const mutations = {
};
_state.metrics.totalSentMessagesCount = totalSentMessagesCount || 0;
},
[types.UPDATE_CSAT_RESPONSE]: MutationHelpers.update,
};
export default {
@@ -86,4 +86,19 @@ describe('#getters', () => {
})
).toEqual('50.00');
});
it('getRatingCount', () => {
const state = {
metrics: {
ratingsCount: { 1: 10, 2: 20, 3: 15, 4: 3, 5: 2 },
},
};
expect(getters.getRatingCount(state)).toEqual({
1: 10,
2: 20,
3: 15,
4: 3,
5: 2,
});
});
});
@@ -241,6 +241,7 @@ export default {
SET_CSAT_RESPONSE_UI_FLAG: 'SET_CSAT_RESPONSE_UI_FLAG',
SET_CSAT_RESPONSE: 'SET_CSAT_RESPONSE',
SET_CSAT_RESPONSE_METRICS: 'SET_CSAT_RESPONSE_METRICS',
UPDATE_CSAT_RESPONSE: 'UPDATE_CSAT_RESPONSE',
// Custom Attributes
SET_CUSTOM_ATTRIBUTE_UI_FLAG: 'SET_CUSTOM_ATTRIBUTE_UI_FLAG',
+12 -3
View File
@@ -14,13 +14,22 @@ export const getHeadingsfromTheArticle = () => {
const rows = [];
const articleElement = document.getElementById('cw-article-content');
articleElement.querySelectorAll('h1, h2, h3').forEach(element => {
const slug = slugifyWithCounter(element.innerText);
const headingText = element.innerText;
const slug = slugifyWithCounter(headingText);
element.id = slug;
element.className = 'scroll-mt-24 heading';
element.innerHTML += `<a class="permalink text-slate-600 ml-3" href="#${slug}" title="${element.innerText}" data-turbolinks="false">#</a>`;
const permalink = document.createElement('a');
permalink.className = 'permalink text-slate-600 ml-3';
permalink.href = `#${slug}`;
permalink.title = headingText;
permalink.dataset.turbolinks = 'false';
permalink.textContent = '#';
element.appendChild(permalink);
rows.push({
slug,
title: element.innerText,
title: headingText,
tag: element.tagName.toLowerCase(),
});
});
-11
View File
@@ -1,11 +0,0 @@
export const OPEN_AI_OPTIONS = {
IMPROVE_WRITING: 'improve_writing',
FIX_SPELLING_GRAMMAR: 'fix_spelling_grammar',
SHORTEN: 'shorten',
EXPAND: 'expand',
MAKE_FRIENDLY: 'make_friendly',
MAKE_FORMAL: 'make_formal',
SIMPLIFY: 'simplify',
REPLY_SUGGESTION: 'reply_suggestion',
SUMMARIZE: 'summarize',
};
+7 -5
View File
@@ -16,10 +16,12 @@ class Conversations::ResolutionJob < ApplicationJob
private
def conversation_scope(account)
if account.auto_resolve_ignore_waiting
account.conversations.resolvable_not_waiting(account.auto_resolve_after)
else
account.conversations.resolvable_all(account.auto_resolve_after)
end
base_scope = if account.auto_resolve_ignore_waiting
account.conversations.resolvable_not_waiting(account.auto_resolve_after)
else
account.conversations.resolvable_all(account.auto_resolve_after)
end
# Exclude orphan conversations where contact was deleted but conversation cleanup is pending
base_scope.where.not(contact_id: nil)
end
end
+48 -43
View File
@@ -1,6 +1,18 @@
class ReportingEventListener < BaseListener
include ReportingEventHelper
def conversation_created(event)
conversation = extract_conversation_and_account(event)[0]
ReportingEvent.create!(
name: 'conversation_created',
value: 0,
event_start_time: conversation.created_at,
event_end_time: conversation.created_at,
**base_event_attributes(conversation, from_state: nil)
)
end
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
@@ -8,14 +20,10 @@ class ReportingEventListener < BaseListener
reporting_event = ReportingEvent.new(
name: 'conversation_resolved',
value: time_to_resolve,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
conversation.updated_at),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
event_start_time: conversation.created_at,
event_end_time: conversation.updated_at
event_end_time: conversation.updated_at,
**base_event_attributes(conversation, from_state: 'handling')
)
create_bot_resolved_event(conversation, reporting_event)
@@ -27,20 +35,14 @@ class ReportingEventListener < BaseListener
conversation = message.conversation
first_response_time = message.created_at.to_i - last_non_human_activity(conversation).to_i
reporting_event = ReportingEvent.new(
ReportingEvent.create!(
name: 'first_response',
value: first_response_time,
value_in_business_hours: business_hours(conversation.inbox, last_non_human_activity(conversation),
message.created_at),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: message.sender_id,
conversation_id: conversation.id,
value_in_business_hours: business_hours(conversation.inbox, last_non_human_activity(conversation), message.created_at),
event_start_time: last_non_human_activity(conversation),
event_end_time: message.created_at
event_end_time: message.created_at,
**base_event_attributes(conversation, from_state: 'waiting', user_id: message.sender_id)
)
reporting_event.save!
end
def reply_created(event)
@@ -50,21 +52,16 @@ class ReportingEventListener < BaseListener
return if waiting_since.blank?
# When waiting_since is nil, set reply_time to 0
reply_time = message.created_at.to_i - waiting_since.to_i
reporting_event = ReportingEvent.new(
ReportingEvent.create!(
name: 'reply_time',
value: reply_time,
value_in_business_hours: business_hours(conversation.inbox, waiting_since, message.created_at),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: waiting_since,
event_end_time: message.created_at
event_end_time: message.created_at,
**base_event_attributes(conversation, from_state: 'waiting')
)
reporting_event.save!
end
def conversation_bot_handoff(event)
@@ -76,18 +73,14 @@ class ReportingEventListener < BaseListener
time_to_handoff = conversation.updated_at.to_i - conversation.created_at.to_i
reporting_event = ReportingEvent.new(
ReportingEvent.create!(
name: 'conversation_bot_handoff',
value: time_to_handoff,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: conversation.created_at,
event_end_time: conversation.updated_at
event_end_time: conversation.updated_at,
**base_event_attributes(conversation, from_state: 'bot_handling')
)
reporting_event.save!
end
def conversation_opened(event)
@@ -99,36 +92,47 @@ class ReportingEventListener < BaseListener
name: 'conversation_resolved'
).order(event_end_time: :desc).first
# For first-time openings, value is 0
# For reopenings, calculate time since resolution
# For first-time openings, value is 0, from_state is nil
# For reopenings, calculate time since resolution, from_state is 'resolved'
if last_resolved_event
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
start_time = last_resolved_event.event_end_time
from_state = 'resolved'
else
time_since_resolved = 0
business_hours_value = 0
start_time = conversation.created_at
from_state = nil
end
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, from_state)
end
private
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
reporting_event = ReportingEvent.new(
def base_event_attributes(conversation, from_state:, user_id: nil)
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: user_id || conversation.assignee_id,
conversation_id: conversation.id,
conversation_created_at: conversation.created_at,
team_id: conversation.team_id,
channel_type: conversation.inbox.channel_type,
from_state: from_state
}
end
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, from_state)
ReportingEvent.create!(
name: 'conversation_opened',
value: time_since_resolved,
value_in_business_hours: business_hours_value,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: start_time,
event_end_time: conversation.updated_at
event_end_time: conversation.updated_at,
**base_event_attributes(conversation, from_state: from_state)
)
reporting_event.save!
end
def create_bot_resolved_event(conversation, reporting_event)
@@ -138,6 +142,7 @@ class ReportingEventListener < BaseListener
bot_resolved_event = reporting_event.dup
bot_resolved_event.name = 'conversation_bot_resolved'
bot_resolved_event.from_state = 'bot_handling'
bot_resolved_event.save!
end
end
+7
View File
@@ -34,6 +34,7 @@ class Channel::Whatsapp < ApplicationRecord
after_create :sync_templates
before_destroy :teardown_webhooks
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
def name
'Whatsapp'
@@ -86,4 +87,10 @@ class Channel::Whatsapp < ApplicationRecord
def teardown_webhooks
Whatsapp::WebhookTeardownService.new(self).perform
end
def should_auto_setup_webhooks?
# Only auto-setup webhooks for whatsapp_cloud provider with manual setup
# Embedded signup calls setup_webhooks explicitly in EmbeddedSignupService
provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup'
end
end
@@ -2,17 +2,21 @@ module ConversationMuteHelpers
extend ActiveSupport::Concern
def mute!
return unless contact
resolved!
contact.update(blocked: true)
create_muted_message
end
def unmute!
return unless contact
contact.update(blocked: false)
create_unmuted_message
end
def muted?
contact.blocked?
contact&.blocked? || false
end
end
+1
View File
@@ -27,6 +27,7 @@ class CsatSurveyResponse < ApplicationRecord
belongs_to :contact
belongs_to :message
belongs_to :assigned_agent, class_name: 'User', optional: true, inverse_of: :csat_survey_responses
belongs_to :review_notes_updated_by, class_name: 'User', optional: true
validates :rating, presence: true, inclusion: { in: [1, 2, 3, 4, 5] }
validates :account_id, presence: true
+4 -7
View File
@@ -64,13 +64,10 @@ class Integrations::Hook < ApplicationRecord
update(status: 'disabled')
end
def process_event(event)
case app_id
when 'openai'
Integrations::Openai::ProcessorService.new(hook: self, event: event).perform if app_id == 'openai'
else
{ error: 'No processor found' }
end
def process_event(_event)
# OpenAI integration migrated to Captain::EditorService
# Other integrations (slack, dialogflow, etc.) handled via HookJob
{ error: 'No processor found' }
end
def feature_allowed?
+24 -7
View File
@@ -3,8 +3,11 @@
# Table name: reporting_events
#
# id :bigint not null, primary key
# channel_type :string
# conversation_created_at :datetime
# event_end_time :datetime
# event_start_time :datetime
# from_state :string
# name :string
# value :float
# value_in_business_hours :float
@@ -13,17 +16,22 @@
# account_id :integer
# conversation_id :integer
# inbox_id :integer
# team_id :bigint
# user_id :integer
#
# Indexes
#
# index_reporting_events_on_account_id (account_id)
# index_reporting_events_on_conversation_id (conversation_id)
# index_reporting_events_on_created_at (created_at)
# index_reporting_events_on_inbox_id (inbox_id)
# index_reporting_events_on_name (name)
# index_reporting_events_on_user_id (user_id)
# reporting_events__account_id__name__created_at (account_id,name,created_at)
# idx_reporting_events_on_account_channel_name_created (account_id,channel_type,name,created_at)
# idx_reporting_events_on_account_conv_created_at_name (account_id,conversation_created_at,name)
# idx_reporting_events_on_account_from_state_name_created (account_id,from_state,name,created_at)
# idx_reporting_events_on_account_team_name_created (account_id,team_id,name,created_at)
# index_reporting_events_on_account_id (account_id)
# index_reporting_events_on_conversation_id (conversation_id)
# index_reporting_events_on_created_at (created_at)
# index_reporting_events_on_inbox_id (inbox_id)
# index_reporting_events_on_name (name)
# index_reporting_events_on_user_id (user_id)
# reporting_events__account_id__name__created_at (account_id,name,created_at)
#
class ReportingEvent < ApplicationRecord
@@ -35,6 +43,7 @@ class ReportingEvent < ApplicationRecord
belongs_to :user, optional: true
belongs_to :inbox, optional: true
belongs_to :conversation, optional: true
belongs_to :team, optional: true
# Scopes for filtering
scope :filter_by_date_range, lambda { |range|
@@ -52,4 +61,12 @@ class ReportingEvent < ApplicationRecord
scope :filter_by_name, lambda { |name|
where(name: name) if name.present?
}
scope :filter_by_team_id, lambda { |team_id|
where(team_id: team_id) if team_id.present?
}
scope :filter_by_channel_type, lambda { |channel_type|
where(channel_type: channel_type) if channel_type.present?
}
end
+2
View File
@@ -90,6 +90,8 @@ class User < ApplicationRecord
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
alias_attribute :conversations, :assigned_conversations
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
has_many :reviewed_csat_survey_responses, foreign_key: 'review_notes_updated_by_id', class_name: 'CsatSurveyResponse',
dependent: :nullify, inverse_of: :review_notes_updated_by
has_many :conversation_participants, dependent: :destroy_async
has_many :participating_conversations, through: :conversation_participants, source: :conversation
+21
View File
@@ -0,0 +1,21 @@
class Captain::TasksPolicy < ApplicationPolicy
def rewrite?
true
end
def summarize?
true
end
def reply_suggestion?
true
end
def label_suggestion?
true
end
def follow_up?
true
end
end
+36 -1
View File
@@ -20,7 +20,7 @@ class CsatSurveyService
delegate :inbox, :contact, to: :conversation
def should_send_csat_survey?
conversation_allows_csat? && csat_enabled? && !csat_already_sent?
conversation_allows_csat? && csat_enabled? && !csat_already_sent? && csat_allowed_by_survey_rules?
end
def conversation_allows_csat?
@@ -39,6 +39,37 @@ class CsatSurveyService
conversation.can_reply?
end
def csat_allowed_by_survey_rules?
return true unless survey_rules_configured?
labels = conversation.label_list
return true if rule_values.empty?
case rule_operator
when 'contains'
rule_values.any? { |label| labels.include?(label) }
when 'does_not_contain'
rule_values.none? { |label| labels.include?(label) }
else
true
end
end
def survey_rules_configured?
return false if csat_config.blank?
return false if csat_config['survey_rules'].blank?
rule_values.any?
end
def rule_operator
csat_config.dig('survey_rules', 'operator') || 'contains'
end
def rule_values
csat_config.dig('survey_rules', 'values') || []
end
def whatsapp_channel?
inbox.channel_type == 'Channel::Whatsapp'
end
@@ -113,6 +144,10 @@ class CsatSurveyService
)
end
def csat_config
inbox.csat_config || {}
end
def send_twilio_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
content_sid = template_config['content_sid']
@@ -26,10 +26,18 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
def build_messages(config = {})
return "No messages in this conversation\n" if @record.messages.empty?
message_text = ''
messages = @record.messages.where.not(message_type: :activity).order(created_at: :asc)
messages = @record.messages.where.not(message_type: [:activity, :template])
messages.each do |message|
if config[:token_limit]
build_limited_messages(messages, config)
else
build_all_messages(messages, config)
end
end
def build_all_messages(messages, config)
message_text = ''
messages.order(created_at: :asc).each do |message|
# Skip private messages unless explicitly included in config
next if message.private? && !config[:include_private_messages]
@@ -38,6 +46,24 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
message_text
end
def build_limited_messages(messages, config)
selected = []
character_count = 0
messages.reorder(created_at: :desc).each do |message|
# Skip private messages unless explicitly included in config
next if message.private? && !config[:include_private_messages]
formatted = format_message(message)
break if character_count + formatted.length > config[:token_limit]
selected.prepend(formatted)
character_count += formatted.length
end
selected.join
end
def format_message(message)
sender = case message.sender_type
when 'User'
@@ -2,8 +2,6 @@ class MessageTemplates::Template::CsatSurvey
pattr_initialize [:conversation!]
def perform
return unless should_send_csat_survey?
ActiveRecord::Base.transaction do
conversation.messages.create!(csat_survey_message_params)
end
@@ -13,39 +11,6 @@ class MessageTemplates::Template::CsatSurvey
delegate :contact, :account, :inbox, to: :conversation
def should_send_csat_survey?
return true unless survey_rules_configured?
labels = conversation.label_list
return true if rule_values.empty?
case rule_operator
when 'contains'
rule_values.any? { |label| labels.include?(label) }
when 'does_not_contain'
rule_values.none? { |label| labels.include?(label) }
else
true
end
end
def survey_rules_configured?
return false if csat_config.blank?
return false if csat_config['survey_rules'].blank?
return false if rule_values.empty?
true
end
def rule_operator
csat_config.dig('survey_rules', 'operator') || 'contains'
end
def rule_values
csat_config.dig('survey_rules', 'values') || []
end
def message_content
return I18n.t('conversations.templates.csat_input_message_body') if csat_config.blank? || csat_config['message'].blank?
@@ -16,6 +16,10 @@ class Whatsapp::EmbeddedSignupService
validate_token_access(access_token)
channel = create_or_reauthorize_channel(access_token, phone_info)
# NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because:
# 1. Reauthorization flow updates an existing channel (not a create), so after_commit on: :create won't trigger
# 2. We need to run check_channel_health_and_prompt_reauth after webhook setup completes
# 3. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
channel.setup_webhooks
check_channel_health_and_prompt_reauth(channel)
channel
@@ -10,10 +10,7 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB
def download_attachment_file(attachment_payload)
url_response = HTTParty.get(
inbox.channel.media_url(
attachment_payload[:id],
inbox.channel.provider_config['phone_number_id']
),
inbox.channel.media_url(attachment_payload[:id]),
headers: inbox.channel.api_headers
)
# This url response will be failure if the access token has expired.
@@ -75,10 +75,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
csat_template_service.get_template_status(template_name)
end
def media_url(media_id, phone_number_id = nil)
url = "#{api_base_path}/v13.0/#{media_id}"
url += "?phone_number_id=#{phone_number_id}" if phone_number_id
url
def media_url(media_id)
"#{api_base_path}/v13.0/#{media_id}"
end
private
@@ -1,5 +1,5 @@
<%=
CSV.generate_line([
<%
headers = [
I18n.t('reports.csat.headers.agent_name'),
I18n.t('reports.csat.headers.rating'),
I18n.t('reports.csat.headers.feedback'),
@@ -8,24 +8,28 @@
I18n.t('reports.csat.headers.contact_phone_number'),
I18n.t('reports.csat.headers.link_to_the_conversation'),
I18n.t('reports.csat.headers.recorded_at')
])
]
headers << I18n.t('reports.csat.headers.review_notes') if ChatwootApp.enterprise?
-%>
<%= CSV.generate_line(headers) -%>
<% @csat_survey_responses.each do |csat_response| %>
<% assigned_agent = csat_response.assigned_agent %>
<% contact = csat_response.contact %>
<% conversation = csat_response.conversation %>
<%=
CSV.generate_line([
<%
row = [
assigned_agent ? "#{assigned_agent.name} (#{assigned_agent.email})" : nil,
csat_response.rating,
csat_response.feedback_message.present? ? csat_response.feedback_message : nil,
contact&.name.present? ? contact&.name: nil,
contact&.email.present? ? contact&.email: nil,
contact&.phone_number.present? ? contact&.phone_number: nil,
conversation ? app_account_conversation_url(account_id: Current.account.id, id: conversation.display_id): nil,
csat_response.created_at,
]).html_safe
csat_response.feedback_message.presence,
contact&.name.presence,
contact&.email.presence,
contact&.phone_number.presence,
conversation ? app_account_conversation_url(account_id: Current.account.id, id: conversation.display_id) : nil,
csat_response.created_at
]
row << csat_response.csat_review_notes if ChatwootApp.enterprise?
-%>
<%= CSV.generate_line(row).html_safe -%>
<% end %>
<%=
CSV.generate_line([
@@ -0,0 +1 @@
json.partial! 'api/v1/models/csat_survey_response', formats: [:json], resource: @csat_survey_response
@@ -1,6 +1,14 @@
json.id resource.id
json.rating resource.rating
json.feedback_message resource.feedback_message
json.csat_review_notes resource.csat_review_notes
json.review_notes_updated_at resource.review_notes_updated_at&.to_i
if resource.review_notes_updated_by
json.review_notes_updated_by do
json.id resource.review_notes_updated_by.id
json.name resource.review_notes_updated_by.name
end
end
json.account_id resource.account_id
json.message_id resource.message_id
if resource.contact
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.9.2'
version: '4.10.1'
development:
<<: *shared
+7
View File
@@ -230,3 +230,10 @@
- name: channel_tiktok
display_name: TikTok Channel
enabled: true
- name: csat_review_notes
display_name: CSAT Review Notes
enabled: false
premium: true
- name: captain_tasks
display_name: Captain Tasks
enabled: true
+4
View File
@@ -202,6 +202,7 @@ en:
rating: Rating
feedback: Feedback Comment
recorded_at: Recorded date
review_notes: Review Notes
notifications:
notification_title:
conversation_creation: 'A conversation (#%{display_id}) has been created in %{inbox_name}'
@@ -344,6 +345,9 @@ en:
copilot_message_required: Message is required
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
upgrade: 'Upgrade your plan to enable Captain AI'
disabled: 'Captain AI is disabled for this account.'
api_key_missing: 'Captain AI API key is not configured.'
copilot:
using_tool: 'Using tool %{function_name}'
completed_tool_call: 'Completed %{function_name} tool call'
+10
View File
@@ -73,6 +73,13 @@ Rails.application.routes.draw do
end
resources :custom_tools
resources :documents, only: [:index, :show, :create, :destroy]
resource :tasks, only: [], controller: 'tasks' do
post :rewrite
post :summarize
post :reply_suggestion
post :label_suggestion
post :follow_up
end
end
resource :saml_settings, only: [:show, :create, :update, :destroy]
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
@@ -189,6 +196,9 @@ Rails.application.routes.draw do
get :metrics
get :download
end
member do
patch :update if ChatwootApp.enterprise?
end
end
resources :applied_slas, only: [:index] do
collection do
@@ -0,0 +1,5 @@
class AddInternalObservationsToCsatSurveyResponses < ActiveRecord::Migration[7.1]
def change
add_column :csat_survey_responses, :csat_review_notes, :text
end
end
@@ -0,0 +1,6 @@
class AddObservationsAuditToCsatSurveyResponses < ActiveRecord::Migration[7.1]
def change
add_column :csat_survey_responses, :review_notes_updated_at, :datetime
add_reference :csat_survey_responses, :review_notes_updated_by, index: true
end
end
@@ -0,0 +1,12 @@
# Enable captain_tasks for existing accounts.
# Unlike 20250416182131_flip_chatwoot_v4_default_feature_flag_installation_config.rb,
# we don't need to update ACCOUNT_LEVEL_FEATURE_DEFAULTS or clear GlobalConfig cache
# because captain_tasks already has `enabled: true` in features.yml - ConfigLoader
# handles the defaults on deploy automatically.
class EnableCaptainTasksForExistingAccounts < ActiveRecord::Migration[7.0]
def up
Account.find_in_batches(batch_size: 100) do |accounts|
accounts.each { |account| account.enable_features!('captain_tasks') }
end
end
end
@@ -0,0 +1,19 @@
class AddFieldsToReportingEvents < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :reporting_events, :conversation_created_at, :datetime
add_column :reporting_events, :from_state, :string
add_column :reporting_events, :team_id, :bigint
add_column :reporting_events, :channel_type, :string
add_index :reporting_events, [:account_id, :conversation_created_at, :name],
algorithm: :concurrently, name: 'idx_reporting_events_on_account_conv_created_at_name'
add_index :reporting_events, [:account_id, :from_state, :name, :created_at],
algorithm: :concurrently, name: 'idx_reporting_events_on_account_from_state_name_created'
add_index :reporting_events, [:account_id, :team_id, :name, :created_at],
algorithm: :concurrently, name: 'idx_reporting_events_on_account_team_name_created'
add_index :reporting_events, [:account_id, :channel_type, :name, :created_at],
algorithm: :concurrently, name: 'idx_reporting_events_on_account_channel_name_created'
end
end
+13 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_01_12_092041) do
ActiveRecord::Schema[7.1].define(version: 2026_01_21_121101) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -733,11 +733,15 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_12_092041) do
t.bigint "assigned_agent_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "csat_review_notes"
t.datetime "review_notes_updated_at"
t.bigint "review_notes_updated_by_id"
t.index ["account_id"], name: "index_csat_survey_responses_on_account_id"
t.index ["assigned_agent_id"], name: "index_csat_survey_responses_on_assigned_agent_id"
t.index ["contact_id"], name: "index_csat_survey_responses_on_contact_id"
t.index ["conversation_id"], name: "index_csat_survey_responses_on_conversation_id"
t.index ["message_id"], name: "index_csat_survey_responses_on_message_id", unique: true
t.index ["review_notes_updated_by_id"], name: "index_csat_survey_responses_on_review_notes_updated_by_id"
end
create_table "custom_attribute_definitions", force: :cascade do |t|
@@ -1110,7 +1114,15 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_12_092041) do
t.float "value_in_business_hours"
t.datetime "event_start_time", precision: nil
t.datetime "event_end_time", precision: nil
t.datetime "conversation_created_at"
t.string "from_state"
t.bigint "team_id"
t.string "channel_type"
t.index ["account_id", "channel_type", "name", "created_at"], name: "idx_reporting_events_on_account_channel_name_created"
t.index ["account_id", "conversation_created_at", "name"], name: "idx_reporting_events_on_account_conv_created_at_name"
t.index ["account_id", "from_state", "name", "created_at"], name: "idx_reporting_events_on_account_from_state_name_created"
t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at"
t.index ["account_id", "team_id", "name", "created_at"], name: "idx_reporting_events_on_account_team_name_created"
t.index ["account_id"], name: "index_reporting_events_on_account_id"
t.index ["conversation_id"], name: "index_reporting_events_on_conversation_id"
t.index ["created_at"], name: "index_reporting_events_on_created_at"

Some files were not shown because too many files have changed in this diff Show More