Skip to content

Commit

Permalink
feat(editor): Canvas chat UI & UX improvements (#11924)
Browse files Browse the repository at this point in the history
  • Loading branch information
OlegIvaniv authored Nov 29, 2024
1 parent 5f6f8a1 commit 1e25774
Show file tree
Hide file tree
Showing 17 changed files with 258 additions and 212 deletions.
25 changes: 16 additions & 9 deletions packages/@n8n/chat/src/components/Input.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ const isSubmitting = ref(false);
const resizeObserver = ref<ResizeObserver | null>(null);
const isSubmitDisabled = computed(() => {
return input.value === '' || waitingForResponse.value || options.disabled?.value === true;
return input.value === '' || unref(waitingForResponse) || options.disabled?.value === true;
});
const isInputDisabled = computed(() => options.disabled?.value === true);
const isFileUploadDisabled = computed(
() => isFileUploadAllowed.value && waitingForResponse.value && !options.disabled?.value,
() => isFileUploadAllowed.value && unref(waitingForResponse) && !options.disabled?.value,
);
const isFileUploadAllowed = computed(() => unref(options.allowFileUploads) === true);
const allowedFileTypes = computed(() => unref(options.allowedFilesMimeTypes));
Expand Down Expand Up @@ -194,10 +194,13 @@ function adjustHeight(event: Event) {
<template>
<div class="chat-input" :style="styleVars" @keydown.stop="onKeyDown">
<div class="chat-inputs">
<div v-if="$slots.leftPanel" class="chat-input-left-panel">
<slot name="leftPanel" />
</div>
<textarea
ref="chatTextArea"
data-test-id="chat-input"
v-model="input"
data-test-id="chat-input"
:disabled="isInputDisabled"
:placeholder="t(props.placeholder)"
@keydown.enter="onSubmitKeydown"
Expand Down Expand Up @@ -251,16 +254,15 @@ function adjustHeight(event: Event) {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
align-items: flex-end;
textarea {
font-family: inherit;
font-size: var(--chat--input--font-size, inherit);
width: 100%;
border: var(--chat--input--border, 0);
border-radius: var(--chat--input--border-radius, 0);
padding: 0.8rem;
padding-right: calc(0.8rem + (var(--controls-count, 1) * var(--chat--textarea--height)));
padding: var(--chat--input--padding, 0.8rem);
min-height: var(--chat--textarea--height, 2.5rem); // Set a smaller initial height
max-height: var(--chat--textarea--max-height, 30rem);
height: var(--chat--textarea--height, 2.5rem); // Set initial height same as min-height
Expand All @@ -271,6 +273,9 @@ function adjustHeight(event: Event) {
outline: none;
line-height: var(--chat--input--line-height, 1.5);
&::placeholder {
font-size: var(--chat--input--placeholder--font-size, var(--chat--input--font-size, inherit));
}
&:focus,
&:hover {
border-color: var(--chat--input--border-active, 0);
Expand All @@ -279,9 +284,6 @@ function adjustHeight(event: Event) {
}
.chat-inputs-controls {
display: flex;
position: absolute;
right: 0.5rem;
bottom: 0;
}
.chat-input-send-button,
.chat-input-file-button {
Expand Down Expand Up @@ -340,4 +342,9 @@ function adjustHeight(event: Event) {
gap: 0.5rem;
padding: var(--chat--files-spacing, 0.25rem);
}
.chat-input-left-panel {
width: var(--chat--input--left--panel--width, 2rem);
margin-left: 0.4rem;
}
</style>
6 changes: 2 additions & 4 deletions packages/@n8n/chat/src/components/Message.vue
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ onMounted(async () => {
font-size: var(--chat--message--font-size, 1rem);
padding: var(--chat--message--padding, var(--chat--spacing));
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
scroll-margin: 100px;
scroll-margin: 3rem;
.chat-message-actions {
position: absolute;
bottom: calc(100% - 0.5rem);
Expand All @@ -151,9 +152,6 @@ onMounted(async () => {
left: auto;
right: 0;
}
&.chat-message-from-bot .chat-message-actions {
bottom: calc(100% - 1rem);
}
&:hover {
.chat-message-actions {
Expand Down
16 changes: 13 additions & 3 deletions packages/@n8n/chat/src/css/markdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ body {
4. Prevent font size adjustment after orientation changes (IE, iOS)
5. Prevent overflow from long words (all)
*/
font-size: 110%; /* 2 */
line-height: 1.6; /* 3 */
line-height: 1.4; /* 3 */
-webkit-text-size-adjust: 100%; /* 4 */
word-break: break-word; /* 5 */

Expand Down Expand Up @@ -407,7 +406,7 @@ body {
h4,
h5,
h6 {
margin: 3.2rem 0 0.8em;
margin: 2rem 0 0.8em;
}

/*
Expand Down Expand Up @@ -641,4 +640,15 @@ body {
body > a:first-child:focus {
top: 1rem;
}

// Lists
ul,
ol {
padding-left: 1.5rem;
margin-bottom: 1rem;

li {
margin-bottom: 0.5rem;
}
}
}
2 changes: 1 addition & 1 deletion packages/@n8n/nodes-langchain/utils/descriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const inputSchemaField: INodeProperties = {
};

export const promptTypeOptions: INodeProperties = {
displayName: 'Prompt Source',
displayName: 'Prompt Source (User Message)',
name: 'promptType',
type: 'options',
options: [
Expand Down
124 changes: 66 additions & 58 deletions packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { waitFor } from '@testing-library/vue';
import { userEvent } from '@testing-library/user-event';
import { createRouter, createWebHistory } from 'vue-router';
import { computed, ref } from 'vue';
import type { INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';

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

import { useWorkflowsStore } from '@/stores/workflows.store';
import { useUIStore } from '@/stores/ui.store';
import { useCanvasStore } from '@/stores/canvas.store';
import * as useChatMessaging from './composables/useChatMessaging';
import * as useChatTrigger from './composables/useChatTrigger';
import { useToast } from '@/composables/useToast';

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

vi.mock('@/composables/useToast', () => {
const showMessage = vi.fn();
Expand Down Expand Up @@ -61,6 +62,26 @@ const mockNodes: INodeUi[] = [
position: [960, 860],
},
];
const mockNodeTypes: INodeTypeDescription[] = [
{
displayName: 'AI Agent',
name: '@n8n/n8n-nodes-langchain.agent',
properties: [],
defaults: {
name: 'AI Agent',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
version: 0,
group: [],
description: '',
codex: {
subcategories: {
AI: ['Agents'],
},
},
},
];

const mockConnections = {
'When chat message received': {
Expand Down Expand Up @@ -110,8 +131,8 @@ describe('CanvasChat', () => {
});

let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
let canvasStore: ReturnType<typeof mockedStore<typeof useCanvasStore>>;
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;

beforeEach(() => {
const pinia = createTestingPinia({
Expand All @@ -131,8 +152,8 @@ describe('CanvasChat', () => {
setActivePinia(pinia);

workflowsStore = mockedStore(useWorkflowsStore);
uiStore = mockedStore(useUIStore);
canvasStore = mockedStore(useCanvasStore);
nodeTypeStore = mockedStore(useNodeTypesStore);

// Setup default mocks
workflowsStore.getCurrentWorkflow.mockReturnValue(
Expand All @@ -141,12 +162,21 @@ describe('CanvasChat', () => {
connections: mockConnections,
}),
);
workflowsStore.getNodeByName.mockImplementation(
(name) => mockNodes.find((node) => node.name === name) ?? null,
);
workflowsStore.getNodeByName.mockImplementation((name) => {
const matchedNode = mockNodes.find((node) => node.name === name) ?? null;

return matchedNode;
});
workflowsStore.isChatPanelOpen = true;
workflowsStore.isLogsPanelOpen = true;
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];

nodeTypeStore.getNodeType = vi.fn().mockImplementation((nodeTypeName) => {
return mockNodeTypes.find((node) => node.name === nodeTypeName) ?? null;
});

workflowsStore.runWorkflow.mockResolvedValue({ executionId: 'test-execution-issd' });
});

afterEach(() => {
Expand Down Expand Up @@ -190,6 +220,10 @@ describe('CanvasChat', () => {
// Verify message and response
expect(await findByText('Hello AI!')).toBeInTheDocument();
await waitFor(async () => {
workflowsStore.getWorkflowExecution = {
...(mockWorkflowExecution as unknown as IExecutionResponse),
status: 'success',
};
expect(await findByText('AI response message')).toBeInTheDocument();
});

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

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

uiStore.isActionActive = { workflowRunning: false };
workflowsStore.getWorkflowExecution = {
...(mockWorkflowExecution as unknown as IExecutionResponse),
status: 'success',
};
await waitFor(() => expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument());
});

Expand Down Expand Up @@ -269,7 +304,7 @@ describe('CanvasChat', () => {
sendMessage: vi.fn(),
extractResponseMessage: vi.fn(),
previousMessageIndex: ref(0),
waitForExecution: vi.fn(),
isLoading: computed(() => false),
});
});

Expand Down Expand Up @@ -339,7 +374,7 @@ describe('CanvasChat', () => {
sendMessage: vi.fn(),
extractResponseMessage: vi.fn(),
previousMessageIndex: ref(0),
waitForExecution: vi.fn(),
isLoading: computed(() => false),
});

workflowsStore.isChatPanelOpen = true;
Expand Down Expand Up @@ -437,7 +472,7 @@ describe('CanvasChat', () => {
sendMessage: sendMessageSpy,
extractResponseMessage: vi.fn(),
previousMessageIndex: ref(0),
waitForExecution: vi.fn(),
isLoading: computed(() => false),
});
workflowsStore.messages = mockMessages;
});
Expand All @@ -449,26 +484,25 @@ describe('CanvasChat', () => {
await userEvent.click(repostButton);

expect(sendMessageSpy).toHaveBeenCalledWith('Original message');
// expect.objectContaining({
// runData: expect.objectContaining({
// 'When chat message received': expect.arrayContaining([
// expect.objectContaining({
// data: expect.objectContaining({
// main: expect.arrayContaining([
// expect.arrayContaining([
// expect.objectContaining({
// json: expect.objectContaining({
// chatInput: 'Original message',
// }),
// }),
// ]),
// ]),
// }),
// }),
// ]),
// }),
// }),
// );
expect.objectContaining({
runData: expect.objectContaining({
'When chat message received': expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({
main: expect.arrayContaining([
expect.arrayContaining([
expect.objectContaining({
json: expect.objectContaining({
chatInput: 'Original message',
}),
}),
]),
]),
}),
}),
]),
}),
});
});

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

describe('execution handling', () => {
it('should update UI when execution is completed', async () => {
const { findByTestId, queryByTestId } = renderComponent();

// Start execution
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Test message');
await userEvent.keyboard('{Enter}');

// Simulate execution completion
uiStore.isActionActive = { workflowRunning: true };
await waitFor(() => {
expect(queryByTestId('chat-message-typing')).toBeInTheDocument();
});

uiStore.isActionActive = { workflowRunning: false };
workflowsStore.setWorkflowExecutionData(
mockWorkflowExecution as unknown as IExecutionResponse,
);

await waitFor(() => {
expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument();
});
});
});

describe('panel state synchronization', () => {
it('should update canvas height when chat or logs panel state changes', async () => {
renderComponent();
Expand Down
Loading

0 comments on commit 1e25774

Please sign in to comment.