Skip to content

Commit 9a2a1e9

Browse files
authored
dxDataGrid - AI Assistant: Implement partial re-rendering of messages when their status is updated (DevExpress#33531)
Co-authored-by: Alyar <>
1 parent cff3e45 commit 9a2a1e9

6 files changed

Lines changed: 428 additions & 160 deletions

File tree

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,13 @@ describe('AIAssistantController', () => {
7777
});
7878

7979
describe('getMessageDataSource', () => {
80-
it('should return dataSource with store and reshapeOnPush', () => {
80+
it('should return dataSource with store', () => {
8181
const controller = createController();
8282
const dataSource = controller.getMessageDataSource() as {
8383
store: ArrayStore<Message, string>;
84-
reshapeOnPush: boolean;
8584
};
8685

8786
expect(dataSource.store).toBeDefined();
88-
expect(dataSource.reshapeOnPush).toBe(true);
8987
});
9088
});
9189

@@ -111,7 +109,8 @@ describe('AIAssistantController', () => {
111109
id: expect.stringContaining(AI_ASSISTANT_AUTHOR_ID),
112110
timestamp: expectedTimestamp,
113111
author: AI_ASSISTANT_AUTHOR,
114-
text: 'Generate values',
112+
headerText: 'Request in progress',
113+
text: MessageStatus.Pending,
115114
status: MessageStatus.Pending,
116115
}),
117116
]);
@@ -176,6 +175,7 @@ describe('AIAssistantController', () => {
176175
text: 'Generate values',
177176
timestamp: '2026-04-16T10:00:00.000Z',
178177
} as Message);
178+
promise.catch(() => {});
179179

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

@@ -184,7 +184,9 @@ describe('AIAssistantController', () => {
184184
expect(messages).toEqual([
185185
expect.objectContaining({
186186
status: MessageStatus.Failure,
187-
text: 'Network error',
187+
headerText: 'Failed to process request',
188+
text: MessageStatus.Failure,
189+
errorText: 'Network error',
188190
}),
189191
]);
190192

@@ -201,6 +203,7 @@ describe('AIAssistantController', () => {
201203
text: 'Generate values',
202204
timestamp: '2026-04-16T10:00:00.000Z',
203205
} as Message);
206+
promise.catch(() => {});
204207

205208
const response = {} as ExecuteGridAssistantCommandResult;
206209

@@ -213,7 +216,9 @@ describe('AIAssistantController', () => {
213216
expect(messages).toEqual([
214217
expect.objectContaining({
215218
status: MessageStatus.Failure,
216-
text: 'Default error message',
219+
headerText: 'Failed to process request',
220+
text: MessageStatus.Failure,
221+
errorText: 'Default error message',
217222
}),
218223
]);
219224

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.integration.test.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,28 @@ import {
66
it,
77
jest,
88
} from '@jest/globals';
9+
import type {
10+
ExecuteGridAssistantCommandParams,
11+
ExecuteGridAssistantCommandResult,
12+
RequestCallbacks,
13+
Response as SendRequestResult,
14+
} from '@js/common/ai-integration';
15+
import type { dxElementWrapper } from '@js/core/renderer';
16+
import $ from '@js/core/renderer';
17+
import type { Message } from '@js/ui/chat';
18+
import { AIIntegration } from '@ts/core/ai_integration/core/ai_integration';
919

1020
import {
1121
afterTest,
1222
beforeTest,
1323
createDataGrid,
1424
type DataGridInstance,
25+
flushAsync,
1526
} from '../../__tests__/__mock__/helpers/utils';
27+
import { CLASSES } from '../../ai_chat/const';
28+
import { MessageStatus } from '../const';
29+
import { GridCommands } from '../grid_commands';
30+
import type { CommandResult } from '../types';
1631

1732
const AI_ASSISTANT_BUTTON_SELECTOR = '.dx-datagrid-ai-assistant-button';
1833
const HIDDEN_CLASS = 'dx-hidden';
@@ -33,6 +48,44 @@ const isAiAssistantButtonVisible = (instance: DataGridInstance): boolean => {
3348
return !button.closest(`.${HIDDEN_CLASS}`);
3449
};
3550

51+
const createMockAIIntegration = (): {
52+
aiIntegration: AIIntegration;
53+
getLastCallbacks: () => RequestCallbacks<ExecuteGridAssistantCommandResult>;
54+
} => {
55+
let lastCallbacks: RequestCallbacks<ExecuteGridAssistantCommandResult> = {};
56+
57+
const aiIntegration = new AIIntegration({
58+
sendRequest(): SendRequestResult {
59+
return {
60+
promise: Promise.resolve('{}'),
61+
abort: jest.fn(),
62+
};
63+
},
64+
});
65+
66+
aiIntegration.executeGridAssistant = jest.fn((
67+
params: ExecuteGridAssistantCommandParams,
68+
callbacks: RequestCallbacks<ExecuteGridAssistantCommandResult>,
69+
): (() => void) => {
70+
lastCallbacks = callbacks;
71+
return jest.fn();
72+
});
73+
74+
return {
75+
aiIntegration,
76+
getLastCallbacks: () => lastCallbacks,
77+
};
78+
};
79+
80+
const findMessageElements = (): dxElementWrapper => $(`.${CLASSES.aiChat}`).find(`.${CLASSES.message}`);
81+
82+
const getMessageStatusClass = ($message: dxElementWrapper): string => {
83+
if ($message.hasClass(CLASSES.messagePending)) return MessageStatus.Pending;
84+
if ($message.hasClass(CLASSES.messageSuccess)) return MessageStatus.Success;
85+
if ($message.hasClass(CLASSES.messageError)) return MessageStatus.Failure;
86+
return '';
87+
};
88+
3689
describe('AIAssistantViewController', () => {
3790
beforeEach(beforeTest);
3891
afterEach(afterTest);
@@ -128,4 +181,181 @@ describe('AIAssistantViewController', () => {
128181
expect(button?.getAttribute('title')).toBe('My Custom Title');
129182
});
130183
});
184+
185+
describe('message rendering', () => {
186+
// eslint-disable-next-line @typescript-eslint/init-declarations
187+
let validateSpy;
188+
// eslint-disable-next-line @typescript-eslint/init-declarations
189+
let executeCommandsSpy;
190+
191+
beforeEach(() => {
192+
validateSpy = jest.spyOn(GridCommands.prototype, 'validate')
193+
.mockReturnValue(true);
194+
executeCommandsSpy = jest.spyOn(GridCommands.prototype, 'executeCommands')
195+
.mockResolvedValue([
196+
{ status: 'success', message: 'Sorted by Name ascending' },
197+
] as CommandResult[]);
198+
});
199+
200+
afterEach(() => {
201+
validateSpy.mockRestore();
202+
executeCommandsSpy.mockRestore();
203+
});
204+
205+
const createDataGridWithAIAssistant = async (): Promise<{
206+
instance: DataGridInstance;
207+
getLastCallbacks: () => RequestCallbacks<ExecuteGridAssistantCommandResult>;
208+
}> => {
209+
const { aiIntegration, getLastCallbacks } = createMockAIIntegration();
210+
211+
const { instance } = await createDataGrid({
212+
dataSource: [
213+
{ id: 1, name: 'Name 1' },
214+
{ id: 2, name: 'Name 2' },
215+
],
216+
columns: [
217+
{ dataField: 'id', caption: 'ID', dataType: 'number' },
218+
{ dataField: 'name', caption: 'Name', dataType: 'string' },
219+
],
220+
aiAssistant: { enabled: true, aiIntegration, title: 'AI Assistant' },
221+
});
222+
223+
// Open the AI assistant popup so Chat renders into the DOM
224+
const viewController = instance.getController('aiAssistantViewController');
225+
226+
await viewController.toggle();
227+
jest.runAllTimers();
228+
229+
return { instance, getLastCallbacks };
230+
};
231+
232+
const sendAIRequest = (
233+
instance: DataGridInstance,
234+
text: string,
235+
): void => {
236+
const controller = instance.getController('aiAssistant');
237+
238+
controller.sendRequestToAI({
239+
author: { id: 'user', name: 'User' },
240+
text,
241+
timestamp: new Date().toISOString(),
242+
} as Message).catch(() => {});
243+
jest.runAllTimers();
244+
};
245+
246+
it('should render pending message after sendRequestToAI', async () => {
247+
const { instance } = await createDataGridWithAIAssistant();
248+
249+
sendAIRequest(instance, 'Sort by Name');
250+
251+
const $messages = findMessageElements();
252+
253+
expect($messages.length).toBe(1);
254+
expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Pending);
255+
});
256+
257+
it('should render success message with command list after AI completes', async () => {
258+
const { instance, getLastCallbacks } = await createDataGridWithAIAssistant();
259+
260+
sendAIRequest(instance, 'Sort by Name');
261+
262+
getLastCallbacks().onComplete?.({
263+
actions: [{ name: 'sort', args: { column: 'Name' } }],
264+
});
265+
await flushAsync();
266+
await flushAsync();
267+
268+
const $messages = findMessageElements();
269+
270+
expect($messages.length).toBe(1);
271+
expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Success);
272+
273+
const $commandItems = $messages.eq(0).find(`.${CLASSES.actionListItem}`);
274+
275+
expect($commandItems.length).toBe(1);
276+
expect($commandItems.find(`.${CLASSES.actionListItemText}`).text())
277+
.toBe('Sorted by Name ascending');
278+
});
279+
280+
it('should render failure message with error text after AI errors', async () => {
281+
const { instance, getLastCallbacks } = await createDataGridWithAIAssistant();
282+
283+
sendAIRequest(instance, 'Sort by Name');
284+
285+
getLastCallbacks().onError?.(new Error('Network error'));
286+
await flushAsync();
287+
288+
const $messages = findMessageElements();
289+
290+
expect($messages.length).toBe(1);
291+
expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure);
292+
expect($messages.eq(0).find(`.${CLASSES.messageErrorText}`).text())
293+
.toBe('Network error');
294+
});
295+
296+
it('should render multiple messages with correct statuses after sequential requests', async () => {
297+
const { instance, getLastCallbacks } = await createDataGridWithAIAssistant();
298+
299+
// First request
300+
sendAIRequest(instance, 'Sort by Name');
301+
302+
const firstCallbacks = getLastCallbacks();
303+
304+
firstCallbacks.onComplete?.({
305+
actions: [{ name: 'sort', args: { column: 'Name' } }],
306+
});
307+
await flushAsync();
308+
await flushAsync();
309+
310+
// Second request
311+
sendAIRequest(instance, 'Filter by Status');
312+
313+
const $messages = findMessageElements();
314+
315+
expect($messages.length).toBe(2);
316+
expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Success);
317+
expect($messages.eq(0).find(`.${CLASSES.actionListItem}`).length).toBe(1);
318+
expect(getMessageStatusClass($messages.eq(1))).toBe(MessageStatus.Pending);
319+
});
320+
321+
it('should only re-render the updated message', async () => {
322+
const { instance, getLastCallbacks } = await createDataGridWithAIAssistant();
323+
324+
// First request — complete it
325+
sendAIRequest(instance, 'Sort by Name');
326+
327+
const firstCallbacks = getLastCallbacks();
328+
329+
firstCallbacks.onComplete?.({
330+
actions: [{ name: 'sort', args: { column: 'Name' } }],
331+
});
332+
await flushAsync();
333+
await flushAsync();
334+
335+
// Second request — pending
336+
sendAIRequest(instance, 'Filter by Status');
337+
338+
const $messagesBefore = findMessageElements();
339+
340+
expect($messagesBefore.length).toBe(2);
341+
342+
const firstMessageNode = $messagesBefore.get(0);
343+
344+
// Complete second request — only message 2 should re-render
345+
const secondCallbacks = getLastCallbacks();
346+
347+
secondCallbacks.onComplete?.({
348+
actions: [{ name: 'filter', args: { column: 'Status' } }],
349+
});
350+
await flushAsync();
351+
await flushAsync();
352+
353+
const $messagesAfter = findMessageElements();
354+
355+
expect($messagesAfter.length).toBe(2);
356+
expect($messagesAfter.get(0)).toBe(firstMessageNode);
357+
expect(getMessageStatusClass($messagesAfter.eq(1)))
358+
.toBe(MessageStatus.Success);
359+
});
360+
});
131361
});

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ExecuteGridAssistantCommandResult } from '@js/common/ai-integration';
2+
import messageLocalization from '@js/common/core/localization/message';
23
import { ArrayStore } from '@js/common/data';
34
import Guid from '@js/core/guid';
45
import { isString } from '@js/core/utils/type';
@@ -22,6 +23,11 @@ export class AIAssistantController extends Controller {
2223

2324
private aiAssistantIntegrationController?: AIAssistantIntegrationController;
2425

26+
// TODO: need to implement method for getting customized response title
27+
private getCustomizedResponseTitle(): string {
28+
return '';
29+
}
30+
2531
private updateAIMessage(messageId: string, data: Partial<Message>): void {
2632
this.messageStore?.push([
2733
{
@@ -62,8 +68,13 @@ export class AIAssistantController extends Controller {
6268
id: aiMessageId,
6369
timestamp: parsedTimestamp,
6470
author: AI_ASSISTANT_AUTHOR,
65-
text: message.text,
71+
headerText: messageLocalization.format('dxDataGrid-aiAssistantProcessingMessageHeader'),
6672
status: MessageStatus.Pending,
73+
// The text field is currently used as a workaround to trigger a status update.
74+
// We have to update this built-in property to force the message to re-render.
75+
// If dxChat supports updating custom fields via the Store Push API in the future,
76+
// we will be able to remove this text update workaround.
77+
text: MessageStatus.Pending,
6778
},
6879
},
6980
]);
@@ -77,15 +88,27 @@ export class AIAssistantController extends Controller {
7788
: MessageStatus.Success;
7889

7990
this.updateAIMessage(messageId, {
80-
status: messageStatus,
91+
headerText: this.getCustomizedResponseTitle(),
8192
commands,
93+
status: messageStatus,
94+
// The text field is currently used as a workaround to trigger a status update.
95+
// We have to update this built-in property to force the message to re-render.
96+
// If dxChat supports updating custom fields via the Store Push API in the future,
97+
// we will be able to remove this text update workaround.
98+
text: messageStatus,
8299
});
83100
}
84101

85102
private failAIMessage(messageId: string, error: Error): void {
86103
this.updateAIMessage(messageId, {
104+
headerText: messageLocalization.format('dxDataGrid-aiAssistantErrorMessageHeader'),
105+
errorText: error.message,
87106
status: MessageStatus.Failure,
88-
text: error.message,
107+
// The text field is currently used as a workaround to trigger a status update.
108+
// We have to update this built-in property to force the message to re-render.
109+
// If dxChat supports updating custom fields via the Store Push API in the future,
110+
// we will be able to remove this text update workaround.
111+
text: MessageStatus.Failure,
89112
});
90113
}
91114

@@ -102,7 +125,6 @@ export class AIAssistantController extends Controller {
102125
public getMessageDataSource(): DataSourceLike<Message> {
103126
return {
104127
store: this.messageStore,
105-
reshapeOnPush: true,
106128
};
107129
}
108130

0 commit comments

Comments
 (0)