From bd732f1fa993fbb1de7299946e82032b8c677820 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:25:11 +0530 Subject: [PATCH 001/155] fix: search faqs in account language (#13428) # Pull Request Template ## Description Reply suggestions uses `search_documentation`. While this is useful, there is a subtle bug, a user's message may be in a different language (say spanish) than the FAQs present (english). This results in embedding search in spanish and compared against english vectors, which results in poor retrieval and poor suggestions. Fixes # (issue) This PR fixes the above behaviour by making a small llm call translate the query before searching in the search documentation tool ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## 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. before: image after: image test on rails console: image ## 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 - [x] Any dependent changes have been merged and published in downstream modules --- Gemfile | 2 + Gemfile.lock | 2 + .../captain/llm/translate_query_service.rb | 49 +++++++++++++++++++ .../tools/search_documentation_service.rb | 6 ++- .../search_reply_documentation_service.rb | 6 ++- lib/captain/tool_instrumentation.rb | 29 ++++++----- .../openai/openai_prompts/reply.liquid | 2 +- 7 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 enterprise/app/services/captain/llm/translate_query_service.rb diff --git a/Gemfile b/Gemfile index 1ae6cf093..2023c32b1 100644 --- a/Gemfile +++ b/Gemfile @@ -197,6 +197,8 @@ gem 'ai-agents', '>= 0.7.0' gem 'ruby_llm', '>= 1.8.2' gem 'ruby_llm-schema' +gem 'cld3', '~> 3.7' + # OpenTelemetry for LLM observability gem 'opentelemetry-sdk' gem 'opentelemetry-exporter-otlp' diff --git a/Gemfile.lock b/Gemfile.lock index b7b7301d3..ddac60fd7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -186,6 +186,7 @@ GEM byebug (11.1.3) childprocess (5.1.0) logger (~> 1.5) + cld3 (3.7.0) climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) @@ -1037,6 +1038,7 @@ DEPENDENCIES bullet bundle-audit byebug + cld3 (~> 3.7) climate_control commonmarker csv-safe diff --git a/enterprise/app/services/captain/llm/translate_query_service.rb b/enterprise/app/services/captain/llm/translate_query_service.rb new file mode 100644 index 000000000..404a44755 --- /dev/null +++ b/enterprise/app/services/captain/llm/translate_query_service.rb @@ -0,0 +1,49 @@ +class Captain::Llm::TranslateQueryService < Captain::BaseTaskService + MODEL = 'gpt-4.1-nano'.freeze + + pattr_initialize [:account!] + + def translate(query, target_language:) + return query if query_in_target_language?(query) + + messages = [ + { role: 'system', content: system_prompt(target_language) }, + { role: 'user', content: query } + ] + + response = make_api_call(model: MODEL, messages: messages) + return query if response[:error] + + response[:message].strip + rescue StandardError => e + Rails.logger.warn "TranslateQueryService failed: #{e.message}, falling back to original query" + query + end + + private + + def event_name + 'translate_query' + end + + def query_in_target_language?(query) + detector = CLD3::NNetLanguageIdentifier.new(0, 1000) + result = detector.find_language(query) + + result.reliable? && result.language == account_language_code + rescue StandardError + false + end + + def account_language_code + account.locale&.split('_')&.first + end + + def system_prompt(target_language) + <<~SYSTEM_PROMPT_MESSAGE + You are a helpful assistant that translates queries from one language to another. + Translate the query to #{target_language}. + Return just the translated query, no other text. + SYSTEM_PROMPT_MESSAGE + end +end diff --git a/enterprise/app/services/captain/tools/search_documentation_service.rb b/enterprise/app/services/captain/tools/search_documentation_service.rb index fbc8f4154..e4a237186 100644 --- a/enterprise/app/services/captain/tools/search_documentation_service.rb +++ b/enterprise/app/services/captain/tools/search_documentation_service.rb @@ -9,7 +9,11 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool def execute(query:) Rails.logger.info { "#{self.class.name}: #{query}" } - responses = assistant.responses.approved.search(query) + translated_query = Captain::Llm::TranslateQueryService + .new(account: assistant.account) + .translate(query, target_language: assistant.account.locale_english_name) + + responses = assistant.responses.approved.search(translated_query) return 'No FAQs found for the given query' if responses.empty? diff --git a/enterprise/app/services/captain/tools/search_reply_documentation_service.rb b/enterprise/app/services/captain/tools/search_reply_documentation_service.rb index d2c1df42f..24c3fd379 100644 --- a/enterprise/app/services/captain/tools/search_reply_documentation_service.rb +++ b/enterprise/app/services/captain/tools/search_reply_documentation_service.rb @@ -18,7 +18,11 @@ class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool def execute(query:) Rails.logger.info { "#{self.class.name}: #{query}" } - responses = search_responses(query) + translated_query = Captain::Llm::TranslateQueryService + .new(account: @account) + .translate(query, target_language: @account.locale_english_name) + + responses = search_responses(translated_query) return 'No FAQs found for the given query' if responses.empty? responses.map { |response| format_response(response) }.join diff --git a/lib/captain/tool_instrumentation.rb b/lib/captain/tool_instrumentation.rb index a2bacce1a..af79a3fca 100644 --- a/lib/captain/tool_instrumentation.rb +++ b/lib/captain/tool_instrumentation.rb @@ -1,5 +1,6 @@ module Captain::ToolInstrumentation extend ActiveSupport::Concern + include Integrations::LlmInstrumentationConstants private @@ -10,15 +11,10 @@ module Captain::ToolInstrumentation response = nil executed = false tracer.in_span(params[:span_name]) do |span| - span.set_attribute('langfuse.user.id', params[:account_id].to_s) if params[:account_id] - span.set_attribute('langfuse.tags', [params[:feature_name]].to_json) - span.set_attribute('langfuse.observation.input', params[:messages].to_json) - + set_tool_session_attributes(span, params) response = yield executed = true - - # Output just the message for cleaner Langfuse display - span.set_attribute('langfuse.observation.output', response[:message] || response.to_json) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json) end response rescue StandardError => e @@ -26,17 +22,24 @@ module Captain::ToolInstrumentation executed ? response : yield end + def set_tool_session_attributes(span, params) + span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id] + span.set_attribute(ATTR_LANGFUSE_SESSION_ID, "#{params[:account_id]}_#{params[:conversation_id]}") if params[:conversation_id].present? + span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json) + end + def record_generation(chat, message, model) return unless ChatwootApp.otel_enabled? return unless message.respond_to?(:role) && message.role.to_s == 'assistant' tracer.in_span("llm.#{event_name}.generation") do |span| - span.set_attribute('gen_ai.system', 'openai') - span.set_attribute('gen_ai.request.model', model) - span.set_attribute('gen_ai.usage.input_tokens', message.input_tokens) - span.set_attribute('gen_ai.usage.output_tokens', message.output_tokens) if message.respond_to?(:output_tokens) - span.set_attribute('langfuse.observation.input', format_chat_messages(chat)) - span.set_attribute('langfuse.observation.output', message.content.to_s) if message.respond_to?(:content) + span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai') + span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model) + span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens) + span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, format_chat_messages(chat)) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content) end rescue StandardError => e Rails.logger.warn "Failed to record generation: #{e.message}" diff --git a/lib/integrations/openai/openai_prompts/reply.liquid b/lib/integrations/openai/openai_prompts/reply.liquid index f9b95dbdf..f8067bb01 100644 --- a/lib/integrations/openai/openai_prompts/reply.liquid +++ b/lib/integrations/openai/openai_prompts/reply.liquid @@ -33,7 +33,7 @@ General guidelines: - Reply in the customer's language {% if has_search_tool %} -**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information. +**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information. **Use the search_documentation tool first** to find relevant information before composing your reply. This ensures your response is accurate and based on actual company documentation. {% endif %} From 6632610e78fc8eb3673beb380ce043751ac974ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:12:52 -0800 Subject: [PATCH 002/155] chore(deps): bump faraday from 2.13.1 to 2.14.1 (#13503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [faraday](https://github.com/lostisland/faraday) from 2.13.1 to 2.14.1.
Release notes

Sourced from faraday's releases.

v2.14.1

Security Note

This release contains a security fix, we recommend all users to upgrade as soon as possible. A Security Advisory with more details will be posted shortly.

What's Changed

New Contributors

Full Changelog: https://github.com/lostisland/faraday/compare/v2.14.0...v2.14.1

v2.14.0

What's Changed

New features ✨

Fixes 🐞

Misc/Docs 📄

New Contributors

Full Changelog: https://github.com/lostisland/faraday/compare/v2.13.4...v2.14.0

v2.13.4

What's Changed

Full Changelog: https://github.com/lostisland/faraday/compare/v2.13.3...v2.13.4

v2.13.3

What's Changed

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=faraday&package-manager=bundler&previous-version=2.13.1&new-version=2.14.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/chatwoot/chatwoot/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index ddac60fd7..db9b59c66 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -298,7 +298,7 @@ GEM railties (>= 5.0.0) faker (3.2.0) i18n (>= 1.8.11, < 2) - faraday (2.13.1) + faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json logger @@ -309,8 +309,8 @@ GEM hashie faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (3.4.0) - net-http (>= 0.5.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) faraday-net_http_persistent (2.1.0) faraday (~> 2.5) net-http-persistent (~> 4.0) @@ -465,7 +465,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.13.2) + json (2.18.1) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -563,8 +563,8 @@ GEM mutex_m (0.3.0) neighbor (0.2.3) activerecord (>= 5.2) - net-http (0.6.0) - uri + net-http (0.9.1) + uri (>= 0.11.1) net-http-persistent (4.0.2) connection_pool (~> 2.2) net-imap (0.4.20) @@ -969,7 +969,7 @@ GEM unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) uniform_notifier (1.17.0) - uri (1.0.4) + uri (1.1.1) uri_template (0.7.0) valid_email2 (5.2.6) activemodel (>= 3.2) From 4622560fac34f435135be8f3c055741df6af251a Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 9 Feb 2026 20:56:40 -0800 Subject: [PATCH 003/155] chore(dev): document codex worktree local setup (#13494) This PR standardizes local Codex worktree usage with a simple dynamic-port workflow and ensures local-only artifacts stay out of version control. To reproduce: create a Codex worktree, run the setup script from `.codex/environments/environment.toml`, and verify that it generates per-worktree DB and port values along with a `Procfile.worktree` for Overmind. Changes included: - Add `.codex/` and `Procfile.worktree` to `.gitignore` - Document the Codex Worktree Workflow in `AGENTS.md`, outlining expected local setup conventions Tested locally by running the setup script with `CODEX_SKIP_INSTALL=1` and `CODEX_SKIP_DB_PREPARE=1`. Verified successful output, dynamic `FRONTEND_URL` / Vite port generation, and that `git diff` contains only the intended documentation and ignore updates. --- .gitignore | 2 ++ AGENTS.md | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index bcc83c1ef..dbdd35bcd 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,7 @@ yarn-debug.log* .vscode .claude/settings.local.json .cursor +.codex/ CLAUDE.local.md # Histoire deployment @@ -101,3 +102,4 @@ CLAUDE.local.md .histoire .pnpm-store/* local/ +Procfile.worktree diff --git a/AGENTS.md b/AGENTS.md index 474fe6e7f..3b1bcb024 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,13 @@ - Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs - Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors +## Codex Worktree Workflow + +- Use a separate git worktree + branch per task to keep changes isolated. +- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration. +- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions. +- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time. + ## Commit Messages - Prefer Conventional Commits: `type(scope): subject` (scope optional) From 6e397c75716b7bbfe248c8e21eecc571283a7785 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:53:53 +0530 Subject: [PATCH 004/155] fix: default model for captain assistant (#13496) --- lib/llm_constants.rb | 2 +- spec/enterprise/models/concerns/agentable_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/llm_constants.rb b/lib/llm_constants.rb index 054b775a5..241f3ccba 100644 --- a/lib/llm_constants.rb +++ b/lib/llm_constants.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module LlmConstants - DEFAULT_MODEL = 'gpt-4.1-mini' + DEFAULT_MODEL = 'gpt-4.1' DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small' PDF_PROCESSING_MODEL = 'gpt-4.1-mini' diff --git a/spec/enterprise/models/concerns/agentable_spec.rb b/spec/enterprise/models/concerns/agentable_spec.rb index 767e51d44..6b170e8d7 100644 --- a/spec/enterprise/models/concerns/agentable_spec.rb +++ b/spec/enterprise/models/concerns/agentable_spec.rb @@ -144,13 +144,13 @@ RSpec.describe Concerns::Agentable do it 'returns default model when config not found' do allow(InstallationConfig).to receive(:find_by).and_return(nil) - expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-mini') + expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1') end it 'returns default model when config value is nil' do allow(mock_installation_config).to receive(:value).and_return(nil) - expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-mini') + expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1') end end From b2526569840c0114a5c768078be0d4a2db7364ce Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:23:14 +0530 Subject: [PATCH 005/155] fix: Prevent race condition in conversation dataFetched flag (#13492) Co-authored-by: Shivam Mishra --- .../store/modules/conversations/actions.js | 6 +- .../store/modules/conversations/index.js | 23 +++- .../specs/conversations/actions.spec.js | 58 ++++++++++ .../specs/conversations/mutations.spec.js | 103 ++++++++++++++++-- .../dashboard/store/mutation-types.js | 1 + 5 files changed, 174 insertions(+), 17 deletions(-) diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 559eaaad9..cc3e9d428 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -96,7 +96,7 @@ const actions = { data: payload, }); if (!payload.length) { - commit(types.SET_ALL_MESSAGES_LOADED); + commit(types.SET_ALL_MESSAGES_LOADED, data.conversationId); } } catch (error) { // Handle error @@ -191,7 +191,7 @@ const actions = { async setActiveChat({ commit, dispatch }, { data, after }) { commit(types.SET_CURRENT_CHAT_WINDOW, data); - commit(types.CLEAR_ALL_MESSAGES_LOADED); + commit(types.CLEAR_ALL_MESSAGES_LOADED, data.id); if (data.dataFetched === undefined) { try { await dispatch('fetchPreviousMessages', { @@ -199,7 +199,7 @@ const actions = { before: data.messages[0].id, conversationId: data.id, }); - data.dataFetched = true; + commit(types.SET_CHAT_DATA_FETCHED, data.id); } catch (error) { // Ignore error } diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index 8d3ef9a9a..3c4003af4 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -63,14 +63,18 @@ export const mutations = { _state.allConversations = []; _state.selectedChatId = null; }, - [types.SET_ALL_MESSAGES_LOADED](_state) { - const [chat] = getSelectedChatConversation(_state); - chat.allMessagesLoaded = true; + [types.SET_ALL_MESSAGES_LOADED](_state, conversationId) { + const chat = getConversationById(_state)(conversationId); + if (chat) { + chat.allMessagesLoaded = true; + } }, - [types.CLEAR_ALL_MESSAGES_LOADED](_state) { - const [chat] = getSelectedChatConversation(_state); - chat.allMessagesLoaded = false; + [types.CLEAR_ALL_MESSAGES_LOADED](_state, conversationId) { + const chat = getConversationById(_state)(conversationId); + if (chat) { + chat.allMessagesLoaded = false; + } }, [types.CLEAR_CURRENT_CHAT_WINDOW](_state) { _state.selectedChatId = null; @@ -91,6 +95,13 @@ export const mutations = { chat.messages = data; }, + [types.SET_CHAT_DATA_FETCHED](_state, conversationId) { + const chat = getConversationById(_state)(conversationId); + if (chat) { + chat.dataFetched = true; + } + }, + [types.SET_CURRENT_CHAT_WINDOW](_state, activeChat) { if (activeChat) { _state.selectedChatId = activeChat.id; diff --git a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js index 502c072f6..87ec5ced7 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js @@ -716,6 +716,64 @@ describe('#addMentions', () => { }); }); + describe('#setActiveChat', () => { + it('should commit SET_CHAT_DATA_FETCHED with conversation ID after fetch', async () => { + const localCommit = vi.fn(); + const localDispatch = vi.fn().mockResolvedValue(); + const data = { id: 42, messages: [{ id: 100 }] }; + + await actions.setActiveChat( + { commit: localCommit, dispatch: localDispatch }, + { data, after: 99 } + ); + + expect(localCommit.mock.calls).toEqual([ + [types.SET_CURRENT_CHAT_WINDOW, data], + [types.CLEAR_ALL_MESSAGES_LOADED, 42], + [types.SET_CHAT_DATA_FETCHED, 42], + ]); + expect(localDispatch).toHaveBeenCalledWith('fetchPreviousMessages', { + after: 99, + before: 100, + conversationId: 42, + }); + }); + + it('should not dispatch fetchPreviousMessages if dataFetched is already set', async () => { + const localCommit = vi.fn(); + const localDispatch = vi.fn(); + const data = { id: 42, messages: [{ id: 100 }], dataFetched: true }; + + await actions.setActiveChat( + { commit: localCommit, dispatch: localDispatch }, + { data } + ); + + expect(localCommit.mock.calls).toEqual([ + [types.SET_CURRENT_CHAT_WINDOW, data], + [types.CLEAR_ALL_MESSAGES_LOADED, 42], + ]); + expect(localDispatch).not.toHaveBeenCalled(); + }); + + it('should commit SET_CHAT_DATA_FETCHED by ID, not mutate the data object directly (race condition fix)', async () => { + const localCommit = vi.fn(); + const localDispatch = vi.fn().mockResolvedValue(); + const data = { id: 42, messages: [{ id: 100 }] }; + + await actions.setActiveChat( + { commit: localCommit, dispatch: localDispatch }, + { data } + ); + + // The action must NOT set dataFetched on the data object directly + expect(data.dataFetched).toBeUndefined(); + + // Instead it commits a mutation that finds the conversation by ID in the store + expect(localCommit).toHaveBeenCalledWith(types.SET_CHAT_DATA_FETCHED, 42); + }); + }); + describe('#getInboxCaptainAssistantById', () => { it('fetches inbox assistant by id', async () => { axios.get.mockResolvedValue({ diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js index e9e78a25c..0c04d2f61 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js @@ -570,25 +570,84 @@ describe('#mutations', () => { }); }); - describe('#SET_ALL_MESSAGES_LOADED', () => { - it('should set allMessagesLoaded to true on selected chat', () => { + describe('#SET_CHAT_DATA_FETCHED', () => { + it('should set dataFetched to true on the conversation by ID', () => { const state = { - allConversations: [{ id: 1, allMessagesLoaded: false }], + allConversations: [{ id: 1 }, { id: 2 }], + }; + mutations[types.SET_CHAT_DATA_FETCHED](state, 1); + expect(state.allConversations[0].dataFetched).toBe(true); + expect(state.allConversations[1].dataFetched).toBeUndefined(); + }); + + it('should do nothing if conversation is not found', () => { + const state = { allConversations: [{ id: 1 }] }; + mutations[types.SET_CHAT_DATA_FETCHED](state, 999); + expect(state.allConversations[0].dataFetched).toBeUndefined(); + }); + + it('should survive the race: SET_ALL_CONVERSATION replaces the object, then SET_CHAT_DATA_FETCHED still works', () => { + // 1. Initial state: conversation exists with dataFetched undefined + const state = { + allConversations: [{ id: 1, messages: [{ id: 'm1' }] }], selectedChatId: 1, }; - mutations[types.SET_ALL_MESSAGES_LOADED](state); + const originalRef = state.allConversations[0]; + + // 2. Simulate SET_ALL_CONVERSATION replacing the object (WebSocket/polling) + // This copies dataFetched from the old object (still undefined) + mutations[types.SET_ALL_CONVERSATION](state, [ + { id: 1, name: 'refreshed', messages: [{ id: 'm2' }] }, + ]); + + // The store now holds a NEW object, old reference is detached + const newRef = state.allConversations[0]; + expect(newRef).not.toBe(originalRef); + expect(newRef.dataFetched).toBeUndefined(); + + // 3. SET_CHAT_DATA_FETCHED finds by ID — works on the current store object + mutations[types.SET_CHAT_DATA_FETCHED](state, 1); + expect(state.allConversations[0].dataFetched).toBe(true); + + // Old detached reference is unaffected + expect(originalRef.dataFetched).toBeUndefined(); + }); + }); + + describe('#SET_ALL_MESSAGES_LOADED', () => { + it('should set allMessagesLoaded to true on the conversation by ID', () => { + const state = { + allConversations: [{ id: 1, allMessagesLoaded: false }, { id: 2 }], + }; + mutations[types.SET_ALL_MESSAGES_LOADED](state, 1); expect(state.allConversations[0].allMessagesLoaded).toBe(true); + expect(state.allConversations[1].allMessagesLoaded).toBeUndefined(); + }); + + it('should do nothing if conversation is not found', () => { + const state = { allConversations: [{ id: 1 }] }; + mutations[types.SET_ALL_MESSAGES_LOADED](state, 999); + expect(state.allConversations[0].allMessagesLoaded).toBeUndefined(); }); }); describe('#CLEAR_ALL_MESSAGES_LOADED', () => { - it('should set allMessagesLoaded to false on selected chat', () => { + it('should set allMessagesLoaded to false on the conversation by ID', () => { const state = { - allConversations: [{ id: 1, allMessagesLoaded: true }], - selectedChatId: 1, + allConversations: [ + { id: 1, allMessagesLoaded: true }, + { id: 2, allMessagesLoaded: true }, + ], }; - mutations[types.CLEAR_ALL_MESSAGES_LOADED](state); + mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 1); expect(state.allConversations[0].allMessagesLoaded).toBe(false); + expect(state.allConversations[1].allMessagesLoaded).toBe(true); + }); + + it('should do nothing if conversation is not found', () => { + const state = { allConversations: [{ id: 1, allMessagesLoaded: true }] }; + mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 999); + expect(state.allConversations[0].allMessagesLoaded).toBe(true); }); }); @@ -797,6 +856,34 @@ describe('#mutations', () => { mutations[types.UPDATE_CONVERSATION](state, conversation); expect(state.allConversations[0].status).toEqual('resolved'); }); + + it('should preserve dataFetched and allMessagesLoaded during update', () => { + const state = { + allConversations: [ + { + id: 1, + status: 'open', + updated_at: 100, + messages: [{ id: 'msg1' }], + dataFetched: true, + allMessagesLoaded: true, + }, + ], + }; + + const conversation = { + id: 1, + status: 'resolved', + updated_at: 200, + messages: [{ id: 'msg2' }], + }; + + mutations[types.UPDATE_CONVERSATION](state, conversation); + expect(state.allConversations[0].status).toEqual('resolved'); + expect(state.allConversations[0].dataFetched).toBe(true); + expect(state.allConversations[0].allMessagesLoaded).toBe(true); + expect(state.allConversations[0].messages).toEqual([{ id: 'msg1' }]); + }); }); describe('#UPDATE_CONVERSATION_CONTACT', () => { diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 1ecafc493..989e08916 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -64,6 +64,7 @@ export default { SET_CONTEXT_MENU_CHAT_ID: 'SET_CONTEXT_MENU_CHAT_ID', + SET_CHAT_DATA_FETCHED: 'SET_CHAT_DATA_FETCHED', SET_CHAT_LIST_FILTERS: 'SET_CHAT_LIST_FILTERS', UPDATE_CHAT_LIST_FILTERS: 'UPDATE_CHAT_LIST_FILTERS', From e65ea24360123eacb9bbd000278265fb91b307c5 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:23:55 +0530 Subject: [PATCH 006/155] fix: Wrong assignee displayed after switching conversations (#13501) --- .../widgets/conversation/ReplyBoxBanner.vue | 5 +++- .../conversation/ConversationAction.vue | 5 +++- .../store/modules/conversations/actions.js | 9 ++++--- .../store/modules/conversations/index.js | 14 +++++++---- .../specs/conversations/actions.spec.js | 24 +++++++++++-------- .../specs/conversations/mutations.spec.js | 15 ++++++++---- 6 files changed, 48 insertions(+), 24 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBoxBanner.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBoxBanner.vue index d80910343..8be715a4f 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBoxBanner.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBoxBanner.vue @@ -31,7 +31,10 @@ const assignedAgent = computed({ }, set(agent) { const agentId = agent ? agent.id : null; - store.dispatch('setCurrentChatAssignee', agent); + store.dispatch('setCurrentChatAssignee', { + conversationId: currentChat.value?.id, + assignee: agent, + }); store.dispatch('assignAgent', { conversationId: currentChat.value?.id, agentId, diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue index 9a7004c5d..35d985c77 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue @@ -85,7 +85,10 @@ export default { }, set(agent) { const agentId = agent ? agent.id : null; - this.$store.dispatch('setCurrentChatAssignee', agent); + this.$store.dispatch('setCurrentChatAssignee', { + conversationId: this.currentChat.id, + assignee: agent, + }); this.$store .dispatch('assignAgent', { conversationId: this.currentChat.id, diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index cc3e9d428..0c4c084e0 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -212,14 +212,17 @@ const actions = { conversationId, agentId, }); - dispatch('setCurrentChatAssignee', response.data); + dispatch('setCurrentChatAssignee', { + conversationId, + assignee: response.data, + }); } catch (error) { // Handle error } }, - setCurrentChatAssignee({ commit }, assignee) { - commit(types.ASSIGN_AGENT, assignee); + setCurrentChatAssignee({ commit }, { conversationId, assignee }) { + commit(types.ASSIGN_AGENT, { conversationId, assignee }); }, assignTeam: async ({ dispatch }, { conversationId, teamId }) => { diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index 3c4003af4..84be116fe 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -108,9 +108,11 @@ export const mutations = { } }, - [types.ASSIGN_AGENT](_state, assignee) { - const [chat] = getSelectedChatConversation(_state); - chat.meta.assignee = assignee; + [types.ASSIGN_AGENT](_state, { conversationId, assignee }) { + const chat = getConversationById(_state)(conversationId); + if (chat) { + chat.meta.assignee = assignee; + } }, [types.ASSIGN_TEAM](_state, { team, conversationId }) { @@ -285,8 +287,10 @@ export const mutations = { // Update assignee on action cable message [types.UPDATE_ASSIGNEE](_state, payload) { - const [chat] = _state.allConversations.filter(c => c.id === payload.id); - chat.meta.assignee = payload.assignee; + const chat = getConversationById(_state)(payload.id); + if (chat) { + chat.meta.assignee = payload.assignee; + } }, [types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) { diff --git a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js index 87ec5ced7..fa052ec1b 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js @@ -355,22 +355,26 @@ describe('#actions', () => { axios.post.mockResolvedValue({ data: { id: 1, name: 'User' }, }); - await actions.assignAgent({ commit }, { conversationId: 1, agentId: 1 }); - expect(commit).toHaveBeenCalledTimes(0); - expect(commit.mock.calls).toEqual([]); + await actions.assignAgent( + { dispatch }, + { conversationId: 1, agentId: 1 } + ); + expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', { + conversationId: 1, + assignee: { id: 1, name: 'User' }, + }); }); }); describe('#setCurrentChatAssignee', () => { it('sends correct mutations if assignment is successful', async () => { - axios.post.mockResolvedValue({ - data: { id: 1, name: 'User' }, - }); - await actions.setCurrentChatAssignee({ commit }, { id: 1, name: 'User' }); + const payload = { + conversationId: 1, + assignee: { id: 1, name: 'User' }, + }; + await actions.setCurrentChatAssignee({ commit }, payload); expect(commit).toHaveBeenCalledTimes(1); - expect(commit.mock.calls).toEqual([ - ['ASSIGN_AGENT', { id: 1, name: 'User' }], - ]); + expect(commit.mock.calls).toEqual([['ASSIGN_AGENT', payload]]); }); }); diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js index 0c04d2f61..01abf05f7 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js @@ -699,15 +699,22 @@ describe('#mutations', () => { }); describe('#ASSIGN_AGENT', () => { - it('should assign agent to selected conversation', () => { + it('should assign agent to the correct conversation by ID', () => { const assignee = { id: 1, name: 'Agent' }; const state = { - allConversations: [{ id: 1, meta: {} }], - selectedChatId: 1, + allConversations: [ + { id: 1, meta: {} }, + { id: 2, meta: {} }, + ], + selectedChatId: 2, }; - mutations[types.ASSIGN_AGENT](state, assignee); + mutations[types.ASSIGN_AGENT](state, { + conversationId: 1, + assignee, + }); expect(state.allConversations[0].meta.assignee).toEqual(assignee); + expect(state.allConversations[1].meta.assignee).toBeUndefined(); }); }); From 0ad47d87f48f64454c1541cf2283402403fb9c24 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 11 Feb 2026 03:55:25 +0530 Subject: [PATCH 007/155] fix: Use Faraday for Telegram document uploads to fix large file failures (#13397) Fixes https://linear.app/chatwoot/issue/CW-6415/sending-large-attachments-11mb-via-telegram-channels-fails-with-http #### Issue Sending large attachments (~11MB) via Telegram channels fails with HTTP 502 (Bad Gateway) and 413 (Request Entity Too Large) errors. The issue is caused by HTTParty's built-in multipart encoding, which reads the entire file into an in-memory string before constructing the request body. For large files, this produces a malformed multipart request that Telegram's API proxy rejects. #### Solution Replace HTTParty with Faraday + multipart-post (both already available in the project) for the sendDocument multipart upload. The multipart-post gem streams file content directly from disk into the HTTP request, producing a correctly formed multipart body that Telegram accepts for large files. --------- Co-authored-by: Sojan Jose --- .../telegram/send_attachments_service.rb | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/app/services/telegram/send_attachments_service.rb b/app/services/telegram/send_attachments_service.rb index 5ba8fd4ae..7b66efd83 100644 --- a/app/services/telegram/send_attachments_service.rb +++ b/app/services/telegram/send_attachments_service.rb @@ -1,3 +1,5 @@ +require 'faraday/multipart' + # Telegram Attachment APIs: ref: https://core.telegram.org/bots/api#inputfile # Media attachments like photos, videos can be clubbed together and sent as a media group @@ -111,17 +113,33 @@ class Telegram::SendAttachmentsService def send_file(chat_id, file_path, reply_to_message_id) File.open(file_path, 'rb') do |file| - HTTParty.post("#{channel.telegram_api_url}/sendDocument", - body: { - chat_id: chat_id, - **business_connection_body, - document: file, - reply_to_message_id: reply_to_message_id - }, - multipart: true) + file_name = File.basename(file_path) + mime_type = Marcel::MimeType.for(name: file_name) || 'application/octet-stream' + + payload = { chat_id: chat_id, document: Faraday::Multipart::FilePart.new(file, mime_type, file_name) } + payload[:reply_to_message_id] = reply_to_message_id if reply_to_message_id + payload.merge!(business_connection_body) + + response = multipart_post_connection.post("#{channel.telegram_api_url}/sendDocument", payload) + parse_faraday_response(response) end end + def multipart_post_connection + @multipart_post_connection ||= Faraday.new do |f| + f.request :multipart + f.options.timeout = 300 + f.options.open_timeout = 60 + end + end + + def parse_faraday_response(response) + parsed = JSON.parse(response.body) + OpenStruct.new(success?: response.success?, parsed_response: parsed) + rescue JSON::ParserError + OpenStruct.new(success?: false, parsed_response: { 'ok' => false, 'error_code' => response.status, 'description' => response.reason_phrase }) + end + def handle_response(response) return true if response.success? From 8f95fafff44d2a5393c0ab187541ded95b655f70 Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 10 Feb 2026 17:27:42 -0800 Subject: [PATCH 008/155] feat: Add a setting to keep conversations pending on bot failures (#13512) Adds an account-level setting `keep_pending_on_bot_failure` to control whether conversations should move from pending to open when agent bot webhooks fail. Some users experience occasional message drops and don't want conversations to automatically reopen due to transient bot failures. This setting gives accounts control over that behavior. This is a temporary setting which will be removed in future once a proper fix for it is done, so it is not added in the UI. --- app/models/account.rb | 2 ++ lib/webhooks/trigger.rb | 15 ++++++--- spec/lib/webhooks/trigger_spec.rb | 56 +++++++++++++++++++++++++++---- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/app/models/account.rb b/app/models/account.rb index fead5f0f7..4816494fb 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -40,6 +40,7 @@ class Account < ApplicationRecord 'auto_resolve_ignore_waiting': { 'type': %w[boolean null] }, 'audio_transcriptions': { 'type': %w[boolean null] }, 'auto_resolve_label': { 'type': %w[string null] }, + 'keep_pending_on_bot_failure': { 'type': %w[boolean null] }, 'conversation_required_attributes': { 'type': %w[array null], 'items': { 'type': 'string' } @@ -88,6 +89,7 @@ class Account < ApplicationRecord store_accessor :settings, :audio_transcriptions, :auto_resolve_label store_accessor :settings, :captain_models, :captain_features + store_accessor :settings, :keep_pending_on_bot_failure has_many :account_users, dependent: :destroy_async has_many :agent_bot_inboxes, dependent: :destroy_async diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb index 54bd7499d..ef3410b78 100644 --- a/lib/webhooks/trigger.rb +++ b/lib/webhooks/trigger.rb @@ -36,16 +36,21 @@ class Webhooks::Trigger case @webhook_type when :agent_bot_webhook - conversation = message.conversation - return unless conversation&.pending? - - conversation.open! - create_agent_bot_error_activity(conversation) + update_conversation_status(message) when :api_inbox_webhook update_message_status(error) end end + def update_conversation_status(message) + conversation = message.conversation + return unless conversation&.pending? + return if conversation&.account&.keep_pending_on_bot_failure + + conversation.open! + create_agent_bot_error_activity(conversation) + end + def create_agent_bot_error_activity(conversation) content = I18n.t('conversations.activity.agent_bot.error_moved_to_open') Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content)) diff --git a/spec/lib/webhooks/trigger_spec.rb b/spec/lib/webhooks/trigger_spec.rb index 78bf361c4..79cf92150 100644 --- a/spec/lib/webhooks/trigger_spec.rb +++ b/spec/lib/webhooks/trigger_spec.rb @@ -74,10 +74,11 @@ describe Webhooks::Trigger do context 'when webhook type is agent bot' do let(:webhook_type) { :agent_bot_webhook } + let!(:pending_conversation) { create(:conversation, inbox: inbox, status: :pending, account: account) } + let!(:pending_message) { create(:message, account: account, inbox: inbox, conversation: pending_conversation) } it 'reopens conversation and enqueues activity message if pending' do - conversation.update(status: :pending) - payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id } + payload = { event: 'message_created', id: pending_message.id } expect(RestClient::Request).to receive(:execute) .with( @@ -92,11 +93,11 @@ describe Webhooks::Trigger do perform_enqueued_jobs do trigger.execute(url, payload, webhook_type) end - end.not_to(change { message.reload.status }) + end.not_to(change { pending_message.reload.status }) - expect(conversation.reload.status).to eq('open') + expect(pending_conversation.reload.status).to eq('open') - activity_message = conversation.reload.messages.order(:created_at).last + activity_message = pending_conversation.reload.messages.order(:created_at).last expect(activity_message.message_type).to eq('activity') expect(activity_message.content).to eq(agent_bot_error_content) end @@ -118,9 +119,52 @@ describe Webhooks::Trigger do end.not_to(change { message.reload.status }) expect(Conversations::ActivityMessageJob).not_to have_been_enqueued - expect(conversation.reload.status).to eq('open') end + + it 'keeps conversation pending when keep_pending_on_bot_failure setting is enabled' do + account.update(keep_pending_on_bot_failure: true) + payload = { event: 'message_created', id: pending_message.id } + + expect(RestClient::Request).to receive(:execute) + .with( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: webhook_timeout + ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once + + trigger.execute(url, payload, webhook_type) + + expect(Conversations::ActivityMessageJob).not_to have_been_enqueued + expect(pending_conversation.reload.status).to eq('pending') + end + + it 'reopens conversation when keep_pending_on_bot_failure setting is disabled' do + account.update(keep_pending_on_bot_failure: false) + payload = { event: 'message_created', id: pending_message.id } + + expect(RestClient::Request).to receive(:execute) + .with( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: webhook_timeout + ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once + expect do + perform_enqueued_jobs do + trigger.execute(url, payload, webhook_type) + end + end.not_to(change { pending_message.reload.status }) + + expect(pending_conversation.reload.status).to eq('open') + + activity_message = pending_conversation.reload.messages.order(:created_at).last + expect(activity_message.message_type).to eq('activity') + expect(activity_message.content).to eq(agent_bot_error_content) + end end end From 7b512bd00eb6fb9c84e37082e673ae47e4ecf768 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:24:45 +0530 Subject: [PATCH 009/155] fix: V2 Assignment service enhancements (#13036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Linear Ticket: https://linear.app/chatwoot/issue/CW-6081/review-feedback ## Description Assignment V2 Service Enhancements - Enable Assignment V2 on plan upgrade - Fix UI issue with fair distribution policy display - Add advanced assignment feature flag and enhance Assignment V2 capabilities ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? This has been tested using the UI. ## 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 --- > [!NOTE] > **Medium Risk** > Changes auto-assignment execution paths, rate limiting defaults, and feature-flag gating (including premium plan behavior), which could affect which conversations get assigned and when. UI rewires inbox settings and policy flows, so regressions are possible around navigation/linking and feature visibility. > > **Overview** > **Adds a new premium `advanced_assignment` feature flag** and uses it to gate capacity/balanced assignment features in the UI (sidebar entry, settings routes, assignment-policy landing cards) and backend (Enterprise balanced selector + capacity filtering). `advanced_assignment` is marked premium, included in Business plan entitlements, and auto-synced in Enterprise accounts when `assignment_v2` is toggled. > > **Improves Assignment V2 policy UX** by adding an inbox-level “Conversation Assignment” section (behind `assignment_v2`) that can link/unlink an assignment policy, navigate to create/edit policy flows with `inboxId` query context, and show an inbox-link prompt after creating a policy. The policy form now defaults to enabled, disables the `balanced` option with a premium badge/message when unavailable, and inbox lists support click-to-navigate. > > **Tightens/adjusts auto-assignment behavior**: bulk assignment now requires `inbox.enable_auto_assignment?`, conversation ordering uses the attached `assignment_policy` priority, and rate limiting uses `assignment_policy` config with an infinite default limit while still tracking assignments. Tests and i18n strings are updated accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 23bc03bf75ee4376071e4d7fc7cd564c601d33d7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Pranav Co-authored-by: iamsivin Co-authored-by: Muhsin Keloth Co-authored-by: Shivam Mishra --- .../AssignmentPolicyCard.story.vue | 3 - .../AssignmentPolicyCard.vue | 17 - .../AssignmentPolicy/components/DataTable.vue | 22 +- .../components/FairDistribution.vue | 22 +- .../AssignmentPolicy/components/RadioCard.vue | 41 +- .../components/story/BaseInfo.story.vue | 4 - .../components-next/sidebar/Sidebar.vue | 29 +- app/javascript/dashboard/featureFlags.js | 2 + .../dashboard/i18n/locale/en/inboxMgmt.json | 47 ++ .../dashboard/i18n/locale/en/settings.json | 27 +- .../settings/assignmentPolicy/Index.vue | 109 ++- .../assignmentPolicy.routes.js | 6 +- .../pages/AgentAssignmentCreatePage.vue | 59 +- .../pages/AgentAssignmentEditPage.vue | 127 ++- .../pages/AgentCapacityEditPage.vue | 83 +- .../components/AgentAssignmentPolicyForm.vue | 58 +- .../pages/components/InboxLinkDialog.vue | 116 +++ .../inbox/settingsPage/CollaboratorsPage.vue | 759 ++++++++++++++---- .../auto_assignment/assignment_service.rb | 9 +- app/services/auto_assignment/rate_limiter.rb | 6 +- config/features.yml | 4 + enterprise/app/models/enterprise/account.rb | 23 + .../auto_assignment/assignment_service.rb | 5 +- .../billing/handle_stripe_event_service.rb | 2 +- .../assignment_service_spec.rb | 5 +- .../auto_assignment/capacity_service_spec.rb | 5 +- .../periodic_assignment_job_spec.rb | 20 +- .../assignment_service_spec.rb | 5 +- .../auto_assignment/rate_limiter_spec.rb | 5 +- 29 files changed, 1284 insertions(+), 336 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/InboxLinkDialog.vue diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue index cd6f1d49b..20ab38d58 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue @@ -39,7 +39,6 @@ const policyA = withCount({ description: 'Distributes conversations evenly among available agents', assignmentOrder: 'round_robin', conversationPriority: 'high', - enabled: true, inboxes: [mockInboxes[0], mockInboxes[1]], isFetchingInboxes: false, }); @@ -50,7 +49,6 @@ const policyB = withCount({ description: 'Assigns based on capacity and workload', assignmentOrder: 'capacity_based', conversationPriority: 'medium', - enabled: true, inboxes: [mockInboxes[2], mockInboxes[3]], isFetchingInboxes: false, }); @@ -61,7 +59,6 @@ const emptyPolicy = withCount({ description: 'Policy with no assigned inboxes', assignmentOrder: 'manual', conversationPriority: 'low', - enabled: false, inboxes: [], isFetchingInboxes: false, }); diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue index fe9965777..cedfb0009 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue @@ -15,7 +15,6 @@ const props = defineProps({ assignmentOrder: { type: String, default: '' }, conversationPriority: { type: String, default: '' }, assignedInboxCount: { type: Number, default: 0 }, - enabled: { type: Boolean, default: false }, inboxes: { type: Array, default: () => [] }, isFetchingInboxes: { type: Boolean, default: false }, }); @@ -65,22 +64,6 @@ const handleFetchInboxes = () => { {{ name }}
-
- - {{ - enabled - ? t( - 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE' - ) - : t( - 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE' - ) - }} - -
-
+
+ +
-import { ref, onMounted } from 'vue'; +import { ref, computed, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'; import Input from 'dashboard/components-next/input/Input.vue'; import DurationInput from 'dashboard/components-next/input/DurationInput.vue'; @@ -15,6 +15,9 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', { }, }); +// The model value is in seconds (for the backend/DB) +// DurationInput works in minutes internally +// We need to convert between seconds and minutes const fairDistributionWindow = defineModel('fairDistributionWindow', { type: Number, default: 3600, @@ -25,6 +28,17 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', { const windowUnit = ref(DURATION_UNITS.MINUTES); +// Convert seconds to minutes for DurationInput +const windowInMinutes = computed({ + get() { + return Math.floor((fairDistributionWindow.value || 0) / 60); + }, + set(minutes) { + fairDistributionWindow.value = minutes * 60; + }, +}); + +// Detect unit based on minutes (converted from seconds) const detectUnit = minutes => { const m = Number(minutes) || 0; if (m === 0) return DURATION_UNITS.MINUTES; @@ -34,7 +48,7 @@ const detectUnit = minutes => { }; onMounted(() => { - windowUnit.value = detectUnit(fairDistributionWindow.value); + windowUnit.value = detectUnit(windowInMinutes.value); }); @@ -73,9 +87,9 @@ onMounted(() => {
- + +import { useI18n } from 'vue-i18n'; + const props = defineProps({ id: { type: String, @@ -16,12 +18,22 @@ const props = defineProps({ type: Boolean, default: false, }, + disabled: { + type: Boolean, + default: false, + }, + disabledMessage: { + type: String, + default: '', + }, }); const emit = defineEmits(['select']); +const { t } = useI18n(); + const handleChange = () => { - if (!props.isActive) { + if (!props.isActive && !props.disabled) { emit('select', props.id); } }; @@ -29,9 +41,11 @@ const handleChange = () => {