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
<img width="2342" height="1502" alt="CleanShot 2026-08-12 at 18 29
02@2x"
src="https://github.com/user-attachments/assets/67301051-6df5-4eb6-9b8d-e1fc75bb02f2"
/>

Sorting options
<img width="580" height="241" alt="image"
src="https://github.com/user-attachments/assets/078566b7-481b-491b-b484-c2fef481f1b1"
/>


## 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)
This commit is contained in:
Aakash Bakhle
2026-08-13 17:39:49 +05:30
committed by GitHub
parent b3a1dbca81
commit 8864f80ab7
26 changed files with 1442 additions and 64 deletions

View File

@@ -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();

View File

@@ -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();

View File

@@ -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:
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\', $event)">{{ label }}</button>',
};
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: '<div><slot /></div>' },
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 }],
]);
});
});

View File

@@ -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 }}
</button>
<div v-if="showMenu" class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="flex relative items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="top-full mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0"
@action="handleAction($event)"
/>
</div>
<div
v-if="showMenu && menuItems.length"
v-on-clickaway="() => toggleDropdown(false)"
class="flex relative items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="top-full mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0"
@action="handleAction($event)"
/>
</div>
</div>
<div class="flex gap-4 justify-between items-center w-full">

View File

@@ -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: '<button @click="$emit(\'click\')"><slot /></button>',
};
const DropdownMenuStub = {
props: ['menuItems'],
emits: ['action'],
template: '<div data-test="dropdown-menu" />',
};
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' })])
);
});
});

View File

@@ -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,

View File

@@ -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:
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\', $event)">{{ label }}</button>',
};
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: '<div><slot /></div>' },
Policy: { template: '<div><slot /></div>' },
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);
});
});

View File

@@ -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);
};
</script>
<template>
@@ -267,11 +299,25 @@ const handleDocumentableClick = () => {
</span>
</div>
</div>
<div
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
>
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
{{ timestamp }}
<div class="inline-flex shrink-0 items-center gap-3">
<Button
v-if="canManage && hasConversationUsage"
v-tooltip.top="usedInConversationsLabel"
:label="usedInConversationsCountText"
:aria-label="usedInConversationsLabel"
:disabled="!usedInConversationsCount"
icon="i-lucide-messages-square"
size="xs"
slate
link
@click.stop="handleViewConversations"
/>
<div
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
>
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
{{ timestamp }}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,93 @@
import { flushPromises, mount } from '@vue/test-utils';
import ConversationUsageDrawer from './ConversationUsageDrawer.vue';
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, { n } = {}) => {
if (key === 'CAPTAIN.DOCUMENTS.USED_IN_CONVERSATIONS') {
return `Used in ${n} ${n === 1 ? 'conversation' : 'conversations'}`;
}
return key;
},
}),
}));
const payload = [
{
record_type: 'conversation',
conversation: { id: 10, display_id: 42 },
message: null,
occurred_at: 1_700_000_000,
},
];
const SidePanelStub = {
name: 'SidePanel',
methods: { open() {}, close() {} },
emits: ['close'],
template:
'<section><slot name="header" /><slot /><button data-test="close" @click="$emit(\'close\')" /></section>',
};
const mountDrawer = async fetcher => {
const wrapper = mount(ConversationUsageDrawer, {
props: {
open: false,
resourceId: 7,
title: 'Returns and refunds',
conversationCount: 1,
fetcher,
emptyStateKey: 'CAPTAIN.RESPONSES.NO_USED_CONVERSATIONS',
},
global: {
stubs: {
SidePanel: SidePanelStub,
Spinner: true,
Button: {
props: ['label'],
template: '<button>{{ label }}</button>',
},
ReportDrilldownCard: {
props: ['record'],
template:
'<div data-test="conversation-card">#{{ record.conversation.display_id }}</div>',
},
},
mocks: { $t: key => key },
},
});
await wrapper.setProps({ open: true });
return wrapper;
};
describe('ConversationUsageDrawer', () => {
it('loads conversations through the supplied shared drilldown fetcher', async () => {
const fetcher = vi.fn().mockResolvedValue({
data: {
meta: { current_page: 1, total_count: 1, conversation_count: 1 },
payload,
},
});
const wrapper = await mountDrawer(fetcher);
await flushPromises();
expect(fetcher).toHaveBeenCalledWith(
expect.objectContaining({ resourceId: 7, page: 1 })
);
expect(wrapper.text()).toContain('Returns and refunds');
expect(wrapper.text()).toContain('Used in 1 conversation');
expect(wrapper.get('[data-test="conversation-card"]').text()).toBe('#42');
});
it('emits close when the shared side panel closes', async () => {
const fetcher = vi
.fn()
.mockResolvedValue({ data: { meta: {}, payload: [] } });
const wrapper = await mountDrawer(fetcher);
await flushPromises();
await wrapper.get('[data-test="close"]').trigger('click');
expect(wrapper.emitted('close')).toBeTruthy();
});
});

View File

@@ -0,0 +1,129 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown';
import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
const props = defineProps({
open: { type: Boolean, default: false },
resourceId: { type: [Number, String], default: null },
title: { type: String, default: '' },
conversationCount: { type: Number, default: 0 },
fetcher: { type: Function, required: true },
emptyStateKey: { type: String, required: true },
});
const emit = defineEmits(['close']);
const panelRef = ref(null);
const { t } = useI18n();
const {
records,
meta,
isFetching,
isFetchingMore,
hasError,
hasRecords,
hasMore,
open: openDrilldown,
close,
loadMore,
} = useReportDrilldown(params => props.fetcher(params));
const resolvedConversationCount = computed(
() => meta.value.conversation_count ?? props.conversationCount
);
const subtitle = computed(() =>
t('CAPTAIN.DOCUMENTS.USED_IN_CONVERSATIONS', {
n: resolvedConversationCount.value,
})
);
const recordKey = record =>
`${record.record_type}-${record.message?.id || record.conversation?.id}-${
record.occurred_at
}`;
const fetchDrilldown = () => {
if (!props.resourceId) return;
openDrilldown({ resourceId: props.resourceId });
};
watch(
() => props.open,
isDrawerOpen => {
if (!isDrawerOpen) {
panelRef.value?.close();
close();
return;
}
panelRef.value?.open();
fetchDrilldown();
}
);
watch(
() => props.resourceId,
() => {
if (props.open) fetchDrilldown();
}
);
</script>
<template>
<SidePanel ref="panelRef" :title="title" width="xl" @close="emit('close')">
<template #header>
<div class="min-w-0">
<h3 class="truncate text-base font-medium text-n-slate-12">
{{ title }}
</h3>
<p class="mt-1 text-sm text-n-slate-11">
{{ subtitle }}
</p>
</div>
</template>
<div v-if="isFetching" class="flex h-40 items-center justify-center">
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t(emptyStateKey) }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</SidePanel>
</template>

View File

@@ -1,8 +1,9 @@
import { flushPromises, shallowMount } from '@vue/test-utils';
import DocumentDetails from './DocumentDetails.vue';
const { dispatch, getterValues } = vi.hoisted(() => ({
const { dispatch, getDrilldown, getterValues } = vi.hoisted(() => ({
dispatch: vi.fn(),
getDrilldown: vi.fn(),
getterValues: {
'captainResponses/getUIFlags': { value: { fetchingList: false } },
'captainResponses/getRecords': { value: [] },
@@ -10,15 +11,32 @@ const { dispatch, getterValues } = vi.hoisted(() => ({
},
}));
const { checkPermissions } = vi.hoisted(() => ({
checkPermissions: vi.fn(() => true),
}));
vi.mock('dashboard/composables/store', () => ({
useStore: () => ({ dispatch }),
useMapGetter: key => getterValues[key],
}));
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
vi.mock('dashboard/composables/usePolicy', () => ({
usePolicy: () => ({ checkPermissions }),
}));
vi.mock('dashboard/api/captain/document', () => ({
default: { getDrilldown },
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
useI18n: () => ({
t: (key, { n } = {}) => {
if (key === 'CAPTAIN.DOCUMENTS.USED_IN_CONVERSATIONS') {
return `Used in ${n} conversations`;
}
return key;
},
}),
}));
const captainDocument = {
@@ -28,6 +46,7 @@ const captainDocument = {
assistant: { id: 7 },
content: 'Document content',
pdf_document: false,
used_in_conversations_count: 8,
};
const SidePanelStub = {
@@ -38,8 +57,17 @@ const SidePanelStub = {
const TabBarStub = {
name: 'TabBar',
template:
'<button data-test="faq-tab" @click="$emit(\'tabChanged\', { key: \'faqs\' })" />',
props: ['tabs'],
template: `
<div>
<button data-test="faq-tab" @click="$emit('tabChanged', { key: 'faqs' })" />
<button
v-if="tabs.some(tab => tab.key === 'usage')"
data-test="usage-tab"
@click="$emit('tabChanged', { key: 'usage' })"
/>
</div>
`,
};
const PaginationFooterStub = {
@@ -48,10 +76,32 @@ const PaginationFooterStub = {
'<button data-test="next-page" @click="$emit(\'update:currentPage\', 2)" />',
};
const ButtonStub = {
inheritAttrs: false,
props: ['label', 'disabled'],
emits: ['click'],
template:
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\', $event)">{{ label }}</button>',
};
describe('DocumentDetails', () => {
beforeEach(() => {
vi.clearAllMocks();
dispatch.mockResolvedValue([]);
getDrilldown.mockResolvedValue({
data: {
meta: { current_page: 1, total_count: 1, conversation_count: 1 },
payload: [
{
record_type: 'conversation',
conversation: { id: 10, display_id: 77 },
message: null,
occurred_at: 1_700_000_000,
},
],
},
});
checkPermissions.mockReturnValue(true);
});
it('requests another FAQ page when the document has more than 25 FAQs', async () => {
@@ -63,6 +113,12 @@ describe('DocumentDetails', () => {
SidePanel: SidePanelStub,
TabBar: TabBarStub,
PaginationFooter: PaginationFooterStub,
Button: ButtonStub,
ReportDrilldownCard: {
props: ['record'],
template:
'<div data-test="conversation-card">#{{ record.conversation.display_id }}</div>',
},
},
},
});
@@ -84,4 +140,70 @@ describe('DocumentDetails', () => {
documentId: 42,
});
});
it('loads document usage in the third details tab', async () => {
const wrapper = shallowMount(DocumentDetails, {
props: { captainDocument },
global: {
directives: { dompurifyHtml: {} },
stubs: {
SidePanel: SidePanelStub,
TabBar: TabBarStub,
PaginationFooter: PaginationFooterStub,
Button: ButtonStub,
ReportDrilldownCard: {
props: ['record'],
template:
'<div data-test="conversation-card">#{{ record.conversation.display_id }}</div>',
},
},
},
});
expect(wrapper.getComponent(TabBarStub).props('tabs')).toEqual([
{
key: 'content',
label: 'CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TAB',
},
{
key: 'faqs',
label: 'CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE',
count: 26,
},
{
key: 'usage',
label: 'CAPTAIN.DOCUMENTS.DETAILS.USED_IN_CONVERSATIONS',
count: 8,
},
]);
await wrapper.get('[data-test="usage-tab"]').trigger('click');
await flushPromises();
expect(getDrilldown).toHaveBeenCalledWith(
expect.objectContaining({ documentId: 42, page: 1 })
);
expect(wrapper.get('[data-test="conversation-card"]').text()).toBe('#77');
expect(wrapper.emitted('viewConversations')).toBeUndefined();
});
it('hides the document usage tab from users who cannot manage the assistant', () => {
checkPermissions.mockReturnValue(false);
const wrapper = shallowMount(DocumentDetails, {
props: { captainDocument },
global: {
directives: { dompurifyHtml: {} },
stubs: {
SidePanel: SidePanelStub,
TabBar: TabBarStub,
PaginationFooter: PaginationFooterStub,
Button: ButtonStub,
ReportDrilldownCard: true,
},
},
});
expect(wrapper.find('[data-test="usage-tab"]').exists()).toBe(false);
});
});

View File

@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { messageTimestamp } from 'shared/helpers/timeHelper';
@@ -17,6 +18,9 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import CaptainDocumentAPI from 'dashboard/api/captain/document';
import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown';
import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue';
import ResponseCard from '../../assistant/ResponseCard.vue';
const props = defineProps({
@@ -29,16 +33,19 @@ const emit = defineEmits(['close']);
const TAB_KEYS = {
CONTENT: 'content',
FAQS: 'faqs',
USAGE: 'usage',
};
const RESPONSES_PER_PAGE = 25;
const { t } = useI18n();
const store = useStore();
const { checkPermissions } = usePolicy();
// The parent mounts this component with v-if, so the panel opens on mount and
// the parent unmounts it only after the slide-out finishes (afterLeave).
const panelRef = ref(null);
const documentDetails = computed(() => props.captainDocument);
const showRawContent = ref(false);
const activeTabIndex = ref(0);
const canManage = computed(() => checkPermissions(['administrator']));
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
@@ -67,14 +74,29 @@ const contentTabLabel = computed(() =>
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TAB')
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TAB')
);
const tabs = computed(() => [
{ key: TAB_KEYS.CONTENT, label: contentTabLabel.value },
{
key: TAB_KEYS.FAQS,
label: t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE'),
count: totalCount.value,
},
]);
const usedInConversationsCount = computed(
() => documentDetails.value.used_in_conversations_count || 0
);
const tabs = computed(() => {
const documentTabs = [
{ key: TAB_KEYS.CONTENT, label: contentTabLabel.value },
{
key: TAB_KEYS.FAQS,
label: t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE'),
count: totalCount.value,
},
];
if (canManage.value) {
documentTabs.push({
key: TAB_KEYS.USAGE,
label: t('CAPTAIN.DOCUMENTS.DETAILS.USED_IN_CONVERSATIONS'),
count: usedInConversationsCount.value,
});
}
return documentTabs;
});
const activeTabKey = computed(() => tabs.value[activeTabIndex.value]?.key);
const isUnreadableContent = computed(() => {
if (!documentContent.value) return false;
@@ -132,6 +154,24 @@ const documentTitle = computed(
() => documentDetails.value.name || documentDetails.value.external_link
);
const fetchDocumentUsage = ({ resourceId, ...params }) =>
CaptainDocumentAPI.getDrilldown({ documentId: resourceId, ...params });
const {
records: usageRecords,
isFetching: isUsageFetching,
isFetchingMore: isUsageFetchingMore,
hasError: hasUsageError,
hasRecords: hasUsageRecords,
hasMore: hasMoreUsage,
open: openUsage,
close: closeUsage,
loadMore: loadMoreUsage,
} = useReportDrilldown(fetchDocumentUsage);
const usageRecordKey = record =>
`${record.record_type}-${record.message?.id || record.conversation?.id}-${record.occurred_at}`;
const handleCopyContent = async () => {
try {
await copyTextToClipboard(documentContent.value);
@@ -143,6 +183,10 @@ const handleCopyContent = async () => {
const handleTabChanged = tab => {
activeTabIndex.value = tabs.value.findIndex(item => item.key === tab.key);
if (tab.key === TAB_KEYS.USAGE) {
openUsage({ resourceId: documentDetails.value.id });
}
};
const fetchResponses = (page = 1) => {
@@ -161,6 +205,8 @@ onMounted(() => {
panelRef.value.open();
fetchResponses();
});
onUnmounted(closeUsage);
</script>
<template>
@@ -366,6 +412,46 @@ onMounted(() => {
/>
</footer>
</section>
<section
v-if="activeTabKey === TAB_KEYS.USAGE"
class="flex flex-col gap-3"
>
<div
v-if="isUsageFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="hasUsageError"
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasUsageRecords"
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.DOCUMENTS.NO_USED_CONVERSATIONS') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in usageRecords"
:key="usageRecordKey(record)"
:record="record"
/>
<Button
v-if="hasMoreUsage"
:label="t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
:is-loading="isUsageFetchingMore"
slate
outline
class="self-center mt-2"
@click="loadMoreUsage"
/>
</div>
</section>
</div>
</div>
</SidePanel>

View File

@@ -900,6 +900,8 @@
"HEADER": "Documents",
"ADD_NEW": "Create a new document",
"FAQ_COUNT": "{n} FAQ | {n} FAQs",
"USED_IN_CONVERSATIONS": "Used in {n} conversation | Used in {n} conversations",
"NO_USED_CONVERSATIONS": "No conversations found for this document.",
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
@@ -937,7 +939,8 @@
},
"SORT": {
"RECENTLY_UPDATED": "Recently updated",
"RECENTLY_CREATED": "Recently created"
"RECENTLY_CREATED": "Recently created",
"MOST_USED": "Most used in conversations"
},
"SEARCH_PLACEHOLDER": "Search..."
},
@@ -965,6 +968,7 @@
"DESCRIPTION": "Review the crawled content and the FAQs generated from this source.",
"SOURCE": "Source",
"GENERATED_FAQS": "Generated FAQs",
"USED_IN_CONVERSATIONS": "Used in conversations",
"LAST_UPDATED": "Last updated",
"NOT_AVAILABLE": "Not available",
"CONTENT_TAB": "Crawled content",
@@ -1168,6 +1172,7 @@
"RESPONSES": {
"HEADER": "FAQs",
"ADD_NEW": "Create new FAQ",
"NO_USED_CONVERSATIONS": "No conversations found for this FAQ.",
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
},

View File

@@ -22,6 +22,7 @@ import CreateResponseDialog from 'dashboard/components-next/captain/pageComponen
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
import ConversationUsageDrawer from 'dashboard/components-next/captain/pageComponents/ConversationUsageDrawer.vue';
const router = useRouter();
const route = useRoute();
@@ -33,6 +34,8 @@ const responses = useMapGetter('captainResponses/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const selectedResponse = ref(null);
const usageResponse = ref(null);
const showResponseUsage = ref(false);
const deleteDialog = ref(null);
const bulkDeleteDialog = ref(null);
@@ -86,6 +89,19 @@ const handleCreateClose = () => {
selectedResponse.value = null;
};
const handleShowResponseUsage = id => {
usageResponse.value =
responses.value.find(response => response.id === id) || null;
showResponseUsage.value = Boolean(usageResponse.value);
};
const handleResponseUsageClose = () => {
showResponseUsage.value = false;
};
const fetchResponseUsage = ({ resourceId, ...params }) =>
CaptainResponseAPI.getDrilldown({ responseId: resourceId, ...params });
const updateURLWithFilters = (page, search) => {
const query = {
page: page || 1,
@@ -182,6 +198,9 @@ const fetchResponseAfterBulkAction = () => {
const onPageChange = page => {
const hadSelection = bulkSelectedIds.value.size > 0;
showResponseUsage.value = false;
usageResponse.value = null;
fetchResponses(page);
if (hadSelection) {
@@ -228,6 +247,8 @@ watch(
selectedAssistantId,
() => {
selectedResponse.value = null;
usageResponse.value = null;
showResponseUsage.value = false;
bulkSelectedIds.value = new Set();
store.dispatch('captainResponses/setRecords', {
records: [],
@@ -337,6 +358,7 @@ onUnmounted(() => {
:status="response.status"
:created-at="response.created_at"
:updated-at="response.updated_at"
:used-in-conversations-count="response.used_in_conversations_count"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="!bulkSelectedIds.has(response.id)"
@@ -345,10 +367,21 @@ onUnmounted(() => {
@navigate="handleNavigationAction"
@select="handleCardSelect"
@hover="isHovered => handleCardHover(isHovered, response.id)"
@view-conversations="handleShowResponseUsage"
/>
</div>
</template>
<ConversationUsageDrawer
:open="showResponseUsage"
:resource-id="usageResponse?.id"
:title="usageResponse?.question || ''"
:conversation-count="usageResponse?.used_in_conversations_count || 0"
:fetcher="fetchResponseUsage"
empty-state-key="CAPTAIN.RESPONSES.NO_USED_CONVERSATIONS"
@close="handleResponseUsageClose"
/>
<DeleteDialog
v-if="selectedResponse"
ref="deleteDialog"

View File

@@ -80,7 +80,10 @@ export function useReportDrilldown(
const open = async request => {
const fingerprint = requestFingerprint(request);
if (activeRequestFingerprint === fingerprint) return;
if (activeRequestFingerprint === fingerprint) {
if (hasError.value) await fetchPage(1, requestToken);
return;
}
abortActiveRequest();
requestToken += 1;