From 42cbf7d3b935042c275798ba68c9c242b80d459b Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:07:00 +0530 Subject: [PATCH 1/6] fix: stray backslash after hard breaks before formatted list items (#15112) --- .../widgets/WootWriter/FullEditor.vue | 4 +-- .../dashboard/helper/editorHelper.js | 3 +- package.json | 5 +--- pnpm-lock.yaml | 28 ++++--------------- 4 files changed, 11 insertions(+), 29 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 1c7787de6..1f3c03a2e 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue @@ -8,6 +8,8 @@ import { EditorState, Selection, imageResizeView, + toggleMark, + wrapInList, } from '@chatwoot/prosemirror-schema'; import { suggestionsPlugin, @@ -17,8 +19,6 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image'; import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview'; import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph'; import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds'; -import { toggleMark } from 'prosemirror-commands'; -import { wrapInList } from 'prosemirror-schema-list'; import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { isEscape } from 'shared/helpers/KeyboardHelpers'; diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index 2d3c75777..3d342f06e 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -1,4 +1,6 @@ import { + InputRule, + inputRules, MessageMarkdownSerializer, MessageMarkdownTransformer, messageSchema, @@ -9,7 +11,6 @@ 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. diff --git a/package.json b/package.json index c699787f2..5c3ac2229 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.22", + "@chatwoot/prosemirror-schema": "1.3.23", "@chatwoot/utils": "^0.0.56", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", @@ -86,9 +86,6 @@ "mitt": "^3.0.1", "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", "snakecase-keys": "^8.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40f84477a..87c892d19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.22 - version: 1.3.22 + specifier: 1.3.23 + version: 1.3.23 '@chatwoot/utils': specifier: ^0.0.56 version: 0.0.56 @@ -180,15 +180,6 @@ importers: pinia: specifier: ^3.0.4 version: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)) - 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 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -461,8 +452,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.22': - resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==} + '@chatwoot/prosemirror-schema@1.3.23': + resolution: {integrity: sha512-jGxbWELCdlVI64BJiE1wT84ekJHYDXXKiluQIKT3aKPEjPwMR48umKF3A0yHjKoR7IIxCC9oM77TvXOA0ebLtw==} '@chatwoot/utils@0.0.56': resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==} @@ -4001,9 +3992,6 @@ packages: prosemirror-tables@1.5.0: resolution: {integrity: sha512-VMx4zlYWm7aBlZ5xtfJHpqa3Xgu3b7srV54fXYnXgsAcIGRqKSrhiK3f89omzzgaAgAtDOV4ImXnLKhVfheVNQ==} - prosemirror-transform@1.10.0: - resolution: {integrity: sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==} - prosemirror-transform@1.12.0: resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} @@ -5136,7 +5124,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.22': + '@chatwoot/prosemirror-schema@1.3.23': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.7.1 @@ -9035,7 +9023,7 @@ snapshots: dependencies: prosemirror-model: 1.22.3 prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 prosemirror-state@1.4.3: dependencies: @@ -9051,10 +9039,6 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.34.1 - prosemirror-transform@1.10.0: - dependencies: - prosemirror-model: 1.22.3 - prosemirror-transform@1.12.0: dependencies: prosemirror-model: 1.22.3 From ddb0535a93f032ef8ea1b215a7ee1b11763340b0 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 22 Jul 2026 22:03:25 +0530 Subject: [PATCH 2/6] perf: reuse resolved count for reopen rate (#15122) This improves the Captain overview by loading reporting metrics and FAQ stats from separate endpoints. Range changes now refresh only the metrics, while reopen-rate calculation reuses the resolved conversation count to avoid redundant database queries. ## What changed - Split Captain overview metrics and FAQ stats into separate APIs. - Fetch FAQ stats independently from range-based metrics. - Reuse resolved conversation totals when calculating reopen rate. - Skip the reopen query when there are no resolved conversations. --- .../dashboard/api/captain/assistant.js | 11 ++- .../captain/assistants/overview/Index.vue | 90 +++++++++++++------ config/routes.rb | 3 +- .../captain/assistant_stats_builder.rb | 45 +++++----- .../accounts/captain/assistants_controller.rb | 8 +- .../app/policies/captain/assistant_policy.rb | 6 +- .../captain/assistant_stats_builder_spec.rb | 8 +- .../policies/captain/assistant_policy_spec.rb | 2 +- 8 files changed, 112 insertions(+), 61 deletions(-) diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js index 5af6110ab..806b45bb2 100644 --- a/app/javascript/dashboard/api/captain/assistant.js +++ b/app/javascript/dashboard/api/captain/assistant.js @@ -26,13 +26,20 @@ class CaptainAssistant extends ApiClient { }); } - getStats({ assistantId, range, signal }) { + getMetrics({ assistantId, range, signal }) { const requestConfig = { params: { range, timezone_offset: getTimezoneOffset() }, }; if (signal) requestConfig.signal = signal; - return axios.get(`${this.url}/${assistantId}/stats`, requestConfig); + return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig); + } + + getFaqStats({ assistantId, signal }) { + const requestConfig = {}; + if (signal) requestConfig.signal = signal; + + return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig); } getSummary({ assistantId, range, stats }) { 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 ba31bc05d..124b7c924 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue @@ -26,25 +26,28 @@ const canDrilldown = computed(() => checkPermissions(['administrator'])); const selectedRange = ref('this_month'); const assistantId = computed(() => route.params.assistantId); -const stats = ref(null); -const isFetching = ref(false); +const metricStats = ref(null); +const faqStats = ref(null); +const isFetchingMetrics = ref(false); // Increments on every fetch so a response (or retry) from a superseded // range/assistant can't clobber the latest request's state. -let fetchToken = 0; -let abortController = null; +let metricsFetchToken = 0; +let faqStatsFetchToken = 0; +let metricsAbortController = null; +let faqStatsAbortController = null; -const fetchStats = async () => { - fetchToken += 1; - const token = fetchToken; - abortController?.abort(); - abortController = new AbortController(); - const { signal } = abortController; - stats.value = null; - isFetching.value = true; +const fetchMetrics = async () => { + metricsFetchToken += 1; + const token = metricsFetchToken; + metricsAbortController?.abort(); + metricsAbortController = new AbortController(); + const { signal } = metricsAbortController; + metricStats.value = null; + isFetchingMetrics.value = true; - const requestStats = () => - CaptainAssistant.getStats({ + const requestMetrics = () => + CaptainAssistant.getMetrics({ assistantId: assistantId.value, range: selectedRange.value, signal, @@ -52,25 +55,54 @@ const fetchStats = async () => { let data = null; try { - ({ data } = await requestStats()); + ({ data } = await requestMetrics()); } catch { // One silent retry before giving up, unless the request was aborted. try { - if (token === fetchToken && !signal.aborted) - ({ data } = await requestStats()); + if (token === metricsFetchToken && !signal.aborted) + ({ data } = await requestMetrics()); } catch { data = null; } } - if (token !== fetchToken || signal.aborted) return; - stats.value = data; - isFetching.value = false; + if (token !== metricsFetchToken || signal.aborted) return; + metricStats.value = data; + isFetchingMetrics.value = false; }; -onUnmounted(() => abortController?.abort()); +const fetchFaqStats = async () => { + faqStatsFetchToken += 1; + const token = faqStatsFetchToken; + faqStatsAbortController?.abort(); + faqStatsAbortController = new AbortController(); + const { signal } = faqStatsAbortController; + faqStats.value = null; -watch([selectedRange, assistantId], fetchStats, { immediate: true }); + try { + const { data } = await CaptainAssistant.getFaqStats({ + assistantId: assistantId.value, + signal, + }); + if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data; + } catch { + if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null; + } +}; + +const summaryStats = computed(() => { + if (!metricStats.value || !faqStats.value) return null; + + return { ...metricStats.value, knowledge: faqStats.value }; +}); + +onUnmounted(() => { + metricsAbortController?.abort(); + faqStatsAbortController?.abort(); +}); + +watch([selectedRange, assistantId], fetchMetrics, { immediate: true }); +watch(assistantId, fetchFaqStats, { immediate: true }); // `direction` says whether a rising trend is good ('up'), bad ('down'), or // neutral, so we can colour the delta independently of its sign. @@ -90,7 +122,7 @@ const formatDuration = hours => hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`; const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => { - const data = stats.value?.[statKey]; + const data = metricStats.value?.[statKey]; if (!data) return { value: '—', trend: '', trendGood: null }; const sign = data.trend > 0 ? '+' : ''; @@ -184,9 +216,9 @@ const closeDrilldown = () => {
- + - +
{ :trend="metric.trend" :hint="metric.hint" :trend-good="metric.trendGood" - :loading="isFetching" - :clickable="canDrilldown && Boolean(metric.metric) && !isFetching" + :loading="isFetchingMetrics" + :clickable=" + canDrilldown && Boolean(metric.metric) && !isFetchingMetrics + " @click="openDrilldown(metric)" />
- +
diff --git a/config/routes.rb b/config/routes.rb index 0bf6b40c8..7314dbda5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,7 +66,8 @@ Rails.application.routes.draw do resources :assistants do member do post :playground - get :stats + get :metrics + get :faq_stats get :summary get :drilldown end diff --git a/enterprise/app/builders/captain/assistant_stats_builder.rb b/enterprise/app/builders/captain/assistant_stats_builder.rb index d162406ad..93f51448b 100644 --- a/enterprise/app/builders/captain/assistant_stats_builder.rb +++ b/enterprise/app/builders/captain/assistant_stats_builder.rb @@ -37,6 +37,23 @@ class Captain::AssistantStatsBuilder build_metrics(current, previous) end + # Approved/pending FAQ counts and the document total in a single round trip. + def faq_stats + approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick( + Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"), + Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"), + Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})") + ) + total = approved + pending + + { + approved: approved, + pending: pending, + documents: documents, + coverage: total.zero? ? 0 : (approved.to_f / total * 100).round + } + end + private attr_reader :window @@ -56,8 +73,7 @@ class Captain::AssistantStatsBuilder handoff_rate: pack(current[:handoff], previous[:handoff], :point), hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent), reopen_rate: pack(current[:reopen], previous[:reopen], :point), - conversation_depth: pack(current[:depth], previous[:depth], :absolute), - knowledge: knowledge + conversation_depth: pack(current[:depth], previous[:depth], :absolute) } end @@ -73,7 +89,7 @@ class Captain::AssistantStatsBuilder auto_resolution: rate(resolution[:resolved], handled), handoff: rate(resolution[:handoff], handled), hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round, - reopen: reopen_rate(range), + reopen: reopen_rate(range, resolution[:resolved]), depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1) } end @@ -158,7 +174,9 @@ class Captain::AssistantStatsBuilder # derived from the assistant's handled conversations (not current inbox membership) so a later # inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference) # and time-based (bot) resolve paths so the denominator matches auto_resolution_rate. - def reopen_rate(range) + def reopen_rate(range, resolved_count) + return 0 if resolved_count.zero? + resolved_scope = account.reporting_events .where(name: RESOLVED_EVENT_NAMES, created_at: range, conversation_id: handled_scope(range).select(:conversation_id)) @@ -178,24 +196,7 @@ class Captain::AssistantStatsBuilder 'ON resolves.conversation_id = reporting_events.conversation_id ' \ 'AND reporting_events.event_end_time >= resolves.event_end_time') .distinct.count('reporting_events.conversation_id') - rate(reopened, resolved_scope.distinct.count(:conversation_id)) - end - - # Approved/pending FAQ counts and the document total in a single round trip. - def knowledge - approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick( - Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"), - Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"), - Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})") - ) - total = approved + pending - - { - approved: approved, - pending: pending, - documents: documents, - coverage: total.zero? ? 0 : (approved.to_f / total * 100).round - } + rate(reopened, resolved_count) end def rate(numerator, denominator) diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb index 1b7d77289..f1509f11c 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb @@ -1,7 +1,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController before_action -> { check_authorization(Captain::Assistant) } - before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown] + before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown] def index @assistants = account_assistants.ordered @@ -42,10 +42,14 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base @tools = assistant.available_agent_tools end - def stats + def metrics render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics end + def faq_stats + render json: Captain::AssistantStatsBuilder.new(@assistant).faq_stats + end + def summary window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset]) result = cached_or_generated_summary(window, summary_stats) diff --git a/enterprise/app/policies/captain/assistant_policy.rb b/enterprise/app/policies/captain/assistant_policy.rb index 573c0400c..fdcb2db89 100644 --- a/enterprise/app/policies/captain/assistant_policy.rb +++ b/enterprise/app/policies/captain/assistant_policy.rb @@ -7,7 +7,11 @@ class Captain::AssistantPolicy < ApplicationPolicy true end - def stats? + def metrics? + true + end + + def faq_stats? true end diff --git a/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb index 6575c9856..c495244c4 100644 --- a/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb +++ b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb @@ -27,7 +27,7 @@ RSpec.describe Captain::AssistantStatsBuilder do expect(metrics.keys).to contain_exactly( :conversations_handled, :auto_resolution_rate, :handoff_rate, - :hours_saved, :reopen_rate, :conversation_depth, :knowledge + :hours_saved, :reopen_rate, :conversation_depth ) expect(metrics[:conversations_handled]).to include(:current, :previous, :trend) end @@ -229,7 +229,7 @@ RSpec.describe Captain::AssistantStatsBuilder do end end - describe '#metrics knowledge' do + describe '#faq_stats' do before do create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved) create(:captain_assistant_response, assistant: assistant, account: account, status: :pending) @@ -237,7 +237,7 @@ RSpec.describe Captain::AssistantStatsBuilder do end it 'returns approved, pending, document counts and coverage' do - knowledge = described_class.new(assistant, '30').metrics[:knowledge] + knowledge = described_class.new(assistant).faq_stats expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75) end @@ -245,7 +245,7 @@ RSpec.describe Captain::AssistantStatsBuilder do it 'reports zero coverage when there are no responses' do Captain::AssistantResponse.where(assistant: assistant).delete_all - knowledge = described_class.new(assistant, '30').metrics[:knowledge] + knowledge = described_class.new(assistant).faq_stats expect(knowledge[:coverage]).to eq(0) end diff --git a/spec/enterprise/policies/captain/assistant_policy_spec.rb b/spec/enterprise/policies/captain/assistant_policy_spec.rb index e04b680e4..b53d73a5e 100644 --- a/spec/enterprise/policies/captain/assistant_policy_spec.rb +++ b/spec/enterprise/policies/captain/assistant_policy_spec.rb @@ -12,7 +12,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } } let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } } - permissions :index?, :show?, :playground? do + permissions :index?, :show?, :playground?, :metrics?, :faq_stats? do context 'when administrator' do it { expect(assistant_policy).to permit(administrator_context, assistant) } end From 34ad78b1220d45ba7ec391e7de3a67f8aff95749 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 23 Jul 2026 11:26:21 +0400 Subject: [PATCH 3/6] fix(instagram): remove resolved restriction banners (#15136) --- .../widgets/conversation/MessagesView.vue | 22 ++---------- app/javascript/dashboard/constants/globals.js | 2 -- .../i18n/locale/en/conversation.json | 2 -- .../dashboard/i18n/locale/en/inboxMgmt.json | 4 +-- .../dashboard/settings/inbox/Settings.vue | 34 ------------------- 5 files changed, 3 insertions(+), 61 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index 6cc2747da..b013a47b9 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -33,9 +33,7 @@ import { // constants import { BUS_EVENTS } from 'shared/constants/busEvents'; import { REPLY_POLICY } from 'shared/constants/links'; -import wootConstants, { - META_RESTRICTION_STATUS_URL, -} from 'dashboard/constants/globals'; +import wootConstants from 'dashboard/constants/globals'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { INBOX_TYPES } from 'dashboard/helper/inbox'; @@ -95,7 +93,6 @@ export default { currentUserId: 'getCurrentUserID', listLoadingStatus: 'getAllMessagesLoaded', currentAccountId: 'getCurrentAccountId', - isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', }), isOpen() { return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN; @@ -173,13 +170,6 @@ export default { instagramInbox ); }, - isInstagramRestrictionBannerVisible() { - return this.isOnChatwootCloud && this.isAnInstagramChannel; - }, - instagramRestrictionStatusUrl() { - return META_RESTRICTION_STATUS_URL; - }, - replyWindowBannerMessage() { if (this.isAWhatsAppChannel) { return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY'); @@ -464,15 +454,7 @@ export default { >
- - -
- - - {{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }} - - {{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }} - - -
-
Date: Thu, 23 Jul 2026 13:57:15 +0530 Subject: [PATCH 4/6] Bump version to 4.16.1 --- VERSION_CW | 2 +- config/app.yml | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION_CW b/VERSION_CW index ecbc3b030..27593c841 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.16.0 +4.16.1 diff --git a/config/app.yml b/config/app.yml index 085af6efb..ffb681f61 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.16.0' + version: '4.16.1' development: <<: *shared diff --git a/package.json b/package.json index 5c3ac2229..15b0272ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.16.0", + "version": "4.16.1", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", From bae20ca83e19952536c9b32e1c6fd7f995ea931a Mon Sep 17 00:00:00 2001 From: Nicky Duijf <47255128+NickThePigeon@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:40:46 +0200 Subject: [PATCH 5/6] feat(conversation): add label search to right-click context menu (#15084) --- .../dashboard/components/ui/ContextMenu.vue | 11 ++- .../conversation/contextMenu/Index.vue | 72 +++++++++++++++---- .../conversation/contextMenu/menuItem.vue | 4 +- .../i18n/locale/en/conversation.json | 2 + 4 files changed, 71 insertions(+), 18 deletions(-) diff --git a/app/javascript/dashboard/components/ui/ContextMenu.vue b/app/javascript/dashboard/components/ui/ContextMenu.vue index 0773e4fd2..9ce6866cb 100644 --- a/app/javascript/dashboard/components/ui/ContextMenu.vue +++ b/app/javascript/dashboard/components/ui/ContextMenu.vue @@ -77,6 +77,15 @@ const handleClose = () => { emit('close'); }; +const handleFocusOut = event => { + // Keep the menu open while focus stays inside it (e.g. the label search + // input); close it once focus leaves the menu entirely. + if (menuRef.value?.contains(event.relatedTarget)) { + return; + } + handleClose(); +}; + onUnmounted(() => { isLocked.value = false; }); @@ -89,7 +98,7 @@ onUnmounted(() => { class="fixed outline-none z-[9999] cursor-pointer" :style="position" tabindex="0" - @blur="handleClose" + @focusout="handleFocusOut" >
diff --git a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue index 34009935c..351ca6d2c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue +++ b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue @@ -7,10 +7,13 @@ import { getSortedAgentsByAvailability, getAgentsByUpdatedPresence, } from 'dashboard/helper/agentHelper.js'; +import { picoSearch } from '@scmmishra/pico-search'; import MenuItem from './menuItem.vue'; import MenuItemWithSubmenu from './menuItemWithSubmenu.vue'; import wootConstants from 'dashboard/constants/globals'; import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue'; +import NextInput from 'dashboard/components-next/input/Input.vue'; +import Icon from 'dashboard/components-next/icon/Icon.vue'; const MENU = { MARK_AS_READ: 'mark-as-read', @@ -31,6 +34,8 @@ export default { MenuItem, MenuItemWithSubmenu, AgentLoadingPlaceholder, + NextInput, + Icon, }, props: { chatId: { @@ -87,6 +92,7 @@ export default { data() { return { MENU, + labelSearchQuery: '', STATUS_TYPE: wootConstants.STATUS_TYPE, readOption: { label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'), @@ -216,6 +222,14 @@ export default { // Don't show snooze if the conversation is already snoozed/resolved/pending return this.status === wootConstants.STATUS_TYPE.OPEN; }, + filteredLabels() { + const labels = this.labelSearchQuery + ? picoSearch(this.labels, this.labelSearchQuery, ['title']) + : this.labels; + // Assigned labels first, keeping each group's existing order. + const isAssigned = label => this.conversationLabels.includes(label.title); + return [...labels].sort((a, b) => isAssigned(b) - isAssigned(a)); + }, }, mounted() { this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]); @@ -335,21 +349,49 @@ export default { :option="labelMenuConfig" :sub-menu-available="!!labels.length" > - +
+ + + +
+
+ +

+ {{ $t('CONVERSATION.CARD_CONTEXT_MENU.NO_LABELS_FOUND') }} +

+
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 9915b26ef..62510ddd0 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -193,6 +193,8 @@ }, "ASSIGN_AGENT": "Assign agent", "ASSIGN_LABEL": "Assign label", + "SEARCH_LABELS": "Search labels", + "NO_LABELS_FOUND": "No labels found", "AGENTS_LOADING": "Loading agents...", "ASSIGN_TEAM": "Assign team", "DELETE": "Delete conversation", From a98666030ba2e228ff2638ef5aa0ce89e7bb7008 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:45:56 +0530 Subject: [PATCH 6/6] chore: Calls page UI improvements (#15129) --- .../AssignmentPolicyCard.vue | 7 ++- .../components-next/Calls/CallListItem.vue | 20 +++--- .../components-next/Calls/CallsFilterBar.vue | 42 +++++++++---- .../Campaigns/CampaignCard/CampaignCard.vue | 8 ++- .../ConversationCard/ConversationCard.vue | 4 +- .../components-next/Inbox/InboxCard.vue | 4 +- .../helpers/composeConversationHelper.js | 4 +- .../specs/composeConversationHelper.spec.js | 14 +++++ .../components-next/audio/AudioPlayer.vue | 8 +-- .../captain/assistant/InboxCard.vue | 8 ++- .../components-next/icon/ChannelIcon.vue | 10 +-- .../components-next/icon/provider.js | 20 +++--- .../icon/specs/provider.spec.js | 23 ++++++- .../components-next/message/Message.vue | 5 +- app/javascript/dashboard/helper/inbox.js | 28 ++++++++- .../dashboard/helper/specs/inbox.spec.js | 62 +++++++++++++++++++ .../SearchResultConversationItem.vue | 4 +- .../components/SearchResultMessageItem.vue | 4 +- .../dashboard/calls/pages/CallsIndex.vue | 24 +++---- .../pages/AgentAssignmentEditPage.vue | 16 ++--- .../pages/AgentCapacityEditPage.vue | 24 ++++--- .../pages/components/InboxLinkDialog.vue | 3 +- .../dashboard/settings/inbox/Settings.vue | 8 ++- enterprise/app/finders/call_finder.rb | 2 +- .../views/api/v1/models/_call.json.jbuilder | 2 + .../api/v1/accounts/calls_controller_spec.rb | 1 + theme/icons.js | 20 ++++++ 27 files changed, 283 insertions(+), 92 deletions(-) diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue index 45311ece6..e015f682a 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue @@ -28,7 +28,12 @@ const inboxes = computed(() => { return { name: inbox.name, id: inbox.id, - icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'), + icon: getInboxIconByType( + inbox.channelType, + inbox.medium, + 'line', + inbox.voiceEnabled + ), }; }); }); diff --git a/app/javascript/dashboard/components-next/Calls/CallListItem.vue b/app/javascript/dashboard/components-next/Calls/CallListItem.vue index dd2b3af37..0a54cab65 100644 --- a/app/javascript/dashboard/components-next/Calls/CallListItem.vue +++ b/app/javascript/dashboard/components-next/Calls/CallListItem.vue @@ -3,6 +3,7 @@ import { computed } from 'vue'; import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; import { relativeDayTimestamp } from 'shared/helpers/timeHelper'; +import { getInboxVoiceIcon } from 'dashboard/helper/inbox'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue'; @@ -60,7 +61,7 @@ const resultLabel = computed(() => { }); const providerIcon = computed(() => - props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone' + getInboxVoiceIcon(props.call.inbox.channelType, props.call.inbox.medium) ); const createdAtLabel = computed(() => @@ -150,7 +151,7 @@ const conversationRoute = computed(() => ({