diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb
index de3d10081..3f0309024 100644
--- a/app/controllers/api/v1/accounts/agent_bots_controller.rb
+++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]
diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
index 482b001d6..3b7fafad5 100644
--- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb
+++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
diff --git a/app/controllers/api/v1/accounts/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb
index 6889d30a4..f678fee42 100644
--- a/app/controllers/api/v1/accounts/labels_controller.rb
+++ b/app/controllers/api/v1/accounts/labels_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action :fetch_label, except: [:index, :create]
before_action :check_authorization
diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
index 580ae77c6..46d89a1ba 100644
--- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
+++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
+ before_action :ensure_embedded_signup_enabled
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
@@ -18,6 +19,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
private
+ def ensure_embedded_signup_enabled
+ return unless ChatwootApp.chatwoot_cloud?
+ return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
+
+ raise Pundit::NotAuthorizedError
+ end
+
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
@@ -44,8 +52,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
def can_reconfigure_channel?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
-
- # Reconfiguring a live embedded-signup channel requires the feature flag.
+ return true if ChatwootApp.chatwoot_cloud?
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
diff --git a/app/javascript/dashboard/api/captain/agentSessions.js b/app/javascript/dashboard/api/captain/agentSessions.js
new file mode 100644
index 000000000..a557b5938
--- /dev/null
+++ b/app/javascript/dashboard/api/captain/agentSessions.js
@@ -0,0 +1,9 @@
+import ApiClient from '../ApiClient';
+
+class CaptainAgentSessions extends ApiClient {
+ constructor() {
+ super('captain/agent_sessions', { accountScoped: true });
+ }
+}
+
+export default new CaptainAgentSessions();
diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js
index 1fc17798d..5af6110ab 100644
--- a/app/javascript/dashboard/api/captain/assistant.js
+++ b/app/javascript/dashboard/api/captain/assistant.js
@@ -26,15 +26,18 @@ class CaptainAssistant extends ApiClient {
});
}
- getStats({ assistantId, range }) {
- return axios.get(`${this.url}/${assistantId}/stats`, {
+ getStats({ assistantId, range, signal }) {
+ const requestConfig = {
params: { range, timezone_offset: getTimezoneOffset() },
- });
+ };
+ if (signal) requestConfig.signal = signal;
+
+ return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
}
- getSummary({ assistantId, range }) {
+ getSummary({ assistantId, range, stats }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
- params: { range, timezone_offset: getTimezoneOffset() },
+ params: { range, timezone_offset: getTimezoneOffset(), stats },
});
}
diff --git a/app/javascript/dashboard/components-next/Calls/CallListItem.vue b/app/javascript/dashboard/components-next/Calls/CallListItem.vue
index 8b0a56bfd..dd2b3af37 100644
--- a/app/javascript/dashboard/components-next/Calls/CallListItem.vue
+++ b/app/javascript/dashboard/components-next/Calls/CallListItem.vue
@@ -25,8 +25,11 @@ const route = useRoute();
const kind = computed(() => getCallKind(props.call));
-const contactName = computed(
- () => props.call.contact.name || props.call.contact.phoneNumber
+const contactName = computed(() =>
+ (props.call.contact.name || props.call.contact.phoneNumber || '').replace(
+ /^\+/,
+ ''
+ )
);
const agentActionLabel = computed(() => {
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
index cf66a0a2f..9a68b71ce 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
@@ -9,6 +9,7 @@ const props = defineProps({
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false },
+ loading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -45,7 +46,11 @@ const onActivate = () => {
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/>
-
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
index df336a7e4..5e868956f 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
@@ -9,6 +9,10 @@ const props = defineProps({
type: String,
default: '30',
},
+ stats: {
+ type: Object,
+ default: null,
+ },
});
const route = useRoute();
@@ -20,22 +24,41 @@ const assistantId = computed(() => route.params.assistantId);
const welcomeMarkdown = ref('');
const isLoading = ref(false);
+// Increments on every fetch so a slow response for a superseded
+// range/stats/assistant can't overwrite the latest request's state.
+let fetchToken = 0;
+
const fetchSummary = async () => {
+ fetchToken += 1;
+ const token = fetchToken;
+
+ if (!props.stats) {
+ welcomeMarkdown.value = '';
+ isLoading.value = false;
+ return;
+ }
+
isLoading.value = true;
+ let message = '';
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
+ stats: props.stats,
});
- welcomeMarkdown.value = data.message ?? '';
+ message = data.message ?? '';
} catch {
- welcomeMarkdown.value = '';
- } finally {
- isLoading.value = false;
+ message = '';
}
+
+ if (token !== fetchToken) return;
+ welcomeMarkdown.value = message;
+ isLoading.value = false;
};
-watch([() => props.range, assistantId], fetchSummary, { immediate: true });
+watch([() => props.range, () => props.stats, assistantId], fetchSummary, {
+ immediate: true,
+});
// Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It
diff --git a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue
index 4d526d558..1390ea0f7 100644
--- a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue
+++ b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue
@@ -3,8 +3,13 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
-import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
-import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
+import {
+ isTwilioComplete,
+ isTwilioMediaTemplate,
+ getTwilioMediaVariableKey,
+ getTwilioMediaUrl,
+ applyTwilioMediaFilename,
+} from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -40,30 +45,23 @@ const templateBody = computed(() => {
return props.template.body || '';
});
-const hasMediaTemplate = computed(() => {
- return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
-});
+// Media-template detection and variable extraction are shared with the mobile
+// app via @chatwoot/utils.
+const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template));
const hasVariables = computed(() => {
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
});
-const mediaVariableKey = computed(() => {
- if (!hasMediaTemplate.value) return null;
- const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
- if (!mediaUrl) return null;
- return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
-});
+const mediaVariableKey = computed(() =>
+ getTwilioMediaVariableKey(props.template)
+);
-const hasMediaVariable = computed(() => {
- return hasMediaTemplate.value && mediaVariableKey.value !== null;
-});
+const hasMediaVariable = computed(() => mediaVariableKey.value !== null);
-const templateMediaUrl = computed(() => {
- if (!hasMediaTemplate.value) return '';
-
- return props.template?.types?.['twilio/media']?.media?.[0] || '';
-});
+const templateMediaUrl = computed(() =>
+ hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : ''
+);
const variablePattern = computed(() => {
if (!hasVariables.value) return [];
@@ -83,26 +81,10 @@ const renderedTemplate = computed(() => {
return rendered;
});
-const isFormInvalid = computed(() => {
- if (!hasVariables.value && !hasMediaVariable.value) return false;
-
- if (hasVariables.value) {
- const hasEmptyVariable = variablePattern.value.some(
- variable => !processedParams.value[variable]
- );
- if (hasEmptyVariable) return true;
- }
-
- if (
- hasMediaVariable.value &&
- mediaVariableKey.value &&
- !processedParams.value[mediaVariableKey.value]
- ) {
- return true;
- }
-
- return false;
-});
+// Completeness validation is shared with the mobile app via @chatwoot/utils.
+const isFormInvalid = computed(
+ () => !isTwilioComplete(props.template, processedParams.value)
+);
const v$ = useVuelidate(
{
@@ -135,19 +117,11 @@ const sendMessage = () => {
const { friendly_name, language } = props.template;
- // Process parameters and extract filename from media URL if needed
- const processedParameters = { ...processedParams.value };
-
- // For media templates, extract filename from full URL
- if (
- hasMediaVariable.value &&
- mediaVariableKey.value &&
- processedParameters[mediaVariableKey.value]
- ) {
- processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
- processedParameters[mediaVariableKey.value]
- );
- }
+ // For media templates, reduce the media URL to a filename before sending.
+ const processedParameters = applyTwilioMediaFilename(
+ props.template,
+ processedParams.value
+ );
const payload = {
message: renderedTemplate.value,
diff --git a/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue b/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue
new file mode 100644
index 000000000..752616109
--- /dev/null
+++ b/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue
@@ -0,0 +1,342 @@
+
+
+
+
+
+
+
+
+
+ {{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }}
+
+
+ {{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }}
+
+
+
+
+ {{ t('CONVERSATION.CAPTAIN_GENERATION.TIMELINE') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ step.name }}
+
+
+
+
+ {{ step.detail }}
+
+
+
+
+
+
+
+
+ {{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }}
+
+
+ {{
+ t(
+ 'CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY',
+ citations.length
+ )
+ }}
+
+
+
+
+
+
+ {{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }}
+
+
+ {{ reasoning }}
+
+
+
+ {{ devDetails }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/message/bubbles/Base.vue b/app/javascript/dashboard/components-next/message/bubbles/Base.vue
index 457b583ea..e799bd755 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/Base.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/Base.vue
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import MessageMeta from '../MessageMeta.vue';
+import CaptainGenerationDetails from '../CaptainGenerationDetails.vue';
import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
@@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
-import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
+import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants';
const props = defineProps({
hideMeta: { type: Boolean, default: false },
});
-const { variant, orientation, inReplyTo, shouldGroupWithNext } =
- useMessageContext();
+const {
+ variant,
+ orientation,
+ inReplyTo,
+ shouldGroupWithNext,
+ id,
+ sender,
+ senderType,
+} = useMessageContext();
const { t } = useI18n();
+const isCaptainMessage = computed(
+ () =>
+ (sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT
+);
+
+const metaColorClass = computed(() =>
+ variant.value === MESSAGE_VARIANTS.PRIVATE
+ ? 'text-n-amber-12/50'
+ : 'text-n-slate-11'
+);
+
+const emailMetaClass = computed(() =>
+ variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : ''
+);
+
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
[MESSAGE_VARIANTS.PRIVATE]:
@@ -114,16 +137,21 @@ const replyToPreview = computed(() => {
/>
-
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue
index 6df06642c..620955cb4 100644
--- a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue
+++ b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue
@@ -13,6 +13,7 @@ import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
+import { isWhatsAppComplete } from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue';
import {
buildTemplateParameters,
@@ -84,29 +85,10 @@ const renderedTemplate = computed(() => {
return replaceTemplateVariables(bodyText.value, processedParams.value);
});
-const isFormInvalid = computed(() => {
- if (!hasVariables.value && !hasMediaHeader.value) return false;
-
- if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
- return true;
- }
-
- if (hasVariables.value && processedParams.value.body) {
- const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
- value => !value
- );
- if (hasEmptyBodyVariable) return true;
- }
-
- if (processedParams.value.buttons) {
- const hasEmptyButtonParameter = processedParams.value.buttons.some(
- button => !button.parameter
- );
- if (hasEmptyButtonParameter) return true;
- }
-
- return false;
-});
+// Completeness validation is shared with the mobile app via @chatwoot/utils.
+const isFormInvalid = computed(
+ () => !isWhatsAppComplete(props.template, processedParams.value)
+);
const v$ = useVuelidate(
{
diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js
index e707284cb..04f46890e 100644
--- a/app/javascript/dashboard/featureFlags.js
+++ b/app/javascript/dashboard/featureFlags.js
@@ -7,8 +7,7 @@ export const FEATURE_FLAGS = {
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
- WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION:
- 'whatsapp_embedded_signup_inbox_creation',
+ WHATSAPP_EMBEDDED_SIGNUP_FLOW: 'whatsapp_embedded_signup_inbox_creation',
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure',
CANNED_RESPONSES: 'canned_responses',
diff --git a/app/javascript/dashboard/helper/URLHelper.js b/app/javascript/dashboard/helper/URLHelper.js
index 76a5d8bd4..8437e116f 100644
--- a/app/javascript/dashboard/helper/URLHelper.js
+++ b/app/javascript/dashboard/helper/URLHelper.js
@@ -127,25 +127,8 @@ export const getHostNameFromURL = url => {
}
};
-/**
- * Extracts filename from a URL
- * @param {string} url - The URL to extract filename from
- * @returns {string} - The extracted filename or original URL if extraction fails
- */
-export const extractFilenameFromUrl = url => {
- if (!url || typeof url !== 'string') return url;
-
- try {
- const urlObj = new URL(url);
- const pathname = urlObj.pathname;
- const filename = pathname.split('/').pop();
- return filename || url;
- } catch (error) {
- // If URL parsing fails, try to extract filename using regex
- const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
- return match ? match[1] : url;
- }
-};
+// Shared with the mobile app via @chatwoot/utils.
+export { extractFilenameFromUrl } from '@chatwoot/utils';
/**
* Normalizes a comma/newline separated list of domains
diff --git a/app/javascript/dashboard/helper/specs/templateHelper.spec.js b/app/javascript/dashboard/helper/specs/templateHelper.spec.js
index 375e38a2d..ba056c317 100644
--- a/app/javascript/dashboard/helper/specs/templateHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/templateHelper.spec.js
@@ -156,12 +156,18 @@ describe('templateHelper', () => {
]);
});
- it('should handle templates with no variables', () => {
+ it('should handle templates with no variables but a media header', () => {
const emptyTemplate = templates.find(
t => t.name === 'no_variable_template'
);
- const result = buildTemplateParameters(emptyTemplate, false);
- expect(result).toEqual({});
+ const result = buildTemplateParameters(emptyTemplate);
+ // hasMediaHeader is derived from the template, so the document header is kept.
+ expect(result.body).toBeUndefined();
+ expect(result.header).toEqual({
+ media_url: '',
+ media_type: 'document',
+ media_name: '',
+ });
});
it('should build parameters for templates with multiple component types', () => {
diff --git a/app/javascript/dashboard/helper/templateHelper.js b/app/javascript/dashboard/helper/templateHelper.js
index 1fb61d760..c875871f4 100644
--- a/app/javascript/dashboard/helper/templateHelper.js
+++ b/app/javascript/dashboard/helper/templateHelper.js
@@ -1,19 +1,16 @@
-// Constants
+import { processVariable, buildWhatsAppProcessedParams } from '@chatwoot/utils';
+
+// Constants and pure template helpers are shared with the mobile app via
+// @chatwoot/utils so the logic lives in one place.
+export {
+ MEDIA_FORMATS,
+ COMPONENT_TYPES,
+ findComponentByType,
+ processVariable,
+} from '@chatwoot/utils';
+
export const DEFAULT_LANGUAGE = 'en';
export const DEFAULT_CATEGORY = 'UTILITY';
-export const COMPONENT_TYPES = {
- HEADER: 'HEADER',
- BODY: 'BODY',
- BUTTONS: 'BUTTONS',
-};
-export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
-
-export const findComponentByType = (template, type) =>
- template.components?.find(component => component.type === type);
-
-export const processVariable = str => {
- return str.replace(/{{|}}/g, '');
-};
export const allKeysRequired = value => {
const keys = Object.keys(value);
@@ -27,70 +24,7 @@ export const replaceTemplateVariables = (templateText, processedParams) => {
});
};
-export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
- const allVariables = {};
-
- const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
- const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
-
- if (!bodyComponent) return allVariables;
-
- const templateString = bodyComponent.text;
-
- // Process body variables
- const matchedVariables = templateString.match(/{{([^}]+)}}/g);
- if (matchedVariables) {
- allVariables.body = {};
- matchedVariables.forEach(variable => {
- const key = processVariable(variable);
- allVariables.body[key] = '';
- });
- }
-
- if (hasMediaHeaderValue) {
- if (!allVariables.header) allVariables.header = {};
- allVariables.header.media_url = '';
- allVariables.header.media_type = headerComponent.format.toLowerCase();
-
- // For document templates, include media_name field for filename support
- if (headerComponent.format.toLowerCase() === 'document') {
- allVariables.header.media_name = '';
- }
- }
-
- // Process button variables
- const buttonComponents = template.components.filter(
- component => component.type === COMPONENT_TYPES.BUTTONS
- );
-
- buttonComponents.forEach(buttonComponent => {
- if (buttonComponent.buttons) {
- buttonComponent.buttons.forEach((button, index) => {
- // Handle URL buttons with variables
- if (button.type === 'URL' && button.url && button.url.includes('{{')) {
- const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
- if (buttonVars.length > 0) {
- if (!allVariables.buttons) allVariables.buttons = [];
- allVariables.buttons[index] = {
- type: 'url',
- parameter: '',
- url: button.url,
- variables: buttonVars.map(v => processVariable(v)),
- };
- }
- }
-
- // Handle copy code buttons
- if (button.type === 'COPY_CODE') {
- if (!allVariables.buttons) allVariables.buttons = [];
- allVariables.buttons[index] = {
- type: 'copy_code',
- parameter: '',
- };
- }
- });
- }
- });
-
- return allVariables;
-};
+// The media-header flag is derived from the template inside the shared helper;
+// the second argument is kept for backwards-compatible call sites.
+export const buildTemplateParameters = template =>
+ buildWhatsAppProcessedParams(template);
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index fe71b8974..f3c81615b 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -72,6 +72,20 @@
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
+ "CAPTAIN_GENERATION": {
+ "TITLE": "How was this reply generated?",
+ "GENERATED_BY": "Generated by Captain",
+ "LOADING": "Loading details…",
+ "EMPTY": "No generation details available for this message.",
+ "TIMELINE": "Generation steps",
+ "STEP_TOOL": "Called {name}",
+ "STEP_HANDOFF": "Handed off to {name}",
+ "REASONING": "Reasoning",
+ "SOURCES": "Knowledge base",
+ "SOURCES_SUMMARY": "{count} result | {count} results",
+ "MODEL": "Generated with {model}",
+ "CREDITS": "Credits: {credits}"
+ },
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels",
diff --git a/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue
index 80f8e08c5..553cbceac 100644
--- a/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue
+++ b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue
@@ -1,5 +1,6 @@
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
index 29411e56d..ba31bc05d 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
@@ -1,5 +1,5 @@