chore: rework the canned response picker with search and preview (#15354)

# Pull Request Template

## Description

This PR reworks the canned response picker to make it easier to search,
browse, and preview canned responses before inserting them. Typing `/`
now opens a larger picker with its own search field and a preview pane.
Previously, only a few responses were visible at a time, there was no
way to preview the full content, and searching relied on typing into the
composer, which stopped working for multi-word queries.

Search is now handled entirely inside the picker, so the composer stays
untouched while searching. Results match both the canned response
shortcut and its content, and each result shows a snippet centered
around the matched text instead of always displaying the beginning of
the response. The preview renders the response exactly as it will be
inserted, with variables resolved against the current conversation and
formatting unsupported by the channel already stripped.

The picker is positioned relative to the current typing line and
teleported to `body`, so it is no longer clipped by the composer. It
behaves consistently across the reply editor, the New Conversation
composer, and narrower editors such as Contact Notes, where the preview
moves below the list instead of disappearing.

This also fixes a pre-existing bug where variables without a value were
removed from the inserted text instead of being left for the backend to
resolve. In the New Conversation composer, where no variables are
available, all `{{ }}` placeholders were previously being silently
removed.

Fixes
https://linear.app/chatwoot/issue/CW-7854/inconvenient-canned-response-picker-and-lack-of-personal-canned

## Type of change

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

## How Has This Been Tested?

### Screenshots
<img width="1135" height="576" alt="image"
src="https://github.com/user-attachments/assets/b5f27b94-eeb5-4ac6-b6d9-da7dfd8c2306"
/>
<img width="393" height="490" alt="image"
src="https://github.com/user-attachments/assets/779f88b1-4958-41f5-9f51-c2eb8db7e53c"
/>



### Steps

1. Open a conversation and type `/` in the reply editor.
2. Search using a multi-word phrase that appears within a canned
response. Verify the matching response appears with a snippet centered
around the matched text.
3. Navigate the results with the arrow keys or Tab and verify the
preview updates.
4. Press Enter or click a response to insert it, and press Escape to
close the picker.
5. Repeat in a narrow editor such as Contact Notes and verify the
preview pane moves below the list.
6. In the New Conversation composer, insert a canned response containing
variables and verify the `{{ }}` placeholders are kept rather than
removed.



## 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
This commit is contained in:
Sivin Varghese
2026-08-10 19:51:30 +05:30
committed by GitHub
parent bdbbaa38de
commit cce94aa936
10 changed files with 612 additions and 71 deletions

View File

@@ -196,7 +196,7 @@ const showEmojiMenu = ref(false);
const showToolsMenu = ref(false);
const mentionSearchKey = ref('');
const toolSearchKey = ref('');
const cannedSearchTerm = ref('');
const cannedSearchKey = ref('');
const variableSearchTerm = ref('');
const emojiSearchTerm = ref('');
const range = ref(null);
@@ -208,6 +208,17 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
// Anchors the picker to the trigger character, since editors can be much taller than the
// line being typed on. Offsets are relative to the editor so the picker can sit on that
// line and track it from there.
const caretPosition = computed(() => {
if (!editorView || !range.value || !editorRoot.value) return null;
const from = Math.min(range.value.from, editorView.state.doc.content.size);
const { top, bottom } = editorView.coordsAtPos(from);
const editorTop = editorRoot.value.getBoundingClientRect().top;
return { top: top - editorTop, height: bottom - top };
});
const isEditorMenuPopover = computed(
() =>
editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false
@@ -242,22 +253,41 @@ const shouldShowCannedResponses = computed(() => {
);
});
// The picker owns the search field, so it takes focus while open. Dismissing it hands
// focus back; selecting one does so through the insert itself. The suggestion stays
// active in the document, so the picker only reopens once the trigger is typed afresh.
const dismissCannedResponses = () => {
showCannedMenu.value = false;
editorView?.focus();
};
// Deleting the trigger drops the suggestion, so the plugin closes the picker through
// `onExit` on its own.
const removeSuggestionTrigger = () => {
if (!editorView || !range.value) return;
const { from, to } = range.value;
const end = Math.min(to, editorView.state.doc.content.size);
editorView.dispatch(editorView.state.tr.delete(from, end));
editorView.focus();
};
function createSuggestionPlugin({
trigger,
minChars = 0,
showMenu,
searchTerm,
isAllowed = () => true,
interceptEnter = true,
}) {
return suggestionsPlugin({
matcher: triggerCharacters(trigger, minChars),
suggestionClass: '',
onEnter: args => {
if (!isAllowed()) return false;
showMenu.value = true;
range.value = args.range;
editorView = args.view;
if (searchTerm) searchTerm.value = args.text || '';
showMenu.value = true;
return false;
},
onChange: args => {
@@ -272,7 +302,7 @@ function createSuggestionPlugin({
return false;
},
onKeyDown: ({ event }) => {
return event.keyCode === 13 && showMenu.value;
return event.keyCode === 13 && showMenu.value && interceptEnter;
},
});
}
@@ -298,8 +328,9 @@ const plugins = computed(() => {
createSuggestionPlugin({
trigger: '/',
showMenu: showCannedMenu,
searchTerm: cannedSearchTerm,
searchTerm: cannedSearchKey,
isAllowed: () => !props.isPrivate,
interceptEnter: false,
}),
createSuggestionPlugin({
trigger: '{{',
@@ -338,8 +369,8 @@ const sendWithSignature = computed(() => {
watch(showUserMentions, updatedValue => {
emit('toggleUserMention', props.isPrivate && updatedValue);
});
watch(showCannedMenu, updatedValue => {
emit('toggleCannedMenu', !props.isPrivate && updatedValue);
watch(shouldShowCannedResponses, updatedValue => {
emit('toggleCannedMenu', updatedValue);
});
watch(showVariables, updatedValue => {
emit('toggleVariablesMenu', !props.isPrivate && updatedValue);
@@ -799,7 +830,6 @@ watch(
showCannedMenu.value = false;
showEmojiMenu.value = false;
showVariables.value = false;
cannedSearchTerm.value = '';
reloadState(props.modelValue);
}
);
@@ -884,7 +914,12 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
/>
<CannedResponse
v-if="shouldShowCannedResponses"
:search-key="cannedSearchTerm"
:caret-position="caretPosition"
:search-key="cannedSearchKey"
:variables="variables"
:schema="editorSchema"
@close="dismissCannedResponses"
@remove-trigger="removeSuggestionTrigger"
@replace="content => insertSpecialContent('cannedResponse', content)"
/>
<VariableList

View File

@@ -1,52 +1,146 @@
<script>
import { mapGetters } from 'vuex';
import MentionBox from '../mentions/MentionBox.vue';
<script setup>
import { computed, ref, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTimeoutFn } from '@vueuse/core';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
import {
resolveVariablesInMessage,
stripUnsupportedFormatting,
} from 'dashboard/helper/editorHelper';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import CaretAnchoredPicker from 'dashboard/components-next/preview-picker/CaretAnchoredPicker.vue';
export default {
components: { MentionBox },
props: {
searchKey: {
type: String,
default: '',
},
const props = defineProps({
caretPosition: {
type: Object,
default: null,
},
emits: ['replace'],
computed: {
...mapGetters({
cannedMessages: 'getCannedResponses',
}),
items() {
return this.cannedMessages.map(cannedMessage => ({
label: cannedMessage.short_code,
key: cannedMessage.short_code,
description: cannedMessage.content,
}));
},
searchKey: {
type: String,
default: '',
},
watch: {
searchKey() {
this.fetchCannedResponses();
},
variables: {
type: Object,
default: () => ({}),
},
mounted() {
this.fetchCannedResponses();
},
methods: {
fetchCannedResponses() {
this.$store.dispatch('getCannedResponse', { searchKey: this.searchKey });
},
handleMentionClick(item = {}) {
this.$emit('replace', item.description);
},
schema: {
type: Object,
default: null,
},
});
const emit = defineEmits(['replace', 'close', 'removeTrigger']);
// Characters kept before the match when a snippet has to skip ahead
const SNIPPET_LEAD = 24;
const SEARCH_DEBOUNCE = 200;
const HIGHLIGHT_CLASS = 'text-n-blue-text';
const store = useStore();
const { t } = useI18n();
const { getPlainText, formatMessage, highlightContent } = useMessageFormatter();
const cannedResponses = useMapGetter('getCannedResponses');
// The trigger can already be followed by text, from a draft or a caret moved back onto it
const searchQuery = ref(props.searchKey);
const searchTerm = computed(() => searchQuery.value.trim());
// An empty term makes `highlightContent`'s regex match at every position, wrapping the
// whole string in empty spans
const highlightMatches = text =>
searchTerm.value
? highlightContent(text, searchTerm.value, HIGHLIGHT_CLASS)
: text;
const buildSnippet = text => {
const term = searchTerm.value;
if (!term) return text;
const index = text.toLowerCase().indexOf(term.toLowerCase());
if (index <= SNIPPET_LEAD) return text;
return `${text.slice(index - SNIPPET_LEAD)}`;
};
// Both steps mirror what insertion does: variables are substituted, then formatting the
// channel's schema cannot carry is stripped. Previewing the raw content would advertise
// styling the message never ends up with.
const resolveContent = message =>
stripUnsupportedFormatting(
resolveVariablesInMessage(message, props.variables),
props.schema
);
const records = computed(() =>
cannedResponses.value.map(({ id, short_code: shortCode, content }) => {
const resolved = resolveContent(content);
return {
id,
content,
resolved,
shortCode,
plainText: getPlainText(resolved).replace(/\s+/g, ' ').trim(),
};
})
);
const items = computed(() =>
records.value.map(record => ({
id: record.id,
content: record.content,
resolved: record.resolved,
label: `/${record.shortCode}`,
title: highlightMatches(`/${record.shortCode}`),
subtitle: highlightMatches(buildSnippet(record.plainText)),
}))
);
const onSelect = item => emit('replace', item.content);
const { run: runFetch } = useAbortableRequest();
const fetchCannedResponses = () => {
runFetch(signal =>
store.dispatch('getCannedResponse', {
searchKey: searchTerm.value,
signal,
})
);
};
const { start: scheduleFetch } = useTimeoutFn(
fetchCannedResponses,
SEARCH_DEBOUNCE,
{ immediate: false }
);
watch(searchTerm, scheduleFetch);
onMounted(fetchCannedResponses);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
<CaretAnchoredPicker
v-model:search="searchQuery"
:caret-position="caretPosition"
:items="items"
@mention-select="handleMentionClick"
/>
:search-placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
:empty-label="
searchTerm
? t('COMBOBOX.EMPTY_SEARCH_RESULTS', { searchTerm })
: t('COMBOBOX.EMPTY_STATE')
"
@select="onSelect"
@close="emit('close')"
@remove-trigger="emit('removeTrigger')"
>
<template #preview="{ item }">
<div
v-dompurify-html="formatMessage(item?.resolved || '')"
class="px-4 py-3 text-sm break-words prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
/>
</template>
</CaretAnchoredPicker>
</template>