From cce94aa936ad4a16cd6fc9fa8b27c87d3fd20a5f Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Mon, 10 Aug 2026 19:51:30 +0530
Subject: [PATCH] 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
### 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
---
.../dashboard/api/cannedResponse.js | 8 +-
.../components-next/popover/Popover.vue | 16 +-
.../preview-picker/CaretAnchoredPicker.vue | 195 ++++++++++++++++++
.../preview-picker/PreviewPicker.vue | 175 ++++++++++++++++
.../components/widgets/WootWriter/Editor.vue | 51 ++++-
.../widgets/conversation/CannedResponse.vue | 176 ++++++++++++----
.../composables/useKeyboardNavigableList.js | 2 +
.../dashboard/helper/editorHelper.js | 13 +-
.../helper/specs/editorContentHelper.spec.js | 43 +++-
.../dashboard/store/modules/cannedResponse.js | 4 +-
10 files changed, 612 insertions(+), 71 deletions(-)
create mode 100644 app/javascript/dashboard/components-next/preview-picker/CaretAnchoredPicker.vue
create mode 100644 app/javascript/dashboard/components-next/preview-picker/PreviewPicker.vue
diff --git a/app/javascript/dashboard/api/cannedResponse.js b/app/javascript/dashboard/api/cannedResponse.js
index f558dcaca..1299a395c 100644
--- a/app/javascript/dashboard/api/cannedResponse.js
+++ b/app/javascript/dashboard/api/cannedResponse.js
@@ -7,9 +7,11 @@ class CannedResponse extends ApiClient {
super('canned_responses', { accountScoped: true });
}
- get({ searchKey }) {
- const url = searchKey ? `${this.url}?search=${searchKey}` : this.url;
- return axios.get(url);
+ get({ searchKey, signal } = {}) {
+ return axios.get(this.url, {
+ params: searchKey ? { search: searchKey } : undefined,
+ signal,
+ });
}
}
diff --git a/app/javascript/dashboard/components-next/popover/Popover.vue b/app/javascript/dashboard/components-next/popover/Popover.vue
index 9d369133f..6691e0a32 100644
--- a/app/javascript/dashboard/components-next/popover/Popover.vue
+++ b/app/javascript/dashboard/components-next/popover/Popover.vue
@@ -107,9 +107,23 @@ const clickOutsideIgnore = [
'[data-popover-content]',
];
+// An overlay opened from inside the popover teleports out of it, so its own Escape handler
+// registers after this one and cannot stop it. Leave Escape to whichever overlay the key
+// was pressed in; closing the popover out from under it would discard the work in progress.
+const isNestedOverlay = event => {
+ const overlay = event.target?.closest?.(clickOutsideIgnore.join(','));
+ return Boolean(
+ overlay &&
+ overlay !== popoverRef.value &&
+ overlay !== mobileContentRef.value
+ );
+};
+
useKeyboardEvents({
Escape: {
- action: () => isActive.value && hide(),
+ action: event => {
+ if (isActive.value && !isNestedOverlay(event)) hide();
+ },
allowOnFocusedInput: true,
},
});
diff --git a/app/javascript/dashboard/components-next/preview-picker/CaretAnchoredPicker.vue b/app/javascript/dashboard/components-next/preview-picker/CaretAnchoredPicker.vue
new file mode 100644
index 000000000..1a5546595
--- /dev/null
+++ b/app/javascript/dashboard/components-next/preview-picker/CaretAnchoredPicker.vue
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/preview-picker/PreviewPicker.vue b/app/javascript/dashboard/components-next/preview-picker/PreviewPicker.vue
new file mode 100644
index 000000000..fa3e69a31
--- /dev/null
+++ b/app/javascript/dashboard/components-next/preview-picker/PreviewPicker.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ groupFor(index) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ emptyLabel }}
+
+
+
+
+
+
+ {{ selectedItem?.label }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index 634276361..3762a5bf3 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -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);
/>
insertSpecialContent('cannedResponse', content)"
/>
-import { mapGetters } from 'vuex';
-import MentionBox from '../mentions/MentionBox.vue';
+
-
-
+ :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')"
+ >
+
+
+
+
diff --git a/app/javascript/dashboard/composables/useKeyboardNavigableList.js b/app/javascript/dashboard/composables/useKeyboardNavigableList.js
index aa9fb9e1d..82a8c22aa 100644
--- a/app/javascript/dashboard/composables/useKeyboardNavigableList.js
+++ b/app/javascript/dashboard/composables/useKeyboardNavigableList.js
@@ -92,6 +92,8 @@ export function useKeyboardNavigableList({
selectedIndex,
}) {
const moveSelection = direction => {
+ if (!items.value?.length) return;
+
selectedIndex.value = updateSelectionIndex(
selectedIndex.value,
items.value.length,
diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js
index 3d342f06e..e01a2f622 100644
--- a/app/javascript/dashboard/helper/editorHelper.js
+++ b/app/javascript/dashboard/helper/editorHelper.js
@@ -6,7 +6,6 @@ import {
messageSchema,
Selection,
} from '@chatwoot/prosemirror-schema';
-import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
@@ -433,12 +432,19 @@ export function stripUnsupportedFormatting(content, schema) {
// Liquid delimiters ({{ }} / {% %}) the backend evaluates on send.
const LIQUID_SYNTAX = /\{\{|\{%/;
+const VARIABLE_PLACEHOLDER = /{{(.*?)}}/g;
+
// Value when set (and not itself Liquid), else the {{placeholder}} for the backend.
export const resolveVariableText = (key, variables) => {
const value = String(variables?.[key] ?? '');
return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`;
};
+export const resolveVariablesInMessage = (message, variables) =>
+ message?.replace(VARIABLE_PLACEHOLDER, (_, key) =>
+ resolveVariableText(key.trim(), variables)
+ );
+
// Name variables normalized like the backend drops (UserDrop/ContactDrop):
// name split on whitespace, each word Ruby-capitalized (rest downcased).
const getNameVariables = (prefix, name) => {
@@ -537,10 +543,7 @@ const nodeCreators = {
to,
}),
cannedResponse: (editorView, content, from, to, variables) => {
- const updatedMessage = replaceVariablesInMessage({
- message: content,
- variables,
- });
+ const updatedMessage = resolveVariablesInMessage(content, variables);
const node = createNode(editorView, 'cannedResponse', updatedMessage);
return {
node,
diff --git a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
index 57d8bd533..66b8bfac3 100644
--- a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
@@ -2,16 +2,11 @@
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
import { getContentNode } from '../editorHelper';
import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
-import { replaceVariablesInMessage } from '@chatwoot/utils';
vi.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: vi.fn(),
}));
-vi.mock('@chatwoot/utils', () => ({
- replaceVariablesInMessage: vi.fn(),
-}));
-
describe('getContentNode', () => {
let editorView;
@@ -61,8 +56,6 @@ describe('getContentNode', () => {
// Mock the node that will be returned by parse
const mockNode = { textContent: updatedMessage };
- replaceVariablesInMessage.mockReturnValue(updatedMessage);
-
// Mock MessageMarkdownTransformer instance with parse method
const mockTransformer = {
parse: vi.fn().mockReturnValue(mockNode),
@@ -77,10 +70,6 @@ describe('getContentNode', () => {
variables
);
- expect(replaceVariablesInMessage).toHaveBeenCalledWith({
- message: content,
- variables,
- });
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
editorView.state.schema
);
@@ -91,6 +80,38 @@ describe('getContentNode', () => {
expect(result.from).toBe(from);
expect(result.to).toBe(to);
});
+
+ it('should keep the placeholder for variables that have no value', () => {
+ const mockTransformer = { parse: vi.fn().mockReturnValue({}) };
+ MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
+
+ getContentNode(
+ editorView,
+ 'cannedResponse',
+ 'Hi {{contact.name}}, your plan is {{contact.custom_attribute.plan}}',
+ { from: 0, to: 10 },
+ { 'contact.name': 'John' }
+ );
+
+ expect(mockTransformer.parse).toHaveBeenCalledWith(
+ 'Hi John, your plan is {{contact.custom_attribute.plan}}'
+ );
+ });
+
+ it('should keep every placeholder when no variables are available', () => {
+ const mockTransformer = { parse: vi.fn().mockReturnValue({}) };
+ MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
+
+ getContentNode(
+ editorView,
+ 'cannedResponse',
+ 'Hi {{contact.name}}',
+ { from: 0, to: 10 },
+ {}
+ );
+
+ expect(mockTransformer.parse).toHaveBeenCalledWith('Hi {{contact.name}}');
+ });
});
describe('getVariableNode', () => {
diff --git a/app/javascript/dashboard/store/modules/cannedResponse.js b/app/javascript/dashboard/store/modules/cannedResponse.js
index 568150392..4845f06f7 100644
--- a/app/javascript/dashboard/store/modules/cannedResponse.js
+++ b/app/javascript/dashboard/store/modules/cannedResponse.js
@@ -35,11 +35,11 @@ const getters = {
const actions = {
getCannedResponse: async function getCannedResponse(
{ commit },
- { searchKey } = {}
+ { searchKey, signal } = {}
) {
commit(types.default.SET_CANNED_UI_FLAG, { fetchingList: true });
try {
- const response = await CannedResponseAPI.get({ searchKey });
+ const response = await CannedResponseAPI.get({ searchKey, signal });
commit(types.default.SET_CANNED, response.data);
commit(types.default.SET_CANNED_UI_FLAG, { fetchingList: false });
} catch (error) {