fix(editor): Handle chat trigger waiting state in setup cards (#27682)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Svetoslav Dekov
2026-03-30 13:56:47 +03:00
committed by GitHub
parent af2af60a0f
commit 21faa2e187
8 changed files with 98 additions and 8 deletions
@@ -194,7 +194,7 @@ describe('NodeExecuteButton', () => {
});
const { getByRole } = renderComponent();
expect(getByRole('button').textContent).toBe('Test chat');
expect(getByRole('button').textContent).toBe('Open chat');
});
it('displays correct button label for polling node', () => {
@@ -469,7 +469,7 @@ describe('useNodeExecution', () => {
const { buttonLabel } = useNodeExecution(node);
expect(buttonLabel.value).toBe('ndv.execute.testChat');
expect(buttonLabel.value).toBe('chat.open');
});
it('should return listenForTestEvent for webhook node', () => {
@@ -221,7 +221,7 @@ export function useNodeExecution(
}
if (isChatNode.value) {
return i18n.baseText('ndv.execute.testChat');
return i18n.baseText('chat.open');
}
if (isWebhookNode.value) {
@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { N8nButton, N8nCallout, N8nIcon, N8nText } from '@n8n/design-system';
import { CHAT_TRIGGER_NODE_TYPE } from '@/app/constants/nodeTypes';
import NodeIcon from '@/app/components/NodeIcon.vue';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
@@ -101,6 +102,10 @@ const useCredentialIcon = computed(
const isComplete = computed(() => props.state.isComplete);
const isExecutable = computed(() => executableNode.value !== null);
const showExecuteButton = computed(() => {
if (nodeType.value?.name === CHAT_TRIGGER_NODE_TYPE && isInListeningState.value) return false;
return isExecutable.value;
});
const isLastCard = computed(() => props.stepIndex === props.totalCards - 1);
const showArrows = computed(() => props.totalCards > 1);
const isPrevDisabled = computed(() => props.stepIndex === 0);
@@ -229,7 +234,7 @@ watch(isActive, (active, wasActive) => {
/>
<TriggerExecuteButton
v-if="isExecutable"
v-if="showExecuteButton"
:label="executeLabel"
:icon="executeButtonIcon"
:disabled="isButtonDisabled || isTestingCredential"
@@ -4,6 +4,7 @@ import { useI18n } from '@n8n/i18n';
import { N8nCallout, N8nIcon, N8nText } from '@n8n/design-system';
import type { INodeUi } from '@/Interface';
import { CHAT_TRIGGER_NODE_TYPE } from '@/app/constants/nodeTypes';
import TriggerExecuteButton from '@/features/setupPanel/components/TriggerExecuteButton.vue';
import WebhookUrlPreview from '@/features/setupPanel/components/WebhookUrlPreview.vue';
import { useTriggerExecution } from '@/features/setupPanel/composables/useTriggerExecution';
@@ -80,6 +81,12 @@ const {
const { webhookUrls } = useWebhookUrls(executableNodeRef);
const showExecuteButton = computed(() => {
if (props.executableNode?.type === CHAT_TRIGGER_NODE_TYPE && isInListeningState.value)
return false;
return !!props.executableNode;
});
const showTriggerCallout = computed(() => props.isTrigger && isInListeningState.value);
const onExecuteClick = async () => {
@@ -183,7 +190,7 @@ defineExpose({ markInteracted });
</div>
<slot name="footer-actions" />
<TriggerExecuteButton
v-if="executableNode"
v-if="showExecuteButton"
:label="executeLabel"
:icon="executeButtonIcon"
:disabled="isButtonDisabled || isTestingCredential"
@@ -4,6 +4,9 @@ import { createTestingPinia } from '@pinia/testing';
import { createTestNode, mockNodeTypeDescription } from '@/__tests__/mocks';
import { mockedStore } from '@/__tests__/utils';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useLogsStore } from '@/app/stores/logs.store';
import { CHAT_TRIGGER_NODE_TYPE } from '@/app/constants/nodeTypes';
import type { INodeUi } from '@/Interface';
import { useTriggerExecution } from '@/features/setupPanel/composables/useTriggerExecution';
@@ -43,10 +46,14 @@ const createNode = (overrides: Partial<INodeUi> = {}): INodeUi =>
describe('useTriggerExecution', () => {
let nodeTypesStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
beforeEach(() => {
createTestingPinia();
nodeTypesStore = mockedStore(useNodeTypesStore);
workflowsStore = mockedStore(useWorkflowsStore);
logsStore = mockedStore(useLogsStore);
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(null);
mockExecutionState.isExecuting = false;
@@ -107,6 +114,66 @@ describe('useTriggerExecution', () => {
expect(isInListeningState.value).toBe(true);
});
it('should be true for chat trigger when logs panel is open and destination node matches', () => {
const chatNode = createNode({
name: 'When chat message received',
type: CHAT_TRIGGER_NODE_TYPE,
});
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(
mockNodeTypeDescription({
name: CHAT_TRIGGER_NODE_TYPE,
displayName: 'When chat message received',
}),
);
logsStore.isOpen = true;
workflowsStore.chatPartialExecutionDestinationNode = 'When chat message received';
const node = ref<INodeUi | null>(chatNode);
const { isInListeningState } = useTriggerExecution(node);
expect(isInListeningState.value).toBe(true);
});
it('should be false for chat trigger when logs panel is closed', () => {
const chatNode = createNode({
name: 'When chat message received',
type: CHAT_TRIGGER_NODE_TYPE,
});
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(
mockNodeTypeDescription({
name: CHAT_TRIGGER_NODE_TYPE,
displayName: 'When chat message received',
}),
);
logsStore.isOpen = false;
workflowsStore.chatPartialExecutionDestinationNode = 'When chat message received';
const node = ref<INodeUi | null>(chatNode);
const { isInListeningState } = useTriggerExecution(node);
expect(isInListeningState.value).toBe(false);
});
it('should be false for chat trigger when destination node does not match', () => {
const chatNode = createNode({
name: 'When chat message received',
type: CHAT_TRIGGER_NODE_TYPE,
});
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(
mockNodeTypeDescription({
name: CHAT_TRIGGER_NODE_TYPE,
displayName: 'When chat message received',
}),
);
logsStore.isOpen = true;
workflowsStore.chatPartialExecutionDestinationNode = 'Some Other Node';
const node = ref<INodeUi | null>(chatNode);
const { isInListeningState } = useTriggerExecution(node);
expect(isInListeningState.value).toBe(false);
});
});
describe('isButtonDisabled', () => {
@@ -6,6 +6,8 @@ import { useNodeExecution, type UseNodeExecutionOptions } from '@/app/composable
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { getTriggerNodeServiceName } from '@/app/utils/nodeTypesUtils';
import { CHAT_TRIGGER_NODE_TYPE } from '@/app/constants/nodeTypes';
import { useLogsStore } from '@/app/stores/logs.store';
/**
* Wraps `useNodeExecution` with listening-hint logic for setup-panel cards.
@@ -19,6 +21,7 @@ export function useTriggerExecution(
const i18n = useI18n();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const logsStore = useLogsStore();
const {
isExecuting,
@@ -39,9 +42,15 @@ export function useTriggerExecution(
: null,
);
const isInListeningState = computed(
() => isListening.value || isListeningForWorkflowEvents.value,
);
const isInListeningState = computed(() => {
if (isListening.value || isListeningForWorkflowEvents.value) return true;
return (
nodeType.value?.name === CHAT_TRIGGER_NODE_TYPE &&
logsStore.isOpen &&
workflowsStore.chatPartialExecutionDestinationNode === nodeValue.value?.name
);
});
const listeningHint = computed(() => {
if (!isInListeningState.value || !nodeType.value) return '';
@@ -1,6 +1,7 @@
import { computed, toValue, type MaybeRef } from 'vue';
import { computedAsync } from '@vueuse/core';
import type { IWebhookDescription } from 'n8n-workflow';
import { CHAT_TRIGGER_NODE_TYPE } from '@/app/constants/nodeTypes';
import type { INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
@@ -82,6 +83,7 @@ export function useWebhookUrls(node: MaybeRef<INodeUi | null>) {
const webhookUrls = computedAsync(async () => {
const currentNode = nodeValue.value;
if (!currentNode || webhooks.value.length === 0) return [];
if (nodeType.value?.name === CHAT_TRIGGER_NODE_TYPE) return [];
// Access parameters synchronously so Vue tracks it as a dependency.
// Without this, changing a node parameter (e.g. webhook path) won't