feat: Update Captain navigation structure (#12761)

# Pull Request Template

## Description

This PR includes an update to the Captain navigation structure.

## Route Structure

```javascript
1. captain_assistants_responses_index    → /captain/:assistantId/faqs
2. captain_assistants_documents_index    → /captain/:assistantId/documents
3. captain_assistants_scenarios_index    → /captain/:assistantId/scenarios
4. captain_assistants_playground_index   → /captain/:assistantId/playground
5. captain_assistants_inboxes_index      → /captain/:assistantId/inboxes
6. captain_tools_index                   → /captain/tools
7. captain_assistants_settings_index     → /captain/:assistantId/settings
8. captain_assistants_guardrails_index   → /captain/:assistantId/settings/guardrails
9. captain_assistants_guidelines_index   → /captain/:assistantId/settings/guidelines
10. captain_assistants_index             → /captain/:navigationPath
```

**How it works:**

1. User clicks sidebar item → Routes to `captain_assistants_index` with
`navigationPath`
2. `AssistantsIndexPage` validates route and gets last active assistant,
if not redirects to assistant create page.
3. Routes to actual page: `/captain/:assistantId/:page`
4. Page loads with correct assistant context

Fixes
https://linear.app/chatwoot/issue/CW-5832/updating-captain-navigation

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Sivin Varghese
2025-11-07 06:01:23 +05:30
committed by GitHub
parent 90352b3a20
commit 5bf39d20e5
35 changed files with 994 additions and 1360 deletions

View File

@@ -1,86 +0,0 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import EditAssistantForm from '../../../../components-next/captain/pageComponents/assistant/EditAssistantForm.vue';
import AssistantPlayground from 'dashboard/components-next/captain/assistant/AssistantPlayground.vue';
import AssistantSettings from 'dashboard/routes/dashboard/captain/assistants/settings/Settings.vue';
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingItem);
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](Number(assistantId))
);
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const currentAccountId = useMapGetter('getCurrentAccountId');
const isCaptainV2Enabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_V2
);
const isAssistantAvailable = computed(() => !!assistant.value?.id);
const handleSubmit = async updatedAssistant => {
try {
await store.dispatch('captainAssistants/update', {
id: assistantId,
...updatedAssistant,
});
useAlert(t('CAPTAIN.ASSISTANTS.EDIT.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.message || t('CAPTAIN.ASSISTANTS.EDIT.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
onMounted(() => {
if (!isAssistantAvailable.value || !isCaptainV2Enabled) {
store.dispatch('captainAssistants/show', assistantId);
}
});
</script>
<template>
<AssistantSettings v-if="isCaptainV2Enabled" />
<PageLayout
v-else
:header-title="assistant?.name"
:show-pagination-footer="false"
:is-fetching="isFetching"
:show-know-more="false"
:back-url="{ name: 'captain_assistants_index' }"
>
<template #body>
<div v-if="!isAssistantAvailable">
{{ t('CAPTAIN.ASSISTANTS.EDIT.NOT_FOUND') }}
</div>
<div v-else class="flex gap-4 h-full">
<div class="flex-1 lg:overflow-auto pr-4 h-full md:h-auto">
<EditAssistantForm
:assistant="assistant"
mode="edit"
@submit="handleSubmit"
/>
</div>
<div class="w-[400px] hidden lg:block h-full">
<AssistantPlayground :assistant-id="Number(assistantId)" />
</div>
</div>
</template>
</PageLayout>
</template>

View File

@@ -1,91 +1,57 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { computed, ref, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
import AssistantPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
const { isOnChatwootCloud } = useAccount();
const router = useRouter();
const store = useStore();
const dialogType = ref('');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const assistants = useMapGetter('captainAssistants/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const selectedAssistant = ref(null);
const deleteAssistantDialog = ref(null);
const handleDelete = () => {
deleteAssistantDialog.value.dialogRef.open();
};
const createAssistantDialog = ref(null);
const router = useRouter();
const handleCreate = () => {
dialogType.value = 'create';
nextTick(() => createAssistantDialog.value.dialogRef.open());
};
const handleEdit = () => {
router.push({
name: 'captain_assistants_edit',
params: { assistantId: selectedAssistant.value.id },
});
};
const handleViewConnectedInboxes = () => {
router.push({
name: 'captain_assistants_inboxes_index',
params: { assistantId: selectedAssistant.value.id },
});
};
const handleAction = ({ action, id }) => {
selectedAssistant.value = assistants.value.find(
assistant => id === assistant.id
);
nextTick(() => {
if (action === 'delete') {
handleDelete();
}
if (action === 'edit') {
handleEdit();
}
if (action === 'viewConnectedInboxes') {
handleViewConnectedInboxes();
}
});
};
const handleCreateClose = () => {
dialogType.value = '';
selectedAssistant.value = null;
};
onMounted(() => store.dispatch('captainAssistants/get'));
const handleAfterCreate = newAssistant => {
// Navigate directly to documents page with the new assistant ID
if (newAssistant?.id) {
router.push({
name: 'captain_assistants_responses_index',
params: {
accountId: router.currentRoute.value.params.accountId,
assistantId: newAssistant.id,
},
});
}
};
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.HEADER')"
:button-label="$t('CAPTAIN.ASSISTANTS.ADD_NEW')"
:button-policy="['administrator']"
:show-pagination-footer="false"
:is-fetching="isFetching"
:is-empty="!assistants.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
is-empty
@click="handleCreate"
>
<template #knowMore>
@@ -107,36 +73,13 @@ onMounted(() => store.dispatch('captainAssistants/get'));
<CaptainPaywall />
</template>
<template #body>
<LimitBanner class="mb-5" />
<div class="flex flex-col gap-4">
<AssistantCard
v-for="assistant in assistants"
:id="assistant.id"
:key="assistant.id"
:name="assistant.name"
:description="assistant.description"
:updated-at="assistant.updated_at || assistant.created_at"
:created-at="assistant.created_at"
@action="handleAction"
/>
</div>
</template>
<DeleteDialog
v-if="selectedAssistant"
ref="deleteAssistantDialog"
:entity="selectedAssistant"
type="Assistants"
/>
<CreateAssistantDialog
v-if="dialogType"
ref="createAssistantDialog"
:type="dialogType"
:selected-assistant="selectedAssistant"
@close="handleCreateClose"
@created="handleAfterCreate"
/>
</PageLayout>
</template>

View File

@@ -10,7 +10,7 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
import SuggestedRules from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
import AddNewRulesInput from 'dashboard/components-next/captain/assistant/AddNewRulesInput.vue';
@@ -23,30 +23,27 @@ const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const assistantId = computed(() => Number(route.params.assistantId));
const isFetching = computed(() => uiFlags.value.fetchingItem);
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](Number(assistantId))
store.getters['captainAssistants/getRecord'](assistantId.value)
);
const searchQuery = ref('');
const newInlineRule = ref('');
const newDialogRule = ref('');
const breadcrumbItems = computed(() => {
return [
{
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
routeName: 'captain_assistants_index',
},
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
{ label: t('CAPTAIN.ASSISTANTS.GUARDRAILS.BREADCRUMB.TITLE') },
];
});
const guardrailsContent = computed(() => assistant.value?.guardrails || []);
const backUrl = computed(() => ({
name: 'captain_assistants_settings_index',
params: {
accountId: route.params.accountId,
assistantId: assistantId.value,
},
}));
const displayGuardrails = computed(() =>
guardrailsContent.value.map((c, idx) => ({ id: idx, content: c }))
);
@@ -113,7 +110,7 @@ const selectedCountLabel = computed(() => {
const saveGuardrails = async list => {
await store.dispatch('captainAssistants/update', {
id: assistantId,
id: assistantId.value,
assistant: { guardrails: list },
});
};
@@ -176,9 +173,13 @@ const addAllExample = () => {
</script>
<template>
<SettingsPageLayout
:breadcrumb-items="breadcrumbItems"
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.TITLE')"
:is-fetching="isFetching"
:back-url="backUrl"
:show-know-more="false"
:show-pagination-footer="false"
:show-assistant-switcher="false"
>
<template #body>
<SettingsHeader
@@ -297,5 +298,5 @@ const addAllExample = () => {
/>
</div>
</template>
</SettingsPageLayout>
</PageLayout>
</template>

View File

@@ -10,7 +10,7 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
import SuggestedRules from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
import AddNewRulesInput from 'dashboard/components-next/captain/assistant/AddNewRulesInput.vue';
@@ -23,32 +23,29 @@ const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const assistantId = computed(() => Number(route.params.assistantId));
const isFetching = computed(() => uiFlags.value.fetchingItem);
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](Number(assistantId))
store.getters['captainAssistants/getRecord'](assistantId.value)
);
const searchQuery = ref('');
const newInlineRule = ref('');
const newDialogRule = ref('');
const breadcrumbItems = computed(() => {
return [
{
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
routeName: 'captain_assistants_index',
},
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
{ label: t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE') },
];
});
const guidelinesContent = computed(
() => assistant.value?.response_guidelines || []
);
const backUrl = computed(() => ({
name: 'captain_assistants_settings_index',
params: {
accountId: route.params.accountId,
assistantId: assistantId.value,
},
}));
const displayGuidelines = computed(() =>
guidelinesContent.value.map((c, idx) => ({ id: idx, content: c }))
);
@@ -119,7 +116,7 @@ const selectedCountLabel = computed(() => {
const saveGuidelines = async list => {
await store.dispatch('captainAssistants/update', {
id: assistantId,
id: assistantId.value,
assistant: { response_guidelines: list },
});
};
@@ -183,9 +180,13 @@ const addAllExample = async () => {
</script>
<template>
<SettingsPageLayout
:breadcrumb-items="breadcrumbItems"
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE')"
:is-fetching="isFetching"
:back-url="backUrl"
:show-know-more="false"
:show-pagination-footer="false"
:show-assistant-switcher="false"
>
<template #body>
<SettingsHeader
@@ -321,5 +322,5 @@ const addAllExample = async () => {
/>
</div>
</template>
</SettingsPageLayout>
</PageLayout>
</template>

View File

@@ -1,14 +1,9 @@
<script setup>
import { computed, onBeforeMount, onMounted, ref, nextTick } from 'vue';
import {
useMapGetter,
useStore,
useStoreGetters,
} from 'dashboard/composables/store';
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import BackButton from 'dashboard/components/widgets/BackButton.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import ConnectInboxDialog from 'dashboard/components-next/captain/pageComponents/inbox/ConnectInboxDialog.vue';
@@ -52,13 +47,7 @@ const handleCreateClose = () => {
selectedInbox.value = null;
};
const getters = useStoreGetters();
const assistantId = Number(route.params.assistantId);
const assistant = computed(() =>
getters['captainAssistants/getRecord'].value(assistantId)
);
onBeforeMount(() => store.dispatch('captainAssistants/show', assistantId));
onMounted(() =>
store.dispatch('captainInboxes/get', {
assistantId: assistantId,
@@ -68,27 +57,16 @@ onMounted(() =>
<template>
<PageLayout
:header-title="$t('CAPTAIN.INBOXES.HEADER')"
:button-label="$t('CAPTAIN.INBOXES.ADD_NEW')"
:button-policy="['administrator']"
:is-fetching="isFetchingAssistant || isFetching"
:is-empty="!captainInboxes.length"
:show-pagination-footer="false"
:show-know-more="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
@click="handleCreate"
>
<template v-if="!isFetchingAssistant" #headerTitle>
<div class="flex flex-row items-center gap-4">
<BackButton compact />
<span
class="flex items-center gap-1 text-lg font-medium text-n-slate-12"
>
{{ assistant.name }}
<span class="i-lucide-chevron-right text-xl text-n-slate-10" />
{{ $t('CAPTAIN.INBOXES.HEADER') }}
</span>
</div>
</template>
<template #emptyState>
<InboxPageEmptyState @click="handleCreate" />
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import AssistantPlayground from 'dashboard/components-next/captain/assistant/AssistantPlayground.vue';
const route = useRoute();
const assistantId = computed(() => Number(route.params.assistantId));
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.PLAYGROUND.HEADER')"
show-assistant-switcher
:show-pagination-footer="false"
class="h-full"
>
<template #body>
<div class="flex flex-col h-full">
<AssistantPlayground :assistant-id="assistantId" />
</div>
</template>
</PageLayout>
</template>

View File

@@ -10,7 +10,7 @@ import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
import SuggestedScenarios from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
import ScenariosCard from 'dashboard/components-next/captain/assistant/ScenariosCard.vue';
@@ -22,28 +22,14 @@ const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const { formatMessage } = useMessageFormatter();
const assistantId = route.params.assistantId;
const assistantId = computed(() => Number(route.params.assistantId));
const uiFlags = useMapGetter('captainScenarios/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingList);
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](Number(assistantId))
);
const scenarios = useMapGetter('captainScenarios/getRecords');
const searchQuery = ref('');
const breadcrumbItems = computed(() => {
return [
{
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
routeName: 'captain_assistants_index',
},
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
{ label: t('CAPTAIN.ASSISTANTS.SCENARIOS.BREADCRUMB.TITLE') },
];
});
const LINK_INSTRUCTION_CLASS =
'[&_a[href^="tool://"]]:text-n-iris-11 [&_a:not([href^="tool://"])]:text-n-slate-12 [&_a]:pointer-events-none [&_a]:cursor-default';
@@ -119,7 +105,7 @@ const updateScenario = async scenario => {
try {
await store.dispatch('captainScenarios/update', {
id: scenario.id,
assistantId: route.params.assistantId,
assistantId: assistantId.value,
...scenario,
tools: getToolsFromInstruction(scenario.instruction),
});
@@ -136,7 +122,7 @@ const deleteScenario = async id => {
try {
await store.dispatch('captainScenarios/delete', {
id,
assistantId: route.params.assistantId,
assistantId: assistantId.value,
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.SUCCESS'));
} catch (error) {
@@ -154,7 +140,7 @@ const bulkDeleteScenarios = async ids => {
idsArray.map(id =>
store.dispatch('captainScenarios/delete', {
id,
assistantId: route.params.assistantId,
assistantId: assistantId.value,
})
)
);
@@ -165,7 +151,7 @@ const bulkDeleteScenarios = async ids => {
const addScenario = async scenario => {
try {
await store.dispatch('captainScenarios/create', {
assistantId: route.params.assistantId,
assistantId: assistantId.value,
...scenario,
tools: getToolsFromInstruction(scenario.instruction),
});
@@ -182,7 +168,7 @@ const addAllExampleScenarios = async () => {
try {
scenariosExample.forEach(async scenario => {
await store.dispatch('captainScenarios/create', {
assistantId: route.params.assistantId,
assistantId: assistantId.value,
...scenario,
});
});
@@ -197,16 +183,18 @@ const addAllExampleScenarios = async () => {
onMounted(() => {
store.dispatch('captainScenarios/get', {
assistantId: assistantId,
assistantId: assistantId.value,
});
store.dispatch('captainTools/getTools');
});
</script>
<template>
<SettingsPageLayout
:breadcrumb-items="breadcrumbItems"
<PageLayout
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
:is-fetching="isFetching"
:show-know-more="false"
:show-pagination-footer="false"
>
<template #body>
<SettingsHeader
@@ -310,5 +298,5 @@ onMounted(() => {
</div>
</div>
</template>
</SettingsPageLayout>
</PageLayout>
</template>

View File

@@ -1,27 +1,39 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Button from 'dashboard/components-next/button/Button.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
import AssistantBasicSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantBasicSettingsForm.vue';
import AssistantSystemSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue';
import AssistantControlItems from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantControlItems.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
const { t } = useI18n();
const route = useRoute();
const store = useStore();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingItem);
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](Number(assistantId))
);
const { isCloudFeatureEnabled } = useAccount();
const isAssistantAvailable = computed(() => !!assistant.value?.id);
const isCaptainV2Enabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
);
const route = useRoute();
const router = useRouter();
const store = useStore();
const deleteAssistantDialog = ref(null);
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const assistants = useMapGetter('captainAssistants/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingItem);
const assistantId = computed(() => Number(route.params.assistantId));
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](assistantId.value)
);
const controlItems = computed(() => {
return [
@@ -34,15 +46,6 @@ const controlItems = computed(() => {
),
routeName: 'captain_assistants_guardrails_index',
},
{
name: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.SCENARIOS.TITLE'
),
description: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.SCENARIOS.DESCRIPTION'
),
routeName: 'captain_assistants_scenarios_index',
},
{
name: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.TITLE'
@@ -55,32 +58,10 @@ const controlItems = computed(() => {
];
});
const breadcrumbItems = computed(() => {
const activeControlItem = controlItems.value?.find(
item => item.routeName === route.name
);
return [
{
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
routeName: 'captain_assistants_index',
},
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
...(activeControlItem
? [
{
label: activeControlItem.name,
routeName: activeControlItem.routeName,
},
]
: []),
];
});
const handleSubmit = async updatedAssistant => {
try {
await store.dispatch('captainAssistants/update', {
id: assistantId,
id: assistantId.value,
...updatedAssistant,
});
useAlert(t('CAPTAIN.ASSISTANTS.EDIT.SUCCESS_MESSAGE'));
@@ -91,64 +72,126 @@ const handleSubmit = async updatedAssistant => {
}
};
onMounted(() => {
if (!isAssistantAvailable.value) {
store.dispatch('captainAssistants/show', assistantId);
const handleDelete = () => {
deleteAssistantDialog.value.dialogRef.open();
};
const handleDeleteSuccess = () => {
// Get remaining assistants after deletion
const remainingAssistants = assistants.value.filter(
a => a.id !== assistantId.value
);
if (remainingAssistants.length > 0) {
// Navigate to the first available assistant's settings
const nextAssistant = remainingAssistants[0];
router.push({
name: 'captain_assistants_settings_index',
params: {
accountId: route.params.accountId,
assistantId: nextAssistant.id,
},
});
} else {
// No assistants left, redirect to create assistant page
router.push({
name: 'captain_assistants_create_index',
params: { accountId: route.params.accountId },
});
}
});
};
</script>
<template>
<SettingsPageLayout
:breadcrumb-items="breadcrumbItems"
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.SETTINGS.HEADER')"
:is-fetching="isFetching"
class="[&>div]:max-w-[80rem]"
:show-pagination-footer="false"
:show-know-more="false"
:class="{
'[&>header>div]:max-w-[80rem] [&>main>div]:max-w-[80rem]':
isCaptainV2Enabled,
}"
>
<template #body>
<div class="flex flex-col gap-6">
<div
class="gap-6 lg:gap-16 pb-8"
:class="{ 'grid grid-cols-2': isCaptainV2Enabled }"
>
<div class="flex flex-col gap-6">
<SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.TITLE')"
:description="
t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.DESCRIPTION')
"
/>
<AssistantBasicSettingsForm
:assistant="assistant"
@submit="handleSubmit"
/>
<div class="flex flex-col gap-6">
<SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.TITLE')"
:description="
t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.DESCRIPTION')
"
/>
<AssistantBasicSettingsForm
:assistant="assistant"
@submit="handleSubmit"
/>
</div>
<span class="h-px w-full bg-n-weak mt-2" />
<div class="flex flex-col gap-6">
<SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.TITLE')"
:description="
t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.DESCRIPTION')
"
/>
<AssistantSystemSettingsForm
:assistant="assistant"
@submit="handleSubmit"
/>
</div>
<span class="h-px w-full bg-n-weak mt-2" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
<h6 class="text-n-slate-12 text-base font-medium">
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.TITLE') }}
</h6>
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.DESCRIPTION') }}
</span>
</div>
<div class="flex-shrink-0">
<Button
:label="
t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.BUTTON_TEXT', {
assistantName: assistant.name,
})
"
color="ruby"
class="max-w-56 !w-fit"
@click="handleDelete"
/>
</div>
</div>
</div>
<span class="h-px w-full bg-n-weak mt-2" />
<div class="flex flex-col gap-6">
<div v-if="isCaptainV2Enabled" class="flex flex-col gap-6">
<SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.TITLE')"
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.TITLE')"
:description="
t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.DESCRIPTION')
t('CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.DESCRIPTION')
"
/>
<AssistantSystemSettingsForm
:assistant="assistant"
@submit="handleSubmit"
/>
<div class="flex flex-col gap-6">
<AssistantControlItems
v-for="item in controlItems"
:key="item.name"
:control-item="item"
/>
</div>
</div>
</div>
</template>
<template #controls>
<div class="flex flex-col gap-6">
<SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.TITLE')"
:description="
t('CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.DESCRIPTION')
"
/>
<div class="flex flex-col gap-6">
<AssistantControlItems
v-for="item in controlItems"
:key="item.name"
:control-item="item"
/>
</div>
</div>
</template>
</SettingsPageLayout>
<DeleteDialog
v-if="assistant"
ref="deleteAssistantDialog"
:entity="assistant"
type="Assistants"
translation-key="ASSISTANTS"
@delete-success="handleDeleteSuccess"
/>
</PageLayout>
</template>

View File

@@ -1,10 +1,14 @@
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { frontendURL } from '../../../helper/URLHelper';
import AssistantIndex from './assistants/Index.vue';
import AssistantEdit from './assistants/Edit.vue';
// import AssistantSettings from './assistants/settings/Settings.vue';
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
import AssistantEmptyStateIndex from './assistants/Index.vue';
import AssistantSettingsIndex from './assistants/settings/Settings.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
import AssistantGuardrailsIndex from './assistants/guardrails/Index.vue';
import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
@@ -13,87 +17,90 @@ import ResponsesIndex from './responses/Index.vue';
import ResponsesPendingIndex from './responses/Pending.vue';
import CustomToolsIndex from './tools/Index.vue';
export const routes = [
const meta = {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const metaV2 = {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const assistantRoutes = [
{
path: frontendURL('accounts/:accountId/captain/assistants'),
component: AssistantIndex,
name: 'captain_assistants_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
name: 'captain_assistants_responses_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/assistants/:assistantId'),
component: AssistantEdit,
name: 'captain_assistants_edit',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
path: frontendURL('accounts/:accountId/captain/:assistantId/documents'),
component: DocumentsIndex,
name: 'captain_assistants_documents_index',
meta,
},
{
path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/inboxes'
),
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
component: CustomToolsIndex,
name: 'captain_tools_index',
meta: metaV2,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),
component: AssistantScenariosIndex,
name: 'captain_assistants_scenarios_index',
meta: metaV2,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/playground'),
component: AssistantPlaygroundIndex,
name: 'captain_assistants_playground_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/inboxes'),
component: AssistantInboxesIndex,
name: 'captain_assistants_inboxes_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
component: ResponsesPendingIndex,
name: 'captain_assistants_responses_pending',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
component: AssistantSettingsIndex,
name: 'captain_assistants_settings_index',
meta,
},
// Settings sub-pages (guardrails and guidelines)
{
path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/guardrails'
'accounts/:accountId/captain/:assistantId/settings/guardrails'
),
component: AssistantGuardrailsIndex,
name: 'captain_assistants_guardrails_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
meta: metaV2,
},
{
path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/scenarios'
),
component: AssistantScenariosIndex,
name: 'captain_assistants_scenarios_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/guidelines'
'accounts/:accountId/captain/:assistantId/settings/guidelines'
),
component: AssistantGuidelinesIndex,
name: 'captain_assistants_guidelines_index',
meta: metaV2,
},
{
path: frontendURL('accounts/:accountId/captain/assistants'),
component: AssistantEmptyStateIndex,
name: 'captain_assistants_create_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
@@ -101,55 +108,26 @@ export const routes = [
},
},
{
path: frontendURL('accounts/:accountId/captain/documents'),
component: DocumentsIndex,
name: 'captain_documents_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL('accounts/:accountId/captain/responses'),
component: ResponsesIndex,
name: 'captain_responses_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL('accounts/:accountId/captain/responses/pending'),
component: ResponsesPendingIndex,
name: 'captain_responses_pending',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL('accounts/:accountId/captain/tools'),
component: CustomToolsIndex,
name: 'captain_tools_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
path: frontendURL('accounts/:accountId/captain/:navigationPath'),
component: AssistantsIndexPage,
name: 'captain_assistants_index',
meta,
},
];
export const routes = [
{
path: frontendURL('accounts/:accountId/captain'),
component: CaptainPageRouteView,
redirect: to => {
return {
name: 'captain_assistants_index',
params: {
navigationPath: 'captain_assistants_responses_index',
...to.params,
},
};
},
children: [...assistantRoutes],
},
];

View File

@@ -1,6 +1,7 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
@@ -10,20 +11,20 @@ import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
const documents = useMapGetter('captainDocuments/getRecords');
const assistants = useMapGetter('captainAssistants/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const documentsMeta = useMapGetter('captainDocuments/getMeta');
const selectedAssistant = ref('all');
const selectedAssistantId = computed(() => Number(route.params.assistantId));
const selectedDocument = ref(null);
const deleteDocumentDialog = ref(null);
@@ -37,12 +38,6 @@ const showCreateDialog = ref(false);
const createDocumentDialog = ref(null);
const relationQuestionDialog = ref(null);
const shouldShowAssistantSelector = computed(() => {
if (assistants.value.length === 0) return false;
return !isFetching.value;
});
const handleShowRelatedDocument = () => {
showRelatedResponses.value = true;
nextTick(() => relationQuestionDialog.value.dialogRef.open());
@@ -77,17 +72,12 @@ const handleAction = ({ action, id }) => {
const fetchDocuments = (page = 1) => {
const filterParams = { page };
if (selectedAssistant.value !== 'all') {
filterParams.assistantId = selectedAssistant.value;
if (selectedAssistantId.value) {
filterParams.assistantId = selectedAssistantId.value;
}
store.dispatch('captainDocuments/get', filterParams);
};
const handleAssistantFilterChange = assistant => {
selectedAssistant.value = assistant;
fetchDocuments();
};
const onPageChange = page => fetchDocuments(page);
const onDeleteSuccess = () => {
@@ -97,9 +87,6 @@ const onDeleteSuccess = () => {
};
onMounted(() => {
if (!assistants.value.length) {
store.dispatch('captainAssistants/get');
}
fetchDocuments();
});
</script>
@@ -114,6 +101,7 @@ onMounted(() => {
:show-pagination-footer="!isFetching && !!documents.length"
:is-fetching="isFetching"
:is-empty="!documents.length"
:show-know-more="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
@update:current-page="onPageChange"
@click="handleCreateDocument"
@@ -138,15 +126,6 @@ onMounted(() => {
<CaptainPaywall />
</template>
<template #controls>
<div v-if="shouldShowAssistantSelector" class="mb-4 -mt-3 flex gap-3">
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
/>
</div>
</template>
<template #body>
<LimitBanner class="mb-5" />
@@ -173,6 +152,7 @@ onMounted(() => {
<CreateDocumentDialog
v-if="showCreateDialog"
ref="createDocumentDialog"
:assistant-id="selectedAssistantId"
@close="handleCreateDialogClose"
/>
<DeleteDialog

View File

@@ -0,0 +1,89 @@
<script setup>
import { computed, nextTick, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const store = useStore();
const router = useRouter();
const { uiSettings } = useUISettings();
const route = useRoute();
const assistants = computed(
() => store.getters['captainAssistants/getRecords']
);
const isAssistantPresent = assistantId => {
return !!assistants.value.find(a => a.id === Number(assistantId));
};
const routeToView = (name, params) => {
router.replace({ name, params, replace: true });
};
const generateRouterParams = () => {
const { last_active_assistant_id: lastActiveAssistantId } =
uiSettings.value || {};
if (isAssistantPresent(lastActiveAssistantId)) {
return {
assistantId: lastActiveAssistantId,
};
}
if (assistants.value.length > 0) {
const { id: assistantId } = assistants.value[0];
return { assistantId };
}
return null;
};
const routeToLastActiveAssistant = () => {
const params = generateRouterParams();
// No assistants found, redirect to create page
if (!params) {
return routeToView('captain_assistants_create_index', {
accountId: route.params.accountId,
});
}
const { navigationPath } = route.params;
const isAValidRoute = [
'captain_assistants_responses_index', // Faq page
'captain_assistants_documents_index', // Document page
'captain_assistants_scenarios_index', // Scenario page
'captain_assistants_playground_index', // Playground page
'captain_assistants_inboxes_index', // Inboxes page
'captain_tools_index', // Tools page
'captain_assistants_settings_index', // Settings page
].includes(navigationPath);
const navigateTo = isAValidRoute
? navigationPath
: 'captain_assistants_responses_index';
return routeToView(navigateTo, {
accountId: route.params.accountId,
...params,
});
};
const performRouting = async () => {
await store.dispatch('captainAssistants/get');
nextTick(() => routeToLastActiveAssistant());
};
onMounted(() => performRouting());
</script>
<template>
<div
class="flex items-center justify-center w-full bg-n-background text-n-slate-11"
>
<Spinner />
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import { watch } from 'vue';
import { useRoute } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
watch(
() => route.params.assistantId,
newAssistantId => {
if (
newAssistantId &&
newAssistantId !== String(uiSettings.value.last_active_assistant_id)
) {
updateUISettings({
last_active_assistant_id: Number(newAssistantId),
});
}
}
);
</script>
<template>
<div class="flex w-full h-full min-h-0">
<section class="flex flex-1 h-full px-0 overflow-hidden bg-n-background">
<router-view />
</section>
</div>
</template>

View File

@@ -8,14 +8,12 @@ import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
@@ -27,7 +25,6 @@ const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const assistants = useMapGetter('captainAssistants/getRecords');
const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
@@ -36,18 +33,13 @@ const selectedResponse = ref(null);
const deleteDialog = ref(null);
const bulkDeleteDialog = ref(null);
const selectedAssistant = ref('all');
const dialogType = ref('');
const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
const shouldShowDropdown = computed(() => {
if (assistants.value.length === 0) return false;
return !isFetching.value;
});
const selectedAssistantId = computed(() => Number(route.params.assistantId));
const pendingCount = useMapGetter('captainResponses/getPendingCount');
@@ -106,8 +98,8 @@ const updateURLWithFilters = (page, search) => {
const fetchResponses = (page = 1) => {
const filterParams = { page, status: 'approved' };
if (selectedAssistant.value !== 'all') {
filterParams.assistantId = selectedAssistant.value;
if (selectedAssistantId.value) {
filterParams.assistantId = selectedAssistantId.value;
}
if (searchQuery.value) {
filterParams.search = searchQuery.value;
@@ -123,33 +115,20 @@ const fetchResponses = (page = 1) => {
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const bulkSelectionState = computed(() => {
const selectedCount = bulkSelectedIds.value.size;
const totalCount = responses.value?.length || 0;
return {
hasSelected: selectedCount > 0,
isIndeterminate: selectedCount > 0 && selectedCount < totalCount,
allSelected: totalCount > 0 && selectedCount === totalCount,
};
});
const bulkCheckbox = computed({
get: () => bulkSelectionState.value.allSelected,
set: value => {
bulkSelectedIds.value = value
? new Set(responses.value.map(r => r.id))
: new Set();
},
});
const buildSelectedCountLabel = computed(() => {
const count = responses.value?.length || 0;
return bulkSelectionState.value.allSelected
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.RESPONSES.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
@@ -179,14 +158,11 @@ const fetchResponseAfterBulkAction = () => {
};
const onPageChange = page => {
// Store current selection state before fetching new page
const wasAllPageSelected = bulkSelectionState.value.allSelected;
const hadPartialSelection = bulkSelectedIds.value.size > 0;
const hadSelection = bulkSelectedIds.value.size > 0;
fetchResponses(page);
// Reset selection if we had any selections on page change
if (wasAllPageSelected || hadPartialSelection) {
if (hadSelection) {
bulkSelectedIds.value = new Set();
}
};
@@ -201,11 +177,6 @@ const onBulkDeleteSuccess = () => {
fetchResponseAfterBulkAction();
};
const handleAssistantFilterChange = assistant => {
selectedAssistant.value = assistant;
fetchResponses(1);
};
const debouncedSearch = debounce(async () => {
fetchResponses(1);
}, 500);
@@ -219,13 +190,12 @@ const initializeFromURL = () => {
};
const navigateToPendingFAQs = () => {
router.push({ name: 'captain_responses_pending' });
router.push({ name: 'captain_assistants_responses_pending' });
};
onMounted(() => {
store.dispatch('captainAssistants/get');
initializeFromURL();
store.dispatch('captainResponses/fetchPendingCount');
store.dispatch('captainResponses/fetchPendingCount', selectedAssistantId);
});
</script>
@@ -255,82 +225,38 @@ onMounted(() => {
/>
</template>
<template #subHeader>
<template #search>
<div
v-if="shouldShowDropdown"
class="mb-2 flex justify-between items-center py-1"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
bulkSelectionState.hasSelected,
}"
v-if="bulkSelectedIds.size === 0"
class="flex gap-3 justify-between w-full items-center"
>
<div
v-if="!bulkSelectionState.hasSelected"
class="flex gap-3 justify-between w-full items-center"
>
<div class="flex items-center gap-3">
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
/>
</div>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
type="search"
autofocus
@input="debouncedSearch"
/>
</div>
<transition
name="slide-fade"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
enter-to-class="opacity-100 transform translate-x-0"
leave-active-class="hidden opacity-0"
>
<div
v-if="bulkSelectionState.hasSelected"
class="flex items-center gap-3"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckbox"
:indeterminate="bulkSelectionState.isIndeterminate"
/>
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
{{ buildSelectedCountLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
{{
$t('CAPTAIN.RESPONSES.SELECTED', {
count: bulkSelectedIds.size,
})
}}
</span>
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex gap-3 items-center">
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="bulkDeleteDialog.dialogRef.open()"
/>
</div>
</div>
</transition>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
type="search"
autofocus
@input="debouncedSearch"
/>
</div>
</template>
<template #subHeader>
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="responses"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{
'mb-2': bulkSelectedIds.size > 0,
}"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
/>
</template>
<template #emptyState>
<ResponsePageEmptyState @click="handleCreate" />
</template>

View File

@@ -7,16 +7,14 @@ import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
@@ -28,7 +26,6 @@ const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const assistants = useMapGetter('captainAssistants/getRecords');
const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
@@ -37,21 +34,20 @@ const selectedResponse = ref(null);
const deleteDialog = ref(null);
const bulkDeleteDialog = ref(null);
const selectedAssistant = ref('all');
const selectedAssistantId = Number(route.params.assistantId);
const dialogType = ref('');
const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
const shouldShowDropdown = computed(() => {
if (assistants.value.length === 0) return false;
return !isFetching.value;
});
const backUrl = computed(() =>
frontendURL(`accounts/${route.params.accountId}/captain/responses`)
);
const backUrl = computed(() => ({
name: 'captain_assistants_responses_index',
params: {
accountId: route.params.accountId,
assistantId: selectedAssistantId,
},
}));
// Filter out approved responses in pending view
const filteredResponses = computed(() =>
@@ -78,11 +74,6 @@ const handleAccept = async () => {
}
};
const handleCreate = () => {
dialogType.value = 'create';
nextTick(() => createDialog.value.dialogRef.open());
};
const handleEdit = () => {
dialogType.value = 'edit';
nextTick(() => createDialog.value.dialogRef.open());
@@ -134,8 +125,8 @@ const updateURLWithFilters = (page, search) => {
const fetchResponses = (page = 1) => {
const filterParams = { page, status: 'pending' };
if (selectedAssistant.value !== 'all') {
filterParams.assistantId = selectedAssistant.value;
if (selectedAssistantId) {
filterParams.assistantId = selectedAssistantId;
}
if (searchQuery.value) {
filterParams.search = searchQuery.value;
@@ -151,33 +142,20 @@ const fetchResponses = (page = 1) => {
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const bulkSelectionState = computed(() => {
const selectedCount = bulkSelectedIds.value.size;
const totalCount = filteredResponses.value?.length || 0;
return {
hasSelected: selectedCount > 0,
isIndeterminate: selectedCount > 0 && selectedCount < totalCount,
allSelected: totalCount > 0 && selectedCount === totalCount,
};
});
const bulkCheckbox = computed({
get: () => bulkSelectionState.value.allSelected,
set: value => {
bulkSelectedIds.value = value
? new Set(filteredResponses.value.map(r => r.id))
: new Set();
},
});
const buildSelectedCountLabel = computed(() => {
const count = filteredResponses.value?.length || 0;
return bulkSelectionState.value.allSelected
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.RESPONSES.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
@@ -219,12 +197,11 @@ const handleBulkApprove = async () => {
};
const onPageChange = page => {
const wasAllPageSelected = bulkSelectionState.value.allSelected;
const hadPartialSelection = bulkSelectedIds.value.size > 0;
const hadSelection = bulkSelectedIds.value.size > 0;
fetchResponses(page);
if (wasAllPageSelected || hadPartialSelection) {
if (hadSelection) {
bulkSelectedIds.value = new Set();
}
};
@@ -239,22 +216,16 @@ const onBulkDeleteSuccess = () => {
fetchResponseAfterBulkAction();
};
const handleAssistantFilterChange = assistant => {
selectedAssistant.value = assistant;
fetchResponses(1);
};
const debouncedSearch = debounce(async () => {
fetchResponses(1);
}, 500);
const hasActiveFilters = computed(() => {
return Boolean(searchQuery.value || selectedAssistant.value !== 'all');
return Boolean(searchQuery.value);
});
const clearFilters = () => {
searchQuery.value = '';
selectedAssistant.value = 'all';
fetchResponses(1);
};
@@ -267,7 +238,6 @@ const initializeFromURL = () => {
};
onMounted(() => {
store.dispatch('captainAssistants/get');
initializeFromURL();
});
</script>
@@ -276,16 +246,14 @@ onMounted(() => {
<PageLayout
:total-count="responseMeta.totalCount"
:current-page="responseMeta.page"
:button-policy="['administrator']"
:header-title="$t('CAPTAIN.RESPONSES.PENDING_FAQS')"
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
:is-fetching="isFetching"
:is-empty="!filteredResponses.length"
:show-pagination-footer="!isFetching && !!filteredResponses.length"
:show-know-more="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
:back-url="backUrl"
@update:current-page="onPageChange"
@click="handleCreate"
>
<template #knowMore>
<FeatureSpotlightPopover
@@ -299,96 +267,53 @@ onMounted(() => {
/>
</template>
<template #subHeader>
<template #search>
<div
v-if="shouldShowDropdown"
class="mb-2 flex justify-between items-center py-1"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
bulkSelectionState.hasSelected,
}"
v-if="bulkSelectedIds.size === 0"
class="flex gap-3 justify-between w-full items-center"
>
<div
v-if="!bulkSelectionState.hasSelected"
class="flex gap-3 justify-between w-full items-center"
>
<div class="flex items-center gap-3">
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
/>
</div>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
type="search"
autofocus
@input="debouncedSearch"
/>
</div>
<transition
name="slide-fade"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
enter-to-class="opacity-100 transform translate-x-0"
leave-active-class="hidden opacity-0"
>
<div
v-if="bulkSelectionState.hasSelected"
class="flex items-center gap-3"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckbox"
:indeterminate="bulkSelectionState.isIndeterminate"
/>
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
{{ buildSelectedCountLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
{{
$t('CAPTAIN.RESPONSES.SELECTED', {
count: bulkSelectedIds.size,
})
}}
</span>
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex gap-3 items-center">
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
sm
ghost
icon="i-lucide-check"
class="!px-1.5"
@click="handleBulkApprove"
/>
<div class="h-4 w-px bg-n-strong" />
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="bulkDeleteDialog.dialogRef.open()"
/>
</div>
</div>
</transition>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
type="search"
autofocus
@input="debouncedSearch"
/>
</div>
</template>
<template #subHeader>
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="filteredResponses"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{
'mb-2': bulkSelectedIds.size > 0,
}"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
>
<template #secondary-actions>
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
sm
ghost
icon="i-lucide-check"
class="!px-1.5"
@click="handleBulkApprove"
/>
</template>
</BulkSelectBar>
</template>
<template #emptyState>
<ResponsePageEmptyState
variant="pending"
:has-active-filters="hasActiveFilters"
@click="handleCreate"
@clear-filters="clearFilters"
/>
</template>

View File

@@ -87,6 +87,7 @@ onMounted(() => {
:is-fetching="isFetching"
:is-empty="!customTools.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN_V2"
:show-know-more="false"
@update:current-page="onPageChange"
@click="openCreateDialog"
>