From 8864f80ab7b9a2ba6e510ec7f4b9163d16e326cf Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:39:49 +0530 Subject: [PATCH] feat(captain): show document conversation usage (#15140) ## Summary Adds conversation usage to Captain documents and user created FAQs. Only knowledge used in a Captain answer sent to the contact is counted. Lookups that end in a handoff are excluded. Administrators can see how many distinct conversations used a knowledge record. Document usage appears in the Usage tab inside document details. User created FAQ usage appears on each FAQ card and opens in a conversation panel. Deleted conversations are excluded from counts, sorting, and conversation lists. Usage shown in side panel CleanShot 2026-08-12 at 18 29
02@2x Sorting options image ## Access Conversation usage is available only to administrators. Agents can still view documents and FAQs, but they cannot see usage counts, the Usage tab, the "Most used" sort, or conversation usage details. The API applies the same rule. An agent request for `sort=most_used` is rejected. ## Performance and pagination Document and FAQ lists return 25 records per page. Usage counts are calculated only for the records in each page. Conversation usage panels load 25 conversations at a time and show a "Load more" action when more conversations are available. The generated FAQs tab also keeps its existing 25 item pagination. The count queries use the JSON indexes on `document_ids` and `used_faq_ids`. The "Most used" sort aggregates one assistant's sessions once before it sorts and returns the requested document page. ## How to test 1. Sign in as an administrator and open the Captain documents page for an assistant with tracked document usage. 2. Open a document and confirm that the Usage tab shows the distinct conversation count and the matching conversations. 3. Select "Most used" and confirm that documents are ordered by distinct conversation usage. 4. Open the user created FAQs page and confirm that FAQ cards show their usage count and open the matching conversations. 5. Confirm that usage panels show 25 conversations first and can load the next page. 6. Trigger a knowledge lookup that ends in a handoff and confirm that it does not increase document or FAQ usage. 7. Sign in as an agent and confirm that usage counts, usage details, and the "Most used" sort are not available. ## Closes [CW-7498](https://linear.app/chatwoot/issue/CW-7498/fe) --- .../dashboard/api/captain/document.js | 7 + .../dashboard/api/captain/response.js | 7 + .../captain/assistant/DocumentCard.spec.js | 93 +++++++++ .../captain/assistant/DocumentCard.vue | 47 ++--- .../assistant/DocumentFiltersBar.spec.js | 79 ++++++++ .../captain/assistant/DocumentFiltersBar.vue | 24 ++- .../captain/assistant/ResponseCard.spec.js | 98 +++++++++ .../captain/assistant/ResponseCard.vue | 58 +++++- .../ConversationUsageDrawer.spec.js | 93 +++++++++ .../ConversationUsageDrawer.vue | 129 ++++++++++++ .../document/DocumentDetails.spec.js | 130 +++++++++++- .../document/DocumentDetails.vue | 104 +++++++++- .../i18n/locale/en/integrations.json | 7 +- .../dashboard/captain/responses/Index.vue | 33 +++ .../reports/composables/useReportDrilldown.js | 5 +- config/routes.rb | 5 +- ...dd_index_on_agent_sessions_document_ids.rb | 7 + db/schema.rb | 3 +- .../captain/conversation_usage_builder.rb | 188 ++++++++++++++++++ .../captain/assistant_responses_controller.rb | 25 ++- .../accounts/captain/documents_controller.rb | 32 ++- .../app/models/captain/agent_session.rb | 2 + .../assistant_responses/index.json.jbuilder | 1 + .../captain/documents/index.json.jbuilder | 1 + .../assistant_responses_controller_spec.rb | 145 ++++++++++++++ .../captain/documents_controller_spec.rb | 183 +++++++++++++++++ 26 files changed, 1442 insertions(+), 64 deletions(-) create mode 100644 app/javascript/dashboard/components-next/captain/assistant/DocumentCard.spec.js create mode 100644 app/javascript/dashboard/components-next/captain/assistant/DocumentFiltersBar.spec.js create mode 100644 app/javascript/dashboard/components-next/captain/assistant/ResponseCard.spec.js create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/ConversationUsageDrawer.spec.js create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/ConversationUsageDrawer.vue create mode 100644 db/migrate/20260806000000_add_index_on_agent_sessions_document_ids.rb create mode 100644 enterprise/app/builders/captain/conversation_usage_builder.rb diff --git a/app/javascript/dashboard/api/captain/document.js b/app/javascript/dashboard/api/captain/document.js index e23a8c460..2b33d86ac 100644 --- a/app/javascript/dashboard/api/captain/document.js +++ b/app/javascript/dashboard/api/captain/document.js @@ -22,6 +22,13 @@ class CaptainDocument extends ApiClient { sync(id) { return axios.post(`${this.url}/${id}/sync`); } + + getDrilldown({ documentId, page, signal }) { + const requestConfig = { params: { page } }; + if (signal) requestConfig.signal = signal; + + return axios.get(`${this.url}/${documentId}/drilldown`, requestConfig); + } } export default new CaptainDocument(); diff --git a/app/javascript/dashboard/api/captain/response.js b/app/javascript/dashboard/api/captain/response.js index 6e4ccf5fe..f4a5c6696 100644 --- a/app/javascript/dashboard/api/captain/response.js +++ b/app/javascript/dashboard/api/captain/response.js @@ -17,6 +17,13 @@ class CaptainResponses extends ApiClient { signal, }); } + + getDrilldown({ responseId, page, signal }) { + const requestConfig = { params: { page } }; + if (signal) requestConfig.signal = signal; + + return axios.get(`${this.url}/${responseId}/drilldown`, requestConfig); + } } export default new CaptainResponses(); diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.spec.js b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.spec.js new file mode 100644 index 000000000..6d8c20a5e --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.spec.js @@ -0,0 +1,93 @@ +import { shallowMount } from '@vue/test-utils'; +import DocumentCard from './DocumentCard.vue'; + +const { checkPermissions } = vi.hoisted(() => ({ + checkPermissions: vi.fn(() => true), +})); + +vi.mock('dashboard/composables/usePolicy', () => ({ + usePolicy: () => ({ checkPermissions }), +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key, { n } = {}) => { + if (key === 'CAPTAIN.DOCUMENTS.FAQ_COUNT') return `${n} FAQs`; + if (key === 'CAPTAIN.DOCUMENTS.USED_IN_CONVERSATIONS') { + return `Used in ${n} conversations`; + } + return key; + }, + }), +})); + +const ButtonStub = { + inheritAttrs: false, + props: ['label', 'disabled'], + emits: ['click'], + template: + '', +}; + +const mountCard = (props = {}) => + shallowMount(DocumentCard, { + props: { + id: 42, + name: 'Returns and refunds', + assistant: { name: 'Acme assistant' }, + externalLink: + 'https://example.com/help/articles/refund-and-return-policy-for-online-orders', + createdAt: 1_700_000_000, + status: 'available', + responsesCount: 12, + ...props, + }, + global: { + directives: { onClickaway: {} }, + stubs: { + Button: ButtonStub, + CardLayout: { template: '
' }, + DocumentSyncStatus: true, + DropdownMenu: true, + Checkbox: true, + Icon: true, + }, + }, + }); + +describe('DocumentCard', () => { + beforeEach(() => { + checkPermissions.mockReturnValue(true); + }); + + it('keeps the source URL flexible and the metadata row on one line', () => { + const wrapper = mountCard(); + const sourceLink = wrapper.get('a[href^="https://example.com"]'); + const metadataRow = sourceLink.element.parentElement; + + expect(sourceLink.classes()).toContain('flex-1'); + expect(sourceLink.classes()).toContain('truncate'); + expect(metadataRow.classList).not.toContain('flex-wrap'); + }); + + it('keeps conversation usage out of the document card', () => { + const wrapper = mountCard(); + + expect(wrapper.find('[aria-label^="Used in"]').exists()).toBe(false); + }); + + it('opens details from the document title without a separate action', async () => { + const wrapper = mountCard(); + const titleButton = wrapper + .findAll('button') + .find(button => button.text() === 'Returns and refunds'); + + expect(titleButton).toBeDefined(); + expect(wrapper.text()).not.toContain('View details'); + await titleButton.trigger('click'); + + expect(wrapper.emitted('action')).toEqual([ + [{ action: 'viewDetails', id: 42 }], + ]); + }); +}); diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue index 8ff38b2eb..ac97417a1 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue @@ -114,14 +114,7 @@ const isRetryableSync = computed( const showSyncStatus = computed(() => !isPdf.value); const menuItems = computed(() => { - const allOptions = [ - { - label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_DETAILS'), - value: 'viewDetails', - action: 'viewDetails', - icon: 'i-lucide-eye', - }, - ]; + const allOptions = []; if (canSync.value) { allOptions.push({ @@ -150,7 +143,6 @@ const createdAtLabel = computed(() => dynamicTime(props.createdAt)); const responsesCountLabel = computed(() => t('CAPTAIN.DOCUMENTS.FAQ_COUNT', { n: props.responsesCount }) ); - const displayLink = computed(() => isPdf.value ? formatDocumentLink(props.externalLink) @@ -195,25 +187,24 @@ const handleRetry = () => { > {{ name }} -
-
-
+
+
diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentFiltersBar.spec.js b/app/javascript/dashboard/components-next/captain/assistant/DocumentFiltersBar.spec.js new file mode 100644 index 000000000..7bcb9eaf3 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentFiltersBar.spec.js @@ -0,0 +1,79 @@ +import { mount } from '@vue/test-utils'; +import DocumentFiltersBar from './DocumentFiltersBar.vue'; + +const { checkPermissions } = vi.hoisted(() => ({ + checkPermissions: vi.fn(() => true), +})); + +vi.mock('dashboard/composables/usePolicy', () => ({ + usePolicy: () => ({ checkPermissions }), +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: key => key.split('.').at(-1).replaceAll('_', ' '), + }), +})); + +const ButtonStub = { + props: ['icon'], + emits: ['click'], + template: '', +}; + +const DropdownMenuStub = { + props: ['menuItems'], + emits: ['action'], + template: '
', +}; + +const mountFilterBar = () => + mount(DocumentFiltersBar, { + global: { + stubs: { + Button: ButtonStub, + DropdownMenu: DropdownMenuStub, + Icon: true, + }, + }, + }); + +describe('DocumentFiltersBar', () => { + beforeEach(() => { + checkPermissions.mockReturnValue(true); + }); + + it('sorts documents by conversation usage from the existing sort menu', async () => { + const wrapper = mountFilterBar(); + const buttons = wrapper.findAllComponents(ButtonStub); + + await buttons.at(-1).trigger('click'); + + const dropdown = wrapper.getComponent(DropdownMenuStub); + expect(dropdown.props('menuItems')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: 'MOST USED', + value: 'most_used', + icon: 'i-lucide-messages-square', + }), + ]) + ); + + dropdown.vm.$emit('action', { action: 'sort', value: 'most_used' }); + expect(wrapper.emitted('selectSort')).toEqual([['most_used']]); + }); + + it('hides conversation usage sorting from agents', async () => { + checkPermissions.mockReturnValue(false); + const wrapper = mountFilterBar(); + const buttons = wrapper.findAllComponents(ButtonStub); + + await buttons.at(-1).trigger('click'); + + const dropdown = wrapper.getComponent(DropdownMenuStub); + expect(dropdown.props('menuItems')).not.toEqual( + expect.arrayContaining([expect.objectContaining({ value: 'most_used' })]) + ); + }); +}); diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentFiltersBar.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentFiltersBar.vue index b0a5b9dd0..5fb976e01 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/DocumentFiltersBar.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentFiltersBar.vue @@ -2,6 +2,7 @@ import { computed, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { vOnClickOutside } from '@vueuse/components'; +import { usePolicy } from 'dashboard/composables/usePolicy'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import Button from 'dashboard/components-next/button/Button.vue'; @@ -16,8 +17,10 @@ const props = defineProps({ const emit = defineEmits(['selectSource', 'selectStatus', 'selectSort']); const { t } = useI18n(); +const { checkPermissions } = usePolicy(); const openMenu = ref(null); +const canViewUsage = computed(() => checkPermissions(['administrator'])); const MENU_CONFIG = [ { @@ -73,6 +76,11 @@ const MENU_CONFIG = [ value: 'recently_created', icon: 'i-lucide-clock', }, + { + labelKey: 'SORT.MOST_USED', + value: 'most_used', + icon: 'i-lucide-messages-square', + }, ], }, ]; @@ -82,13 +90,15 @@ const filterMenus = computed(() => menu => !(menu.key === 'status' && props.activeSourceFilter === 'pdf') ).map(menu => { const active = props[menu.activeKey]; - const items = menu.options.map(opt => ({ - label: t(`CAPTAIN.DOCUMENTS.FILTERS.${opt.labelKey}`), - value: opt.value, - icon: opt.icon, - action: menu.key, - isSelected: opt.value === active, - })); + const items = menu.options + .filter(option => option.value !== 'most_used' || canViewUsage.value) + .map(opt => ({ + label: t(`CAPTAIN.DOCUMENTS.FILTERS.${opt.labelKey}`), + value: opt.value, + icon: opt.icon, + action: menu.key, + isSelected: opt.value === active, + })); return { ...menu, items, diff --git a/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.spec.js b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.spec.js new file mode 100644 index 000000000..9dad7e76c --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.spec.js @@ -0,0 +1,98 @@ +import { shallowMount } from '@vue/test-utils'; +import ResponseCard from './ResponseCard.vue'; + +const { checkPermissions } = vi.hoisted(() => ({ + checkPermissions: vi.fn(() => true), +})); + +vi.mock('dashboard/composables/usePolicy', () => ({ + usePolicy: () => ({ checkPermissions }), +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key, { n } = {}) => { + if (key === 'CAPTAIN.DOCUMENTS.USED_IN_CONVERSATIONS') { + return `Used in ${n} conversations`; + } + return key; + }, + }), +})); + +const ButtonStub = { + inheritAttrs: false, + props: ['label', 'disabled'], + emits: ['click'], + template: + '', +}; + +const mountCard = (props = {}) => + shallowMount(ResponseCard, { + props: { + id: 42, + question: 'How long do refunds take?', + answer: 'Refunds take five business days.', + assistant: { name: 'Acme assistant' }, + documentable: { + id: 1, + type: 'User', + available_name: 'John', + }, + createdAt: 1_700_000_000, + updatedAt: 1_700_000_000, + usedInConversationsCount: 4, + ...props, + }, + global: { + directives: { onClickaway: {} }, + stubs: { + Button: ButtonStub, + CardLayout: { template: '
' }, + Policy: { template: '
' }, + DropdownMenu: true, + Checkbox: true, + Icon: true, + }, + }, + }); + +describe('ResponseCard', () => { + beforeEach(() => { + checkPermissions.mockReturnValue(true); + }); + + it('opens conversation usage for a user-created FAQ', async () => { + const wrapper = mountCard(); + const usageButton = wrapper.get('[aria-label="Used in 4 conversations"]'); + + expect(usageButton.text()).toBe('4'); + await usageButton.trigger('click'); + expect(wrapper.emitted('viewConversations')).toEqual([[42]]); + }); + + it('shows zero usage as disabled metadata', () => { + const wrapper = mountCard({ usedInConversationsCount: 0 }); + const usageButton = wrapper.get('[aria-label="Used in 0 conversations"]'); + + expect(usageButton.text()).toBe('0'); + expect(usageButton.attributes('disabled')).toBeDefined(); + }); + + it('does not show usage for an FAQ without a user source', () => { + const wrapper = mountCard({ + documentable: null, + usedInConversationsCount: 4, + }); + + expect(wrapper.find('[aria-label^="Used in"]').exists()).toBe(false); + }); + + it('hides usage analytics from users who cannot manage the assistant', () => { + checkPermissions.mockReturnValue(false); + const wrapper = mountCard(); + + expect(wrapper.find('[aria-label^="Used in"]').exists()).toBe(false); + }); +}); diff --git a/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue index 97227c35d..546f8442c 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue @@ -3,6 +3,7 @@ import { computed } from 'vue'; import { useToggle } from '@vueuse/core'; import { useI18n } from 'vue-i18n'; import { dynamicTime } from 'shared/helpers/timeHelper'; +import { usePolicy } from 'dashboard/composables/usePolicy'; import CardLayout from 'dashboard/components-next/CardLayout.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; @@ -48,6 +49,10 @@ const props = defineProps({ type: Number, required: true, }, + usedInConversationsCount: { + type: Number, + default: null, + }, isSelected: { type: Boolean, default: false, @@ -66,9 +71,16 @@ const props = defineProps({ }, }); -const emit = defineEmits(['action', 'navigate', 'select', 'hover']); +const emit = defineEmits([ + 'action', + 'navigate', + 'select', + 'hover', + 'viewConversations', +]); const { t } = useI18n(); +const { checkPermissions } = usePolicy(); const [showActionsDropdown, toggleDropdown] = useToggle(); @@ -110,6 +122,20 @@ const menuItems = computed(() => [ const timestamp = computed(() => dynamicTime(props.updatedAt || props.createdAt) ); +const canManage = computed(() => checkPermissions(['administrator'])); +const hasConversationUsage = computed( + () => + props.documentable?.type === 'User' && + props.usedInConversationsCount !== null +); +const usedInConversationsLabel = computed(() => + t('CAPTAIN.DOCUMENTS.USED_IN_CONVERSATIONS', { + n: props.usedInConversationsCount, + }) +); +const usedInConversationsCountText = computed(() => + String(props.usedInConversationsCount) +); const handleAssistantAction = ({ action, value }) => { toggleDropdown(false); @@ -122,6 +148,12 @@ const handleDocumentableClick = () => { type: props.documentable.type, }); }; + +const handleViewConversations = () => { + if (!props.usedInConversationsCount) return; + + emit('viewConversations', props.id); +};