Merge branch 'develop' into chore/update-rails

This commit is contained in:
Sojan Jose
2026-01-22 09:09:25 -08:00
committed by GitHub
13 changed files with 209 additions and 93 deletions
@@ -24,9 +24,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def search
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
contacts = resolved_contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
contacts = Current.account.contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q].strip}%"
)
@contacts = fetch_contacts(contacts)
@@ -1271,7 +1271,7 @@ export default {
key="copilot-bottom-panel"
:is-generating-content="copilot.isButtonDisabled.value"
@submit="onSubmitCopilotReply"
@cancel="copilot.toggleEditor"
@cancel="copilot.reset"
/>
<ReplyBottomPanel
v-else
@@ -515,6 +515,10 @@ const languages = [
name: 'Portuguese',
id: 'pt',
},
{
name: 'Portuguese (Brazil)',
id: 'pt_BR',
},
{
name: 'Punjabi',
id: 'pa',
@@ -13,7 +13,7 @@ import { mapGetters } from 'vuex';
// utils & constants
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { OPEN_AI_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { CAPTAIN_EVENTS } from '../../../../helper/AnalyticsHelper/events';
export default {
name: 'LabelSuggestion',
@@ -114,7 +114,7 @@ export default {
// dismiss this once the values are set
this.isDismissed = true;
this.trackLabelEvent(OPEN_AI_EVENTS.DISMISS_LABEL_SUGGESTION);
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_DISMISSED);
},
isConversationDismissed() {
return LocalStorage.getFlag(
@@ -132,7 +132,7 @@ export default {
conversationId: this.conversationId,
labels: labelsToAdd,
});
this.trackLabelEvent(OPEN_AI_EVENTS.APPLY_LABEL_SUGGESTION);
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_APPLIED);
},
trackLabelEvent(event) {
const payload = {
@@ -8,7 +8,6 @@ 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');
@@ -23,8 +22,8 @@ vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
OPEN_AI_EVENTS: {
TEST_EVENT: 'open_ai_test_event',
CAPTAIN_EVENTS: {
TEST_EVENT: 'captain_test_event',
},
}));
@@ -65,17 +64,6 @@ describe('useCaptain', () => {
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('rewrites content', async () => {
TasksAPI.rewrite.mockResolvedValue({
data: { message: 'Rewritten content', follow_up_context: { id: 'ctx1' } },
@@ -7,10 +7,9 @@ import {
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 { useAlert } 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';
export function useCaptain() {
@@ -79,23 +78,6 @@ export function useCaptain() {
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.
@@ -234,8 +216,5 @@ export function useCaptain() {
getReplySuggestion,
followUp,
processEvent,
// Analytics
recordAnalytics,
};
}
@@ -1,6 +1,56 @@
import { ref, computed } from 'vue';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
// Actions that map to REWRITE events (with operation attribute)
const REWRITE_ACTIONS = [
'improve',
'fix_spelling_grammar',
'casual',
'professional',
'expand',
'shorten',
'rephrase',
'make_friendly',
'make_formal',
'simplify',
];
/**
* Gets the event key suffix based on action type.
* @param {string} action - The action type
* @returns {string} The event key prefix (REWRITE, SUMMARIZE, or REPLY_SUGGESTION)
*/
function getEventPrefix(action) {
if (action === 'summarize') return 'SUMMARIZE';
if (action === 'reply_suggestion') return 'REPLY_SUGGESTION';
return 'REWRITE';
}
/**
* Builds the analytics payload based on action type.
* @param {string} action - The action type
* @param {number} conversationId - The conversation ID
* @param {number} [followUpCount] - Optional follow-up count
* @returns {Object} The payload object
*/
function buildPayload(action, conversationId, followUpCount = undefined) {
const payload = { conversationId };
// Add operation for rewrite actions
if (REWRITE_ACTIONS.includes(action)) {
payload.operation = action;
}
// Add followUpCount if provided
if (followUpCount !== undefined) {
payload.followUpCount = followUpCount;
}
return payload;
}
/**
* Composable for managing Copilot reply generation state and actions.
@@ -9,7 +59,7 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
* @returns {Object} Copilot reply state and methods
*/
export function useCopilotReply() {
const { processEvent, followUp } = useCaptain();
const { processEvent, followUp, currentChat } = useCaptain();
const { updateUISettings } = useUISettings();
const showEditor = ref(false);
@@ -19,6 +69,13 @@ export function useCopilotReply() {
const followUpContext = ref(null);
const abortController = ref(null);
// Tracking state
const currentAction = ref(null);
const followUpCount = ref(0);
const trackedConversationId = ref(null);
const conversationId = computed(() => currentChat.value?.id);
const isActive = computed(() => showEditor.value || isGenerating.value);
const isButtonDisabled = computed(
() => isGenerating.value || !isContentReady.value
@@ -29,8 +86,22 @@ export function useCopilotReply() {
/**
* Resets all copilot editor state and cancels any ongoing generation.
* @param {boolean} [trackDismiss=true] - Whether to track dismiss event
*/
function reset() {
function reset(trackDismiss = true) {
// Track dismiss event if there was content and we're not accepting
if (trackDismiss && generatedContent.value && currentAction.value) {
const eventKey = `${getEventPrefix(currentAction.value)}_DISMISSED`;
useTrack(
CAPTAIN_EVENTS[eventKey],
buildPayload(
currentAction.value,
trackedConversationId.value,
followUpCount.value
)
);
}
if (abortController.value) {
abortController.value.abort();
abortController.value = null;
@@ -40,6 +111,9 @@ export function useCopilotReply() {
isContentReady.value = false;
generatedContent.value = '';
followUpContext.value = null;
currentAction.value = null;
followUpCount.value = 0;
trackedConversationId.value = null;
}
/**
@@ -70,11 +144,14 @@ export function useCopilotReply() {
return;
}
// Reset and start new generation
reset();
// Reset without tracking dismiss (starting new action)
reset(false);
abortController.value = new AbortController();
isGenerating.value = true;
isContentReady.value = false;
currentAction.value = action;
followUpCount.value = 0;
trackedConversationId.value = conversationId.value;
try {
const { message: content, followUpContext: newContext } =
@@ -85,7 +162,15 @@ export function useCopilotReply() {
if (!abortController.value?.signal.aborted) {
generatedContent.value = content;
followUpContext.value = newContext;
if (content) showEditor.value = true;
if (content) {
showEditor.value = true;
// Track "Used" event on successful generation
const eventKey = `${getEventPrefix(action)}_USED`;
useTrack(
CAPTAIN_EVENTS[eventKey],
buildPayload(action, trackedConversationId.value)
);
}
isGenerating.value = false;
}
} catch {
@@ -106,6 +191,12 @@ export function useCopilotReply() {
isGenerating.value = true;
isContentReady.value = false;
// Track follow-up sent event
useTrack(CAPTAIN_EVENTS.FOLLOW_UP_SENT, {
conversationId: trackedConversationId.value,
});
followUpCount.value += 1;
try {
const { message: content, followUpContext: updatedContext } =
await followUp({
@@ -137,7 +228,28 @@ export function useCopilotReply() {
*/
function accept() {
const content = generatedContent.value;
// Track "Applied" event
if (currentAction.value) {
const eventKey = `${getEventPrefix(currentAction.value)}_APPLIED`;
useTrack(
CAPTAIN_EVENTS[eventKey],
buildPayload(
currentAction.value,
trackedConversationId.value,
followUpCount.value
)
);
}
// Reset state without tracking dismiss
showEditor.value = false;
generatedContent.value = '';
followUpContext.value = null;
currentAction.value = null;
followUpCount.value = 0;
trackedConversationId.value = null;
return content;
}
@@ -84,22 +84,28 @@ export const PORTALS_EVENTS = Object.freeze({
PREVIEW_ARTICLE: 'Previewed article',
});
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',
MAKE_FRIENDLY: 'OpenAI: Used make friendly',
MAKE_FORMAL: 'OpenAI: Used make formal',
SIMPLIFY: 'OpenAI: Used simplify',
APPLY_LABEL_SUGGESTION: 'OpenAI: Apply label from suggestion',
DISMISS_LABEL_SUGGESTION: 'OpenAI: Dismiss label suggestions',
ADDED_AI_INTEGRATION_VIA_CTA_BUTTON:
'OpenAI: Added AI integration via CTA button',
DISMISS_AI_SUGGESTION: 'OpenAI: Dismiss AI suggestions',
export const CAPTAIN_EVENTS = Object.freeze({
// Rewrite events (with operation attribute in payload)
REWRITE_USED: 'Captain: Rewrite used',
REWRITE_APPLIED: 'Captain: Rewrite applied',
REWRITE_DISMISSED: 'Captain: Rewrite dismissed',
// Summarize events
SUMMARIZE_USED: 'Captain: Summarize used',
SUMMARIZE_APPLIED: 'Captain: Summarize applied',
SUMMARIZE_DISMISSED: 'Captain: Summarize dismissed',
// Reply suggestion events
REPLY_SUGGESTION_USED: 'Captain: Reply suggestion used',
REPLY_SUGGESTION_APPLIED: 'Captain: Reply suggestion applied',
REPLY_SUGGESTION_DISMISSED: 'Captain: Reply suggestion dismissed',
// Follow-up events
FOLLOW_UP_SENT: 'Captain: Follow-up sent',
// Label suggestions
LABEL_SUGGESTION_APPLIED: 'Captain: Label suggestion applied',
LABEL_SUGGESTION_DISMISSED: 'Captain: Label suggestion dismissed',
});
export const COPILOT_EVENTS = Object.freeze({
@@ -51,7 +51,6 @@ class Messages::SearchDataPresenter < SimpleDelegator
def additional_attributes_data
{
campaign_id: additional_attributes&.dig('campaign_id'),
automation_rule_id: content_attributes&.dig('automation_rule_id')
}
end
@@ -1,4 +1,9 @@
class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def strong(_node)
out('*', :children, '*')
end
@@ -15,13 +20,20 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderer
out(node.url)
end
def list(_node)
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
out('- ', :children)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('- ', :children)
end
cr
end
@@ -357,18 +357,6 @@ RSpec.describe 'Contacts API', type: :request do
expect(response.body).not_to include(contact1.email)
end
it 'searches contacts using company name' do
contact2.update(additional_attributes: { company_name: 'acme.inc' })
get "/api/v1/accounts/#{account.id}/contacts/search",
params: { q: 'acme.inc' },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include(contact2.email)
expect(response.body).not_to include(contact1.email)
end
it 'matches the resolved contact respecting the identifier character casing' do
contact_normal = create(:contact, name: 'testcontact', account: account, identifier: 'testidentifer')
contact_special = create(:contact, name: 'testcontact', account: account, identifier: 'TestIdentifier')
@@ -61,15 +61,10 @@ RSpec.describe Messages::SearchDataPresenter do
context 'with campaign and automation data' do
before do
message.update(
additional_attributes: { 'campaign_id' => '123' },
content_attributes: { 'automation_rule_id' => '456' }
)
end
it 'includes campaign_id' do
expect(presenter.search_data[:additional_attributes][:campaign_id]).to eq('123')
end
it 'includes automation_rule_id' do
expect(presenter.search_data[:additional_attributes][:automation_rule_id]).to eq('456')
end
@@ -53,12 +53,28 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result.strip).to eq('*bold _italic_*')
end
it 'converts bullet lists' do
content = "- item 1\n- item 2"
it 'preserves unordered list with dash markers' do
content = "- item 1\n- item 2\n- item 3"
result = described_class.new(content, channel_type).render
expect(result.strip).to include('- item 1')
expect(result.strip).to include('- item 2')
expect(result).to include("- item 1\n- item 2")
expect(result).to include('- item 1')
expect(result).to include('- item 2')
expect(result).to include('- item 3')
end
it 'converts asterisk unordered lists to dash markers' do
content = "* item 1\n* item 2\n* item 3"
result = described_class.new(content, channel_type).render
expect(result).to include('- item 1')
expect(result).to include('- item 2')
expect(result).to include('- item 3')
end
it 'preserves ordered list markers with numbering' do
content = "1. first step\n2. second step\n3. third step"
result = described_class.new(content, channel_type).render
expect(result).to include('1. first step')
expect(result).to include('2. second step')
expect(result).to include('3. third step')
end
it 'preserves newlines in plain text without list markers' do
@@ -432,6 +448,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result).to include("Line 1\nLine 2\nLine 3")
end
it 'preserves ordered list markers with numbering in Twilio WhatsApp' do
content = "1. first step\n2. second step\n3. third step"
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
result = described_class.new(content, channel_type, channel).render
expect(result).to include('1. first step')
expect(result).to include('2. second step')
expect(result).to include('3. third step')
end
it 'preserves unordered list markers in Twilio WhatsApp' do
content = "- item 1\n- item 2\n- item 3"
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
result = described_class.new(content, channel_type, channel).render
expect(result).to include('- item 1')
expect(result).to include('- item 2')
expect(result).to include('- item 3')
end
it 'backwards compatible when channel is not provided' do
content = '**bold** _italic_'
result = described_class.new(content, channel_type).render
@@ -483,12 +517,12 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
context 'when testing all formatting types' do
let(:channel_type) { 'Channel::Whatsapp' }
it 'handles ordered lists' do
it 'handles ordered lists with proper numbering' do
content = "1. first\n2. second\n3. third"
result = described_class.new(content, channel_type).render
expect(result).to include('first')
expect(result).to include('second')
expect(result).to include('third')
expect(result).to include('1. first')
expect(result).to include('2. second')
expect(result).to include('3. third')
end
end