Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,14 @@
bottom: 0;
line-height: 0;
}

.dx-ai-chat--disabled {
.dx-chat-messagebox, .dx-ai-chat__message-regenerate-button {
opacity: 0.5;
pointer-events: none;
}

.dx-ai-chat__message-regenerate-button {
cursor: default;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ describe('AIAssistantController', () => {
const timestamp = '2026-04-16T10:00:00.000Z';
const expectedTimestamp = Date.parse(timestamp);

// eslint-disable-next-line @typescript-eslint/no-floating-promises
controller.sendRequestToAI({
author: { id: 'user', name: 'User' },
text: 'Generate values',
Expand All @@ -104,6 +105,7 @@ describe('AIAssistantController', () => {
it('should keep message as pending when AI integration is not configured', async () => {
const controller = createController();

// eslint-disable-next-line @typescript-eslint/no-floating-promises
controller.sendRequestToAI({
author: { id: 'user', name: 'User' },
text: 'Generate values',
Expand All @@ -124,6 +126,7 @@ describe('AIAssistantController', () => {
'aiAssistant.aiIntegration': mockAIIntegration,
});

// eslint-disable-next-line @typescript-eslint/no-floating-promises
controller.sendRequestToAI({
author: { id: 'user', name: 'User' },
text: 'Generate values',
Expand Down Expand Up @@ -153,7 +156,7 @@ describe('AIAssistantController', () => {
'aiAssistant.aiIntegration': mockAIIntegration,
});

controller.sendRequestToAI({
const promise = controller.sendRequestToAI({
author: { id: 'user', name: 'User' },
text: 'Generate values',
timestamp: '2026-04-16T10:00:00.000Z',
Expand All @@ -169,14 +172,16 @@ describe('AIAssistantController', () => {
text: 'Network error',
}),
]);

await expect(promise).rejects.toThrow('Network error');
});

it('should fail message when response has no actions', async () => {
const controller = createController({
'aiAssistant.aiIntegration': mockAIIntegration,
});

controller.sendRequestToAI({
const promise = controller.sendRequestToAI({
author: { id: 'user', name: 'User' },
text: 'Generate values',
timestamp: '2026-04-16T10:00:00.000Z',
Expand All @@ -196,6 +201,57 @@ describe('AIAssistantController', () => {
text: 'Default error message',
}),
]);

await expect(promise).rejects.toThrow('Default error message');
});

it('should resolve promise when command succeeds', async () => {
const controller = createController({
'aiAssistant.aiIntegration': mockAIIntegration,
});

const promise = controller.sendRequestToAI({
author: { id: 'user', name: 'User' },
text: 'Generate values',
timestamp: '2026-04-16T10:00:00.000Z',
} as Message);

const actions = [{ name: 'sort', args: { column: 'Name' } }];
sendRequestCallbacks.onComplete?.({ actions });

await expect(promise).resolves.toBeUndefined();
});

it('should reject promise when onError is called', async () => {
const controller = createController({
'aiAssistant.aiIntegration': mockAIIntegration,
});

const promise = controller.sendRequestToAI({
author: { id: 'user', name: 'User' },
text: 'Generate values',
timestamp: '2026-04-16T10:00:00.000Z',
} as Message);

sendRequestCallbacks.onError?.(new Error('Network error'));

await expect(promise).rejects.toThrow('Network error');
});

it('should reject promise when response has no actions', async () => {
const controller = createController({
'aiAssistant.aiIntegration': mockAIIntegration,
});

const promise = controller.sendRequestToAI({
author: { id: 'user', name: 'User' },
text: 'Generate values',
timestamp: '2026-04-16T10:00:00.000Z',
} as Message);

sendRequestCallbacks.onComplete?.({} as ExecuteGridAssistantCommandResult);

await expect(promise).rejects.toThrow('Default error message');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ describe('AIAssistantView', () => {
describe('chat event handlers', () => {
describe('onMessageEntered', () => {
it('should send request to AI with the entered message', () => {
mockAIAssistantController.sendRequestToAI.mockReturnValue(Promise.resolve());
createAIAssistantView();

const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions;
Expand All @@ -296,6 +297,82 @@ describe('AIAssistantView', () => {
expect(mockAIAssistantController.sendRequestToAI).toHaveBeenCalledTimes(1);
expect(mockAIAssistantController.sendRequestToAI).toHaveBeenCalledWith(message);
});

it('should not send request when chat is disabled', () => {
createAIAssistantView();

const aiChatInstance = (AIChat as jest.Mock)
.mock.results[0].value as { isDisabled: jest.Mock; setDisabled: jest.Mock };
aiChatInstance.isDisabled.mockReturnValue(true);

const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions;
const message = {
author: { id: 'user', name: 'User' },
text: 'Generate summary',
};

aiChatConfig.chatOptions?.onMessageEntered?.({ message } as any);

expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled();
});

it('should call setDisabled(true) before sending request', () => {
mockAIAssistantController.sendRequestToAI.mockReturnValue(Promise.resolve());
createAIAssistantView();

const aiChatInstance = (AIChat as jest.Mock)
.mock.results[0].value as { setDisabled: jest.Mock };

const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions;
const message = {
author: { id: 'user', name: 'User' },
text: 'Generate summary',
};

aiChatConfig.chatOptions?.onMessageEntered?.({ message } as any);

expect(aiChatInstance.setDisabled).toHaveBeenCalledWith(true);
});

it('should call setDisabled(false) after request completes successfully', async () => {
mockAIAssistantController.sendRequestToAI.mockReturnValue(Promise.resolve());
createAIAssistantView();

const aiChatInstance = (AIChat as jest.Mock)
.mock.results[0].value as { setDisabled: jest.Mock };

const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions;
const message = {
author: { id: 'user', name: 'User' },
text: 'Generate summary',
};

aiChatConfig.chatOptions?.onMessageEntered?.({ message } as any);
await Promise.resolve();

expect(aiChatInstance.setDisabled).toHaveBeenLastCalledWith(false);
});

it('should call setDisabled(false) after request fails', async () => {
mockAIAssistantController.sendRequestToAI.mockReturnValue(
Promise.reject(new Error('Network error')),
);
createAIAssistantView();

const aiChatInstance = (AIChat as jest.Mock)
.mock.results[0].value as { setDisabled: jest.Mock };

const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions;
const message = {
author: { id: 'user', name: 'User' },
text: 'Generate summary',
};

aiChatConfig.chatOptions?.onMessageEntered?.({ message } as any);
await Promise.resolve();

expect(aiChatInstance.setDisabled).toHaveBeenLastCalledWith(false);
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,26 +102,31 @@ export class AIAssistantController extends Controller {
};
}

public sendRequestToAI(message: Message): void {
public sendRequestToAI(message: Message): Promise<void> {
const aiMessageId = this.createPendingAIMessage(message);

this.aiAssistantIntegrationController?.sendRequest(message.text, {
onComplete: (response: ExecuteGridAssistantCommandResult): void => {
fromPromise(this.processResponse(response))
.done((commands: CommandResults) => {
this.completeAIMessage(aiMessageId, commands);
})
.fail((errorMessage) => {
const error = errorMessage instanceof Error
? errorMessage
: new Error(String(errorMessage));

this.failAIMessage(aiMessageId, error);
});
},
onError: (error: Error): void => {
this.failAIMessage(aiMessageId, error);
},
return new Promise((resolve, reject) => {
this.aiAssistantIntegrationController?.sendRequest(message.text, {
onComplete: (response: ExecuteGridAssistantCommandResult): void => {
fromPromise(this.processResponse(response))
.done((commands: CommandResults) => {
this.completeAIMessage(aiMessageId, commands);
resolve();
})
.fail((errorMessage) => {
const error = errorMessage instanceof Error
? errorMessage
: new Error(String(errorMessage));

this.failAIMessage(aiMessageId, error);
reject(error);
});
},
onError: (error: Error): void => {
this.failAIMessage(aiMessageId, error);
reject(error);
},
});
Comment thread
Alyar666 marked this conversation as resolved.
});
Comment thread
Alyar666 marked this conversation as resolved.
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Callback } from '@js/core/utils/callbacks';
import { getHeight } from '@js/core/utils/size';
import type { Properties as ChatProperties } from '@js/ui/chat';
import type { Properties as PopupProperties } from '@js/ui/popup';
import { fromPromise } from '@ts/core/utils/m_deferred';
import { AI_ASSISTANT_POPUP_OFFSET } from '@ts/grids/grid_core/ai_assistant/const';
import {
isChatOptions,
Expand Down Expand Up @@ -90,7 +91,14 @@ export class AIAssistantView extends View {
dataSource: this.aiAssistantController.getMessageDataSource(),
reloadOnChange: true,
onMessageEntered: (e): void => {
this.aiAssistantController.sendRequestToAI(e.message);
if (this.aiChatInstance?.isDisabled()) {
return;
}

this.aiChatInstance?.setDisabled(true);
fromPromise(this.aiAssistantController.sendRequestToAI(e.message)).always(() => {
this.aiChatInstance?.setDisabled(false);
});
},
...this.option('aiAssistant.chat'),
};
Expand Down
Loading
Loading