From 3a0b5f387d9d720a746866160e65b14623bde3fa Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 27 May 2025 14:10:27 -0600 Subject: [PATCH 1/4] fix: Update specs, add background response job implementation for copilot threads (#11600) - Enable jobs by default when a copilot thread or a message is created. - Rename thread_id to copilot_thread_id to keep it consistent with the model name - Add a spec for search_linear_issues service --- .../captain/copilot_messages_controller.rb | 3 +- .../captain/copilot_threads_controller.rb | 9 +++- enterprise/app/helpers/captain/chat_helper.rb | 3 ++ .../app/jobs/captain/copilot/response_job.rb | 25 +++++++++++ enterprise/app/models/copilot_message.rb | 18 +++++--- .../services/captain/copilot/chat_service.rb | 2 +- .../copilot/search_linear_issues_service.rb | 2 +- .../copilot_messages_controller_spec.rb | 2 +- .../copilot_threads_controller_spec.rb | 5 ++- .../jobs/captain/copilot/response_job_spec.rb | 41 +++++++++++++++++++ .../enterprise/models/copilot_message_spec.rb | 11 +++-- .../captain/copilot/chat_service_spec.rb | 10 ++--- .../copilot/search_articles_service_spec.rb | 30 +++----------- .../search_linear_issues_service_spec.rb | 31 +++++++++++--- spec/factories/articles.rb | 9 ++-- spec/factories/categories.rb | 3 +- 16 files changed, 145 insertions(+), 59 deletions(-) create mode 100644 enterprise/app/jobs/captain/copilot/response_job.rb create mode 100644 spec/enterprise/jobs/captain/copilot/response_job_spec.rb diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb index d7d79e9af..75abc0cdc 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb @@ -12,9 +12,10 @@ class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts: def create @copilot_message = @copilot_thread.copilot_messages.create!( - message: params[:message], + message: { content: params[:message] }, message_type: :user ) + @copilot_message.enqueue_response_job(params[:conversation_id], Current.user.id) end private diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb index 482acd7e7..533859b95 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb @@ -18,7 +18,12 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts:: assistant: assistant ) - @copilot_thread.copilot_messages.create!(message_type: :user, message: copilot_thread_params[:message]) + copilot_message = @copilot_thread.copilot_messages.create!( + message_type: :user, + message: { content: copilot_thread_params[:message] } + ) + + copilot_message.enqueue_response_job(copilot_thread_params[:conversation_id], Current.user.id) end end @@ -33,7 +38,7 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts:: end def copilot_thread_params - params.permit(:message, :assistant_id) + params.permit(:message, :assistant_id, :conversation_id) end def permitted_params diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb index e326f3094..924f11269 100644 --- a/enterprise/app/helpers/captain/chat_helper.rb +++ b/enterprise/app/helpers/captain/chat_helper.rb @@ -13,6 +13,9 @@ module Captain::ChatHelper ) handle_response(response) + rescue StandardError => e + Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}" + raise e end private diff --git a/enterprise/app/jobs/captain/copilot/response_job.rb b/enterprise/app/jobs/captain/copilot/response_job.rb new file mode 100644 index 000000000..fca5b0cce --- /dev/null +++ b/enterprise/app/jobs/captain/copilot/response_job.rb @@ -0,0 +1,25 @@ +class Captain::Copilot::ResponseJob < ApplicationJob + queue_as :default + + def perform(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:) + Rails.logger.info("#{self.class.name} Copilot response job for assistant_id=#{assistant.id} user_id=#{user_id}") + generate_chat_response( + assistant: assistant, + conversation_id: conversation_id, + user_id: user_id, + copilot_thread_id: copilot_thread_id, + message: message + ) + end + + private + + def generate_chat_response(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:) + Captain::Copilot::ChatService.new( + assistant, + user_id: user_id, + copilot_thread_id: copilot_thread_id, + conversation_id: conversation_id + ).generate_response(message) + end +end diff --git a/enterprise/app/models/copilot_message.rb b/enterprise/app/models/copilot_message.rb index f3816685c..5941656c3 100644 --- a/enterprise/app/models/copilot_message.rb +++ b/enterprise/app/models/copilot_message.rb @@ -19,14 +19,12 @@ class CopilotMessage < ApplicationRecord belongs_to :copilot_thread belongs_to :account - before_validation :ensure_account - enum message_type: { user: 0, assistant: 1, assistant_thinking: 2 } - validates :message_type, presence: true, inclusion: { in: message_types.keys } + validates :message_type, presence: true validates :message, presence: true + before_validation :ensure_account validate :validate_message_attributes - after_create_commit :broadcast_message def push_event_data @@ -39,10 +37,20 @@ class CopilotMessage < ApplicationRecord } end + def enqueue_response_job(conversation_id, user_id) + Captain::Copilot::ResponseJob.perform_later( + assistant: copilot_thread.assistant, + conversation_id: conversation_id, + user_id: user_id, + copilot_thread_id: copilot_thread.id, + message: message['content'] + ) + end + private def ensure_account - self.account = copilot_thread.account + self.account_id = copilot_thread&.account_id end def broadcast_message diff --git a/enterprise/app/services/captain/copilot/chat_service.rb b/enterprise/app/services/captain/copilot/chat_service.rb index 0c4811c7e..701837f55 100644 --- a/enterprise/app/services/captain/copilot/chat_service.rb +++ b/enterprise/app/services/captain/copilot/chat_service.rb @@ -51,7 +51,7 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService "#{self.class.name} Assistant: #{@assistant.id}, Previous History: #{config[:previous_history]&.length || 0}, Language: #{config[:language]}" ) - @copilot_thread = @account.copilot_threads.find_by(id: config[:thread_id]) if config[:thread_id].present? + @copilot_thread = @account.copilot_threads.find_by(id: config[:copilot_thread_id]) if config[:copilot_thread_id].present? @previous_history = if @copilot_thread.present? @copilot_thread.previous_history else diff --git a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb index ac5116303..0d59e194d 100644 --- a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb @@ -46,7 +46,7 @@ class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseS end def active? - @user.present? && @assistant.account.hooks.find_by(app_id: 'linear').present? + @user.present? && @assistant.account.hooks.exists?(app_id: 'linear') end private diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb index d435013cf..511f4ea73 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb @@ -44,7 +44,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r end.to change(CopilotMessage, :count).by(1) expect(response).to have_http_status(:success) - expect(CopilotMessage.last.message).to eq(message_content) + expect(CopilotMessage.last.message).to eq({ 'content' => message_content }) expect(CopilotMessage.last.message_type).to eq('user') expect(CopilotMessage.last.copilot_thread_id).to eq(copilot_thread.id) end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb index 748c3adf6..b8fc628d1 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb @@ -4,6 +4,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do let(:account) { create(:account) } let(:admin) { create(:user, account: account, role: :administrator) } let(:agent) { create(:user, account: account, role: :agent) } + let(:conversation) { create(:conversation, account: account) } def json_response JSON.parse(response.body, symbolize_names: true) @@ -50,7 +51,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do let(:assistant) { create(:captain_assistant, account: account) } - let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id } } + let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id, conversation_id: conversation.display_id } } context 'when it is an un-authenticated user' do it 'returns unauthorized' do @@ -103,7 +104,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do message = thread.copilot_messages.last expect(message.message_type).to eq('user') - expect(message.message).to eq(valid_params[:message]) + expect(message.message).to eq({ 'content' => valid_params[:message] }) end end end diff --git a/spec/enterprise/jobs/captain/copilot/response_job_spec.rb b/spec/enterprise/jobs/captain/copilot/response_job_spec.rb new file mode 100644 index 000000000..c8aacda5e --- /dev/null +++ b/spec/enterprise/jobs/captain/copilot/response_job_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe Captain::Copilot::ResponseJob, type: :job do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) } + let(:conversation_id) { 123 } + let(:message) { { 'content' => 'Test message' } } + + describe '#perform' do + let(:chat_service) { instance_double(Captain::Copilot::ChatService) } + + before do + allow(Captain::Copilot::ChatService).to receive(:new).with( + assistant, + user_id: user.id, + copilot_thread_id: copilot_thread.id, + conversation_id: conversation_id + ).and_return(chat_service) + allow(chat_service).to receive(:generate_response).with(message) + end + + it 'initializes ChatService with correct parameters and calls generate_response' do + expect(Captain::Copilot::ChatService).to receive(:new).with( + assistant, + user_id: user.id, + copilot_thread_id: copilot_thread.id, + conversation_id: conversation_id + ) + expect(chat_service).to receive(:generate_response).with(message) + described_class.perform_now( + assistant: assistant, + conversation_id: conversation_id, + user_id: user.id, + copilot_thread_id: copilot_thread.id, + message: message + ) + end + end +end diff --git a/spec/enterprise/models/copilot_message_spec.rb b/spec/enterprise/models/copilot_message_spec.rb index e5c904c45..6c0e496f2 100644 --- a/spec/enterprise/models/copilot_message_spec.rb +++ b/spec/enterprise/models/copilot_message_spec.rb @@ -1,15 +1,14 @@ require 'rails_helper' RSpec.describe CopilotMessage, type: :model do - describe 'associations' do - it { is_expected.to belong_to(:copilot_thread) } - it { is_expected.to belong_to(:account) } - end + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) } describe 'validations' do it { is_expected.to validate_presence_of(:message_type) } it { is_expected.to validate_presence_of(:message) } - it { is_expected.to validate_inclusion_of(:message_type).in_array(described_class.message_types.keys) } end describe 'callbacks' do @@ -31,7 +30,7 @@ RSpec.describe CopilotMessage, type: :model do message = build(:captain_copilot_message, copilot_thread: copilot_thread) expect(Rails.configuration.dispatcher).to receive(:dispatch) - .with(COPILOT_MESSAGE_CREATED, anything, copilot_message: message) + .with('copilot.message.created', anything, copilot_message: message) message.save! end diff --git a/spec/enterprise/services/captain/copilot/chat_service_spec.rb b/spec/enterprise/services/captain/copilot/chat_service_spec.rb index e498fa83a..a4274ec4e 100644 --- a/spec/enterprise/services/captain/copilot/chat_service_spec.rb +++ b/spec/enterprise/services/captain/copilot/chat_service_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Captain::Copilot::ChatService do let(:previous_history) { [{ role: copilot_message.message_type, content: copilot_message.message['content'] }] } let(:config) do - { user_id: user.id, thread_id: copilot_thread.id, conversation_id: conversation.display_id } + { user_id: user.id, copilot_thread_id: copilot_thread.id, conversation_id: conversation.display_id } end before do @@ -157,16 +157,16 @@ RSpec.describe Captain::Copilot::ChatService do end describe '#setup_message_history' do - context 'when thread_id is present' do + context 'when copilot_thread_id is present' do it 'finds the copilot thread and sets previous history from it' do - service = described_class.new(assistant, { thread_id: copilot_thread.id }) + service = described_class.new(assistant, { copilot_thread_id: copilot_thread.id }) expect(service.copilot_thread).to eq(copilot_thread) expect(service.previous_history).to eq previous_history end end - context 'when thread_id is not present' do + context 'when copilot_thread_id is not present' do it 'uses previous_history from config if present' do custom_history = [{ role: 'user', content: 'Custom message' }] service = described_class.new(assistant, { previous_history: custom_history }) @@ -222,7 +222,7 @@ RSpec.describe Captain::Copilot::ChatService do }.with_indifferent_access) expect do - described_class.new(assistant, { thread_id: copilot_thread.id }).generate_response('Hello') + described_class.new(assistant, { copilot_thread_id: copilot_thread.id }).generate_response('Hello') end.to change(CopilotMessage, :count).by(1) last_message = CopilotMessage.last diff --git a/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb index e4504d7bd..eb11e0d69 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb @@ -102,10 +102,6 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do end context 'when no articles are found' do - before do - allow(Article).to receive(:where).and_return(Article.none) - end - it 'returns no articles found message' do expect(service.execute({ 'query' => 'test' })).to eq('No articles found') end @@ -113,13 +109,8 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do context 'when articles are found' do let(:portal) { create(:portal, account: account) } - let(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') } - let(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') } - - before do - article1 - article2 - end + let!(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') } + let!(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') } it 'returns formatted articles with count' do result = service.execute({ 'query' => 'Test' }) @@ -130,11 +121,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do context 'when filtered by category' do let(:category) { create(:category, slug: 'test-category', portal: portal, account: account) } - let(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') } - - before do - article3 - end + let!(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') } it 'returns only articles from the specified category' do result = service.execute({ 'query' => 'Test', 'category_id' => category.id }) @@ -146,15 +133,8 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do end context 'when filtered by status' do - let(:article3) do - create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published') - end - let(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') } - - before do - article3 - article4 - end + let!(:article3) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published') } + let!(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') } it 'returns only articles with the specified status' do result = service.execute({ 'query' => 'Test', 'status' => 'published' }) diff --git a/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb index f4a5225b1..f987b7a6a 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb @@ -3,7 +3,8 @@ require 'rails_helper' RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do let(:account) { create(:account) } let(:assistant) { create(:captain_assistant, account: account) } - let(:service) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:service) { described_class.new(assistant, user: user) } describe '#name' do it 'returns the correct service name' do @@ -40,14 +41,34 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do create(:integrations_hook, :linear, account: account) end - it 'returns true' do - expect(service.active?).to be true + context 'when user is present' do + it 'returns true' do + expect(service.active?).to be true + end + end + + context 'when user is not present' do + let(:service) { described_class.new(assistant) } + + it 'returns false' do + expect(service.active?).to be false + end end end context 'when Linear integration is not enabled' do - it 'returns false' do - expect(service.active?).to be false + context 'when user is present' do + it 'returns false' do + expect(service.active?).to be false + end + end + + context 'when user is not present' do + let(:service) { described_class.new(assistant) } + + it 'returns false' do + expect(service.active?).to be false + end end end end diff --git a/spec/factories/articles.rb b/spec/factories/articles.rb index e53549090..b86e422a9 100644 --- a/spec/factories/articles.rb +++ b/spec/factories/articles.rb @@ -1,13 +1,14 @@ FactoryBot.define do factory :article, class: 'Article' do - account_id { 1 } - category_id { 1 } + account + category { nil } + portal locale { 'en' } - author_id { 1 } + association :author, factory: :user title { "#{Faker::Movie.title} #{SecureRandom.hex}" } content { 'MyText' } description { 'MyDescrption' } - status { 1 } + status { :published } views { 0 } end end diff --git a/spec/factories/categories.rb b/spec/factories/categories.rb index f7becf8fc..f9685b8b0 100644 --- a/spec/factories/categories.rb +++ b/spec/factories/categories.rb @@ -1,9 +1,10 @@ FactoryBot.define do factory :category, class: 'Category' do - portal { portal } + portal name { 'MyString' } description { 'MyText' } position { 1 } + slug { name.parameterize } after(:build) do |category| category.account ||= category.portal.account From 03bde0a8aa0b6ac21a6145dde68f5b1a9326799b Mon Sep 17 00:00:00 2001 From: Petterson <58094725+hahuma@users.noreply.github.com> Date: Tue, 27 May 2025 17:24:43 -0300 Subject: [PATCH 2/4] fix: Truncate name in attachment bubble (#11540) # Pull Request ## Description This PR fixes a UI issue where very long attachment filenames (sometimes including parameters or hashes) would overflow or break the layout in the message bubble. The fix applies Tailwind's truncate utility class to ensure these filenames are properly truncated with ellipsis, preserving layout consistency and improving readability. Fixes #11514 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Manually tested with attachment messages containing long filenames (e.g., with hashes, query params, or excessive length). - Verified that filenames are now truncated with ellipsis. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] New and existing unit tests pass locally with my changes --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../components-next/message/bubbles/BaseAttachment.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue b/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue index a4e4aa729..a573e7868 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue @@ -40,7 +40,7 @@ const senderName = computed(() => { -
+
{{ t(senderTranslationKey, { From 22b5e12a53a3183127a13f8552cfb748f9b7525d Mon Sep 17 00:00:00 2001 From: Vaishnavi Aswale <48441026+Vaishnavi-Aswale@users.noreply.github.com> Date: Tue, 27 May 2025 14:34:59 -0700 Subject: [PATCH 3/4] fix: use supported access method for schema_format in Rails 7 (#11576) Description In Rails 7.1+, accessing `schema_format` via `ActiveRecord::Base` is no longer supported, causing a method error. The correct approach is to use `ActiveRecord.schema_format`, which aligns with the public API. This issue occurs locally because the environment uses newer versions of Ruby and Rails than others. Fixes: https://github.com/chatwoot/chatwoot/issues/11594 Co-authored-by: Sojan Jose --- lib/tasks/db_enhancements.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tasks/db_enhancements.rake b/lib/tasks/db_enhancements.rake index d9cc22334..5fddb6cde 100644 --- a/lib/tasks/db_enhancements.rake +++ b/lib/tasks/db_enhancements.rake @@ -18,7 +18,7 @@ db_namespace = namespace :db do ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| ActiveRecord::Base.establish_connection(db_config.configuration_hash) unless ActiveRecord::Base.connection.table_exists? 'ar_internal_metadata' - db_namespace['load_config'].invoke if ActiveRecord::Base.schema_format == :ruby + db_namespace['load_config'].invoke if ActiveRecord.schema_format == :ruby ActiveRecord::Tasks::DatabaseTasks.load_schema_current(:ruby, ENV.fetch('SCHEMA', nil)) db_namespace['seed'].invoke end From f42fddd38e1d9563799619cfb3bd17e706bafc0c Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 27 May 2025 18:36:32 -0600 Subject: [PATCH 4/4] feat: Add stores for copilotMessages and copilotThreads (#11603) - Set up stores for copilotThreads and copilotMessages. - Add support for upsert messages to the copilotMessages store on receiving ActionCable events. - Implement support for the upsert option. --- .../dashboard/api/captain/copilotMessages.js | 18 + .../dashboard/api/captain/copilotThreads.js | 9 + .../dashboard/helper/actionCable.js | 5 + .../helper/specs/actionCable.spec.js | 67 +++ .../store/captain/copilotMessages.js | 19 + .../dashboard/store/captain/copilotThreads.js | 7 + .../dashboard/store/captain/storeFactory.js | 90 +---- .../store/captain/storeFactory.spec.js | 380 ++++++++++++++++++ .../store/captain/storeFactoryHelper.js | 77 ++++ app/javascript/dashboard/store/index.js | 5 + 10 files changed, 607 insertions(+), 70 deletions(-) create mode 100644 app/javascript/dashboard/api/captain/copilotMessages.js create mode 100644 app/javascript/dashboard/api/captain/copilotThreads.js create mode 100644 app/javascript/dashboard/helper/specs/actionCable.spec.js create mode 100644 app/javascript/dashboard/store/captain/copilotMessages.js create mode 100644 app/javascript/dashboard/store/captain/copilotThreads.js create mode 100644 app/javascript/dashboard/store/captain/storeFactory.spec.js create mode 100644 app/javascript/dashboard/store/captain/storeFactoryHelper.js diff --git a/app/javascript/dashboard/api/captain/copilotMessages.js b/app/javascript/dashboard/api/captain/copilotMessages.js new file mode 100644 index 000000000..49e05398a --- /dev/null +++ b/app/javascript/dashboard/api/captain/copilotMessages.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CopilotMessages extends ApiClient { + constructor() { + super('captain/copilot_threads', { accountScoped: true }); + } + + get(threadId) { + return axios.get(`${this.url}/${threadId}/copilot_messages`); + } + + create({ threadId, ...rest }) { + return axios.post(`${this.url}/${threadId}/copilot_messages`, rest); + } +} + +export default new CopilotMessages(); diff --git a/app/javascript/dashboard/api/captain/copilotThreads.js b/app/javascript/dashboard/api/captain/copilotThreads.js new file mode 100644 index 000000000..7fdce3b91 --- /dev/null +++ b/app/javascript/dashboard/api/captain/copilotThreads.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CopilotThreads extends ApiClient { + constructor() { + super('captain/copilot_threads', { accountScoped: true }); + } +} + +export default new CopilotThreads(); diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 515806b31..991576e66 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector { 'conversation.read': this.onConversationRead, 'conversation.updated': this.onConversationUpdated, 'account.cache_invalidated': this.onCacheInvalidate, + 'copilot.message.created': this.onCopilotMessageCreated, }; } @@ -189,6 +190,10 @@ class ActionCableConnector extends BaseActionCableConnector { this.app.$store.dispatch('notifications/updateNotification', data); }; + onCopilotMessageCreated = data => { + this.app.$store.dispatch('copilotMessages/upsert', data); + }; + onCacheInvalidate = data => { const keys = data.cache_keys; this.app.$store.dispatch('labels/revalidate', { newKey: keys.label }); diff --git a/app/javascript/dashboard/helper/specs/actionCable.spec.js b/app/javascript/dashboard/helper/specs/actionCable.spec.js new file mode 100644 index 000000000..4ad8a52c6 --- /dev/null +++ b/app/javascript/dashboard/helper/specs/actionCable.spec.js @@ -0,0 +1,67 @@ +import { describe, it, beforeEach, expect, vi } from 'vitest'; +import ActionCableConnector from '../actionCable'; + +vi.mock('shared/helpers/mitt', () => ({ + emitter: { + emit: vi.fn(), + }, +})); + +vi.mock('dashboard/composables/useImpersonation', () => ({ + useImpersonation: () => ({ + isImpersonating: { value: false }, + }), +})); + +global.chatwootConfig = { + websocketURL: 'wss://test.chatwoot.com', +}; + +describe('ActionCableConnector - Copilot Tests', () => { + let store; + let actionCable; + let mockDispatch; + + beforeEach(() => { + vi.clearAllMocks(); + mockDispatch = vi.fn(); + store = { + $store: { + dispatch: mockDispatch, + getters: { + getCurrentAccountId: 1, + }, + }, + }; + + actionCable = ActionCableConnector.init(store.$store, 'test-token'); + }); + describe('copilot event handlers', () => { + it('should register the copilot.message.created event handler', () => { + expect(Object.keys(actionCable.events)).toContain( + 'copilot.message.created' + ); + expect(actionCable.events['copilot.message.created']).toBe( + actionCable.onCopilotMessageCreated + ); + }); + + it('should handle the copilot.message.created event through the ActionCable system', () => { + const copilotData = { + id: 2, + content: 'This is a copilot message from ActionCable', + conversation_id: 456, + created_at: '2025-05-27T15:58:04-06:00', + account_id: 1, + }; + actionCable.onReceived({ + event: 'copilot.message.created', + data: copilotData, + }); + expect(mockDispatch).toHaveBeenCalledWith( + 'copilotMessages/upsert', + copilotData + ); + }); + }); +}); diff --git a/app/javascript/dashboard/store/captain/copilotMessages.js b/app/javascript/dashboard/store/captain/copilotMessages.js new file mode 100644 index 000000000..83b7fddce --- /dev/null +++ b/app/javascript/dashboard/store/captain/copilotMessages.js @@ -0,0 +1,19 @@ +import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages'; +import { createStore } from './storeFactory'; + +export default createStore({ + name: 'CopilotMessages', + API: CopilotMessagesAPI, + getters: { + getMessagesByThreadId: state => copilotThreadId => { + return state.records.filter( + record => record.copilot_thread?.id === Number(copilotThreadId) + ); + }, + }, + actions: mutationTypes => ({ + upsert({ commit }, data) { + commit(mutationTypes.UPSERT, data); + }, + }), +}); diff --git a/app/javascript/dashboard/store/captain/copilotThreads.js b/app/javascript/dashboard/store/captain/copilotThreads.js new file mode 100644 index 000000000..8d820f305 --- /dev/null +++ b/app/javascript/dashboard/store/captain/copilotThreads.js @@ -0,0 +1,7 @@ +import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads'; +import { createStore } from './storeFactory'; + +export default createStore({ + name: 'CopilotThreads', + API: CopilotThreadsAPI, +}); diff --git a/app/javascript/dashboard/store/captain/storeFactory.js b/app/javascript/dashboard/store/captain/storeFactory.js index a55522062..ad669f62b 100644 --- a/app/javascript/dashboard/store/captain/storeFactory.js +++ b/app/javascript/dashboard/store/captain/storeFactory.js @@ -1,5 +1,11 @@ -import { throwErrorMessage } from 'dashboard/store/utils/api'; import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; +import { + createRecord, + deleteRecord, + getRecords, + showRecord, + updateRecord, +} from './storeFactoryHelper'; export const generateMutationTypes = name => { const capitalizedName = name.toUpperCase(); @@ -10,6 +16,7 @@ export const generateMutationTypes = name => { EDIT: `EDIT_${capitalizedName}`, DELETE: `DELETE_${capitalizedName}`, SET_META: `SET_${capitalizedName}_META`, + UPSERT: `UPSERT_${capitalizedName}`, }; }; @@ -33,7 +40,6 @@ export const createGetters = () => ({ getMeta: state => state.meta, }); -// store/mutations.js export const createMutations = mutationTypes => ({ [mutationTypes.SET_UI_FLAG](state, data) { state.uiFlags = { @@ -51,78 +57,19 @@ export const createMutations = mutationTypes => ({ [mutationTypes.ADD]: MutationHelpers.create, [mutationTypes.EDIT]: MutationHelpers.update, [mutationTypes.DELETE]: MutationHelpers.destroy, + [mutationTypes.UPSERT]: MutationHelpers.setSingleRecord, }); -// store/actions/crud.js export const createCrudActions = (API, mutationTypes) => ({ - async get({ commit }, params = {}) { - commit(mutationTypes.SET_UI_FLAG, { fetchingList: true }); - try { - const response = await API.get(params); - commit(mutationTypes.SET, response.data.payload); - commit(mutationTypes.SET_META, response.data.meta); - return response.data.payload; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { fetchingList: false }); - } - }, - - async show({ commit }, id) { - commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true }); - try { - const response = await API.show(id); - commit(mutationTypes.ADD, response.data); - return response.data; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false }); - } - }, - - async create({ commit }, dataObj) { - commit(mutationTypes.SET_UI_FLAG, { creatingItem: true }); - try { - const response = await API.create(dataObj); - commit(mutationTypes.ADD, response.data); - return response.data; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { creatingItem: false }); - } - }, - - async update({ commit }, { id, ...updateObj }) { - commit(mutationTypes.SET_UI_FLAG, { updatingItem: true }); - try { - const response = await API.update(id, updateObj); - commit(mutationTypes.EDIT, response.data); - return response.data; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { updatingItem: false }); - } - }, - - async delete({ commit }, id) { - commit(mutationTypes.SET_UI_FLAG, { deletingItem: true }); - try { - await API.delete(id); - commit(mutationTypes.DELETE, id); - return id; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { deletingItem: false }); - } - }, + get: getRecords(mutationTypes, API), + show: showRecord(mutationTypes, API), + create: createRecord(mutationTypes, API), + update: updateRecord(mutationTypes, API), + delete: deleteRecord(mutationTypes, API), }); + export const createStore = options => { - const { name, API, actions } = options; + const { name, API, actions, getters } = options; const mutationTypes = generateMutationTypes(name); const customActions = actions ? actions(mutationTypes) : {}; @@ -130,7 +77,10 @@ export const createStore = options => { return { namespaced: true, state: createInitialState(), - getters: createGetters(), + getters: { + ...createGetters(), + ...(getters || {}), + }, mutations: createMutations(mutationTypes), actions: { ...createCrudActions(API, mutationTypes), diff --git a/app/javascript/dashboard/store/captain/storeFactory.spec.js b/app/javascript/dashboard/store/captain/storeFactory.spec.js new file mode 100644 index 000000000..0aec26e40 --- /dev/null +++ b/app/javascript/dashboard/store/captain/storeFactory.spec.js @@ -0,0 +1,380 @@ +import { throwErrorMessage } from 'dashboard/store/utils/api'; +import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; +import { + generateMutationTypes, + createInitialState, + createGetters, + createMutations, + createCrudActions, + createStore, +} from './storeFactory'; + +vi.mock('dashboard/store/utils/api', () => ({ + throwErrorMessage: vi.fn(), +})); + +vi.mock('shared/helpers/vuex/mutationHelpers', () => ({ + set: vi.fn(), + create: vi.fn(), + update: vi.fn(), + destroy: vi.fn(), + setSingleRecord: vi.fn(), +})); + +describe('storeFactory', () => { + describe('generateMutationTypes', () => { + it('generates correct mutation types with capitalized name', () => { + const result = generateMutationTypes('test'); + expect(result).toEqual({ + SET_UI_FLAG: 'SET_TEST_UI_FLAG', + SET: 'SET_TEST', + ADD: 'ADD_TEST', + EDIT: 'EDIT_TEST', + DELETE: 'DELETE_TEST', + SET_META: 'SET_TEST_META', + UPSERT: 'UPSERT_TEST', + }); + }); + }); + + describe('createInitialState', () => { + it('returns the correct initial state structure', () => { + const result = createInitialState(); + expect(result).toEqual({ + records: [], + meta: {}, + uiFlags: { + fetchingList: false, + fetchingItem: false, + creatingItem: false, + updatingItem: false, + deletingItem: false, + }, + }); + }); + }); + + describe('createGetters', () => { + it('returns getters with correct implementations', () => { + const getters = createGetters(); + + const state = { + records: [{ id: 2 }, { id: 1 }, { id: 3 }], + uiFlags: { fetchingList: true }, + meta: { totalCount: 10, page: 1 }, + }; + expect(getters.getRecords(state)).toEqual([ + { id: 3 }, + { id: 2 }, + { id: 1 }, + ]); + + expect(getters.getRecord(state)(2)).toEqual({ id: 2 }); + expect(getters.getRecord(state)(4)).toEqual({}); + + expect(getters.getUIFlags(state)).toEqual({ + fetchingList: true, + }); + + expect(getters.getMeta(state)).toEqual({ + totalCount: 10, + page: 1, + }); + }); + }); + + describe('createMutations', () => { + it('creates mutations with correct implementations', () => { + const mutationTypes = generateMutationTypes('test'); + const mutations = createMutations(mutationTypes); + + const state = { uiFlags: { fetchingList: false } }; + mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true }); + expect(state.uiFlags).toEqual({ fetchingList: true }); + + const metaState = { meta: {} }; + mutations[mutationTypes.SET_META](metaState, { + total_count: '10', + page: '2', + }); + expect(metaState.meta).toEqual({ totalCount: 10, page: 2 }); + + expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set); + expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create); + expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update); + expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy); + expect(mutations[mutationTypes.UPSERT]).toBe( + MutationHelpers.setSingleRecord + ); + }); + }); + + describe('createCrudActions', () => { + let API; + let commit; + let mutationTypes; + let actions; + + beforeEach(() => { + API = { + get: vi.fn(), + show: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + commit = vi.fn(); + mutationTypes = generateMutationTypes('test'); + actions = createCrudActions(API, mutationTypes); + }); + + describe('get action', () => { + it('handles successful API response', async () => { + const payload = [{ id: 1 }]; + const meta = { total_count: 10, page: 1 }; + API.get.mockResolvedValue({ data: { payload, meta } }); + + const result = await actions.get({ commit }); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingList: true, + }); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingList: false, + }); + expect(result).toEqual(payload); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.get.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.get({ commit }); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingList: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingList: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + + describe('show action', () => { + it('handles successful API response', async () => { + const data = { id: 1, name: 'Test' }; + API.show.mockResolvedValue({ data }); + + const result = await actions.show({ commit }, 1); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingItem: true, + }); + expect(commit).toHaveBeenCalledWith(mutationTypes.ADD, data); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingItem: false, + }); + expect(result).toEqual(data); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.show.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.show({ commit }, 1); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingItem: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingItem: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + + describe('create action', () => { + it('handles successful API response', async () => { + const data = { id: 1, name: 'Test' }; + API.create.mockResolvedValue({ data }); + + const result = await actions.create({ commit }, { name: 'Test' }); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + creatingItem: true, + }); + expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + creatingItem: false, + }); + expect(result).toEqual(data); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.create.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.create({ commit }, { name: 'Test' }); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + creatingItem: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + creatingItem: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + + describe('update action', () => { + it('handles successful API response', async () => { + const data = { id: 1, name: 'Updated' }; + API.update.mockResolvedValue({ data }); + + const result = await actions.update( + { commit }, + { id: 1, name: 'Updated' } + ); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + updatingItem: true, + }); + expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' }); + expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + updatingItem: false, + }); + expect(result).toEqual(data); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.update.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.update( + { commit }, + { id: 1, name: 'Updated' } + ); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + updatingItem: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + updatingItem: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + + describe('delete action', () => { + it('handles successful API response', async () => { + API.delete.mockResolvedValue({}); + + const result = await actions.delete({ commit }, 1); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + deletingItem: true, + }); + expect(API.delete).toHaveBeenCalledWith(1); + expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + deletingItem: false, + }); + expect(result).toEqual(1); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.delete.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.delete({ commit }, 1); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + deletingItem: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + deletingItem: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + }); + + describe('createStore', () => { + it('creates a complete store with default options', () => { + const API = {}; + const store = createStore({ name: 'test', API }); + + expect(store.namespaced).toBe(true); + expect(store.state).toEqual(createInitialState()); + expect(Object.keys(store.getters)).toEqual([ + 'getRecords', + 'getRecord', + 'getUIFlags', + 'getMeta', + ]); + expect(Object.keys(store.mutations)).toEqual([ + 'SET_TEST_UI_FLAG', + 'SET_TEST_META', + 'SET_TEST', + 'ADD_TEST', + 'EDIT_TEST', + 'DELETE_TEST', + 'UPSERT_TEST', + ]); + expect(Object.keys(store.actions)).toEqual([ + 'get', + 'show', + 'create', + 'update', + 'delete', + ]); + }); + + it('creates a store with custom actions and getters', () => { + const API = {}; + const customGetters = { customGetter: () => 'custom' }; + const customActions = () => ({ + customAction: () => 'custom', + }); + + const store = createStore({ + name: 'test', + API, + getters: customGetters, + actions: customActions, + }); + + expect(store.getters).toHaveProperty('customGetter'); + expect(store.actions).toHaveProperty('customAction'); + expect(Object.keys(store.getters)).toEqual([ + 'getRecords', + 'getRecord', + 'getUIFlags', + 'getMeta', + 'customGetter', + ]); + expect(Object.keys(store.actions)).toEqual([ + 'get', + 'show', + 'create', + 'update', + 'delete', + 'customAction', + ]); + }); + }); +}); diff --git a/app/javascript/dashboard/store/captain/storeFactoryHelper.js b/app/javascript/dashboard/store/captain/storeFactoryHelper.js new file mode 100644 index 000000000..7a04d2e81 --- /dev/null +++ b/app/javascript/dashboard/store/captain/storeFactoryHelper.js @@ -0,0 +1,77 @@ +import { throwErrorMessage } from 'dashboard/store/utils/api'; + +export const getRecords = + (mutationTypes, API) => + async ({ commit }, params = {}) => { + commit(mutationTypes.SET_UI_FLAG, { fetchingList: true }); + try { + const response = await API.get(params); + commit(mutationTypes.SET, response.data.payload); + commit(mutationTypes.SET_META, response.data.meta); + return response.data.payload; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { fetchingList: false }); + } + }; + +export const showRecord = + (mutationTypes, API) => + async ({ commit }, id) => { + commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true }); + try { + const response = await API.show(id); + commit(mutationTypes.ADD, response.data); + return response.data; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false }); + } + }; + +export const createRecord = + (mutationTypes, API) => + async ({ commit }, dataObj) => { + commit(mutationTypes.SET_UI_FLAG, { creatingItem: true }); + try { + const response = await API.create(dataObj); + commit(mutationTypes.UPSERT, response.data); + return response.data; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { creatingItem: false }); + } + }; + +export const updateRecord = + (mutationTypes, API) => + async ({ commit }, { id, ...updateObj }) => { + commit(mutationTypes.SET_UI_FLAG, { updatingItem: true }); + try { + const response = await API.update(id, updateObj); + commit(mutationTypes.EDIT, response.data); + return response.data; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { updatingItem: false }); + } + }; + +export const deleteRecord = + (mutationTypes, API) => + async ({ commit }, id) => { + commit(mutationTypes.SET_UI_FLAG, { deletingItem: true }); + try { + await API.delete(id); + commit(mutationTypes.DELETE, id); + return id; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { deletingItem: false }); + } + }; diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 5daf73ae1..960285ebf 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -51,6 +51,9 @@ import captainDocuments from './captain/document'; import captainResponses from './captain/response'; import captainInboxes from './captain/inboxes'; import captainBulkActions from './captain/bulkActions'; +import copilotThreads from './captain/copilotThreads'; +import copilotMessages from './captain/copilotMessages'; + const plugins = []; export default createStore({ @@ -106,6 +109,8 @@ export default createStore({ captainResponses, captainInboxes, captainBulkActions, + copilotThreads, + copilotMessages, }, plugins, });