feat: Conversation workflows(EE) (#13040)

We are expanding Chatwoot’s automation capabilities by
introducing **Conversation Workflows**, a dedicated section in settings
where teams can configure rules that govern how conversations are closed
and what information agents must fill before resolving. This feature
helps teams enforce data consistency, collect structured resolution
information, and ensure downstream reporting is accurate.

Instead of having auto‑resolution buried inside Account Settings, we
introduced a new sidebar item:
- Auto‑resolve conversations (existing behaviour)
- Required attributes on resolution (new)

This groups all conversation‑closing logic into a single place.

#### Required Attributes on Resolve

Admins can now pick which custom conversation attributes must be filled
before an agent can resolve a conversation.

**How it works**

- Admin selects one or more attributes from the list of existing
conversation level custom attributes.
- These selected attributes become mandatory during resolution.
- List all the attributes configured via Required Attributes (Text,
Number, Link, Date, List, Checkbox)
- When an agent clicks Resolve Conversation:
If attributes already have values → the conversation resolves normally.
If attributes are missing → a modal appears prompting the agent to fill
them.

<img width="1554" height="1282" alt="CleanShot 2025-12-10 at 11 42
23@2x"
src="https://github.com/user-attachments/assets/4cd5d6e1-abe8-4999-accd-d4a08913b373"
/>


#### Custom Attributes Integration

On the Custom Attributes page, we will surfaced indicators showing how
each attribute is being used.

Each attribute will show badges such as:

- Resolution → used in the required‑on‑resolve workflow

- Pre‑chat form → already existing

<img width="2390" height="1822" alt="CleanShot 2025-12-10 at 11 43
42@2x"
src="https://github.com/user-attachments/assets/b92a6eb7-7f6c-40e6-bf23-6a5310f2d9c5"
/>


#### Admin Flow

- Navigate to Settings → Conversation Workflows.
- Under Required attributes on resolve, click Add Required Attribute.
- Pick from the dropdown list of conversation attributes.
- Save changes.

Agents will now be prompted automatically whenever they resolve.

<img width="2434" height="872" alt="CleanShot 2025-12-10 at 11 44 42@2x"
src="https://github.com/user-attachments/assets/632fc0e5-767c-4a1c-8cf4-ffe3d058d319"
/>



#### NOTES
- The Required Attributes on Resolve modal should only appear when
values are missing.
- Required attributes must block the resolution action until satisfied.
- Bulk‑resolve actions should follow the same rules — any conversation
missing attributes cannot be bulk‑resolved, rest will be resolved, show
a notification that the resolution cannot be done.
- API resolution does not respect the attributes.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Muhsin Keloth
2026-01-27 11:36:20 +04:00
committed by GitHub
parent 885b041a83
commit 04b2901e1f
39 changed files with 1514 additions and 329 deletions

View File

@@ -76,10 +76,7 @@ const toggleButtonText = computed(() =>
const filteredCustomAttributes = computed(() =>
attributes.value.map(attribute => {
// Check if the attribute key exists in customAttributes
const hasValue = Object.hasOwnProperty.call(
customAttributes.value,
attribute.attribute_key
);
const hasValue = attribute.attribute_key in customAttributes.value;
return {
...attribute,

View File

@@ -14,7 +14,6 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
import AccountId from './components/AccountId.vue';
import BuildInfo from './components/BuildInfo.vue';
import AccountDelete from './components/AccountDelete.vue';
import AutoResolve from './components/AutoResolve.vue';
import AudioTranscription from './components/AudioTranscription.vue';
import SectionLayout from './components/SectionLayout.vue';
@@ -25,7 +24,6 @@ export default {
AccountId,
BuildInfo,
AccountDelete,
AutoResolve,
AudioTranscription,
SectionLayout,
WithLabel,
@@ -64,12 +62,6 @@ export default {
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
showAutoResolutionConfig() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
);
},
showAudioTranscriptionConfig() {
return this.isFeatureEnabledonAccount(
this.accountId,
@@ -239,7 +231,6 @@ export default {
<woot-loading-state v-if="uiFlags.isFetchingItem" />
</div>
<AutoResolve v-if="showAutoResolutionConfig" />
<AudioTranscription v-if="showAudioTranscriptionConfig" />
<AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">

View File

@@ -4,7 +4,6 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue';
@@ -125,81 +124,90 @@ const toggleAutoResolve = async () => {
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
:hide-content="!isEnabled"
with-border
<div
class="flex flex-col w-full outline-1 outline outline-n-container rounded-xl bg-n-solid-2 divide-y divide-n-weak"
>
<template #headerActions>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
</div>
</template>
<form class="grid gap-5" @submit.prevent="handleSubmit">
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.HELP')"
>
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model="duration"
v-model:unit="unit"
min="0"
max="1438560"
class="w-full"
/>
<div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full">
<h3 class="text-base font-medium text-n-slate-12">
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE') }}
</h3>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
</div>
</WithLabel>
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
>
<TextArea
v-model="message"
class="w-full"
:placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
"
/>
</WithLabel>
<WithLabel :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')">
<div
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
</div>
<p class="mb-0 text-sm text-n-slate-11">
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE') }}
</p>
</div>
<div v-if="isEnabled" class="px-5 py-4">
<form class="grid gap-5" @submit.prevent="handleSubmit">
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.HELP')"
>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL') }}
</span>
<Switch v-model="ignoreWaiting" />
</div>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL') }}
</span>
<SingleSelect
v-model="labelToApply"
:options="labelOptions"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER')
"
placeholder-icon="i-lucide-chevron-down"
placeholder-trailing-icon
variant="faded"
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model="duration"
v-model:unit="unit"
min="0"
max="1438560"
class="w-full"
/>
</div>
</WithLabel>
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
>
<TextArea
v-model="message"
class="w-full"
:placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
"
/>
</WithLabel>
<WithLabel :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')">
<div
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL')
}}
</span>
<Switch v-model="ignoreWaiting" />
</div>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL') }}
</span>
<SingleSelect
v-model="labelToApply"
:options="labelOptions"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER')
"
placeholder-icon="i-lucide-chevron-down"
placeholder-trailing-icon
variant="faded"
/>
</div>
</div>
</WithLabel>
<div class="flex gap-2">
<NextButton
blue
type="submit"
:is-loading="isSubmitting"
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')"
/>
</div>
</WithLabel>
<div class="flex gap-2">
<NextButton
blue
type="submit"
:is-loading="isSubmitting"
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')"
/>
</div>
</form>
</SectionLayout>
</form>
</div>
</div>
</template>

View File

@@ -1,176 +0,0 @@
<script setup>
import { useAlert } from 'dashboard/composables';
import EditAttribute from './EditAttribute.vue';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attributeModel: {
type: String,
default: 'conversation_attribute',
},
});
const { t } = useI18n();
const showEditPopup = ref(false);
const showDeletePopup = ref(false);
const selectedAttribute = ref({});
const getters = useStoreGetters();
const store = useStore();
const attributes = computed(() =>
getters['attributes/getAttributesByModel'].value(props.attributeModel)
);
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const attributeDisplayName = computed(
() => selectedAttribute.value.attribute_display_name
);
const deleteConfirmText = computed(
() =>
`${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${attributeDisplayName.value}`
);
const deleteRejectText = computed(() => t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO'));
const confirmDeleteTitle = computed(() =>
t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
attributeName: attributeDisplayName.value,
})
);
const confirmPlaceHolderText = computed(
() =>
`${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
attributeName: attributeDisplayName.value,
})}`
);
const deleteAttributes = async ({ id }) => {
try {
await store.dispatch('attributes/delete', id);
useAlert(t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message || t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const openEditPopup = response => {
showEditPopup.value = true;
selectedAttribute.value = response;
};
const hideEditPopup = () => {
showEditPopup.value = false;
};
const closeDelete = () => {
showDeletePopup.value = false;
selectedAttribute.value = {};
};
const confirmDeletion = () => {
deleteAttributes(selectedAttribute.value);
closeDelete();
};
const openDelete = value => {
showDeletePopup.value = true;
selectedAttribute.value = value;
};
const tableHeaders = computed(() => {
return [
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.NAME'),
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.DESCRIPTION'),
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.TYPE'),
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.KEY'),
];
});
</script>
<template>
<div class="flex flex-col">
<table class="min-w-full overflow-x-auto">
<thead>
<th
v-for="tableHeader in tableHeaders"
:key="tableHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11"
>
{{ tableHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak flex-1 text-n-slate-12">
<tr v-for="attribute in attributes" :key="attribute.attribute_key">
<td
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_display_name }}
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
{{ attribute.attribute_description }}
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{
$t(
`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${attribute.attribute_display_type?.toUpperCase()}`
)
}}
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 attribute-key overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_key }}
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end">
<Button
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.EDIT')"
icon="i-lucide-pen"
slate
xs
faded
@click="openEditPopup(attribute)"
/>
<Button
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.DELETE')"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="openDelete(attribute)"
/>
</div>
</td>
</tr>
</tbody>
</table>
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
<EditAttribute
:selected-attribute="selectedAttribute"
:is-updating="uiFlags.isUpdating"
@on-close="hideEditPopup"
/>
</woot-modal>
<woot-confirm-delete-modal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="confirmDeleteTitle"
:message="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.MESSAGE')"
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
:confirm-value="selectedAttribute.attribute_display_name"
:confirm-place-holder-text="confirmPlaceHolderText"
@on-confirm="confirmDeletion"
@on-close="closeDelete"
/>
</div>
</template>
<style lang="scss" scoped>
.attribute-key {
font-family: monospace;
}
</style>

View File

@@ -0,0 +1,22 @@
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
import ConversationWorkflowIndex from './index.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/conversation-workflow'),
component: SettingsWrapper,
children: [
{
path: '',
name: 'conversation_workflow_index',
component: ConversationWorkflowIndex,
meta: {
permissions: ['administrator'],
},
},
],
},
],
};

View File

@@ -0,0 +1,48 @@
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ConversationRequiredAttributes from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributes.vue';
import AutoResolve from 'dashboard/routes/dashboard/settings/account/components/AutoResolve.vue';
const { accountId } = useAccount();
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showAutoResolutionConfig = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
);
});
const showRequiredAttributes = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES
);
});
</script>
<template>
<SettingsLayout :no-records-found="false" class="gap-10">
<template #header>
<BaseSettingsHeader
:title="$t('CONVERSATION_WORKFLOW.INDEX.HEADER.TITLE')"
:description="$t('CONVERSATION_WORKFLOW.INDEX.HEADER.DESCRIPTION')"
feature-name="conversation-workflow"
/>
</template>
<template #body>
<div class="flex flex-col gap-6">
<AutoResolve v-if="showAutoResolutionConfig" />
<ConversationRequiredAttributes :is-enabled="showRequiredAttributes" />
</div>
</template>
</SettingsLayout>
</template>

View File

@@ -24,6 +24,7 @@ import teams from './teams/teams.routes';
import customRoles from './customRoles/customRole.routes';
import profile from './profile/profile.routes';
import security from './security/security.routes';
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
import captain from './captain/captain.routes';
export default {
@@ -64,6 +65,7 @@ export default {
...customRoles.routes,
...profile.routes,
...security.routes,
...conversationWorkflow.routes,
...captain.routes,
],
};