Skip to content

Commit 1ab105d

Browse files
OlegIvanivriascho
authored andcommitted
feat(editor): Canvas chat UI & UX improvements (#11924)
1 parent beda838 commit 1ab105d

File tree

17 files changed

+258
-212
lines changed

17 files changed

+258
-212
lines changed

Diff for: packages/@n8n/chat/src/components/Input.vue

+16-9
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@ const isSubmitting = ref(false);
3838
const resizeObserver = ref<ResizeObserver | null>(null);
3939
4040
const isSubmitDisabled = computed(() => {
41-
return input.value === '' || waitingForResponse.value || options.disabled?.value === true;
41+
return input.value === '' || unref(waitingForResponse) || options.disabled?.value === true;
4242
});
4343
4444
const isInputDisabled = computed(() => options.disabled?.value === true);
4545
const isFileUploadDisabled = computed(
46-
() => isFileUploadAllowed.value && waitingForResponse.value && !options.disabled?.value,
46+
() => isFileUploadAllowed.value && unref(waitingForResponse) && !options.disabled?.value,
4747
);
4848
const isFileUploadAllowed = computed(() => unref(options.allowFileUploads) === true);
4949
const allowedFileTypes = computed(() => unref(options.allowedFilesMimeTypes));
@@ -194,10 +194,13 @@ function adjustHeight(event: Event) {
194194
<template>
195195
<div class="chat-input" :style="styleVars" @keydown.stop="onKeyDown">
196196
<div class="chat-inputs">
197+
<div v-if="$slots.leftPanel" class="chat-input-left-panel">
198+
<slot name="leftPanel" />
199+
</div>
197200
<textarea
198201
ref="chatTextArea"
199-
data-test-id="chat-input"
200202
v-model="input"
203+
data-test-id="chat-input"
201204
:disabled="isInputDisabled"
202205
:placeholder="t(props.placeholder)"
203206
@keydown.enter="onSubmitKeydown"
@@ -251,16 +254,15 @@ function adjustHeight(event: Event) {
251254
width: 100%;
252255
display: flex;
253256
justify-content: center;
254-
align-items: center;
257+
align-items: flex-end;
255258
256259
textarea {
257260
font-family: inherit;
258261
font-size: var(--chat--input--font-size, inherit);
259262
width: 100%;
260263
border: var(--chat--input--border, 0);
261264
border-radius: var(--chat--input--border-radius, 0);
262-
padding: 0.8rem;
263-
padding-right: calc(0.8rem + (var(--controls-count, 1) * var(--chat--textarea--height)));
265+
padding: var(--chat--input--padding, 0.8rem);
264266
min-height: var(--chat--textarea--height, 2.5rem); // Set a smaller initial height
265267
max-height: var(--chat--textarea--max-height, 30rem);
266268
height: var(--chat--textarea--height, 2.5rem); // Set initial height same as min-height
@@ -271,6 +273,9 @@ function adjustHeight(event: Event) {
271273
outline: none;
272274
line-height: var(--chat--input--line-height, 1.5);
273275
276+
&::placeholder {
277+
font-size: var(--chat--input--placeholder--font-size, var(--chat--input--font-size, inherit));
278+
}
274279
&:focus,
275280
&:hover {
276281
border-color: var(--chat--input--border-active, 0);
@@ -279,9 +284,6 @@ function adjustHeight(event: Event) {
279284
}
280285
.chat-inputs-controls {
281286
display: flex;
282-
position: absolute;
283-
right: 0.5rem;
284-
bottom: 0;
285287
}
286288
.chat-input-send-button,
287289
.chat-input-file-button {
@@ -340,4 +342,9 @@ function adjustHeight(event: Event) {
340342
gap: 0.5rem;
341343
padding: var(--chat--files-spacing, 0.25rem);
342344
}
345+
346+
.chat-input-left-panel {
347+
width: var(--chat--input--left--panel--width, 2rem);
348+
margin-left: 0.4rem;
349+
}
343350
</style>

Diff for: packages/@n8n/chat/src/components/Message.vue

+2-4
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ onMounted(async () => {
136136
font-size: var(--chat--message--font-size, 1rem);
137137
padding: var(--chat--message--padding, var(--chat--spacing));
138138
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
139-
scroll-margin: 100px;
139+
scroll-margin: 3rem;
140+
140141
.chat-message-actions {
141142
position: absolute;
142143
bottom: calc(100% - 0.5rem);
@@ -151,9 +152,6 @@ onMounted(async () => {
151152
left: auto;
152153
right: 0;
153154
}
154-
&.chat-message-from-bot .chat-message-actions {
155-
bottom: calc(100% - 1rem);
156-
}
157155
158156
&:hover {
159157
.chat-message-actions {

Diff for: packages/@n8n/chat/src/css/markdown.scss

+13-3
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@ body {
3737
4. Prevent font size adjustment after orientation changes (IE, iOS)
3838
5. Prevent overflow from long words (all)
3939
*/
40-
font-size: 110%; /* 2 */
41-
line-height: 1.6; /* 3 */
40+
line-height: 1.4; /* 3 */
4241
-webkit-text-size-adjust: 100%; /* 4 */
4342
word-break: break-word; /* 5 */
4443

@@ -407,7 +406,7 @@ body {
407406
h4,
408407
h5,
409408
h6 {
410-
margin: 3.2rem 0 0.8em;
409+
margin: 2rem 0 0.8em;
411410
}
412411

413412
/*
@@ -641,4 +640,15 @@ body {
641640
body > a:first-child:focus {
642641
top: 1rem;
643642
}
643+
644+
// Lists
645+
ul,
646+
ol {
647+
padding-left: 1.5rem;
648+
margin-bottom: 1rem;
649+
650+
li {
651+
margin-bottom: 0.5rem;
652+
}
653+
}
644654
}

Diff for: packages/@n8n/nodes-langchain/utils/descriptions.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export const inputSchemaField: INodeProperties = {
6666
};
6767

6868
export const promptTypeOptions: INodeProperties = {
69-
displayName: 'Prompt Source',
69+
displayName: 'Prompt Source (User Message)',
7070
name: 'promptType',
7171
type: 'options',
7272
options: [

Diff for: packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts

+66-58
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { waitFor } from '@testing-library/vue';
44
import { userEvent } from '@testing-library/user-event';
55
import { createRouter, createWebHistory } from 'vue-router';
66
import { computed, ref } from 'vue';
7+
import type { INodeTypeDescription } from 'n8n-workflow';
78
import { NodeConnectionType } from 'n8n-workflow';
89

910
import CanvasChat from './CanvasChat.vue';
@@ -15,14 +16,14 @@ import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
1516
import { chatEventBus } from '@n8n/chat/event-buses';
1617

1718
import { useWorkflowsStore } from '@/stores/workflows.store';
18-
import { useUIStore } from '@/stores/ui.store';
1919
import { useCanvasStore } from '@/stores/canvas.store';
2020
import * as useChatMessaging from './composables/useChatMessaging';
2121
import * as useChatTrigger from './composables/useChatTrigger';
2222
import { useToast } from '@/composables/useToast';
2323

2424
import type { IExecutionResponse, INodeUi } from '@/Interface';
2525
import type { ChatMessage } from '@n8n/chat/types';
26+
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
2627

2728
vi.mock('@/composables/useToast', () => {
2829
const showMessage = vi.fn();
@@ -61,6 +62,26 @@ const mockNodes: INodeUi[] = [
6162
position: [960, 860],
6263
},
6364
];
65+
const mockNodeTypes: INodeTypeDescription[] = [
66+
{
67+
displayName: 'AI Agent',
68+
name: '@n8n/n8n-nodes-langchain.agent',
69+
properties: [],
70+
defaults: {
71+
name: 'AI Agent',
72+
},
73+
inputs: [NodeConnectionType.Main],
74+
outputs: [NodeConnectionType.Main],
75+
version: 0,
76+
group: [],
77+
description: '',
78+
codex: {
79+
subcategories: {
80+
AI: ['Agents'],
81+
},
82+
},
83+
},
84+
];
6485

6586
const mockConnections = {
6687
'When chat message received': {
@@ -110,8 +131,8 @@ describe('CanvasChat', () => {
110131
});
111132

112133
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
113-
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
114134
let canvasStore: ReturnType<typeof mockedStore<typeof useCanvasStore>>;
135+
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
115136

116137
beforeEach(() => {
117138
const pinia = createTestingPinia({
@@ -131,8 +152,8 @@ describe('CanvasChat', () => {
131152
setActivePinia(pinia);
132153

133154
workflowsStore = mockedStore(useWorkflowsStore);
134-
uiStore = mockedStore(useUIStore);
135155
canvasStore = mockedStore(useCanvasStore);
156+
nodeTypeStore = mockedStore(useNodeTypesStore);
136157

137158
// Setup default mocks
138159
workflowsStore.getCurrentWorkflow.mockReturnValue(
@@ -141,12 +162,21 @@ describe('CanvasChat', () => {
141162
connections: mockConnections,
142163
}),
143164
);
144-
workflowsStore.getNodeByName.mockImplementation(
145-
(name) => mockNodes.find((node) => node.name === name) ?? null,
146-
);
165+
workflowsStore.getNodeByName.mockImplementation((name) => {
166+
const matchedNode = mockNodes.find((node) => node.name === name) ?? null;
167+
168+
return matchedNode;
169+
});
147170
workflowsStore.isChatPanelOpen = true;
171+
workflowsStore.isLogsPanelOpen = true;
148172
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
149173
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
174+
175+
nodeTypeStore.getNodeType = vi.fn().mockImplementation((nodeTypeName) => {
176+
return mockNodeTypes.find((node) => node.name === nodeTypeName) ?? null;
177+
});
178+
179+
workflowsStore.runWorkflow.mockResolvedValue({ executionId: 'test-execution-issd' });
150180
});
151181

152182
afterEach(() => {
@@ -190,6 +220,10 @@ describe('CanvasChat', () => {
190220
// Verify message and response
191221
expect(await findByText('Hello AI!')).toBeInTheDocument();
192222
await waitFor(async () => {
223+
workflowsStore.getWorkflowExecution = {
224+
...(mockWorkflowExecution as unknown as IExecutionResponse),
225+
status: 'success',
226+
};
193227
expect(await findByText('AI response message')).toBeInTheDocument();
194228
});
195229

@@ -231,11 +265,12 @@ describe('CanvasChat', () => {
231265
await userEvent.type(input, 'Test message');
232266
await userEvent.keyboard('{Enter}');
233267

234-
// Verify loading states
235-
uiStore.isActionActive = { workflowRunning: true };
236268
await waitFor(() => expect(queryByTestId('chat-message-typing')).toBeInTheDocument());
237269

238-
uiStore.isActionActive = { workflowRunning: false };
270+
workflowsStore.getWorkflowExecution = {
271+
...(mockWorkflowExecution as unknown as IExecutionResponse),
272+
status: 'success',
273+
};
239274
await waitFor(() => expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument());
240275
});
241276

@@ -269,7 +304,7 @@ describe('CanvasChat', () => {
269304
sendMessage: vi.fn(),
270305
extractResponseMessage: vi.fn(),
271306
previousMessageIndex: ref(0),
272-
waitForExecution: vi.fn(),
307+
isLoading: computed(() => false),
273308
});
274309
});
275310

@@ -339,7 +374,7 @@ describe('CanvasChat', () => {
339374
sendMessage: vi.fn(),
340375
extractResponseMessage: vi.fn(),
341376
previousMessageIndex: ref(0),
342-
waitForExecution: vi.fn(),
377+
isLoading: computed(() => false),
343378
});
344379

345380
workflowsStore.isChatPanelOpen = true;
@@ -437,7 +472,7 @@ describe('CanvasChat', () => {
437472
sendMessage: sendMessageSpy,
438473
extractResponseMessage: vi.fn(),
439474
previousMessageIndex: ref(0),
440-
waitForExecution: vi.fn(),
475+
isLoading: computed(() => false),
441476
});
442477
workflowsStore.messages = mockMessages;
443478
});
@@ -449,26 +484,25 @@ describe('CanvasChat', () => {
449484
await userEvent.click(repostButton);
450485

451486
expect(sendMessageSpy).toHaveBeenCalledWith('Original message');
452-
// expect.objectContaining({
453-
// runData: expect.objectContaining({
454-
// 'When chat message received': expect.arrayContaining([
455-
// expect.objectContaining({
456-
// data: expect.objectContaining({
457-
// main: expect.arrayContaining([
458-
// expect.arrayContaining([
459-
// expect.objectContaining({
460-
// json: expect.objectContaining({
461-
// chatInput: 'Original message',
462-
// }),
463-
// }),
464-
// ]),
465-
// ]),
466-
// }),
467-
// }),
468-
// ]),
469-
// }),
470-
// }),
471-
// );
487+
expect.objectContaining({
488+
runData: expect.objectContaining({
489+
'When chat message received': expect.arrayContaining([
490+
expect.objectContaining({
491+
data: expect.objectContaining({
492+
main: expect.arrayContaining([
493+
expect.arrayContaining([
494+
expect.objectContaining({
495+
json: expect.objectContaining({
496+
chatInput: 'Original message',
497+
}),
498+
}),
499+
]),
500+
]),
501+
}),
502+
}),
503+
]),
504+
}),
505+
});
472506
});
473507

474508
it('should show message options only for appropriate messages', async () => {
@@ -494,32 +528,6 @@ describe('CanvasChat', () => {
494528
});
495529
});
496530

497-
describe('execution handling', () => {
498-
it('should update UI when execution is completed', async () => {
499-
const { findByTestId, queryByTestId } = renderComponent();
500-
501-
// Start execution
502-
const input = await findByTestId('chat-input');
503-
await userEvent.type(input, 'Test message');
504-
await userEvent.keyboard('{Enter}');
505-
506-
// Simulate execution completion
507-
uiStore.isActionActive = { workflowRunning: true };
508-
await waitFor(() => {
509-
expect(queryByTestId('chat-message-typing')).toBeInTheDocument();
510-
});
511-
512-
uiStore.isActionActive = { workflowRunning: false };
513-
workflowsStore.setWorkflowExecutionData(
514-
mockWorkflowExecution as unknown as IExecutionResponse,
515-
);
516-
517-
await waitFor(() => {
518-
expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument();
519-
});
520-
});
521-
});
522-
523531
describe('panel state synchronization', () => {
524532
it('should update canvas height when chat or logs panel state changes', async () => {
525533
renderComponent();

0 commit comments

Comments
 (0)