From 56dc580f50e01a313837acd2da29e7145d3a6858 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Fri, 10 Jul 2026 15:24:14 +0530
Subject: [PATCH 1/8] feat: support manual setup for WhatsApp calls inbox
creation (#14976)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Re-enables the "WhatsApp Calls" inbox creation option, which disappeared
when embedded signup was disabled on production. The channel card now
only requires the `channel_voice` account feature, and the creation flow
offers the manual WhatsApp Cloud API setup form. Once the channel is
created with manual credentials, calling is enabled automatically — the
same post-setup step the embedded signup flow used to perform.
When an installation has embedded signup configured, the flow still uses
it; the manual form is the fallback (and the effective path on Chatwoot
Cloud today).
## How to test
1. Enable the `channel_voice` feature on the account.
2. Go to Add Inbox → the "WhatsApp Calls" card is visible again → select
it.
3. Fill in the manual Cloud API credentials (inbox name, phone number,
phone number ID, business account ID, API key) and submit.
4. The inbox is created and calling is enabled:
`provider_config.calling_enabled` is true and the Calls tab toggle is
on. If the number isn't enrolled in the Business Calling API, an alert
explains the enable failure but the messaging inbox is still created.
## What changed
- `ChannelItem.vue`: the `whatsapp_call` card is gated only on
`channel_voice` (previously also required the embedded signup app ID).
- `CloudWhatsapp.vue`: new `enableCallingOnComplete` prop that calls the
enable-calling API after channel creation.
- `WhatsappCall.vue`: renders embedded signup when available, otherwise
the manual setup form — both with calling enabled on completion.
---
.../components/widgets/ChannelItem.vue | 11 +----------
.../settings/inbox/channels/CloudWhatsapp.vue | 15 +++++++++++++++
.../settings/inbox/channels/WhatsappCall.vue | 17 ++++++++++++++++-
3 files changed, 32 insertions(+), 11 deletions(-)
diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue
index 2652b582b..7ed2505c1 100644
--- a/app/javascript/dashboard/components/widgets/ChannelItem.vue
+++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue
@@ -56,19 +56,10 @@ const isActive = computed(() => {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
- if (key === 'voice') {
+ if (key === 'voice' || key === 'whatsapp_call') {
return props.enabledFeatures.channel_voice;
}
- if (key === 'whatsapp_call') {
- return (
- !IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
- props.enabledFeatures.channel_voice &&
- !!window.chatwootConfig?.whatsappAppId &&
- window.chatwootConfig.whatsappAppId !== 'none'
- );
- }
-
return [
'website',
'twilio',
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue
index 0c1ac3a14..cb739ac93 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators';
import router from '../../../../index';
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
+import InboxesAPI from 'dashboard/api/inboxes';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -12,6 +13,12 @@ export default {
components: {
NextButton,
},
+ props: {
+ enableCallingOnComplete: {
+ type: Boolean,
+ default: false,
+ },
+ },
setup() {
return { v$: useVuelidate() };
},
@@ -59,6 +66,14 @@ export default {
}
);
+ if (this.enableCallingOnComplete) {
+ try {
+ await InboxesAPI.enableWhatsappCalling(whatsappChannel.id);
+ } catch (_) {
+ useAlert(this.$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE_FAILED'));
+ }
+ }
+
router.replace({
name: 'settings_inboxes_add_agents',
params: {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue
index c27cd7d1f..d9ac031e1 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue
@@ -1,11 +1,26 @@
-
+
+
From 848e94bcf2e3a6293fb36eb5e92336a8f15313f7 Mon Sep 17 00:00:00 2001
From: Sony Mathew
Date: Fri, 10 Jul 2026 17:23:00 +0530
Subject: [PATCH 2/8] fix: throttle filtered unread count rebuilds (#14980)
Reduces database pressure from filtered unread-count cache rebuilds
during high-traffic account rollouts by serving stale snapshots longer
and limiting inline saved-filter rebuild fanout.
## Closes
None
## What changed
- Increase filtered unread-count refresh throttling from 30 seconds to 5
minutes.
- Increase the stale snapshot window from 30 minutes to 1 hour.
- Reduce inline saved-filter count rebuilds per request from 10 to 3.
- Update unread-count specs to assert refresh and stale behavior through
the shared constants.
## How to test
- Enable `conversation_unread_counts` and `unread_count_for_filters` for
an account with conversation custom filters.
- Open the dashboard and verify unread-count badges still return values.
- Mutate conversations and verify stale filtered counts are served while
rebuilds are throttled, instead of repeatedly rebuilding every 30
seconds.
---
app/services/conversations/unread_counts.rb | 6 ++---
.../filtered_count_store_spec.rb | 23 ++++++++++++++++---
.../unread_counts/filtered_counter_spec.rb | 22 +++++++++++++++---
3 files changed, 42 insertions(+), 9 deletions(-)
diff --git a/app/services/conversations/unread_counts.rb b/app/services/conversations/unread_counts.rb
index 1b3ee3fb2..e00f8357d 100644
--- a/app/services/conversations/unread_counts.rb
+++ b/app/services/conversations/unread_counts.rb
@@ -2,9 +2,9 @@ module Conversations::UnreadCounts
READY_TTL = 24.hours.to_i
SET_TTL = 25.hours.to_i
FILTERED_COUNT_FRESH_TTL = 5.minutes.to_i
- FILTERED_COUNT_STALE_WINDOW = 30.minutes.to_i
+ FILTERED_COUNT_STALE_WINDOW = 1.hour.to_i
FILTERED_COUNT_REDIS_TTL = FILTERED_COUNT_FRESH_TTL + FILTERED_COUNT_STALE_WINDOW
FILTERED_COUNT_VERSION_TTL = SET_TTL
- FILTERED_COUNT_MIN_REFRESH_INTERVAL = 30.seconds.to_i
- MAX_INLINE_FILTER_BUILDS = 10
+ FILTERED_COUNT_MIN_REFRESH_INTERVAL = 5.minutes.to_i
+ MAX_INLINE_FILTER_BUILDS = 3
end
diff --git a/spec/services/conversations/unread_counts/filtered_count_store_spec.rb b/spec/services/conversations/unread_counts/filtered_count_store_spec.rb
index 7732eb2dd..3a7cd52d8 100644
--- a/spec/services/conversations/unread_counts/filtered_count_store_spec.rb
+++ b/spec/services/conversations/unread_counts/filtered_count_store_spec.rb
@@ -96,7 +96,14 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do
described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 2.minutes)).to be_stale
- expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 36.minutes)).to be_expired
+ expect(
+ described_class.built_in_filter_counts_state(
+ account_id: account_id,
+ user_id: user_id,
+ now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_FRESH_TTL +
+ Conversations::UnreadCounts::FILTERED_COUNT_STALE_WINDOW + 1.second
+ )
+ ).to be_expired
Redis::Alfred.delete(described_class.built_in_filter_counts_key(account_id, user_id))
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id)).to be_missing
@@ -202,8 +209,18 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do
)
snapshot = described_class.built_in_filter_counts(account_id: account_id, user_id: user_id)
- expect(described_class.refresh_due?(snapshot, now: built_at + 10.seconds)).to be(false)
- expect(described_class.refresh_due?(snapshot, now: built_at + 31.seconds)).to be(true)
+ expect(
+ described_class.refresh_due?(
+ snapshot,
+ now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second
+ )
+ ).to be(false)
+ expect(
+ described_class.refresh_due?(
+ snapshot,
+ now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
+ )
+ ).to be(true)
expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(true)
expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(false)
diff --git a/spec/services/conversations/unread_counts/filtered_counter_spec.rb b/spec/services/conversations/unread_counts/filtered_counter_spec.rb
index bb5d419a6..2904d1e4e 100644
--- a/spec/services/conversations/unread_counts/filtered_counter_spec.rb
+++ b/spec/services/conversations/unread_counts/filtered_counter_spec.rb
@@ -48,10 +48,22 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do
create(:mention, account: account, conversation: second_mention, user: agent)
store.bump_conversation_version!(account.id)
- expect(described_class.new(account: account, user: agent, now: now + 10.seconds).perform[:mentions_count]).to eq(1)
+ expect(
+ described_class.new(
+ account: account,
+ user: agent,
+ now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second
+ ).perform[:mentions_count]
+ ).to eq(1)
Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
- expect(described_class.new(account: account, user: agent, now: now + 31.seconds).perform[:mentions_count]).to eq(2)
+ expect(
+ described_class.new(
+ account: account,
+ user: agent,
+ now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
+ ).perform[:mentions_count]
+ ).to eq(2)
end
it 'returns stale built-in counts when a refresh build hits a database error' do
@@ -62,7 +74,11 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do
store.bump_conversation_version!(account.id)
Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
- failing_counter = described_class.new(account: account, user: agent, now: now + 31.seconds)
+ failing_counter = described_class.new(
+ account: account,
+ user: agent,
+ now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
+ )
allow(failing_counter).to receive(:built_in_counts_from_database).and_raise(ActiveRecord::StatementInvalid.new('statement timeout'))
expect(failing_counter.perform[:mentions_count]).to eq(1)
From 03a1b1dbc14094aaf1b62c7d7ebd0b6915a2c56a Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Fri, 10 Jul 2026 17:24:34 +0530
Subject: [PATCH 3/8] chore: insert resolved variable value in reply editor
(#14921)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Pull Request Template
## Description
This PR makes reply editor variables insert their resolved value (for
example, the contact's name) instead of the raw `{{contact.name}}`
placeholder, matching canned response behavior. This works both when
picking a variable from the `{{` menu and when an agent manually types
out `{{contact.name}}` — it resolves the moment the closing `}}` is
typed.
If a variable has no value, the `{{placeholder}}` is kept so the backend
can still resolve it when the message is sent. Private notes are left
untouched.
For safety, a resolved value that itself contains Liquid syntax `({{ }}`
or `{% %})` also keeps its placeholder, so customer-controlled fields
can never inject Liquid into the outgoing message.
Fixes
https://linear.app/chatwoot/issue/CW-7528/reply-editor-inserts-variable-placeholder-instead-of-the-value
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
1. Open a conversation and add a reply.
2. Type `{{` and pick a variable that has a value (e.g. Contact name) →
it inserts the actual value.
3. Manually type `{{contact.name}}` and close the braces → it
auto-resolves to the value.
4. Insert/type a variable with no value → the `{{placeholder}}` stays;
confirm it resolves correctly on send.
5. Repeat in a private note → placeholders are left as-is.
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
.../components/widgets/WootWriter/Editor.vue | 5 +
.../widgets/conversation/ReplyBox.vue | 10 +-
.../dashboard/helper/editorHelper.js | 60 ++++++-
.../helper/specs/editorContentHelper.spec.js | 58 +++++--
.../helper/specs/editorHelper.spec.js | 149 ++++++++++++++++++
package.json | 1 +
pnpm-lock.yaml | 7 +-
7 files changed, 275 insertions(+), 15 deletions(-)
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index 09dc23819..634276361 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -62,6 +62,7 @@ import {
calculateMenuPosition,
getEffectiveChannelType,
stripUnsupportedFormatting,
+ createVariableInputRule,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -306,6 +307,10 @@ const plugins = computed(() => {
searchTerm: variableSearchTerm,
isAllowed: () => !props.isPrivate,
}),
+ createVariableInputRule({
+ isPrivate: () => props.isPrivate,
+ getVariables: () => props.variables,
+ }),
createSuggestionPlugin({
trigger: ':',
minChars: 2,
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index 6af876cc6..471d10f3c 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -48,6 +48,8 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
+ getAgentVariables,
+ getContactVariables,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -393,7 +395,13 @@ export default {
contact: this.currentContact,
inbox: this.inbox,
});
- return variables;
+ // Match the backend drops: names are Ruby-capitalized and
+ // {{agent.*}} is the message sender, not the assignee.
+ return {
+ ...variables,
+ ...getContactVariables(this.currentContact),
+ ...getAgentVariables(this.currentUser),
+ };
},
connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox;
diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js
index 32f56172a..2d3c75777 100644
--- a/app/javascript/dashboard/helper/editorHelper.js
+++ b/app/javascript/dashboard/helper/editorHelper.js
@@ -9,6 +9,7 @@ import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
+import { InputRule, inputRules } from 'prosemirror-inputrules';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
@@ -428,6 +429,55 @@ export function stripUnsupportedFormatting(content, schema) {
* - emoji
*/
+// Liquid delimiters ({{ }} / {% %}) the backend evaluates on send.
+const LIQUID_SYNTAX = /\{\{|\{%/;
+
+// Value when set (and not itself Liquid), else the {{placeholder}} for the backend.
+export const resolveVariableText = (key, variables) => {
+ const value = String(variables?.[key] ?? '');
+ return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`;
+};
+
+// Name variables normalized like the backend drops (UserDrop/ContactDrop):
+// name split on whitespace, each word Ruby-capitalized (rest downcased).
+const getNameVariables = (prefix, name) => {
+ const names = (name || '')
+ .split(/\s+/)
+ .filter(Boolean)
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
+ return {
+ [`${prefix}.name`]: names.join(' '),
+ [`${prefix}.first_name`]: names[0] || '',
+ [`${prefix}.last_name`]: names.length > 1 ? names[names.length - 1] : '',
+ };
+};
+
+// {{agent.*}} values for the message sender.
+export const getAgentVariables = user => ({
+ ...getNameVariables('agent', user.name),
+ 'agent.email': user.email,
+});
+
+// {{contact.*}} name values.
+export const getContactVariables = contact =>
+ getNameVariables('contact', contact?.name);
+
+// Resolves a manually typed {{variable}} to its value on the closing braces.
+// Leaves the placeholder when there's no value, the value is Liquid, or it's a private note.
+export const createVariableInputRule = ({ isPrivate, getVariables }) => {
+ const rule = new InputRule(
+ /\{\{([^{}]+)\}\}$/,
+ (editorState, match, from, to) => {
+ if (isPrivate()) return null;
+ const [, key] = match;
+ const text = resolveVariableText(key, getVariables());
+ if (text === `{{${key}}}`) return null;
+ return editorState.tr.insertText(text, from, to);
+ }
+ );
+ return inputRules({ rules: [rule] });
+};
+
/**
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
* @param {Object} editorView - The editor view instance.
@@ -462,7 +512,7 @@ const createNode = (editorView, nodeType, content) => {
);
}
case 'variable':
- return state.schema.text(`{{${content}}}`);
+ return state.schema.text(content);
case 'emoji':
return state.schema.text(content);
case 'tool': {
@@ -497,8 +547,12 @@ const nodeCreators = {
to,
};
},
- variable: (editorView, content, from, to) => ({
- node: createNode(editorView, 'variable', content),
+ variable: (editorView, content, from, to, variables) => ({
+ node: createNode(
+ editorView,
+ 'variable',
+ resolveVariableText(content, variables)
+ ),
from,
to,
}),
diff --git a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
index 4efb4d1d9..57d8bd533 100644
--- a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
@@ -94,16 +94,56 @@ describe('getContentNode', () => {
});
describe('getVariableNode', () => {
- it('should create a variable node', () => {
- const content = 'name';
- const from = 0;
- const to = 10;
- getContentNode(editorView, 'variable', content, {
- from,
- to,
- });
+ it('should render the resolved value directly when the variable has a value', () => {
+ getContentNode(
+ editorView,
+ 'variable',
+ 'contact.name',
+ { from: 0, to: 10 },
+ { 'contact.name': 'John' }
+ );
- expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
+ expect(editorView.state.schema.text).toHaveBeenCalledWith('John');
+ });
+
+ it('should resolve camelCase custom attributes and non-string values', () => {
+ getContentNode(
+ editorView,
+ 'variable',
+ 'contact.custom_attribute.cloudCustomer',
+ { from: 0, to: 10 },
+ { 'contact.custom_attribute.cloudCustomer': true }
+ );
+
+ expect(editorView.state.schema.text).toHaveBeenCalledWith('true');
+ });
+
+ it('should keep the placeholder when the variable has no value', () => {
+ getContentNode(
+ editorView,
+ 'variable',
+ 'contact.email',
+ { from: 0, to: 10 },
+ {}
+ );
+
+ expect(editorView.state.schema.text).toHaveBeenCalledWith(
+ '{{contact.email}}'
+ );
+ });
+
+ it('should keep the placeholder when the value contains Liquid syntax', () => {
+ getContentNode(
+ editorView,
+ 'variable',
+ 'contact.name',
+ { from: 0, to: 10 },
+ { 'contact.name': '{{agent.email}}' }
+ );
+
+ expect(editorView.state.schema.text).toHaveBeenCalledWith(
+ '{{contact.name}}'
+ );
});
});
diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
index 220b9903e..fafe1bc56 100644
--- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
@@ -11,9 +11,12 @@ import {
calculateMenuPosition,
cleanSignature,
collapseSelection,
+ createVariableInputRule,
extractTextFromMarkdown,
findNodeToInsertImage,
findSignatureInBody,
+ getAgentVariables,
+ getContactVariables,
getContentNode,
getFormattingForEditor,
getMenuAnchor,
@@ -1228,3 +1231,149 @@ describe('Menu positioning helpers', () => {
});
});
});
+
+describe('getAgentVariables', () => {
+ it('builds agent variables from the user', () => {
+ expect(
+ getAgentVariables({ name: 'John Doe', email: 'john@example.com' })
+ ).toEqual({
+ 'agent.name': 'John Doe',
+ 'agent.first_name': 'John',
+ 'agent.last_name': 'Doe',
+ 'agent.email': 'john@example.com',
+ });
+ });
+
+ it('normalizes casing like the backend UserDrop (Ruby capitalize)', () => {
+ const variables = getAgentVariables({ name: 'JANE doE' });
+
+ expect(variables['agent.name']).toBe('Jane Doe');
+ expect(variables['agent.first_name']).toBe('Jane');
+ expect(variables['agent.last_name']).toBe('Doe');
+ });
+
+ it('ignores extra whitespace between words', () => {
+ expect(getAgentVariables({ name: ' john doe ' })['agent.name']).toBe(
+ 'John Doe'
+ );
+ });
+
+ it('leaves last_name empty for single-word names', () => {
+ const variables = getAgentVariables({ name: 'john' });
+
+ expect(variables['agent.first_name']).toBe('John');
+ expect(variables['agent.last_name']).toBe('');
+ });
+
+ it('handles a missing name', () => {
+ const variables = getAgentVariables({ email: 'john@example.com' });
+
+ expect(variables['agent.name']).toBe('');
+ expect(variables['agent.first_name']).toBe('');
+ expect(variables['agent.last_name']).toBe('');
+ });
+});
+
+describe('getContactVariables', () => {
+ it('normalizes casing like the backend ContactDrop (Ruby capitalize)', () => {
+ expect(getContactVariables({ name: 'JANE doE' })).toEqual({
+ 'contact.name': 'Jane Doe',
+ 'contact.first_name': 'Jane',
+ 'contact.last_name': 'Doe',
+ });
+ });
+
+ it('leaves last_name empty for single-word names', () => {
+ const variables = getContactVariables({ name: 'john' });
+
+ expect(variables['contact.first_name']).toBe('John');
+ expect(variables['contact.last_name']).toBe('');
+ });
+
+ it('handles a missing contact', () => {
+ expect(getContactVariables(undefined)['contact.name']).toBe('');
+ });
+});
+
+describe('createVariableInputRule', () => {
+ // Editor holding `{{key}` so we can simulate typing the final `}`.
+ const buildView = (typed, { isPrivate = false, variables = {} } = {}) => {
+ const plugin = createVariableInputRule({
+ isPrivate: () => isPrivate,
+ getVariables: () => variables,
+ });
+ const state = EditorState.create({
+ schema,
+ doc: schema.node('doc', null, [
+ schema.node('paragraph', null, [schema.text(typed)]),
+ ]),
+ plugins: [plugin],
+ });
+ return new EditorView(document.body, { state });
+ };
+
+ // Types the closing `}`; when the rule declines, insert it like the browser would.
+ const typeClosingBrace = view => {
+ const end = view.state.doc.content.size - 1;
+ const handled = view.someProp('handleTextInput', fn =>
+ fn(view, end, end, '}')
+ );
+ if (!handled) {
+ view.dispatch(view.state.tr.insertText('}', end, end));
+ }
+ };
+
+ it('resolves a manually typed {{variable}} to its value on the closing brace', () => {
+ const view = buildView('{{contact.name}', {
+ variables: { 'contact.name': 'John' },
+ });
+
+ typeClosingBrace(view);
+
+ expect(view.state.doc.textContent).toBe('John');
+ view.destroy();
+ });
+
+ it('resolves boolean/non-string values', () => {
+ const view = buildView('{{contact.custom_attribute.cloudCustomer}', {
+ variables: { 'contact.custom_attribute.cloudCustomer': true },
+ });
+
+ typeClosingBrace(view);
+
+ expect(view.state.doc.textContent).toBe('true');
+ view.destroy();
+ });
+
+ it('keeps the placeholder when the variable has no value', () => {
+ const view = buildView('{{contact.email}', { variables: {} });
+
+ typeClosingBrace(view);
+
+ expect(view.state.doc.textContent).toBe('{{contact.email}}');
+ view.destroy();
+ });
+
+ it('keeps the placeholder when the value itself contains Liquid syntax', () => {
+ const view = buildView('{{contact.name}', {
+ variables: { 'contact.name': '{{agent.email}}' },
+ });
+
+ typeClosingBrace(view);
+
+ expect(view.state.doc.textContent).toBe('{{contact.name}}');
+ view.destroy();
+ });
+
+ it('does not resolve inside a private note', () => {
+ const view = buildView('{{contact.name}', {
+ isPrivate: true,
+ variables: { 'contact.name': 'John' },
+ });
+
+ typeClosingBrace(view);
+
+ expect(view.state.doc.textContent).toBe('{{contact.name}}');
+ view.destroy();
+ });
+});
diff --git a/package.json b/package.json
index 917a1b97d..d964a30a4 100644
--- a/package.json
+++ b/package.json
@@ -87,6 +87,7 @@
"opus-recorder": "^8.0.5",
"pinia": "^3.0.4",
"prosemirror-commands": "^1.7.1",
+ "prosemirror-inputrules": "^1.4.0",
"prosemirror-schema-list": "^1.5.1",
"qrcode": "^1.5.4",
"semver": "7.6.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 80fbf318a..0ffb85c18 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -183,6 +183,9 @@ importers:
prosemirror-commands:
specifier: ^1.7.1
version: 1.7.1
+ prosemirror-inputrules:
+ specifier: ^1.4.0
+ version: 1.4.0
prosemirror-schema-list:
specifier: ^1.5.1
version: 1.5.1
@@ -9037,7 +9040,7 @@ snapshots:
prosemirror-state@1.4.3:
dependencies:
prosemirror-model: 1.22.3
- prosemirror-transform: 1.10.0
+ prosemirror-transform: 1.12.0
prosemirror-view: 1.34.1
prosemirror-tables@1.5.0:
@@ -9065,7 +9068,7 @@ snapshots:
dependencies:
prosemirror-model: 1.22.3
prosemirror-state: 1.4.3
- prosemirror-transform: 1.10.0
+ prosemirror-transform: 1.12.0
proto-list@1.2.4: {}
From 98154bbeab2f5ea888dcb61bfd3a35a109ebb9a0 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Fri, 10 Jul 2026 16:22:49 +0400
Subject: [PATCH 4/8] fix(meta): show restriction alerts for inbox setup
(#14974)
Instagram inbox creation and WhatsApp embedded signup on Chatwoot Cloud
now reflect the temporary Meta restriction. Instagram is hidden from
onboarding on Cloud, while the regular Instagram inbox creation page
shows a disabled action with a status-linked amber warning. WhatsApp
embedded signup on Cloud stays visible with its connect action disabled.
WhatsApp Call setup always uses the manual WhatsApp form.
Existing Instagram conversations and Instagram inbox settings on Cloud
also show amber warning banners with the public incident link.
Self-hosted installations keep their existing Instagram, WhatsApp, and
WhatsApp Call setup behavior because the temporary restriction is based
only on the Chatwoot Cloud environment check.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
---
.../components/widgets/ChannelItem.vue | 6 +--
.../widgets/conversation/MessagesView.vue | 21 ++++++++-
app/javascript/dashboard/constants/globals.js | 7 +--
.../i18n/locale/en/conversation.json | 2 +
.../dashboard/i18n/locale/en/inboxMgmt.json | 7 ++-
.../inbox-setup/useChannelConfig.js | 7 ++-
.../inbox-setup/useChannelConnect.js | 7 +++
.../inbox-setup/useDetectedChannels.spec.js | 36 +++++++++++----
.../dashboard/settings/inbox/Settings.vue | 34 ++++++++++++++
.../settings/inbox/channels/CloudWhatsapp.vue | 1 +
.../settings/inbox/channels/Instagram.vue | 39 ++++++++++++++--
.../settings/inbox/channels/Whatsapp.vue | 15 +++++--
.../settings/inbox/channels/WhatsappCall.vue | 17 +------
.../inbox/channels/WhatsappEmbeddedSignup.vue | 45 ++++++++++++++++++-
14 files changed, 196 insertions(+), 48 deletions(-)
diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue
index 7ed2505c1..e055c2d9e 100644
--- a/app/javascript/dashboard/components/widgets/ChannelItem.vue
+++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue
@@ -1,7 +1,6 @@
Date: Mon, 13 Jul 2026 12:59:36 +0530
Subject: [PATCH 5/8] feat(captain): expand assistant description limit
(#14985)
# Pull Request Template
## Description
Increases description for Captain.
Why?
We are planning to include business context in description and 255 char
limit on the column and 200 char limit on the UI are very limiting to
get proper context.
## Type of change
Improvement to accommodate business context
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally
## 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
---
.../captain/assistant/AddNewScenariosDialog.vue | 1 +
.../components-next/captain/assistant/ScenariosCard.vue | 1 +
.../captain/pageComponents/assistant/AssistantForm.vue | 1 +
.../assistant/settings/AssistantBasicSettingsForm.vue | 1 +
...00000_change_captain_assistant_description_to_text.rb | 9 +++++++++
db/schema.rb | 4 ++--
enterprise/app/models/captain/assistant.rb | 6 ++++--
enterprise/app/models/captain/scenario.rb | 4 +++-
.../captain/onboarding/website_analyzer_service.rb | 2 +-
9 files changed, 23 insertions(+), 6 deletions(-)
create mode 100644 db/migrate/20260710000000_change_captain_assistant_description_to_text.rb
diff --git a/app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue b/app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue
index 89f115a64..08d79d27c 100644
--- a/app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue
+++ b/app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue
@@ -107,6 +107,7 @@ const onClickCancel = () => {