Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(editor): Canvas chat UI & UX improvements #11924

Merged
merged 15 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
3 changes: 2 additions & 1 deletion 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 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;
OlegIvaniv marked this conversation as resolved.
Show resolved Hide resolved
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
Loading